promo! react

This commit is contained in:
Zakhar 2026-08-24 13:07:16 +03:00
parent dec37e92c8
commit 8c5d86c9ab
41 changed files with 4174 additions and 2 deletions

1231
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"scripts": {
"start": "./node_modules/http-server/bin/http-server -p 9876 -S -C local.hvatilo.ru.pem -K local.hvatilo.ru-key.pem -o .",
"build": "./build.js"
"start": "vite",
"build": "vite build"
},
"dependencies": {
"http-server": "^14.1.1",
@ -11,8 +11,14 @@
},
"devDependencies": {
"@minify-html/node": "^0.18.1",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"easy-https": "^1.0.1",
"javascript-obfuscator": "^5.5.0",
"typescript": "^7.0.2",
"vite": "^8.2.2",
"ws": "^8.20.1"
}
}

256
src/App.tsx Normal file
View File

@ -0,0 +1,256 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { loadInitialData, setOption } from './api/promoApi';
import { Cart } from './components/Cart/Cart';
import { CookiePopup } from './components/CookiePopup/CookiePopup';
import { FamilySection } from './components/Family/FamilySection';
import { CategoriesSection } from './components/Categories/CategoriesSection';
import { DaysSection } from './components/Days/DaysSection';
import { Accordion } from './components/Accordion/Accordion';
import { normalizeProductImage } from './utils/product';
import type { InitialData } from './types/api';
import type { Product } from './types/cart';
import type { User } from './types/user';
export function App() {
const [initialData, setInitialData] = useState<InitialData | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [isCartComplete, setIsCartComplete] = useState(false);
const [isCartLoading, setIsCartLoading] = useState(true);
const [openSection, setOpenSection] = useState<string>('family');
useEffect(() => {
loadInitialData().then(data => {
setInitialData(data);
setProducts(data.cart.flat().map(product => normalizeProductImage(product)));
});
}, []);
const users = initialData?.users ?? [];
const categories = initialData?.available_groups ?? [];
const selectedCategories = initialData?.initial_groups ?? [];
const days = initialData?.days ?? 7;
const handleDaysChange = useCallback(async (nextDays: number) => {
setInitialData(current => current ? { ...current, days: nextDays } : current);
await setOption('days', nextDays);
}, []);
const handleUsersChange = useCallback(async (nextUsers: User[]) => {
setInitialData(current => current ? { ...current, users: nextUsers } : current);
await setOption('users', nextUsers);
}, []);
const handleCategoriesChange = useCallback((nextCategories: string[]) => {
setInitialData(current => current ? { ...current, initial_groups: nextCategories } : current);
}, []);
const pageContent = useMemo(() => {
if (!initialData) {
return <LoadingPage>Загрузка...</LoadingPage>;
}
return (
<Container>
<LeftColumn>
<TitleBlock>
<h1>Автокорзина</h1>
<p>Собираем продукты под ваши настройки</p>
</TitleBlock>
<Cart
products={products}
days={initialData.days}
excess={initialData.excess ?? 0}
isComplete={isCartComplete}
isLoading={isCartLoading}
onProductsChange={setProducts}
onComplete={() => {
setIsCartComplete(true);
setIsCartLoading(false);
}}
onLoadingChange={setIsCartLoading}
/>
</LeftColumn>
<RightColumn>
<FilterTitle>
<h2>Настройте корзину под себя</h2>
<p>Чем больше вы расскажете, тем точнее будет подбор</p>
</FilterTitle>
<SettingsPanel>
<Accordion
id="days"
title="На сколько дней собираем корзину?"
icon={<img src="/images/calendar.svg" alt="календарь" />}
isOpen={openSection === 'days'}
onToggle={() => setOpenSection(openSection === 'days' ? '' : 'days')}
>
<DaysSection value={days} onChange={handleDaysChange} />
</Accordion>
<Accordion
id="family"
title="Кто будет есть?"
description={
users.length > 0
? `${users.length} человек`
: 'Не указаны'
}
icon={<img src="/images/family.svg" alt="семья" />}
isOpen={openSection === 'family'}
onToggle={() => setOpenSection(openSection === 'family' ? '' : 'family')}
>
<FamilySection
users={users}
availableTags={categories}
onChange={handleUsersChange}
/>
</Accordion>
<Accordion
id="preferences"
title="Предпочтения"
description={
selectedCategories.length > 0
? `Выбраны ${selectedCategories.length}`
: 'Не указаны'
}
isHighlighted={selectedCategories.length > 0}
isOpen={openSection === 'preferences'}
onToggle={() => setOpenSection(openSection === 'preferences' ? '' : 'preferences')}
>
<CategoriesSection
categories={categories}
selectedCategories={selectedCategories}
onLocalChange={handleCategoriesChange}
onSaved={() => undefined}
/>
</Accordion>
</SettingsPanel>
</RightColumn>
</Container>
);
}, [
categories,
days,
handleCategoriesChange,
handleDaysChange,
handleUsersChange,
initialData,
isCartComplete,
isCartLoading,
openSection,
products,
selectedCategories.length,
users,
]);
return (
<>
{pageContent}
<CookiePopup />
</>
);
}
const LoadingPage = styled.div`
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: ${({ theme }) => theme.colors.white};
font-size: 20px;
`;
const Container = styled.main`
position: relative;
display: flex;
width: calc(100% - 40px);
max-width: 1228px;
height: 90vh;
margin: 40px auto 0;
background: ${({ theme }) => theme.colors.white};
border-radius: ${({ theme }) => theme.radius.page};
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
overflow: hidden;
@media (max-width: 900px) {
flex-direction: column;
height: auto;
min-height: 90vh;
}
`;
const Column = styled.section`
padding: 40px;
flex: 1;
width: 50%;
overflow-y: auto;
& + & {
border-left: 1px solid #f0f0f0;
}
@media (max-width: 900px) {
width: 100%;
padding: 20px;
& + & {
border-left: none;
border-top: 1px solid #f0f0f0;
}
}
`;
const LeftColumn = styled(Column)`
display: flex;
flex-direction: column;
height: 100%;
max-width: 490px;
`;
const RightColumn = styled(Column)``;
const TitleBlock = styled.div`
margin-bottom: 32px;
h1 {
font-size: 32px;
line-height: 38px;
font-weight: 700;
color: ${({ theme }) => theme.colors.darkGreen};
margin-bottom: 8px;
}
p {
font-size: 16px;
line-height: 19px;
color: ${({ theme }) => theme.colors.gray};
font-weight: 350;
}
`;
const FilterTitle = styled.div`
margin-bottom: 40px;
h2 {
font-size: 20px;
line-height: 24px;
margin-bottom: 8px;
color: ${({ theme }) => theme.colors.darkGreen};
}
p {
font-size: 16px;
line-height: 19px;
color: ${({ theme }) => theme.colors.gray};
font-weight: 350;
}
`;
const SettingsPanel = styled.div`
border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: 16px;
`;

7
src/api/config.ts Normal file
View File

@ -0,0 +1,7 @@
export const API_HOST = 'https://local.hvatilo.ru:8055';
export const jsonHeaders = {
'Content-Type': 'application/json',
};
export const cspNonce = '**MY_PRETTY_CSP_NONCE**';

31
src/api/http.ts Normal file
View File

@ -0,0 +1,31 @@
import { API_HOST } from './config';
import { refreshToken } from './tokenApi';
export function apiUrl(path: string): string {
return `${API_HOST}${path}`;
}
export async function requestWithRefresh<TResponse>(
url: string,
options: RequestInit = {},
): Promise<TResponse> {
const response = await fetch(url, {
...options,
credentials: 'include',
});
const data = await response.json();
if (data?.error_message === 'need_refresh') {
await refreshToken();
const retryResponse = await fetch(url, {
...options,
credentials: 'include',
});
return await retryResponse.json() as Promise<TResponse>;
}
return Promise.resolve(data) as Promise<TResponse>;
}

