marketing/src/components/Cart/Cart.tsx
2026-08-30 14:54:25 +03:00

335 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import {useGlobalZustandState} from "../../hooks/useGlobalZustandState";
interface CartProps {
products: Product[];
days: number;
isComplete: boolean;
isLoading: boolean;
onProductsChange: Dispatch<SetStateAction<Product[]>>;
onComplete: () => void;
onLoadingChange: (isLoading: boolean) => void;
}
export function Cart({
products,
days,
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 {showFullRefreshButton, excess} = useGlobalZustandState((state) => state);
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>
{showFullRefreshButton && <AbsoluteRefreshButton>Начать заново</AbsoluteRefreshButton>}
</SummaryTitle>
<SummaryDetails $hidden={isLoading}>
{products.length > 0 && (
<>
<span>
{products.length} {declension(products.length, ['товар', 'товара', 'товаров'])}
</span>
<b>&bull;</b>
<span>{formattedTotalPrice}</span>
<SummaryExcess>
С запасами на {stockDays} {declension(stockDays, ['день', 'дня', 'дней'])}
</SummaryExcess>
</>
)}
</SummaryDetails>
<Loader $hidden={!isLoading} data-display-text={displayText}>
{displayText}
</Loader>
</div>
</Summary>
<ScrollWrapper>
<CartList>
{visibleProducts.map(product => (
<CartItem
key={product.name}
product={product}
isProcessing={processingProductName === product.name}
onOpen={() => setSelectedProduct(product)}
onReplace={() => handleReplace(product)}
onDelete={() => handleDelete(product)}
/>
))}
</CartList>
{products.length === 0 && (
<EmptyLoader src="/images/empty-cart-loader.svg" alt="сбор корзины" />
)}
</ScrollWrapper>
{remaining > 0 && (
<ShowMore type="button" onClick={() => setVisibleCount(products.length)}>
Показать ещё <span>{remaining}</span> товаров
<svg viewBox="0 0 24 24" fill="none">
<polyline points="6 9 12 15 18 9" />
</svg>
</ShowMore>
)}
<CartFooter
isVisible={isComplete}
totalPrice={formattedTotalPrice}
/>
<ProductModal
product={selectedProduct}
onClose={() => setSelectedProduct(null)}
onReplace={(product) => {
handleReplace(product);
setSelectedProduct(null);
}}
onDelete={(product) => {
handleDelete(product);
setSelectedProduct(null);
}}
/>
</>
);
}
const AbsoluteRefreshButton = styled.button`
background-color: ${({ theme }) => theme.colors.green};
height: 32px;
color: #ffffff;
border: none;
padding: 14px;
border-radius: 12px;
font-size: 16px;
font-weight: 370;
line-height: 20px;
cursor: pointer;
transition: background 0.2s;
display: flex;
align-items: center;
position: absolute;
right: 0;
top: 0;
&:hover,
&:focus {
background-color: ${({ theme }) => theme.colors.greenHover};
}
&:active {
background-color: #4A8D19;
}
`
const Summary = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-shrink: 0;
width: 100%;
position: relative;
`;
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;
top:32px;
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;
}
`;