marketing/src/App.tsx

336 lines
14 KiB
TypeScript
Raw Normal View History

2026-08-24 10:07:16 +00:00
import { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { loadInitialData, setOption } from './api/promoApi';
import { Cart } from './components/Cart/Cart';
import { CookiePopup } from './components/CookiePopup/CookiePopup';
import { FamilySection } from './components/Family/FamilySection';
import { CategoriesSection } from './components/Categories/CategoriesSection';
import { DaysSection } from './components/Days/DaysSection';
import { Accordion } from './components/Accordion/Accordion';
import { normalizeProductImage } from './utils/product';
import type { InitialData } from './types/api';
import type { Product } from './types/cart';
import type { User } from './types/user';
2026-08-30 11:47:34 +00:00
import CustomSlider from "./components/Fullness/FullnessSlider";
import {useGlobalZustandState} from "./hooks/useGlobalZustandState";
2026-08-24 10:07:16 +00:00
export function App() {
const [initialData, setInitialData] = useState<InitialData | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [isCartComplete, setIsCartComplete] = useState(false);
const [isCartLoading, setIsCartLoading] = useState(true);
const [openSection, setOpenSection] = useState<string>('family');
2026-08-30 11:47:34 +00:00
const [needRefresh, setNeedRefresh] = useState<boolean>(true);
const [isFullnessHighlighted, setIsFullnessHighlighted] = useState<boolean>(false);
2026-08-24 10:07:16 +00:00
2026-08-30 11:47:34 +00:00
const {setInitial} = useGlobalZustandState((state) => state);
const loadInitialState = async () => {
2026-08-24 10:07:16 +00:00
loadInitialData().then(data => {
setInitialData(data);
setProducts(data.cart.flat().map(product => normalizeProductImage(product)));
2026-08-30 11:47:34 +00:00
setInitial(false, data.excess || 0, data.precision || 0);
setIsCartComplete(false)
2026-08-24 10:07:16 +00:00
});
2026-08-30 11:47:34 +00:00
};
useEffect(() => {
if (needRefresh) {
setNeedRefresh(false);
loadInitialState();
}
}, [needRefresh, setNeedRefresh, loadInitialState]);
2026-08-24 10:07:16 +00:00
const users = initialData?.users ?? [];
const categories = initialData?.available_groups ?? [];
const selectedCategories = initialData?.initial_groups ?? [];
const days = initialData?.days ?? 7;
const handleDaysChange = useCallback(async (nextDays: number) => {
await setOption('days', nextDays);
2026-08-30 11:47:34 +00:00
loadInitialState();
}, [loadInitialState]);
2026-08-24 10:07:16 +00:00
const handleUsersChange = useCallback(async (nextUsers: User[]) => {
await setOption('users', nextUsers);
2026-08-30 11:47:34 +00:00
loadInitialState();
}, [loadInitialState]);
2026-08-24 10:07:16 +00:00
const handleCategoriesChange = useCallback((nextCategories: string[]) => {
setInitialData(current => current ? { ...current, initial_groups: nextCategories } : current);
2026-08-30 11:47:34 +00:00
loadInitialState();
}, [loadInitialState]);
const handlePercentChange = useCallback((percent: number) => {
setInitialData(current => current ? { ...current, percent } : current);
loadInitialState();
}, [loadInitialState]);
2026-08-24 10:07:16 +00:00
const pageContent = useMemo(() => {
if (!initialData) {
return <LoadingPage>Загрузка...</LoadingPage>;
}
return (
<Container>
<LeftColumn>
<TitleBlock>
2026-08-30 11:47:34 +00:00
<h1>Семейные покупки</h1>
<p>Заботимся о балансе нутриентов</p>
2026-08-24 10:07:16 +00:00
</TitleBlock>
<Cart
products={products}
days={initialData.days}
isComplete={isCartComplete}
isLoading={isCartLoading}
2026-08-30 11:47:34 +00:00
onProductsChange={(valueOrArrUpdater) => {
const newProducts = typeof valueOrArrUpdater === 'function'
? valueOrArrUpdater(products)
: valueOrArrUpdater;
if (newProducts.length < products.length) {
setIsCartComplete(false);
setTimeout(() => {
setIsCartComplete(false);
}, 3000);
}
setProducts(newProducts);
}}
2026-08-24 10:07:16 +00:00
onComplete={() => {
setIsCartComplete(true);
setIsCartLoading(false);
}}
onLoadingChange={setIsCartLoading}
/>
</LeftColumn>
<RightColumn>
<FilterTitle>
<h2>Настройте корзину под себя</h2>
<p>Чем больше вы расскажете, тем точнее будет подбор</p>
</FilterTitle>
<SettingsPanel>
<Accordion
id="days"
title="На сколько дней собираем корзину?"
icon={<img src="/images/calendar.svg" alt="календарь" />}
isOpen={openSection === 'days'}
onToggle={() => setOpenSection(openSection === 'days' ? '' : 'days')}
>
<DaysSection value={days} onChange={handleDaysChange} />
</Accordion>
<Accordion
id="family"
title="Кто будет есть?"
description={
users.length > 0
? `${users.length} человек`
: 'Не указаны'
}
icon={<img src="/images/family.svg" alt="семья" />}
isOpen={openSection === 'family'}
onToggle={() => setOpenSection(openSection === 'family' ? '' : 'family')}
>
<FamilySection
users={users}
availableTags={categories}
onChange={handleUsersChange}
/>
</Accordion>
<Accordion
id="preferences"
2026-08-30 11:47:34 +00:00
title="Обязательные продукты"
2026-08-24 10:07:16 +00:00
description={
selectedCategories.length > 0
? `Выбраны ${selectedCategories.length}`
: 'Не указаны'
}
isHighlighted={selectedCategories.length > 0}
isOpen={openSection === 'preferences'}
onToggle={() => setOpenSection(openSection === 'preferences' ? '' : 'preferences')}
>
<CategoriesSection
categories={categories}
selectedCategories={selectedCategories}
onLocalChange={handleCategoriesChange}
onSaved={() => undefined}
/>
</Accordion>
2026-08-30 11:47:34 +00:00
<Accordion
id="fullness"
title="Заполненность корзины"
icon={<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 13.23C17 13.23 16.09 12.77 15.182 12.77C13.818 12.77 12 14.615 12 17.385C12 20.154 14.49 22 17 22C19.51 22 22 20.154 22 17.385C22 14.616 20.182 12.769 18.818 12.769C17.909 12.769 17 13.231 17 13.231C17 11.847 17.91 10.001 19.727 10.001M10.655 5C11.551 5 12.278 4.328 12.278 3.5C12.278 2.672 11.55 2 10.655 2H5.245C4.349 2 3.623 2.672 3.623 3.5C3.623 4.328 4.349 5 5.246 5M11.169 4.923C12.125 6.689 12.909 8.283 13.389 10C13.4283 10.14 13.4653 10.281 13.5 10.423M10.428 22H6.328C2.747 22 2 21.31 2 18V13.777C2 10.377 3.098 7.886 4.705 4.915" stroke="#859D96" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>}
description={'Чем меньше значение, тем меньше продуктов и калорий в корзине'}
isHighlighted={isFullnessHighlighted}
isOpen={openSection === 'fullness'}
onToggle={() => {
if (openSection !== 'fullness') {
setIsFullnessHighlighted(true);
}
setOpenSection(openSection === 'fullness' ? '' : 'fullness')
}}
>
<CustomSlider onChange={(percent) => {console.log(percent)}} />
<Alert>
<div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3.5" y="3.5" width="17" height="17" rx="8.5" stroke="#5EA12D"/>
<circle cx="12.1111" cy="11.8924" r="7.11111" fill="#5EA12D"/>
<path d="M9.61488 13.1843C8.90453 11.7621 9.3189 8.83864 9.61488 7.55469L9.72587 7.73247L9.9996 8.08802L10.0551 8.14728L10.2808 8.38432L10.5583 8.68061L10.8358 8.97691L11.2798 9.33247L11.5572 9.51024L12.0567 9.80654L12.2787 9.92506L12.3859 9.98432C12.4969 10.0436 13.1675 10.3991 13.519 10.5177C13.6077 10.5651 14.0175 10.7152 14.221 10.8139C14.6206 10.9562 15.2755 11.5843 15.5529 11.8806C15.9969 12.3073 16.3669 13.2041 16.4964 13.5991C16.7627 14.3102 16.5704 15.5942 16.4409 16.1473L16.1079 17.0954L15.7194 17.8658L15.22 18.5769L14.8315 18.9917L14.443 19.3473L13.9991 19.7028H13.8881L14.1655 19.3473C14.2099 19.2999 14.369 19.0905 14.443 18.9917C14.5762 18.8021 14.8315 18.3991 14.9425 18.2214C15.1645 17.9369 15.4419 16.9177 15.5529 16.4436C15.7157 15.9102 15.5122 15.1004 15.3827 14.7843C15.0719 14.0258 14.5355 13.3818 14.2765 13.1251C14.2765 13.0777 13.7216 12.5917 13.4441 12.3547L12.7226 11.8806L11.8902 11.4065L11.2243 11.051H11.1133V11.1102L11.8902 11.7028L13.1111 12.888C13.5107 13.1725 14.2395 14.3893 14.554 14.9621C14.8204 15.3414 14.776 16.3448 14.7205 16.7991C13.7438 16.8939 12.2047 16.0485 11.5572 15.6139C11.2058 15.3967 10.3252 14.6065 9.61488 13.1843Z" fill="#F1FAF0"/>
</svg>
</div>
<div>
Процент влияет на количество калорий, базовый рацион &mdash; <span>100%</span>, если часть еды покупается и съедается за
пределами дома подвиньте ползунок влево, если вы ждёте гостей вправо
</div>
</Alert>
</Accordion>
2026-08-24 10:07:16 +00:00
</SettingsPanel>
</RightColumn>
</Container>
);
}, [
categories,
days,
handleCategoriesChange,
handleDaysChange,
handleUsersChange,
initialData,
isCartComplete,
isCartLoading,
openSection,
products,
selectedCategories.length,
users,
]);
return (
<>
{pageContent}
<CookiePopup />
</>
);
}
const LoadingPage = styled.div`
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: ${({ theme }) => theme.colors.white};
font-size: 20px;
`;
const Container = styled.main`
position: relative;
display: flex;
width: calc(100% - 40px);
max-width: 1228px;
height: 90vh;
margin: 40px auto 0;
background: ${({ theme }) => theme.colors.white};
border-radius: ${({ theme }) => theme.radius.page};
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
overflow: hidden;
@media (max-width: 900px) {
flex-direction: column;
height: auto;
min-height: 90vh;
}
`;
const Column = styled.section`
padding: 40px;
flex: 1;
width: 50%;
overflow-y: auto;
& + & {
border-left: 1px solid #f0f0f0;
}
@media (max-width: 900px) {
width: 100%;
padding: 20px;
& + & {
border-left: none;
border-top: 1px solid #f0f0f0;
}
}
`;
const LeftColumn = styled(Column)`
display: flex;
flex-direction: column;
height: 100%;
max-width: 490px;
`;
const RightColumn = styled(Column)``;
const TitleBlock = styled.div`
margin-bottom: 32px;
h1 {
font-size: 32px;
line-height: 38px;
font-weight: 700;
color: ${({ theme }) => theme.colors.darkGreen};
margin-bottom: 8px;
}
p {
font-size: 16px;
line-height: 19px;
color: ${({ theme }) => theme.colors.gray};
font-weight: 350;
}
`;
const FilterTitle = styled.div`
margin-bottom: 40px;
h2 {
font-size: 20px;
line-height: 24px;
margin-bottom: 8px;
color: ${({ theme }) => theme.colors.darkGreen};
}
p {
font-size: 16px;
line-height: 19px;
color: ${({ theme }) => theme.colors.gray};
font-weight: 350;
}
`;
const SettingsPanel = styled.div`
border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: 16px;
2026-08-30 11:47:34 +00:00
`;
const Alert = styled.div`
gap: 8px;
display: flex;
flex-direction: row;
font-weight: 370;
background-color: #F1FAF0;
border-radius: 8px;
padding: 12px;
svg {
width: 24px;
}
span {
font-weight: 450;
color: #5EA12D;
}
`