119
src/api/promoApi.ts Normal file
View File

@ -0,0 +1,119 @@
import { apiUrl, requestWithRefresh } from './http';
import { jsonHeaders } from './config';
import { initToken, refreshToken } from './tokenApi';
import type { CartUpdateResponse, InitialData } from '../types/api';
export async function loadInitialData(): Promise<InitialData> {
try {
const response = await fetch(apiUrl('/api/promo/get_initial_data'), {
credentials: 'include',
});
const data = await response.json();
if (response.status === 400) {
await clearGuestCookie();
document.location.reload();
return fallbackInitialData;
}
if (response.status === 401 && data.error_message !== 'need_refresh') {
await initToken();
await new Promise(resolve => setTimeout(resolve, 100));
const retryResponse = await fetch(apiUrl('/api/promo/get_initial_data'), {
credentials: 'include',
});
return retryResponse.json();
}
if (response.status === 403 || data.error_message === 'need_refresh') {
await refreshToken();
const retryResponse = await fetch(apiUrl('/api/promo/get_initial_data'), {
credentials: 'include',
});
return retryResponse.json();
}
return data;
} catch {
return fallbackInitialData;
}
}
export function getCartUpdates(): Promise<CartUpdateResponse> {
return new Promise<CartUpdateResponse>((resolve, reject) => {
// Use a simple mutex to ensure only one request is active at a time
let isPending = false;
let pendingRequest: Promise<CartUpdateResponse | null> | null = null;
if (isPending) {
// If already pending, reject the current request and return a placeholder
return reject(new Error('getCartUpdates is already in progress'));
}
isPending = true;
// Create a new request that will resolve when the actual request completes
pendingRequest = requestWithRefresh<CartUpdateResponse>(apiUrl('/api/promo/get_updates'), {
headers: jsonHeaders,
}).then(
(response) => {
isPending = false;
resolve(response as CartUpdateResponse);
return response;
},
(error) => {
isPending = false;
reject(error);
return null;
}
);
// Return the promise so the caller can wait for it
return pendingRequest;
});
}
export function replaceProduct(productName: string): Promise<unknown> {
return requestWithRefresh(apiUrl('/api/promo/replace_item'), {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ product_name: productName }),
});
}
export function deleteProduct(productName: string): Promise<unknown> {
return requestWithRefresh(apiUrl('/api/promo/delete_item'), {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ product_name: productName }),
});
}
export function setOption<TValue>(key: string, value: TValue): Promise<unknown> {
return requestWithRefresh(apiUrl('/api/promo/set_option'), {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ [key]: value }),
});
}
export function clearGuestCookie(): Promise<unknown> {
return requestWithRefresh(apiUrl('/api/guest/clear_cookie'), {
method: 'POST',
});
}
const fallbackInitialData: InitialData = {
cart: [],
users: [],
initial_groups: [],
available_groups: [],
days: 7,
excess: 0,
precision: 0,
}

30
src/api/tokenApi.ts Normal file
View File

@ -0,0 +1,30 @@
import { API_HOST } from './config';
export async function initToken(): Promise<unknown> {
const response = await fetch(`${API_HOST}/api/guest/init_cookie?utm_source=hvatilo_promo`, {
method: 'POST',
credentials: 'include',
});
return response.json();
}
export async function refreshToken(): Promise<unknown> {
const response = await fetch(`${API_HOST}/api/guest/refresh_cookie`, {
method: 'POST',
credentials: 'include',
});
if (response.status === 400) {
await fetch(`${API_HOST}/api/guest/clear_cookie`, {
method: 'POST',
credentials: 'include',
});
await new Promise(resolve => setTimeout(resolve, 100));
return initToken();
}
return response.json();
}

View File

