Добавлены новые компоненты для отображения лучших цен и товаров дня с использованием GraphQL. Реализована логика загрузки данных, обработка ошибок и отображение состояния загрузки. Обновлены компоненты BestPriceSection, ProductOfDaySection и TopSalesSection для интеграции с новыми запросами. Улучшено взаимодействие с пользователем через уведомления и обработку кликов.

This commit is contained in:
Bivekich
2025-07-10 00:11:04 +03:00
parent 2a983c956c
commit c703fc839a
6 changed files with 918 additions and 138 deletions

View File

@ -1,44 +1,111 @@
import React from "react";
import { useQuery } from "@apollo/client";
import BestPriceItem from "../BestPriceItem";
import { GET_BEST_PRICE_PRODUCTS } from "../../lib/graphql";
// Моковые данные для лучших цен
const bestPriceItems = [
{
image: "images/162615.webp",
discount: "-35%",
price: "от 17 087 ₽",
oldPrice: "22 347 ₽",
title: 'Аккумуляторная батарея TYUMEN BATTERY "STANDARD", 6CT-60L, 60',
brand: "TYUMEN BATTERY",
},
// ...добавьте еще 7 карточек для примера
...Array(7).fill(0).map((_, i) => ({
image: "images/162615.webp",
discount: "-35%",
price: `от ${(17087 + i * 1000).toLocaleString('ru-RU')}`,
oldPrice: `${(22347 + i * 1000).toLocaleString('ru-RU')}`,
title: `Товар №${i + 2}`,
brand: `Бренд ${i + 2}`,
}))
];
interface BestPriceProductData {
id: string;
productId: string;
discount: number;
isActive: boolean;
sortOrder: number;
product: {
id: string;
name: string;
article?: string;
brand?: string;
retailPrice?: number;
images: { url: string; alt?: string }[];
};
}
const BestPriceSection: React.FC = () => (
<section className="main">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-hflex flex-block-118">
<div className="w-layout-vflex flex-block-119">
<h1 className="heading-20">ЛУЧШАЯ ЦЕНА!</h1>
<div className="text-block-58">Подборка лучших предложенийпо цене</div>
<a href="#" className="button-24 w-button">Показать все</a>
const BestPriceSection: React.FC = () => {
const { data, loading, error } = useQuery(GET_BEST_PRICE_PRODUCTS);
if (loading) {
return (
<section className="main">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-hflex flex-block-118">
<div className="w-layout-vflex flex-block-119">
<h1 className="heading-20">ЛУЧШАЯ ЦЕНА!</h1>
<div className="text-block-58">Загрузка...</div>
</div>
</div>
</div>
<div className="w-layout-hflex flex-block-121">
{bestPriceItems.map((item, i) => (
<BestPriceItem key={i} {...item} />
))}
</section>
);
}
if (error) {
console.error('Ошибка загрузки товаров с лучшей ценой:', error);
return (
<section className="main">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-hflex flex-block-118">
<div className="w-layout-vflex flex-block-119">
<h1 className="heading-20">ЛУЧШАЯ ЦЕНА!</h1>
<div className="text-block-58">Ошибка загрузки данных</div>
</div>
</div>
</div>
</section>
);
}
const bestPriceProducts: BestPriceProductData[] = data?.bestPriceProducts || [];
// Функция для форматирования цены
const formatPrice = (price?: number) => {
if (!price) return '—';
return `от ${price.toLocaleString('ru-RU')}`;
};
// Функция для расчета цены со скидкой
const calculateDiscountedPrice = (price?: number, discount?: number) => {
if (!price || !discount) return price;
return price * (1 - discount / 100);
};
// Преобразование данных для компонента BestPriceItem
const bestPriceItems = bestPriceProducts
.filter(item => item.isActive)
.sort((a, b) => a.sortOrder - b.sortOrder)
.slice(0, 8) // Ограничиваем до 8 товаров
.map(item => ({
image: item.product.images?.[0]?.url || "images/162615.webp", // Fallback изображение
discount: `-${item.discount}%`,
price: formatPrice(calculateDiscountedPrice(item.product.retailPrice, item.discount)),
oldPrice: formatPrice(item.product.retailPrice),
title: item.product.name,
brand: item.product.brand || "",
article: item.product.article,
productId: item.product.id,
}));
// Если нет товаров, не показываем секцию
if (bestPriceItems.length === 0) {
return null;
}
return (
<section className="main">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-hflex flex-block-118">
<div className="w-layout-vflex flex-block-119">
<h1 className="heading-20">ЛУЧШАЯ ЦЕНА!</h1>
<div className="text-block-58">Подборка лучших предложенийпо цене</div>
<a href="#" className="button-24 w-button">Показать все</a>
</div>
<div className="w-layout-hflex flex-block-121">
{bestPriceItems.map((item, i) => (
<BestPriceItem key={i} {...item} />
))}
</div>
</div>
</div>
</div>
</section>
);
</section>
);
};
export default BestPriceSection;

