first commit

This commit is contained in:
Bivekich
2025-06-26 06:59:59 +03:00
commit d44874775c
450 changed files with 76635 additions and 0 deletions

View 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>
);
}

View 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;

View 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;

View 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;

View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;