506 lines
20 KiB
TypeScript
506 lines
20 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import styled, {css} from 'styled-components';
|
||
import {clearCart, 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';
|
||
import CustomSlider from "./components/Fullness/FullnessSlider";
|
||
import {useGlobalZustandState} from "./hooks/useGlobalZustandState";
|
||
import {setUsers, useAppDispatch, useAppSelector} from "./store";
|
||
import {FinitaLaCommedia} from "./components/Cart/FinitaLaCommedia";
|
||
|
||
import {reachGoal} from "./hooks/useYM";
|
||
|
||
export function App() {
|
||
const [initialData, setInitialData] = useState<InitialData | null>(null);
|
||
const [products, setProducts] = useState<Product[]>([]);
|
||
const [isCartComplete, setIsCartComplete] = useState(false);
|
||
const [showEmptyPopup, setShowEmptyPopup] = useState(false);
|
||
const [isCartLoading, setIsCartLoading] = useState(true);
|
||
const [showLoader, setShowLoader] = useState(false);
|
||
const [openSection, setOpenSection] = useState<string>('family');
|
||
const [needRefresh, setNeedRefresh] = useState<boolean>(true);
|
||
const [isFullnessHighlighted, setIsFullnessHighlighted] = useState<boolean>(false);
|
||
const [mobileShowFilters, setMobileShowFilters] = useState<boolean>(true);
|
||
|
||
const {setInitial, precision} = useGlobalZustandState((state) => state);
|
||
const dispatch = useAppDispatch();
|
||
const users = useAppSelector(state => state.family.users);
|
||
|
||
useEffect(() => {
|
||
if (showLoader) {
|
||
let timeout = setTimeout(() => { setShowLoader(false); }, 10_000);
|
||
return () => {
|
||
if (timeout) {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
}
|
||
}, [showLoader, setShowLoader]);
|
||
|
||
const loadInitialState = async () => {
|
||
setShowLoader(true);
|
||
loadInitialData().then(data => {
|
||
setInitialData(data);
|
||
dispatch(setUsers(data.users));
|
||
setProducts(data.cart.flat().map(product => normalizeProductImage(product)));
|
||
setInitial(false, data.excess || 0, data.precision || 0);
|
||
setIsCartComplete(false)
|
||
}).finally(() => {
|
||
setShowLoader(false);
|
||
});
|
||
};
|
||
|
||
const emptyCart = async () => {
|
||
return clearCart().finally(() => {
|
||
return loadInitialState();
|
||
});
|
||
};
|
||
useEffect(() => {
|
||
if (needRefresh) {
|
||
setNeedRefresh(false);
|
||
loadInitialState();
|
||
}
|
||
}, [needRefresh, setNeedRefresh, loadInitialState]);
|
||
|
||
const categories = initialData?.available_groups ?? [];
|
||
const selectedCategories = initialData?.initial_groups ?? [];
|
||
const days = initialData?.days ?? 7;
|
||
|
||
const handleDaysChange = useCallback(async (nextDays: number) => {
|
||
setShowLoader(true);
|
||
setOption('days', nextDays)
|
||
.finally(() => {
|
||
loadInitialState();
|
||
});
|
||
}, [loadInitialState]);
|
||
|
||
const handleUsersChange = useCallback(async (nextUsers: User[]) => {
|
||
setShowLoader(true);
|
||
setOption('users', nextUsers)
|
||
.finally(() => {
|
||
loadInitialState();
|
||
});
|
||
}, [loadInitialState]);
|
||
|
||
const handleCategoriesChange = useCallback(async (nextCategories: string[]) => {
|
||
setShowLoader(true);
|
||
setOption('initial_groups', nextCategories)
|
||
.finally(() => {
|
||
loadInitialState();
|
||
});
|
||
}, [loadInitialState]);
|
||
|
||
const handlePercentChange = useCallback(async (fullness: number) => {
|
||
setShowLoader(true);
|
||
setOption('fullness', fullness)
|
||
.finally(() => {
|
||
loadInitialState();
|
||
})
|
||
}, [loadInitialState]);
|
||
|
||
useEffect(() => {
|
||
if (isCartComplete && !isCartLoading && precision < 80) {
|
||
setShowEmptyPopup(true);
|
||
} else {
|
||
setShowEmptyPopup(false);
|
||
}
|
||
}, [isCartComplete, isCartLoading, precision, setShowEmptyPopup]);
|
||
|
||
useEffect(() => {
|
||
reachGoal('ac_open_settings_tab_' + openSection);
|
||
}, [openSection])
|
||
|
||
const pageContent = useMemo(() => {
|
||
if (!initialData) {
|
||
return <LoadingPage>Загрузка...</LoadingPage>;
|
||
}
|
||
|
||
return (
|
||
<Container $fullHeight={mobileShowFilters}>
|
||
{showLoader && <Overlay><div /></Overlay>}
|
||
<LeftColumn $dataShow={!mobileShowFilters}>
|
||
<TitleBlock>
|
||
<h1>Хватило</h1>
|
||
<MobileFilter onClick={() => setMobileShowFilters(true)}>
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M19 3V7M19 21V11M12 3V15M12 21V19M5 3V5M5 21V9" stroke="#5EA12D" strokeWidth="2" strokeLinecap="round"/>
|
||
<path d="M17 9C17 10.1046 17.8954 11 19 11C20.1046 11 21 10.1046 21 9C21 7.89543 20.1046 7 19 7C17.8954 7 17 7.89543 17 9Z" stroke="#5EA12D" strokeWidth="2" strokeLinecap="round"/>
|
||
<path d="M10 17C10 18.1046 10.8954 19 12 19C13.1046 19 14 18.1046 14 17C14 15.8954 13.1046 15 12 15C10.8954 15 10 15.8954 10 17Z" stroke="#5EA12D" strokeWidth="2" strokeLinecap="round"/>
|
||
<path d="M3 7C3 8.10457 3.89543 9 5 9C6.10457 9 7 8.10457 7 7C7 5.89543 6.10457 5 5 5C3.89543 5 3 5.89543 3 7Z" stroke="#5EA12D" strokeWidth="2" strokeLinecap="round"/>
|
||
</svg>
|
||
</MobileFilter>
|
||
<p>Заботимся о балансе нутриентов</p>
|
||
</TitleBlock>
|
||
|
||
<Cart
|
||
products={products}
|
||
days={initialData.days}
|
||
isComplete={isCartComplete}
|
||
isLoading={isCartLoading}
|
||
onProductsChange={(valueOrArrUpdater) => {
|
||
const newProducts = typeof valueOrArrUpdater === 'function'
|
||
? valueOrArrUpdater(products)
|
||
: valueOrArrUpdater;
|
||
|
||
if (newProducts.length < products.length) {
|
||
setIsCartComplete(false);
|
||
setTimeout(() => {
|
||
setIsCartComplete(false);
|
||
}, 3000);
|
||
}
|
||
setProducts(newProducts);
|
||
}}
|
||
onComplete={() => {
|
||
setIsCartComplete(true);
|
||
setIsCartLoading(false);
|
||
}}
|
||
onLoadingChange={setIsCartLoading}
|
||
onMistake={() => setNeedRefresh(true)}
|
||
/>
|
||
</LeftColumn>
|
||
|
||
<RightColumn $dataShow={mobileShowFilters}>
|
||
<FilterTitle>
|
||
<h2>Настройте корзину под себя</h2>
|
||
<MobileBack onClick={() => setMobileShowFilters(false)}>
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M20 12L5 12M10.9231 6L5 12L10.9231 18" stroke="#5EA12D" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
</svg>
|
||
</MobileBack>
|
||
<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"
|
||
title="Обязательные продукты"
|
||
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={() => setNeedRefresh(true)}
|
||
/>
|
||
</Accordion>
|
||
<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" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
</svg>}
|
||
description={openSection === 'fullness' ? 'Чем меньше значение, тем меньше продуктов и калорий в корзине' : initialData.fullness + '% — ваша цель. Результат может варьироваться'}
|
||
isHighlighted={isFullnessHighlighted}
|
||
isOpen={openSection === 'fullness'}
|
||
onToggle={() => {
|
||
if (openSection !== 'fullness') {
|
||
setIsFullnessHighlighted(true);
|
||
}
|
||
setOpenSection(openSection === 'fullness' ? '' : 'fullness')
|
||
}}
|
||
>
|
||
<CustomSlider defaultValue={initialData.fullness} onChange={handlePercentChange} />
|
||
<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>
|
||
Процент влияет на количество калорий, базовый рацион — <span>100%</span>, если часть еды покупается и съедается за
|
||
пределами дома — подвиньте ползунок влево, если вы ждёте гостей — вправо
|
||
</div>
|
||
</Alert>
|
||
</Accordion>
|
||
</SettingsPanel>
|
||
</RightColumn>
|
||
</Container>
|
||
);
|
||
}, [
|
||
categories,
|
||
days,
|
||
handleCategoriesChange,
|
||
handleDaysChange,
|
||
handleUsersChange,
|
||
initialData,
|
||
isCartComplete,
|
||
isCartLoading,
|
||
openSection,
|
||
products,
|
||
selectedCategories.length,
|
||
users,
|
||
]);
|
||
|
||
return (
|
||
<>
|
||
{pageContent}
|
||
<CookiePopup />
|
||
{showEmptyPopup && <FinitaLaCommedia onClose={() => setShowEmptyPopup(false)} onRefresh={emptyCart} />}
|
||
</>
|
||
);
|
||
}
|
||
|
||
const Overlay = styled.div`
|
||
position: fixed;
|
||
display: flex;
|
||
inset: 0;
|
||
background-color: rgba(255, 255, 255, 0.1);
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 9;
|
||
overflow: auto;
|
||
|
||
height: 100dvh;
|
||
height: -webkit-fill-available;
|
||
|
||
div {
|
||
--color-1: #ffa0a0;
|
||
--size: 1.2px;
|
||
width: calc(48 * var(--size));
|
||
height: calc(48 * var(--size));
|
||
border: calc(5 * var(--size)) solid var(--color-1);
|
||
border-bottom-color: transparent;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
box-sizing: border-box;
|
||
animation: rotation 1s linear infinite;
|
||
}
|
||
|
||
@keyframes rotation {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
`;
|
||
|
||
const LoadingPage = styled.div`
|
||
min-height: 100svh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: ${({ theme }) => theme.colors.white};
|
||
font-size: 20px;
|
||
`;
|
||
|
||
const Container = styled.main<{$fullHeight: boolean}>`
|
||
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: ${$fullHeight => $fullHeight ? 'auto' : '90vh'};
|
||
}
|
||
|
||
${({ theme }) => theme.media.mobile(css<{$fullHeight: boolean}>`
|
||
margin: 0;
|
||
width: 100%;
|
||
h1 {
|
||
padding-top: 10px;
|
||
}
|
||
height: ${({ $fullHeight }) => $fullHeight ? 'auto' : '100dvh'};
|
||
border-radius: ${({ $fullHeight }) => $fullHeight ? '0 0 8px 8px' : '0'};
|
||
`)}
|
||
`;
|
||
|
||
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;
|
||
}
|
||
}
|
||
`;
|
||
|
||
const LeftColumn = styled(Column)<{$dataShow: boolean}>`
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
max-width: 490px;
|
||
position: relative;
|
||
${props => props.theme.media.mobile(css`
|
||
display: ${props.$dataShow ? 'flex' : 'none'};
|
||
`)}
|
||
`;
|
||
|
||
const RightColumn = styled(Column)<{$dataShow: boolean}>`
|
||
${({ theme }) => theme.media.mobile(css`
|
||
display: none;
|
||
`)}
|
||
${props => props.theme.media.mobile(css`
|
||
display: ${props.$dataShow ? 'flex' : 'none'};
|
||
flex-direction: column;
|
||
`)}
|
||
`;
|
||
|
||
const TitleBlock = styled.div`
|
||
margin-bottom: 32px;
|
||
position: relative;
|
||
|
||
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`
|
||
position: relative;
|
||
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;
|
||
}
|
||
|
||
${({ theme }) => theme.media.mobile(css`
|
||
margin-bottom: 32px;
|
||
h2 {
|
||
padding-top: 10px;
|
||
text-align: center;
|
||
font-size: 16px;
|
||
line-height: 19px;
|
||
}
|
||
p {
|
||
font-size: 14px;
|
||
line-height: 16px;
|
||
max-width: 271px;
|
||
text-align: center;
|
||
margin: 0 auto;
|
||
}
|
||
`)}
|
||
`;
|
||
|
||
const SettingsPanel = styled.div`
|
||
border: 1px solid ${({ theme }) => theme.colors.border};
|
||
border-radius: 12px;
|
||
`;
|
||
|
||
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;
|
||
}
|
||
`
|
||
|
||
const MobileFilter = styled.div`
|
||
display: none;
|
||
position: absolute;
|
||
top:0;
|
||
right:0;
|
||
width: 40px;
|
||
height: 40px;
|
||
justify-content: center;
|
||
align-items: center;
|
||
${({ theme }) => theme.media.mobile(css`
|
||
display: flex;
|
||
`)}
|
||
`
|
||
const MobileBack = styled.div`
|
||
display: none;
|
||
position: absolute;
|
||
top:0;
|
||
left:0;
|
||
width: 40px;
|
||
height: 40px;
|
||
justify-content: center;
|
||
align-items: center;
|
||
${({ theme }) => theme.media.mobile(css`
|
||
display: flex;
|
||
`)}
|
||
` |