@ -0,0 +1,136 @@
import type { ReactNode } from 'react';
import styled from 'styled-components';
import { ArrowIcon } from '../Icons/ArrowIcon';
import { HeartIcon } from '../Icons/HeartIcon';
interface AccordionProps {
id: string;
title: string;
description?: string;
icon?: ReactNode;
isOpen: boolean;
isHighlighted?: boolean;
onToggle: () => void;
children: ReactNode;
}
export function Accordion({
title,
description,
icon,
isOpen,
isHighlighted = true,
onToggle,
children,
}: AccordionProps) {
return (
<Item>
<Header type="button" onClick={onToggle}>
<HeaderLeft>
<IconCircle $highlighted={isHighlighted}>
{icon ?? <HeartIcon />}
</IconCircle>
<div>
<HeaderTitle>{title}</HeaderTitle>
{description && <Description>{description}</Description>}
</div>
</HeaderLeft>
<ArrowWrap $open={isOpen}>
<ArrowIcon />
</ArrowWrap>
</Header>
<Content $open={isOpen}>
<ContentInner>{children}</ContentInner>
</Content>
</Item>
);
}
const Item = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.colors.border};
position: relative;
&:last-child {
border-bottom: none;
}
`;
const Header = styled.button`
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
cursor: pointer;
user-select: none;
background: transparent;
border: 0;
text-align: left;
`;
const HeaderLeft = styled.div`
display: flex;
align-items: center;
`;
const IconCircle = styled.div<{ $highlighted: boolean }>`
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
flex-shrink: 0;
background: ${({ $highlighted }) => ($highlighted ? '#E6F696' : '#E7EDE7')};
img {
width: 44px;
height: 44px;
}
svg rect {
fill: ${({ $highlighted }) => ($highlighted ? '#E6F696' : '#E7EDE7')};
}
svg path {
stroke: ${({ $highlighted }) => ($highlighted ? '#5EA12D' : '#A3ADA2')};
}
`;
const HeaderTitle = styled.div`
font-size: 16px;
line-height: 19px;
font-weight: 500;
color: ${({ theme }) => theme.colors.darkGreen};
`;
const Description = styled.div`
font-size: 14px;
color: ${({ theme }) => theme.colors.gray};
margin-top: 2px;
`;
const ArrowWrap = styled.div<{ $open: boolean }>`
color: ${({ theme }) => theme.colors.gray};
transition: transform 0.3s ease;
transform: ${({ $open }) => ($open ? 'rotate(180deg)' : 'rotate(0deg)')};
svg {
width: 16px;
height: 16px;
}
`;
const Content = styled.div<{ $open: boolean }>`
max-height: ${({ $open }) => ($open ? '500px' : '0')};
overflow: hidden;
transition: max-height 0.4s ease, padding 0.3s ease;
`;
const ContentInner = styled.div`
padding: 0 24px 20px;
`;

View File

@ -0,0 +1,297 @@
import {Dispatch, SetStateAction, useMemo, useState} from 'react';
import styled from 'styled-components';
import { deleteProduct, replaceProduct } from '../../api/promoApi';
import { useCartPolling } from '../../hooks/useCartPolling';
import { declension } from '../../utils/declension';
import { formatCurrency } from '../../utils/formatCurrency';
import type { Product } from '../../types/cart';
import { CartItem } from './CartItem';
import { CartFooter } from './CartFooter';
import { ProductModal } from './ProductModal';
interface CartProps {
products: Product[];
days: number;
excess: number;
isComplete: boolean;
isLoading: boolean;
onProductsChange: Dispatch<SetStateAction<Product[]>>;
onComplete: () => void;
onLoadingChange: (isLoading: boolean) => void;
}
export function Cart({
products,
days,
excess,
isComplete,
isLoading,
onProductsChange,
onComplete,
onLoadingChange,
}: CartProps) {
const [visibleCount, setVisibleCount] = useState(5);
const [displayText, setDisplayText] = useState('');
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [processingProductName, setProcessingProductName] = useState<string | null>(null);
const visibleProducts = products.slice(0, visibleCount);
const remaining = products.length - visibleProducts.length;
const totalPrice = useMemo(
() => products.reduce((sum, product) => sum + product.total_price, 0),
[products],
);
const formattedTotalPrice = formatCurrency(totalPrice);
const stockDays = Math.round(days * excess + days);
useCartPolling({
enabled: !isComplete,
onProductsChange,
onComplete,
onLoadingChange,
setDisplayText,
});
async function handleReplace(product: Product) {
setProcessingProductName(product.name);
try {
await replaceProduct(product.name);
onProductsChange(products.filter(item => item.name !== product.name));
} finally {
setProcessingProductName(null);
}
}
async function handleDelete(product: Product) {
setProcessingProductName(product.name);
try {
await deleteProduct(product.name);
onProductsChange(products.filter(item => item.name !== product.name));
} finally {
setProcessingProductName(null);
}
}
return (
<>
<Summary>
<div>
<SummaryTitle>
Ваша корзина на <Green>{days} {declension(days, ['день', 'дня', 'дней'])}</Green>
</SummaryTitle>
<SummaryDetails $hidden={isLoading}>
{products.length > 0 && (
<>
<span>
{products.length} {declension(products.length, ['товар', 'товара', 'товаров'])}
</span>
<b>&bull;</b>
<span>{formattedTotalPrice}</span>
<SummaryExcess>
С запасами на {stockDays} {declension(stockDays, ['день', 'дня', 'дней'])}
</SummaryExcess>
</>
)}
</SummaryDetails>
<Loader $hidden={!isLoading} data-display-text={displayText}>
{displayText}
</Loader>
</div>
</Summary>
<ScrollWrapper>
<CartList>
{visibleProducts.map(product => (
<CartItem
key={product.name}
product={product}
isProcessing={processingProductName === product.name}
onOpen={() => setSelectedProduct(product)}
onReplace={() => handleReplace(product)}
onDelete={() => handleDelete(product)}
/>
))}
</CartList>
{products.length === 0 && (
<EmptyLoader src="/images/empty-cart-loader.svg" alt="сбор корзины" />
)}
</ScrollWrapper>
{remaining > 0 && (
<ShowMore type="button" onClick={() => setVisibleCount(products.length)}>
Показать ещё <span>{remaining}</span> товаров
<svg viewBox="0 0 24 24" fill="none">
<polyline points="6 9 12 15 18 9" />
</svg>
</ShowMore>
)}
<CartFooter
isVisible={isComplete}
totalPrice={formattedTotalPrice}
/>
<ProductModal
product={selectedProduct}
onClose={() => setSelectedProduct(null)}
onReplace={handleReplace}
onAnother={() => undefined}
/>
</>
);
}
const Summary = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-shrink: 0;
width: 100%;
`;
const SummaryTitle = styled.div`
font-size: 20px;
line-height: 24px;
font-weight: 600;
color: ${({ theme }) => theme.colors.darkGreen};
margin-bottom: 8px;
`;
const Green = styled.span`
color: ${({ theme }) => theme.colors.green};
`;
const SummaryDetails = styled.div<{ $hidden: boolean }>`
min-height: 24px;
font-size: 14px;
color: ${({ theme }) => theme.colors.gray};
display: flex;
justify-content: start;
align-items: center;
gap: 8px;
visibility: ${({ $hidden }) => ($hidden ? 'hidden' : 'visible')};
span {
font-weight: 350;
}
b {
font-family: initial;
font-size: 20px;
line-height: 8px;
margin-bottom: -2px;
}
`;
const SummaryExcess = styled.div`
background-color: ${({ theme }) => theme.colors.paleGreen};
padding: 3px 6px 5px;
color: #6D8982;
border-radius: 4px;
font-size: 14px;
font-weight: 350;
`;
const Loader = styled.div<{ $hidden: boolean, "data-display-text": string }>`
font-size: 16px;
line-height: 19px;
display: inline-block;
color: transparent;
background-clip: text;
position: absolute;
visibility: ${({ $hidden }) => ($hidden ? 'hidden' : 'visible')};
&:before,
&:after {
content: '${({"data-display-text": displayText}) => displayText}';
display: block;
position: absolute;
inset: 0;
}
&:before {
background: linear-gradient(79deg, #FFBA00 0%, #FFEC47 31%, #C4E30F 63%, #5EA12D 98%) 100%;
background-clip: text;
animation: opacityAnim 1s ease-in-out 0s infinite alternate;
}
&:after {
background: linear-gradient(79deg, #5EA12D 2%, #C4E30F 38%, #FFEC47 69%, #FFBA00 100%) 100%;
background-clip: text;
animation: opacityAnim 1s ease-in-out -1s infinite alternate;
}
`;
const ScrollWrapper = styled.div`
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
margin-bottom: 10px;
position: relative;
display: flex;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 4px;
}
`;
const CartList = styled.ul`
width: 100%;
list-style: none;
`;
const EmptyLoader = styled.img`
animation: spin 1.4s cubic-bezier(0.4, 0.2, 0.35, 0.7) infinite;
position: absolute;
left: 50%;
margin-left: -30px;
align-self: center;
`;
const ShowMore = styled.button`
display: flex;
align-items: center;
justify-content: center;
padding: 20px 0 10px;
color: ${({ theme }) => theme.colors.green};
font-size: 14px;
cursor: pointer;
font-weight: 500;
transition: 0.2s;
flex-shrink: 0;
gap: 4px;
border: 0;
background: transparent;
&:hover {
opacity: 0.8;
}
svg {
width: 14px;
height: 14px;
stroke: ${({ theme }) => theme.colors.green};
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
`;

View File

