promo! react
This commit is contained in:
parent
dec37e92c8
commit
8c5d86c9ab
1231
package-lock.json
generated
1231
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"start": "./node_modules/http-server/bin/http-server -p 9876 -S -C local.hvatilo.ru.pem -K local.hvatilo.ru-key.pem -o .",
|
||||
"build": "./build.js"
|
||||
"start": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"http-server": "^14.1.1",
|
||||
@ -11,8 +11,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@minify-html/node": "^0.18.1",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"easy-https": "^1.0.1",
|
||||
"javascript-obfuscator": "^5.5.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2",
|
||||
"ws": "^8.20.1"
|
||||
}
|
||||
}
|
||||
|
||||
256
src/App.tsx
Normal file
256
src/App.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { loadInitialData, setOption } from './api/promoApi';
|
||||
import { Cart } from './components/Cart/Cart';
|
||||
import { CookiePopup } from './components/CookiePopup/CookiePopup';
|
||||
import { FamilySection } from './components/Family/FamilySection';
|
||||
import { CategoriesSection } from './components/Categories/CategoriesSection';
|
||||
import { DaysSection } from './components/Days/DaysSection';
|
||||
import { Accordion } from './components/Accordion/Accordion';
|
||||
import { normalizeProductImage } from './utils/product';
|
||||
import type { InitialData } from './types/api';
|
||||
import type { Product } from './types/cart';
|
||||
import type { User } from './types/user';
|
||||
|
||||
export function App() {
|
||||
const [initialData, setInitialData] = useState<InitialData | null>(null);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [isCartComplete, setIsCartComplete] = useState(false);
|
||||
const [isCartLoading, setIsCartLoading] = useState(true);
|
||||
const [openSection, setOpenSection] = useState<string>('family');
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData().then(data => {
|
||||
setInitialData(data);
|
||||
setProducts(data.cart.flat().map(product => normalizeProductImage(product)));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const users = initialData?.users ?? [];
|
||||
const categories = initialData?.available_groups ?? [];
|
||||
const selectedCategories = initialData?.initial_groups ?? [];
|
||||
const days = initialData?.days ?? 7;
|
||||
|
||||
const handleDaysChange = useCallback(async (nextDays: number) => {
|
||||
setInitialData(current => current ? { ...current, days: nextDays } : current);
|
||||
await setOption('days', nextDays);
|
||||
}, []);
|
||||
|
||||
const handleUsersChange = useCallback(async (nextUsers: User[]) => {
|
||||
setInitialData(current => current ? { ...current, users: nextUsers } : current);
|
||||
await setOption('users', nextUsers);
|
||||
}, []);
|
||||
|
||||
const handleCategoriesChange = useCallback((nextCategories: string[]) => {
|
||||
setInitialData(current => current ? { ...current, initial_groups: nextCategories } : current);
|
||||
}, []);
|
||||
|
||||
const pageContent = useMemo(() => {
|
||||
if (!initialData) {
|
||||
return <LoadingPage>Загрузка...</LoadingPage>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<LeftColumn>
|
||||
<TitleBlock>
|
||||
<h1>Автокорзина</h1>
|
||||
<p>Собираем продукты под ваши настройки</p>
|
||||
</TitleBlock>
|
||||
|
||||
<Cart
|
||||
products={products}
|
||||
days={initialData.days}
|
||||
excess={initialData.excess ?? 0}
|
||||
isComplete={isCartComplete}
|
||||
isLoading={isCartLoading}
|
||||
onProductsChange={setProducts}
|
||||
onComplete={() => {
|
||||
setIsCartComplete(true);
|
||||
setIsCartLoading(false);
|
||||
}}
|
||||
onLoadingChange={setIsCartLoading}
|
||||
/>
|
||||
</LeftColumn>
|
||||
|
||||
<RightColumn>
|
||||
<FilterTitle>
|
||||
<h2>Настройте корзину под себя</h2>
|
||||
<p>Чем больше вы расскажете, тем точнее будет подбор</p>
|
||||
</FilterTitle>
|
||||
|
||||
<SettingsPanel>
|
||||
<Accordion
|
||||
id="days"
|
||||
title="На сколько дней собираем корзину?"
|
||||
icon={<img src="/images/calendar.svg" alt="календарь" />}
|
||||
isOpen={openSection === 'days'}
|
||||
onToggle={() => setOpenSection(openSection === 'days' ? '' : 'days')}
|
||||
>
|
||||
<DaysSection value={days} onChange={handleDaysChange} />
|
||||
</Accordion>
|
||||
|
||||
<Accordion
|
||||
id="family"
|
||||
title="Кто будет есть?"
|
||||
description={
|
||||
users.length > 0
|
||||
? `${users.length} человек`
|
||||
: 'Не указаны'
|
||||
}
|
||||
icon={<img src="/images/family.svg" alt="семья" />}
|
||||
isOpen={openSection === 'family'}
|
||||
onToggle={() => setOpenSection(openSection === 'family' ? '' : 'family')}
|
||||
>
|
||||
<FamilySection
|
||||
users={users}
|
||||
availableTags={categories}
|
||||
onChange={handleUsersChange}
|
||||
/>
|
||||
</Accordion>
|
||||
|
||||
<Accordion
|
||||
id="preferences"
|
||||
title="Предпочтения"
|
||||
description={
|
||||
selectedCategories.length > 0
|
||||
? `Выбраны ${selectedCategories.length}`
|
||||
: 'Не указаны'
|
||||
}
|
||||
isHighlighted={selectedCategories.length > 0}
|
||||
isOpen={openSection === 'preferences'}
|
||||
onToggle={() => setOpenSection(openSection === 'preferences' ? '' : 'preferences')}
|
||||
>
|
||||
<CategoriesSection
|
||||
categories={categories}
|
||||
selectedCategories={selectedCategories}
|
||||
onLocalChange={handleCategoriesChange}
|
||||
onSaved={() => undefined}
|
||||
/>
|
||||
</Accordion>
|
||||
</SettingsPanel>
|
||||
</RightColumn>
|
||||
</Container>
|
||||
);
|
||||
}, [
|
||||
categories,
|
||||
days,
|
||||
handleCategoriesChange,
|
||||
handleDaysChange,
|
||||
handleUsersChange,
|
||||
initialData,
|
||||
isCartComplete,
|
||||
isCartLoading,
|
||||
openSection,
|
||||
products,
|
||||
selectedCategories.length,
|
||||
users,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{pageContent}
|
||||
<CookiePopup />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const LoadingPage = styled.div`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${({ theme }) => theme.colors.white};
|
||||
font-size: 20px;
|
||||
`;
|
||||
|
||||
const Container = styled.main`
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: calc(100% - 40px);
|
||||
max-width: 1228px;
|
||||
height: 90vh;
|
||||
margin: 40px auto 0;
|
||||
background: ${({ theme }) => theme.colors.white};
|
||||
border-radius: ${({ theme }) => theme.radius.page};
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: 90vh;
|
||||
}
|
||||
`;
|
||||
|
||||
const Column = styled.section`
|
||||
padding: 40px;
|
||||
flex: 1;
|
||||
width: 50%;
|
||||
overflow-y: auto;
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
|
||||
& + & {
|
||||
border-left: none;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const LeftColumn = styled(Column)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 490px;
|
||||
`;
|
||||
|
||||
const RightColumn = styled(Column)``;
|
||||
|
||||
const TitleBlock = styled.div`
|
||||
margin-bottom: 32px;
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
line-height: 38px;
|
||||
font-weight: 700;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
color: ${({ theme }) => theme.colors.gray};
|
||||
font-weight: 350;
|
||||
}
|
||||
`;
|
||||
|
||||
const FilterTitle = styled.div`
|
||||
margin-bottom: 40px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
margin-bottom: 8px;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
color: ${({ theme }) => theme.colors.gray};
|
||||
font-weight: 350;
|
||||
}
|
||||
`;
|
||||
|
||||
const SettingsPanel = styled.div`
|
||||
border: 1px solid ${({ theme }) => theme.colors.border};
|
||||
border-radius: 16px;
|
||||
`;
|
||||
7
src/api/config.ts
Normal file
7
src/api/config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export const API_HOST = 'https://local.hvatilo.ru:8055';
|
||||
|
||||
export const jsonHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
export const cspNonce = '**MY_PRETTY_CSP_NONCE**';
|
||||
31
src/api/http.ts
Normal file
31
src/api/http.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { API_HOST } from './config';
|
||||
import { refreshToken } from './tokenApi';
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
return `${API_HOST}${path}`;
|
||||
}
|
||||
|
||||
export async function requestWithRefresh<TResponse>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<TResponse> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data?.error_message === 'need_refresh') {
|
||||
await refreshToken();
|
||||
|
||||
const retryResponse = await fetch(url, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
return await retryResponse.json() as Promise<TResponse>;
|
||||
}
|
||||
|
||||
return Promise.resolve(data) as Promise<TResponse>;
|
||||
}
|
||||
119
src/api/promoApi.ts
Normal file
119
src/api/promoApi.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { apiUrl, requestWithRefresh } from './http';
|
||||
import { jsonHeaders } from './config';
|
||||
import { initToken, refreshToken } from './tokenApi';
|
||||
import type { CartUpdateResponse, InitialData } from '../types/api';
|
||||
|
||||
export async function loadInitialData(): Promise<InitialData> {
|
||||
try {
|
||||
const response = await fetch(apiUrl('/api/promo/get_initial_data'), {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status === 400) {
|
||||
await clearGuestCookie();
|
||||
document.location.reload();
|
||||
return fallbackInitialData;
|
||||
}
|
||||
|
||||
if (response.status === 401 && data.error_message !== 'need_refresh') {
|
||||
await initToken();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const retryResponse = await fetch(apiUrl('/api/promo/get_initial_data'), {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
return retryResponse.json();
|
||||
}
|
||||
|
||||
if (response.status === 403 || data.error_message === 'need_refresh') {
|
||||
await refreshToken();
|
||||
|
||||
const retryResponse = await fetch(apiUrl('/api/promo/get_initial_data'), {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
return retryResponse.json();
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch {
|
||||
return fallbackInitialData;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCartUpdates(): Promise<CartUpdateResponse> {
|
||||
return new Promise<CartUpdateResponse>((resolve, reject) => {
|
||||
// Use a simple mutex to ensure only one request is active at a time
|
||||
let isPending = false;
|
||||
let pendingRequest: Promise<CartUpdateResponse | null> | null = null;
|
||||
|
||||
if (isPending) {
|
||||
// If already pending, reject the current request and return a placeholder
|
||||
return reject(new Error('getCartUpdates is already in progress'));
|
||||
}
|
||||
|
||||
isPending = true;
|
||||
|
||||
// Create a new request that will resolve when the actual request completes
|
||||
pendingRequest = requestWithRefresh<CartUpdateResponse>(apiUrl('/api/promo/get_updates'), {
|
||||
headers: jsonHeaders,
|
||||
}).then(
|
||||
(response) => {
|
||||
isPending = false;
|
||||
resolve(response as CartUpdateResponse);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
isPending = false;
|
||||
reject(error);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
// Return the promise so the caller can wait for it
|
||||
return pendingRequest;
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceProduct(productName: string): Promise<unknown> {
|
||||
return requestWithRefresh(apiUrl('/api/promo/replace_item'), {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ product_name: productName }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteProduct(productName: string): Promise<unknown> {
|
||||
return requestWithRefresh(apiUrl('/api/promo/delete_item'), {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ product_name: productName }),
|
||||
});
|
||||
}
|
||||
|
||||
export function setOption<TValue>(key: string, value: TValue): Promise<unknown> {
|
||||
return requestWithRefresh(apiUrl('/api/promo/set_option'), {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ [key]: value }),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearGuestCookie(): Promise<unknown> {
|
||||
return requestWithRefresh(apiUrl('/api/guest/clear_cookie'), {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
const fallbackInitialData: InitialData = {
|
||||
cart: [],
|
||||
users: [],
|
||||
initial_groups: [],
|
||||
available_groups: [],
|
||||
days: 7,
|
||||
excess: 0,
|
||||
precision: 0,
|
||||
}
|
||||
30
src/api/tokenApi.ts
Normal file
30
src/api/tokenApi.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { API_HOST } from './config';
|
||||
|
||||
export async function initToken(): Promise<unknown> {
|
||||
const response = await fetch(`${API_HOST}/api/guest/init_cookie?utm_source=hvatilo_promo`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function refreshToken(): Promise<unknown> {
|
||||
const response = await fetch(`${API_HOST}/api/guest/refresh_cookie`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.status === 400) {
|
||||
await fetch(`${API_HOST}/api/guest/clear_cookie`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
return initToken();
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
136
src/components/Accordion/Accordion.tsx
Normal file
136
src/components/Accordion/Accordion.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { ArrowIcon } from '../Icons/ArrowIcon';
|
||||
import { HeartIcon } from '../Icons/HeartIcon';
|
||||
|
||||
interface AccordionProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
isOpen: boolean;
|
||||
isHighlighted?: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Accordion({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
isOpen,
|
||||
isHighlighted = true,
|
||||
onToggle,
|
||||
children,
|
||||
}: AccordionProps) {
|
||||
return (
|
||||
<Item>
|
||||
<Header type="button" onClick={onToggle}>
|
||||
<HeaderLeft>
|
||||
<IconCircle $highlighted={isHighlighted}>
|
||||
{icon ?? <HeartIcon />}
|
||||
</IconCircle>
|
||||
|
||||
<div>
|
||||
<HeaderTitle>{title}</HeaderTitle>
|
||||
{description && <Description>{description}</Description>}
|
||||
</div>
|
||||
</HeaderLeft>
|
||||
|
||||
<ArrowWrap $open={isOpen}>
|
||||
<ArrowIcon />
|
||||
</ArrowWrap>
|
||||
</Header>
|
||||
|
||||
<Content $open={isOpen}>
|
||||
<ContentInner>{children}</ContentInner>
|
||||
</Content>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
const Item = styled.div`
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colors.border};
|
||||
position: relative;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled.button`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
const HeaderLeft = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const IconCircle = styled.div<{ $highlighted: boolean }>`
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
background: ${({ $highlighted }) => ($highlighted ? '#E6F696' : '#E7EDE7')};
|
||||
|
||||
img {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
svg rect {
|
||||
fill: ${({ $highlighted }) => ($highlighted ? '#E6F696' : '#E7EDE7')};
|
||||
}
|
||||
|
||||
svg path {
|
||||
stroke: ${({ $highlighted }) => ($highlighted ? '#5EA12D' : '#A3ADA2')};
|
||||
}
|
||||
`;
|
||||
|
||||
const HeaderTitle = styled.div`
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
font-weight: 500;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
`;
|
||||
|
||||
const Description = styled.div`
|
||||
font-size: 14px;
|
||||
color: ${({ theme }) => theme.colors.gray};
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
const ArrowWrap = styled.div<{ $open: boolean }>`
|
||||
color: ${({ theme }) => theme.colors.gray};
|
||||
transition: transform 0.3s ease;
|
||||
transform: ${({ $open }) => ($open ? 'rotate(180deg)' : 'rotate(0deg)')};
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled.div<{ $open: boolean }>`
|
||||
max-height: ${({ $open }) => ($open ? '500px' : '0')};
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s ease, padding 0.3s ease;
|
||||
`;
|
||||
|
||||
const ContentInner = styled.div`
|
||||
padding: 0 24px 20px;
|
||||
`;
|
||||
297
src/components/Cart/Cart.tsx
Normal file
297
src/components/Cart/Cart.tsx
Normal file
@ -0,0 +1,297 @@
|
||||
import {Dispatch, SetStateAction, useMemo, useState} from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { deleteProduct, replaceProduct } from '../../api/promoApi';
|
||||
import { useCartPolling } from '../../hooks/useCartPolling';
|
||||
import { declension } from '../../utils/declension';
|
||||
import { formatCurrency } from '../../utils/formatCurrency';
|
||||
import type { Product } from '../../types/cart';
|
||||
import { CartItem } from './CartItem';
|
||||
import { CartFooter } from './CartFooter';
|
||||
import { ProductModal } from './ProductModal';
|
||||
|
||||
interface CartProps {
|
||||
products: Product[];
|
||||
days: number;
|
||||
excess: number;
|
||||
isComplete: boolean;
|
||||
isLoading: boolean;
|
||||
onProductsChange: Dispatch<SetStateAction<Product[]>>;
|
||||
onComplete: () => void;
|
||||
onLoadingChange: (isLoading: boolean) => void;
|
||||
}
|
||||
|
||||
export function Cart({
|
||||
products,
|
||||
days,
|
||||
excess,
|
||||
isComplete,
|
||||
isLoading,
|
||||
onProductsChange,
|
||||
onComplete,
|
||||
onLoadingChange,
|
||||
}: CartProps) {
|
||||
const [visibleCount, setVisibleCount] = useState(5);
|
||||
const [displayText, setDisplayText] = useState('');
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [processingProductName, setProcessingProductName] = useState<string | null>(null);
|
||||
|
||||
const visibleProducts = products.slice(0, visibleCount);
|
||||
const remaining = products.length - visibleProducts.length;
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() => products.reduce((sum, product) => sum + product.total_price, 0),
|
||||
[products],
|
||||
);
|
||||
|
||||
const formattedTotalPrice = formatCurrency(totalPrice);
|
||||
const stockDays = Math.round(days * excess + days);
|
||||
|
||||
useCartPolling({
|
||||
enabled: !isComplete,
|
||||
onProductsChange,
|
||||
onComplete,
|
||||
onLoadingChange,
|
||||
setDisplayText,
|
||||
});
|
||||
|
||||
async function handleReplace(product: Product) {
|
||||
setProcessingProductName(product.name);
|
||||
|
||||
try {
|
||||
await replaceProduct(product.name);
|
||||
onProductsChange(products.filter(item => item.name !== product.name));
|
||||
} finally {
|
||||
setProcessingProductName(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(product: Product) {
|
||||
setProcessingProductName(product.name);
|
||||
|
||||
try {
|
||||
await deleteProduct(product.name);
|
||||
onProductsChange(products.filter(item => item.name !== product.name));
|
||||
} finally {
|
||||
setProcessingProductName(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Summary>
|
||||
<div>
|
||||
<SummaryTitle>
|
||||
Ваша корзина на <Green>{days} {declension(days, ['день', 'дня', 'дней'])}</Green>
|
||||
</SummaryTitle>
|
||||
|
||||
<SummaryDetails $hidden={isLoading}>
|
||||
{products.length > 0 && (
|
||||
<>
|
||||
<span>
|
||||
{products.length} {declension(products.length, ['товар', 'товара', 'товаров'])}
|
||||
</span>
|
||||
<b>•</b>
|
||||
<span>{formattedTotalPrice}</span>
|
||||
<SummaryExcess>
|
||||
С запасами на {stockDays} {declension(stockDays, ['день', 'дня', 'дней'])}
|
||||
</SummaryExcess>
|
||||
</>
|
||||
)}
|
||||
</SummaryDetails>
|
||||
|
||||
<Loader $hidden={!isLoading} data-display-text={displayText}>
|
||||
{displayText}
|
||||
</Loader>
|
||||
</div>
|
||||
</Summary>
|
||||
|
||||
<ScrollWrapper>
|
||||
<CartList>
|
||||
{visibleProducts.map(product => (
|
||||
<CartItem
|
||||
key={product.name}
|
||||
product={product}
|
||||
isProcessing={processingProductName === product.name}
|
||||
onOpen={() => setSelectedProduct(product)}
|
||||
onReplace={() => handleReplace(product)}
|
||||
onDelete={() => handleDelete(product)}
|
||||
/>
|
||||
))}
|
||||
</CartList>
|
||||
|
||||
{products.length === 0 && (
|
||||
<EmptyLoader src="/images/empty-cart-loader.svg" alt="сбор корзины" />
|
||||
)}
|
||||
</ScrollWrapper>
|
||||
|
||||
{remaining > 0 && (
|
||||
<ShowMore type="button" onClick={() => setVisibleCount(products.length)}>
|
||||
Показать ещё <span>{remaining}</span> товаров
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</ShowMore>
|
||||
)}
|
||||
|
||||
<CartFooter
|
||||
isVisible={isComplete}
|
||||
totalPrice={formattedTotalPrice}
|
||||
/>
|
||||
|
||||
<ProductModal
|
||||
product={selectedProduct}
|
||||
onClose={() => setSelectedProduct(null)}
|
||||
onReplace={handleReplace}
|
||||
onAnother={() => undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Summary = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const SummaryTitle = styled.div`
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
font-weight: 600;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const Green = styled.span`
|
||||
color: ${({ theme }) => theme.colors.green};
|
||||
`;
|
||||
|
||||
const SummaryDetails = styled.div<{ $hidden: boolean }>`
|
||||
min-height: 24px;
|
||||
font-size: 14px;
|
||||
color: ${({ theme }) => theme.colors.gray};
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
visibility: ${({ $hidden }) => ($hidden ? 'hidden' : 'visible')};
|
||||
|
||||
span {
|
||||
font-weight: 350;
|
||||
}
|
||||
|
||||
b {
|
||||
font-family: initial;
|
||||
font-size: 20px;
|
||||
line-height: 8px;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const SummaryExcess = styled.div`
|
||||
background-color: ${({ theme }) => theme.colors.paleGreen};
|
||||
padding: 3px 6px 5px;
|
||||
color: #6D8982;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 350;
|
||||
`;
|
||||
|
||||
const Loader = styled.div<{ $hidden: boolean, "data-display-text": string }>`
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
display: inline-block;
|
||||
color: transparent;
|
||||
background-clip: text;
|
||||
position: absolute;
|
||||
visibility: ${({ $hidden }) => ($hidden ? 'hidden' : 'visible')};
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '${({"data-display-text": displayText}) => displayText}';
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
background: linear-gradient(79deg, #FFBA00 0%, #FFEC47 31%, #C4E30F 63%, #5EA12D 98%) 100%;
|
||||
background-clip: text;
|
||||
animation: opacityAnim 1s ease-in-out 0s infinite alternate;
|
||||
}
|
||||
|
||||
&:after {
|
||||
background: linear-gradient(79deg, #5EA12D 2%, #C4E30F 38%, #FFEC47 69%, #FFBA00 100%) 100%;
|
||||
background-clip: text;
|
||||
animation: opacityAnim 1s ease-in-out -1s infinite alternate;
|
||||
}
|
||||
`;
|
||||
|
||||
const ScrollWrapper = styled.div`
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
margin-bottom: 10px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CartList = styled.ul`
|
||||
width: 100%;
|
||||
list-style: none;
|
||||
`;
|
||||
|
||||
const EmptyLoader = styled.img`
|
||||
animation: spin 1.4s cubic-bezier(0.4, 0.2, 0.35, 0.7) infinite;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-left: -30px;
|
||||
align-self: center;
|
||||
`;
|
||||
|
||||
const ShowMore = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 0 10px;
|
||||
color: ${({ theme }) => theme.colors.green};
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: 0.2s;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: ${({ theme }) => theme.colors.green};
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
`;
|
||||
99
src/components/Cart/CartFooter.tsx
Normal file
99
src/components/Cart/CartFooter.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import styled from 'styled-components';
|
||||
import {BagIcon} from "../Icons/BagIcon";
|
||||
|
||||
|
||||
interface CartFooterProps {
|
||||
isVisible: boolean;
|
||||
totalPrice: string;
|
||||
}
|
||||
|
||||
export function CartFooter({ isVisible, totalPrice }: CartFooterProps) {
|
||||
return (
|
||||
<Footer $visible={isVisible}>
|
||||
<TotalWrap>
|
||||
<div>
|
||||
<IconCircle>
|
||||
<BagIcon />
|
||||
</IconCircle>
|
||||
|
||||
<div>
|
||||
<TotalLabel>Итого</TotalLabel>
|
||||
<TotalPrice>{totalPrice}</TotalPrice>
|
||||
</div>
|
||||
</div>
|
||||
</TotalWrap>
|
||||
|
||||
<CheckoutButton type="button">
|
||||
Оформить заказ
|
||||
</CheckoutButton>
|
||||
</Footer>
|
||||
);
|
||||
}
|
||||
|
||||
const Footer = styled.div<{ $visible: boolean }>`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
|
||||
& > * {
|
||||
visibility: ${({ $visible }) => ($visible ? 'visible' : 'hidden')};
|
||||
}
|
||||
`;
|
||||
|
||||
const TotalWrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const IconCircle = styled.div`
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const TotalLabel = styled.span`
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
`;
|
||||
|
||||
const TotalPrice = styled.div`
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const CheckoutButton = styled.button`
|
||||
background-color: ${({ theme }) => theme.colors.green};
|
||||
height: 54px;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 370;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: ${({ theme }) => theme.colors.greenHover};
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #4A8D19;
|
||||
}
|
||||
`;
|
||||
165
src/components/Cart/CartItem.tsx
Normal file
165
src/components/Cart/CartItem.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { getProductAmount } from '../../utils/product';
|
||||
import type { Product } from '../../types/cart';
|
||||
|
||||
interface CartItemProps {
|
||||
product: Product;
|
||||
isProcessing: boolean;
|
||||
onOpen: () => void;
|
||||
onReplace: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function CartItem({
|
||||
product,
|
||||
isProcessing,
|
||||
onOpen,
|
||||
onReplace,
|
||||
onDelete,
|
||||
}: CartItemProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Item $processing={isProcessing}>
|
||||
<ItemImage type="button" onClick={onOpen}>
|
||||
<img src={product.image} alt="" />
|
||||
</ItemImage>
|
||||
|
||||
<ItemDetails>
|
||||
<ItemName>{product.name}</ItemName>
|
||||
<ItemPrice>
|
||||
{getProductAmount(product)} × {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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
150
src/components/Cart/ProductModal.tsx
Normal file
150
src/components/Cart/ProductModal.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import styled from 'styled-components';
|
||||
import { formatCurrency } from '../../utils/formatCurrency';
|
||||
import { getLargeProductImage } from '../../utils/product';
|
||||
import type { Product } from '../../types/cart';
|
||||
|
||||
interface ProductModalProps {
|
||||
product: Product | null;
|
||||
onClose: () => void;
|
||||
onReplace: (product: Product) => void;
|
||||
onAnother: (product: Product) => void;
|
||||
}
|
||||
|
||||
export function ProductModal({
|
||||
product,
|
||||
onClose,
|
||||
onReplace,
|
||||
onAnother,
|
||||
}: ProductModalProps) {
|
||||
if (!product) return null;
|
||||
|
||||
return (
|
||||
<Overlay onClick={onClose}>
|
||||
<Window onClick={event => event.stopPropagation()}>
|
||||
<CloseButton type="button" aria-label="Закрыть" onClick={onClose}>
|
||||
×
|
||||
</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;
|
||||
}
|
||||
`;
|
||||
213
src/components/Categories/CategoriesSection.tsx
Normal file
213
src/components/Categories/CategoriesSection.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { setOption } from '../../api/promoApi';
|
||||
import { useDebounceBatch } from '../../hooks/useDebounceBatch';
|
||||
|
||||
interface CategoriesSectionProps {
|
||||
categories: string[];
|
||||
selectedCategories: string[];
|
||||
onLocalChange: (categories: string[]) => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function CategoriesSection({
|
||||
categories,
|
||||
selectedCategories,
|
||||
onLocalChange,
|
||||
onSaved,
|
||||
}: CategoriesSectionProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loadingCategories, setLoadingCategories] = useState<string[]>([]);
|
||||
|
||||
const maxVisibleCategories = 7;
|
||||
|
||||
const dataToShow = useMemo(() => {
|
||||
if (expanded) return categories;
|
||||
|
||||
return (selectedCategories.length > 0 ? selectedCategories : categories)
|
||||
.slice(0, maxVisibleCategories);
|
||||
}, [categories, expanded, selectedCategories]);
|
||||
|
||||
const sendUpdatedCategories = useDebounceBatch<string>(async categoryNames => {
|
||||
let nextCategories = [...selectedCategories];
|
||||
|
||||
categoryNames.forEach(categoryName => {
|
||||
if (nextCategories.includes(categoryName)) {
|
||||
nextCategories = nextCategories.filter(item => item !== categoryName);
|
||||
} else {
|
||||
nextCategories.push(categoryName);
|
||||
}
|
||||
});
|
||||
|
||||
onLocalChange(nextCategories);
|
||||
await setOption('categories', nextCategories);
|
||||
onSaved();
|
||||
}, 1500);
|
||||
|
||||
function toggleCategory(category: string) {
|
||||
if (loadingCategories.includes(category)) return;
|
||||
|
||||
setLoadingCategories(current => [...current, category]);
|
||||
|
||||
sendUpdatedCategories(category).finally(() => {
|
||||
setLoadingCategories(current => current.filter(item => item !== category));
|
||||
});
|
||||
}
|
||||
|
||||
if (categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Block>
|
||||
<Header>
|
||||
<h3>Что добавить в корзину?</h3>
|
||||
<p>Выберите группы продуктов, которые хотите видеть в корзине</p>
|
||||
</Header>
|
||||
|
||||
<Wrapper>
|
||||
<Grid>
|
||||
{dataToShow.map(category => (
|
||||
<CategoryButton
|
||||
key={category}
|
||||
type="button"
|
||||
$active={selectedCategories.includes(category)}
|
||||
$loading={loadingCategories.includes(category)}
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
<span>{category}</span>
|
||||
</CategoryButton>
|
||||
))}
|
||||
|
||||
{dataToShow.length > 0 && (
|
||||
<ToggleButton
|
||||
type="button"
|
||||
$collapsed={!expanded}
|
||||
onClick={() => setExpanded(value => !value)}
|
||||
>
|
||||
{!expanded && dataToShow.length < selectedCategories.length
|
||||
? `+${selectedCategories.length - dataToShow.length}`
|
||||
: ''}
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
</ToggleButton>
|
||||
)}
|
||||
</Grid>
|
||||
</Wrapper>
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
||||
const Block = styled.div``;
|
||||
|
||||
const Header = styled.div`
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
margin-bottom: 4px;
|
||||
font-weight: 350;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: ${({ theme }) => theme.colors.gray};
|
||||
margin-bottom: 16px;
|
||||
font-weight: 350;
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const CategoryButton = styled.button<{ $active: boolean; $loading: boolean }>`
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 32px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 1.2s;
|
||||
background-color: ${({ $active }) => ($active ? '#0C3B2E' : '#F1FAF0')};
|
||||
color: ${({ $active }) => ($active ? '#F1FAF0' : '#0C3B2E')};
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ $active }) => ($active ? '#0C3B2E' : '#e4f0de')};
|
||||
}
|
||||
|
||||
${({ $loading }) => $loading && `
|
||||
color: #0C3B2E;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
background: linear-gradient(79deg, #FFBA00 0%, #FFEC47 31%, #C4E30F 63%, #5EA12D 98%) 100%;
|
||||
animation: opacityAnim 1s ease-in-out 0s infinite alternate;
|
||||
}
|
||||
|
||||
&:after {
|
||||
background: linear-gradient(79deg, #5EA12D 2%, #C4E30F 38%, #FFEC47 69%, #FFBA00 100%) 100%;
|
||||
animation: opacityAnim 1s ease-in-out -1s infinite alternate;
|
||||
}
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ToggleButton = styled.button<{ $collapsed: boolean }>`
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f2f9ef;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
padding: 6px 8px 6px 16px;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
font-size: 14px;
|
||||
font-weight: 350;
|
||||
|
||||
&:hover {
|
||||
background-color: #e4f0de;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: ${({ theme }) => theme.colors.darkGreen};
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: transform 0.3s ease;
|
||||
transform: ${({ $collapsed }) => ($collapsed ? 'rotate(180deg)' : 'none')};
|
||||
}
|
||||
`;
|
||||
157
src/components/CookiePopup/CookiePopup.tsx
Normal file
157
src/components/CookiePopup/CookiePopup.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export function CookiePopup() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [hiding, setHiding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem('cookie_accepted')) {
|
||||
const timerId = window.setTimeout(() => {
|
||||
setVisible(true);
|
||||
}, 500);
|
||||
|
||||
return () => window.clearTimeout(timerId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
function accept() {
|
||||
localStorage.setItem('cookie_accepted', 'true');
|
||||
setHiding(true);
|
||||
|
||||
window.setTimeout(() => {
|
||||
setVisible(false);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup $hiding={hiding}>
|
||||
<IconWrapper>
|
||||
<img src="/images/cookie.png" alt="Cookie icon" />
|
||||
</IconWrapper>
|
||||
|
||||
<Text>
|
||||
Мы используем{' '}
|
||||
<a href="/cookies_and_recommendations.html#c" target="_blank" rel="noreferrer">
|
||||
куки
|
||||
</a>{' '}
|
||||
и{' '}
|
||||
<a href="/cookies_and_recommendations.html#r" target="_blank" rel="noreferrer">
|
||||
рекомендательные технологии
|
||||
</a>
|
||||
, это помогает улучшать сервис и запоминать ваши настройки
|
||||
</Text>
|
||||
|
||||
<AcceptButton type="button" onClick={accept}>
|
||||
Хорошо
|
||||
</AcceptButton>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
const Popup = styled.div<{ $hiding: boolean }>`
|
||||
position: fixed;
|
||||
bottom: 25px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(${({ $hiding }) => ($hiding ? '30px' : '0')});
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
border: 2px solid #E7EDE7;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.05), 0 4px 10px rgba(0, 0, 0, 0.02);
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
width: 95%;
|
||||
max-width: 780px;
|
||||
z-index: 9999;
|
||||
opacity: ${({ $hiding }) => ($hiding ? 0 : 1)};
|
||||
visibility: ${({ $hiding }) => ($hiding ? 'hidden' : 'visible')};
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
@media (max-width: 600px) {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
bottom: 15px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #edf2f7;
|
||||
box-shadow: inset 0 4px 10px 0 rgba(12, 59, 46, 0.1);
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 85%;
|
||||
height: 85%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Text = styled.p`
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.01em;
|
||||
|
||||
a {
|
||||
color: #69a342;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
order: 2;
|
||||
}
|
||||
`;
|
||||
|
||||
const AcceptButton = styled.button`
|
||||
flex-shrink: 0;
|
||||
background-color: #d6e6d4;
|
||||
color: #1c3a2e;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.1s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background-color: #c2d6c0;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
order: 3;
|
||||
}
|
||||
`;
|
||||
53
src/components/Days/DaysSection.tsx
Normal file
53
src/components/Days/DaysSection.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface DaysSectionProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const options = [1, 3, 7];
|
||||
|
||||
export function DaysSection({ value, onChange }: DaysSectionProps) {
|
||||
return (
|
||||
<DaysOptions>
|
||||
{options.map(option => (
|
||||
<DayButton
|
||||
key={option}
|
||||
type="button"
|
||||
$active={value === option}
|
||||
onClick={() => onChange(option)}
|
||||
>
|
||||
{option}
|
||||
</DayButton>
|
||||
))}
|
||||
</DaysOptions>
|
||||
);
|
||||
}
|
||||
|
||||
const DaysOptions = styled.div`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const DayButton = styled.button<{ $active: boolean }>`
|
||||
flex: 1;
|
||||
max-width: 140px;
|
||||
height: 52px;
|
||||
border: 2px solid ${({ $active }) => ($active ? '#5EA12D' : '#e1e1e1')};
|
||||
border-radius: 8px;
|
||||
background: ${({ $active }) => ($active ? '#E6F696' : '#ffffff')};
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
font-weight: 350;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: ${({ theme }) => theme.colors.green};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
237
src/components/Family/FamilySection.tsx
Normal file
237
src/components/Family/FamilySection.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
import { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { declension } from '../../utils/declension';
|
||||
import type { User } from '../../types/user';
|
||||
import { PersonModal } from './PersonModal';
|
||||
|
||||
interface FamilySectionProps {
|
||||
users: User[];
|
||||
availableTags: string[];
|
||||
onChange: (users: User[]) => void;
|
||||
}
|
||||
|
||||
export function FamilySection({
|
||||
users,
|
||||
availableTags,
|
||||
onChange,
|
||||
}: FamilySectionProps) {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
function handleDelete(index: number) {
|
||||
onChange(users.filter((_, userIndex) => userIndex !== index));
|
||||
}
|
||||
|
||||
function handleSave(user: User) {
|
||||
if (editingIndex === null) {
|
||||
onChange([...users, user]);
|
||||
} else {
|
||||
onChange(users.map((item, index) => index === editingIndex ? user : item));
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
setEditingIndex(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FamilyList>
|
||||
{users.map((user, index) => (
|
||||
<FamilyMember
|
||||
key={`${user.name}-${index}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingIndex(index);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Avatar>
|
||||
<img src={getAvatarSrc(user)} alt="" />
|
||||
</Avatar>
|
||||
|
||||
<Info>
|
||||
<Name>{user.name}</Name>
|
||||
<Age>{user.age} {declension(user.age, ['год', 'года', 'лет'])}</Age>
|
||||
</Info>
|
||||
|
||||
<DeleteButton
|
||||
type="button"
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
handleDelete(index);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</DeleteButton>
|
||||
</FamilyMember>
|
||||
))}
|
||||
</FamilyList>
|
||||
|
||||
<AddWrapper>
|
||||
<AddButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingIndex(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Добавить человека <span>+</span>
|
||||
</AddButton>
|
||||
</AddWrapper>
|
||||
|
||||
<PersonModal
|
||||
isOpen={isModalOpen}
|
||||
user={editingIndex === null ? null : users[editingIndex]}
|
||||
availableTags={availableTags}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setEditingIndex(null);
|
||||
}}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getAvatarSrc(user: User): string {
|
||||
if (user.age < 16) {
|
||||
return user.gender === 'f'
|
||||
? '/images/broc-girl.png'
|
||||
: '/images/broc-boy.png';
|
||||
}
|
||||
|
||||
return user.gender === 'f'
|
||||
? '/images/broc-woman.png'
|
||||
: '/images/broc-man.png';
|
||||
}
|
||||
|
||||
const FamilyList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const FamilyMember = styled.button`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid ${({ theme }) => theme.colors.border};
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
transition: 0.2s;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: #c0c0c0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
&:nth-child(n+1) ${() => Avatar} {
|
||||
background-color: #B4AAFF;
|
||||
}
|
||||
|
||||
&:nth-child(n+2) ${() => Avatar} {
|
||||
background-color: #CEE0FF;
|
||||
}
|
||||
|
||||
&:nth-child(n+3) ${() => Avatar} {
|
||||
background-color: #E6F696;
|
||||
}
|
||||
|
||||
&:nth-child(n+4) ${() => Avatar} {
|
||||
background-color: #FFF488;
|
||||
}
|
||||
|
||||
&:nth-child(n+5) ${() => Avatar} {
|
||||
background-color: #FFD623;
|
||||
}
|
||||
`;
|
||||
|
||||
const Avatar = styled.div`
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 14px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const Info = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Name = styled.div`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
`;
|
||||
|
||||
const Age = styled.div`
|
||||
font-size: 14px;
|
||||
color: ${({ theme }) => theme.colors.gray};
|
||||
`;
|
||||
|
||||
const DeleteButton = styled.button`
|
||||
color: #d1d5db;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
padding: 0 8px;
|
||||
transition: 0.2s;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
&:hover {
|
||||
color: #e53935;
|
||||
}
|
||||
`;
|
||||
|
||||
const AddWrapper = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const AddButton = styled.button`
|
||||
padding: 18px 23px;
|
||||
border-radius: 12px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
color: ${({ theme }) => theme.colors.green};
|
||||
font-size: 15px;
|
||||
font-weight: 350;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: #F7FBF6;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
background-color: #EEF8EB;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 20px;
|
||||
margin-left: 8px;
|
||||
font-weight: 300;
|
||||
}
|
||||
`;
|
||||
350
src/components/Family/PersonModal.tsx
Normal file
350
src/components/Family/PersonModal.tsx
Normal file
@ -0,0 +1,350 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { Gender, User } from '../../types/user';
|
||||
import { TagsInput } from './TagsInput';
|
||||
|
||||
interface PersonModalProps {
|
||||
isOpen: boolean;
|
||||
user: User | null;
|
||||
availableTags: string[];
|
||||
onClose: () => void;
|
||||
onSave: (user: User) => void;
|
||||
}
|
||||
|
||||
interface PersonForm {
|
||||
name: string;
|
||||
gender: Gender;
|
||||
age: string;
|
||||
weight: string;
|
||||
height: string;
|
||||
avoid: string[];
|
||||
favorite: string[];
|
||||
}
|
||||
|
||||
const defaultForm: PersonForm = {
|
||||
name: '',
|
||||
gender: 'm',
|
||||
age: '',
|
||||
weight: '',
|
||||
height: '',
|
||||
avoid: [],
|
||||
favorite: [],
|
||||
};
|
||||
|
||||
export function PersonModal({
|
||||
isOpen,
|
||||
user,
|
||||
availableTags,
|
||||
onClose,
|
||||
onSave,
|
||||
}: PersonModalProps) {
|
||||
const [form, setForm] = useState<PersonForm>(defaultForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (user) {
|
||||
setForm({
|
||||
name: user.name,
|
||||
gender: user.gender,
|
||||
age: String(user.age),
|
||||
weight: user.weight ? String(user.weight) : '',
|
||||
height: user.height ? String(user.height) : '',
|
||||
avoid: user.avoid ?? [],
|
||||
favorite: user.favorite ?? [],
|
||||
});
|
||||
} else {
|
||||
setForm(defaultForm);
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
function updateForm<TName extends keyof PersonForm>(name: TName, value: PersonForm[TName]) {
|
||||
setForm(current => ({
|
||||
...current,
|
||||
[name]: value,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleAvoidChange(tags: string[]) {
|
||||
setForm(current => ({
|
||||
...current,
|
||||
avoid: tags,
|
||||
favorite: current.favorite.filter(tag => !tags.includes(tag)),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleFavoriteChange(tags: string[]) {
|
||||
setForm(current => ({
|
||||
...current,
|
||||
favorite: tags,
|
||||
avoid: current.avoid.filter(tag => !tags.includes(tag)),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const age = Number(form.age);
|
||||
|
||||
if (!form.name.trim()) {
|
||||
alert('Пожалуйста, введите имя.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!age || age < 7) {
|
||||
alert('Возраст должен быть от 7 лет.');
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
id: user?.id,
|
||||
name: form.name.trim(),
|
||||
gender: form.gender,
|
||||
age,
|
||||
weight: form.weight ? Number(form.weight) : undefined,
|
||||
height: form.height ? Number(form.height) : undefined,
|
||||
avoid: form.avoid,
|
||||
favorite: form.favorite,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Overlay onClick={onClose}>
|
||||
<Content onClick={event => event.stopPropagation()}>
|
||||
<Header>
|
||||
<h2>{user ? 'Редактировать человека' : 'Добавить человека'}</h2>
|
||||
<CloseButton type="button" onClick={onClose}>
|
||||
×
|
||||
</CloseButton>
|
||||
</Header>
|
||||
|
||||
<FormGroup>
|
||||
<label>Имя</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Имя"
|
||||
value={form.name}
|
||||
onChange={event => updateForm('name', event.target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label>Пол</label>
|
||||
<GenderToggles>
|
||||
<GenderButton
|
||||
type="button"
|
||||
$active={form.gender === 'f'}
|
||||
onClick={() => updateForm('gender', 'f')}
|
||||
>
|
||||
Женский
|
||||
</GenderButton>
|
||||
<GenderButton
|
||||
type="button"
|
||||
$active={form.gender === 'm'}
|
||||
onClick={() => updateForm('gender', 'm')}
|
||||
>
|
||||
Мужской
|
||||
</GenderButton>
|
||||
</GenderToggles>
|
||||
</FormGroup>
|
||||
|
||||
<FormRow>
|
||||
<FormGroup>
|
||||
<label>Возраст</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.age}
|
||||
onChange={event => updateForm('age', event.target.value)}
|
||||
/>
|
||||
<Hint>от 7 лет</Hint>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label>Вес</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.weight}
|
||||
onChange={event => updateForm('weight', event.target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label>Рост</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.height}
|
||||
onChange={event => updateForm('height', event.target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
|
||||
<PrefsGrid>
|
||||
<TagsInput
|
||||
title="Запрещённые"
|
||||
type="avoid"
|
||||
value={form.avoid}
|
||||
availableTags={availableTags}
|
||||
excludedTags={form.favorite}
|
||||
onChange={handleAvoidChange}
|
||||
/>
|
||||
|
||||
<TagsInput
|
||||
title="Любимые"
|
||||
type="favorite"
|
||||
value={form.favorite}
|
||||
availableTags={availableTags}
|
||||
excludedTags={form.avoid}
|
||||
onChange={handleFavoriteChange}
|
||||
/>
|
||||
</PrefsGrid>
|
||||
|
||||
<SaveButton type="button" onClick={handleSave}>
|
||||
Сохранить
|
||||
</SaveButton>
|
||||
</Content>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
const Overlay = styled.div`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
|
||||
max-height: 95vh;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
}
|
||||
`;
|
||||
|
||||
const CloseButton = styled.button`
|
||||
font-size: 24px;
|
||||
color: #b0b0b0;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
}
|
||||
`;
|
||||
|
||||
const FormGroup = styled.div`
|
||||
margin-bottom: 16px;
|
||||
flex: 1;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e1e1e1;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&:focus {
|
||||
border-color: ${({ theme }) => theme.colors.green};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const FormRow = styled.div`
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
${FormGroup} {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
const Hint = styled.span`
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: ${({ theme }) => theme.colors.gray};
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const GenderToggles = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const GenderButton = styled.button<{ $active: boolean }>`
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid ${({ $active }) => ($active ? '#e9f6d9' : '#e1e1e1')};
|
||||
border-radius: 8px;
|
||||
background: ${({ $active }) => ($active ? '#e9f6d9' : '#ffffff')};
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
font-weight: ${({ $active }) => ($active ? 500 : 400)};
|
||||
`;
|
||||
|
||||
const PrefsGrid = styled.div`
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 16px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
const SaveButton = styled.button`
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: ${({ theme }) => theme.colors.green};
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #5b8f29;
|
||||
}
|
||||
`;
|
||||
169
src/components/Family/TagsInput.tsx
Normal file
169
src/components/Family/TagsInput.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface TagsInputProps {
|
||||
title: string;
|
||||
type: 'avoid' | 'favorite';
|
||||
value: string[];
|
||||
availableTags: string[];
|
||||
excludedTags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
}
|
||||
|
||||
export function TagsInput({
|
||||
title,
|
||||
type,
|
||||
value,
|
||||
availableTags,
|
||||
excludedTags,
|
||||
onChange,
|
||||
}: TagsInputProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
if (!normalizedQuery) return [];
|
||||
|
||||
const selected = new Set([...value, ...excludedTags]);
|
||||
|
||||
return availableTags.filter(tag =>
|
||||
tag.toLowerCase().includes(normalizedQuery) && !selected.has(tag),
|
||||
);
|
||||
}, [availableTags, excludedTags, query, value]);
|
||||
|
||||
function addTag(tag: string) {
|
||||
if (value.includes(tag)) return;
|
||||
|
||||
onChange([...value, tag]);
|
||||
setQuery('');
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
onChange(value.filter(item => item !== tag));
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<Title>{title}</Title>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={type === 'avoid' ? 'Например: лук' : ''}
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
/>
|
||||
|
||||
{matches.length > 0 && (
|
||||
<Autocomplete>
|
||||
{matches.map(match => (
|
||||
<button
|
||||
key={match}
|
||||
type="button"
|
||||
onClick={() => addTag(match)}
|
||||
>
|
||||
{match}
|
||||
</button>
|
||||
))}
|
||||
</Autocomplete>
|
||||
)}
|
||||
|
||||
<TagsWrap>
|
||||
{value.map(tag => (
|
||||
<Tag key={tag} $type={type}>
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(tag)}>
|
||||
×
|
||||
</button>
|
||||
</Tag>
|
||||
))}
|
||||
</TagsWrap>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const Column = styled.div`
|
||||
flex: 1;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${({ theme }) => theme.colors.darkGreen};
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e1e1e1;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
margin-bottom: 6px;
|
||||
|
||||
&:focus {
|
||||
border-color: ${({ theme }) => theme.colors.green};
|
||||
}
|
||||
`;
|
||||
|
||||
const Autocomplete = styled.div`
|
||||
position: absolute;
|
||||
top: 65px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #e1e1e1;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: 0.2s;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const TagsWrap = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background-color: ${({ $type }) => ($type === 'avoid' ? '#fce4ec' : '#f0f4c3')};
|
||||
color: ${({ $type }) => ($type === 'avoid' ? '#c62828' : '#33691e')};
|
||||
|
||||
button {
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
opacity: 0.6;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
13
src/components/Icons/ArrowIcon.tsx
Normal file
13
src/components/Icons/ArrowIcon.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
export function ArrowIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M19 9L12 16L5 9"
|
||||
stroke="#0C3B2E"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
9
src/components/Icons/BagIcon.tsx
Normal file
9
src/components/Icons/BagIcon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
export function BagIcon() {
|
||||
return <svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="44" height="44" rx="22" fill="#E6F696"/>
|
||||
<path d="M18 18L18 17C18 14.7909 19.7909 13 22 13C24.2091 13 26 14.7909 26 17L26 18" stroke="#5EA12D" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M25 24V22" stroke="#5EA12D" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M19 24V22" stroke="#5EA12D" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M14 22C14 20.1144 14 19.1716 14.5858 18.5858C15.1716 18 16.1144 18 18 18H26C27.8856 18 28.8284 18 29.4142 18.5858C30 19.1716 30 20.1144 30 22V23C30 26.7712 30 28.6569 28.8284 29.8284C27.6569 31 25.7712 31 22 31C18.2288 31 16.3431 31 15.1716 29.8284C14 28.6569 14 26.7712 14 23V22Z" stroke="#5EA12D" stroke-width="2"/>
|
||||
</svg>;
|
||||
}
|
||||
11
src/components/Icons/HeartIcon.tsx
Normal file
11
src/components/Icons/HeartIcon.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
export function HeartIcon() {
|
||||
return (
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
|
||||
<rect width="44" height="44" rx="22" />
|
||||
<path
|
||||
d="M14.4507 23.9082L21.4033 30.4395C21.6428 30.6644 21.7625 30.7769 21.9037 30.8046C21.9673 30.8171 22.0327 30.8171 22.0963 30.8046C22.2375 30.7769 22.3572 30.6644 22.5967 30.4395L29.5493 23.9082C31.5055 22.0706 31.743 19.0466 30.0978 16.9261L29.7885 16.5273C27.8203 13.9906 23.8696 14.416 22.4867 17.3137C22.2913 17.723 21.7087 17.723 21.5133 17.3137C20.1304 14.416 16.1797 13.9906 14.2115 16.5273L13.9022 16.9261C12.2569 19.0466 12.4945 22.0706 14.4507 23.9082Z"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
src/components/components/Icons/BagIcon.tsx
Normal file
20
src/components/components/Icons/BagIcon.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
export function BagIcon() {
|
||||
return (
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
|
||||
<rect width="44" height="44" rx="22" fill="#E6F696" />
|
||||
<path
|
||||
d="M18 18L18 17C18 14.7909 19.7909 13 22 13C24.2091 13 26 14.7909 26 17L26 18"
|
||||
stroke="#5EA12D"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="M25 24V22" stroke="#5EA12D" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M19 24V22" stroke="#5EA12D" strokeWidth="2" strokeLinecap="round" />
|
||||
<path
|
||||
d="M14 22C14 20.1144 14 19.1716 14.5858 18.5858C15.1716 18 16.1144 18 18 18H26C27.8856 18 28.8284 18 29.4142 18.5858C30 19.1716 30 20.1144 30 22V23C30 26.7712 30 28.6569 28.8284 29.8284C27.6569 31 25.7712 31 22 31C18.2288 31 16.3431 31 15.1716 29.8284C14 28.6569 14 26.7712 14 23V22Z"
|
||||
stroke="#5EA12D"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
92
src/hooks/useCartPolling.ts
Normal file
92
src/hooks/useCartPolling.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import {Dispatch, SetStateAction, useEffect, useRef} from 'react';
|
||||
import { getCartUpdates } from '../api/promoApi';
|
||||
import { normalizeProductImage } from '../utils/product';
|
||||
import type { Product } from '../types/cart';
|
||||
|
||||
interface UseCartPollingParams {
|
||||
enabled: boolean;
|
||||
onProductsChange: Dispatch<SetStateAction<Product[]>>;
|
||||
onComplete: () => void;
|
||||
onLoadingChange: (isLoading: boolean) => void;
|
||||
setDisplayText: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export function useCartPolling({
|
||||
enabled,
|
||||
onProductsChange,
|
||||
onComplete,
|
||||
onLoadingChange,
|
||||
setDisplayText,
|
||||
}: UseCartPollingParams) {
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
onLoadingChange(true);
|
||||
|
||||
const update = await getCartUpdates();
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (update.status === 'complete') {
|
||||
onLoadingChange(false);
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
if (update.status === 'waiting') {
|
||||
setDisplayText('Брок бегает по супермаркету...')
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.status === 'clear') {
|
||||
const nextProducts = update.payload?.chunk?.map(product => normalizeProductImage(product)) ?? [];
|
||||
onProductsChange(nextProducts);
|
||||
}
|
||||
|
||||
if (update.status === 'data') {
|
||||
const chunk = update.payload?.chunk?.map(product => normalizeProductImage(product)) ?? [];
|
||||
|
||||
onProductsChange(current => {
|
||||
const next = [...current];
|
||||
|
||||
chunk.forEach(product => {
|
||||
const index = next.findIndex(item => item.name === product.name);
|
||||
|
||||
if (index === -1) {
|
||||
next.push(product);
|
||||
} else {
|
||||
next[index] = {
|
||||
...next[index],
|
||||
n: next[index].n + product.n,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
setDisplayText('Брок закинул пару товаров...')
|
||||
}
|
||||
timeoutRef.current = window.setTimeout(tick, 3000);
|
||||
} catch (error) {
|
||||
setDisplayText('Ищем Брока...')
|
||||
timeoutRef.current = window.setTimeout(tick, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
tick();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [enabled, onComplete, onLoadingChange, onProductsChange]);
|
||||
}
|
||||
53
src/hooks/useDebounceBatch.ts
Normal file
53
src/hooks/useDebounceBatch.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
interface Batch<T> {
|
||||
ids: T[];
|
||||
resolves: Array<(value: unknown) => void>;
|
||||
rejects: Array<(reason?: unknown) => void>;
|
||||
}
|
||||
|
||||
export function useDebounceBatch<T>(
|
||||
handler: (items: T[]) => Promise<unknown>,
|
||||
delay: number,
|
||||
) {
|
||||
const pendingRef = useRef<Batch<T> | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
return useMemo(() => {
|
||||
return (item: T) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pendingRef.current) {
|
||||
pendingRef.current = {
|
||||
ids: [],
|
||||
resolves: [],
|
||||
rejects: [],
|
||||
};
|
||||
}
|
||||
|
||||
pendingRef.current.ids.push(item);
|
||||
pendingRef.current.resolves.push(resolve);
|
||||
pendingRef.current.rejects.push(reject);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(async () => {
|
||||
const batch = pendingRef.current;
|
||||
|
||||
pendingRef.current = null;
|
||||
timerRef.current = null;
|
||||
|
||||
if (!batch) return;
|
||||
|
||||
try {
|
||||
const result = await handler(batch.ids);
|
||||
batch.resolves.forEach(resolveBatch => resolveBatch(result));
|
||||
} catch (error) {
|
||||
batch.rejects.forEach(rejectBatch => rejectBatch(error));
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
};
|
||||
}, [delay, handler]);
|
||||
}
|
||||
21
src/main.tsx
Normal file
21
src/main.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { App } from './App';
|
||||
import { GlobalStyle } from './styles/GlobalStyle';
|
||||
import { theme } from './styles/theme';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Root element #root was not found');
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<GlobalStyle />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
17
src/styled-components.d.ts
vendored
Normal file
17
src/styled-components.d.ts
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
import type { CSSProp } from "styled-components";
|
||||
import Theme from './theme';
|
||||
|
||||
type ThemeType = typeof Theme;
|
||||
|
||||
declare module "styled-components" {
|
||||
export interface DefaultTheme extends ThemeType {
|
||||
colors: ThemeType['colors'];
|
||||
radius: ThemeType['radius'];
|
||||
}
|
||||
}
|
||||
|
||||
declare module "react" {
|
||||
interface DOMAttributes<T> {
|
||||
css?: CSSProp;
|
||||
}
|
||||
}
|
||||
55
src/styles/GlobalStyle.ts
Normal file
55
src/styles/GlobalStyle.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
export const GlobalStyle = createGlobalStyle`
|
||||
@font-face {
|
||||
font-family: 'FallbackFont';
|
||||
src: local('Helvetica');
|
||||
size-adjust: 98.5%;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Roboto', 'FallbackFont', system-ui, -apple-system, 'Segoe UI', Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: ${({ theme }) => theme.colors.pageBg};
|
||||
background-image: url('/images/background-raspberry.png');
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
background-size: contain;
|
||||
background-position: left top;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@keyframes opacityAnim {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
19
src/styles/theme.ts
Normal file
19
src/styles/theme.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export const theme = {
|
||||
colors: {
|
||||
darkGreen: '#0C3B2E',
|
||||
green: '#5EA12D',
|
||||
greenHover: '#72B541',
|
||||
lightGreen: '#E6F696',
|
||||
paleGreen: '#F1FAF0',
|
||||
gray: '#A3ADA2',
|
||||
border: '#ebebeb',
|
||||
white: '#ffffff',
|
||||
pageBg: '#FF7D89',
|
||||
},
|
||||
radius: {
|
||||
page: '24px',
|
||||
card: '16px',
|
||||
button: '12px',
|
||||
small: '8px',
|
||||
},
|
||||
};
|
||||
33
src/template.html
Normal file
33
src/template.html
Normal file
@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Автокорзина</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
rel="preload"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@100..900&display=swap"
|
||||
id="style_preload"
|
||||
as="style"
|
||||
/>
|
||||
<script nonce="**MY_PRETTY_CSP_NONCE**">
|
||||
document.getElementById('style_preload').addEventListener('load', function() {
|
||||
this.rel = 'stylesheet';
|
||||
});
|
||||
</script>
|
||||
<noscript>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@100..900&display=optional"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
src/types/api.ts
Normal file
25
src/types/api.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { Product } from './cart';
|
||||
import type { User } from './user';
|
||||
|
||||
export interface InitialData {
|
||||
existing?: number;
|
||||
available_groups: string[];
|
||||
initial_groups: string[];
|
||||
days: number;
|
||||
users: User[];
|
||||
cart: Product[][];
|
||||
excess?: number;
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
export type CartUpdateStatus = 'clear' | 'data' | 'waiting' | 'complete';
|
||||
|
||||
export interface CartUpdateResponse {
|
||||
status: CartUpdateStatus;
|
||||
error_message?: string;
|
||||
payload?: {
|
||||
excess?: number;
|
||||
precision?: number;
|
||||
chunk?: Product[];
|
||||
};
|
||||
}
|
||||
11
src/types/cart.ts
Normal file
11
src/types/cart.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export type ProductUnit = 'g' | 'pcs';
|
||||
|
||||
export interface Product {
|
||||
name: string;
|
||||
n: number;
|
||||
price: number;
|
||||
total_price: number;
|
||||
currency: string;
|
||||
image?: string;
|
||||
unit: ProductUnit;
|
||||
}
|
||||
12
src/types/user.ts
Normal file
12
src/types/user.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export type Gender = 'm' | 'f';
|
||||
|
||||
export interface User {
|
||||
id?: number;
|
||||
name: string;
|
||||
gender: Gender;
|
||||
age: number;
|
||||
weight?: number;
|
||||
height?: number;
|
||||
avoid: string[];
|
||||
favorite: string[];
|
||||
}
|
||||
9
src/utils/declension.ts
Normal file
9
src/utils/declension.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function declension(number: number, titles: [string, string, string]): string {
|
||||
const cases = [2, 0, 1, 1, 1, 2];
|
||||
|
||||
const idx = number % 100 > 4 && number % 100 < 20
|
||||
? 2
|
||||
: cases[Math.min(number % 10, 5)];
|
||||
|
||||
return titles[idx];
|
||||
}
|
||||
6
src/utils/formatCurrency.ts
Normal file
6
src/utils/formatCurrency.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export function formatCurrency(value: number): string {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
}).format(Math.round(value * 100) / 100);
|
||||
}
|
||||
18
src/utils/product.ts
Normal file
18
src/utils/product.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { Product } from '../types/cart';
|
||||
|
||||
export function normalizeProductImage(product: Product, size = '65,fit'): Product {
|
||||
return {
|
||||
...product,
|
||||
image: product.image?.replace('{SIZE}', size),
|
||||
};
|
||||
}
|
||||
|
||||
export function getLargeProductImage(product: Product): string {
|
||||
return product.image?.replace('65,fit', '335,fit') ?? '';
|
||||
}
|
||||
|
||||
export function getProductAmount(product: Product): string {
|
||||
return product.unit === 'g'
|
||||
? `${product.n} г`
|
||||
: `${product.n} шт`;
|
||||
}
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
22
vite.config.ts
Normal file
22
vite.config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import fs from 'node:fs';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: 'local.hvatilo.ru',
|
||||
port: 9876,
|
||||
https: {
|
||||
cert: fs.readFileSync('local.hvatilo.ru.pem'),
|
||||
key: fs.readFileSync('local.hvatilo.ru-key.pem'),
|
||||
},
|
||||
open: '/src/template.html',
|
||||
},
|
||||
input: {
|
||||
main: './src/template.html',
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user