first commit
This commit is contained in:
108
src/components/card/InfoCard.tsx
Normal file
108
src/components/card/InfoCard.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
|
||||
import React from "react";
|
||||
import { useFavorites } from "@/contexts/FavoritesContext";
|
||||
|
||||
interface InfoCardProps {
|
||||
brand?: string;
|
||||
articleNumber?: string;
|
||||
name?: string;
|
||||
productId?: string;
|
||||
offerKey?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export default function InfoCard({
|
||||
brand,
|
||||
articleNumber,
|
||||
name,
|
||||
productId,
|
||||
offerKey,
|
||||
price = 0,
|
||||
currency = 'RUB',
|
||||
image
|
||||
}: InfoCardProps) {
|
||||
const { addToFavorites, removeFromFavorites, isFavorite } = useFavorites();
|
||||
|
||||
// Проверяем, есть ли товар в избранном
|
||||
const isItemFavorite = isFavorite(productId, offerKey, articleNumber, brand);
|
||||
|
||||
// Обработчик клика по сердечку
|
||||
const handleFavoriteClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (isItemFavorite) {
|
||||
// Создаем ID для удаления
|
||||
const id = `${productId || offerKey || ''}:${articleNumber}:${brand}`;
|
||||
removeFromFavorites(id);
|
||||
} else {
|
||||
// Добавляем в избранное
|
||||
addToFavorites({
|
||||
productId,
|
||||
offerKey,
|
||||
name: name || "Название товара",
|
||||
brand: brand || "БРЕНД",
|
||||
article: articleNumber || "АРТИКУЛ",
|
||||
price,
|
||||
currency,
|
||||
image
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section-info">
|
||||
<div className="w-layout-blockcontainer container info w-container">
|
||||
<div className="w-layout-vflex flex-block-9">
|
||||
<div className="w-layout-hflex flex-block-7">
|
||||
<a href="/" className="link-block w-inline-block">
|
||||
<div>Главная</div>
|
||||
</a>
|
||||
<div className="text-block-3">→</div>
|
||||
<a href="/catalog" className="link-block w-inline-block">
|
||||
<div>Каталог</div>
|
||||
</a>
|
||||
<div className="text-block-3">→</div>
|
||||
<a href="#" className="link-block w-inline-block">
|
||||
<div>Автозапчасти</div>
|
||||
</a>
|
||||
<div className="text-block-3">→</div>
|
||||
<a href="#" className="link-block-2 w-inline-block">
|
||||
<div>{name || "Деталь"} </div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-bi">
|
||||
<div className="w-layout-hflex headingbi">
|
||||
<h1 className="heading-bi">{name || "Название товара"} {brand || "БРЕНД"}</h1>
|
||||
<div
|
||||
className="div-block-127"
|
||||
onClick={handleFavoriteClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.transform = 'scale(1.1)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'}
|
||||
>
|
||||
<div className="icon-setting w-embed">
|
||||
<svg width="currentwidth" height="currentheight" 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"}
|
||||
style={{ color: isItemFavorite ? "#e53935" : undefined }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex rightbi">
|
||||
<div className="text-block-5-copy">{brand || "БРЕНД"} <strong className="bold-text">{articleNumber || "АРТИКУЛ"}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
106
src/components/card/ProductBuyBlock.tsx
Normal file
106
src/components/card/ProductBuyBlock.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React, { useState } from "react";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
interface ProductBuyBlockProps {
|
||||
offer?: any;
|
||||
}
|
||||
|
||||
const ProductBuyBlock = ({ offer }: ProductBuyBlockProps) => {
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const { addItem } = useCart();
|
||||
|
||||
if (!offer) {
|
||||
return (
|
||||
<div className="w-layout-hflex add-to-cart-block-copy">
|
||||
<div className="text-center py-4">
|
||||
<p className="text-gray-500">Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleQuantityChange = (delta: number) => {
|
||||
const newQuantity = Math.max(1, Math.min(offer.quantity || 999, quantity + delta));
|
||||
setQuantity(newQuantity);
|
||||
};
|
||||
|
||||
// Обработчик добавления в корзину
|
||||
const handleAddToCart = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
if (!offer.price || offer.price <= 0) {
|
||||
toast.error('Цена товара не найдена');
|
||||
return;
|
||||
}
|
||||
|
||||
// Добавляем товар в корзину
|
||||
addItem({
|
||||
productId: offer.id ? String(offer.id) : undefined,
|
||||
offerKey: offer.offerKey || undefined,
|
||||
name: offer.name || `${offer.brand} ${offer.articleNumber}`,
|
||||
description: offer.name || `${offer.brand} ${offer.articleNumber}`,
|
||||
price: offer.price,
|
||||
currency: 'RUB',
|
||||
quantity: quantity,
|
||||
image: offer.image || undefined,
|
||||
brand: offer.brand,
|
||||
article: offer.articleNumber,
|
||||
supplier: offer.supplier || (offer.type === 'external' ? 'AutoEuro' : 'Внутренний'),
|
||||
deliveryTime: offer.deliveryTime ? String(offer.deliveryTime) + ' дней' : '1 день',
|
||||
isExternal: offer.type === 'external'
|
||||
});
|
||||
|
||||
// Показываем успешный тоастер
|
||||
toast.success(
|
||||
<div>
|
||||
<div className="font-semibold">Товар добавлен в корзину!</div>
|
||||
<div className="text-sm text-gray-600">{offer.name || `${offer.brand} ${offer.articleNumber}`}</div>
|
||||
</div>,
|
||||
{
|
||||
duration: 3000,
|
||||
icon: '🛒',
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Ошибка добавления в корзину:', error);
|
||||
toast.error('Ошибка при добавлении товара в корзину');
|
||||
}
|
||||
};
|
||||
|
||||
const totalPrice = offer.price * quantity;
|
||||
|
||||
return (
|
||||
<div className="w-layout-hflex add-to-cart-block-copy">
|
||||
<div className="pcs-card">{offer.quantity || 0} шт</div>
|
||||
<div className="price opencard">{totalPrice.toLocaleString('ru-RU')} ₽</div>
|
||||
<div className="w-layout-hflex pcs-copy">
|
||||
<div className="minus-plus" onClick={() => handleQuantityChange(-1)}>
|
||||
<img loading="lazy" src="images/minus_icon.svg" alt="" />
|
||||
</div>
|
||||
<div className="input-pcs">
|
||||
<div className="text-block-26">{quantity}</div>
|
||||
</div>
|
||||
<div className="minus-plus" onClick={() => handleQuantityChange(1)}>
|
||||
<img loading="lazy" src="images/plus_icon.svg" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="button-icon w-inline-block"
|
||||
onClick={handleAddToCart}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.transform = 'scale(1.05)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'}
|
||||
>
|
||||
<img loading="lazy" src="images/cart_icon.svg" alt="" className="image-11" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductBuyBlock;
|
139
src/components/card/ProductCharacteristics.tsx
Normal file
139
src/components/card/ProductCharacteristics.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import React from "react";
|
||||
|
||||
interface ProductCharacteristicsProps {
|
||||
result?: any;
|
||||
}
|
||||
|
||||
const ProductCharacteristics = ({ result }: ProductCharacteristicsProps) => {
|
||||
|
||||
// Функция для рендеринга характеристик из нашей базы данных
|
||||
const renderInternalCharacteristics = () => {
|
||||
if (!result?.characteristics || result.characteristics.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="w-layout-vflex flex-block-53">
|
||||
<div className="w-layout-hflex flex-block-55">
|
||||
<span className="text-block-29" style={{ fontWeight: 'bold', color: '#333' }}>
|
||||
Характеристики товара:
|
||||
</span>
|
||||
</div>
|
||||
{result.characteristics.map((char: any, index: number) => (
|
||||
<div key={index} className="w-layout-hflex flex-block-55">
|
||||
<span className="text-block-29">{char.characteristic.name}:</span>
|
||||
<span className="text-block-28">{char.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Функция для рендеринга характеристик из Parts Index
|
||||
const renderPartsIndexCharacteristics = () => {
|
||||
if (!result?.partsIndexCharacteristics || result.partsIndexCharacteristics.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="w-layout-vflex flex-block-53">
|
||||
<div className="w-layout-hflex flex-block-55">
|
||||
<span className="text-block-29" style={{ fontWeight: 'bold', color: '#333' }}>
|
||||
Дополнительные характеристики:
|
||||
</span>
|
||||
</div>
|
||||
{result.partsIndexCharacteristics.map((char: any, index: number) => (
|
||||
<div key={index} className="w-layout-hflex flex-block-55">
|
||||
<span className="text-block-29">{char.name}:</span>
|
||||
<span className="text-block-28">{char.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Функция для рендеринга изображений из нашей базы данных
|
||||
const renderInternalImages = () => {
|
||||
if (!result?.images || result.images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="w-layout-vflex flex-block-53" style={{ marginTop: '20px' }}>
|
||||
<div className="w-layout-hflex flex-block-55">
|
||||
<span className="text-block-29" style={{ fontWeight: 'bold', color: '#333' }}>
|
||||
Изображения товара:
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-layout-hflex" style={{ flexWrap: 'wrap', gap: '10px', marginTop: '10px' }}>
|
||||
{result.images.slice(0, 6).map((image: any, index: number) => (
|
||||
<div key={image.id || index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
width: '120px',
|
||||
height: '120px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f9f9f9'
|
||||
}}>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt || `${result?.brand} ${result?.articleNumber} - изображение ${index + 1}`}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
onClick={() => window.open(image.url, '_blank')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-layout-hflex flex-block-52">
|
||||
{result && (
|
||||
<>
|
||||
<div className="w-layout-vflex flex-block-53">
|
||||
<div className="w-layout-hflex flex-block-55">
|
||||
<span className="text-block-29">Бренд:</span>
|
||||
<span className="text-block-28">{result.brand}</span>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-55">
|
||||
<span className="text-block-29">Артикул:</span>
|
||||
<span className="text-block-28">{result.articleNumber}</span>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-55">
|
||||
<span className="text-block-29">Название:</span>
|
||||
<span className="text-block-28">{result.name}</span>
|
||||
</div>
|
||||
{result?.description && (
|
||||
<div className="w-layout-hflex flex-block-55">
|
||||
<span className="text-block-29">Описание:</span>
|
||||
<span className="text-block-28" style={{ maxWidth: '400px', wordWrap: 'break-word' }}>
|
||||
{result.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Характеристики из нашей базы данных */}
|
||||
{renderInternalCharacteristics()}
|
||||
|
||||
{/* Дополнительные характеристики из Parts Index */}
|
||||
{renderPartsIndexCharacteristics()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Изображения из нашей базы данных */}
|
||||
{renderInternalImages()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCharacteristics;
|
33
src/components/card/ProductDescriptionTabs.tsx
Normal file
33
src/components/card/ProductDescriptionTabs.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
interface ProductDescriptionTabsProps {
|
||||
result?: any;
|
||||
}
|
||||
|
||||
const tabList = [
|
||||
{ key: 'description', label: 'Описание' },
|
||||
{ key: 'characteristics', label: 'Характеристики' },
|
||||
// { key: 'reviews', label: 'Отзывы' },
|
||||
// { key: 'analogs', label: 'Аналоги' }
|
||||
];
|
||||
|
||||
const ProductDescriptionTabs = ({ result }: ProductDescriptionTabsProps) => {
|
||||
const [activeTab, setActiveTab] = useState<'description' | 'characteristics' | 'reviews' | 'analogs'>('characteristics');
|
||||
|
||||
return (
|
||||
<div className="w-layout-hflex flex-block-51">
|
||||
{tabList.map(tab => (
|
||||
<div
|
||||
key={tab.key}
|
||||
className={activeTab === tab.key ? 'tab_card-activ' : 'tab_card'}
|
||||
onClick={() => setActiveTab(tab.key as typeof activeTab)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{tab.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDescriptionTabs;
|
123
src/components/card/ProductImageGallery.tsx
Normal file
123
src/components/card/ProductImageGallery.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
interface ProductImageGalleryProps {
|
||||
imageUrl?: string;
|
||||
images?: string[]; // если появятся несколько картинок
|
||||
partsIndexImages?: string[]; // изображения из Parts Index
|
||||
}
|
||||
|
||||
export default function ProductImageGallery({ imageUrl, images, partsIndexImages }: ProductImageGalleryProps) {
|
||||
// Убираем defaultImage - больше не используем заглушку
|
||||
// const defaultImage = "/images/image-10.png";
|
||||
|
||||
// Объединяем все доступные изображения
|
||||
const allImages = [
|
||||
...(partsIndexImages && partsIndexImages.length > 0 ? partsIndexImages : []),
|
||||
...(images && images.length > 0 ? images : []),
|
||||
...(imageUrl ? [imageUrl] : [])
|
||||
];
|
||||
|
||||
// Если нет изображений, показываем заглушку с текстом
|
||||
const galleryImages = allImages.length > 0 ? allImages : [];
|
||||
const [selectedImage, setSelectedImage] = useState(galleryImages[0] || '');
|
||||
const [isOverlayOpen, setIsOverlayOpen] = useState(false);
|
||||
|
||||
// Обновляем selectedImage при изменении galleryImages
|
||||
useEffect(() => {
|
||||
if (galleryImages.length > 0 && !selectedImage) {
|
||||
setSelectedImage(galleryImages[0]);
|
||||
}
|
||||
}, [galleryImages, selectedImage]);
|
||||
|
||||
// Закрытие overlay по ESC
|
||||
useEffect(() => {
|
||||
if (!isOverlayOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOverlayOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOverlayOpen]);
|
||||
|
||||
// Клик вне картинки
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const handleOverlayClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) setIsOverlayOpen(false);
|
||||
}, []);
|
||||
|
||||
// Если нет изображений, показываем заглушку
|
||||
if (galleryImages.length === 0) {
|
||||
return (
|
||||
<div className="w-layout-vflex core-product-copy-copy">
|
||||
<div className="div-block-20 flex items-center justify-center bg-gray-100 rounded-lg" style={{ minHeight: '300px' }}>
|
||||
<div className="text-center text-gray-500">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-sm">Изображения товара не найдены</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-layout-vflex core-product-copy-copy">
|
||||
{/* Основная картинка */}
|
||||
<div className="div-block-20 cursor-zoom-in" onClick={() => setIsOverlayOpen(true)} tabIndex={0} aria-label="Открыть изображение в полный экран" role="button" onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && setIsOverlayOpen(true)}>
|
||||
<img src={selectedImage} loading="lazy" alt="Изображение товара" className="image-10-copy" />
|
||||
</div>
|
||||
{/* Миниатюры */}
|
||||
<div className="w-layout-hflex flex-block-56 mt-2 gap-2">
|
||||
{galleryImages.map((img, idx) => (
|
||||
<img
|
||||
key={img + idx}
|
||||
src={img}
|
||||
loading="lazy"
|
||||
alt={`Миниатюра ${idx + 1}`}
|
||||
className={`small-img cursor-pointer border ${selectedImage === img ? 'border-blue-500' : 'border-transparent'} rounded transition`}
|
||||
onClick={() => {
|
||||
setSelectedImage(img);
|
||||
setIsOverlayOpen(true);
|
||||
}}
|
||||
tabIndex={0}
|
||||
aria-label={`Показать изображение ${idx + 1}`}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
setSelectedImage(img);
|
||||
setIsOverlayOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Overlay для просмотра картинки */}
|
||||
{isOverlayOpen && selectedImage && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-fade-in"
|
||||
onClick={handleOverlayClick}
|
||||
aria-modal="true"
|
||||
role="dialog"
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsOverlayOpen(false)}
|
||||
className="absolute top-6 right-6 text-white bg-black/40 rounded-full p-2 hover:bg-black/70 focus:outline-none"
|
||||
aria-label="Закрыть просмотр изображения"
|
||||
tabIndex={0}
|
||||
>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M8 24L24 8M8 8l16 16" stroke="#fff" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt="Просмотр товара"
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-lg shadow-2xl border-4 border-white"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
76
src/components/card/ProductInfo.tsx
Normal file
76
src/components/card/ProductInfo.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
|
||||
interface ProductInfoProps {
|
||||
offer?: any;
|
||||
}
|
||||
|
||||
const ProductInfo: React.FC<ProductInfoProps> = ({ offer }) => {
|
||||
if (!offer) {
|
||||
return (
|
||||
<div className="w-layout-hflex info-block-search">
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
Нет данных о предложении
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Форматируем срок доставки
|
||||
const formatDeliveryTime = (deliveryTime: number | string) => {
|
||||
const days = typeof deliveryTime === 'string' ? parseInt(deliveryTime) : deliveryTime;
|
||||
|
||||
if (!days || days === 0) {
|
||||
return "Сегодня";
|
||||
} else if (days === 1) {
|
||||
return "Завтра";
|
||||
} else if (days <= 3) {
|
||||
return `${days} дня`;
|
||||
} else if (days <= 7) {
|
||||
return `${days} дней`;
|
||||
} else {
|
||||
return `${days} дней`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-layout-hflex info-block-search">
|
||||
{/* Иконки рекомендации (если есть) */}
|
||||
<div className="w-layout-hflex info-block-product-card-search">
|
||||
{offer.recommended && (
|
||||
<>
|
||||
<div className="w-layout-hflex item-recommend-copy">
|
||||
<img loading="lazy" src="/images/ri_refund-fill_1.svg" alt="Рекомендуем" />
|
||||
</div>
|
||||
<div className="w-layout-hflex item-recommend-copy">
|
||||
<img loading="lazy" src="/images/mdi_approve.svg" alt="Проверено" />
|
||||
</div>
|
||||
<div className="w-layout-hflex item-recommend-copy">
|
||||
<img loading="lazy" src="/images/ri_refund-fill.svg" alt="Гарантия" className="image-16" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Срок доставки */}
|
||||
<div className="delivery-time-search">
|
||||
{formatDeliveryTime(offer.deliveryTime || offer.deliveryDays || 0)}
|
||||
</div>
|
||||
|
||||
{/* Шанс отказа */}
|
||||
{offer.rejects !== undefined && offer.rejects > 0 && (
|
||||
<div className="rejects-info" style={{ fontSize: '12px', color: '#666', marginLeft: '8px' }}>
|
||||
Шанс отказа: {offer.rejects}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Название склада */}
|
||||
{offer.warehouseName && (
|
||||
<div className="warehouse-info" style={{ fontSize: '12px', color: '#666', marginLeft: '8px' }}>
|
||||
Склад: {offer.warehouseName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductInfo;
|
19
src/components/card/ProductItemCard.tsx
Normal file
19
src/components/card/ProductItemCard.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import ProductInfo from "./ProductInfo";
|
||||
import ProductBuyBlock from "./ProductBuyBlock";
|
||||
|
||||
interface ProductItemCardProps {
|
||||
isLast?: boolean;
|
||||
offer?: any;
|
||||
}
|
||||
|
||||
const ProductItemCard = ({ isLast = false, offer }: ProductItemCardProps) => {
|
||||
return (
|
||||
<div className={`w-layout-hflex product-item-card${isLast ? " last" : ""}`}>
|
||||
<ProductInfo offer={offer} />
|
||||
<ProductBuyBlock offer={offer} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductItemCard;
|
43
src/components/card/ProductList.tsx
Normal file
43
src/components/card/ProductList.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import ProductItemCard from "./ProductItemCard";
|
||||
import ProductListSkeleton from "./ProductListSkeleton";
|
||||
|
||||
interface ProductListProps {
|
||||
offers?: any[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ProductList = ({ offers = [], isLoading = false }: ProductListProps) => {
|
||||
// Показываем скелетон во время загрузки
|
||||
if (isLoading) {
|
||||
return <ProductListSkeleton count={4} />;
|
||||
}
|
||||
|
||||
// Фильтруем предложения - показываем только те, у которых есть цена
|
||||
const validOffers = offers.filter(offer => offer && offer.price && offer.price > 0);
|
||||
|
||||
// Если нет валидных предложений
|
||||
if (validOffers.length === 0) {
|
||||
return (
|
||||
<div className="w-layout-vflex product-list-search">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">Предложения с ценами не найдены</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-layout-vflex product-list-search">
|
||||
{validOffers.map((offer, idx) => (
|
||||
<ProductItemCard
|
||||
key={`${offer.type}-${offer.id || idx}`}
|
||||
offer={offer}
|
||||
isLast={idx === validOffers.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductList;
|
66
src/components/card/ProductListSkeleton.tsx
Normal file
66
src/components/card/ProductListSkeleton.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import 'react-loading-skeleton/dist/skeleton.css';
|
||||
|
||||
interface ProductListSkeletonProps {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
const ProductListSkeleton: React.FC<ProductListSkeletonProps> = ({ count = 4 }) => {
|
||||
return (
|
||||
<div className="w-layout-vflex product-list-search">
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div key={index} className={`w-layout-hflex product-item-card${index === count - 1 ? " last" : ""}`}>
|
||||
{/* ProductInfo скелетон */}
|
||||
<div className="w-layout-hflex info-block-search">
|
||||
<div className="w-layout-hflex info-block-product-card-search">
|
||||
{/* Иконки рекомендации */}
|
||||
<div className="w-layout-hflex item-recommend-copy">
|
||||
<Skeleton width={24} height={24} />
|
||||
</div>
|
||||
<div className="w-layout-hflex item-recommend-copy">
|
||||
<Skeleton width={24} height={24} />
|
||||
</div>
|
||||
<div className="w-layout-hflex item-recommend-copy">
|
||||
<Skeleton width={24} height={24} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Срок доставки */}
|
||||
<div className="delivery-time-search">
|
||||
<Skeleton width={80} height={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ProductBuyBlock скелетон */}
|
||||
<div className="w-layout-hflex add-to-cart-block-copy">
|
||||
<div className="pcs-card">
|
||||
<Skeleton width={50} height={20} />
|
||||
</div>
|
||||
<div className="price opencard">
|
||||
<Skeleton width={100} height={24} />
|
||||
</div>
|
||||
<div className="w-layout-hflex pcs-copy">
|
||||
<div className="minus-plus">
|
||||
<Skeleton width={20} height={20} />
|
||||
</div>
|
||||
<div className="input-pcs">
|
||||
<div className="text-block-26">
|
||||
<Skeleton width={30} height={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="minus-plus">
|
||||
<Skeleton width={20} height={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="button-icon w-inline-block">
|
||||
<Skeleton width={32} height={32} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductListSkeleton;
|
49
src/components/card/ProductSortHeader.tsx
Normal file
49
src/components/card/ProductSortHeader.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
|
||||
interface ProductSortHeaderProps {
|
||||
brand?: string;
|
||||
articleNumber?: string;
|
||||
name?: string;
|
||||
sortBy: string;
|
||||
onSortChange: (sortBy: string) => void;
|
||||
}
|
||||
|
||||
const sortOptions = [
|
||||
{ key: "delivery", label: "Доставка" },
|
||||
{ key: "quantity", label: "Количество" },
|
||||
|
||||
{ key: "price", label: "Цена" }
|
||||
];
|
||||
|
||||
const ProductSortHeader: React.FC<ProductSortHeaderProps> = ({
|
||||
brand,
|
||||
articleNumber,
|
||||
name,
|
||||
sortBy,
|
||||
onSortChange
|
||||
}) => {
|
||||
const handleClick = (key: string) => {
|
||||
if (sortBy === key) {
|
||||
onSortChange(""); // сброс сортировки
|
||||
} else {
|
||||
onSortChange(key);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-layout-hflex sort-list-card">
|
||||
{sortOptions.map(option => (
|
||||
<div
|
||||
key={option.key}
|
||||
className={`sort-item${sortBy === option.key ? " active" : ""}`}
|
||||
onClick={() => handleClick(option.key)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductSortHeader;
|
27
src/components/card/ShowMoreOffers.tsx
Normal file
27
src/components/card/ShowMoreOffers.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
interface ShowMoreOffersProps {
|
||||
hasMoreOffers?: boolean;
|
||||
onShowMore?: () => void;
|
||||
remainingCount?: number;
|
||||
}
|
||||
|
||||
const ShowMoreOffers = ({ hasMoreOffers = false, onShowMore, remainingCount = 0 }: ShowMoreOffersProps) => {
|
||||
if (!hasMoreOffers || remainingCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-layout-hflex show-more-search">
|
||||
<button
|
||||
onClick={onShowMore}
|
||||
className="text-block-27"
|
||||
>
|
||||
Показать еще предложения ({remainingCount})
|
||||
</button>
|
||||
<img src="images/arrow_drop_down.svg" loading="lazy" alt="" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowMoreOffers;
|
Reference in New Issue
Block a user