diff --git a/images/broc-finita-la-commedia.png b/images/broc-finita-la-commedia.png new file mode 100644 index 0000000..83f3fe5 Binary files /dev/null and b/images/broc-finita-la-commedia.png differ diff --git a/images/broc-gifts.png b/images/broc-gifts.png new file mode 100644 index 0000000..3ede1bd Binary files /dev/null and b/images/broc-gifts.png differ diff --git a/images/broc-man.png b/images/broc-man.png index 7a826d8..40c566c 100644 Binary files a/images/broc-man.png and b/images/broc-man.png differ diff --git a/images/broc-woman.png b/images/broc-woman.png index 21bb0c7..1114ba5 100644 Binary files a/images/broc-woman.png and b/images/broc-woman.png differ diff --git a/images/leaf.svg b/images/leaf.svg new file mode 100644 index 0000000..f619828 --- /dev/null +++ b/images/leaf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/package-lock.json b/package-lock.json index f65a472..d00b1da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "http-server": "^14.1.1", "react": "^19.2.8", "react-dom": "^19.2.8", - "styled-components": "^6.5.3" + "styled-components": "^6.5.3", + "zustand": "^5.0.15" }, "devDependencies": { "@minify-html/node": "^0.18.1", @@ -507,7 +508,7 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3528,6 +3529,35 @@ "funding": { "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 + } + } } } } diff --git a/package.json b/package.json index 91c031c..32281e3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "http-server": "^14.1.1", "react": "^19.2.8", "react-dom": "^19.2.8", - "styled-components": "^6.5.3" + "styled-components": "^6.5.3", + "zustand": "^5.0.15" }, "devDependencies": { "@minify-html/node": "^0.18.1", diff --git a/src/App.tsx b/src/App.tsx index 0e464bd..0fd03b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,8 @@ 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"; export function App() { const [initialData, setInitialData] = useState(null); @@ -18,13 +20,26 @@ export function App() { const [isCartComplete, setIsCartComplete] = useState(false); const [isCartLoading, setIsCartLoading] = useState(true); const [openSection, setOpenSection] = useState('family'); + const [needRefresh, setNeedRefresh] = useState(true); + const [isFullnessHighlighted, setIsFullnessHighlighted] = useState(false); - useEffect(() => { + const {setInitial} = useGlobalZustandState((state) => state); + + + const loadInitialState = async () => { loadInitialData().then(data => { setInitialData(data); 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 categories = initialData?.available_groups ?? []; @@ -32,18 +47,24 @@ export function App() { const days = initialData?.days ?? 7; const handleDaysChange = useCallback(async (nextDays: number) => { - setInitialData(current => current ? { ...current, days: nextDays } : current); await setOption('days', nextDays); - }, []); + loadInitialState(); + }, [loadInitialState]); const handleUsersChange = useCallback(async (nextUsers: User[]) => { - setInitialData(current => current ? { ...current, users: nextUsers } : current); await setOption('users', nextUsers); - }, []); + loadInitialState(); + }, [loadInitialState]); const handleCategoriesChange = useCallback((nextCategories: string[]) => { 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(() => { if (!initialData) { @@ -54,8 +75,8 @@ export function App() { -

Автокорзина

-

Собираем продукты под ваши настройки

+

Семейные покупки

+

Заботимся о балансе нутриентов

{ + 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); @@ -111,7 +144,7 @@ export function App() { 0 ? `Выбраны ${selectedCategories.length}` @@ -128,6 +161,37 @@ export function App() { onSaved={() => undefined} /> + + + } + description={'Чем меньше значение, тем меньше продуктов и калорий в корзине'} + isHighlighted={isFullnessHighlighted} + isOpen={openSection === 'fullness'} + onToggle={() => { + if (openSection !== 'fullness') { + setIsFullnessHighlighted(true); + } + setOpenSection(openSection === 'fullness' ? '' : 'fullness') + }} + > + {console.log(percent)}} /> + +
+ + + + + +
+
+ Процент влияет на количество калорий, базовый рацион — 100%, если часть еды покупается и съедается за + пределами дома — подвиньте ползунок влево, если вы ждёте гостей — вправо +
+
+
@@ -253,4 +317,21 @@ const FilterTitle = styled.div` const SettingsPanel = styled.div` border: 1px solid ${({ theme }) => theme.colors.border}; border-radius: 16px; -`; \ No newline at end of file +`; + +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; + } +` \ No newline at end of file diff --git a/src/api/promoApi.ts b/src/api/promoApi.ts index 293e405..008470a 100644 --- a/src/api/promoApi.ts +++ b/src/api/promoApi.ts @@ -109,6 +109,14 @@ export function clearGuestCookie(): Promise { }); } +export function subscribeOnFinish(email: string): Promise { + return requestWithRefresh(apiUrl('/api/promo/subscribe'), { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ email }), + }); +} + const fallbackInitialData: InitialData = { cart: [], users: [], diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx index 6098c42..7a3a0b4 100644 --- a/src/components/Accordion/Accordion.tsx +++ b/src/components/Accordion/Accordion.tsx @@ -127,7 +127,7 @@ const ArrowWrap = styled.div<{ $open: boolean }>` `; const Content = styled.div<{ $open: boolean }>` - max-height: ${({ $open }) => ($open ? '500px' : '0')}; + max-height: ${({ $open }) => ($open ? 'auto' : '0')}; overflow: hidden; transition: max-height 0.4s ease, padding 0.3s ease; `; diff --git a/src/components/Cart/Cart.tsx b/src/components/Cart/Cart.tsx index 6fc0fdc..0c773a1 100644 --- a/src/components/Cart/Cart.tsx +++ b/src/components/Cart/Cart.tsx @@ -8,6 +8,7 @@ import type { Product } from '../../types/cart'; import { CartItem } from './CartItem'; import { CartFooter } from './CartFooter'; import { ProductModal } from './ProductModal'; +import {useGlobalZustandState} from "../../hooks/useGlobalZustandState"; interface CartProps { products: Product[]; @@ -35,6 +36,8 @@ export function Cart({ const [selectedProduct, setSelectedProduct] = useState(null); const [processingProductName, setProcessingProductName] = useState(null); + const {showFullRefreshButton} = useGlobalZustandState((state) => state); + const visibleProducts = products.slice(0, visibleCount); const remaining = products.length - visibleProducts.length; @@ -82,6 +85,7 @@ export function Cart({
Ваша корзина на {days} {declension(days, ['день', 'дня', 'дней'])} + {showFullRefreshButton && Начать заново} @@ -141,13 +145,47 @@ export function Cart({ setSelectedProduct(null)} - onReplace={handleReplace} - onAnother={() => undefined} + onReplace={(product) => { + 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` display: flex; justify-content: space-between; @@ -155,6 +193,7 @@ const Summary = styled.div` margin-bottom: 24px; flex-shrink: 0; width: 100%; + position: relative; `; const SummaryTitle = styled.div` @@ -207,6 +246,7 @@ const Loader = styled.div<{ $hidden: boolean, "data-display-text": string }>` color: transparent; background-clip: text; position: absolute; + top:32px; visibility: ${({ $hidden }) => ($hidden ? 'hidden' : 'visible')}; &:before, diff --git a/src/components/Cart/CartFooter.tsx b/src/components/Cart/CartFooter.tsx index 3837461..9cbc315 100644 --- a/src/components/Cart/CartFooter.tsx +++ b/src/components/Cart/CartFooter.tsx @@ -1,5 +1,7 @@ import styled from 'styled-components'; import {BagIcon} from "../Icons/BagIcon"; +import {useState} from "react"; +import {OrderPopup} from "./OrderPopup"; interface CartFooterProps { @@ -8,6 +10,8 @@ interface CartFooterProps { } export function CartFooter({ isVisible, totalPrice }: CartFooterProps) { + const [showCompletePopup, setShowCompletePopup] = useState(false); + return (
@@ -23,9 +27,12 @@ export function CartFooter({ isVisible, totalPrice }: CartFooterProps) {
- + setShowCompletePopup(true)}> Оформить заказ + {showCompletePopup && <> + + } ); } diff --git a/src/components/Cart/CartItem.tsx b/src/components/Cart/CartItem.tsx index 37a2257..230759f 100644 --- a/src/components/Cart/CartItem.tsx +++ b/src/components/Cart/CartItem.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; +import {useState} from 'react'; import styled from 'styled-components'; -import { getProductAmount } from '../../utils/product'; -import type { Product } from '../../types/cart'; +import {getProductAmount} from '../../utils/product'; +import type {Product} from '../../types/cart'; interface CartItemProps { product: Product; @@ -23,7 +23,7 @@ export function CartItem({ return ( - + @@ -42,20 +42,24 @@ export function CartItem({ {isMenuOpen && ( - -
    -
  • - -
  • -
  • - -
  • -
-
+ <> + setIsMenuOpen(false)}> + + +
    +
  • + +
  • +
  • + +
  • +
+
+ )}
@@ -64,13 +68,14 @@ export function CartItem({ const Item = styled.li<{ $processing: boolean }>` display: flex; - align-items: center; + flex-direction: row; + align-items: stretch; padding: 16px 0; border-bottom: 1px solid #f0f0f0; position: relative; transition: opacity 0.4s, transform 0.4s; - opacity: ${({ $processing }) => ($processing ? 0.5 : 1)}; - pointer-events: ${({ $processing }) => ($processing ? 'none' : 'auto')}; + opacity: ${({$processing}) => ($processing ? 0.5 : 1)}; + pointer-events: ${({$processing}) => ($processing ? 'none' : 'auto')}; `; const ItemImage = styled.button` @@ -95,22 +100,28 @@ const ItemImage = styled.button` `; const ItemDetails = styled.div` - flex: 1; + flex: 1 1 100%; + display: flex; + justify-content: space-between; + flex-direction: column; `; const ItemName = styled.div` font-size: 15px; - color: ${({ theme }) => theme.colors.darkGreen}; + color: ${({theme}) => theme.colors.darkGreen}; margin-bottom: 4px; `; const ItemPrice = styled.div` font-size: 14px; - color: ${({ theme }) => theme.colors.gray}; + color: ${({theme}) => theme.colors.gray}; `; const MenuWrapper = styled.div` position: relative; + width: 26px; + display: flex; + align-self: flex-start; `; const MenuButton = styled.button` @@ -123,23 +134,34 @@ const MenuButton = styled.button` letter-spacing: 2px; border-radius: 4px; transition: 0.2s; + position: absolute; + top: 0; right: 0; &:hover { 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` position: absolute; right: 0; top: 100%; background: #ffffff; - border: 1px solid #eee; 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; - padding: 6px 0; + padding: 10px; z-index: 10; margin-top: 4px; @@ -151,15 +173,17 @@ const Dropdown = styled.div` width: 100%; padding: 10px 16px; font-size: 14px; - color: ${({ theme }) => theme.colors.darkGreen}; + color: ${({theme}) => theme.colors.darkGreen}; cursor: pointer; transition: 0.2s; background: transparent; border: 0; - text-align: left; + border-radius: 8px; + text-align: center; &:hover { - background: #f3f4f6; + background: #5EA12D; + color: #fff; } } `; \ No newline at end of file diff --git a/src/components/Cart/CelebrationCanvas.tsx b/src/components/Cart/CelebrationCanvas.tsx new file mode 100644 index 0000000..9554703 --- /dev/null +++ b/src/components/Cart/CelebrationCanvas.tsx @@ -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; // оттенок (0–360) + 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 = ({ + active = true, + duration = 3000, + particleCount = 150, + onComplete, + }) => { + const canvasRef = useRef(null); + const particlesRef = useRef([]); + const animationRef = useRef(undefined); + const startTimeRef = useRef(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 ( + + + + ); +}; + +export default CelebrationCanvas; \ No newline at end of file diff --git a/src/components/Cart/FinishPopupContent.tsx b/src/components/Cart/FinishPopupContent.tsx new file mode 100644 index 0000000..aeeb9fd --- /dev/null +++ b/src/components/Cart/FinishPopupContent.tsx @@ -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(""); + const [buttonContent, setButtonContent] = useState("Получить подарок"); + + return + +

Спасибо!

+

+ Вы помогли нам протестировать автокорзину. Сейчас сервис ещё разрабатывается, поэтому оформить заказ пока нельзя. + Оставьте адрес электронной почты, и после запуска мы подарим вам 3 бесплатные корзины. +

+ + { + if (buttonContent === 'Получить подарок') { + setButtonContent('...') + subscribeOnFinish(email).then((data) => { + if (data && data.status === 200) { + setButtonContent('Письмо отправлено') + } else { + setButtonContent('Возникла ошибка') + } + }).catch(() => { + setButtonContent('Возникла ошибка') + }) + } + }}>{buttonContent} + {createPortal(, document.body)} +
; +}; + +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; + } +`; \ No newline at end of file diff --git a/src/components/Cart/OrderPopup.tsx b/src/components/Cart/OrderPopup.tsx new file mode 100644 index 0000000..f7ba941 --- /dev/null +++ b/src/components/Cart/OrderPopup.tsx @@ -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 setShowCompletePopup(false)}> + event.stopPropagation()}> + setShowCompletePopup(false)}> + × + + {showFinal ? : + + Стоимость составления корзины + 199 ₽ + Включена в итоговую стоимость заказа + + setShowFinal(true)}>Продолжить + + + } + + +} + +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; +` \ No newline at end of file diff --git a/src/components/Cart/ProductModal.tsx b/src/components/Cart/ProductModal.tsx index d8a6b8c..96dce47 100644 --- a/src/components/Cart/ProductModal.tsx +++ b/src/components/Cart/ProductModal.tsx @@ -7,14 +7,14 @@ interface ProductModalProps { product: Product | null; onClose: () => void; onReplace: (product: Product) => void; - onAnother: (product: Product) => void; + onDelete: (product: Product) => void; } export function ProductModal({ product, onClose, onReplace, - onAnother, + onDelete, }: ProductModalProps) { if (!product) return null; @@ -43,10 +43,10 @@ export function ProductModal({ href="#" onClick={event => { event.preventDefault(); - onAnother(product); + onDelete(product); }} > - Ещё вариант + Удалить diff --git a/src/components/Categories/CategoriesSection.tsx b/src/components/Categories/CategoriesSection.tsx index ab011b2..71e419b 100644 --- a/src/components/Categories/CategoriesSection.tsx +++ b/src/components/Categories/CategoriesSection.tsx @@ -16,7 +16,7 @@ export function CategoriesSection({ onLocalChange, onSaved, }: CategoriesSectionProps) { - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(true); const [loadingCategories, setLoadingCategories] = useState([]); const maxVisibleCategories = 7; @@ -62,7 +62,7 @@ export function CategoriesSection({

Что добавить в корзину?

-

Выберите группы продуктов, которые хотите видеть в корзине

+

Выберите группы продуктов, которые должны обязательно попасть в корзину

diff --git a/src/components/Family/FamilySection.tsx b/src/components/Family/FamilySection.tsx index bdb96d2..3252e90 100644 --- a/src/components/Family/FamilySection.tsx +++ b/src/components/Family/FamilySection.tsx @@ -51,7 +51,11 @@ export function FamilySection({ {user.name} - {user.age} {declension(user.age, ['год', 'года', 'лет'])} + + {user.age} {declension(user.age, ['год', 'года', 'лет'])} + + {user.gender === 'm' ? 'Мужской' : 'Женский'} + Avatar} { - background-color: #B4AAFF; - } - - &:nth-child(n+2) ${() => Avatar} { background-color: #CEE0FF; } - &:nth-child(n+3) ${() => Avatar} { + &:nth-child(2n+2) ${() => Avatar} { background-color: #E6F696; } - &:nth-child(n+4) ${() => Avatar} { - background-color: #FFF488; + &:nth-child(3n+3) ${() => Avatar} { + background-color: #fff488; } - &:nth-child(n+5) ${() => Avatar} { - background-color: #FFD623; + &:nth-child(4n+4) ${() => Avatar} { + background-color: #ffd623; } `; @@ -179,6 +179,7 @@ const Name = styled.div` const Age = styled.div` font-size: 14px; + font-weight: 350; color: ${({ theme }) => theme.colors.gray}; `; diff --git a/src/components/Family/PersonModal.tsx b/src/components/Family/PersonModal.tsx index 11ae6ad..f10dc67 100644 --- a/src/components/Family/PersonModal.tsx +++ b/src/components/Family/PersonModal.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; +import {useEffect, useState} from 'react'; import styled from 'styled-components'; -import type { Gender, User } from '../../types/user'; -import { TagsInput } from './TagsInput'; +import type {Gender, User} from '../../types/user'; +import {Tag, TagsInput, TagsWrap} from './TagsInput'; interface PersonModalProps { isOpen: boolean; @@ -17,7 +17,7 @@ interface PersonForm { age: string; weight: string; height: string; - avoid: string[]; + avoided: string[]; favorite: string[]; } @@ -27,7 +27,7 @@ const defaultForm: PersonForm = { age: '', weight: '', height: '', - avoid: [], + avoided: [], favorite: [], }; @@ -39,6 +39,7 @@ export function PersonModal({ onSave, }: PersonModalProps) { const [form, setForm] = useState(defaultForm); + const [showExpanded, setShowExpanded] = useState<'' | 'avoid' | 'favorite'>(''); useEffect(() => { if (!isOpen) return; @@ -50,7 +51,7 @@ export function PersonModal({ age: String(user.age), weight: user.weight ? String(user.weight) : '', height: user.height ? String(user.height) : '', - avoid: user.avoid ?? [], + avoided: user.avoided ?? [], favorite: user.favorite ?? [], }); } else { @@ -70,7 +71,7 @@ export function PersonModal({ function handleAvoidChange(tags: string[]) { setForm(current => ({ ...current, - avoid: tags, + avoided: tags, favorite: current.favorite.filter(tag => !tags.includes(tag)), })); } @@ -79,7 +80,7 @@ export function PersonModal({ setForm(current => ({ ...current, favorite: tags, - avoid: current.avoid.filter(tag => !tags.includes(tag)), + avoided: current.avoided.filter(tag => !tags.includes(tag)), })); } @@ -103,105 +104,133 @@ export function PersonModal({ age, weight: form.weight ? Number(form.weight) : undefined, height: form.height ? Number(form.height) : undefined, - avoid: form.avoid, + avoided: form.avoided, favorite: form.favorite, }); } return ( - event.stopPropagation()}> -
-

{user ? 'Редактировать человека' : 'Добавить человека'}

- - × - -
- - - - updateForm('name', event.target.value)} - /> - - - - - - updateForm('gender', 'f')} - > - Женский - - updateForm('gender', 'm')} - > - Мужской - - - - - + {showExpanded ? ( + event.stopPropagation()}> +
+

{showExpanded == 'favorite' ? 'Любимые' : 'Не добавлять'}

+ setShowExpanded('')}> + × + +
- - updateForm('age', event.target.value)} - /> - от 7 лет + + {((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).map(tag => ( + + {tag} + + + ))} + +
+ ) : ( + event.stopPropagation()}> +
+

{user ? 'Редактировать человека' : 'Добавить человека'}

+ + × + +
- + updateForm('weight', event.target.value)} + type="text" + placeholder="Имя" + value={form.name} + onChange={event => updateForm('name', event.target.value)} /> - - updateForm('height', event.target.value)} - /> + + + updateForm('gender', 'f')} + > + Женский + + updateForm('gender', 'm')} + > + Мужской + + -
- - + + + + updateForm('age', event.target.value)} + /> + от 7 лет + - - + + + updateForm('weight', event.target.value)} + /> + - - Сохранить - -
+ + + updateForm('height', event.target.value)} + /> + + + + + setShowExpanded('avoid')} + /> + + setShowExpanded('favorite')} + /> + + + + Сохранить + + + )}
); } @@ -238,7 +267,7 @@ const Header = styled.div` h2 { font-size: 20px; font-weight: 600; - color: ${({ theme }) => theme.colors.darkGreen}; + color: ${({theme}) => theme.colors.darkGreen}; } `; @@ -251,7 +280,7 @@ const CloseButton = styled.button` border: 0; &:hover { - color: ${({ theme }) => theme.colors.darkGreen}; + color: ${({theme}) => theme.colors.darkGreen}; } `; @@ -262,7 +291,7 @@ const FormGroup = styled.div` label { display: block; font-size: 14px; - color: ${({ theme }) => theme.colors.darkGreen}; + color: ${({theme}) => theme.colors.darkGreen}; margin-bottom: 6px; } @@ -276,7 +305,7 @@ const FormGroup = styled.div` transition: 0.2s; &: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` display: block; font-size: 12px; - color: ${({ theme }) => theme.colors.gray}; + color: ${({theme}) => theme.colors.gray}; margin-top: 4px; `; @@ -311,13 +340,13 @@ const GenderToggles = styled.div` const GenderButton = styled.button<{ $active: boolean }>` flex: 1; padding: 10px; - border: 1px solid ${({ $active }) => ($active ? '#e9f6d9' : '#e1e1e1')}; + border: 1px solid ${({$active}) => ($active ? '#e9f6d9' : '#e1e1e1')}; border-radius: 8px; - background: ${({ $active }) => ($active ? '#e9f6d9' : '#ffffff')}; + background: ${({$active}) => ($active ? '#e9f6d9' : '#ffffff')}; font-size: 14px; cursor: pointer; transition: 0.2s; - font-weight: ${({ $active }) => ($active ? 500 : 400)}; + font-weight: ${({$active}) => ($active ? 500 : 400)}; `; const PrefsGrid = styled.div` @@ -326,6 +355,7 @@ const PrefsGrid = styled.div` margin-bottom: 20px; border-top: 1px solid #f0f0f0; padding-top: 16px; + min-height: 200px; @media (max-width: 600px) { flex-direction: column; @@ -335,7 +365,7 @@ const PrefsGrid = styled.div` const SaveButton = styled.button` width: 100%; padding: 12px; - background: ${({ theme }) => theme.colors.green}; + background: ${({theme}) => theme.colors.green}; color: #fff; border: none; border-radius: 12px; diff --git a/src/components/Family/TagsInput.tsx b/src/components/Family/TagsInput.tsx index 4c6cb58..c056f5d 100644 --- a/src/components/Family/TagsInput.tsx +++ b/src/components/Family/TagsInput.tsx @@ -8,6 +8,7 @@ interface TagsInputProps { availableTags: string[]; excludedTags: string[]; onChange: (tags: string[]) => void; + onExpand: () => void; } export function TagsInput({ @@ -17,6 +18,7 @@ export function TagsInput({ availableTags, excludedTags, onChange, + onExpand, }: TagsInputProps) { const [query, setQuery] = useState(''); @@ -69,7 +71,7 @@ export function TagsInput({ )} - {value.map(tag => ( + {value.slice(0, 4).map(tag => ( {tag} ))} + {value.length > 4 && onExpand()}> + +{value.length - 4} + + + + } ); } +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` flex: 1; position: relative; @@ -138,13 +182,13 @@ const Autocomplete = styled.div` } `; -const TagsWrap = styled.div` +export const TagsWrap = styled.div` display: flex; flex-wrap: wrap; gap: 6px; `; -const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>` +export const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>` display: inline-flex; align-items: center; padding: 4px 10px; @@ -166,4 +210,27 @@ const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>` opacity: 1; } } -`; \ No newline at end of file +`; + +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')}; + } +` \ No newline at end of file diff --git a/src/components/Fullness/FullnessSlider.tsx b/src/components/Fullness/FullnessSlider.tsx new file mode 100644 index 0000000..dfbaf57 --- /dev/null +++ b/src/components/Fullness/FullnessSlider.tsx @@ -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 = ({ + min = 30, + max = 170, + step = 10, + defaultValue = 90, + onChange, + }) => { + const [value, setValue] = useState(defaultValue); + + const handleChange = (e: ChangeEvent) => { + const newValue = Number(e.target.value); + setValue(newValue); + if (onChange) onChange(newValue); + }; + + // Процент заполнения трека (от 0% до 100%) + const percent = ((value - min) / (max - min)) * 100; + + return ( + + + + + + + + + + + {min}% + {(percent > 10) && (percent < 90) ? ({value}%) : null} + {max}% + + + ); +}; + +export default CustomSlider; \ No newline at end of file diff --git a/src/components/components/TextInput.tsx b/src/components/components/TextInput.tsx new file mode 100644 index 0000000..2d463f0 --- /dev/null +++ b/src/components/components/TextInput.tsx @@ -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 = ({ + label, + value, + onChange, + state = 'default', + placeholder = 'Текст', + allowClear = false, + onClear, + }) => { + const isDisabled = state === 'disabled'; + + const handleClear = () => { + if (onClear) { + onClear(); + } else { + onChange(''); + } + }; + + return ( + + {label && } + + + onChange(e.target.value)} + /> + + {allowClear && ( + + × + + )} + + + ); +}; \ No newline at end of file diff --git a/src/hooks/useCartPolling.ts b/src/hooks/useCartPolling.ts index c89a228..0939d7f 100644 --- a/src/hooks/useCartPolling.ts +++ b/src/hooks/useCartPolling.ts @@ -2,6 +2,7 @@ import {Dispatch, SetStateAction, useEffect, useRef} from 'react'; import { getCartUpdates } from '../api/promoApi'; import { normalizeProductImage } from '../utils/product'; import type { Product } from '../types/cart'; +import {useGlobalZustandState} from "./useGlobalZustandState"; interface UseCartPollingParams { enabled: boolean; @@ -14,22 +15,21 @@ interface UseCartPollingParams { let missing_scenarios = [ [ [10_000, 'Ищем Брока...'], - [30_000, 'Брок сбежал из супермаркета...'], - [30_000, 'Мы всё ещё пытаемся его найти...'], + [10_000, 'Мы всё ещё пытаемся его найти...'], [10_000, 'Может быть он в колбасном отделе?'], [10_000, 'Или в своём любимом?'], [10_000, 'Какой у Брока любимый отдел?'], [10_000, 'Если это прятки, то я проиграл, выходи'], - [15_000, 'Я сдаюсь, перезагрузите страницу?'], + [10_000, 'Я сдаюсь, перезагрузите страницу?'], ], [ [10_000, 'Брок у полки с соком...'], - [20_000, 'Он выбирает апельсиновый...'], - [15_000, 'Вспомнил, что любит яблочный...'], - [25_000, 'Теперь сравнивает состав...'], - [20_000, 'Сока нет, есть нектар...'], - [15_000, 'Брок возмущён...'], - [20_000, 'Он уходит с водой...'], + [10_000, 'Он выбирает апельсиновый...'], + [10_000, 'Вспомнил, что любит яблочный...'], + [10_000, 'Теперь сравнивает состав...'], + [10_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, 'Брок утверждает, что видел такие пакеты в рекламе 2007 года...'], + // [10_000, 'Менеджер ищет пакеты с динозаврами на складе...'], + // [10_000, 'Это надолго, нужна перезагрузка'], + // ], [ - [10_000, 'Брок встал в очередь на кассу...'], - [10_000, 'У Брока в корзине один огурец и лотерейный билет...'], - [10_000, 'Брок завис на вопросе: «Пакет нужен?»'], - [10_000, 'Брок спрашивает: «А у вас есть пакет с динозаврами?»'], - [10_000, 'Брок утверждает, что видел такие пакеты в рекламе 2007 года...'], - [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, 'Сотрудник делает замечание...'], + [10_000, 'Брок не слышит — он в наушниках...'], + [10_000, 'Брок врезался в пирамиду из консервов...'], + [10_000, 'Консервы разлетелись'], + [10_000, 'Брок помогает собирать, напевая оперу...'], [10_000, 'Импровизированный концерт у полки с томатами.'], [10_000, 'Перезагрузите страницу?'], ], @@ -116,21 +116,25 @@ export function useCartPolling({ setDisplayText, }: UseCartPollingParams) { const timeoutRef = useRef(null); + const {updateFromResponse, markAsComplete, markAsIncomplete} = useGlobalZustandState((state) => state); useEffect(() => { if (!enabled) return; + setDisplayText('Брок бегает по супермаркету...') + let cancelled = false; let missing_timeout: ReturnType | 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; const timeoutFunction = () => { - console.log(missing_scenario_index); missing_scenario_element++; - setDisplayText(missing_scenarios[missing_scenario_index][missing_scenario_element][1]) - missing_timeout = setTimeout(timeoutFunction, missing_scenarios[missing_scenario_index][missing_scenario_element][0]); + if (missing_scenario_element < missing_scenarios[missing_scenario_index].length) { + setDisplayText(missing_scenarios[missing_scenario_index][missing_scenario_element][1]) + missing_timeout = setTimeout(timeoutFunction, missing_scenarios[missing_scenario_index][missing_scenario_element][0]); + } }; async function tick() { @@ -148,29 +152,32 @@ export function useCartPolling({ timeoutFunction(); }, 10_000); } - setDisplayText('Брок бегает по супермаркету...') timeoutRef.current = window.setTimeout(tick, 2000); return; } else if (missing_timeout) { clearTimeout(missing_timeout); } + missing_scenario_element = -1; if (update.status === 'complete') { onLoadingChange(false); onComplete(); + updateFromResponse(true, update.payload?.excess || 0, update.payload?.precision || 0); return; } if (update.status === 'clear') { const nextProducts = update.payload?.chunk?.map(product => normalizeProductImage(product)) ?? []; timeoutRef.current = window.setTimeout(tick, 3000); + markAsIncomplete(); onProductsChange(nextProducts); } if (update.status === 'data') { 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]; chunk.forEach(product => { @@ -203,9 +210,12 @@ export function useCartPolling({ return () => { cancelled = true; + if (missing_timeout) { + window.clearTimeout(missing_timeout); + } if (timeoutRef.current) { window.clearTimeout(timeoutRef.current); } }; - }, [enabled, onComplete, onLoadingChange, onProductsChange]); + }, [enabled, onComplete, onLoadingChange, onProductsChange, updateFromResponse, markAsComplete, markAsIncomplete]); } \ No newline at end of file diff --git a/src/hooks/useGlobalZustandState.tsx b/src/hooks/useGlobalZustandState.tsx new file mode 100644 index 0000000..4c6aeea --- /dev/null +++ b/src/hooks/useGlobalZustandState.tsx @@ -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((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}), +})); \ No newline at end of file diff --git a/src/types/user.ts b/src/types/user.ts index 9b7a7fc..de63ddb 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -7,6 +7,6 @@ export interface User { age: number; weight?: number; height?: number; - avoid: string[]; + avoided: string[]; favorite: string[]; } \ No newline at end of file diff --git a/src_static/autocart.js b/src_static/autocart.js index 7cf6c26..1744d8e 100644 --- a/src_static/autocart.js +++ b/src_static/autocart.js @@ -2352,7 +2352,7 @@ function wholeRender(initialData) { 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) { people[editingIndex] = personData; editingIndex = null;