promo! react
This commit is contained in:
+256
@@ -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;
|
||||
`;
|
||||
@@ -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**';
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>•</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="Закрыть">×</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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
`;
|
||||
@@ -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>•</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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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)} × {Math.round(product.price * 100) / 100} ₽
|
||||
</ItemPrice>
|
||||
</ItemDetails>
|
||||
|
||||
<MenuWrapper>
|
||||
<MenuButton
|
||||
type="button"
|
||||
onClick={() => setIsMenuOpen(value => !value)}
|
||||
>
|
||||
⋮
|
||||
</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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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}>
|
||||
×
|
||||
</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')};
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
Вы выбираете, что хотите приготовить — <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>
|
||||
<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">© 2026 — <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">© 2026 <br>
|
||||
<!--ООО «Дорфан»-->
|
||||
Самозанятый Телятников Захар Александрович
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>,
|
||||
);
|
||||
Vendored
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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} шт`;
|
||||
}
|
||||
Reference in New Issue
Block a user