Добавлено получение баннеров для главного слайдера с использованием GraphQL. Обновлен компонент HeroSlider для отображения активных баннеров с сортировкой. Реализована логика отображения дефолтного баннера при отсутствии данных. Обновлены стили и структура компонента для улучшения пользовательского интерфейса.

This commit is contained in:
Bivekich
2025-07-15 09:03:32 +03:00
parent 9c152501db
commit 3e98f8fed6
19 changed files with 1109 additions and 342 deletions

View File

@ -3,16 +3,17 @@ import React, { useState, useRef, useEffect } from 'react';
interface CatalogSortDropdownProps {
active: number;
onChange: (index: number) => void;
options?: string[];
}
const sortOptions = [
const defaultSortOptions = [
'По популярности',
'Сначала дешевле',
'Сначала дороже',
'Высокий рейтинг',
];
const CatalogSortDropdown: React.FC<CatalogSortDropdownProps> = ({ active, onChange }) => {
const CatalogSortDropdown: React.FC<CatalogSortDropdownProps> = ({ active, onChange, options = defaultSortOptions }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -52,7 +53,7 @@ const CatalogSortDropdown: React.FC<CatalogSortDropdownProps> = ({ active, onCha
<div>Сортировка</div>
</div>
<nav className={`dropdown-list-2 w-dropdown-list${isOpen ? ' w--open' : ''}`} style={{ minWidth: 180, whiteSpace: 'normal' }}>
{sortOptions.map((option, index) => (
{options.map((option: string, index: number) => (
<a
key={index}
href="#"

View File

@ -0,0 +1,28 @@
import React from 'react';
interface CloseIconProps {
size?: number;
color?: string;
}
const CloseIcon: React.FC<CloseIconProps> = ({ size = 20, color = '#fff' }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 6L6 18M6 6L18 18"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export default CloseIcon;

View File

@ -3,6 +3,7 @@ import { useCart } from "@/contexts/CartContext";
import { useFavorites } from "@/contexts/FavoritesContext";
import toast from "react-hot-toast";
import CartIcon from "./CartIcon";
import { isDeliveryDate } from "@/lib/utils";
const INITIAL_OFFERS_LIMIT = 5;
@ -50,6 +51,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
const { addItem } = useCart();
const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
const [visibleOffersCount, setVisibleOffersCount] = useState(INITIAL_OFFERS_LIMIT);
const [sortBy, setSortBy] = useState<'stock' | 'delivery' | 'price'>('price'); // Локальная сортировка для каждого товара
const [quantities, setQuantities] = useState<{ [key: number]: number }>(
offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {})
);
@ -63,8 +65,52 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
setQuantities(offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {}));
}, [offers.length]);
const displayedOffers = offers.slice(0, visibleOffersCount);
const hasMoreOffers = visibleOffersCount < offers.length;
// Функция для парсинга цены из строки
const parsePrice = (priceStr: string): number => {
const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.');
return parseFloat(cleanPrice) || 0;
};
// Функция для парсинга количества в наличии
const parseStock = (stockStr: string): number => {
const match = stockStr.match(/\d+/);
return match ? parseInt(match[0]) : 0;
};
// Функция для парсинга времени доставки
const parseDeliveryTime = (daysStr: string): string => {
// Если это дата (содержит название месяца), возвращаем как есть
if (isDeliveryDate(daysStr)) {
return daysStr;
}
// Иначе парсим как количество дней (для обратной совместимости)
const match = daysStr.match(/\d+/);
return match ? `${match[0]} дней` : daysStr;
};
// Функция сортировки предложений
const sortOffers = (offers: CoreProductCardOffer[]) => {
const sorted = [...offers];
switch (sortBy) {
case 'stock':
return sorted.sort((a, b) => parseStock(b.pcs) - parseStock(a.pcs));
case 'delivery':
return sorted.sort((a, b) => {
const aDelivery = a.deliveryTime || 999;
const bDelivery = b.deliveryTime || 999;
return aDelivery - bDelivery;
});
case 'price':
return sorted.sort((a, b) => parsePrice(a.price) - parsePrice(b.price));
default:
return sorted;
}
};
const sortedOffers = sortOffers(offers);
const displayedOffers = sortedOffers.slice(0, visibleOffersCount);
const hasMoreOffers = visibleOffersCount < sortedOffers.length;
// Проверяем, есть ли товар в избранном
const isItemFavorite = isFavorite(
@ -74,24 +120,6 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
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 handleInputChange = (idx: number, val: string) => {
setInputValues(prev => ({ ...prev, [idx]: val }));
if (val === "") return;
@ -316,10 +344,28 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
</div>
<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
className={`sort-item first ${sortBy === 'stock' ? 'active' : ''}`}
onClick={() => setSortBy('stock')}
style={{ cursor: 'pointer' }}
>
Наличие
</div>
<div
className={`sort-item ${sortBy === 'delivery' ? 'active' : ''}`}
onClick={() => setSortBy('delivery')}
style={{ cursor: 'pointer' }}
>
Доставим
</div>
</div>
<div
className={`sort-item price ${sortBy === 'price' ? 'active' : ''}`}
onClick={() => setSortBy('price')}
style={{ cursor: 'pointer' }}
>
Цена
</div>
<div className="sort-item price">Цена</div>
</div>
{displayedOffers.map((offer, idx) => {
const isLast = idx === displayedOffers.length - 1;
@ -414,7 +460,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
className="w-layout-hflex show-more-search"
onClick={() => {
if (hasMoreOffers) {
setVisibleOffersCount(prev => Math.min(prev + 10, offers.length));
setVisibleOffersCount(prev => Math.min(prev + 10, sortedOffers.length));
} else {
setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
}
@ -422,11 +468,11 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
style={{ cursor: 'pointer' }}
tabIndex={0}
role="button"
aria-label={hasMoreOffers ? `Еще ${offers.length - visibleOffersCount} предложений` : 'Скрыть предложения'}
aria-label={hasMoreOffers ? `Еще ${sortedOffers.length - visibleOffersCount} предложений` : 'Скрыть предложения'}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
if (hasMoreOffers) {
setVisibleOffersCount(prev => Math.min(prev + 10, offers.length));
setVisibleOffersCount(prev => Math.min(prev + 10, sortedOffers.length));
} else {
setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
}
@ -434,7 +480,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
}}
>
<div className="text-block-27">
{hasMoreOffers ? `Еще ${offers.length - visibleOffersCount} предложений` : 'Скрыть'}
{hasMoreOffers ? `Еще ${sortedOffers.length - visibleOffersCount} предложений` : 'Скрыть'}
</div>
<img
src="/images/arrow_drop_down.svg"

View File

@ -2,6 +2,7 @@ import React, { useState } from "react";
import { useCart } from "@/contexts/CartContext";
import { toast } from "react-hot-toast";
import CartIcon from "../CartIcon";
import { isDeliveryDate } from "@/lib/utils";
interface ProductBuyBlockProps {
offer?: any;
@ -51,7 +52,9 @@ const ProductBuyBlock = ({ offer }: ProductBuyBlockProps) => {
brand: offer.brand,
article: offer.articleNumber,
supplier: offer.supplier || (offer.type === 'external' ? 'AutoEuro' : 'Внутренний'),
deliveryTime: offer.deliveryTime ? String(offer.deliveryTime) + ' дней' : '1 день',
deliveryTime: offer.deliveryTime ? (typeof offer.deliveryTime === 'string' && isDeliveryDate(offer.deliveryTime)
? offer.deliveryTime
: String(offer.deliveryTime) + ' дней') : '1 день',
isExternal: offer.type === 'external'
});

View File

@ -1,4 +1,5 @@
import React from "react";
import { isDeliveryDate } from "@/lib/utils";
interface ProductInfoProps {
offer?: any;
@ -17,6 +18,11 @@ const ProductInfo: React.FC<ProductInfoProps> = ({ offer }) => {
// Форматируем срок доставки
const formatDeliveryTime = (deliveryTime: number | string) => {
// Если это уже дата (содержит название месяца), возвращаем как есть
if (typeof deliveryTime === 'string' && isDeliveryDate(deliveryTime)) {
return deliveryTime;
}
const days = typeof deliveryTime === 'string' ? parseInt(deliveryTime) : deliveryTime;
if (!days || days === 0) {

View File

@ -1,39 +1,141 @@
import React from "react";
import { useQuery } from '@apollo/client';
import { GET_PARTSINDEX_CATEGORIES } from '@/lib/graphql';
import { useRouter } from 'next/router';
const CategoryNavSection: React.FC = () => (
<section className="catnav">
<div className="w-layout-blockcontainer batd w-container">
<div className="w-layout-hflex flex-block-108-copy">
<div className="ci1">
<div className="text-block-54-copy">Детали для ТО</div>
</div>
<div className="ci2">
<div className="text-block-54">Шины</div>
</div>
<div className="ci3">
<div className="text-block-54">Диски</div>
</div>
<div className="ci4">
<div className="text-block-54">Масла и жидкости</div>
</div>
<div className="ci5">
<div className="text-block-54">Инструменты</div>
</div>
<div className="ci6">
<div className="text-block-54">Автохимия</div>
</div>
<div className="ci7">
<div className="text-block-54">Аксессуары</div>
</div>
<div className="ci8">
<div className="text-block-54">Электрика</div>
</div>
<div className="ci9">
<div className="text-block-54">АКБ</div>
</div>
</div>
</div>
</section>
interface PartsIndexCatalog {
id: string;
name: string;
image?: string;
groups?: PartsIndexGroup[];
}
interface PartsIndexGroup {
id: string;
name: string;
image?: string;
subgroups?: PartsIndexSubgroup[];
entityNames?: { id: string; name: string }[];
}
interface PartsIndexSubgroup {
id: string;
name: string;
image?: string;
entityNames?: { id: string; name: string }[];
}
const CategoryNavSection: React.FC = () => {
const router = useRouter();
const { data, loading, error } = useQuery<{ partsIndexCategoriesWithGroups: PartsIndexCatalog[] }>(
GET_PARTSINDEX_CATEGORIES,
{
variables: {
lang: 'ru'
},
errorPolicy: 'all',
fetchPolicy: 'cache-first'
}
);
// Обработчик клика по категории для перехода в каталог с товарами
const handleCategoryClick = (catalog: PartsIndexCatalog) => {
console.log('🔍 Клик по категории:', { catalogId: catalog.id, categoryName: catalog.name });
// Получаем первую группу для groupId (это правильный ID для partsIndexCategory)
const firstGroup = catalog.groups?.[0];
const groupId = firstGroup?.id;
console.log('🔍 Найденная группа:', { groupId, groupName: firstGroup?.name });
// Переходим на страницу каталога с параметрами PartsIndex
router.push({
pathname: '/catalog',
query: {
partsIndexCatalog: catalog.id,
categoryName: encodeURIComponent(catalog.name),
...(groupId && { partsIndexCategory: groupId })
}
});
};
// Fallback данные на случай ошибки
const fallbackCategories = [
{ id: '1', name: 'Двигатель', image: '/images/catalog_item.png' },
{ id: '2', name: 'Трансмиссия', image: '/images/catalog_item2.png' },
{ id: '3', name: 'Подвеска', image: '/images/catalog_item3.png' },
{ id: '4', name: 'Тормоза', image: '/images/catalog_item4.png' },
{ id: '5', name: 'Электрика', image: '/images/catalog_item5.png' },
{ id: '6', name: 'Кузов', image: '/images/catalog_item6.png' },
{ id: '7', name: 'Салон', image: '/images/catalog_item7.png' },
{ id: '8', name: 'Климат', image: '/images/catalog_item8.png' },
{ id: '9', name: 'Расходники', image: '/images/catalog_item9.png' }
];
// Используем данные из API или fallback
const categories = data?.partsIndexCategoriesWithGroups || [];
const displayCategories = categories.length > 0 ? categories : fallbackCategories;
if (loading) {
return (
<div className="w-layout-blockcontainer container-2 w-container">
<div className="w-layout-hflex flex-block-6">
{Array.from({ length: 9 }).map((_, index) => (
<div key={index} className="w-layout-vflex flex-block-7">
<div className="animate-pulse bg-gray-200 h-6 w-20 rounded mb-2"></div>
</div>
))}
</div>
<div className="w-layout-hflex flex-block-5">
{Array.from({ length: 9 }).map((_, index) => (
<div key={index} className="w-layout-vflex flex-block-8">
<div className="animate-pulse bg-gray-200 h-32 w-full rounded"></div>
</div>
))}
</div>
</div>
);
}
return (
<div className="w-layout-blockcontainer container-2 w-container">
{/* Навигационная панель с названиями категорий */}
<div className="w-layout-hflex flex-block-6">
{displayCategories.slice(0, 9).map((catalog) => (
<div key={catalog.id} className="w-layout-vflex flex-block-7">
<div
className="text-block-10"
onClick={() => handleCategoryClick(catalog)}
style={{ cursor: 'pointer' }}
>
{catalog.name}
</div>
</div>
))}
</div>
{/* Блок с изображениями категорий */}
<div className="w-layout-hflex flex-block-5">
{displayCategories.slice(0, 9).map((catalog) => (
<div key={catalog.id} className="w-layout-vflex flex-block-8">
<img
src={catalog.image || '/images/catalog_item.png'}
loading="lazy"
alt={catalog.name}
className="image-5"
onClick={() => handleCategoryClick(catalog)}
style={{ cursor: 'pointer' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = '/images/catalog_item.png';
}}
/>
</div>
))}
</div>
</div>
);
};
export default CategoryNavSection;

View File

@ -1,6 +1,23 @@
import React, { useEffect } from "react";
import { useQuery } from '@apollo/client';
import { GET_HERO_BANNERS } from '@/lib/graphql';
import Link from 'next/link';
interface HeroBanner {
id: string;
title: string;
subtitle?: string;
imageUrl: string;
linkUrl?: string;
isActive: boolean;
sortOrder: number;
}
const HeroSlider = () => {
const { data, loading, error } = useQuery(GET_HERO_BANNERS, {
errorPolicy: 'all'
});
useEffect(() => {
if (typeof window !== "undefined" && window.Webflow && window.Webflow.require) {
if (window.Webflow.destroy) {
@ -12,8 +29,16 @@ const HeroSlider = () => {
}
}, []);
// Фильтруем только активные баннеры и сортируем их
const banners: HeroBanner[] = data?.heroBanners
?.filter((banner: HeroBanner) => banner.isActive)
?.slice()
?.sort((a: HeroBanner, b: HeroBanner) => a.sortOrder - b.sortOrder) || [];
// Если нет данных или происходит загрузка, показываем дефолтный баннер
if (loading || error || banners.length === 0) {
return (
<section className="section-5">
<section className="section-5" style={{ overflow: 'hidden' }}>
<div className="w-layout-blockcontainer container w-container">
<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"
@ -21,92 +46,35 @@ const HeroSlider = () => {
<div className="mask w-slider-mask">
<div className="slide w-slide">
<div className="w-layout-vflex flex-block-100">
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy"
<div className="div-block-35">
<img src="/images/imgfb.png" loading="lazy"
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w" alt=""
className="image-21" /></div>
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w"
alt="Автозапчасти ProteK"
className="image-21" />
</div>
<div className="w-layout-vflex flex-block-99">
<h2 className="heading-17">ШИРОКИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div>
<div className="text-block-51">
Сотрудничаем только с проверенными поставщиками. Постоянно обновляем
ассортимент, чтобы предложить самые лучшие и актуальные детали.
</div>
</div>
<div className="w-layout-hflex flex-block-101">
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt=""
className="image-20" />
<div className="w-layout-hflex flex-block-102">
<img src="/images/1.png" loading="lazy" alt="" className="image-20" />
<div className="text-block-52">Быстрая доставка по всей стране</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt=""
className="image-20" />
<div className="w-layout-hflex flex-block-102">
<img src="/images/2.png" loading="lazy" alt="" className="image-20" />
<div className="text-block-52">Высокое качество продукции</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt=""
className="image-20" />
<div className="w-layout-hflex flex-block-102">
<img src="/images/3.png" loading="lazy" alt="" className="image-20" />
<div className="text-block-52">Выгодные цены</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Профессиональная консультация</div>
</div>
</div>
</div>
</div>
<div className="w-slide">
<div className="w-layout-vflex flex-block-100">
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy"
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w" alt=""
className="image-21" /></div>
<div className="w-layout-vflex flex-block-99">
<h2 className="heading-17">УЗКИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div>
</div>
<div className="w-layout-hflex flex-block-101">
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Быстрая доставка по всей стране</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Высокое качество продукции</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Выгодные цены</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Профессиональная консультация</div>
</div>
</div>
</div>
</div>
<div className="w-slide">
<div className="w-layout-vflex flex-block-100">
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy"
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w" alt=""
className="image-21" /></div>
<div className="w-layout-vflex flex-block-99">
<h2 className="heading-17">ЛУЧШИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div>
</div>
<div className="w-layout-hflex flex-block-101">
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Быстрая доставка по всей стране</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Высокое качество продукции</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Выгодные цены</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt=""
className="image-20" />
<div className="w-layout-hflex flex-block-102">
<img src="/images/4.png" loading="lazy" alt="" className="image-20" />
<div className="text-block-52">Профессиональная консультация</div>
</div>
</div>
@ -128,6 +96,89 @@ const HeroSlider = () => {
</div>
</section>
);
}
const renderSlide = (banner: HeroBanner) => {
const slideContent = (
<div className="w-layout-vflex flex-block-100">
<div className="div-block-35">
<img
src={banner.imageUrl}
loading="lazy"
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
alt={banner.title}
className="image-21"
/>
</div>
<div className="w-layout-vflex flex-block-99">
<h2 className="heading-17">{banner.title}</h2>
{banner.subtitle && (
<div className="text-block-51">{banner.subtitle}</div>
)}
</div>
</div>
);
// Если есть ссылка, оборачиваем в Link
if (banner.linkUrl) {
return (
<Link href={banner.linkUrl} className="slide w-slide" style={{ cursor: 'pointer' }}>
{slideContent}
</Link>
);
}
return (
<div className="slide w-slide">
{slideContent}
</div>
);
};
return (
<section className="section-5" style={{ overflow: 'hidden' }}>
<div className="w-layout-blockcontainer container w-container">
<div
data-delay="4000"
data-animation="slide"
className="slider w-slider"
data-autoplay="true"
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">
{banners.map((banner) => (
<React.Fragment key={banner.id}>
{renderSlide(banner)}
</React.Fragment>
))}
</div>
{/* Показываем стрелки и навигацию только если баннеров больше одного */}
{banners.length > 1 && (
<>
<div className="left-arrow w-slider-arrow-left">
<div className="div-block-34">
<div className="icon-2 w-icon-slider-left"></div>
</div>
</div>
<div className="right-arrow w-slider-arrow-right">
<div className="div-block-34">
<div className="icon-2 w-icon-slider-right"></div>
</div>
</div>
<div className="slide-nav w-slider-nav w-slider-nav-invert w-round"></div>
</>
)}
</div>
</div>
</section>
);
};
export default HeroSlider;

View File

@ -0,0 +1,209 @@
import React, { useState, useEffect } from 'react';
import { useQuery } from '@apollo/client';
import { GET_HERO_BANNERS } from '@/lib/graphql';
import Link from 'next/link';
interface HeroBanner {
id: string;
title: string;
subtitle?: string;
imageUrl: string;
linkUrl?: string;
isActive: boolean;
sortOrder: number;
}
const ProductOfDayBanner: React.FC = () => {
const [currentSlide, setCurrentSlide] = useState(0);
const { data, loading, error } = useQuery(GET_HERO_BANNERS, {
errorPolicy: 'all'
});
// Фильтруем только активные баннеры и сортируем их
const banners: HeroBanner[] = data?.heroBanners
?.filter((banner: HeroBanner) => banner.isActive)
?.slice()
?.sort((a: HeroBanner, b: HeroBanner) => a.sortOrder - b.sortOrder) || [];
// Если нет баннеров из админки, показываем дефолтный
const allBanners = banners.length > 0 ? banners : [{
id: 'default',
title: 'ДОСТАВИМ БЫСТРО!',
subtitle: 'Дополнительная скидка на товары с местного склада',
imageUrl: '/images/imgfb.png',
linkUrl: '',
isActive: true,
sortOrder: 0
}];
// Автопрокрутка слайдов
useEffect(() => {
if (allBanners.length > 1) {
const interval = setInterval(() => {
setCurrentSlide(prev => (prev + 1) % allBanners.length);
}, 5000); // Меняем слайд каждые 5 секунд
return () => clearInterval(interval);
}
}, [allBanners.length]);
// Сброс текущего слайда если он вне диапазона
useEffect(() => {
if (currentSlide >= allBanners.length) {
setCurrentSlide(0);
}
}, [allBanners.length, currentSlide]);
const handlePrevSlide = () => {
setCurrentSlide(prev => prev === 0 ? allBanners.length - 1 : prev - 1);
};
const handleNextSlide = () => {
setCurrentSlide(prev => (prev + 1) % allBanners.length);
};
const handleSlideIndicator = (index: number) => {
setCurrentSlide(index);
};
const currentBanner = allBanners[currentSlide];
const renderBannerContent = (banner: HeroBanner) => {
return (
<div className="div-block-128" style={{
backgroundImage: `url(${banner.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '200px',
width: '100%',
height: '100%',
borderRadius: '20px',
overflow: 'hidden'
}}>
{(banner.title || banner.subtitle) && (
<div style={{
position: 'absolute',
bottom: '20px',
left: '20px',
right: '20px',
color: 'white',
textShadow: '0 2px 4px rgba(0,0,0,0.5)'
}}>
{banner.title && (
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 'bold' }}>
{banner.title}
</h3>
)}
{banner.subtitle && (
<p style={{ margin: '5px 0 0 0', fontSize: '14px' }}>
{banner.subtitle}
</p>
)}
</div>
)}
</div>
);
};
const bannerContent = renderBannerContent(currentBanner);
const finalContent = currentBanner.linkUrl ? (
<Link href={currentBanner.linkUrl} style={{ cursor: 'pointer', display: 'block', width: '100%', height: '100%' }}>
{bannerContent}
</Link>
) : bannerContent;
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{finalContent}
{/* Навигация стрелками (показываем только если баннеров больше 1) */}
{allBanners.length > 1 && (
<>
<div
onClick={handlePrevSlide}
style={{
position: 'absolute',
left: '10px',
top: '50%',
transform: 'translateY(-50%)',
background: 'rgba(0,0,0,0.5)',
color: 'white',
border: 'none',
borderRadius: '50%',
width: '40px',
height: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
zIndex: 10,
fontSize: '18px'
}}
>
</div>
<div
onClick={handleNextSlide}
style={{
position: 'absolute',
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
background: 'rgba(0,0,0,0.5)',
color: 'white',
border: 'none',
borderRadius: '50%',
width: '40px',
height: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
zIndex: 10,
fontSize: '18px'
}}
>
</div>
</>
)}
{/* Индикаторы слайдов (показываем только если баннеров больше 1) */}
{allBanners.length > 1 && (
<div style={{
position: 'absolute',
bottom: '10px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: '8px',
zIndex: 10
}}>
{allBanners.map((_, index) => (
<div
key={index}
onClick={() => handleSlideIndicator(index)}
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
background: index === currentSlide ? 'white' : 'rgba(255,255,255,0.5)',
cursor: 'pointer',
transition: 'background 0.3s'
}}
/>
))}
</div>
)}
</div>
);
};
export default ProductOfDayBanner;

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect } from "react";
import { useQuery } from '@apollo/client';
import { GET_DAILY_PRODUCTS, PARTS_INDEX_SEARCH_BY_ARTICLE } from '@/lib/graphql';
import Link from 'next/link';
import ProductOfDayBanner from './ProductOfDayBanner';
interface DailyProduct {
id: string;
@ -31,7 +32,6 @@ const ProductOfDaySection: React.FC = () => {
// Состояние для текущего слайда
const [currentSlide, setCurrentSlide] = useState(0);
const sliderRef = useRef<HTMLDivElement>(null);
const { data, loading, error } = useQuery<{ dailyProducts: DailyProduct[] }>(
GET_DAILY_PRODUCTS,
@ -113,7 +113,7 @@ const ProductOfDaySection: React.FC = () => {
return null;
};
// Обработчики для слайдера
// Обработчики для навигации по товарам дня
const handlePrevSlide = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@ -158,63 +158,7 @@ const ProductOfDaySection: React.FC = () => {
<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>
{/* Стрелки слайдера (показываем только если товаров больше 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>
<ProductOfDayBanner />
<div className="div-block-129">
<div className="w-layout-hflex flex-block-109">

View File

@ -404,6 +404,7 @@ const KnotIn: React.FC<KnotInProps> = ({
onClick={() => setIsImageModalOpen(false)}
style={{ cursor: 'zoom-out' }}
>
<div className="relative">
<img
src={imageUrl}
alt={unitName || unitInfo?.name || "Изображение узла"}
@ -411,9 +412,11 @@ const KnotIn: React.FC<KnotInProps> = ({
onClick={e => e.stopPropagation()}
style={{ background: '#fff' }}
/>
{/* Убираем интерактивные точки в модальном окне */}
</div>
<button
onClick={() => setIsImageModalOpen(false)}
className="absolute top-4 right-4 text-white text-3xl font-bold bg-black bg-opacity-40 rounded-full w-10 h-10 flex items-center justify-center"
className="absolute top-4 right-4 text-white text-3xl font-bold bg-black bg-opacity-40 rounded-full w-10 h-10 flex items-center justify-center hover:bg-black hover:bg-opacity-60 transition-colors"
aria-label="Закрыть"
style={{ zIndex: 10000 }}
>

View File

@ -36,7 +36,9 @@ const KnotParts: React.FC<KnotPartsProps> = ({
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
const [tooltipPart, setTooltipPart] = useState<any>(null);
const [clickedPart, setClickedPart] = useState<string | number | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Отладочные логи для проверки данных
React.useEffect(() => {
@ -63,8 +65,31 @@ const KnotParts: React.FC<KnotPartsProps> = ({
// Обработчик клика по детали в списке
const handlePartClick = (part: any) => {
if (part.codeonimage && onPartSelect) {
onPartSelect(part.codeonimage);
const codeOnImage = part.codeonimage || part.detailid;
if (codeOnImage && onPartSelect) {
onPartSelect(codeOnImage);
}
// Также подсвечиваем деталь на схеме при клике
if (codeOnImage && onPartHover) {
// Очищаем предыдущий таймер, если он есть
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
}
// Устанавливаем состояние кликнутой детали
setClickedPart(codeOnImage);
// Подсвечиваем на схеме
onPartHover(codeOnImage);
// Убираем подсветку через интервал
clickTimeoutRef.current = setTimeout(() => {
setClickedPart(null);
if (onPartHover) {
onPartHover(null);
}
}, 1500); // Подсветка будет видна 1.5 секунды
}
};
@ -150,6 +175,9 @@ const KnotParts: React.FC<KnotPartsProps> = ({
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
}
};
}, []);
@ -213,12 +241,17 @@ const KnotParts: React.FC<KnotPartsProps> = ({
<div className="knot-parts">
{parts.map((part, idx) => {
const codeOnImage = part.codeonimage || part.detailid;
const isHighlighted = highlightedCodeOnImage !== null && highlightedCodeOnImage !== undefined && (
(part.codeonimage && part.codeonimage.toString() === highlightedCodeOnImage.toString()) ||
(part.detailid && part.detailid.toString() === highlightedCodeOnImage.toString())
);
const isSelected = selectedParts.has(part.detailid || part.codeonimage || idx.toString());
const isClicked = clickedPart !== null && (
(part.codeonimage && part.codeonimage.toString() === clickedPart.toString()) ||
(part.detailid && part.detailid.toString() === clickedPart.toString())
);
// Создаем уникальный ключ
const uniqueKey = `part-${idx}-${part.detailid || part.oem || part.name || 'unknown'}`;
@ -226,9 +259,11 @@ const KnotParts: React.FC<KnotPartsProps> = ({
return (
<div
key={uniqueKey}
className={`w-layout-hflex knotlistitem rounded-lg cursor-pointer transition-colors ${
className={`w-layout-hflex knotlistitem rounded-lg cursor-pointer transition-all duration-300 ${
isSelected
? 'bg-green-100 border-green-500'
: isClicked
? 'bg-red-100 border-red-400 shadow-md'
: isHighlighted
? 'bg-slate-200'
: 'bg-white border-gray-200 hover:border-gray-300'
@ -240,13 +275,37 @@ const KnotParts: React.FC<KnotPartsProps> = ({
>
<div className="w-layout-hflex flex-block-116">
<div
className={`nuberlist ${isSelected ? 'text-green-700 font-bold' : isHighlighted ? ' font-bold' : ''}`}
className={`nuberlist ${
isSelected
? 'text-green-700 font-bold'
: isClicked
? 'text-red-700 font-bold'
: isHighlighted
? 'font-bold'
: ''
}`}
>
{part.codeonimage || idx + 1}
</div>
<div className={`oemnuber ${isSelected ? 'text-green-800 font-semibold' : isHighlighted ? ' font-semibold' : ''}`}>{part.oem}</div>
<div className={`oemnuber ${
isSelected
? 'text-green-800 font-semibold'
: isClicked
? 'text-red-800 font-semibold'
: isHighlighted
? 'font-semibold'
: ''
}`}>{part.oem}</div>
</div>
<div className={`partsname ${isSelected ? 'text-green-800 font-semibold' : isHighlighted ? ' font-semibold' : ''}`}>
<div className={`partsname ${
isSelected
? 'text-green-800 font-semibold'
: isClicked
? 'text-red-800 font-semibold'
: isHighlighted
? 'font-semibold'
: ''
}`}>
{part.name}
</div>
<div className="w-layout-hflex flex-block-117">

View File

@ -4,7 +4,7 @@ import React, { createContext, useContext, useReducer, useEffect, ReactNode } fr
import { useMutation, useQuery } from '@apollo/client'
import toast from 'react-hot-toast'
import { GET_FAVORITES, ADD_TO_FAVORITES, REMOVE_FROM_FAVORITES, CLEAR_FAVORITES } from '@/lib/favorites-queries'
import DeleteCartIcon from '@/components/DeleteCartIcon'
import CloseIcon from '@/components/CloseIcon'
// Типы
export interface FavoriteItem {
@ -135,7 +135,7 @@ const FavoritesProvider: React.FC<FavoritesProviderProps> = ({ children }) => {
const [removeFavoriteMutation] = useMutation(REMOVE_FROM_FAVORITES, {
onCompleted: () => {
toast('Товар удален из избранного', {
icon: <DeleteCartIcon size={20} color="#ec1c24" />,
icon: <CloseIcon size={20} color="#fff" />,
style: {
background: '#6b7280', // Серый фон
color: '#fff', // Белый текст

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { useLazyQuery } from '@apollo/client';
import { SEARCH_PRODUCT_OFFERS } from '@/lib/graphql';
@ -33,17 +33,19 @@ interface ProductPriceVariables {
brand: string;
}
export const useProductPrices = (products: Array<{ code: string; brand: string; id: string }>) => {
export const useProductPrices = () => {
const [pricesMap, setPricesMap] = useState<Map<string, ProductOffer | null>>(new Map());
const [loadingPrices, setLoadingPrices] = useState<Set<string>>(new Set());
const [loadedPrices, setLoadedPrices] = useState<Set<string>>(new Set());
const [searchOffers] = useLazyQuery<ProductPriceData, ProductPriceVariables>(SEARCH_PRODUCT_OFFERS);
const loadPrice = async (product: { code: string; brand: string; id: string }) => {
const loadPrice = useCallback(async (product: { code: string; brand: string; id: string }) => {
const key = `${product.id}_${product.code}_${product.brand}`;
if (pricesMap.has(key) || loadingPrices.has(key)) {
return; // Уже загружено или загружается
// Если уже загружено или загружается - не делаем повторный запрос
if (loadedPrices.has(key) || loadingPrices.has(key)) {
return;
}
console.log('💰 Загружаем цену для:', product.code, product.brand);
@ -87,35 +89,31 @@ export const useProductPrices = (products: Array<{ code: string; brand: string;
newSet.delete(key);
return newSet;
});
setLoadedPrices(prev => new Set([...prev, key]));
}
};
}, [searchOffers, loadedPrices, loadingPrices]);
useEffect(() => {
// Загружаем цены для всех товаров с небольшой задержкой между запросами
products.forEach((product, index) => {
setTimeout(() => {
loadPrice(product);
}, index * 100); // Задержка 100мс между запросами
});
}, [products]);
const getPrice = (product: { code: string; brand: string; id: string }) => {
const getPrice = useCallback((product: { code: string; brand: string; id: string }) => {
const key = `${product.id}_${product.code}_${product.brand}`;
return pricesMap.get(key);
};
}, [pricesMap]);
const isLoadingPrice = (product: { code: string; brand: string; id: string }) => {
const isLoadingPrice = useCallback((product: { code: string; brand: string; id: string }) => {
const key = `${product.id}_${product.code}_${product.brand}`;
return loadingPrices.has(key);
};
}, [loadingPrices]);
const loadPriceOnDemand = (product: { code: string; brand: string; id: string }) => {
const ensurePriceLoaded = useCallback((product: { code: string; brand: string; id: string }) => {
const key = `${product.id}_${product.code}_${product.brand}`;
if (!loadedPrices.has(key) && !loadingPrices.has(key)) {
loadPrice(product);
};
}
}, [loadPrice, loadedPrices, loadingPrices]);
return {
getPrice,
isLoadingPrice,
loadPriceOnDemand
loadPrice,
ensurePriceLoaded
};
};

View File

@ -45,6 +45,20 @@ export const GET_TOP_SALES_PRODUCTS = gql`
}
`
export const GET_HERO_BANNERS = gql`
query GetHeroBanners {
heroBanners {
id
title
subtitle
imageUrl
linkUrl
isActive
sortOrder
}
}
`
export const CHECK_CLIENT_BY_PHONE = gql`
mutation CheckClientByPhone($phone: String!) {
checkClientByPhone(phone: $phone) {

View File

@ -93,3 +93,13 @@ export const memoize = <T extends (...args: any[]) => any>(
export const clearMemoCache = () => {
memoCache.clear();
};
// Проверка, является ли строка датой доставки
export const isDeliveryDate = (dateString: string): boolean => {
const months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
];
return months.some(month => dateString.includes(month));
};

View File

@ -52,18 +52,20 @@ export default function App({ Component, pageProps }: AppProps) {
<Component {...pageProps} />
</Layout>
<Toaster
position="top-right"
position="top-center"
toastOptions={{
duration: 4000,
style: {
background: '#363636',
color: '#fff',
marginTop: '80px', // Отступ сверху, чтобы не закрывать кнопки меню
},
success: {
duration: 3000,
style: {
background: '#22c55e', // Зеленый фон для успешных уведомлений
color: '#fff', // Белый текст
marginTop: '80px', // Отступ сверху для успешных уведомлений
},
iconTheme: {
primary: '#22c55e',
@ -72,6 +74,9 @@ export default function App({ Component, pageProps }: AppProps) {
},
error: {
duration: 5000,
style: {
marginTop: '80px', // Отступ сверху для ошибок
},
iconTheme: {
primary: '#ef4444',
secondary: '#fff',

View File

@ -38,7 +38,8 @@ const mockData = Array(12).fill({
brand: "Borsehung",
});
const ITEMS_PER_PAGE = 20;
const ITEMS_PER_PAGE = 50; // Целевое количество товаров на странице
const PARTSINDEX_PAGE_SIZE = 25; // Размер страницы PartsIndex API (фиксированный)
const MAX_BRANDS_DISPLAY = 10; // Сколько брендов показывать изначально
export default function Catalog() {
@ -73,6 +74,13 @@ export default function Catalog() {
const [partsIndexPage, setPartsIndexPage] = useState(1); // Текущая страница для PartsIndex
const [totalPages, setTotalPages] = useState(1); // Общее количество страниц
// Новые состояния для логики автоподгрузки PartsIndex
const [accumulatedEntities, setAccumulatedEntities] = useState<PartsIndexEntity[]>([]); // Все накопленные товары
const [entitiesWithOffers, setEntitiesWithOffers] = useState<PartsIndexEntity[]>([]); // Товары с предложениями
const [isAutoLoading, setIsAutoLoading] = useState(false); // Автоматическая подгрузка в процессе
const [currentUserPage, setCurrentUserPage] = useState(1); // Текущая пользовательская страница
const [entitiesCache, setEntitiesCache] = useState<Map<number, PartsIndexEntity[]>>(new Map()); // Кэш страниц
// Карта видимости товаров по индексу
const [visibilityMap, setVisibilityMap] = useState<Map<number, boolean>>(new Map());
@ -108,7 +116,8 @@ export default function Catalog() {
categoryName,
isPartsAPIMode,
isPartsIndexMode,
isPartsIndexCatalogOnly
isPartsIndexCatalogOnly,
'router.query': router.query
});
// Загружаем артикулы PartsAPI
@ -135,7 +144,7 @@ export default function Catalog() {
catalogId: catalogId as string,
groupId: groupId as string,
lang: 'ru',
limit: ITEMS_PER_PAGE,
limit: PARTSINDEX_PAGE_SIZE,
page: partsIndexPage,
q: searchQuery || undefined,
params: undefined // Будем обновлять через refetch
@ -164,12 +173,24 @@ export default function Catalog() {
// allEntities больше не используется - используем allLoadedEntities
// Хук для загрузки цен товаров PartsIndex
const productsForPrices = visibleEntities.map(entity => ({
const { getPrice, isLoadingPrice, ensurePriceLoaded } = useProductPrices();
// Загружаем цены для видимых товаров PartsIndex
useEffect(() => {
if (isPartsIndexMode && visibleEntities.length > 0) {
visibleEntities.forEach((entity, index) => {
const productForPrice = {
id: entity.id,
code: entity.code,
brand: entity.brand.name
}));
const { getPrice, isLoadingPrice, loadPriceOnDemand } = useProductPrices(productsForPrices);
};
// Загружаем с небольшой задержкой чтобы не перегружать сервер
setTimeout(() => {
ensurePriceLoaded(productForPrice);
}, index * 50);
});
}
}, [isPartsIndexMode, visibleEntities, ensurePriceLoaded]);
useEffect(() => {
if (articlesData?.partsAPIArticles) {
@ -193,9 +214,6 @@ export default function Catalog() {
const newEntities = entitiesData.partsIndexCatalogEntities.list;
const pagination = entitiesData.partsIndexCatalogEntities.pagination;
// Обновляем список товаров
setVisibleEntities(newEntities);
// Обновляем информацию о пагинации
const currentPage = pagination?.page?.current || 1;
const hasNext = pagination?.page?.next !== null;
@ -204,6 +222,20 @@ export default function Catalog() {
setPartsIndexPage(currentPage);
setHasMoreEntities(hasNext);
// Сохраняем в кэш
setEntitiesCache(prev => new Map(prev).set(currentPage, newEntities));
// Если это первая страница или сброс, заменяем накопленные товары
if (currentPage === 1) {
setAccumulatedEntities(newEntities);
// Устанавливаем visibleEntities сразу, не дожидаясь проверки цен
setVisibleEntities(newEntities);
console.log('✅ Установлены visibleEntities для первой страницы:', newEntities.length);
} else {
// Добавляем к накопленным товарам
setAccumulatedEntities(prev => [...prev, ...newEntities]);
}
// Вычисляем общее количество страниц (приблизительно)
if (hasNext) {
setTotalPages(currentPage + 1); // Минимум еще одна страница
@ -216,7 +248,7 @@ export default function Catalog() {
}, [entitiesData]);
// Преобразование выбранных фильтров в формат PartsIndex API
const convertFiltersToPartsIndexParams = useCallback((): Record<string, any> => {
const convertFiltersToPartsIndexParams = useMemo((): Record<string, any> => {
if (!paramsData?.partsIndexCatalogParams?.list || Object.keys(selectedFilters).length === 0) {
return {};
}
@ -241,6 +273,84 @@ export default function Catalog() {
return apiParams;
}, [paramsData, selectedFilters]);
// Функция автоматической подгрузки дополнительных страниц PartsIndex
const autoLoadMoreEntities = useCallback(async () => {
if (isAutoLoading || !hasMoreEntities || !isPartsIndexMode) {
return;
}
console.log('🔄 Автоподгрузка: проверяем товары с предложениями...');
// Восстанавливаем автоподгрузку
console.log('🔄 Автоподгрузка активна');
// Подсчитываем текущее количество товаров с предложениями
const currentEntitiesWithOffers = accumulatedEntities.filter(entity => {
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
const priceData = getPrice(productForPrice);
const isLoadingPriceData = isLoadingPrice(productForPrice);
// Товар считается "с предложениями" если у него есть реальная цена (не null и не undefined)
return (priceData && priceData.price && priceData.price > 0) || isLoadingPriceData;
});
console.log('📊 Автоподгрузка: текущее состояние:', {
накопленоТоваров: accumulatedEntities.length,
сПредложениями: currentEntitiesWithOffers.length,
целевоеКоличество: ITEMS_PER_PAGE,
естьЕщеТовары: hasMoreEntities
});
// Если у нас уже достаточно товаров с предложениями, не загружаем
if (currentEntitiesWithOffers.length >= ITEMS_PER_PAGE) {
console.log('✅ Автоподгрузка: достаточно товаров с предложениями');
return;
}
// Даем время на загрузку цен товаров, если их слишком много загружается
const loadingCount = accumulatedEntities.filter(entity => {
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
return isLoadingPrice(productForPrice);
}).length;
// Ждем только если загружается больше 5 товаров одновременно
if (loadingCount > 5) {
console.log('⏳ Автоподгрузка: ждем загрузки цен для', loadingCount, 'товаров (больше 5)');
return;
}
// Если накопили уже много товаров, но мало с предложениями - прекращаем попытки
if (accumulatedEntities.length >= ITEMS_PER_PAGE * 8) { // Увеличили лимит с 4 до 8 страниц
console.log('⚠️ Автоподгрузка: достигли лимита попыток, прекращаем');
return;
}
setIsAutoLoading(true);
try {
console.log('🔄 Автоподгрузка: загружаем следующую страницу PartsIndex...');
const apiParams = convertFiltersToPartsIndexParams;
const paramsString = Object.keys(apiParams).length > 0 ? JSON.stringify(apiParams) : undefined;
const result = await refetchEntities({
catalogId: catalogId as string,
groupId: groupId as string,
lang: 'ru',
limit: PARTSINDEX_PAGE_SIZE,
page: partsIndexPage + 1,
q: searchQuery || undefined,
params: paramsString
});
console.log('✅ Автоподгрузка: страница загружена, результат:', result.data?.partsIndexCatalogEntities?.list?.length || 0);
} catch (error) {
console.error('❌ Автоподгрузка: ошибка загрузки следующей страницы:', error);
} finally {
setIsAutoLoading(false);
}
}, [isAutoLoading, hasMoreEntities, isPartsIndexMode, accumulatedEntities.length, partsIndexPage, refetchEntities, catalogId, groupId, searchQuery]);
// Генерация фильтров для PartsIndex на основе параметров API
const generatePartsIndexFilters = useCallback((): FilterConfig[] => {
if (!paramsData?.partsIndexCatalogParams?.list) {
@ -292,6 +402,114 @@ export default function Catalog() {
}
}, [isPartsIndexMode, generatePartsIndexFilters, paramsLoading]);
// Автоматическая подгрузка товаров с задержкой для загрузки цен
useEffect(() => {
if (!isPartsIndexMode || accumulatedEntities.length === 0 || isAutoLoading) {
return;
}
// Даем время на загрузку цен (3 секунды после последнего изменения)
const timer = setTimeout(() => {
autoLoadMoreEntities();
}, 3000);
return () => clearTimeout(timer);
}, [isPartsIndexMode, accumulatedEntities.length, isAutoLoading]);
// Дополнительный триггер автоподгрузки при изменении количества товаров с предложениями
useEffect(() => {
console.log('🔍 Проверка триггера автоподгрузки:', {
isPartsIndexMode,
entitiesWithOffersLength: entitiesWithOffers.length,
isAutoLoading,
hasMoreEntities,
targetItemsPerPage: ITEMS_PER_PAGE
});
if (!isPartsIndexMode || entitiesWithOffers.length === 0 || isAutoLoading) {
return;
}
// Если товаров с предложениями мало, запускаем автоподгрузку через 1 секунду
if (entitiesWithOffers.length < ITEMS_PER_PAGE && hasMoreEntities) {
console.log('🚀 Запускаем автоподгрузку: товаров', entitiesWithOffers.length, 'из', ITEMS_PER_PAGE);
const timer = setTimeout(() => {
console.log('🚀 Дополнительная автоподгрузка: недостаточно товаров с предложениями');
autoLoadMoreEntities();
}, 1000);
return () => clearTimeout(timer);
} else {
console.log('✅ Автоподгрузка не нужна: товаров достаточно или нет больше данных');
}
}, [isPartsIndexMode, entitiesWithOffers.length, hasMoreEntities, isAutoLoading]);
// Обновляем список товаров с предложениями при изменении накопленных товаров или цен
useEffect(() => {
if (!isPartsIndexMode) {
return;
}
// Показываем все товары, но отдельно считаем те, у которых есть цены
const entitiesWithOffers = accumulatedEntities;
// Подсчитываем количество товаров с реальными ценами для автоподгрузки
const entitiesWithRealPrices = accumulatedEntities.filter(entity => {
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
const priceData = getPrice(productForPrice);
return priceData && priceData.price && priceData.price > 0;
});
console.log('📊 Обновляем entitiesWithOffers:', {
накопленоТоваров: accumulatedEntities.length,
отображаемыхТоваров: entitiesWithOffers.length,
сРеальнымиЦенами: entitiesWithRealPrices.length,
целевоеКоличество: ITEMS_PER_PAGE
});
setEntitiesWithOffers(entitiesWithOffers);
// Показываем товары для текущей пользовательской страницы
const startIndex = (currentUserPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const visibleForCurrentPage = entitiesWithOffers.slice(startIndex, endIndex);
console.log('📊 Обновляем visibleEntities:', {
currentUserPage,
startIndex,
endIndex,
visibleForCurrentPage: visibleForCurrentPage.length,
entitiesWithOffers: entitiesWithOffers.length
});
setVisibleEntities(visibleForCurrentPage);
}, [isPartsIndexMode, accumulatedEntities, currentUserPage]);
// Отдельный useEffect для обновления статистики цен (без влияния на visibleEntities)
useEffect(() => {
if (!isPartsIndexMode || accumulatedEntities.length === 0) {
return;
}
// Обновляем статистику каждые 2 секунды
const timer = setTimeout(() => {
const entitiesWithRealPrices = accumulatedEntities.filter(entity => {
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
const priceData = getPrice(productForPrice);
return priceData && priceData.price && priceData.price > 0;
});
console.log('💰 Обновление статистики цен:', {
накопленоТоваров: accumulatedEntities.length,
сРеальнымиЦенами: entitiesWithRealPrices.length,
процентЗагрузки: Math.round((entitiesWithRealPrices.length / accumulatedEntities.length) * 100)
});
}, 2000);
return () => clearTimeout(timer);
}, [isPartsIndexMode, accumulatedEntities.length, getPrice]);
// Генерируем динамические фильтры для PartsAPI
const generatePartsAPIFilters = useCallback((): FilterConfig[] => {
if (!allArticles.length) return [];
@ -431,9 +649,6 @@ export default function Catalog() {
});
}, [allArticles, searchQuery, selectedFilters]);
// Упрощенная логика - показываем все загруженные товары без клиентской фильтрации
const filteredEntities = visibleEntities;
// Обновляем видимые артикулы при изменении поиска или фильтров для PartsAPI
useEffect(() => {
if (isPartsAPIMode) {
@ -458,10 +673,14 @@ export default function Catalog() {
if (searchQuery.trim() || Object.keys(selectedFilters).length > 0) {
console.log('🔍 Поисковый запрос или фильтры изменились, сбрасываем пагинацию');
setPartsIndexPage(1);
setCurrentUserPage(1);
setHasMoreEntities(true);
setAccumulatedEntities([]);
setEntitiesWithOffers([]);
setEntitiesCache(new Map());
// Перезагружаем данные с новыми параметрами фильтрации
const apiParams = convertFiltersToPartsIndexParams();
const apiParams = convertFiltersToPartsIndexParams;
const paramsString = Object.keys(apiParams).length > 0 ? JSON.stringify(apiParams) : undefined;
// Также обновляем параметры фильтрации
@ -477,7 +696,7 @@ export default function Catalog() {
catalogId: catalogId as string,
groupId: groupId as string,
lang: 'ru',
limit: ITEMS_PER_PAGE,
limit: PARTSINDEX_PAGE_SIZE,
page: 1,
q: searchQuery || undefined,
params: paramsString
@ -503,26 +722,55 @@ export default function Catalog() {
return () => clearTimeout(timer);
} else if (isPartsIndexMode && !entitiesLoading && !entitiesError) {
// Для PartsIndex показываем пустое состояние если нет товаров
setShowEmptyState(visibleEntities.length === 0);
// Для PartsIndex показываем пустое состояние если нет товаров И данные уже загружены
const hasLoadedData = accumulatedEntities.length > 0 || Boolean(entitiesData?.partsIndexCatalogEntities?.list);
setShowEmptyState(hasLoadedData && visibleEntities.length === 0);
console.log('📊 Определяем showEmptyState для PartsIndex:', {
hasLoadedData,
visibleEntitiesLength: visibleEntities.length,
accumulatedEntitiesLength: accumulatedEntities.length,
showEmptyState: hasLoadedData && visibleEntities.length === 0
});
} else {
setShowEmptyState(false);
}
}, [isPartsAPIMode, articlesLoading, articlesError, visibleProductsCount, allArticles.length,
isPartsIndexMode, entitiesLoading, entitiesError, visibleEntities.length, filteredEntities.length]);
isPartsIndexMode, entitiesLoading, entitiesError, visibleEntities.length, accumulatedEntities.length, entitiesData]);
// Функции для навигации по страницам PartsIndex
// Функции для навигации по пользовательским страницам
const handleNextPage = useCallback(() => {
if (hasMoreEntities && !entitiesLoading) {
setPartsIndexPage(prev => prev + 1);
const maxUserPage = Math.ceil(accumulatedEntities.length / ITEMS_PER_PAGE);
console.log('🔄 Нажата кнопка "Вперед":', {
currentUserPage,
maxUserPage,
accumulatedEntitiesLength: accumulatedEntities.length,
ITEMS_PER_PAGE
});
if (currentUserPage < maxUserPage) {
setCurrentUserPage(prev => {
console.log('✅ Переходим на страницу:', prev + 1);
return prev + 1;
});
} else {
console.log('⚠️ Нельзя перейти вперед: уже на последней странице');
}
}, [hasMoreEntities, entitiesLoading]);
}, [currentUserPage, accumulatedEntities.length]);
const handlePrevPage = useCallback(() => {
if (partsIndexPage > 1 && !entitiesLoading) {
setPartsIndexPage(prev => prev - 1);
console.log('🔄 Нажата кнопка "Назад":', {
currentUserPage,
accumulatedEntitiesLength: accumulatedEntities.length
});
if (currentUserPage > 1) {
setCurrentUserPage(prev => {
const newPage = prev - 1;
console.log('✅ Переходим на страницу:', newPage);
return newPage;
});
} else {
console.log('⚠️ Нельзя перейти назад: уже на первой странице');
}
}, [partsIndexPage, entitiesLoading]);
}, [currentUserPage, accumulatedEntities.length]);
// Функция для загрузки следующей порции товаров по кнопке (только для PartsAPI)
const handleLoadMorePartsAPI = useCallback(async () => {
@ -592,9 +840,7 @@ export default function Catalog() {
isPartsAPIMode ?
(visibilityMap.size === 0 && allArticles.length > 0 ? undefined : visibleProductsCount) :
isPartsIndexMode ?
(searchQuery.trim() || Object.keys(selectedFilters).length > 0 ?
filteredEntities.length :
entitiesData?.partsIndexCatalogEntities?.pagination?.limit || visibleEntities.length) :
entitiesWithOffers.length :
3587
}
productName={
@ -740,25 +986,21 @@ export default function Catalog() {
)}
{/* Отображение товаров PartsIndex */}
{isPartsIndexMode && filteredEntities.length > 0 && (
{isPartsIndexMode && (() => {
console.log('🎯 Проверяем отображение PartsIndex товаров:', {
isPartsIndexMode,
visibleEntitiesLength: visibleEntities.length,
visibleEntities: visibleEntities.map(e => ({ id: e.id, code: e.code, brand: e.brand.name }))
});
return visibleEntities.length > 0;
})() && (
<>
{filteredEntities
{visibleEntities
.map((entity, idx) => {
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
const priceData = getPrice(productForPrice);
const isLoadingPriceData = isLoadingPrice(productForPrice);
return {
entity,
idx,
productForPrice,
priceData,
isLoadingPriceData,
hasOffer: priceData !== null || isLoadingPriceData
};
})
.filter(item => item.hasOffer) // Показываем только товары с предложениями или загружающиеся
.map(({ entity, idx, productForPrice, priceData, isLoadingPriceData }) => {
// Определяем цену для отображения
let displayPrice = "Цена по запросу";
let displayCurrency = "RUB";
@ -790,7 +1032,7 @@ export default function Catalog() {
onAddToCart={async () => {
// Если цена не загружена, загружаем её и добавляем в корзину
if (!priceData && !isLoadingPriceData) {
loadPriceOnDemand(productForPrice);
ensurePriceLoaded(productForPrice);
console.log('🔄 Загружаем цену для:', entity.code, entity.brand.name);
return;
}
@ -843,40 +1085,61 @@ export default function Catalog() {
{/* Пагинация для PartsIndex */}
<div className="w-layout-hflex pagination">
<button
onClick={handlePrevPage}
disabled={partsIndexPage <= 1 || entitiesLoading}
onClick={() => {
console.log('🖱️ Клик по кнопке "Назад"');
handlePrevPage();
}}
disabled={currentUserPage <= 1}
className="button_strock w-button mr-2"
>
Назад
</button>
<span className="flex items-center px-4 text-gray-600">
Страница {partsIndexPage} {totalPages > partsIndexPage && `из ${totalPages}+`}
Страница {currentUserPage} из {Math.ceil(accumulatedEntities.length / ITEMS_PER_PAGE) || 1}
{isAutoLoading && ' (загружаем...)'}
<span className="ml-2 text-xs text-gray-400">
(товаров: {accumulatedEntities.length})
</span>
</span>
<button
onClick={handleNextPage}
disabled={!hasMoreEntities || entitiesLoading}
onClick={() => {
console.log('🖱️ Клик по кнопке "Вперед"');
handleNextPage();
}}
disabled={currentUserPage >= Math.ceil(accumulatedEntities.length / ITEMS_PER_PAGE)}
className="button_strock w-button ml-2"
>
{entitiesLoading ? 'Загрузка...' : 'Вперед →'}
Вперед
</button>
</div>
{/* Отладочная информация */}
{isPartsIndexMode && (
<div className="text-xs text-gray-500 mt-4 p-2 bg-gray-100 rounded">
<div>🔍 Отладка PartsIndex:</div>
<div> hasMoreItems: {hasMoreItems ? 'да' : 'нет'}</div>
<div> hasMoreEntities: {hasMoreEntities ? 'да' : 'нет'}</div>
<div> entitiesPage: {entitiesPage}</div>
<div>🔍 Отладка PartsIndex (исправленная логика):</div>
<div> accumulatedEntities: {accumulatedEntities.length}</div>
<div> entitiesWithOffers: {entitiesWithOffers.length}</div>
<div> visibleEntities: {visibleEntities.length}</div>
<div> filteredEntities: {filteredEntities.length}</div>
<div> groupId: {groupId || 'отсутствует'}</div>
<div> isLoadingMore: {isLoadingMore ? 'да' : 'нет'}</div>
<div> currentUserPage: {currentUserPage}</div>
<div> partsIndexPage (API): {partsIndexPage}</div>
<div> isAutoLoading: {isAutoLoading ? 'да' : 'нет'}</div>
<div> hasMoreEntities: {hasMoreEntities ? 'да' : 'нет'}</div>
<div> entitiesLoading: {entitiesLoading ? 'да' : 'нет'}</div>
<div> catalogId: {catalogId || 'отсутствует'}</div>
<div> Пагинация: {JSON.stringify(entitiesData?.partsIndexCatalogEntities?.pagination)}</div>
<div> groupId: {groupId || 'отсутствует'}</div>
<div> Target: {ITEMS_PER_PAGE} товаров на страницу</div>
<div> showEmptyState: {showEmptyState ? 'да' : 'нет'}</div>
<button
onClick={() => {
console.log('🔧 Ручной запуск автоподгрузки');
autoLoadMoreEntities();
}}
className="mt-2 px-3 py-1 bg-blue-500 text-white text-xs rounded"
disabled={isAutoLoading}
>
{isAutoLoading ? 'Загружаем...' : 'Загрузить еще'}
</button>
</div>
)}
</>
@ -892,7 +1155,16 @@ export default function Catalog() {
)}
{/* Пустое состояние для PartsIndex */}
{isPartsIndexMode && !entitiesLoading && !entitiesError && showEmptyState && (
{isPartsIndexMode && !entitiesLoading && !entitiesError && (() => {
console.log('🎯 Проверяем пустое состояние PartsIndex:', {
isPartsIndexMode,
entitiesLoading,
entitiesError,
showEmptyState,
visibleEntitiesLength: visibleEntities.length
});
return showEmptyState;
})() && (
<CatalogEmptyState
categoryName={decodeURIComponent(categoryName as string || 'товаров')}
hasFilters={searchQuery.trim() !== '' || Object.keys(selectedFilters).some(key => selectedFilters[key].length > 0)}

View File

@ -17,6 +17,7 @@ import MetaTags from "@/components/MetaTags";
import { getMetaByPath } from "@/lib/meta-config";
import JsonLdScript from "@/components/JsonLdScript";
import { generateOrganizationSchema, generateWebSiteSchema, PROTEK_ORGANIZATION } from "@/lib/schema";
import HeroSlider from "@/components/index/HeroSlider";
export default function Home() {
const metaData = getMetaByPath('/');

View File

@ -21,12 +21,24 @@ import { createProductMeta } from "@/lib/meta-config";
const ANALOGS_CHUNK_SIZE = 5;
const sortOptions = [
"По цене",
"По рейтингу",
"По количеству"
// Функция для расчета даты доставки
const calculateDeliveryDate = (deliveryDays: number): string => {
const today = new Date();
const deliveryDate = new Date(today);
deliveryDate.setDate(today.getDate() + deliveryDays);
const months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
];
const day = deliveryDate.getDate();
const month = months[deliveryDate.getMonth()];
const year = deliveryDate.getFullYear();
return `${day} ${month} ${year}`;
};
// Функция для создания динамических фильтров
const createFilters = (result: any, loadedAnalogs: any): FilterConfig[] => {
const filters: FilterConfig[] = [];
@ -175,15 +187,18 @@ const getBestOffers = (offers: any[]) => {
return result;
};
// Убрано: функция сортировки теперь в CoreProductCard
const transformOffersForCard = (offers: any[]) => {
return offers.map(offer => {
const isExternal = offer.type === 'external';
const deliveryDays = isExternal ? offer.deliveryTime : offer.deliveryDays;
return {
id: offer.id,
productId: offer.productId,
offerKey: offer.offerKey,
pcs: `${offer.quantity} шт.`,
days: `${isExternal ? offer.deliveryTime : offer.deliveryDays} дн.`,
days: deliveryDays ? calculateDeliveryDate(deliveryDays) : 'Уточняйте',
recommended: !isExternal && offer.available,
price: `${offer.price.toLocaleString('ru-RU')}`,
count: "1",
@ -191,7 +206,7 @@ const transformOffersForCard = (offers: any[]) => {
currency: offer.currency || "RUB",
warehouse: offer.warehouse,
supplier: offer.supplier,
deliveryTime: isExternal ? offer.deliveryTime : offer.deliveryDays,
deliveryTime: deliveryDays,
};
});
};
@ -200,7 +215,7 @@ export default function SearchResult() {
const router = useRouter();
const { article, brand, q, artId } = router.query;
const [sortActive, setSortActive] = useState(0);
// Убрано: глобальная сортировка теперь не используется
const [showFiltersMobile, setShowFiltersMobile] = useState(false);
const [showSortMobile, setShowSortMobile] = useState(false);
const [searchQuery, setSearchQuery] = useState<string>("");
@ -542,7 +557,7 @@ export default function SearchResult() {
<section className="main mobile-only">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-hflex flex-block-84">
{/* <CatalogSortDropdown active={sortActive} onChange={setSortActive} /> */}
{/* Глобальная сортировка убрана - теперь каждый товар сортируется индивидуально */}
<div className="w-layout-hflex flex-block-85" onClick={() => setShowFiltersMobile((v) => !v)}>
<span className="code-embed-9 w-embed">
<svg width="currentwidth" height="currentheight" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@ -584,7 +599,7 @@ export default function SearchResult() {
title={`${offer.brand} ${offer.articleNumber}${offer.isAnalog ? ' (аналог)' : ''}`}
description={offer.name}
price={`${offer.price.toLocaleString()}`}
delivery={`${offer.deliveryDuration} ${offer.deliveryDuration === 1 ? 'день' : 'дней'}`}
delivery={offer.deliveryDuration ? calculateDeliveryDate(offer.deliveryDuration) : 'Уточняйте'}
stock={`${offer.quantity} шт.`}
offer={offer}
/>