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
+256
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
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
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
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
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();
}
-881
View File
@@ -1,881 +0,0 @@
<!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>
<style nonce="**MY_PRETTY_CSP_NONCE**">
@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: #FF7D89;
background-image: url('/images/background-raspberry.png');
background-repeat: no-repeat;
background-attachment: fixed;
background-size: contain;
background-position: left top;
display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px; min-height: 100vh;
}
.container {
position: relative;
display: flex;
width: 100%;
max-width: 1228px;
background: #ffffff;
border-radius: 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
overflow: hidden;
height: 90vh;
}
/* --- Колонки --- */
.column { padding: 40px; flex: 1; width: 50%; overflow-y: auto;}
.column + .column { border-left: 1px solid #f0f0f0; }
/* --- Левая колонка (Фиксированная высота) --- */
.relative {position: relative;}
#title-block {margin-bottom: 32px;}
.col-left { display: flex; flex-direction: column; height:100%;max-width: 490px;}
h1 { font-size: 32px; line-height: 38px; font-weight: 700; color: #0C3B2E; margin-bottom: 8px; }
.subtitle { font-size: 16px;line-height: 19px; color: #A3ADA2; flex-shrink: 0; font-weight: 350;}
#summary-loader.hidden {visibility: hidden;}
.loader{
font-size: 16px;
line-height: 19px;
display:inline-block;
color:transparent;
-webkit-background-clip: text;
background-clip: text;
position: absolute;
}
.loader:after,
.loader:before {
content: 'Брок бегает по супермаркету...';
display: block;
position: absolute;
top: 0; bottom: 0; left: 0; right: 0;
}
.loader:before {
background: linear-gradient(79deg, #FFBA00 0%, #FFEC47 31%, #C4E30F 63%, #5EA12D 98%) 100%;
-webkit-background-clip: text;
background-clip: text;
animation: OpacityAnim 1s ease-in-out 0s infinite alternate;
}
.loader:after {
background: linear-gradient(79deg, #5EA12D 2%, #C4E30F 38%, #FFEC47 69%, #FFBA00 100%) 100%;
-webkit-background-clip: text;
background-clip: text;
animation: OpacityAnim 1s ease-in-out -1s infinite alternate;
}
@keyframes OpacityAnim {
0%{opacity: 1.0}
100%{opacity: 0.0}
}
.cart-summary { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-shrink: 0;width: 100%; }
.cart-summary > div {width: 100%;}
.summary-title { font-size: 20px; line-height: 24px; font-weight: 600; color: #0C3B2E; margin-bottom: 8px;}
.summary-days { color: #6ba531; }
.summary-details { font-size: 14px; color: #A3ADA2;display: flex; justify-content: start; align-items: center; gap: 8px;position: absolute;flex: 1;width: 100%;}
.summary-details.hidden { visibility: hidden; }
.summary-details span { font-weight: 350;}
.summary-details b {font-family: initial;font-size: 20px;line-height: 8px;margin-bottom: -2px;}
.summary-excess {background-color: #F1FAF0;padding:3px 6px 5px 6px;color:#6D8982;border-radius: 4px;font-size: 14px;font-weight: 350;}
.reorder-btn { padding: 8px 16px; background: transparent; border: 1px solid #6ba531; color: #6ba531; border-radius: 8px; cursor: pointer; font-size: 14px; transition: 0.2s; flex-shrink: 0; }
.reorder-btn:hover { background: #6ba531; color: #fff; }
/* Список и скролл */
.cart-scroll-wrapper { flex: 1; overflow-y: auto; overflow-x: hidden; padding-right: 4px; margin-bottom: 10px; position: relative; transition: max-height 0.3s ease;
display: flex;}
.cart-scroll-wrapper::-webkit-scrollbar { width: 6px; }
.cart-scroll-wrapper::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
.cart-scroll-wrapper::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 4px; }
.cart-items { list-style: none; }
.cart-item { display: flex; align-items: center; padding: 16px 0; border-bottom: 1px solid #f0f0f0; position: relative; transition: opacity 0.4s, transform 0.4s; }
.cart-item.replacing { opacity: 0.5; pointer-events: none; }
#empty-cart-loader {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;}
#empty-cart-loader.hidden { visibility: hidden; }
.item-image { width: 72px; height: 65px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 28px; margin-right: 16px; flex-shrink: 0; position: relative; overflow: hidden; }
/* Лоадер при замене */
.cart-item.replacing .item-image::after {
content: '';
position: absolute;
width: 30px; height: 30px;
border: 3px solid #e1e1e1;
border-top: 3px solid #6ba531;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.item-details { flex: 1; }
.item-name { font-size: 15px; color: #0C3B2E; margin-bottom: 4px; }
.item-price { font-size: 14px; color: #A3ADA2; }
/* Меню (Три точки) */
.menu-btn-wrapper { position: relative; }
.menu-btn { background: none; border: none; color: #b0b0b0; cursor: pointer; padding: 4px 8px; font-size: 20px; letter-spacing: 2px; border-radius: 4px; transition: 0.2s; }
.menu-btn:hover { background: #f3f4f6; color: #0C3B2E; }
.menu-dropdown { 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; display: none; z-index: 10; margin-top: 4px; }
.menu-dropdown.active { display: block; }
.menu-dropdown ul { list-style: none; }
.menu-dropdown li { padding: 10px 16px; font-size: 14px; color: #0C3B2E; cursor: pointer; transition: 0.2s; }
.menu-dropdown li:hover { background: #f3f4f6; }
.show-more { display: flex; align-items: center; justify-content: center; padding: 20px 0 10px; color: #6ba531; font-size: 14px; cursor: pointer; font-weight: 500; transition: 0.2s; flex-shrink: 0; gap: 4px;}
.show-more:hover { opacity: 0.8; }
.show-more svg { width: 14px; height: 14px; stroke: #6ba531; }
.show-more.hidden { display: none; }
.cart-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 20px; border-top: 1px solid #f0f0f0; flex-shrink: 0; }
.total-label { font-size: 16px; line-height: 19px; color: #0C3B2E;visibility: hidden;}
.cart-footer .icon-circle {visibility: hidden;}
.cart-footer .checkout-btn {visibility: hidden;}
#total-price { font-size: 20px; line-height: 24px; font-weight: 500; color: #0C3B2E; margin-top: 4px;visibility: hidden;}
.checkout-btn { background-color: #5EA12D;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; }
.total-wrap { display: flex; flex-direction: column; align-items: flex-start; }
.total-wrap > div { display: flex; align-items: center; }
.checkout-btn:hover { background-color: #72B541; }
.checkout-btn:focus, .checkout-btn:focus-visible { background-color: #72B541;box-shadow: 0 4px 10px rgba(12, 59, 46, 0.1); }
.checkout-btn:active { background-color: #4A8D19;}
.cart-footer.visible .total-label,
.cart-footer.visible .icon-circle,
.cart-footer.visible .checkout-btn,
.cart-footer.visible #total-price {visibility: visible;}
button {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Old versions of Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently
supported by Chrome, Edge, Opera and Firefox */
}
/* --- Правая колонка (Аккордеон) --- */
#filter-title {margin-bottom: 40px;}
h2 {font-size: 20px; line-height: 24px; margin-bottom: 8px;color: #0C3B2E;}
.settings-panel { border: 1px solid #ebebeb; border-radius: 16px;}
.accordion-item { border-bottom: 1px solid #ebebeb;position: relative;}
.accordion-item:last-child { border-bottom: none; }
.accordion-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; cursor: pointer; user-select: none; }
.header-left { display: flex; align-items: center; }
.icon-circle { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 16px; margin-right: 8px; flex-shrink: 0; }
.bg-green-light rect { fill: #E6F696; }
.bg-green-light path { stroke: #5EA12D; }
.bg-gray-light rect { fill: #E7EDE7; }
.bg-gray-light path { stroke: #A3ADA2; }
.header-title { font-size: 16px; line-height: 19px; font-weight: 500; color: #0C3B2E; }
.setting-desc { font-size: 14px; color: #A3ADA2; margin-top: 2px; }
.arrow-icon { color: #A3ADA2; transition: transform 0.3s ease; }
.arrow-icon svg { width: 16px; height: 16px; stroke: #A3ADA2; stroke-width: 2; }
.accordion-content { max-height: 0; overflow: hidden; transition: max-height 0.4s ease, padding 0.3s ease; padding: 0 24px; position: relative; }
.accordion-item.open .accordion-content { max-height: 500px; padding: 0 24px 20px; }
.accordion-item.open .arrow-icon { transform: rotate(180deg); }
/* Дни */
.days-options { display: flex; gap: 12px; }
.day-btn { flex: 1; height: 52px;max-width:140px;border: 2px solid #e1e1e1; border-radius: 8px; background: #ffffff; font-size: 16px; line-height:19px;font-weight: 350; color: #0C3B2E; cursor: pointer; transition: 0.2s; }
.day-btn:hover { border-color: #6ba531; }
.day-btn.active { background: #E6F696; border-color: #5EA12D; }
.day-btn:focus { outline: none; }
/* Семья (Кто будет есть?) */
.family-list { display: flex; flex-direction: column; gap: 12px; }
.family-member { display: flex; align-items: center; padding: 12px 16px; border: 1px solid #ebebeb; border-radius: 12px; background: #fff; transition: 0.2s; cursor: pointer; }
.family-member:hover { border-color: #c0c0c0; box-shadow: 0 2px 8px rgba(0,0,0,0.02); }
.family-member .avatar { width: 46px; height: 46px; border-radius: 50%; background: #f8f9fa; display: flex; align-items: center; justify-content: center; margin-right: 14px; overflow: hidden;font-size: 12px;}
.family-member .avatar img {max-width: 100%;}
.family-member .info { flex: 1; display: flex; flex-direction: column; pointer-events: none; }
.family-member .info .name { font-size: 16px; font-weight: 600; color: #0C3B2E; }
.family-member .info .age { font-size: 14px; color: #A3ADA2; }
.family-member .delete-member { color: #d1d5db; cursor: pointer; font-size: 18px; padding: 0 8px; transition: 0.2s; }
.family-member .delete-member:hover { color: #e53935; }
.family-member:nth-child(n+1) .avatar {background-color: #B4AAFF}
.family-member:nth-child(n+2) .avatar {background-color: #CEE0FF}
.family-member:nth-child(n+3) .avatar {background-color: #E6F696}
.family-member:nth-child(n+4) .avatar {background-color: #FFF488}
.family-member:nth-child(n+5) .avatar {background-color: #FFD623}
.add-member-btn-wrapper {width: 100%;display: flex;align-items: center;justify-content: center;}
.add-member-btn { padding:18px 23px;border-radius:12px;height:52px;display: flex; align-items: center; justify-content: center; margin-top: 16px; color: #6ba531; font-size: 15px; font-weight: 350; cursor: pointer; transition: 0.2s; background-color: transparent;border:0;}
.add-member-btn:hover { background-color: #F7FBF6;}
.add-member-btn:active { background-color: #EEF8EB;}
.add-member-btn:focus { background-color: #EEF8EB;outline: none;}
.add-member-btn span { font-size: 20px; margin-left: 8px; font-weight: 300; }
/* ----- БЛОК: Категории в предпочтениях ----- */
.category-selector-block .category-header h3 { font-size: 16px; line-height: 19px; color: #0C3B2E; margin-bottom: 4px;font-weight: 350; }
.category-selector-block .category-header p { font-size: 14px; color: #A3ADA2; margin-bottom: 16px; font-weight: 350;}
.categories-wrapper { display: flex; flex-wrap: wrap; gap: 8px; }
.toggle-categories-btn, .category-btn {
height: 32px;
}
.category-btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: background-color 1.2s;
}
.category-btn:focus {
outline: none;
background-color: #9FC6A4;
box-shadow: 0 4px 10px rgba(12, 59, 46, 0.1);
}
.category-btn.active {
background-color: #0C3B2E; /* Темно-зеленый как на скриншоте */
color: #F1FAF0;
}
.category-btn.inactive {
background-color: #F1FAF0; /* Светло-зеленый */
color: #0C3B2E;
}
.category-btn.inactive:hover {
background-color: #e4f0de;
}
.spacer-width {
visibility: hidden;
position: relative;
}
.spacer-over {
position: absolute;
z-index: 2;
}
.loading-border {
position: relative;
overflow: hidden;
}
.loading-border .spacer-over,
.loading-border:before,
.loading-border:after {
color:#0C3B2E;
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.loading-border::before {
content: '';
background: linear-gradient(79deg, #FFBA00 0%, #FFEC47 31%, #C4E30F 63%, #5EA12D 98%) 100%;
animation: OpacityAnim 1s ease-in-out 0s infinite alternate;
}
.loading-border::after {
content: '';
background: linear-gradient(79deg, #5EA12D 2%, #C4E30F 38%, #FFEC47 69%, #FFBA00 100%) 100%;
animation: OpacityAnim 1s ease-in-out -1s infinite alternate;
}
.toggle-categories-btn {
height: 32px;
min-width: 32px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f2f9ef;
border: none;
border-radius: 8px;
cursor: pointer;
transition: 0.2s;
}
.toggle-categories-btn:hover { background-color: #e4f0de; }
.toggle-categories-btn svg { width: 18px; height: 18px; stroke: #0C3B2E; transition: transform 0.3s ease; }
.toggle-categories-btn.collapsed svg { transform: rotate(180deg); }
/* Адрес */
.address-input-wrap { position: relative; }
.address-input-wrap input { width: 100%; padding: 12px 36px 12px 16px; border: 1px solid #e1e1e1; border-radius: 8px; font-size: 14px; color: #0C3B2E; background: #fafafa; outline: none; }
.address-input-wrap .clear-input { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: #b0b0b0; cursor: pointer; font-size: 18px; }
/* --- Модальное окно (Добавить/Редактировать человека) --- */
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); display: none; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
.modal-content { 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;}
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.modal-header h2 { font-size: 20px; font-weight: 600; color: #0C3B2E; }
.modal-header .close-modal { font-size: 24px; color: #b0b0b0; cursor: pointer; line-height: 1; }
.modal-header .close-modal:hover { color: #0C3B2E; }
.categories-grid {gap: 10px;display: flex;flex-wrap: wrap;}
.category-header {visibility: hidden;}
#expand-categories {visibility: hidden;}
#expand-categories.show-more {padding: 6px 8px 6px 16px;display: flex;gap: 8px;justify-content: center;align-items: center;width: auto;color:#0C3B2E; font-size: 14px;font-weight: 350;}
.visible #expand-categories {visibility: visible;}
.visible .category-header {visibility: visible;}
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 14px; color: #0C3B2E; margin-bottom: 6px; }
.form-group input { width: 100%; padding: 10px 12px; border: 1px solid #e1e1e1; border-radius: 8px; font-size: 14px; outline: none; transition: 0.2s; }
.form-group input:focus { border-color: #6ba531; }
.form-row { display: flex; gap: 16px; margin-bottom: 16px; }
.form-row .form-group { flex: 1; margin-bottom: 0; }
.hint { display: block; font-size: 12px; color: #A3ADA2; margin-top: 4px; }
.gender-toggles { display: flex; gap: 8px; margin-bottom: 16px; }
.gender-btn { flex: 1; padding: 10px; border: 1px solid #e1e1e1; border-radius: 8px; background: #ffffff; font-size: 14px; cursor: pointer; transition: 0.2s; }
.gender-btn.active { background: #e9f6d9; border-color: #e9f6d9; font-weight: 500; }
/* Внутренние стили для модального окна (Запрещенные / Любимые) */
.modal-prefs-grid { display: flex; gap: 16px; margin-bottom: 20px; border-top: 1px solid #f0f0f0; padding-top: 16px; }
.modal-prefs-grid .pref-col { flex: 1; position: relative; }
.modal-prefs-grid .pref-col-title { display: block; font-size: 14px; font-weight: 600; color: #0C3B2E; margin-bottom: 6px; }
.modal-prefs-grid .pref-input { width: 100%; padding: 8px 10px; border: 1px solid #e1e1e1; border-radius: 8px; font-size: 14px; outline: none; margin-bottom: 6px; }
.modal-prefs-grid .pref-input:focus { border-color: #6ba531; }
.modal-prefs-grid .autocomplete-list { 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; display: none; z-index: 20; }
.modal-prefs-grid .autocomplete-list.active { display: block; }
.modal-prefs-grid .autocomplete-list div { padding: 8px 12px; cursor: pointer; font-size: 14px; transition: 0.2s; }
.modal-prefs-grid .autocomplete-list div:hover { background: #f3f4f6; }
.modal-prefs-grid .tags-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
.modal-prefs-grid .tag { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 6px; font-size: 13px; }
.modal-prefs-grid .tag-red { background-color: #fce4ec; color: #c62828; }
.modal-prefs-grid .tag-green { background-color: #f0f4c3; color: #33691e; }
.modal-prefs-grid .tag-close { margin-left: 6px; cursor: pointer; font-size: 15px; opacity: 0.6; }
.modal-prefs-grid .tag-close:hover { opacity: 1; }
.save-btn { width: 100%; padding: 12px; background: #6ba531; color: #fff; border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; transition: 0.2s; }
.save-btn:hover { background: #5b8f29; }
@media (max-width: 900px) {
.container { flex-direction: column; }
.column { width: 100%; padding: 20px;}
.column + .column { border-left: none; border-top: 1px solid #f0f0f0; }
.form-row, .modal-prefs-grid { flex-direction: column; gap: 16px; }
.col-left { min-height: 400px; }
}
/* --- Стили для попапа --- */
#cookie-popup {
position: fixed;
bottom: 25px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: #ffffff;
border-radius: 20px;
border: 2px solid #E7EDE7;
box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.05), 0px 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: 0;
visibility: hidden;
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Класс для показа попапа */
#cookie-popup.show {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
/* Класс для скрытия (при нажатии) */
#cookie-popup.hide {
opacity: 0;
visibility: hidden;
transform: translateX(-50%) translateY(30px);
}
/* Иконка печенья */
.cookie-icon-wrapper {
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;
}
.cookie-icon-wrapper img {
width: 85%;
height: 85%;
object-fit: contain;
}
/* Текст */
.cookie-text {
margin: 0;
flex-grow: 1;
color: #0C3B2E; /* Темный зелено-серый цвет текста */
font-size: 14px;
line-height: 20px;
letter-spacing: 0.01em;
}
.cookie-text .highlight {
color: #69a342; /* Зеленый цвет для слова "куки" */
font-weight: 600;
}
/* Кнопка */
#cookie-accept {
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;
}
#cookie-accept:hover {
background-color: #c2d6c0;
}
#cookie-accept:active {
transform: scale(0.96);
}
/* CSS с префиксами */
/* Обертка на весь экран */
.product-modal-overlay {
position: fixed;
display: none;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
justify-content: center;
align-items: center;
z-index: 1000;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.product-modal-overlay.show {
display: flex;
}
/* Само окно */
.product-modal-window {
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;
box-sizing: border-box;
}
/* Кнопка закрытия (крестик) */
.product-modal-close {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: #b3b3b3;
font-size: 24px;
line-height: 1;
cursor: pointer;
padding: 5px;
}
.product-modal-close:hover {
color: #777;
}
/* Блок с картинкой */
.product-modal-image {
display: flex;
justify-content: center;
margin: 20px 0;
}
.product-modal-image img {
width: 160px;
height: auto;
object-fit: contain;
}
/* Заголовок */
.product-modal-title {
font-size: 18px;
font-weight: 700;
text-align: center;
color: #222222;
line-height: 1.4;
margin: 0 0 10px 0;
}
/* Подзаголовок (цена и количество) */
.product-modal-details {
font-size: 15px;
text-align: center;
color: #555555;
margin: 0 0 24px 0;
}
/* Основная зеленая кнопка */
.product-modal-btn {
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;
transition: background-color 0.2s;
}
.product-modal-btn:hover {
background-color: #4e9247;
}
/* Ссылка внизу */
.product-modal-link {
display: block;
text-align: center;
margin-top: 15px;
color: #5ba653;
font-size: 14px;
text-decoration: none;
font-weight: 500;
}
.product-modal-link:hover {
text-decoration: underline;
}
/* Адаптивность для мобильных устройств */
@media (max-width: 600px) {
#cookie-popup {
flex-wrap: wrap;
justify-content: center;
padding: 20px;
bottom: 15px;
text-align: center;
border-radius: 16px;
gap: 12px;
}
.cookie-icon-wrapper {
width: 50px;
height: 50px;
}
.cookie-text {
font-size: 14px;
width: 100%;
order: 2;
}
#cookie-accept {
width: 100%;
justify-content: center;
padding: 12px;
order: 3;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Левая колонка -->
<div class="column col-left" id="app">
<div id="title-block">
<h1>Автокорзина</h1>
<p class="subtitle">Собираем продукты под ваши настройки</p>
</div>
<div class="cart-summary">
<div>
<div class="summary-title">Ваша корзина на <span class="summary-days"> - </span></div>
<div class="relative">
<div><div class="loader hidden" id="summary-loader">Брок бегает по супермаркету...</div></div>
<div class="summary-details" id="summary-details">
<span>84 товара</span>
<b>&bull;</b>
<span>12 020 ₽</span>
<div class="summary-excess">С запасами до 13 дней</div>
</div>
</div>
</div>
</div>
<div class="cart-scroll-wrapper" id="scroll-wrapper">
<ul class="cart-items" id="cart-list"></ul>
<img src="/images/empty-cart-loader.svg" alt="сбор корзины" id="empty-cart-loader">
</div>
<div class="show-more" id="show-more-btn">
Показать ещё <span id="remaining-count">-</span> товаров
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</div>
<div class="cart-footer">
<div class="total-wrap">
<div>
<div class="icon-circle bg-green-light">
<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>
</div>
<div>
<span class="total-label">Итого</span>
<div id="total-price"> - </div>
</div>
</div>
</div>
<button class="checkout-btn">Оформить заказ</button>
</div>
</div>
<!-- Правая колонка -->
<div class="column">
<div id="filter-title">
<h2>Настройте корзину под себя</h2>
<p class="subtitle">Чем больше вы расскажете, тем точнее будет подбор</p>
</div>
<div class="settings-panel" id="settings-panel">
<div class="accordion-item">
<div class="accordion-header">
<div class="header-left">
<div class="icon-circle bg-green-light"><img src="/images/calendar.svg" alt="календарь" /></div>
<span class="header-title">На сколько дней собираем корзину?</span>
</div>
<div class="arrow-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 9L12 16L5 9" stroke="#0C3B2E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<div class="accordion-content">
<div class="days-options">
<button class="day-btn" data-days="1">1</button>
<button class="day-btn" data-days="3">3</button>
<button class="day-btn active" data-days="7">7</button>
</div>
</div>
</div>
<div class="accordion-item open">
<div class="accordion-header">
<div class="header-left">
<div class="icon-circle bg-green-light"><img src="/images/family.svg" alt="семья" /></div>
<div>
<div class="header-title">Кто будет есть?</div>
<div class="setting-desc" id="family-desc">2 человека</div>
</div>
</div>
<div class="arrow-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 9L12 16L5 9" stroke="#0C3B2E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<div class="accordion-content">
<div class="family-list" id="family-list">
<!-- Семья рендерится через JS -->
</div>
<div class="add-member-btn-wrapper">
<button class="add-member-btn" id="addMemberBtn">
Добавить человека <span>+</span>
</button>
</div>
</div>
</div>
<!-- Вкладка Предпочтения (Категории) -->
<div class="accordion-item" id="prefs-accordion">
<div class="accordion-header">
<div class="header-left">
<div class="icon-circle bg-gray-light" id="preferences-icon">
<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"/>
<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" stroke-width="2"/>
</svg>
</div>
<div>
<div class="header-title">Предпочтения</div>
<div class="setting-desc" id="prefs-desc">Не указаны</div>
</div>
</div>
<div class="arrow-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 9L12 16L5 9" stroke="#0C3B2E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<div class="accordion-content">
<div class="category-selector-block">
<div class="category-header">
<h3>Что добавить в корзину?</h3>
<p>Выберите группы продуктов, которые хотите видеть в корзине</p>
</div>
<div class="categories-wrapper">
<div class="categories-grid" id="categories-grid">
<button class="toggle-categories-btn" id="expand-categories">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"></polyline></svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Модальное окно добавления/редактирования человека с НОВЫМИ ПОЛЯМИ -->
<div class="modal-overlay" id="personModal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">Добавить человека</h2>
<span class="close-modal" id="closeModalBtn">×</span>
</div>
<div class="modal-body">
<div class="form-group">
<label>Имя</label>
<input type="text" id="inputName" placeholder="Имя">
</div>
<div class="form-group">
<label>Пол</label>
<div class="gender-toggles" id="genderToggles">
<button class="gender-btn" data-gender="f">Женский</button>
<button class="gender-btn active" data-gender="m">Мужской</button>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Возраст</label>
<input type="number" id="inputAge" value="30">
<span class="hint">от 7 лет</span>
</div>
<div class="form-group">
<label>Вес</label>
<input type="number" id="inputWeight" value="87">
</div>
<div class="form-group">
<label>Рост</label>
<input type="number" id="inputHeight" value="177">
</div>
</div>
<!-- ВОЗВРАЩЁННЫЕ ПОЛЯ: Запрещённые и Любимые -->
<div class="modal-prefs-grid">
<div class="pref-col">
<span class="pref-col-title">Запрещённые</span>
<input class="pref-input" type="text" placeholder="Например: лук" data-type="avoid">
<div class="autocomplete-list" data-type="avoid"></div>
<div class="tags-wrap" data-target="avoid"></div>
</div>
<div class="pref-col">
<span class="pref-col-title">Любимые</span>
<input class="pref-input" type="text" data-type="favorite">
<div class="autocomplete-list" data-type="favorite"></div>
<div class="tags-wrap" data-target="favorite"></div>
</div>
</div>
<button class="save-btn" id="savePersonBtn">Сохранить</button>
</div>
</div>
</div>
<div class="product-modal-overlay" id="modalProductOverlay">
<div class="product-modal-window">
<button class="product-modal-close" aria-label="Закрыть">&times;</button>
<div class="product-modal-image">
<!-- Замените src на ваш реальный URL картинки -->
<img src="" id="modalProductImage" alt="Мороженое Baskin Robbins">
</div>
<h2 class="product-modal-title" id="modalProductTitle">Мороженое "Baskin Robbins" Банан с клубникой, 90 мл</h2>
<p class="product-modal-details"><span id="modalProductQuantity">1 шт</span> · <span id="modalProductPrice">200 ₽</span></p>
<button class="product-modal-btn" id="modalProductReplaceBtn">Заменить</button>
<a href="#" class="product-modal-link" id="modalProductAnotherBtn">Ещё вариант</a>
</div>
</div>
<div id="cookie-popup">
<div class="cookie-icon-wrapper">
<img src="/images/cookie.png" alt="Cookie icon">
</div>
<p class="cookie-text">
Мы используем <a class="highlight" href="/cookies_and_recommendations.html#c" target="_blank">куки</a> и <a class="highlight" href="/cookies_and_recommendations.html#r" target="_blank">рекомендательные технологии</a>, это помогает
улучшать сервис и запоминать ваши настройки
</p>
<button id="cookie-accept">Хорошо</button>
</div>
<!--<button class="reorder-btn">Начать заново</button>-->
<script nonce="**MY_PRETTY_CSP_NONCE**" src="autocart.js"></script>
</body>
</html>
-2548
View File
File diff suppressed because it is too large Load Diff
+136
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;
`;
+297
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;
}
`;
+99
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;
}
`;
+165
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;
}
}
`;
+150
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;
}
`;
@@ -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')};
}
`;
+157
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;
}
`;
+53
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;
}
`;
+237
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;
}
`;
+350
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;
}
`;
+169
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;
}
}
`;
+13
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>
);
}
+9
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>;
}
+11
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>
);
}
@@ -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>
);
}
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Рекомендательные технологии и куки</title>
</head>
<body>
</body>
</html>
+92
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]);
}
+53
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]);
}
-429
View File
@@ -1,429 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=5.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Хватило.ру - персональный помощник по питанию</title>
<meta name="description" content="Приложение Hvatilo собирает список продуктов, подстраивается под наличие в магазине и помогает готовить всей семьей без лишних трат. Планируйте рацион легко.">
<meta name="keywords" content="планировщик питания, приложение для покупок продуктов, автокорзина, список покупок онлайн, рацион для семьи, помощник по питанию">
<link rel="apple-touch-icon" href="/app/icons/logo512.png">
<link rel="icon" href="/app/icons/favicon.ico">
<meta name="theme-color" content="#000000">
<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=Onest: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=Onest:wght@100..900&display=swap"
rel="stylesheet"
type="text/css"
/>
</noscript>
<style nonce="**MY_PRETTY_CSP_NONCE**">
html, body {height: 100vh;overflow: hidden;}
body {background: #FCFFFB;padding:0;margin:0;font-family: 'Onest', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, sans-serif;}
#scroller {scroll-snap-type: y mandatory;overflow-y: scroll;overflow-x:hidden;height: 100vh;scroll-behavior: smooth;}
.container {max-width: 1440px;margin: 0 auto;position: relative;width:100%;}
.text_secondary {color:#4e7067;}
h1 {font-size: 254px;padding:0;margin:0;}
h2 {font-size: 48px;color:#0C3B2E;text-transform: uppercase;margin:0;padding:0;}
h3 {font-size: 18px;color:#0C3B2E;font-weight: bold;padding:0;margin:0;}
p.description {font-size: 18px;color:#4e7067;padding:0;margin:0;}
p.description_large {font-size: 24px;color:#4e7067;padding:0;margin:0;}
a:focus {outline: none;}
span.nobreak {white-space: nowrap;}
#top_logo {font-size: 20px;line-height: 24px;display: flex;align-items: center;gap:6px;height:24px;margin-top:8px;}
#top_logo img {width: 24px;}
#top_logo a {font-weight: bold;color: #0C3B2E;text-decoration: none;}
#top_line_container {position: fixed;top:0;width: 100%;background-color: #FCFFFB;user-select: none;z-index: 11;min-width: 360px;}
#top_line {position:relative;display: flex;flex-direction: row;justify-content: space-around;padding:18px 0;height:80px;box-sizing: border-box; border-bottom: 1px solid #e7ede7;}
#top_menu {display:flex;flex-direction:row;gap: 40px;margin-top:13px;height:18px;align-items: center;}
#top_menu a {color: #4e7067;font-size:14px;font-weight: 500;line-height: 17px;text-decoration: none;}
#top_menu a:hover {color: #5EA12D;}
#top_menu a:focus {outline: none;}
#try_button {background-color: #FFBA00;padding:13px 43px;font-weight: bold;border-radius: 100px;color:#0C3B2E;text-decoration: none;
display: flex;}
.page_container {width: 100%;min-height: 100vh;display: flex;flex-direction: column;align-items: center;justify-content: center;scroll-snap-align: start;min-width: 360px;padding-top: 39px;}
#page_index {}
#page_index_content {align-items: start;}
#page_index p {font-size: 18px;}
.container_row {display: flex;flex-direction: row;gap: 34px;align-items: flex-end}
.big_index_greeting {font-size:254px;font-weight: 900;line-height: 214px;color:#0c3b2e;text-transform: uppercase;letter-spacing: -0px;}
#page_index_store_btns {height:52px;display: flex;flex-direction: row;gap:30px;align-items: center;padding-top:40px;}
#page_index_store_btns a {display: flex;align-items: center;color:#4e7067;font-weight: bold;padding:14px 0;text-decoration: none;}
#page_index_store_btns img {height: 24px;margin-right: 12px;}
#page_autobasket {}
#page_autobasket_content {display: flex;flex-direction: column;gap: 64px;}
#page_autobasket_content_description {text-align: center;}
#page_autobasket_content_description p {font-size: 24px;color: #4e7067;}
#page_autobasket_images {display: flex;flex-direction: row;gap: 20px;justify-content: center;}
#page_autobasket_images div {display: flex;flex-direction: column; gap: 16px;max-width: 387px;text-align: center;align-items: center;}
#page_autobasket_images img {max-width: 100%;}
#page_autobasket_images p {font-size: 18px;color:#4e7067;padding:0;margin:0;}
.autobasket_image_wrapper {aspect-ratio: 300/317;background-color: #edfaad;border-radius: 24px;justify-content: center;}
#page_meal_plan {}
#page_meal_plan_content {display: flex;flex-direction: row;gap:20px;padding:0 120px;justify-content: space-around;}
#page_meal_plan_content_description {display: flex;flex-direction: column;gap:40px;min-width: 400px;}
#page_meal_plan_broc {max-width:529px;border-radius: 24px;}
#page_meal_plan_items {gap: 24px;display: flex;flex-direction: column;}
.page_meal_plan_item {display: flex;flex-direction: row;gap: 16px;max-width: 650px;}
.page_meal_plan_item p {font-size:18px;color:#4e7067;padding: 0;margin:0;}
.page_meal_plan_item img {width:54px;}
.page_meal_plan_item_text {display: flex;flex-direction: column;gap:8px;}
.page_meal_plan_item_text span {white-space: nowrap;}
#page_how_it_works {display: flex;flex-direction: column;justify-content: space-between;padding-top:110px;box-sizing: border-box;}
#page_how_it_works_content {text-align: center;}
#page_how_it_works_step {}
#page_how_it_works_steps {display: flex;flex-direction: row;justify-content: space-between;margin-top:64px;font-size:18px;}
#page_how_it_works_steps .step-image {width:80px;}
.page_how_it_works_steps_arrow {display: flex;justify-content: center;align-items: center;}
#page_how_it_works_step_1 {position: relative;}
#page_how_it_works_step_1_arrow {height:30px;align-self: center}
#page_how_it_works_step_2 {}
#page_how_it_works_step_2_arrow {height:30px;}
#page_how_it_works_step_3 {position: relative;align-items: end;display: flex;}
#page_how_it_works_step_3 img {position:absolute;width:240px;top:-170px;}
#page_how_it_works_info {background-color: #edfaae;width:100%;border-radius: 24px;position: relative;display: flex;flex-direction: row;box-sizing: border-box;min-height:320px;margin-top:60px;}
#page_how_it_works_info_image {position: relative;height:320px;margin-top:70px;}
#page_how_it_works_info_image img {position: absolute;max-width: 419px;left:-70px;top:-140px;}
#page_how_it_works_info_content {font-size: 24px;text-align: left;margin-left:340px;margin-top:25px;display: flex;flex-direction: column;justify-content: space-around;}
#page_how_it_works_info_content ul {align-self: center;justify-content: center;}
#page_how_it_works_info_content ul li::marker {padding-top:0;}
#page_how_it_works_info_content ul li {list-style-type: disc;padding-top:3px;padding-bottom:24px;}
#page_how_it_works_info_content ul li span {margin-top: -28px;display: flex;}
#page_meal_plan_broc_small {display: none;}
#page_footer {height: 136px;width: 100%;padding-top:15px;}
#page_footer > div {display: flex;align-items: center;justify-content: space-between;}
#page_footer a {color:#5EA12D;text-decoration: none;}
#bottom_logo {font-size: 20px;line-height: 24px;font-weight: bold;color: #0C3B2E;display: flex;align-items: center;gap:6px;height:24px;margin-top:8px;}
#bottom_logo img {width: 24px;}
#bottom_contacts_small {display: none;}
br.small {display: none;}
br.medium {display: none;}
br.big {display: initial;}
@media (max-width: 1360px){
#page_how_it_works_steps {padding: 0 20px;}
#page_footer {padding-bottom: 80px;}
}
@media (max-width: 1280px){
.big_index_greeting {font-size: 204px;line-height: 185px;}
p.description_large {font-size: 22px;}
.page_container {padding-top:0;}
}
@media (max-width: 1100px){
.big_index_greeting {font-size: 174px;line-height: 155px;}
p.description_large {font-size: 18px;}
#page_autobasket_images {max-width: 100%;padding: 0 20px;}
#page_autobasket_images > div {max-width: 33%;}
#page_autobasket_images div img {max-width: 100% !important;}
#page_how_it_works_info_content {font-size: 20px;}
}
@media (max-width: 960px){
.big_index_greeting {font-size: 144px;line-height: 135px;}
#page_index p {font-size: 14px;}
#top_line {justify-content: space-between;padding: 18px;}
#top_menu {display: none;}
p.description_large br {display: none;}
#page_autobasket_content_description p {font-size: 20px;}
.page_container {min-height: auto;padding-top:100px;}
#page_meal_plan_content {padding: 0 20px;box-sizing: border-box;}
#page_how_it_works_steps {flex-direction: column; display: flex;align-items: center;gap: 24px;color:#0C3B2E;}
#page_how_it_works_steps > div {display: flex;flex-direction: row;gap: 20px;}
#page_how_it_works_steps > div img {height: auto;max-width: 80px;max-height: 80px;}
#page_how_it_works_steps > div:nth-child(3) {flex-direction: row-reverse;}
.page_how_it_works_steps_arrow {display: none !important;}
#page_how_it_works_step_3 img {top:0;position: relative;width: auto;}
#page_how_it_works_info_content br {display: none;}
}
@media (max-width: 800px){
.big_index_greeting {font-size: 144px;line-height: 135px;}
#page_index p {font-size: 24px;text-align: center}
#page_index_content h1 + div {flex-direction: column-reverse;}
#page_index_content h1 + div div {width: 100%;}
#page_index_store_btns {justify-content: center;}
p.description_large {font-size: 20px;}
#page_autobasket_content_description p {font-size: 16px;}
#page_meal_plan_broc {display: none;}
#page_how_it_works_info_content {margin-left: 280px;color:#0C3B2E;}
#page_how_it_works_info_image img {max-width: 360px;}
#page_footer {padding-left: 40px;padding-right: 20px;}
}
@media (max-width: 600px){
br.big {display: none;}
br.medium {display: initial !important;}
#page_index {height: 100vh;padding-top: 0;}
.big_index_greeting {font-size: 85px;line-height: 81px;text-align: left;max-width: 80%;margin: 0 20px;}
.big_index_greeting:last-child {text-align: right;}
#page_index p {font-size: 24px;text-align: center}
#page_index_content {width: 100%;max-width: 480px;}
#page_index_content h1 + div {flex-direction: column-reverse;}
#page_index_content h1 + div div {width: 100%;}
#page_index_store_btns {justify-content: center;}
#page_meal_plan_content_description {gap: 30px;}
#page_meal_plan_content {gap: 0;}
#page_meal_plan_broc_small {display: block;border-radius: 24px;}
#page_autobasket_content_description p {font-size: 22px;padding: 0 15px;}
#page_autobasket_images {flex-direction: column;}
#page_autobasket_images p {font-size: 20px;}
#page_autobasket_images div {max-width: 100% !important;}
#page_autobasket_images > div {flex-direction: row;}
.autobasket_image_wrapper img {max-width: 100% !important;}
#page_autobasket_images > div > img,
.autobasket_image_wrapper {max-width: 50% !important;}
h3 {font-size: 22px;}
#page_how_it_works_info {height: 280px;}
#page_how_it_works_info_image img {max-width: 220px;}
#page_how_it_works_step_3 img { max-width: 120px !important;max-height: 120px !important;}
#page_how_it_works_step_3 {width: 100%;align-items: center;}
#page_how_it_works_step_3 p {width: 100%;}
#page_how_it_works_info_content {margin-left: 100px;color:#0C3B2E;}
#page_footer {margin-top: 60px;}
#page_footer > div {flex-direction: column;align-items: flex-start;padding-left:10px;gap: 16px;}
#page_footer > div {font-size: 14px;}
#page_footer a {color:#4e7067;}
#bottom_logo {margin-bottom: 32px;}
#bottom_contacts_header {font-weight: bold;margin-bottom: 4px;}
#bottom_contacts_header {font-size:14px;}
#bottom_contacts {margin-bottom: 8px;}
#bottom_contacts_big {display: none;}
#bottom_contacts_small {display: flex;color:#4e7067;padding-bottom: 80px;}
}
@media (max-width: 420px){
br.small {display: initial !important;}
#page_index_content {padding-top:0;width: 100%;}
.big_index_greeting {font-size: 65px;line-height: 61px;text-align: left;max-width: 80%;margin: 0 20px;}
.big_index_greeting:last-child {text-align: right;}
#page_index p {font-size: 20px;text-align: center}
#top_line {padding:10px 20px;height:52px;}
#top_logo span {display: none;}
h2 {font-size: 32px;}
#page_meal_plan_content_description {max-width: 100%;min-width: 320px;}
#page_meal_plan_content_description h2 {text-align: center;}
#page_how_it_works_content {padding: 0 20px;box-sizing: border-box;}
#try_button {font-size: 12px;padding:8.5px 35.5px;}
p.text_secondary {margin: 0;}
#page_autobasket_images p {font-size: 18px;}
#page_autobasket_images > div {flex-direction: column;}
#page_autobasket_images div {gap: 8px;}
#page_autobasket_images div div {gap: 2px;}
#page_autobasket_images div img {max-width: 100% !important;}
#page_autobasket_content_description {max-width: 1500px;}
p.description_large {font-size: 16px;}
#page_autobasket_content_description h2 {padding-bottom: 24px;}
#page_autobasket_content_description p {font-size: 16px;margin: 0;}
#page_how_it_works_info {height: auto;}
#page_how_it_works_steps {font-size: 14px;padding:0;align-items: initial;margin-top: 20px;}
#page_how_it_works_steps p {display: flex;align-content: center;align-items: center;width: 100%;justify-content: center;}
#page_how_it_works_steps > div {width: 100%;}
#page_how_it_works_info {margin-top:20px; display: flex;flex-direction: column-reverse;}
#page_how_it_works_info_content {margin-left:0;font-size: 16px;}
#page_how_it_works_info ul {margin-top:25px;margin-right: auto;}
#page_how_it_works_info_content ul li:last-child {padding-bottom: 0;}
#page_how_it_works_info_image {height: auto;margin-top: -15px;margin-bottom: 20px; }
#page_how_it_works_info_image img {position: relative;top:initial;left:initial;}
#page_footer > div {font-size: 12px;}
#bottom_contacts_header {font-size:12px;}
}
@media (max-height: 1000px){
#page_autobasket_content {gap: 20px;}
#page_autobasket_images div img {max-width: 300px;}
}
@media (max-height: 800px),
(max-width: 1000px)
{
#scroller {scroll-snap-type: initial;scroll-behavior: initial;}
}
</style>
</head>
<body>
<div id="top_line_container">
<div class="container">
<div id="top_line">
<div id="top_logo"><a href="#"><img src="/images/logo.png" alt="Хватило лого"><span>Хватило</span></a></div>
<div id="top_menu">
<a href="#page_autobasket">Автокорзина</a>
<a href="#page_meal_plan">План питания</a>
<a href="#page_how_it_works">Список покупок</a>
<a href="#page_footer">Контакты</a>
</div>
<div><a href="https://hvatilo.ru/app/" id="try_button" target="_blank">Попробовать</a></div>
</div>
</div>
</div>
<div id="scroller">
<main>
<div class="page_container" id="page_index">
<div id="page_index_content">
<h1 class="big_index_greeting">Хватит</h1>
<div class="container_row">
<div>
<p class="text_secondary">
Персональный помощник <br> по питанию: баланс, <br class="big"> энергия,<br class="small medium"> порядок <br class="big">в холодильнике - <br> для всей семьи
</p>
</div>
<div class="big_index_greeting">гадать</div>
</div>
<div id="page_index_store_btns">
<a href="https://play.google.com/store/apps/details?id=eu.zaek.hvatilo.twa&hl=ru" target="_blank" rel="noreferrer noopener"><img src="/images/google_play.png" alt="">Google play</a>
<a href="https://www.rustore.ru/catalog/app/eu.zaek.hvatilo.twa" target="_blank" rel="noreferrer noopener"><img src="/images/rustore.png" alt="">RuStore</a>
</div>
</div>
</div>
<div class="page_container" id="page_autobasket">
<div id="page_autobasket_content">
<div id="page_autobasket_content_description">
<h2>Автокорзина</h2>
<p>Вы выбираете, сколько человек в семье, <wbr/>указываете, на сколько дней нужны <wbr/> <span class="nobreak">продукты — и приложение</span> мгновенно <br class="small"/>собирает корзину с учётом порций.
<br> На выходе — <span class="nobreak">чёткий список покупок</span>, <br class="small" />разбитый по отделам магазина</p>
</div>
<div id="page_autobasket_images">
<div>
<img src="/images/autobasket_1.png" alt="">
<div>
<h3>Никаких сомнений</h3>
<p>Забудьте про мучительные вопросы <br>«А хватит ли?», «А что купить ещё?»</p>
</div>
</div>
<div>
<img src="/images/autobasket_2.png" alt="">
<div>
<h3>Контроль в телефоне</h3>
<p>Список всегда под рукой, можно вычёркивать по пути</p>
</div>
</div>
<div>
<div class="autobasket_image_wrapper">
<img src="/images/broc_happy.png" alt="">
</div>
<div>
<h3>Всё схвачено</h3>
<p>Мы уже учли, что есть в магазине</p>
</div>
</div>
</div>
</div>
</div>
<div class="page_container" id="page_meal_plan">
<div id="page_meal_plan_content" class="container">
<div id="page_meal_plan_content_description">
<h2>План питания</h2>
<p class="description_large">
Добавляете любимые рецепты и указываете <br>
возможные замены для продуктов. Из готовых <br>
рецептов собираете план на день/неделю, планом <br>
можно делиться с другими пользователями!
</p>
<img id="page_meal_plan_broc_small" src="/images/broc_diet.png" alt="">
<div id="page_meal_plan_items">
<div class="page_meal_plan_item">
<div><img src="/images/balance-icon.png" alt=""></div>
<div class="page_meal_plan_item_text"><h3>Баланс</h3><p>Вы сами выбираете блюда</p></div>
</div>
<div class="page_meal_plan_item">
<div><img src="/images/economy-icon.png" alt=""></div>
<div class="page_meal_plan_item_text"><h3>Экономия</h3><p>Замена без потери вкуса</p></div>
</div>
<div class="page_meal_plan_item">
<div><img src="/images/personalization-icon.png" alt=""></div>
<div class="page_meal_plan_item_text"><h3>Персонализация</h3><p>Общий план — муж/ребёнок/соседка по комнате знают, <wbr><span>что готовить,</span> без ваших
<br class="small">объяснений</p></div>
</div>
</div>
</div>
<div>
<img id="page_meal_plan_broc" src="/images/broc_diet.png" alt="">
</div>
</div>
</div>
<div class="page_container" id="page_how_it_works">
<div id="page_how_it_works_content" class="container">
<h2>Как это работает</h2>
<br>
<br class="big">
<p class="description_large">
Вы выбираете, что хотите приготовить &mdash; <br class="big medium small"> Hvatilo
помогает собрать остальное
</p>
<div id="page_how_it_works_steps">
<div id="page_how_it_works_step_1">
<img src="/images/smile-icon.png" class="step-image" alt="">
<p>Открываете сегодняшний <br class="big"> день <br class="small">в плане питания</p>
</div>
<div class="page_how_it_works_steps_arrow">
<img src="/images/arrow-1.png" alt="" id="page_how_it_works_step_1_arrow">
</div>
<div id="page_how_it_works_step_2">
<img src="/images/laugh-icon.png" class="step-image" alt="">
<p>Заменить ужин — пара кликов, <br>и рецепт встаёт в план <br class="big">с новым <br class="small"> списком покупок</p>
</div>
<div class="page_how_it_works_steps_arrow"><img src="/images/arrow-2.png" alt="" id="page_how_it_works_step_2_arrow"></div>
<div id="page_how_it_works_step_3">
<img src="/images/broc_meal_plan.png" alt="">
<p>Из списка продуктов можно <br> сформировать автокорзину <br>на недостающие</p>
</div>
</div>
<div id="page_how_it_works_info">
<div id="page_how_it_works_info_image"><img src="/images/happy-salad.png" alt=""></div>
<div id="page_how_it_works_info_content">
<ul>
<li>
<span>Отсутствие продукта в магазине <br class="small">не сломает приём пищи: <br><br class="small"> автокорзина подстроится <br class="small">под наличие</span>
</li>
<li>&nbsp;
<span>В холодильнике только <br class="small"/>те продукты, которые вы успеете <br class="small big"> съесть, в помойку отправится <br
class="small">только упаковка</span>
</li>
</ul>
</div>
</div>
</div>
<div id="page_footer">
<div class="container container_row">
<div>
<div id="bottom_logo"><img src="/images/logo.png" alt="">Хватило</div>
<div id="bottom_contacts_header">Контакты</div>
<div id="bottom_contacts"><a href="mailto:info@hvatilo.ru">info@hvatilo.ru</a></div>
<div id="bottom_contacts_big">&copy; 2026 &mdash; <a href="mailto:info@hvatilo.ru">info@hvatilo.ru</a> <br></div>
</div>
<div>
<a href="https://hvatilo.ru/app/offer" target="_blank">Пользовательское соглашение</a>
</div>
<div id="bottom_contacts_small">&copy; 2026 <br>
<!--ООО &laquo;Дорфан&raquo;-->
Самозанятый Телятников Захар Александрович
</div>
</div>
</div>
</div>
</main>
</div>
</body>
</html>
+21
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
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
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
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
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
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
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
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
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];
}
+6
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
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} шт`;
}