335 lines
9.6 KiB
TypeScript
335 lines
9.6 KiB
TypeScript
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>•</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;
|
||
}
|
||
`; |