@ -0,0 +1,99 @@
import styled from 'styled-components';
import {BagIcon} from "../Icons/BagIcon";
interface CartFooterProps {
isVisible: boolean;
totalPrice: string;
}
export function CartFooter({ isVisible, totalPrice }: CartFooterProps) {
return (
<Footer $visible={isVisible}>
<TotalWrap>
<div>
<IconCircle>
<BagIcon />
</IconCircle>
<div>
<TotalLabel>Итого</TotalLabel>
<TotalPrice>{totalPrice}</TotalPrice>
</div>
</div>
</TotalWrap>
<CheckoutButton type="button">
Оформить заказ
</CheckoutButton>
</Footer>
);
}
const Footer = styled.div<{ $visible: boolean }>`
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
flex-shrink: 0;
& > * {
visibility: ${({ $visible }) => ($visible ? 'visible' : 'hidden')};
}
`;
const TotalWrap = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
& > div {
display: flex;
align-items: center;
}
`;
const IconCircle = styled.div`
width: 44px;
height: 44px;
border-radius: 50%;
margin-right: 8px;
`;
const TotalLabel = styled.span`
font-size: 16px;
line-height: 19px;
color: ${({ theme }) => theme.colors.darkGreen};
`;
const TotalPrice = styled.div`
font-size: 20px;
line-height: 24px;
font-weight: 500;
color: ${({ theme }) => theme.colors.darkGreen};
margin-top: 4px;
`;
const CheckoutButton = styled.button`
background-color: ${({ theme }) => theme.colors.green};
height: 54px;
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;
&:hover,
&:focus {
background-color: ${({ theme }) => theme.colors.greenHover};
}
&:active {
background-color: #4A8D19;
}
`;

View File

@ -0,0 +1,165 @@
import { useState } from 'react';
import styled from 'styled-components';
import { getProductAmount } from '../../utils/product';
import type { Product } from '../../types/cart';
interface CartItemProps {
product: Product;
isProcessing: boolean;
onOpen: () => void;
onReplace: () => void;
onDelete: () => void;
}
export function CartItem({
product,
isProcessing,
onOpen,
onReplace,
onDelete,
}: CartItemProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<Item $processing={isProcessing}>
<ItemImage type="button" onClick={onOpen}>
<img src={product.image} alt="" />
</ItemImage>
<ItemDetails>
<ItemName>{product.name}</ItemName>
<ItemPrice>
{getProductAmount(product)} &times; {Math.round(product.price * 100) / 100}
</ItemPrice>
</ItemDetails>
<MenuWrapper>
<MenuButton
type="button"
onClick={() => setIsMenuOpen(value => !value)}
>
&#8942;
</MenuButton>
{isMenuOpen && (
<Dropdown>
<ul>
<li>
<button type="button" onClick={onReplace}>
Заменить
</button>
</li>
<li>
<button type="button" onClick={onDelete}>
Удалить
</button>
</li>
</ul>
</Dropdown>
)}
</MenuWrapper>
</Item>
);
}
const Item = styled.li<{ $processing: boolean }>`
display: flex;
align-items: center;
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')};
`;
const ItemImage = styled.button`
width: 72px;
height: 65px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
flex-shrink: 0;
position: relative;
overflow: hidden;
border: 0;
background: transparent;
cursor: pointer;
img {
max-width: 100%;
max-height: 100%;
}
`;
const ItemDetails = styled.div`
flex: 1;
`;
const ItemName = styled.div`
font-size: 15px;
color: ${({ theme }) => theme.colors.darkGreen};
margin-bottom: 4px;
`;
const ItemPrice = styled.div`
font-size: 14px;
color: ${({ theme }) => theme.colors.gray};
`;
const MenuWrapper = styled.div`
position: relative;
`;
const MenuButton = styled.button`
background: none;
border: none;
color: #b0b0b0;
cursor: pointer;
padding: 4px 8px;
font-size: 20px;
letter-spacing: 2px;
border-radius: 4px;
transition: 0.2s;
&:hover {
background: #f3f4f6;
color: ${({ theme }) => theme.colors.darkGreen};
}
`;
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);
min-width: 150px;
padding: 6px 0;
z-index: 10;
margin-top: 4px;
ul {
list-style: none;
}
button {
width: 100%;
padding: 10px 16px;
font-size: 14px;
color: ${({ theme }) => theme.colors.darkGreen};
cursor: pointer;
transition: 0.2s;
background: transparent;
border: 0;
text-align: left;
&:hover {
background: #f3f4f6;
}
}
`;

View File

