marketing/src/components/Cart/Cart.tsx
2026-09-05 21:48:01 +03:00

408 lines
12 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";
import {NotEnoughPopup} from "./NotEnoughPopup";
import {reachGoal} from "../../hooks/useYM";
interface CartProps {
products: Product[];
days: number;
isComplete: boolean;
isLoading: boolean;
onProductsChange: Dispatch<SetStateAction<Product[]>>;
onComplete: () => void;
onMistake: () => void;
onLoadingChange: (isLoading: boolean) => void;
}
export function Cart({
products,
days,
isComplete,
isLoading,
onProductsChange,
onComplete,
onLoadingChange,
onMistake,
}: CartProps) {
const [showNotEnoughPopup, setShowNotEnoughPopup] = useState(false);
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,
onMistake,
});
async function handleReplace(product: Product) {
reachGoal('ac_product_replace_' + product.name);
setProcessingProductName(product.name);
try {
await replaceProduct(product.name);
onProductsChange(products.filter(item => item.name !== product.name));
} finally {
setProcessingProductName(null);
}
}
async function handleDelete(product: Product) {
reachGoal('ac_product_delete_' + product.name);
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 && (
<>
<div>
<span>
{products.length} {declension(products.length, ['товар', 'товара', 'товаров'])}
</span>
<b>&bull;</b>
<span>{formattedTotalPrice}</span>
</div>
<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)}
/>
))}
{products.length > 0 && products.length === visibleProducts.length && isComplete && !isLoading && <NotEnoughWrapper>
<ShowNotEnough type="button" onClick={() => {
reachGoal('ac_not_enough')
setShowNotEnoughPopup(true)
}}>
Не хватило
</ShowNotEnough>
</NotEnoughWrapper>}
</CartList>
{products.length === 0 && (
<EmptyLoader src="/images/empty-cart-loader.svg" alt="сбор корзины"/>
)}
</ScrollWrapper>
{remaining > 0 && (
<ShowMoreWrap>
<ShowMore type="button" onClick={() => setVisibleCount(products.length)}>
Показать ещё <span>{remaining}</span> товаров
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 9L12 16L5 9" stroke="#5EA12D" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round"/>
</svg>
</ShowMore>
</ShowMoreWrap>
)}
<CartFooter
isVisible={isComplete}
totalPrice={formattedTotalPrice}
/>
<ProductModal
product={selectedProduct}
onClose={() => setSelectedProduct(null)}
onReplace={(product) => {
handleReplace(product);
setSelectedProduct(null);
}}
onDelete={(product) => {
handleDelete(product);
setSelectedProduct(null);
}}
/>
{showNotEnoughPopup && <NotEnoughPopup onClose={() => {
setShowNotEnoughPopup(false)
}}/>}
</>
);
}
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;
> div {
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;
flex: 1 1 100%;
justify-content: space-between;
align-items: center;
gap: 8px;
visibility: ${({$hidden}) => ($hidden ? 'hidden' : 'visible')};
* {
color:inherit;
}
> div:first-child {
display: flex;
align-items: center;
justify-content: start;
gap: 8px;
}
span {
font-weight: 350;
white-space: nowrap;
color:inherit;
}
b {
color:inherit;
font-family: initial;
font-size: 20px;
line-height: 8px;
}
`;
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;
justify-content: center;
`;
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 9%, #C4E30F 24%, #5EA12D 98%) 100%;
background-clip: text;
animation: opacityAnim 1s ease-in-out 0s infinite alternate;
}
&:after {
background: linear-gradient(79deg, #5EA12D 2%, #C4E30F 76%, #FFEC47 91%, #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: 0;
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 ShowMoreWrap = styled.div`
padding: 16px 8px 24px 8px;
display: flex;
justify-content: center;
`;
const ShowMore = styled.button`
display: flex;
align-items: center;
justify-content: center;
padding:8px 24px;
border-radius: 8px;
color: ${({theme}) => theme.colors.green};
font-size: 14px;
cursor: pointer;
font-weight: 500;
transition: 0.2s;
flex-shrink: 0;
gap: 4px;
border: 0;
height: 52px;
background: transparent;
span {
color: ${({theme}) => theme.colors.green};
}
svg {
width: 24px;
height: 24px;
stroke: ${({theme}) => theme.colors.green};
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
margin-left: 6px;
}
span {
color: inherit;
font-weight: inherit;
}
&:hover {
background-color: #F7FBF6;
}
&:active,
&:focus {
background-color: #EEF8EB;
outline: none;
}
`;
const NotEnoughWrapper = styled.div`
display: flex;
flex: 1 1 100%;
justify-content: center;
`;
const ShowNotEnough = styled(ShowMore)`
text-align: center;
`;