Files
protekauto-frontend/src/components/CoreProductCard.tsx

417 lines
17 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 React, { useState } from "react";
import { useCart } from "@/contexts/CartContext";
import { useFavorites } from "@/contexts/FavoritesContext";
import toast from "react-hot-toast";
const INITIAL_OFFERS_LIMIT = 5;
interface CoreProductCardOffer {
id?: string;
productId?: string;
offerKey?: string;
pcs: string;
days: string;
recommended?: boolean;
price: string;
count: string;
isExternal?: boolean;
currency?: string;
warehouse?: string;
supplier?: string;
deliveryTime?: number;
}
interface CoreProductCardProps {
brand: string;
article: string;
name: string;
image?: string;
offers: CoreProductCardOffer[];
showMoreText?: string;
isAnalog?: boolean;
isLoadingOffers?: boolean;
onLoadOffers?: () => void;
partsIndexPowered?: boolean;
}
const CoreProductCard: React.FC<CoreProductCardProps> = ({
brand,
article,
name,
image,
offers,
showMoreText,
isAnalog = false,
isLoadingOffers = false,
onLoadOffers,
partsIndexPowered = false
}) => {
const { addItem } = useCart();
const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
const [visibleOffersCount, setVisibleOffersCount] = useState(INITIAL_OFFERS_LIMIT);
const [quantities, setQuantities] = useState<{ [key: number]: number }>(
offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {})
);
const [quantityErrors, setQuantityErrors] = useState<{ [key: number]: string }>({});
const displayedOffers = offers.slice(0, visibleOffersCount);
const hasMoreOffers = visibleOffersCount < offers.length;
// Проверяем, есть ли товар в избранном
const isItemFavorite = isFavorite(
offers[0]?.productId,
offers[0]?.offerKey,
article,
brand
);
// Функция для парсинга цены из строки
const parsePrice = (priceStr: string): number => {
const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.');
return parseFloat(cleanPrice) || 0;
};
// Функция для парсинга времени доставки
const parseDeliveryTime = (daysStr: string): string => {
const match = daysStr.match(/\d+/);
return match ? `${match[0]} дней` : daysStr;
};
// Функция для парсинга количества в наличии
const parseStock = (stockStr: string): number => {
const match = stockStr.match(/\d+/);
return match ? parseInt(match[0]) : 0;
};
const handleQuantityInput = (index: number, value: string) => {
const offer = offers[index];
const availableStock = parseStock(offer.pcs);
let num = parseInt(value, 10);
if (isNaN(num) || num < 1) num = 1;
if (num > availableStock) {
toast.error(`Максимум ${availableStock} шт.`);
return;
}
setQuantities(prev => ({ ...prev, [index]: num }));
};
const handleAddToCart = (offer: CoreProductCardOffer, index: number) => {
const quantity = quantities[index] || 1;
const availableStock = parseStock(offer.pcs);
// Проверяем наличие
if (quantity > availableStock) {
toast.error(`Недостаточно товара в наличии. Доступно: ${availableStock} шт.`);
return;
}
const numericPrice = parsePrice(offer.price);
addItem({
productId: offer.productId,
offerKey: offer.offerKey,
name: name,
description: `${brand} ${article} - ${name}`,
brand: brand,
article: article,
price: numericPrice,
currency: offer.currency || 'RUB',
quantity: quantity,
deliveryTime: parseDeliveryTime(offer.days),
warehouse: offer.warehouse || 'Склад',
supplier: offer.supplier || (offer.isExternal ? 'AutoEuro' : 'Protek'),
isExternal: offer.isExternal || false,
image: image,
});
// Показываем тоастер вместо alert
toast.success(
<div>
<div className="font-semibold">Товар добавлен в корзину!</div>
<div className="text-sm text-gray-600">{`${brand} ${article} (${quantity} шт.)`}</div>
</div>,
{
duration: 3000,
icon: '🛒',
}
);
};
// Обработчик клика по сердечку
const handleFavoriteClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isItemFavorite) {
// Находим товар в избранном и удаляем по правильному ID
const favoriteItem = favorites.find((item: any) => {
// Проверяем по разным комбинациям идентификаторов
if (offers[0]?.productId && item.productId === offers[0].productId) return true;
if (offers[0]?.offerKey && item.offerKey === offers[0].offerKey) return true;
if (item.article === article && item.brand === brand) return true;
return false;
});
if (favoriteItem) {
removeFromFavorites(favoriteItem.id);
}
} else {
// Добавляем в избранное
const bestOffer = offers[0]; // Берем первое предложение как лучшее
const numericPrice = bestOffer ? parsePrice(bestOffer.price) : 0;
addToFavorites({
productId: bestOffer?.productId,
offerKey: bestOffer?.offerKey,
name: name,
brand: brand,
article: article,
price: numericPrice,
currency: bestOffer?.currency || 'RUB',
image: image
});
}
};
if (isLoadingOffers) {
return (
<div className="w-layout-hflex core-product-search-s1">
<div className="w-layout-vflex core-product-s1">
<div className="w-layout-vflex flex-block-47">
<div className="div-block-19">
<img src="/images/info.svg" loading="lazy" alt="info" className="image-9" />
</div>
<div className="w-layout-vflex flex-block-50">
<div className="w-layout-hflex flex-block-79">
<h3 className="heading-10 name">{brand}</h3>
<h3 className="heading-10">{article}</h3>
</div>
<div className="text-block-21">{name}</div>
</div>
</div>
</div>
<div className="w-layout-vflex flex-block-48-copy items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-600"></div>
<p className="mt-2 text-gray-500">Загрузка предложений...</p>
</div>
</div>
);
}
if (!offers || offers.length === 0) {
return (
<div className="w-layout-hflex core-product-search-s1">
<div className="w-layout-vflex core-product-s1">
<div className="w-layout-vflex flex-block-47">
<div className="div-block-19">
<img src="/images/info.svg" loading="lazy" alt="info" className="image-9" />
</div>
<div className="w-layout-vflex flex-block-50">
<div className="w-layout-hflex flex-block-79">
<h3 className="heading-10 name">{brand}</h3>
<h3 className="heading-10">{article}</h3>
</div>
<div className="text-block-21">{name}</div>
</div>
</div>
{image && (
<div className="div-block-20">
<img src={image} loading="lazy" alt={name} className="image-10" />
{partsIndexPowered && (
<div className="text-xs text-gray-500 mt-1 text-center">
powered by <span className="font-semibold text-blue-600">Parts Index</span>
</div>
)}
</div>
)}
</div>
<div className="w-layout-vflex flex-block-48-copy items-center justify-center">
{onLoadOffers ? (
<button
onClick={onLoadOffers}
className="bg-blue-600 text-white font-bold py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors"
>
Загрузить предложения
</button>
) : (
<p className="text-gray-500">Предложений не найдено.</p>
)}
</div>
</div>
);
}
return (
<>
<div className="w-layout-hflex core-product-search-s1">
<div className="w-layout-vflex core-product-s1">
<div className="w-layout-vflex flex-block-47">
<div className="div-block-19">
<img src="/images/info.svg" loading="lazy" alt="info" className="image-9" />
</div>
<div className="w-layout-vflex flex-block-50">
<div className="w-layout-hflex flex-block-79">
<h3 className="heading-10 name">{brand}</h3>
<h3 className="heading-10">{article}</h3>
<div
className="favorite-icon w-embed"
onClick={handleFavoriteClick}
style={{ cursor: 'pointer', marginLeft: '10px', color: isItemFavorite ? '#e53935' : undefined }}
>
<svg width="24" height="24" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M15 25L13.405 23.5613C7.74 18.4714 4 15.1035 4 10.9946C4 7.6267 6.662 5 10.05 5C11.964 5 13.801 5.88283 15 7.26703C16.199 5.88283 18.036 5 19.95 5C23.338 5 26 7.6267 26 10.9946C26 15.1035 22.26 18.4714 16.595 23.5613L15 25Z"
fill={isItemFavorite ? "#e53935" : "currentColor"}
/>
</svg>
</div>
</div>
<div className="text-block-21">{name}</div>
</div>
</div>
{image && (
<div className="div-block-20">
<img src={image} loading="lazy" alt={name} className="image-10" />
{partsIndexPowered && (
<div className="text-xs text-gray-500 mt-1 text-center">
powered by <span className="font-semibold text-blue-600">Parts Index</span>
</div>
)}
</div>
)}
</div>
<div className="w-layout-vflex flex-block-48-copy">
<div className="w-layout-hflex sort-list-s1">
<div className="w-layout-hflex flex-block-49">
<div className="sort-item first">Наличие</div>
<div className="sort-item">Доставка</div>
</div>
<div className="sort-item price">Цена</div>
</div>
<div className="w-layout-vflex product-list-search-s1">
{displayedOffers.map((offer, idx) => {
const isLast = idx === displayedOffers.length - 1;
return (
<div
className="w-layout-hflex product-item-search-s1"
key={idx}
style={isLast ? { borderBottom: 'none' } : undefined}
>
<div className="w-layout-hflex flex-block-81">
<div className="w-layout-hflex info-block-search-s1">
<div className="pcs-search-s1">{offer.pcs}</div>
<div className="pcs-search">{offer.days}</div>
</div>
<div className="w-layout-hflex info-block-product-card-search-s1">
{offer.recommended && (
<>
<div className="w-layout-hflex item-recommend">
<img src="/images/ri_refund-fill.svg" loading="lazy" alt="" />
</div>
<div className="text-block-25-s1">Рекомендуем</div>
</>
)}
</div>
<div className="price-s1">{offer.price}</div>
</div>
<div className="w-layout-hflex add-to-cart-block-s1">
<div className="w-layout-hflex flex-block-82">
<div className="w-layout-hflex pcs-cart-s1">
<button
type="button"
className="minus-plus"
onClick={() => handleQuantityInput(idx, ((quantities[idx] || 1) - 1).toString())}
style={{ cursor: 'pointer' }}
aria-label="Уменьшить количество"
>
<div className="pluspcs w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 10.5V9.5H14V10.5H6Z" fill="currentColor" />
</svg>
</div>
</button>
<div className="input-pcs">
<input
type="number"
min={1}
max={parseStock(offer.pcs)}
value={quantities[idx] || 1}
onChange={e => handleQuantityInput(idx, e.target.value)}
className="text-block-26 w-full text-center outline-none"
aria-label="Количество"
/>
</div>
<button
type="button"
className="minus-plus"
onClick={() => handleQuantityInput(idx, ((quantities[idx] || 1) + 1).toString())}
style={{ cursor: 'pointer' }}
aria-label="Увеличить количество"
>
<div className="pluspcs w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 10.5V9.5H14V10.5H6ZM9.5 6H10.5V14H9.5V6Z" fill="currentColor" />
</svg>
</div>
</button>
</div>
<button
type="button"
onClick={() => handleAddToCart(offer, idx)}
className="button-icon w-inline-block"
style={{ cursor: 'pointer' }}
aria-label="Добавить в корзину"
>
<div className="div-block-26">
<img loading="lazy" src="/images/cart_icon.svg" alt="В корзину" className="image-11" />
</div>
</button>
</div>
</div>
</div>
);
})}
</div>
{hasMoreOffers || visibleOffersCount > INITIAL_OFFERS_LIMIT ? (
<div
className="w-layout-hflex show-more-search"
onClick={() => {
if (hasMoreOffers) {
setVisibleOffersCount(prev => Math.min(prev + 10, offers.length));
} else {
setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
}
}}
style={{ cursor: 'pointer' }}
tabIndex={0}
role="button"
aria-label={hasMoreOffers ? `Еще ${offers.length - visibleOffersCount} предложений` : 'Скрыть предложения'}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
if (hasMoreOffers) {
setVisibleOffersCount(prev => Math.min(prev + 10, offers.length));
} else {
setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
}
}
}}
>
<div className="text-block-27">
{hasMoreOffers ? `Еще ${offers.length - visibleOffersCount} предложений` : 'Скрыть'}
</div>
<img
src="/images/arrow_drop_down.svg"
loading="lazy"
alt=""
className={`transition-transform duration-200 ${!hasMoreOffers ? 'rotate-180' : ''}`}
/>
</div>
) : null}
</div>
</div>
</>
);
};
export default CoreProductCard;