@ -0,0 +1,150 @@
import styled from 'styled-components';
import { formatCurrency } from '../../utils/formatCurrency';
import { getLargeProductImage } from '../../utils/product';
import type { Product } from '../../types/cart';
interface ProductModalProps {
product: Product | null;
onClose: () => void;
onReplace: (product: Product) => void;
onAnother: (product: Product) => void;
}
export function ProductModal({
product,
onClose,
onReplace,
onAnother,
}: ProductModalProps) {
if (!product) return null;
return (
<Overlay onClick={onClose}>
<Window onClick={event => event.stopPropagation()}>
<CloseButton type="button" aria-label="Закрыть" onClick={onClose}>
&times;
</CloseButton>
<ImageWrap>
<img src={getLargeProductImage(product)} alt={product.name} />
</ImageWrap>
<Title>{product.name}</Title>
<Details>
<span>{product.n}</span> · <span>{formatCurrency(product.total_price)}</span>
</Details>
<ActionButton type="button" onClick={() => onReplace(product)}>
Заменить
</ActionButton>
<AnotherButton
href="#"
onClick={event => {
event.preventDefault();
onAnother(product);
}}
>
Ещё вариант
</AnotherButton>
</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: 1000;
`;
const Window = styled.div`
background-color: #ffffff;
border-radius: 12px;
width: 100%;
max-width: 340px;
padding: 20px 24px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
position: relative;
`;
const CloseButton = styled.button`
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: #b3b3b3;
font-size: 24px;
line-height: 1;
cursor: pointer;
padding: 5px;
&:hover {
color: #777;
}
`;
const ImageWrap = styled.div`
display: flex;
justify-content: center;
margin: 20px 0;
img {
width: 160px;
height: auto;
object-fit: contain;
}
`;
const Title = styled.h2`
font-size: 18px;
font-weight: 700;
text-align: center;
color: #222222;
line-height: 1.4;
margin: 0 0 10px;
`;
const Details = styled.p`
font-size: 15px;
text-align: center;
color: #555555;
margin: 0 0 24px;
`;
const ActionButton = styled.button`
display: block;
width: 100%;
background-color: #5ba653;
color: #ffffff;
font-size: 16px;
font-weight: 600;
padding: 14px 0;
border: none;
border-radius: 8px;
cursor: pointer;
&:hover {
background-color: #4e9247;
}
`;
const AnotherButton = styled.a`
display: block;
text-align: center;
margin-top: 15px;
color: #5ba653;
font-size: 14px;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
`;

View File

@ -0,0 +1,213 @@
import { useMemo, useState } from 'react';
import styled from 'styled-components';
import { setOption } from '../../api/promoApi';
import { useDebounceBatch } from '../../hooks/useDebounceBatch';
interface CategoriesSectionProps {
categories: string[];
selectedCategories: string[];
onLocalChange: (categories: string[]) => void;
onSaved: () => void;
}
export function CategoriesSection({
categories,
selectedCategories,
onLocalChange,
onSaved,
}: CategoriesSectionProps) {
const [expanded, setExpanded] = useState(false);
const [loadingCategories, setLoadingCategories] = useState<string[]>([]);
const maxVisibleCategories = 7;
const dataToShow = useMemo(() => {
if (expanded) return categories;
return (selectedCategories.length > 0 ? selectedCategories : categories)
.slice(0, maxVisibleCategories);
}, [categories, expanded, selectedCategories]);
const sendUpdatedCategories = useDebounceBatch<string>(async categoryNames => {
let nextCategories = [...selectedCategories];
categoryNames.forEach(categoryName => {
if (nextCategories.includes(categoryName)) {
nextCategories = nextCategories.filter(item => item !== categoryName);
} else {
nextCategories.push(categoryName);
}
});
onLocalChange(nextCategories);
await setOption('categories', nextCategories);
onSaved();
}, 1500);
function toggleCategory(category: string) {
if (loadingCategories.includes(category)) return;
setLoadingCategories(current => [...current, category]);
sendUpdatedCategories(category).finally(() => {
setLoadingCategories(current => current.filter(item => item !== category));
});
}
if (categories.length === 0) {
return null;
}
return (
<Block>
<Header>
<h3>Что добавить в корзину?</h3>
<p>Выберите группы продуктов, которые хотите видеть в корзине</p>
</Header>
<Wrapper>
<Grid>
{dataToShow.map(category => (
<CategoryButton
key={category}
type="button"
$active={selectedCategories.includes(category)}
$loading={loadingCategories.includes(category)}
onClick={() => toggleCategory(category)}
>
<span>{category}</span>
</CategoryButton>
))}
{dataToShow.length > 0 && (
<ToggleButton
type="button"
$collapsed={!expanded}
onClick={() => setExpanded(value => !value)}
>
{!expanded && dataToShow.length < selectedCategories.length
? `+${selectedCategories.length - dataToShow.length}`
: ''}
<svg viewBox="0 0 24 24" fill="none">
<polyline points="18 15 12 9 6 15" />
</svg>
</ToggleButton>
)}
</Grid>
</Wrapper>
</Block>
);
}
const Block = styled.div``;
const Header = styled.div`
h3 {
font-size: 16px;
line-height: 19px;
color: ${({ theme }) => theme.colors.darkGreen};
margin-bottom: 4px;
font-weight: 350;
}
p {
font-size: 14px;
color: ${({ theme }) => theme.colors.gray};
margin-bottom: 16px;
font-weight: 350;
}
`;
const Wrapper = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
`;
const Grid = styled.div`
gap: 10px;
display: flex;
flex-wrap: wrap;
`;
const CategoryButton = styled.button<{ $active: boolean; $loading: boolean }>`
position: relative;
overflow: hidden;
height: 32px;
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: background-color 1.2s;
background-color: ${({ $active }) => ($active ? '#0C3B2E' : '#F1FAF0')};
color: ${({ $active }) => ($active ? '#F1FAF0' : '#0C3B2E')};
&:hover {
background-color: ${({ $active }) => ($active ? '#0C3B2E' : '#e4f0de')};
}
${({ $loading }) => $loading && `
color: #0C3B2E;
&:before,
&:after {
content: '';
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
position: absolute;
inset: 0;
}
&:before {
background: linear-gradient(79deg, #FFBA00 0%, #FFEC47 31%, #C4E30F 63%, #5EA12D 98%) 100%;
animation: opacityAnim 1s ease-in-out 0s infinite alternate;
}
&:after {
background: linear-gradient(79deg, #5EA12D 2%, #C4E30F 38%, #FFEC47 69%, #FFBA00 100%) 100%;
animation: opacityAnim 1s ease-in-out -1s infinite alternate;
}
span {
position: relative;
z-index: 2;
}
`}
`;
const ToggleButton = styled.button<{ $collapsed: boolean }>`
height: 32px;
min-width: 32px;
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
background-color: #f2f9ef;
border: none;
border-radius: 8px;
cursor: pointer;
transition: 0.2s;
padding: 6px 8px 6px 16px;
color: ${({ theme }) => theme.colors.darkGreen};
font-size: 14px;
font-weight: 350;
&:hover {
background-color: #e4f0de;
}
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,157 @@
import { useEffect, useState } from 'react';
import styled from 'styled-components';
export function CookiePopup() {
const [visible, setVisible] = useState(false);
const [hiding, setHiding] = useState(false);
useEffect(() => {
if (!localStorage.getItem('cookie_accepted')) {
const timerId = window.setTimeout(() => {
setVisible(true);
}, 500);
return () => window.clearTimeout(timerId);
}
}, []);
if (!visible) return null;
function accept() {
localStorage.setItem('cookie_accepted', 'true');
setHiding(true);
window.setTimeout(() => {
setVisible(false);
}, 400);
}
return (
<Popup $hiding={hiding}>
<IconWrapper>
<img src="/images/cookie.png" alt="Cookie icon" />
</IconWrapper>
<Text>
Мы используем{' '}
<a href="/cookies_and_recommendations.html#c" target="_blank" rel="noreferrer">
куки
</a>{' '}
и{' '}
<a href="/cookies_and_recommendations.html#r" target="_blank" rel="noreferrer">
рекомендательные технологии
</a>
, это помогает улучшать сервис и запоминать ваши настройки
</Text>
<AcceptButton type="button" onClick={accept}>
Хорошо
</AcceptButton>
</Popup>
);
}
const Popup = styled.div<{ $hiding: boolean }>`
position: fixed;
bottom: 25px;
left: 50%;
transform: translateX(-50%) translateY(${({ $hiding }) => ($hiding ? '30px' : '0')});
background: #ffffff;
border-radius: 20px;
border: 2px solid #E7EDE7;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.05), 0 4px 10px rgba(0, 0, 0, 0.02);
padding: 16px 32px;
display: flex;
align-items: center;
gap: 20px;
width: 95%;
max-width: 780px;
z-index: 9999;
opacity: ${({ $hiding }) => ($hiding ? 0 : 1)};
visibility: ${({ $hiding }) => ($hiding ? 'hidden' : 'visible')};
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
@media (max-width: 600px) {
flex-wrap: wrap;
justify-content: center;
padding: 20px;
bottom: 15px;
text-align: center;
border-radius: 16px;
gap: 12px;
}
`;
const IconWrapper = styled.div`
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 50%;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #edf2f7;
box-shadow: inset 0 4px 10px 0 rgba(12, 59, 46, 0.1);
overflow: hidden;
img {
width: 85%;
height: 85%;
object-fit: contain;
}
@media (max-width: 600px) {
width: 50px;
height: 50px;
}
`;
const Text = styled.p`
margin: 0;
flex-grow: 1;
color: ${({ theme }) => theme.colors.darkGreen};
font-size: 14px;
line-height: 20px;
letter-spacing: 0.01em;
a {
color: #69a342;
font-weight: 600;
}
@media (max-width: 600px) {
font-size: 14px;
width: 100%;
order: 2;
}
`;
const AcceptButton = styled.button`
flex-shrink: 0;
background-color: #d6e6d4;
color: #1c3a2e;
border: none;
padding: 10px 24px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.1s ease;
white-space: nowrap;
&:hover {
background-color: #c2d6c0;
}
&:active {
transform: scale(0.96);
}
@media (max-width: 600px) {
width: 100%;
justify-content: center;
padding: 12px;
order: 3;
}
`;

View File

@ -0,0 +1,53 @@
import styled from 'styled-components';
interface DaysSectionProps {
value: number;
onChange: (value: number) => void;
}
const options = [1, 3, 7];
export function DaysSection({ value, onChange }: DaysSectionProps) {
return (
<DaysOptions>
{options.map(option => (
<DayButton
key={option}
type="button"
$active={value === option}
onClick={() => onChange(option)}
>
{option}
</DayButton>
))}
</DaysOptions>
);
}
const DaysOptions = styled.div`
display: flex;
gap: 12px;
`;
const DayButton = styled.button<{ $active: boolean }>`
flex: 1;
max-width: 140px;
height: 52px;
border: 2px solid ${({ $active }) => ($active ? '#5EA12D' : '#e1e1e1')};
border-radius: 8px;
background: ${({ $active }) => ($active ? '#E6F696' : '#ffffff')};
font-size: 16px;
line-height: 19px;
font-weight: 350;
color: ${({ theme }) => theme.colors.darkGreen};
cursor: pointer;
transition: 0.2s;
&:hover {
border-color: ${({ theme }) => theme.colors.green};
}
&:focus {
outline: none;
}
`;

View File

@ -0,0 +1,237 @@
import { useState } from 'react';
import styled from 'styled-components';
import { declension } from '../../utils/declension';
import type { User } from '../../types/user';
import { PersonModal } from './PersonModal';
interface FamilySectionProps {
users: User[];
availableTags: string[];
onChange: (users: User[]) => void;
}
export function FamilySection({
users,
availableTags,
onChange,
}: FamilySectionProps) {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
function handleDelete(index: number) {
onChange(users.filter((_, userIndex) => userIndex !== index));
}
function handleSave(user: User) {
if (editingIndex === null) {
onChange([...users, user]);
} else {
onChange(users.map((item, index) => index === editingIndex ? user : item));
}
setIsModalOpen(false);
setEditingIndex(null);
}
return (
<>
<FamilyList>
{users.map((user, index) => (
<FamilyMember
key={`${user.name}-${index}`}
type="button"
onClick={() => {
setEditingIndex(index);
setIsModalOpen(true);
}}
>
<Avatar>
<img src={getAvatarSrc(user)} alt="" />
</Avatar>
<Info>
<Name>{user.name}</Name>
<Age>{user.age} {declension(user.age, ['год', 'года', 'лет'])}</Age>
</Info>
<DeleteButton
type="button"
onClick={event => {
event.stopPropagation();
handleDelete(index);
}}
>
×
</DeleteButton>
</FamilyMember>
))}
</FamilyList>
<AddWrapper>
<AddButton
type="button"
onClick={() => {
setEditingIndex(null);
setIsModalOpen(true);
}}
>
Добавить человека <span>+</span>
</AddButton>
</AddWrapper>
<PersonModal
isOpen={isModalOpen}
user={editingIndex === null ? null : users[editingIndex]}
availableTags={availableTags}
onClose={() => {
setIsModalOpen(false);
setEditingIndex(null);
}}
onSave={handleSave}
/>
</>
);
}
function getAvatarSrc(user: User): string {
if (user.age < 16) {
return user.gender === 'f'
? '/images/broc-girl.png'
: '/images/broc-boy.png';
}
return user.gender === 'f'
? '/images/broc-woman.png'
: '/images/broc-man.png';
}
const FamilyList = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const FamilyMember = styled.button`
width: 100%;
display: flex;
align-items: center;
padding: 12px 16px;
border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: 12px;
background: #fff;
transition: 0.2s;
cursor: pointer;
text-align: left;
&:hover {
border-color: #c0c0c0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
}
&:nth-child(n+1) ${() => Avatar} {
background-color: #B4AAFF;
}
&:nth-child(n+2) ${() => Avatar} {
background-color: #CEE0FF;
}
&:nth-child(n+3) ${() => Avatar} {
background-color: #E6F696;
}
&:nth-child(n+4) ${() => Avatar} {
background-color: #FFF488;
}
&:nth-child(n+5) ${() => Avatar} {
background-color: #FFD623;
}
`;
const Avatar = styled.div`
width: 46px;
height: 46px;
border-radius: 50%;
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
margin-right: 14px;
overflow: hidden;
img {
max-width: 100%;
}
`;
const Info = styled.div`
flex: 1;
display: flex;
flex-direction: column;
`;
const Name = styled.div`
font-size: 16px;
font-weight: 600;
color: ${({ theme }) => theme.colors.darkGreen};
`;
const Age = styled.div`
font-size: 14px;
color: ${({ theme }) => theme.colors.gray};
`;
const DeleteButton = styled.button`
color: #d1d5db;
cursor: pointer;
font-size: 18px;
padding: 0 8px;
transition: 0.2s;
background: transparent;
border: 0;
&:hover {
color: #e53935;
}
`;
const AddWrapper = styled.div`
width: 100%;
display: flex;
align-items: center;
justify-content: center;
`;
const AddButton = styled.button`
padding: 18px 23px;
border-radius: 12px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 16px;
color: ${({ theme }) => theme.colors.green};
font-size: 15px;
font-weight: 350;
cursor: pointer;
transition: 0.2s;
background-color: transparent;
border: 0;
&:hover {
background-color: #F7FBF6;
}
&:active,
&:focus {
background-color: #EEF8EB;
outline: none;
}
span {
font-size: 20px;
margin-left: 8px;
font-weight: 300;
}
`;

View File

@ -0,0 +1,350 @@
import { useEffect, useState } from 'react';
import styled from 'styled-components';
import type { Gender, User } from '../../types/user';
import { TagsInput } from './TagsInput';
interface PersonModalProps {
isOpen: boolean;
user: User | null;
availableTags: string[];
onClose: () => void;
onSave: (user: User) => void;
}
interface PersonForm {
name: string;
gender: Gender;
age: string;
weight: string;
height: string;
avoid: string[];
favorite: string[];
}
const defaultForm: PersonForm = {
name: '',
gender: 'm',
age: '',
weight: '',
height: '',
avoid: [],
favorite: [],
};
export function PersonModal({
isOpen,
user,
availableTags,
onClose,
onSave,
}: PersonModalProps) {
const [form, setForm] = useState<PersonForm>(defaultForm);
useEffect(() => {
if (!isOpen) return;
if (user) {
setForm({
name: user.name,
gender: user.gender,
age: String(user.age),
weight: user.weight ? String(user.weight) : '',
height: user.height ? String(user.height) : '',
avoid: user.avoid ?? [],
favorite: user.favorite ?? [],
});
} else {
setForm(defaultForm);
}
}, [isOpen, user]);
if (!isOpen) return null;
function updateForm<TName extends keyof PersonForm>(name: TName, value: PersonForm[TName]) {
setForm(current => ({
...current,
[name]: value,
}));
}
function handleAvoidChange(tags: string[]) {
setForm(current => ({
...current,
avoid: tags,
favorite: current.favorite.filter(tag => !tags.includes(tag)),
}));
}
function handleFavoriteChange(tags: string[]) {
setForm(current => ({
...current,
favorite: tags,
avoid: current.avoid.filter(tag => !tags.includes(tag)),
}));
}
function handleSave() {
const age = Number(form.age);
if (!form.name.trim()) {
alert('Пожалуйста, введите имя.');
return;
}
if (!age || age < 7) {
alert('Возраст должен быть от 7 лет.');
return;
}
onSave({
id: user?.id,
name: form.name.trim(),
gender: form.gender,
age,
weight: form.weight ? Number(form.weight) : undefined,
height: form.height ? Number(form.height) : undefined,
avoid: form.avoid,
favorite: form.favorite,
});
}
return (
<Overlay onClick={onClose}>
<Content onClick={event => event.stopPropagation()}>
<Header>
<h2>{user ? 'Редактировать человека' : 'Добавить человека'}</h2>
<CloseButton type="button" onClick={onClose}>
×
</CloseButton>
</Header>
<FormGroup>
<label>Имя</label>
<input
type="text"
placeholder="Имя"
value={form.name}
onChange={event => updateForm('name', event.target.value)}
/>
</FormGroup>
<FormGroup>
<label>Пол</label>
<GenderToggles>
<GenderButton
type="button"
$active={form.gender === 'f'}
onClick={() => updateForm('gender', 'f')}
>
Женский
</GenderButton>
<GenderButton
type="button"
$active={form.gender === 'm'}
onClick={() => updateForm('gender', 'm')}
>
Мужской
</GenderButton>
</GenderToggles>
</FormGroup>
<FormRow>
<FormGroup>
<label>Возраст</label>
<input
type="number"
value={form.age}
onChange={event => updateForm('age', event.target.value)}
/>
<Hint>от 7 лет</Hint>
</FormGroup>
<FormGroup>
<label>Вес</label>
<input
type="number"
value={form.weight}
onChange={event => updateForm('weight', event.target.value)}
/>
</FormGroup>
<FormGroup>
<label>Рост</label>
<input
type="number"
value={form.height}
onChange={event => updateForm('height', event.target.value)}
/>
</FormGroup>
</FormRow>
<PrefsGrid>
<TagsInput
title="Запрещённые"
type="avoid"
value={form.avoid}
availableTags={availableTags}
excludedTags={form.favorite}
onChange={handleAvoidChange}
/>
<TagsInput
title="Любимые"
type="favorite"
value={form.favorite}
availableTags={availableTags}
excludedTags={form.avoid}
onChange={handleFavoriteChange}
/>
</PrefsGrid>
<SaveButton type="button" onClick={handleSave}>
Сохранить
</SaveButton>
</Content>
</Overlay>
);
}
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 CloseButton = styled.button`
font-size: 24px;
color: #b0b0b0;
cursor: pointer;
line-height: 1;
background: transparent;
border: 0;
&:hover {
color: ${({ theme }) => theme.colors.darkGreen};
}
`;
const FormGroup = styled.div`
margin-bottom: 16px;
flex: 1;
label {
display: block;
font-size: 14px;
color: ${({ theme }) => theme.colors.darkGreen};
margin-bottom: 6px;
}
input {
width: 100%;
padding: 10px 12px;
border: 1px solid #e1e1e1;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: 0.2s;
&:focus {
border-color: ${({ theme }) => theme.colors.green};
}
}
`;
const FormRow = styled.div`
display: flex;
gap: 16px;
margin-bottom: 16px;
${FormGroup} {
margin-bottom: 0;
}
@media (max-width: 600px) {
flex-direction: column;
}
`;
const Hint = styled.span`
display: block;
font-size: 12px;
color: ${({ theme }) => theme.colors.gray};
margin-top: 4px;
`;
const GenderToggles = styled.div`
display: flex;
gap: 8px;
margin-bottom: 16px;
`;
const GenderButton = styled.button<{ $active: boolean }>`
flex: 1;
padding: 10px;
border: 1px solid ${({ $active }) => ($active ? '#e9f6d9' : '#e1e1e1')};
border-radius: 8px;
background: ${({ $active }) => ($active ? '#e9f6d9' : '#ffffff')};
font-size: 14px;
cursor: pointer;
transition: 0.2s;
font-weight: ${({ $active }) => ($active ? 500 : 400)};
`;
const PrefsGrid = styled.div`
display: flex;
gap: 16px;
margin-bottom: 20px;
border-top: 1px solid #f0f0f0;
padding-top: 16px;
@media (max-width: 600px) {
flex-direction: column;
}
`;
const SaveButton = styled.button`
width: 100%;
padding: 12px;
background: ${({ theme }) => theme.colors.green};
color: #fff;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: 0.2s;
&:hover {
background: #5b8f29;
}
`;

View File

@ -0,0 +1,169 @@
import { useMemo, useState } from 'react';
import styled from 'styled-components';
interface TagsInputProps {
title: string;
type: 'avoid' | 'favorite';
value: string[];
availableTags: string[];
excludedTags: string[];
onChange: (tags: string[]) => void;
}
export function TagsInput({
title,
type,
value,
availableTags,
excludedTags,
onChange,
}: TagsInputProps) {
const [query, setQuery] = useState('');
const matches = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return [];
const selected = new Set([...value, ...excludedTags]);
return availableTags.filter(tag =>
tag.toLowerCase().includes(normalizedQuery) && !selected.has(tag),
);
}, [availableTags, excludedTags, query, value]);
function addTag(tag: string) {
if (value.includes(tag)) return;
onChange([...value, tag]);
setQuery('');
}
function removeTag(tag: string) {
onChange(value.filter(item => item !== tag));
}
return (
<Column>
<Title>{title}</Title>
<Input
type="text"
placeholder={type === 'avoid' ? 'Например: лук' : ''}
value={query}
onChange={event => setQuery(event.target.value)}
/>
{matches.length > 0 && (
<Autocomplete>
{matches.map(match => (
<button
key={match}
type="button"
onClick={() => addTag(match)}
>
{match}
</button>
))}
</Autocomplete>
)}
<TagsWrap>
{value.map(tag => (
<Tag key={tag} $type={type}>
{tag}
<button type="button" onClick={() => removeTag(tag)}>
×
</button>
</Tag>
))}
</TagsWrap>
</Column>
);
}
const Column = styled.div`
flex: 1;
position: relative;
`;
const Title = styled.span`
display: block;
font-size: 14px;
font-weight: 600;
color: ${({ theme }) => theme.colors.darkGreen};
margin-bottom: 6px;
`;
const Input = styled.input`
width: 100%;
padding: 8px 10px;
border: 1px solid #e1e1e1;
border-radius: 8px;
font-size: 14px;
outline: none;
margin-bottom: 6px;
&:focus {
border-color: ${({ theme }) => theme.colors.green};
}
`;
const Autocomplete = styled.div`
position: absolute;
top: 65px;
left: 0;
width: 100%;
background: #fff;
border: 1px solid #e1e1e1;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
max-height: 120px;
overflow-y: auto;
z-index: 20;
button {
width: 100%;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
transition: 0.2s;
background: transparent;
border: 0;
text-align: left;
&:hover {
background: #f3f4f6;
}
}
`;
const TagsWrap = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px;
`;
const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>`
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 6px;
font-size: 13px;
background-color: ${({ $type }) => ($type === 'avoid' ? '#fce4ec' : '#f0f4c3')};
color: ${({ $type }) => ($type === 'avoid' ? '#c62828' : '#33691e')};
button {
margin-left: 6px;
cursor: pointer;
font-size: 15px;
opacity: 0.6;
border: 0;
background: transparent;
color: inherit;
&:hover {
opacity: 1;
}
}
`;

View File

@ -0,0 +1,13 @@
export function ArrowIcon() {
return (
<svg viewBox="0 0 24 24" fill="none">
<path
d="M19 9L12 16L5 9"
stroke="#0C3B2E"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -0,0 +1,9 @@
export function BagIcon() {
return <svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="44" height="44" rx="22" fill="#E6F696"/>
<path d="M18 18L18 17C18 14.7909 19.7909 13 22 13C24.2091 13 26 14.7909 26 17L26 18" stroke="#5EA12D" stroke-width="2" stroke-linecap="round"/>
<path d="M25 24V22" stroke="#5EA12D" stroke-width="2" stroke-linecap="round"/>
<path d="M19 24V22" stroke="#5EA12D" stroke-width="2" stroke-linecap="round"/>
<path d="M14 22C14 20.1144 14 19.1716 14.5858 18.5858C15.1716 18 16.1144 18 18 18H26C27.8856 18 28.8284 18 29.4142 18.5858C30 19.1716 30 20.1144 30 22V23C30 26.7712 30 28.6569 28.8284 29.8284C27.6569 31 25.7712 31 22 31C18.2288 31 16.3431 31 15.1716 29.8284C14 28.6569 14 26.7712 14 23V22Z" stroke="#5EA12D" stroke-width="2"/>
</svg>;
}

View File

@ -0,0 +1,11 @@
export function HeartIcon() {
return (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<rect width="44" height="44" rx="22" />
<path
d="M14.4507 23.9082L21.4033 30.4395C21.6428 30.6644 21.7625 30.7769 21.9037 30.8046C21.9673 30.8171 22.0327 30.8171 22.0963 30.8046C22.2375 30.7769 22.3572 30.6644 22.5967 30.4395L29.5493 23.9082C31.5055 22.0706 31.743 19.0466 30.0978 16.9261L29.7885 16.5273C27.8203 13.9906 23.8696 14.416 22.4867 17.3137C22.2913 17.723 21.7087 17.723 21.5133 17.3137C20.1304 14.416 16.1797 13.9906 14.2115 16.5273L13.9022 16.9261C12.2569 19.0466 12.4945 22.0706 14.4507 23.9082Z"
strokeWidth="2"
/>
</svg>
);
}

View File

@ -0,0 +1,20 @@
export function BagIcon() {
return (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<rect width="44" height="44" rx="22" fill="#E6F696" />
<path
d="M18 18L18 17C18 14.7909 19.7909 13 22 13C24.2091 13 26 14.7909 26 17L26 18"
stroke="#5EA12D"
strokeWidth="2"
strokeLinecap="round"
/>
<path d="M25 24V22" stroke="#5EA12D" strokeWidth="2" strokeLinecap="round" />
<path d="M19 24V22" stroke="#5EA12D" strokeWidth="2" strokeLinecap="round" />
<path
d="M14 22C14 20.1144 14 19.1716 14.5858 18.5858C15.1716 18 16.1144 18 18 18H26C27.8856 18 28.8284 18 29.4142 18.5858C30 19.1716 30 20.1144 30 22V23C30 26.7712 30 28.6569 28.8284 29.8284C27.6569 31 25.7712 31 22 31C18.2288 31 16.3431 31 15.1716 29.8284C14 28.6569 14 26.7712 14 23V22Z"
stroke="#5EA12D"
strokeWidth="2"
/>
</svg>
);
}

View File

@ -0,0 +1,92 @@
import {Dispatch, SetStateAction, useEffect, useRef} from 'react';
import { getCartUpdates } from '../api/promoApi';
import { normalizeProductImage } from '../utils/product';
import type { Product } from '../types/cart';
interface UseCartPollingParams {
enabled: boolean;
onProductsChange: Dispatch<SetStateAction<Product[]>>;
onComplete: () => void;
onLoadingChange: (isLoading: boolean) => void;
setDisplayText: Dispatch<SetStateAction<string>>;
}
export function useCartPolling({
enabled,
onProductsChange,
onComplete,
onLoadingChange,
setDisplayText,
}: UseCartPollingParams) {
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
async function tick() {
try {
onLoadingChange(true);
const update = await getCartUpdates();
if (cancelled) return;
if (update.status === 'complete') {
onLoadingChange(false);
onComplete();
return;
}
if (update.status === 'waiting') {
setDisplayText('Брок бегает по супермаркету...')
return;
}
if (update.status === 'clear') {
const nextProducts = update.payload?.chunk?.map(product => normalizeProductImage(product)) ?? [];
onProductsChange(nextProducts);
}
if (update.status === 'data') {
const chunk = update.payload?.chunk?.map(product => normalizeProductImage(product)) ?? [];
onProductsChange(current => {
const next = [...current];
chunk.forEach(product => {
const index = next.findIndex(item => item.name === product.name);
if (index === -1) {
next.push(product);
} else {
next[index] = {
...next[index],
n: next[index].n + product.n,
};
}
});
return next;
});
setDisplayText('Брок закинул пару товаров...')
}
timeoutRef.current = window.setTimeout(tick, 3000);
} catch (error) {
setDisplayText('Ищем Брока...')
timeoutRef.current = window.setTimeout(tick, 3000);
}
}
tick();
return () => {
cancelled = true;
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, [enabled, onComplete, onLoadingChange, onProductsChange]);
}

View File

@ -0,0 +1,53 @@
import { useMemo, useRef } from 'react';
interface Batch<T> {
ids: T[];
resolves: Array<(value: unknown) => void>;
rejects: Array<(reason?: unknown) => void>;
}
export function useDebounceBatch<T>(
handler: (items: T[]) => Promise<unknown>,
delay: number,
) {
const pendingRef = useRef<Batch<T> | null>(null);
const timerRef = useRef<number | null>(null);
return useMemo(() => {
return (item: T) => {
return new Promise((resolve, reject) => {
if (!pendingRef.current) {
pendingRef.current = {
ids: [],
resolves: [],
rejects: [],
};
}
pendingRef.current.ids.push(item);
pendingRef.current.resolves.push(resolve);
pendingRef.current.rejects.push(reject);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(async () => {
const batch = pendingRef.current;
pendingRef.current = null;
timerRef.current = null;
if (!batch) return;
try {
const result = await handler(batch.ids);
batch.resolves.forEach(resolveBatch => resolveBatch(result));
} catch (error) {
batch.rejects.forEach(rejectBatch => rejectBatch(error));
}
}, delay);
});
};
}, [delay, handler]);
}

21
src/main.tsx Normal file
View File

@ -0,0 +1,21 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { ThemeProvider } from 'styled-components';
import { App } from './App';
import { GlobalStyle } from './styles/GlobalStyle';
import { theme } from './styles/theme';
const root = document.getElementById('root');
if (!root) {
throw new Error('Root element #root was not found');
}
createRoot(root).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<GlobalStyle />
<App />
</ThemeProvider>
</React.StrictMode>,
);

17
src/styled-components.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
import type { CSSProp } from "styled-components";
import Theme from './theme';
type ThemeType = typeof Theme;
declare module "styled-components" {
export interface DefaultTheme extends ThemeType {
colors: ThemeType['colors'];
radius: ThemeType['radius'];
}
}
declare module "react" {
interface DOMAttributes<T> {
css?: CSSProp;
}
}

55
src/styles/GlobalStyle.ts Normal file
View File

@ -0,0 +1,55 @@
import { createGlobalStyle } from 'styled-components';
export const GlobalStyle = createGlobalStyle`
@font-face {
font-family: 'FallbackFont';
src: local('Helvetica');
size-adjust: 98.5%;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Roboto', 'FallbackFont', system-ui, -apple-system, 'Segoe UI', Helvetica, sans-serif;
}
html {
overflow: hidden;
}
body {
background-color: ${({ theme }) => theme.colors.pageBg};
background-image: url('/images/background-raspberry.png');
background-repeat: no-repeat;
background-attachment: fixed;
background-size: contain;
background-position: left top;
min-height: 100vh;
}
button {
-webkit-touch-callout: none;
user-select: none;
}
@keyframes opacityAnim {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
`;

19
src/styles/theme.ts Normal file
View File

@ -0,0 +1,19 @@
export const theme = {
colors: {
darkGreen: '#0C3B2E',
green: '#5EA12D',
greenHover: '#72B541',
lightGreen: '#E6F696',
paleGreen: '#F1FAF0',
gray: '#A3ADA2',
border: '#ebebeb',
white: '#ffffff',
pageBg: '#FF7D89',
},
radius: {
page: '24px',
card: '16px',
button: '12px',
small: '8px',
},
};

33
src/template.html Normal file
View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Автокорзина</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
rel="preload"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@100..900&display=swap"
id="style_preload"
as="style"
/>
<script nonce="**MY_PRETTY_CSP_NONCE**">
document.getElementById('style_preload').addEventListener('load', function() {
this.rel = 'stylesheet';
});
</script>
<noscript>
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@100..900&display=optional"
rel="stylesheet"
type="text/css"
/>
</noscript>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

25
src/types/api.ts Normal file
View File

@ -0,0 +1,25 @@
import type { Product } from './cart';
import type { User } from './user';
export interface InitialData {
existing?: number;
available_groups: string[];
initial_groups: string[];
days: number;
users: User[];
cart: Product[][];
excess?: number;
precision?: number;
}
export type CartUpdateStatus = 'clear' | 'data' | 'waiting' | 'complete';
export interface CartUpdateResponse {
status: CartUpdateStatus;
error_message?: string;
payload?: {
excess?: number;
precision?: number;
chunk?: Product[];
};
}

11
src/types/cart.ts Normal file
View File

@ -0,0 +1,11 @@
export type ProductUnit = 'g' | 'pcs';
export interface Product {
name: string;
n: number;
price: number;
total_price: number;
currency: string;
image?: string;
unit: ProductUnit;
}

12
src/types/user.ts Normal file
View File

@ -0,0 +1,12 @@
export type Gender = 'm' | 'f';
export interface User {
id?: number;
name: string;
gender: Gender;
age: number;
weight?: number;
height?: number;
avoid: string[];
favorite: string[];
}

9
src/utils/declension.ts Normal file
View File

@ -0,0 +1,9 @@
export function declension(number: number, titles: [string, string, string]): string {
const cases = [2, 0, 1, 1, 1, 2];
const idx = number % 100 > 4 && number % 100 < 20
? 2
: cases[Math.min(number % 10, 5)];
return titles[idx];
}

View File

@ -0,0 +1,6 @@
export function formatCurrency(value: number): string {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
}).format(Math.round(value * 100) / 100);
}

18
src/utils/product.ts Normal file
View File

@ -0,0 +1,18 @@
import type { Product } from '../types/cart';
export function normalizeProductImage(product: Product, size = '65,fit'): Product {
return {
...product,
image: product.image?.replace('{SIZE}', size),
};
}
export function getLargeProductImage(product: Product): string {
return product.image?.replace('65,fit', '335,fit') ?? '';
}
export function getProductAmount(product: Product): string {
return product.unit === 'g'
? `${product.n} г`
: `${product.n} шт`;
}

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

22
vite.config.ts Normal file
View File

@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import fs from 'node:fs';
export default defineConfig({
plugins: [react()],
server: {
host: 'local.hvatilo.ru',
port: 9876,
https: {
cert: fs.readFileSync('local.hvatilo.ru.pem'),
key: fs.readFileSync('local.hvatilo.ru-key.pem'),
},
open: '/src/template.html',
},
input: {
main: './src/template.html',
},
build: {
outDir: 'dist',
},
});