View File

@ -1,67 +1,350 @@
import React from "react";
import React, { useState, useEffect, useRef } from "react";
import { useQuery } from '@apollo/client';
import { GET_DAILY_PRODUCTS, PARTS_INDEX_SEARCH_BY_ARTICLE } from '@/lib/graphql';
import Link from 'next/link';
const ProductOfDaySection: React.FC = () => (
<section className="main">
<div className="w-layout-blockcontainer batd w-container">
<div className="w-layout-hflex flex-block-108">
<div data-delay="4000" data-animation="slide" className="slider w-slider" data-autoplay="false" data-easing="ease" data-hide-arrows="false" data-disable-swipe="false" data-autoplay-limit="0" data-nav-spacing="3" data-duration="500" data-infinite="true">
<div className="mask w-slider-mask">
<div className="slide w-slide">
<div className="div-block-128"></div>
interface DailyProduct {
id: string;
discount?: number;
isActive: boolean;
sortOrder: number;
product: {
id: string;
name: string;
slug: string;
article?: string;
brand?: string;
retailPrice?: number;
wholesalePrice?: number;
images: Array<{
id: string;
url: string;
alt?: string;
order: number;
}>;
};
}
const ProductOfDaySection: React.FC = () => {
// Получаем текущую дату в формате YYYY-MM-DD
const today = new Date().toISOString().split('T')[0];
// Состояние для текущего слайда
const [currentSlide, setCurrentSlide] = useState(0);
const sliderRef = useRef<HTMLDivElement>(null);
const { data, loading, error } = useQuery<{ dailyProducts: DailyProduct[] }>(
GET_DAILY_PRODUCTS,
{
variables: { displayDate: today },
errorPolicy: 'all'
}
);
// Фильтруем только активные товары и сортируем по sortOrder
const activeProducts = React.useMemo(() => {
if (!data?.dailyProducts) return [];
return data.dailyProducts
.filter(item => item.isActive)
.sort((a, b) => a.sortOrder - b.sortOrder);
}, [data]);
// Получаем данные из PartsIndex для текущего товара
const currentProduct = activeProducts[currentSlide];
const { data: partsIndexData } = useQuery(
PARTS_INDEX_SEARCH_BY_ARTICLE,
{
variables: {
articleNumber: currentProduct?.product?.article || '',
brandName: currentProduct?.product?.brand || '',
lang: 'ru'
},
skip: !currentProduct?.product?.article || !currentProduct?.product?.brand,
errorPolicy: 'ignore'
}
);
// Функция для расчета цены со скидкой
const calculateDiscountedPrice = (price: number, discount?: number) => {
if (!discount) return price;
return price * (1 - discount / 100);
};
// Функция для форматирования цены
const formatPrice = (price: number) => {
return new Intl.NumberFormat('ru-RU').format(Math.round(price));
};
// Функция для получения изображения товара
const getProductImage = (product: DailyProduct['product']) => {
// Сначала пытаемся использовать собственные изображения товара
const productImage = product.images
?.sort((a, b) => a.order - b.order)
?.[0];
if (productImage) {
return {
url: productImage.url,
alt: productImage.alt || product.name,
source: 'internal'
};
}
// Если нет собственных изображений, используем PartsIndex
const partsIndexImage = partsIndexData?.partsIndexSearchByArticle?.images?.[0];
if (partsIndexImage) {
return {
url: partsIndexImage,
alt: product.name,
source: 'partsindex'
};
}
return null;
};
// Обработчики для слайдера
const handlePrevSlide = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setCurrentSlide(prev => prev === 0 ? activeProducts.length - 1 : prev - 1);
};
const handleNextSlide = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setCurrentSlide(prev => prev === activeProducts.length - 1 ? 0 : prev + 1);
};
const handlePrevSlideTouch = (e: React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
setCurrentSlide(prev => prev === 0 ? activeProducts.length - 1 : prev - 1);
};
const handleNextSlideTouch = (e: React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
setCurrentSlide(prev => prev === activeProducts.length - 1 ? 0 : prev + 1);
};
const handleSlideIndicator = (index: number) => {
setCurrentSlide(index);
};
// Сброс слайда при изменении товаров
useEffect(() => {
setCurrentSlide(0);
}, [activeProducts]);
// Если нет активных товаров дня, не показываем секцию
if (loading || error || activeProducts.length === 0) {
return null;
}
const product = currentProduct.product;
const productImage = getProductImage(product);
const originalPrice = product.retailPrice || product.wholesalePrice || 0;
const discountedPrice = calculateDiscountedPrice(originalPrice, currentProduct.discount);
const hasDiscount = currentProduct.discount && currentProduct.discount > 0;
return (
<section className="main">
<div className="w-layout-blockcontainer batd w-container">
<div className="w-layout-hflex flex-block-108">
<div
ref={sliderRef}
className="slider w-slider"
>
<div className="mask w-slider-mask">
{activeProducts.map((_, index) => (
<div
key={index}
className={`slide w-slide ${index === currentSlide ? 'w--current' : ''}`}
style={{
display: index === currentSlide ? 'block' : 'none'
}}
>
<div className="div-block-128"></div>
</div>
))}
</div>
<div className="w-slide"></div>
<div className="w-slide"></div>
</div>
<div className="left-arrow w-slider-arrow-left">
<div className="div-block-34">
<div className="code-embed-14 w-embed"><svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
</svg></div>
</div>
</div>
<div className="right-arrow w-slider-arrow-right">
<div className="div-block-34 right">
<div className="code-embed-14 w-embed"><svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
</svg></div>
</div>
</div>
<div className="slide-nav w-slider-nav w-slider-nav-invert w-round"></div>
</div>
<div className="div-block-129">
<div className="w-layout-hflex flex-block-109">
<h1 className="heading-18">ТОВАРЫ ДНЯ</h1>
<div className="saletag">-35%</div>
</div>
<div className="w-layout-hflex flex-block-110">
<div className="w-layout-vflex flex-block-111">
<div className="w-layout-hflex flex-block-16">
<div className="text-block-8">от 17 087 </div>
<div className="text-block-9">22 347 </div>
{/* Стрелки слайдера (показываем только если товаров больше 1) */}
{activeProducts.length > 1 && (
<>
<div className="left-arrow w-slider-arrow-left">
<div className="div-block-34">
<div className="code-embed-14 w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
</div>
</div>
</div>
<div className="right-arrow w-slider-arrow-right">
<div className="div-block-34 right">
<div className="code-embed-14 w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
</div>
</div>
</div>
</>
)}
{/* Индикаторы слайдов */}
{activeProducts.length > 1 && (
<div className="slide-nav w-slider-nav w-slider-nav-invert w-round">
{activeProducts.map((_, index) => (
<div
key={index}
className={`w-slider-dot ${index === currentSlide ? 'w--current' : ''}`}
onClick={() => handleSlideIndicator(index)}
onMouseDown={(e) => e.preventDefault()}
style={{ cursor: 'pointer', zIndex: 10 }}
></div>
))}
</div>
<div className="text-block-10">Аккумуляторная батарея TYUMEN BATTERY &quot;STANDARD&quot;, 6CT-60L, 60</div>
</div><img width="Auto" height="Auto" alt="" src="/images/162615.webp" loading="lazy" srcSet="/images/162615-p-500.webp 500w, /images/162615.webp 600w" sizes="(max-width: 600px) 100vw, 600px" className="image-5-copy" />
)}
</div>
<div className="w-layout-hflex flex-block-125">
<div className="div-block-134">
<div className="code-embed-17 w-embed"><svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentcolor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
</svg></div>
<div className="div-block-129">
<div className="w-layout-hflex flex-block-109">
<h1 className="heading-18">ТОВАРЫ ДНЯ</h1>
{hasDiscount && (
<div className="saletag">-{currentProduct.discount}%</div>
)}
</div>
<div className="div-block-134-copy">
<div className="code-embed-17 w-embed"><svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentcolor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
</svg></div>
<div className="w-layout-hflex flex-block-110">
<div className="w-layout-vflex flex-block-111">
<div className="w-layout-hflex flex-block-16">
<div className="text-block-8">
от {formatPrice(discountedPrice)}
</div>
{hasDiscount && (
<div className="text-block-9">
{formatPrice(originalPrice)}
</div>
)}
</div>
<div className="text-block-10" title={product.name}>
{product.brand && `${product.brand} `}
{product.name}
</div>
{/* Счетчик товаров если их больше одного */}
{activeProducts.length > 1 && (
<div className="text-xs text-gray-500 mt-2">
{currentSlide + 1} из {activeProducts.length}
</div>
)}
</div>
{productImage && (
<div className="relative">
<img
width="Auto"
height="Auto"
alt={productImage.alt}
src={productImage.url}
loading="lazy"
className="image-5-copy"
style={{ cursor: 'pointer' }}
/>
{/* Метка источника изображения */}
{productImage.source === 'partsindex' && (
<div className="absolute bottom-0 right-0 bg-blue-600 text-white text-xs px-2 py-1 rounded-tl">
Parts Index
</div>
)}
</div>
)}
</div>
<div className="w-layout-hflex flex-block-126">
<div className="div-block-135"></div>
<div className="div-block-135"></div>
<div className="w-layout-hflex flex-block-125">
{/* Левая стрелка - предыдущий товар */}
{activeProducts.length > 1 ? (
<div
className="div-block-134"
onClick={handlePrevSlide}
onMouseDown={(e) => e.preventDefault()}
onTouchStart={handlePrevSlideTouch}
style={{ cursor: 'pointer' }}
title="Предыдущий товар"
>
<div className="code-embed-17 w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentcolor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
</div>
</div>
) : (
<div className="div-block-134" style={{ opacity: 0.3 }}>
<div className="code-embed-17 w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentcolor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
</div>
</div>
)}
{/* Правая стрелка - следующий товар */}
{activeProducts.length > 1 ? (
<div
className="div-block-134-copy"
onClick={handleNextSlide}
onMouseDown={(e) => e.preventDefault()}
onTouchStart={handleNextSlideTouch}
style={{ cursor: 'pointer' }}
title="Следующий товар"
>
<div className="code-embed-17 w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentcolor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
</div>
</div>
) : (
<div className="div-block-134-copy" style={{ opacity: 0.3 }}>
<div className="code-embed-17 w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentcolor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
</div>
</div>
)}
{/* Индикаторы точки */}
<div className="w-layout-hflex flex-block-126">
{activeProducts.length > 1 ? (
activeProducts.map((_, index) => (
<div
key={index}
className="div-block-135"
onClick={() => handleSlideIndicator(index)}
style={{
cursor: 'pointer',
opacity: index === currentSlide ? 1 : 0.5,
backgroundColor: index === currentSlide ? 'currentColor' : 'rgba(128,128,128,0.5)'
}}
title={`Товар ${index + 1}`}
/>
))
) : (
<>
<div className="div-block-135" style={{ backgroundColor: 'currentColor' }}></div>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
);
};
export default ProductOfDaySection;

View File

@ -1,54 +1,122 @@
import React from "react";
import ArticleCard from "../ArticleCard";
import { PartsAPIArticle } from "@/types/partsapi";
import { useQuery } from "@apollo/client";
import TopSalesItem from "../TopSalesItem";
import { GET_TOP_SALES_PRODUCTS } from "../../lib/graphql";
// Моковые данные для топ продаж
const topSalesArticles: PartsAPIArticle[] = [
{
artId: "1",
artArticleNr: "6CT-60L",
artSupBrand: "TYUMEN BATTERY",
supBrand: "TYUMEN BATTERY",
supId: 1,
productGroup: "Аккумуляторная батарея",
ptId: 1,
},
{
artId: "2",
artArticleNr: "A0001",
artSupBrand: "Borsehung",
supBrand: "Borsehung",
supId: 2,
productGroup: "Масляный фильтр",
ptId: 2,
},
// ...добавьте еще 6 статей для примера
...Array(6).fill(0).map((_, i) => ({
artId: `${i+3}`,
artArticleNr: `ART${i+3}`,
artSupBrand: `Brand${i+3}`,
supBrand: `Brand${i+3}`,
supId: i+3,
productGroup: `Product Group ${i+3}`,
ptId: i+3,
}))
];
interface TopSalesProductData {
id: string;
productId: string;
isActive: boolean;
sortOrder: number;
product: {
id: string;
name: string;
article?: string;
brand?: string;
retailPrice?: number;
images: { url: string; alt?: string }[];
};
}
const TopSalesSection: React.FC = () => (
<section className="main">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-vflex inbt">
<div className="w-layout-hflex flex-block-31">
<h2 className="heading-4">Топ продаж</h2>
const TopSalesSection: React.FC = () => {
const { data, loading, error } = useQuery(GET_TOP_SALES_PRODUCTS);
if (loading) {
return (
<section className="main">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-vflex inbt">
<div className="w-layout-hflex flex-block-31">
<h2 className="heading-4">Топ продаж</h2>
</div>
<div className="w-layout-hflex core-product-search">
<div className="text-block-58">Загрузка...</div>
</div>
</div>
</div>
<div className="w-layout-hflex core-product-search">
{topSalesArticles.map((article, i) => (
<ArticleCard key={article.artId || i} article={article} index={i} />
))}
</section>
);
}
if (error) {
console.error('Ошибка загрузки топ продаж:', error);
return (
<section className="main">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-vflex inbt">
<div className="w-layout-hflex flex-block-31">
<h2 className="heading-4">Топ продаж</h2>
</div>
<div className="w-layout-hflex core-product-search">
<div className="text-block-58">Ошибка загрузки</div>
</div>
</div>
</div>
</section>
);
}
// Фильтруем активные товары и сортируем по sortOrder
const activeTopSalesProducts = (data?.topSalesProducts || [])
.filter((item: TopSalesProductData) => item.isActive)
.sort((a: TopSalesProductData, b: TopSalesProductData) => a.sortOrder - b.sortOrder)
.slice(0, 8); // Ограничиваем до 8 товаров
if (activeTopSalesProducts.length === 0) {
return (
<section className="main">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-vflex inbt">
<div className="w-layout-hflex flex-block-31">
<h2 className="heading-4">Топ продаж</h2>
</div>
<div className="w-layout-hflex core-product-search">
<div className="text-block-58">Нет товаров в топ продаж</div>
</div>
</div>
</div>
</section>
);
}
return (
<section className="main">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-vflex inbt">
<div className="w-layout-hflex flex-block-31">
<h2 className="heading-4">Топ продаж</h2>
</div>
<div className="w-layout-hflex core-product-search">
{activeTopSalesProducts.map((item: TopSalesProductData) => {
const product = item.product;
const price = product.retailPrice
? `от ${product.retailPrice.toLocaleString('ru-RU')}`
: 'По запросу';
const image = product.images && product.images.length > 0
? product.images[0].url
: '/images/162615.webp'; // Fallback изображение
const title = product.name;
const brand = product.brand || 'Неизвестный бренд';
return (
<TopSalesItem
key={item.id}
image={image}
price={price}
title={title}
brand={brand}
article={product.article}
productId={product.id}
/>
);
})}
</div>
</div>
</div>
</div>
</section>
);
</section>
);
};
export default TopSalesSection;