marketing/src/components/Cart/Cart.tsx

337 lines
9.7 KiB
TypeScript
Raw Normal View History

2026-08-24 10:07:16 +00:00
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';
2026-08-30 11:47:34 +00:00
import {useGlobalZustandState} from "../../hooks/useGlobalZustandState";
2026-08-24 10:07:16 +00:00
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);
2026-08-30 11:47:34 +00:00
const {showFullRefreshButton} = useGlobalZustandState((state) => state);
2026-08-24 10:07:16 +00:00
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>
2026-08-30 11:47:34 +00:00
{showFullRefreshButton && <AbsoluteRefreshButton>Начать заново</AbsoluteRefreshButton>}
2026-08-24 10:07:16 +00:00
</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)}
2026-08-30 11:47:34 +00:00
onReplace={(product) => {
handleReplace(product);
setSelectedProduct(null);
}}
onDelete={(product) => {
handleDelete(product);
setSelectedProduct(null);
}}
2026-08-24 10:07:16 +00:00
/>
</>
);
}
2026-08-30 11:47:34 +00:00
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;
}
`
2026-08-24 10:07:16 +00:00
const Summary = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-shrink: 0;
width: 100%;
2026-08-30 11:47:34 +00:00
position: relative;
2026-08-24 10:07:16 +00:00
`;
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;
2026-08-30 11:47:34 +00:00
top:32px;
2026-08-24 10:07:16 +00:00
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;
}
`;