final flow

This commit is contained in:
Zakhar 2026-08-30 14:47:34 +03:00
parent 9dc251bd8f
commit b016abdba2
27 changed files with 1268 additions and 202 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

BIN
images/broc-gifts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 111 KiB

5
images/leaf.svg Normal file
View File

@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 1.4 KiB

34
package-lock.json generated
View File

@ -8,7 +8,8 @@
"http-server": "^14.1.1", "http-server": "^14.1.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"styled-components": "^6.5.3" "styled-components": "^6.5.3",
"zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
"@minify-html/node": "^0.18.1", "@minify-html/node": "^0.18.1",
@ -507,7 +508,7 @@
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -3528,6 +3529,35 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
},
"node_modules/zustand": {
"version": "5.0.15",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
"integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }

View File

@ -7,7 +7,8 @@
"http-server": "^14.1.1", "http-server": "^14.1.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"styled-components": "^6.5.3" "styled-components": "^6.5.3",
"zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
"@minify-html/node": "^0.18.1", "@minify-html/node": "^0.18.1",

View File

@ -11,6 +11,8 @@ import { normalizeProductImage } from './utils/product';
import type { InitialData } from './types/api'; import type { InitialData } from './types/api';
import type { Product } from './types/cart'; import type { Product } from './types/cart';
import type { User } from './types/user'; import type { User } from './types/user';
import CustomSlider from "./components/Fullness/FullnessSlider";
import {useGlobalZustandState} from "./hooks/useGlobalZustandState";
export function App() { export function App() {
const [initialData, setInitialData] = useState<InitialData | null>(null); const [initialData, setInitialData] = useState<InitialData | null>(null);
@ -18,13 +20,26 @@ export function App() {
const [isCartComplete, setIsCartComplete] = useState(false); const [isCartComplete, setIsCartComplete] = useState(false);
const [isCartLoading, setIsCartLoading] = useState(true); const [isCartLoading, setIsCartLoading] = useState(true);
const [openSection, setOpenSection] = useState<string>('family'); const [openSection, setOpenSection] = useState<string>('family');
const [needRefresh, setNeedRefresh] = useState<boolean>(true);
const [isFullnessHighlighted, setIsFullnessHighlighted] = useState<boolean>(false);
useEffect(() => { const {setInitial} = useGlobalZustandState((state) => state);
const loadInitialState = async () => {
loadInitialData().then(data => { loadInitialData().then(data => {
setInitialData(data); setInitialData(data);
setProducts(data.cart.flat().map(product => normalizeProductImage(product))); setProducts(data.cart.flat().map(product => normalizeProductImage(product)));
setInitial(false, data.excess || 0, data.precision || 0);
setIsCartComplete(false)
}); });
}, []); };
useEffect(() => {
if (needRefresh) {
setNeedRefresh(false);
loadInitialState();
}
}, [needRefresh, setNeedRefresh, loadInitialState]);
const users = initialData?.users ?? []; const users = initialData?.users ?? [];
const categories = initialData?.available_groups ?? []; const categories = initialData?.available_groups ?? [];
@ -32,18 +47,24 @@ export function App() {
const days = initialData?.days ?? 7; const days = initialData?.days ?? 7;
const handleDaysChange = useCallback(async (nextDays: number) => { const handleDaysChange = useCallback(async (nextDays: number) => {
setInitialData(current => current ? { ...current, days: nextDays } : current);
await setOption('days', nextDays); await setOption('days', nextDays);
}, []); loadInitialState();
}, [loadInitialState]);
const handleUsersChange = useCallback(async (nextUsers: User[]) => { const handleUsersChange = useCallback(async (nextUsers: User[]) => {
setInitialData(current => current ? { ...current, users: nextUsers } : current);
await setOption('users', nextUsers); await setOption('users', nextUsers);
}, []); loadInitialState();
}, [loadInitialState]);
const handleCategoriesChange = useCallback((nextCategories: string[]) => { const handleCategoriesChange = useCallback((nextCategories: string[]) => {
setInitialData(current => current ? { ...current, initial_groups: nextCategories } : current); setInitialData(current => current ? { ...current, initial_groups: nextCategories } : current);
}, []); loadInitialState();
}, [loadInitialState]);
const handlePercentChange = useCallback((percent: number) => {
setInitialData(current => current ? { ...current, percent } : current);
loadInitialState();
}, [loadInitialState]);
const pageContent = useMemo(() => { const pageContent = useMemo(() => {
if (!initialData) { if (!initialData) {
@ -54,8 +75,8 @@ export function App() {
<Container> <Container>
<LeftColumn> <LeftColumn>
<TitleBlock> <TitleBlock>
<h1>Автокорзина</h1> <h1>Семейные покупки</h1>
<p>Собираем продукты под ваши настройки</p> <p>Заботимся о балансе нутриентов</p>
</TitleBlock> </TitleBlock>
<Cart <Cart
@ -64,7 +85,19 @@ export function App() {
excess={initialData.excess ?? 0} excess={initialData.excess ?? 0}
isComplete={isCartComplete} isComplete={isCartComplete}
isLoading={isCartLoading} isLoading={isCartLoading}
onProductsChange={setProducts} onProductsChange={(valueOrArrUpdater) => {
const newProducts = typeof valueOrArrUpdater === 'function'
? valueOrArrUpdater(products)
: valueOrArrUpdater;
if (newProducts.length < products.length) {
setIsCartComplete(false);
setTimeout(() => {
setIsCartComplete(false);
}, 3000);
}
setProducts(newProducts);
}}
onComplete={() => { onComplete={() => {
setIsCartComplete(true); setIsCartComplete(true);
setIsCartLoading(false); setIsCartLoading(false);
@ -111,7 +144,7 @@ export function App() {
<Accordion <Accordion
id="preferences" id="preferences"
title="Предпочтения" title="Обязательные продукты"
description={ description={
selectedCategories.length > 0 selectedCategories.length > 0
? `Выбраны ${selectedCategories.length}` ? `Выбраны ${selectedCategories.length}`
@ -128,6 +161,37 @@ export function App() {
onSaved={() => undefined} onSaved={() => undefined}
/> />
</Accordion> </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" 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>
</SettingsPanel> </SettingsPanel>
</RightColumn> </RightColumn>
</Container> </Container>
@ -254,3 +318,20 @@ const SettingsPanel = styled.div`
border: 1px solid ${({ theme }) => theme.colors.border}; border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: 16px; border-radius: 16px;
`; `;
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;
}
`

View File

@ -109,6 +109,14 @@ export function clearGuestCookie(): Promise<unknown> {
}); });
} }
export function subscribeOnFinish(email: string): Promise<unknown> {
return requestWithRefresh(apiUrl('/api/promo/subscribe'), {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ email }),
});
}
const fallbackInitialData: InitialData = { const fallbackInitialData: InitialData = {
cart: [], cart: [],
users: [], users: [],

View File

@ -127,7 +127,7 @@ const ArrowWrap = styled.div<{ $open: boolean }>`
`; `;
const Content = styled.div<{ $open: boolean }>` const Content = styled.div<{ $open: boolean }>`
max-height: ${({ $open }) => ($open ? '500px' : '0')}; max-height: ${({ $open }) => ($open ? 'auto' : '0')};
overflow: hidden; overflow: hidden;
transition: max-height 0.4s ease, padding 0.3s ease; transition: max-height 0.4s ease, padding 0.3s ease;
`; `;

View File

@ -8,6 +8,7 @@ import type { Product } from '../../types/cart';
import { CartItem } from './CartItem'; import { CartItem } from './CartItem';
import { CartFooter } from './CartFooter'; import { CartFooter } from './CartFooter';
import { ProductModal } from './ProductModal'; import { ProductModal } from './ProductModal';
import {useGlobalZustandState} from "../../hooks/useGlobalZustandState";
interface CartProps { interface CartProps {
products: Product[]; products: Product[];
@ -35,6 +36,8 @@ export function Cart({
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [processingProductName, setProcessingProductName] = useState<string | null>(null); const [processingProductName, setProcessingProductName] = useState<string | null>(null);
const {showFullRefreshButton} = useGlobalZustandState((state) => state);
const visibleProducts = products.slice(0, visibleCount); const visibleProducts = products.slice(0, visibleCount);
const remaining = products.length - visibleProducts.length; const remaining = products.length - visibleProducts.length;
@ -82,6 +85,7 @@ export function Cart({
<div> <div>
<SummaryTitle> <SummaryTitle>
Ваша корзина на <Green>{days} {declension(days, ['день', 'дня', 'дней'])}</Green> Ваша корзина на <Green>{days} {declension(days, ['день', 'дня', 'дней'])}</Green>
{showFullRefreshButton && <AbsoluteRefreshButton>Начать заново</AbsoluteRefreshButton>}
</SummaryTitle> </SummaryTitle>
<SummaryDetails $hidden={isLoading}> <SummaryDetails $hidden={isLoading}>
@ -141,13 +145,47 @@ export function Cart({
<ProductModal <ProductModal
product={selectedProduct} product={selectedProduct}
onClose={() => setSelectedProduct(null)} onClose={() => setSelectedProduct(null)}
onReplace={handleReplace} onReplace={(product) => {
onAnother={() => undefined} handleReplace(product);
setSelectedProduct(null);
}}
onDelete={(product) => {
handleDelete(product);
setSelectedProduct(null);
}}
/> />
</> </>
); );
} }
const AbsoluteRefreshButton = styled.button`
background-color: ${({ theme }) => theme.colors.green};
height: 32px;
color: #ffffff;
border: none;
padding: 14px;
border-radius: 12px;
font-size: 16px;
font-weight: 370;
line-height: 20px;
cursor: pointer;
transition: background 0.2s;
display: flex;
align-items: center;
position: absolute;
right: 0;
top: 0;
&:hover,
&:focus {
background-color: ${({ theme }) => theme.colors.greenHover};
}
&:active {
background-color: #4A8D19;
}
`
const Summary = styled.div` const Summary = styled.div`
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -155,6 +193,7 @@ const Summary = styled.div`
margin-bottom: 24px; margin-bottom: 24px;
flex-shrink: 0; flex-shrink: 0;
width: 100%; width: 100%;
position: relative;
`; `;
const SummaryTitle = styled.div` const SummaryTitle = styled.div`
@ -207,6 +246,7 @@ const Loader = styled.div<{ $hidden: boolean, "data-display-text": string }>`
color: transparent; color: transparent;
background-clip: text; background-clip: text;
position: absolute; position: absolute;
top:32px;
visibility: ${({ $hidden }) => ($hidden ? 'hidden' : 'visible')}; visibility: ${({ $hidden }) => ($hidden ? 'hidden' : 'visible')};
&:before, &:before,

View File

@ -1,5 +1,7 @@
import styled from 'styled-components'; import styled from 'styled-components';
import {BagIcon} from "../Icons/BagIcon"; import {BagIcon} from "../Icons/BagIcon";
import {useState} from "react";
import {OrderPopup} from "./OrderPopup";
interface CartFooterProps { interface CartFooterProps {
@ -8,6 +10,8 @@ interface CartFooterProps {
} }
export function CartFooter({ isVisible, totalPrice }: CartFooterProps) { export function CartFooter({ isVisible, totalPrice }: CartFooterProps) {
const [showCompletePopup, setShowCompletePopup] = useState(false);
return ( return (
<Footer $visible={isVisible}> <Footer $visible={isVisible}>
<TotalWrap> <TotalWrap>
@ -23,9 +27,12 @@ export function CartFooter({ isVisible, totalPrice }: CartFooterProps) {
</div> </div>
</TotalWrap> </TotalWrap>
<CheckoutButton type="button"> <CheckoutButton type="button" onClick={() => setShowCompletePopup(true)}>
Оформить заказ Оформить заказ
</CheckoutButton> </CheckoutButton>
{showCompletePopup && <>
<OrderPopup setShowCompletePopup={setShowCompletePopup} />
</>}
</Footer> </Footer>
); );
} }

View File

@ -1,7 +1,7 @@
import { useState } from 'react'; import {useState} from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { getProductAmount } from '../../utils/product'; import {getProductAmount} from '../../utils/product';
import type { Product } from '../../types/cart'; import type {Product} from '../../types/cart';
interface CartItemProps { interface CartItemProps {
product: Product; product: Product;
@ -23,7 +23,7 @@ export function CartItem({
return ( return (
<Item $processing={isProcessing}> <Item $processing={isProcessing}>
<ItemImage type="button" onClick={onOpen}> <ItemImage type="button" onClick={onOpen}>
<img src={product.image} alt="" /> <img src={product.image} alt=""/>
</ItemImage> </ItemImage>
<ItemDetails> <ItemDetails>
@ -42,6 +42,9 @@ export function CartItem({
</MenuButton> </MenuButton>
{isMenuOpen && ( {isMenuOpen && (
<>
<Overlay onClick={() => setIsMenuOpen(false)}>
</Overlay>
<Dropdown> <Dropdown>
<ul> <ul>
<li> <li>
@ -56,6 +59,7 @@ export function CartItem({
</li> </li>
</ul> </ul>
</Dropdown> </Dropdown>
</>
)} )}
</MenuWrapper> </MenuWrapper>
</Item> </Item>
@ -64,13 +68,14 @@ export function CartItem({
const Item = styled.li<{ $processing: boolean }>` const Item = styled.li<{ $processing: boolean }>`
display: flex; display: flex;
align-items: center; flex-direction: row;
align-items: stretch;
padding: 16px 0; padding: 16px 0;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
position: relative; position: relative;
transition: opacity 0.4s, transform 0.4s; transition: opacity 0.4s, transform 0.4s;
opacity: ${({ $processing }) => ($processing ? 0.5 : 1)}; opacity: ${({$processing}) => ($processing ? 0.5 : 1)};
pointer-events: ${({ $processing }) => ($processing ? 'none' : 'auto')}; pointer-events: ${({$processing}) => ($processing ? 'none' : 'auto')};
`; `;
const ItemImage = styled.button` const ItemImage = styled.button`
@ -95,22 +100,28 @@ const ItemImage = styled.button`
`; `;
const ItemDetails = styled.div` const ItemDetails = styled.div`
flex: 1; flex: 1 1 100%;
display: flex;
justify-content: space-between;
flex-direction: column;
`; `;
const ItemName = styled.div` const ItemName = styled.div`
font-size: 15px; font-size: 15px;
color: ${({ theme }) => theme.colors.darkGreen}; color: ${({theme}) => theme.colors.darkGreen};
margin-bottom: 4px; margin-bottom: 4px;
`; `;
const ItemPrice = styled.div` const ItemPrice = styled.div`
font-size: 14px; font-size: 14px;
color: ${({ theme }) => theme.colors.gray}; color: ${({theme}) => theme.colors.gray};
`; `;
const MenuWrapper = styled.div` const MenuWrapper = styled.div`
position: relative; position: relative;
width: 26px;
display: flex;
align-self: flex-start;
`; `;
const MenuButton = styled.button` const MenuButton = styled.button`
@ -123,23 +134,34 @@ const MenuButton = styled.button`
letter-spacing: 2px; letter-spacing: 2px;
border-radius: 4px; border-radius: 4px;
transition: 0.2s; transition: 0.2s;
position: absolute;
top: 0; right: 0;
&:hover { &:hover {
background: #f3f4f6; background: #f3f4f6;
color: ${({ theme }) => theme.colors.darkGreen}; color: ${({theme}) => theme.colors.darkGreen};
} }
`; `;
const Overlay = styled.div`
position: fixed;
display: flex;
inset: 0;
background-color: rgba(0, 0, 0, 0);
justify-content: center;
align-items: center;
z-index: 9;
`;
const Dropdown = styled.div` const Dropdown = styled.div`
position: absolute; position: absolute;
right: 0; right: 0;
top: 100%; top: 100%;
background: #ffffff; background: #ffffff;
border: 1px solid #eee;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); box-shadow: 0 11px 29px rgba(0, 0, 0, 0.06);
min-width: 150px; min-width: 150px;
padding: 6px 0; padding: 10px;
z-index: 10; z-index: 10;
margin-top: 4px; margin-top: 4px;
@ -151,15 +173,17 @@ const Dropdown = styled.div`
width: 100%; width: 100%;
padding: 10px 16px; padding: 10px 16px;
font-size: 14px; font-size: 14px;
color: ${({ theme }) => theme.colors.darkGreen}; color: ${({theme}) => theme.colors.darkGreen};
cursor: pointer; cursor: pointer;
transition: 0.2s; transition: 0.2s;
background: transparent; background: transparent;
border: 0; border: 0;
text-align: left; border-radius: 8px;
text-align: center;
&:hover { &:hover {
background: #f3f4f6; background: #5EA12D;
color: #fff;
} }
} }
`; `;

View File

@ -0,0 +1,203 @@
import React, { useEffect, useRef, useCallback } from 'react';
import styled from 'styled-components';
// Тип для одной частицы
interface Particle {
x: number;
y: number;
vx: number; // скорость по X
vy: number; // скорость по Y
radius: number; // начальный радиус
hue: number; // оттенок (0360)
life: number; // оставшееся время жизни (в кадрах)
maxLife: number; // полное время жизни
}
interface CelebrationCanvasProps {
/** Включена ли анимация */
active?: boolean;
/** Длительность анимации в миллисекундах (по умолчанию 3000) */
duration?: number;
/** Количество частиц (по умолчанию 150) */
particleCount?: number;
/** Колбэк по окончании анимации */
onComplete?: () => void;
}
// Стилизованный контейнер фиксирован на весь экран, поверх всего (z-index: 5),
// но не перехватывает клики (pointer-events: none)
const Container = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 6;
pointer-events: none;
`;
const StyledCanvas = styled.canvas`
display: block;
width: 100%;
height: 100%;
background: transparent; /* прозрачный фон */
z-index: 6;
`;
const CelebrationCanvas: React.FC<CelebrationCanvasProps> = ({
active = true,
duration = 3000,
particleCount = 150,
onComplete,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const animationRef = useRef<number|undefined>(undefined);
const startTimeRef = useRef<number>(0);
// Основной цикл анимации
const animate = useCallback(
(timestamp: number) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const elapsed = timestamp - startTimeRef.current;
// Завершаем по таймеру
if (elapsed > duration) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (onComplete) onComplete();
return;
}
// Очищаем canvas (прозрачный фон)
ctx.clearRect(0, 0, canvas.width, canvas.height);
const particles = particlesRef.current;
// Обновляем и рисуем каждую частицу
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.life -= 1;
// Удаляем мёртвые или вышедшие за границы частицы
if (
p.life <= 0 ||
p.x < 0 ||
p.x > canvas.width ||
p.y < 0 ||
p.y > canvas.height
) {
particles.splice(i, 1);
continue;
}
// Прозрачность и размер зависят от оставшегося времени жизни
const alpha = Math.min(1, p.life / p.maxLife);
const radius = p.radius * (0.3 + 0.7 * alpha);
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${p.hue}, 100%, 60%, ${alpha})`;
ctx.fill();
}
// Если частицы кончились раньше таймера завершаем
if (particles.length === 0) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (onComplete) onComplete();
return;
}
// Продолжаем анимацию
animationRef.current = requestAnimationFrame(animate);
},
[duration, onComplete]
);
// Запуск / остановка анимации при изменении active
useEffect(() => {
if (!active) {
// Останавливаем анимацию и очищаем canvas
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = undefined;
}
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
return;
}
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Подгоняем размер canvas под окно
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
window.addEventListener('resize', resize);
resize();
// Генерируем частицы из центра
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const newParticles: Particle[] = [];
for (let i = 0; i < particleCount; i++) {
const angle = Math.random() * 2 * Math.PI;
const speed = 2 + Math.random() * 7;
const radius = 2 + Math.random() * 4;
const hue = Math.random() * 360;
const life = 40 + Math.random() * 80; // количество кадров жизни
newParticles.push({
x: centerX,
y: centerY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
radius,
hue,
life,
maxLife: life,
});
}
particlesRef.current = newParticles;
// Запоминаем время старта
startTimeRef.current = performance.now();
// Отменяем предыдущий кадр, если был
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
animationRef.current = requestAnimationFrame(animate);
// Cleanup при размонтировании или изменении зависимостей
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = undefined;
}
window.removeEventListener('resize', resize);
};
}, [active, particleCount, animate]);
// Компонент всегда рендерится, но анимация запускается только при active=true
return (
<Container>
<StyledCanvas ref={canvasRef} />
</Container>
);
};
export default CelebrationCanvas;

View File

@ -0,0 +1,83 @@
import styled from "styled-components";
import {TextInput} from "../components/TextInput";
import {useState} from "react";
import CelebrationCanvas from "./CelebrationCanvas";
import {createPortal} from "react-dom";
import {subscribeOnFinish} from "../../api/promoApi";
export const FinishPopupContent = ({setShowCompletePopup}: any) => {
const [email, setEmail] = useState<string>("");
const [buttonContent, setButtonContent] = useState<string>("Получить подарок");
return <FinishPopupContentWrap>
<img src="/images/broc-gifts.png" alt=""/>
<h2>Спасибо!</h2>
<p>
Вы помогли нам протестировать автокорзину. Сейчас сервис ещё разрабатывается, поэтому оформить заказ пока нельзя.
Оставьте адрес электронной почты, и после запуска мы подарим вам <strong>3 бесплатные корзины</strong>.
</p>
<TextInput placeholder={'Почта'} value={email} onChange={setEmail} />
<FinishButton onClick={() => {
if (buttonContent === 'Получить подарок') {
setButtonContent('...')
subscribeOnFinish(email).then((data) => {
if (data && data.status === 200) {
setButtonContent('Письмо отправлено')
} else {
setButtonContent('Возникла ошибка')
}
}).catch(() => {
setButtonContent('Возникла ошибка')
})
}
}}>{buttonContent}</FinishButton>
{createPortal(<CelebrationCanvas duration={5000} particleCount={300} />, document.body)}
</FinishPopupContentWrap>;
};
const FinishPopupContentWrap = styled.div`
padding: 20px;
img {
max-width: 100%;
}
h2 {
margin-top: 24px;
margin-bottom: 8px;
font-size: 32px;
line-height: 48px;
font-weight: 450;
text-align: center;
}
p {
font-size: 16px;
line-height: 19px;
font-weight: 360;
text-align: center;
margin-bottom: 24px;
}
`;
const FinishButton = styled.button`
background-color: ${({ theme }) => theme.colors.green};
height: 54px;
width: 100%;
color: #ffffff;
border: none;
padding: 14px 32px;
border-radius: 12px;
font-size: 16px;
font-weight: 370;
line-height: 20px;
cursor: pointer;
transition: background 0.2s;
margin-top: 16px;
&:hover,
&:focus {
background-color: ${({ theme }) => theme.colors.greenHover};
}
&:active {
background-color: #4A8D19;
}
`;

View File

@ -0,0 +1,128 @@
import styled from "styled-components";
import {useState} from "react";
import {FinishPopupContent} from "./FinishPopupContent";
export const OrderPopup = ({setShowCompletePopup}: any) => {
const [showFinal, setShowFinal] = useState(false);
return <Overlay onClick={() => setShowCompletePopup(false)}>
<Window onClick={event => event.stopPropagation()}>
<CloseButton type="button" aria-label="Закрыть" onClick={() => setShowCompletePopup(false)}>
&times;
</CloseButton>
{showFinal ? <FinishPopupContent setShowCompletePopup={setShowCompletePopup} /> :
<ModalContent>
<ServiceFeeTitle>Стоимость составления корзины</ServiceFeeTitle>
<ServiceFee>199 </ServiceFee>
<ServiceFeeDescription>Включена в итоговую стоимость заказа</ServiceFeeDescription>
<ButtonBlock>
<ActiveButton onClick={() => setShowFinal(true)}>Продолжить</ActiveButton>
<Button onClick={() => setShowCompletePopup(false)}>Вернуться к корзине</Button>
</ButtonBlock>
</ModalContent>}
</Window>
</Overlay>
}
const Overlay = styled.div`
position: fixed;
display: flex;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
justify-content: center;
align-items: center;
z-index: 4;
`;
const Window = styled.div`
background-color: #ffffff;
border-radius: 12px;
width: auto;
max-width: 535px;
padding: 20px 24px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 9;
`;
const CloseButton = styled.button`
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
width: 24px;
height: 24px;
font-weight: 300;
line-height: 12px;
color: #CEDFD0;
font-size: 24px;
cursor: pointer;
padding: 5px;
transition: 0.2s;
&:hover {
color: #ACBAAE;
}
`;
const ModalContent = styled.div`
padding: 20px;
color: #0C3B2E;
max-width: 335px;
`;
const ButtonBlock = styled.div`
margin-top: 24px;
display: flex;
gap: 16px;
flex-direction: column;
`;
const Button = styled.div`
height: 54px;
border-radius: 12px;
text-align: center;
background: #fff;
color: #5EA12D;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 16px;
cursor: pointer;
`;
const ActiveButton = styled(Button)`
background-color: #5EA12D;
color: #fff;
font-size: 16px;
line-height: 20px;
&:hover,
&:focus {
background-color: ${({theme}) => theme.colors.greenHover};
}
&:active {
background-color: #4A8D19;
}
`;
const ServiceFeeTitle = styled.div`
font-size: 20px;
line-height: 24px;
font-weight: 450;
text-align: center;
`;
const ServiceFee = styled.div`
color: #5EA12D;
font-size: 32px;
line-height: 38px;
font-weight: 420;
text-align: center;
margin-top: 8px;
margin-bottom: 8px;
`
const ServiceFeeDescription = styled.div`
font-size: 16px;
line-height: 19px;
font-weight: 350;
text-align: center;
`

View File

@ -7,14 +7,14 @@ interface ProductModalProps {
product: Product | null; product: Product | null;
onClose: () => void; onClose: () => void;
onReplace: (product: Product) => void; onReplace: (product: Product) => void;
onAnother: (product: Product) => void; onDelete: (product: Product) => void;
} }
export function ProductModal({ export function ProductModal({
product, product,
onClose, onClose,
onReplace, onReplace,
onAnother, onDelete,
}: ProductModalProps) { }: ProductModalProps) {
if (!product) return null; if (!product) return null;
@ -43,10 +43,10 @@ export function ProductModal({
href="#" href="#"
onClick={event => { onClick={event => {
event.preventDefault(); event.preventDefault();
onAnother(product); onDelete(product);
}} }}
> >
Ещё вариант Удалить
</AnotherButton> </AnotherButton>
</Window> </Window>
</Overlay> </Overlay>

View File

@ -16,7 +16,7 @@ export function CategoriesSection({
onLocalChange, onLocalChange,
onSaved, onSaved,
}: CategoriesSectionProps) { }: CategoriesSectionProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(true);
const [loadingCategories, setLoadingCategories] = useState<string[]>([]); const [loadingCategories, setLoadingCategories] = useState<string[]>([]);
const maxVisibleCategories = 7; const maxVisibleCategories = 7;
@ -62,7 +62,7 @@ export function CategoriesSection({
<Block> <Block>
<Header> <Header>
<h3>Что добавить в корзину?</h3> <h3>Что добавить в корзину?</h3>
<p>Выберите группы продуктов, которые хотите видеть в корзине</p> <p>Выберите группы продуктов, которые должны обязательно попасть в корзину</p>
</Header> </Header>
<Wrapper> <Wrapper>

View File

@ -51,7 +51,11 @@ export function FamilySection({
<Info> <Info>
<Name>{user.name}</Name> <Name>{user.name}</Name>
<Age>{user.age} {declension(user.age, ['год', 'года', 'лет'])}</Age> <Age>
{user.age} {declension(user.age, ['год', 'года', 'лет'])}
<b> </b>
<span>{user.gender === 'm' ? 'Мужской' : 'Женский'}</span>
</Age>
</Info> </Info>
<DeleteButton <DeleteButton
@ -129,23 +133,19 @@ const FamilyMember = styled.button`
} }
&:nth-child(n+1) ${() => Avatar} { &:nth-child(n+1) ${() => Avatar} {
background-color: #B4AAFF;
}
&:nth-child(n+2) ${() => Avatar} {
background-color: #CEE0FF; background-color: #CEE0FF;
} }
&:nth-child(n+3) ${() => Avatar} { &:nth-child(2n+2) ${() => Avatar} {
background-color: #E6F696; background-color: #E6F696;
} }
&:nth-child(n+4) ${() => Avatar} { &:nth-child(3n+3) ${() => Avatar} {
background-color: #FFF488; background-color: #fff488;
} }
&:nth-child(n+5) ${() => Avatar} { &:nth-child(4n+4) ${() => Avatar} {
background-color: #FFD623; background-color: #ffd623;
} }
`; `;
@ -179,6 +179,7 @@ const Name = styled.div`
const Age = styled.div` const Age = styled.div`
font-size: 14px; font-size: 14px;
font-weight: 350;
color: ${({ theme }) => theme.colors.gray}; color: ${({ theme }) => theme.colors.gray};
`; `;

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import {useEffect, useState} from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import type { Gender, User } from '../../types/user'; import type {Gender, User} from '../../types/user';
import { TagsInput } from './TagsInput'; import {Tag, TagsInput, TagsWrap} from './TagsInput';
interface PersonModalProps { interface PersonModalProps {
isOpen: boolean; isOpen: boolean;
@ -17,7 +17,7 @@ interface PersonForm {
age: string; age: string;
weight: string; weight: string;
height: string; height: string;
avoid: string[]; avoided: string[];
favorite: string[]; favorite: string[];
} }
@ -27,7 +27,7 @@ const defaultForm: PersonForm = {
age: '', age: '',
weight: '', weight: '',
height: '', height: '',
avoid: [], avoided: [],
favorite: [], favorite: [],
}; };
@ -39,6 +39,7 @@ export function PersonModal({
onSave, onSave,
}: PersonModalProps) { }: PersonModalProps) {
const [form, setForm] = useState<PersonForm>(defaultForm); const [form, setForm] = useState<PersonForm>(defaultForm);
const [showExpanded, setShowExpanded] = useState<'' | 'avoid' | 'favorite'>('');
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
@ -50,7 +51,7 @@ export function PersonModal({
age: String(user.age), age: String(user.age),
weight: user.weight ? String(user.weight) : '', weight: user.weight ? String(user.weight) : '',
height: user.height ? String(user.height) : '', height: user.height ? String(user.height) : '',
avoid: user.avoid ?? [], avoided: user.avoided ?? [],
favorite: user.favorite ?? [], favorite: user.favorite ?? [],
}); });
} else { } else {
@ -70,7 +71,7 @@ export function PersonModal({
function handleAvoidChange(tags: string[]) { function handleAvoidChange(tags: string[]) {
setForm(current => ({ setForm(current => ({
...current, ...current,
avoid: tags, avoided: tags,
favorite: current.favorite.filter(tag => !tags.includes(tag)), favorite: current.favorite.filter(tag => !tags.includes(tag)),
})); }));
} }
@ -79,7 +80,7 @@ export function PersonModal({
setForm(current => ({ setForm(current => ({
...current, ...current,
favorite: tags, favorite: tags,
avoid: current.avoid.filter(tag => !tags.includes(tag)), avoided: current.avoided.filter(tag => !tags.includes(tag)),
})); }));
} }
@ -103,13 +104,38 @@ export function PersonModal({
age, age,
weight: form.weight ? Number(form.weight) : undefined, weight: form.weight ? Number(form.weight) : undefined,
height: form.height ? Number(form.height) : undefined, height: form.height ? Number(form.height) : undefined,
avoid: form.avoid, avoided: form.avoided,
favorite: form.favorite, favorite: form.favorite,
}); });
} }
return ( return (
<Overlay onClick={onClose}> <Overlay onClick={onClose}>
{showExpanded ? (
<Content onClick={event => event.stopPropagation()}>
<Header>
<h2>{showExpanded == 'favorite' ? 'Любимые' : 'Не добавлять'}</h2>
<CloseButton type="button" onClick={() => setShowExpanded('')}>
×
</CloseButton>
</Header>
<FormGroup>
<TagsWrap>
{((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).map(tag => (
<Tag key={tag} $type={showExpanded}>
{tag}
<button type="button"
onClick={() => (showExpanded == 'favorite' ? handleFavoriteChange : handleAvoidChange)(
((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).filter(item => item != tag)
)}>
×
</button>
</Tag>
))}
</TagsWrap>
</FormGroup>
</Content>
) : (
<Content onClick={event => event.stopPropagation()}> <Content onClick={event => event.stopPropagation()}>
<Header> <Header>
<h2>{user ? 'Редактировать человека' : 'Добавить человека'}</h2> <h2>{user ? 'Редактировать человека' : 'Добавить человека'}</h2>
@ -180,12 +206,13 @@ export function PersonModal({
<PrefsGrid> <PrefsGrid>
<TagsInput <TagsInput
title="Запрещённые" title="Не добавлять"
type="avoid" type="avoid"
value={form.avoid} value={form.avoided}
availableTags={availableTags} availableTags={availableTags}
excludedTags={form.favorite} excludedTags={form.favorite}
onChange={handleAvoidChange} onChange={handleAvoidChange}
onExpand={() => setShowExpanded('avoid')}
/> />
<TagsInput <TagsInput
@ -193,8 +220,9 @@ export function PersonModal({
type="favorite" type="favorite"
value={form.favorite} value={form.favorite}
availableTags={availableTags} availableTags={availableTags}
excludedTags={form.avoid} excludedTags={form.avoided}
onChange={handleFavoriteChange} onChange={handleFavoriteChange}
onExpand={() => setShowExpanded('favorite')}
/> />
</PrefsGrid> </PrefsGrid>
@ -202,6 +230,7 @@ export function PersonModal({
Сохранить Сохранить
</SaveButton> </SaveButton>
</Content> </Content>
)}
</Overlay> </Overlay>
); );
} }
@ -238,7 +267,7 @@ const Header = styled.div`
h2 { h2 {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: ${({ theme }) => theme.colors.darkGreen}; color: ${({theme}) => theme.colors.darkGreen};
} }
`; `;
@ -251,7 +280,7 @@ const CloseButton = styled.button`
border: 0; border: 0;
&:hover { &:hover {
color: ${({ theme }) => theme.colors.darkGreen}; color: ${({theme}) => theme.colors.darkGreen};
} }
`; `;
@ -262,7 +291,7 @@ const FormGroup = styled.div`
label { label {
display: block; display: block;
font-size: 14px; font-size: 14px;
color: ${({ theme }) => theme.colors.darkGreen}; color: ${({theme}) => theme.colors.darkGreen};
margin-bottom: 6px; margin-bottom: 6px;
} }
@ -276,7 +305,7 @@ const FormGroup = styled.div`
transition: 0.2s; transition: 0.2s;
&:focus { &:focus {
border-color: ${({ theme }) => theme.colors.green}; border-color: ${({theme}) => theme.colors.green};
} }
} }
`; `;
@ -298,7 +327,7 @@ const FormRow = styled.div`
const Hint = styled.span` const Hint = styled.span`
display: block; display: block;
font-size: 12px; font-size: 12px;
color: ${({ theme }) => theme.colors.gray}; color: ${({theme}) => theme.colors.gray};
margin-top: 4px; margin-top: 4px;
`; `;
@ -311,13 +340,13 @@ const GenderToggles = styled.div`
const GenderButton = styled.button<{ $active: boolean }>` const GenderButton = styled.button<{ $active: boolean }>`
flex: 1; flex: 1;
padding: 10px; padding: 10px;
border: 1px solid ${({ $active }) => ($active ? '#e9f6d9' : '#e1e1e1')}; border: 1px solid ${({$active}) => ($active ? '#e9f6d9' : '#e1e1e1')};
border-radius: 8px; border-radius: 8px;
background: ${({ $active }) => ($active ? '#e9f6d9' : '#ffffff')}; background: ${({$active}) => ($active ? '#e9f6d9' : '#ffffff')};
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
transition: 0.2s; transition: 0.2s;
font-weight: ${({ $active }) => ($active ? 500 : 400)}; font-weight: ${({$active}) => ($active ? 500 : 400)};
`; `;
const PrefsGrid = styled.div` const PrefsGrid = styled.div`
@ -326,6 +355,7 @@ const PrefsGrid = styled.div`
margin-bottom: 20px; margin-bottom: 20px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
padding-top: 16px; padding-top: 16px;
min-height: 200px;
@media (max-width: 600px) { @media (max-width: 600px) {
flex-direction: column; flex-direction: column;
@ -335,7 +365,7 @@ const PrefsGrid = styled.div`
const SaveButton = styled.button` const SaveButton = styled.button`
width: 100%; width: 100%;
padding: 12px; padding: 12px;
background: ${({ theme }) => theme.colors.green}; background: ${({theme}) => theme.colors.green};
color: #fff; color: #fff;
border: none; border: none;
border-radius: 12px; border-radius: 12px;

View File

@ -8,6 +8,7 @@ interface TagsInputProps {
availableTags: string[]; availableTags: string[];
excludedTags: string[]; excludedTags: string[];
onChange: (tags: string[]) => void; onChange: (tags: string[]) => void;
onExpand: () => void;
} }
export function TagsInput({ export function TagsInput({
@ -17,6 +18,7 @@ export function TagsInput({
availableTags, availableTags,
excludedTags, excludedTags,
onChange, onChange,
onExpand,
}: TagsInputProps) { }: TagsInputProps) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
@ -69,7 +71,7 @@ export function TagsInput({
)} )}
<TagsWrap> <TagsWrap>
{value.map(tag => ( {value.slice(0, 4).map(tag => (
<Tag key={tag} $type={type}> <Tag key={tag} $type={type}>
{tag} {tag}
<button type="button" onClick={() => removeTag(tag)}> <button type="button" onClick={() => removeTag(tag)}>
@ -77,11 +79,53 @@ export function TagsInput({
</button> </button>
</Tag> </Tag>
))} ))}
{value.length > 4 && <TagCollapse $type={'favorite'} $collapsed={true} onClick={() => onExpand()}>
+{value.length - 4}
<svg viewBox="0 0 24 24" fill="none">
<polyline points="18 15 12 9 6 15" />
</svg>
</TagCollapse>}
</TagsWrap> </TagsWrap>
</Column> </Column>
); );
} }
const Overlay = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
`;
const Content = styled.div`
background: #ffffff;
border-radius: 24px;
width: 100%;
max-width: 440px;
padding: 24px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
max-height: 95vh;
overflow-y: auto;
`;
const Header = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
font-size: 20px;
font-weight: 600;
color: ${({ theme }) => theme.colors.darkGreen};
}
`;
const Column = styled.div` const Column = styled.div`
flex: 1; flex: 1;
position: relative; position: relative;
@ -138,13 +182,13 @@ const Autocomplete = styled.div`
} }
`; `;
const TagsWrap = styled.div` export const TagsWrap = styled.div`
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
`; `;
const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>` export const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>`
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 4px 10px; padding: 4px 10px;
@ -167,3 +211,26 @@ const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>`
} }
} }
`; `;
const TagCollapse = styled(Tag)<{$collapsed: boolean}>`
border: 1px solid #E7EDE7;
background: transparent;
color: ${({ theme }) => theme.colors.darkGreen};
cursor: pointer;
transition: 0.2s;
&:hover {
background-color: #f0f4c3;
}
svg {
width: 18px;
height: 18px;
stroke: ${({ theme }) => theme.colors.darkGreen};
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 0.3s ease;
transform: ${({ $collapsed }) => ($collapsed ? 'rotate(180deg)' : 'none')};
}
`

View File

@ -0,0 +1,162 @@
import React, { useState, ChangeEvent } from 'react';
import styled from 'styled-components';
// --- Типы ---
interface SliderProps {
min?: number;
max?: number;
step?: number;
defaultValue?: number;
onChange?: (value: number) => void;
}
// --- Стили (Styled-Components) ---
const Container = styled.div`
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
`;
const SliderWrapper = styled.div`
position: relative;
width: 100%;
height: 24px; // Высота для кликабельной области бегунка
display: flex;
align-items: center;
`;
const Track = styled.div`
position: relative;
width: 100%;
height: 12px;
background-color: #f0f7ec; // Светло-зеленый фон
border-radius: 6px;
cursor: pointer;
`;
const Fill = styled.div<{ $percent: number }>`
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: #5b9e30; // Зеленый цвет заполнения
width: ${({ $percent }) => $percent}%;
// Скругление только слева, если не достигнут максимум
border-radius: ${({ $percent }) =>
$percent >= 100 ? '6px' : $percent <= 0 ? '6px' : '6px 0 0 6px'
};
pointer-events: none;
`;
const Thumb = styled.div<{ $percent: number }>`
position: absolute;
top: 50%;
left: ${({ $percent }) => $percent}%;
transform: translate(-50%, -50%);
width: 24px;
height: 24px;
background-color: #5b9e30;
border-radius: 50%;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
// Мобильная версия: немного увеличиваем для удобного нажатия пальцем
@media (max-width: 768px) {
width: 28px;
height: 28px;
}
`;
// Прозрачный нативный инпут для обработки перетаскивания и кликов
const RangeInput = styled.input`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
margin: 0;
`;
const LabelsContainer = styled.div`
position: relative;
width: 100%;
height: 30px;
margin-top: 8px;
`;
const Label = styled.span`
position: absolute;
font-size: 16px;
color: #333333;
@media (max-width: 768px) {
font-size: 14px;
}
`;
const LeftLabel = styled(Label)`
left: 0;
transform: translateX(0);
`;
const RightLabel = styled(Label)`
right: 0;
transform: translateX(0);
`;
const CenterLabel = styled(Label)<{ $percent: number }>`
left: ${({ $percent }) => $percent}%;
transform: translateX(-50%);
`;
// --- Основной Компонент ---
const CustomSlider: React.FC<SliderProps> = ({
min = 30,
max = 170,
step = 10,
defaultValue = 90,
onChange,
}) => {
const [value, setValue] = useState<number>(defaultValue);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const newValue = Number(e.target.value);
setValue(newValue);
if (onChange) onChange(newValue);
};
// Процент заполнения трека (от 0% до 100%)
const percent = ((value - min) / (max - min)) * 100;
return (
<Container>
<SliderWrapper>
<Track>
<Fill $percent={percent} />
<Thumb $percent={percent} />
<RangeInput
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={handleChange}
aria-label="Процентное значение"
/>
</Track>
</SliderWrapper>
<LabelsContainer>
<LeftLabel>{min}%</LeftLabel>
{(percent > 10) && (percent < 90) ? (<CenterLabel $percent={percent}>{value}%</CenterLabel>) : null}
<RightLabel>{max}%</RightLabel>
</LabelsContainer>
</Container>
);
};
export default CustomSlider;

View File

@ -0,0 +1,160 @@
import React from 'react';
import styled, { css } from 'styled-components';
export type InputState = 'default' | 'success' | 'error' | 'disabled';
interface TextInputProps {
label?: string;
value: string;
onChange: (value: string) => void;
state?: InputState;
placeholder?: string;
allowClear?: boolean;
onClear?: () => void;
}
// --- Стили ---
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
font-family: Arial, sans-serif;
`;
const Label = styled.label<{ $state: InputState }>`
font-size: 16px;
color: #000;
margin-bottom: 4px;
${({ $state }) => $state === 'disabled' && css`
color: #9e9e9e;
`}
`;
const Wrapper = styled.div<{ $state: InputState; $allowClear: boolean }>`
position: relative;
display: flex;
align-items: center;
border-radius: 6px;
background: #fff;
transition: border-color 0.2s;
/* Default State */
border: 1px solid #e0e0e0;
&:focus-within {
border-color: #b3b3b3;
}
/* Success State */
${({ $state }) => $state === 'success' && css`
border: 2px solid #4CAF50;
`}
/* Error State */
${({ $state }) => $state === 'error' && css`
border: 2px solid #F44336;
`}
/* Disabled State */
${({ $state }) => $state === 'disabled' && css`
background-color: #f2f2f2;
border: 1px solid #e0e0e0;
`}
/* Padding adjustments for clear button */
${({ $allowClear }) => $allowClear && css`
input {
padding-right: 40px;
}
`}
`;
const Input = styled.input<{ $state: InputState }>`
width: 100%;
padding: 12px 16px;
border: none;
outline: none;
background: transparent;
font-size: 16px;
color: #333;
&::placeholder {
color: #999;
}
${({ $state }) => $state === 'disabled' && css`
color: #9e9e9e;
cursor: not-allowed;
`}
`;
const ClearButton = styled.button<{ $state: InputState }>`
position: absolute;
right: 12px;
background: none;
border: none;
font-size: 20px;
color: #9e9e9e;
cursor: pointer;
padding: 0;
line-height: 1;
&:hover {
color: #333;
}
${({ $state }) => $state === 'disabled' && css`
cursor: not-allowed;
color: #c0c0c0;
`}
`;
export const TextInput: React.FC<TextInputProps> = ({
label,
value,
onChange,
state = 'default',
placeholder = 'Текст',
allowClear = false,
onClear,
}) => {
const isDisabled = state === 'disabled';
const handleClear = () => {
if (onClear) {
onClear();
} else {
onChange('');
}
};
return (
<Container>
{label && <Label $state={state}>{label}</Label>}
<Wrapper $state={state} $allowClear={allowClear}>
<Input
type="text"
value={value}
placeholder={placeholder}
disabled={isDisabled}
$state={state}
onChange={(e) => onChange(e.target.value)}
/>
{allowClear && (
<ClearButton
type="button"
onClick={handleClear}
disabled={isDisabled}
$state={state}
aria-label="Очистить"
>
×
</ClearButton>
)}
</Wrapper>
</Container>
);
};

View File

@ -2,6 +2,7 @@ import {Dispatch, SetStateAction, useEffect, useRef} from 'react';
import { getCartUpdates } from '../api/promoApi'; import { getCartUpdates } from '../api/promoApi';
import { normalizeProductImage } from '../utils/product'; import { normalizeProductImage } from '../utils/product';
import type { Product } from '../types/cart'; import type { Product } from '../types/cart';
import {useGlobalZustandState} from "./useGlobalZustandState";
interface UseCartPollingParams { interface UseCartPollingParams {
enabled: boolean; enabled: boolean;
@ -14,22 +15,21 @@ interface UseCartPollingParams {
let missing_scenarios = [ let missing_scenarios = [
[ [
[10_000, 'Ищем Брока...'], [10_000, 'Ищем Брока...'],
[30_000, 'Брок сбежал из супермаркета...'], [10_000, 'Мы всё ещё пытаемся его найти...'],
[30_000, 'Мы всё ещё пытаемся его найти...'],
[10_000, 'Может быть он в колбасном отделе?'], [10_000, 'Может быть он в колбасном отделе?'],
[10_000, 'Или в своём любимом?'], [10_000, 'Или в своём любимом?'],
[10_000, 'Какой у Брока любимый отдел?'], [10_000, 'Какой у Брока любимый отдел?'],
[10_000, 'Если это прятки, то я проиграл, выходи'], [10_000, 'Если это прятки, то я проиграл, выходи'],
[15_000, 'Я сдаюсь, перезагрузите страницу?'], [10_000, 'Я сдаюсь, перезагрузите страницу?'],
], ],
[ [
[10_000, 'Брок у полки с соком...'], [10_000, 'Брок у полки с соком...'],
[20_000, 'Он выбирает апельсиновый...'], [10_000, 'Он выбирает апельсиновый...'],
[15_000, 'Вспомнил, что любит яблочный...'], [10_000, 'Вспомнил, что любит яблочный...'],
[25_000, 'Теперь сравнивает состав...'], [10_000, 'Теперь сравнивает состав...'],
[20_000, 'Сока нет, есть нектар...'], [10_000, 'Сока нет, есть нектар...'],
[15_000, 'Брок возмущён...'], [10_000, 'Брок возмущён...'],
[20_000, 'Он уходит с водой...'], [10_000, 'Он уходит с водой...'],
[10_000, 'Классика. Перезагрузите страницу?'] [10_000, 'Классика. Перезагрузите страницу?']
], ],
[ [
@ -63,24 +63,24 @@ let missing_scenarios = [
[10_000, 'Брок отвлёкся на робот-пылесос...'], [10_000, 'Брок отвлёкся на робот-пылесос...'],
[10_000, 'Перезагрузите страницу?'], [10_000, 'Перезагрузите страницу?'],
], ],
// [
// [10_000, 'Брок встал в очередь на кассу...'],
// [10_000, 'У Брока в корзине один огурец и лотерейный билет...'],
// [10_000, 'Брок завис на вопросе: «Пакет нужен?»'],
// [10_000, 'Брок спрашивает: «А у вас есть пакет с динозаврами?»'],
// [10_000, 'Брок утверждает, что видел такие пакеты в рекламе 2007 года...'],
// [10_000, 'Менеджер ищет пакеты с динозаврами на складе...'],
// [10_000, 'Это надолго, нужна перезагрузка'],
// ],
[ [
[10_000, 'Брок встал в очередь на кассу...'], [10_000, 'Брок обнаружил тележку с крутящимся колесом...'],
[10_000, 'У Брока в корзине один огурец и лотерейный билет...'], [10_000, 'Теперь он использует её как скейтборд...'],
[10_000, 'Брок завис на вопросе: «Пакет нужен?»'], [10_000, 'Брок проехал мимо отдела напитков...'],
[10_000, 'Брок спрашивает: «А у вас есть пакет с динозаврами?»'], [10_000, 'Сотрудник делает замечание...'],
[10_000, 'Брок утверждает, что видел такие пакеты в рекламе 2007 года...'], [10_000, 'Брок не слышит — он в наушниках...'],
[10_000, 'Менеджер ищет пакеты с динозаврами на складе...'], [10_000, 'Брок врезался в пирамиду из консервов...'],
[10_000, 'Это надолго, нужна перезагрузка'], [10_000, 'Консервы разлетелись'],
], [10_000, 'Брок помогает собирать, напевая оперу...'],
[
[10_000, 'Брок обнаружил тележку с крутящимся передним колесом...'],
[20_000, 'Теперь он использует её как скейтборд...'],
[30_000, 'Брок проехал мимо отдела напитков...'],
[15_000, 'Сотрудник делает замечание...'],
[15_000, 'Брок не слышит — он в наушниках...'],
[20_000, 'Брок врезался в пирамиду из консервов...'],
[25_000, 'Консервы разлетелись'],
[15_000, 'Брок помогает собирать, напевая оперу...'],
[10_000, 'Импровизированный концерт у полки с томатами.'], [10_000, 'Импровизированный концерт у полки с томатами.'],
[10_000, 'Перезагрузите страницу?'], [10_000, 'Перезагрузите страницу?'],
], ],
@ -116,21 +116,25 @@ export function useCartPolling({
setDisplayText, setDisplayText,
}: UseCartPollingParams) { }: UseCartPollingParams) {
const timeoutRef = useRef<number | null>(null); const timeoutRef = useRef<number | null>(null);
const {updateFromResponse, markAsComplete, markAsIncomplete} = useGlobalZustandState((state) => state);
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
setDisplayText('Брок бегает по супермаркету...')
let cancelled = false; let cancelled = false;
let missing_timeout: ReturnType<typeof setTimeout> | null = null; let missing_timeout: ReturnType<typeof setTimeout> | null = null;
let missing_scenario_index = Math.round(Math.random() * missing_scenarios.length); let missing_scenario_index = Math.round(Math.random() * (missing_scenarios.length - 1));
let missing_scenario_element = -1; let missing_scenario_element = -1;
const timeoutFunction = () => { const timeoutFunction = () => {
console.log(missing_scenario_index);
missing_scenario_element++; missing_scenario_element++;
if (missing_scenario_element < missing_scenarios[missing_scenario_index].length) {
setDisplayText(missing_scenarios[missing_scenario_index][missing_scenario_element][1]) setDisplayText(missing_scenarios[missing_scenario_index][missing_scenario_element][1])
missing_timeout = setTimeout(timeoutFunction, missing_scenarios[missing_scenario_index][missing_scenario_element][0]); missing_timeout = setTimeout(timeoutFunction, missing_scenarios[missing_scenario_index][missing_scenario_element][0]);
}
}; };
async function tick() { async function tick() {
@ -148,29 +152,32 @@ export function useCartPolling({
timeoutFunction(); timeoutFunction();
}, 10_000); }, 10_000);
} }
setDisplayText('Брок бегает по супермаркету...')
timeoutRef.current = window.setTimeout(tick, 2000); timeoutRef.current = window.setTimeout(tick, 2000);
return; return;
} else if (missing_timeout) { } else if (missing_timeout) {
clearTimeout(missing_timeout); clearTimeout(missing_timeout);
} }
missing_scenario_element = -1;
if (update.status === 'complete') { if (update.status === 'complete') {
onLoadingChange(false); onLoadingChange(false);
onComplete(); onComplete();
updateFromResponse(true, update.payload?.excess || 0, update.payload?.precision || 0);
return; return;
} }
if (update.status === 'clear') { if (update.status === 'clear') {
const nextProducts = update.payload?.chunk?.map(product => normalizeProductImage(product)) ?? []; const nextProducts = update.payload?.chunk?.map(product => normalizeProductImage(product)) ?? [];
timeoutRef.current = window.setTimeout(tick, 3000); timeoutRef.current = window.setTimeout(tick, 3000);
markAsIncomplete();
onProductsChange(nextProducts); onProductsChange(nextProducts);
} }
if (update.status === 'data') { if (update.status === 'data') {
const chunk = update.payload?.chunk?.map(product => normalizeProductImage(product)) ?? []; const chunk = update.payload?.chunk?.map(product => normalizeProductImage(product)) ?? [];
updateFromResponse(false, update.payload?.excess || 0, update.payload?.precision || 0);
onProductsChange(current => { onProductsChange((current: Product[]) => {
const next = [...current]; const next = [...current];
chunk.forEach(product => { chunk.forEach(product => {
@ -203,9 +210,12 @@ export function useCartPolling({
return () => { return () => {
cancelled = true; cancelled = true;
if (missing_timeout) {
window.clearTimeout(missing_timeout);
}
if (timeoutRef.current) { if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current); window.clearTimeout(timeoutRef.current);
} }
}; };
}, [enabled, onComplete, onLoadingChange, onProductsChange]); }, [enabled, onComplete, onLoadingChange, onProductsChange, updateFromResponse, markAsComplete, markAsIncomplete]);
} }

View File

@ -0,0 +1,26 @@
import { create } from 'zustand';
type AppState = {
isComplete: boolean;
excess: number;
precision: number;
showFullRefreshButton: boolean;
setInitial: (isComplete: boolean, excess: number, precision: number) => void;
updateFromResponse: (isComplete: boolean | undefined, excess: number | undefined, precision: number | undefined) => void;
markAsComplete: () => void;
markAsIncomplete: () => void;
setFullRefreshButton: (b: boolean) => void;
};
export const useGlobalZustandState = create<AppState>((set) => ({
isComplete: false,
showFullRefreshButton: false,
excess: 0,
precision: 0,
setInitial: (isComplete, excess, precision) => set({ isComplete, excess, precision }),
updateFromResponse: (isComplete, excess, precision) => set({ isComplete, excess, precision }),
markAsComplete: () => set({isComplete: true}),
markAsIncomplete: () => set({isComplete: false}),
setFullRefreshButton: (showFullRefreshButton: boolean) => set({showFullRefreshButton}),
}));

View File

@ -7,6 +7,6 @@ export interface User {
age: number; age: number;
weight?: number; weight?: number;
height?: number; height?: number;
avoid: string[]; avoided: string[];
favorite: string[]; favorite: string[];
} }

View File

@ -2352,7 +2352,7 @@ function wholeRender(initialData) {
favTags.push(el.textContent.trim().replace('×', '').trim()); favTags.push(el.textContent.trim().replace('×', '').trim());
}); });
const personData = { name, age, weight, height, gender, avoid: forbTags, favorite: favTags }; const personData = { name, age, weight, height, gender, avoided: forbTags, favorite: favTags };
if (editingIndex !== null) { if (editingIndex !== null) {
people[editingIndex] = personData; people[editingIndex] = personData;
editingIndex = null; editingIndex = null;