Files
protekauto-frontend/src/pages/catalog.tsx

1203 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Head from "next/head";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import ProductListCard from "@/components/ProductListCard";
import Filters, { FilterConfig } from "@/components/Filters";
import FiltersWithSearch from "@/components/FiltersWithSearch";
import CatalogProductCard from "@/components/CatalogProductCard";
import CatalogPagination from "@/components/CatalogPagination";
import CatalogSubscribe from "@/components/CatalogSubscribe";
import CatalogInfoHeader from "@/components/CatalogInfoHeader";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useRouter } from "next/router";
import { useQuery } from '@apollo/client';
import FiltersPanelMobile from '@/components/FiltersPanelMobile';
import MobileMenuBottomSection from '../components/MobileMenuBottomSection';
import { GET_PARTSAPI_ARTICLES, GET_PARTSAPI_MAIN_IMAGE, SEARCH_PRODUCT_OFFERS, GET_PARTSINDEX_CATALOG_ENTITIES, GET_PARTSINDEX_CATALOG_PARAMS } from '@/lib/graphql';
import { PartsAPIArticlesData, PartsAPIArticlesVariables, PartsAPIArticle, PartsAPIMainImageData, PartsAPIMainImageVariables } from '@/types/partsapi';
import { PartsIndexEntitiesData, PartsIndexEntitiesVariables, PartsIndexEntity, PartsIndexParamsData, PartsIndexParamsVariables } from '@/types/partsindex';
import LoadingSpinner from '@/components/LoadingSpinner';
import ArticleCard from '@/components/ArticleCard';
import CatalogEmptyState from '@/components/CatalogEmptyState';
import { useProductPrices } from '@/hooks/useProductPrices';
import { PriceSkeleton } from '@/components/skeletons/ProductListSkeleton';
import { useCart } from '@/contexts/CartContext';
import toast from 'react-hot-toast';
import CartIcon from '@/components/CartIcon';
import MetaTags from "@/components/MetaTags";
import { getMetaByPath, createCategoryMeta } from "@/lib/meta-config";
import JsonLdScript from "@/components/JsonLdScript";
import { generateBreadcrumbSchema, generateWebSiteSchema } from "@/lib/schema";
const mockData = Array(12).fill({
image: "",
discount: "-35%",
price: "от 17 087 ₽",
oldPrice: "22 347 ₽",
title: 'Аккумуляторная батарея TYUMEN BATTERY "STANDARD", 6CT-60L, 60',
brand: "Borsehung",
});
const ITEMS_PER_PAGE = 50; // Целевое количество товаров на странице
const PARTSINDEX_PAGE_SIZE = 25; // Размер страницы PartsIndex API (фиксированный)
const MAX_BRANDS_DISPLAY = 10; // Сколько брендов показывать изначально
export default function Catalog() {
const router = useRouter();
const { addItem } = useCart();
const {
partsApiCategory: strId,
categoryName,
partsIndexCatalog: catalogId,
partsIndexCategory: groupId
} = router.query;
const [showFiltersMobile, setShowFiltersMobile] = useState(false);
const [showSortMobile, setShowSortMobile] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedFilters, setSelectedFilters] = useState<{[key: string]: string[]}>({});
const [visibleArticles, setVisibleArticles] = useState<PartsAPIArticle[]>([]);
const [visibleEntities, setVisibleEntities] = useState<PartsIndexEntity[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [entitiesPage, setEntitiesPage] = useState(1); // Страница для PartsIndex
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMoreEntities, setHasMoreEntities] = useState(true); // Есть ли еще товары на сервере
const [showAllBrands, setShowAllBrands] = useState(false);
const [catalogFilters, setCatalogFilters] = useState<FilterConfig[]>([]);
const [filtersLoading, setFiltersLoading] = useState(true);
const [sortActive, setSortActive] = useState(0);
const [visibleProductsCount, setVisibleProductsCount] = useState(0); // Счетчик товаров с предложениями
const [filtersGenerating, setFiltersGenerating] = useState(false); // Состояние генерации фильтров
const [targetVisibleCount, setTargetVisibleCount] = useState(ITEMS_PER_PAGE); // Целевое количество видимых товаров
const [loadedArticlesCount, setLoadedArticlesCount] = useState(ITEMS_PER_PAGE); // Количество загруженных артикулов
const [showEmptyState, setShowEmptyState] = useState(false);
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());
// Обработчик изменения видимости товара
const handleVisibilityChange = useCallback((index: number, isVisible: boolean) => {
setVisibilityMap(prev => {
const currentVisibility = prev.get(index);
// Обновляем только если значение действительно изменилось
if (currentVisibility === isVisible) {
return prev;
}
const newMap = new Map(prev);
newMap.set(index, isVisible);
return newMap;
});
}, []);
// Пересчитываем количество видимых товаров при изменении карты видимости
useEffect(() => {
const visibleCount = Array.from(visibilityMap.values()).filter(Boolean).length;
setVisibleProductsCount(visibleCount);
}, [visibilityMap]);
// Определяем режим работы
const isPartsAPIMode = Boolean(strId && categoryName);
const isPartsIndexMode = Boolean(catalogId && categoryName && groupId); // Требуем groupId для PartsIndex
const isPartsIndexCatalogOnly = Boolean(catalogId && categoryName && !groupId); // Каталог без группы
// Отладочная информация
console.log('🔍 Режимы работы каталога:', {
catalogId,
groupId,
categoryName,
isPartsAPIMode,
isPartsIndexMode,
isPartsIndexCatalogOnly,
'router.query': router.query
});
// Загружаем артикулы PartsAPI
const { data: articlesData, loading: articlesLoading, error: articlesError } = useQuery<PartsAPIArticlesData, PartsAPIArticlesVariables>(
GET_PARTSAPI_ARTICLES,
{
variables: {
strId: parseInt(strId as string),
carId: 9877,
carType: 'PC'
},
skip: !isPartsAPIMode,
fetchPolicy: 'cache-first'
}
);
const allArticles = articlesData?.partsAPIArticles || [];
// Загружаем товары PartsIndex
const { data: entitiesData, loading: entitiesLoading, error: entitiesError, refetch: refetchEntities } = useQuery<PartsIndexEntitiesData, PartsIndexEntitiesVariables>(
GET_PARTSINDEX_CATALOG_ENTITIES,
{
variables: {
catalogId: catalogId as string,
groupId: groupId as string,
lang: 'ru',
limit: PARTSINDEX_PAGE_SIZE,
page: partsIndexPage,
q: searchQuery || undefined,
params: undefined // Будем обновлять через refetch
},
skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId
fetchPolicy: 'cache-and-network'
}
);
// Загружаем параметры фильтрации для PartsIndex
const { data: paramsData, loading: paramsLoading, error: paramsError, refetch: refetchParams } = useQuery<PartsIndexParamsData, PartsIndexParamsVariables>(
GET_PARTSINDEX_CATALOG_PARAMS,
{
variables: {
catalogId: catalogId as string,
groupId: groupId as string,
lang: 'ru',
q: searchQuery || undefined,
params: undefined // Будем обновлять через refetch
},
skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId
fetchPolicy: 'cache-first'
}
);
// allEntities больше не используется - используем allLoadedEntities
// Хук для загрузки цен товаров PartsIndex
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
};
// Загружаем с небольшой задержкой чтобы не перегружать сервер
setTimeout(() => {
ensurePriceLoaded(productForPrice);
}, index * 50);
});
}
}, [isPartsIndexMode, visibleEntities, ensurePriceLoaded]);
useEffect(() => {
if (articlesData?.partsAPIArticles) {
// Загружаем изначально только ITEMS_PER_PAGE товаров
const initialLoadCount = Math.min(ITEMS_PER_PAGE, articlesData.partsAPIArticles.length);
setVisibleArticles(articlesData.partsAPIArticles.slice(0, initialLoadCount));
setLoadedArticlesCount(initialLoadCount);
setTargetVisibleCount(ITEMS_PER_PAGE);
setCurrentPage(1);
}
}, [articlesData]);
useEffect(() => {
if (entitiesData?.partsIndexCatalogEntities?.list) {
console.log('📊 Обновляем entitiesData:', {
listLength: entitiesData.partsIndexCatalogEntities.list.length,
pagination: entitiesData.partsIndexCatalogEntities.pagination,
currentPage: entitiesData.partsIndexCatalogEntities.pagination?.page?.current || 1
});
const newEntities = entitiesData.partsIndexCatalogEntities.list;
const pagination = entitiesData.partsIndexCatalogEntities.pagination;
// Обновляем информацию о пагинации
const currentPage = pagination?.page?.current || 1;
const hasNext = pagination?.page?.next !== null;
const hasPrev = pagination?.page?.prev !== null;
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); // Минимум еще одна страница
} else {
setTotalPages(currentPage); // Это последняя страница
}
console.log('✅ Пагинация обновлена:', { currentPage, hasNext, hasPrev });
}
}, [entitiesData]);
// Преобразование выбранных фильтров в формат PartsIndex API
const convertFiltersToPartsIndexParams = useMemo((): Record<string, any> => {
if (!paramsData?.partsIndexCatalogParams?.list || Object.keys(selectedFilters).length === 0) {
return {};
}
const apiParams: Record<string, any> = {};
paramsData.partsIndexCatalogParams.list.forEach((param: any) => {
const selectedValues = selectedFilters[param.name];
if (selectedValues && selectedValues.length > 0) {
// Находим соответствующие значения из API данных
const matchingValues = param.values.filter((value: any) =>
selectedValues.includes(value.title || value.value)
);
if (matchingValues.length > 0) {
// Используем ID параметра из API и значения
apiParams[param.id] = matchingValues.map((v: any) => v.value);
}
}
});
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) {
return [];
}
return paramsData.partsIndexCatalogParams.list.map((param: any) => {
if (param.type === 'range') {
// Для range фильтров ищем min и max значения
const numericValues = param.values
.map((v: any) => parseFloat(v.value))
.filter((v: number) => !isNaN(v));
const min = numericValues.length > 0 ? Math.min(...numericValues) : 0;
const max = numericValues.length > 0 ? Math.max(...numericValues) : 100;
return {
type: 'range' as const,
title: param.name,
min,
max
};
} else {
// Для dropdown фильтров
return {
type: 'dropdown' as const,
title: param.name,
options: param.values
.filter((value: any) => value.available) // Показываем только доступные
.map((value: any) => value.title || value.value),
multi: true,
showAll: true
};
}
});
}, [paramsData]);
useEffect(() => {
if (isPartsIndexMode) {
// Для PartsIndex генерируем фильтры на основе параметров API
const filters = generatePartsIndexFilters();
setCatalogFilters(filters);
setFiltersLoading(paramsLoading);
} else {
// Для других режимов убираем запрос на catalog-filters
setFiltersLoading(false);
}
}, [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 [];
// Получаем список видимых товаров из карты видимости
const visibleIndices = Array.from(visibilityMap.entries())
.filter(([_, isVisible]) => isVisible)
.map(([index]) => index);
// Если еще нет данных о видимости, используем все товары (для начальной загрузки)
const articlesToProcess = visibilityMap.size === 0 ? allArticles :
visibleIndices.map(index => allArticles[index]).filter(Boolean);
const brandCounts = new Map<string, number>();
const productGroups = new Set<string>();
// Подсчитываем количество товаров для каждого бренда (только видимые)
articlesToProcess.forEach(article => {
if (article?.artSupBrand) {
brandCounts.set(article.artSupBrand, (brandCounts.get(article.artSupBrand) || 0) + 1);
}
if (article?.productGroup) productGroups.add(article.productGroup);
});
const filters: FilterConfig[] = [];
if (brandCounts.size > 1) {
// Сортируем бренды по количеству товаров (по убыванию)
const sortedBrands = Array.from(brandCounts.entries())
.sort((a, b) => b[1] - a[1]) // Сортируем по количеству товаров
.map(([brand]) => brand);
// Показываем либо первые N брендов, либо все (если нажата кнопка "Показать еще")
const brandsToShow = showAllBrands ? sortedBrands : sortedBrands.slice(0, MAX_BRANDS_DISPLAY);
filters.push({
type: "dropdown",
title: "Бренд",
options: brandsToShow.sort(), // Сортируем по алфавиту для удобства
multi: true,
showAll: true,
defaultOpen: true,
hasMore: !showAllBrands && sortedBrands.length > MAX_BRANDS_DISPLAY,
onShowMore: () => setShowAllBrands(true)
});
}
if (productGroups.size > 1) {
filters.push({
type: "dropdown",
title: "Группа товаров",
options: Array.from(productGroups).sort(),
multi: true,
showAll: true,
defaultOpen: true,
});
}
return filters;
}, [allArticles, showAllBrands, visibilityMap]);
const dynamicFilters = useMemo(() => {
if (isPartsIndexMode) {
return generatePartsIndexFilters();
} else if (isPartsAPIMode) {
return generatePartsAPIFilters();
}
return [];
}, [isPartsIndexMode, isPartsAPIMode, generatePartsIndexFilters, generatePartsAPIFilters]);
// Отдельный useEffect для управления состоянием загрузки фильтров
useEffect(() => {
if ((isPartsAPIMode && allArticles.length > 0) || (isPartsIndexMode && visibleEntities.length > 0)) {
setFiltersGenerating(true);
const timer = setTimeout(() => {
setFiltersGenerating(false);
}, 300);
return () => clearTimeout(timer);
} else {
setFiltersGenerating(false);
}
}, [isPartsAPIMode, allArticles.length, isPartsIndexMode, visibleEntities.length]);
const handleDesktopFilterChange = (filterTitle: string, value: string | string[]) => {
setSelectedFilters(prev => ({
...prev,
[filterTitle]: Array.isArray(value) ? value : [value]
}));
};
const handleMobileFilterChange = (type: string, value: any) => {
setSelectedFilters(prev => ({
...prev,
[type]: Array.isArray(value) ? value : [value]
}));
};
// Функция для сброса всех фильтров
const handleResetFilters = useCallback(() => {
setSearchQuery('');
setSelectedFilters({});
setShowAllBrands(false);
setPartsIndexPage(1); // Сбрасываем страницу PartsIndex на первую
}, []);
// Фильтрация по поиску и фильтрам для PartsAPI
const filteredArticles = useMemo(() => {
return allArticles.filter(article => {
// Фильтрация по поиску
if (searchQuery.trim()) {
const searchLower = searchQuery.toLowerCase();
const articleTitle = [
article.artSupBrand || '',
article.artArticleNr || '',
article.productGroup || ''
].join(' ').toLowerCase();
if (!articleTitle.includes(searchLower)) {
return false;
}
}
// Фильтрация по выбранным фильтрам
const brandFilter = selectedFilters['Бренд'] || [];
if (brandFilter.length > 0 && !brandFilter.includes(article.artSupBrand || '')) {
return false;
}
const groupFilter = selectedFilters['Группа товаров'] || [];
if (groupFilter.length > 0 && !groupFilter.includes(article.productGroup || '')) {
return false;
}
return true;
});
}, [allArticles, searchQuery, selectedFilters]);
// Обновляем видимые артикулы при изменении поиска или фильтров для PartsAPI
useEffect(() => {
if (isPartsAPIMode) {
setVisibleArticles(filteredArticles.slice(0, ITEMS_PER_PAGE));
setCurrentPage(1);
setIsLoadingMore(false);
setVisibilityMap(new Map()); // Сбрасываем карту видимости при изменении фильтров
setVisibleProductsCount(0); // Сбрасываем счетчик
setLoadedArticlesCount(ITEMS_PER_PAGE); // Сбрасываем счетчик загруженных
setShowEmptyState(false); // Сбрасываем пустое состояние
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPartsAPIMode, searchQuery, JSON.stringify(selectedFilters), filteredArticles.length]);
// Обновляем видимые товары при изменении поиска или фильтров для PartsIndex
useEffect(() => {
if (isPartsIndexMode) {
// При изменении поиска или фильтров сбрасываем пагинацию
setShowEmptyState(false);
// Если изменился поисковый запрос или фильтры, нужно перезагрузить данные с сервера
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 paramsString = Object.keys(apiParams).length > 0 ? JSON.stringify(apiParams) : undefined;
// Также обновляем параметры фильтрации
refetchParams({
catalogId: catalogId as string,
groupId: groupId as string,
lang: 'ru',
q: searchQuery || undefined,
params: paramsString
});
refetchEntities({
catalogId: catalogId as string,
groupId: groupId as string,
lang: 'ru',
limit: PARTSINDEX_PAGE_SIZE,
page: 1,
q: searchQuery || undefined,
params: paramsString
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPartsIndexMode, searchQuery, JSON.stringify(selectedFilters), refetchEntities, refetchParams, convertFiltersToPartsIndexParams]);
// Управляем показом пустого состояния с задержкой
useEffect(() => {
if (isPartsAPIMode && !articlesLoading && !articlesError) {
// Если товаров вообще нет - показываем сразу
if (allArticles.length === 0) {
setShowEmptyState(true);
return;
}
// Если товары есть, но нет видимых - ждем 2 секунды
const timer = setTimeout(() => {
setShowEmptyState(visibleProductsCount === 0 && allArticles.length > 0);
}, 2000); // Даем 2 секунды на загрузку данных о предложениях
return () => clearTimeout(timer);
} else if (isPartsIndexMode && !entitiesLoading && !entitiesError) {
// Для 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, accumulatedEntities.length, entitiesData]);
// Функции для навигации по пользовательским страницам
const handleNextPage = useCallback(() => {
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('⚠️ Нельзя перейти вперед: уже на последней странице');
}
}, [currentUserPage, accumulatedEntities.length]);
const handlePrevPage = useCallback(() => {
console.log('🔄 Нажата кнопка "Назад":', {
currentUserPage,
accumulatedEntitiesLength: accumulatedEntities.length
});
if (currentUserPage > 1) {
setCurrentUserPage(prev => {
const newPage = prev - 1;
console.log('✅ Переходим на страницу:', newPage);
return newPage;
});
} else {
console.log('⚠️ Нельзя перейти назад: уже на первой странице');
}
}, [currentUserPage, accumulatedEntities.length]);
// Функция для загрузки следующей порции товаров по кнопке (только для PartsAPI)
const handleLoadMorePartsAPI = useCallback(async () => {
if (isLoadingMore || !isPartsAPIMode) {
return;
}
setIsLoadingMore(true);
try {
const additionalCount = Math.min(ITEMS_PER_PAGE, filteredArticles.length - loadedArticlesCount);
if (additionalCount > 0) {
const newArticles = filteredArticles.slice(loadedArticlesCount, loadedArticlesCount + additionalCount);
setVisibleArticles(prev => [...prev, ...newArticles]);
setLoadedArticlesCount(prev => prev + additionalCount);
setTargetVisibleCount(prev => prev + ITEMS_PER_PAGE);
}
} catch (error) {
console.error('❌ Ошибка загрузки дополнительных товаров:', error);
} finally {
setIsLoadingMore(false);
}
}, [isPartsAPIMode, loadedArticlesCount, filteredArticles, isLoadingMore]);
// Определяем есть ли еще товары для загрузки (только для PartsAPI)
const hasMoreItems = useMemo(() => {
if (isPartsAPIMode) {
return loadedArticlesCount < filteredArticles.length;
}
return false;
}, [isPartsAPIMode, loadedArticlesCount, filteredArticles.length]);
if (filtersLoading) {
return <div className="py-8 text-center">Загрузка фильтров...</div>;
}
// Определяем meta-теги для каталога
const categoryNameDecoded = decodeURIComponent(categoryName as string || 'Каталог');
const metaData = createCategoryMeta(categoryNameDecoded, visibleProductsCount || undefined);
// Генерируем микроразметку для каталога
const breadcrumbSchema = generateBreadcrumbSchema([
{ name: "Главная", url: "https://protek.ru/" },
{ name: "Каталог", url: "https://protek.ru/catalog" },
...(categoryName ? [{ name: categoryNameDecoded, url: `https://protek.ru/catalog?categoryName=${categoryName}` }] : [])
]);
const websiteSchema = generateWebSiteSchema(
"Protek - Каталог автозапчастей",
"https://protek.ru",
"https://protek.ru/search"
);
return (
<>
<MetaTags {...metaData} />
<JsonLdScript schema={breadcrumbSchema} />
<JsonLdScript schema={websiteSchema} />
<CatalogInfoHeader
title={
isPartsAPIMode ? decodeURIComponent(categoryName as string || 'Запчасти') :
isPartsIndexMode ? decodeURIComponent(categoryName as string || 'Товары') :
"Аккумуляторы"
}
count={
isPartsAPIMode ?
(visibilityMap.size === 0 && allArticles.length > 0 ? undefined : visibleProductsCount) :
isPartsIndexMode ?
entitiesWithOffers.length :
3587
}
productName={
isPartsAPIMode ? "запчасть" :
isPartsIndexMode ? "товар" :
"аккумулятор"
}
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Каталог" },
...((isPartsAPIMode || isPartsIndexMode) ? [{ label: decodeURIComponent(categoryName as string || 'Товары') }] : [])
]}
showCount={true}
showProductHelp={true}
/>
<section className="main">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-hflex flex-block-13">
<div className="w-layout-hflex flex-block-84">
<div className="w-layout-hflex flex-block-85" onClick={() => setShowFiltersMobile((v) => !v)}>
<div 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">
<path d="M21 4H14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M10 4H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M21 12H12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M8 12H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M21 20H16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 20H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 2V6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M8 10V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16 18V22" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div>Фильтры</div>
</div>
</div>
{isPartsAPIMode ? (
<div className="filters-desktop">
<Filters
filters={dynamicFilters}
onFilterChange={handleDesktopFilterChange}
filterValues={selectedFilters}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isLoading={filtersGenerating}
/>
</div>
) : isPartsIndexMode ? (
<div className="filters-desktop">
<Filters
filters={catalogFilters}
onFilterChange={handleDesktopFilterChange}
filterValues={selectedFilters}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isLoading={filtersLoading}
/>
</div>
) : (
<div className="filters-desktop">
<Filters
filters={catalogFilters}
onFilterChange={handleDesktopFilterChange}
filterValues={selectedFilters}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isLoading={filtersLoading}
/>
</div>
)}
<FiltersPanelMobile
open={showFiltersMobile}
onClose={() => setShowFiltersMobile(false)}
filters={isPartsAPIMode ? dynamicFilters : catalogFilters}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filterValues={selectedFilters}
onFilterChange={handleMobileFilterChange}
/>
<div className="w-layout-vflex flex-block-14-copy-copy">
{/* Индикатор загрузки для PartsAPI */}
{isPartsAPIMode && articlesLoading && (
<div className="flex justify-center items-center py-8">
<LoadingSpinner size="lg" text="Загружаем артикулы..." />
</div>
)}
{/* Индикатор загрузки для PartsIndex */}
{isPartsIndexMode && entitiesLoading && (
<div className="flex justify-center items-center py-8">
<LoadingSpinner size="lg" text="Загружаем товары..." />
</div>
)}
{/* Сообщение об ошибке */}
{isPartsAPIMode && articlesError && (
<div className="flex justify-center items-center py-8">
<div className="text-lg text-red-600">Ошибка загрузки артикулов: {articlesError.message}</div>
</div>
)}
{/* Сообщение об ошибке для PartsIndex */}
{isPartsIndexMode && entitiesError && (
<div className="flex justify-center items-center py-8">
<div className="text-lg text-red-600">Ошибка загрузки товаров: {entitiesError.message}</div>
</div>
)}
{/* Отображение артикулов PartsAPI */}
{isPartsAPIMode && visibleArticles.length > 0 && (
<>
{visibleArticles.map((article, idx) => (
<ArticleCard
key={`${article.artId}_${idx}`}
article={article}
index={idx}
onVisibilityChange={handleVisibilityChange}
/>
))}
{/* Кнопка "Показать еще" */}
{hasMoreItems && (
<div className="w-layout-hflex pagination">
<button
onClick={handleLoadMorePartsAPI}
disabled={isLoadingMore}
className="button_strock w-button"
>
{isLoadingMore ? (
<>
Загружаем...
</>
) : (
<>
Показать еще
</>
)}
</button>
</div>
)}
</>
)}
{/* Отображение товаров PartsIndex */}
{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;
})() && (
<>
{visibleEntities
.map((entity, idx) => {
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
const priceData = getPrice(productForPrice);
const isLoadingPriceData = isLoadingPrice(productForPrice);
// Определяем цену для отображения
let displayPrice = "Цена по запросу";
let displayCurrency = "RUB";
let priceElement;
if (isLoadingPriceData) {
priceElement = <PriceSkeleton />;
} else if (priceData && priceData.price) {
displayPrice = `${priceData.price.toLocaleString('ru-RU')}`;
displayCurrency = priceData.currency || "RUB";
}
return (
<CatalogProductCard
key={`${entity.id}_${idx}`}
title={entity.originalName || entity.name?.name || 'Товар без названия'}
brand={entity.brand.name}
articleNumber={entity.code}
brandName={entity.brand.name}
image={entity.images?.[0] || ''}
price={isLoadingPriceData ? "" : displayPrice}
priceElement={priceElement}
oldPrice=""
discount=""
currency={displayCurrency}
productId={entity.id}
artId={entity.id}
offerKey={priceData?.offerKey}
onAddToCart={async () => {
// Если цена не загружена, загружаем её и добавляем в корзину
if (!priceData && !isLoadingPriceData) {
ensurePriceLoaded(productForPrice);
console.log('🔄 Загружаем цену для:', entity.code, entity.brand.name);
return;
}
// Если цена есть, добавляем в корзину
if (priceData && priceData.price) {
const itemToAdd = {
productId: entity.id,
offerKey: priceData.offerKey,
name: entity.originalName || entity.name?.name || 'Товар без названия',
description: `${entity.brand.name} ${entity.code}`,
brand: entity.brand.name,
article: entity.code,
price: priceData.price,
currency: priceData.currency || 'RUB',
quantity: 1,
stock: undefined, // информация о наличии не доступна для PartsIndex
deliveryTime: '1-3 дня',
warehouse: 'Parts Index',
supplier: 'Parts Index',
isExternal: true,
image: entity.images?.[0] || '',
};
const result = await addItem(itemToAdd);
if (result.success) {
// Показываем уведомление
toast.success(
<div>
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{`${entity.brand.name} ${entity.code} за ${priceData.price.toLocaleString('ru-RU')}`}</div>
</div>,
{
duration: 3000,
icon: <CartIcon size={20} color="#fff" />,
}
);
} else {
toast.error(result.error || 'Ошибка при добавлении товара в корзину');
}
} else {
toast.error('Цена товара еще загружается. Попробуйте снова через несколько секунд.');
}
}}
/>
);
})}
{/* Пагинация для PartsIndex */}
<div className="w-layout-hflex pagination">
<button
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">
Страница {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={() => {
console.log('🖱️ Клик по кнопке "Вперед"');
handleNextPage();
}}
disabled={currentUserPage >= Math.ceil(accumulatedEntities.length / ITEMS_PER_PAGE)}
className="button_strock w-button ml-2"
>
Вперед
</button>
</div>
{/* Отладочная информация */}
{isPartsIndexMode && (
<div className="text-xs text-gray-500 mt-4 p-2 bg-gray-100 rounded">
<div>🔍 Отладка PartsIndex (исправленная логика):</div>
<div> accumulatedEntities: {accumulatedEntities.length}</div>
<div> entitiesWithOffers: {entitiesWithOffers.length}</div>
<div> visibleEntities: {visibleEntities.length}</div>
<div> currentUserPage: {currentUserPage}</div>
<div> partsIndexPage (API): {partsIndexPage}</div>
<div> isAutoLoading: {isAutoLoading ? 'да' : 'нет'}</div>
<div> hasMoreEntities: {hasMoreEntities ? 'да' : 'нет'}</div>
<div> entitiesLoading: {entitiesLoading ? 'да' : 'нет'}</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>
)}
</>
)}
{/* Пустое состояние для PartsAPI */}
{isPartsAPIMode && !articlesLoading && !articlesError && showEmptyState && (
<CatalogEmptyState
categoryName={decodeURIComponent(categoryName as string || 'товаров')}
hasFilters={searchQuery.trim() !== '' || Object.keys(selectedFilters).some(key => selectedFilters[key].length > 0)}
onResetFilters={handleResetFilters}
/>
)}
{/* Пустое состояние для PartsIndex */}
{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)}
onResetFilters={handleResetFilters}
/>
)}
{/* Каталог PartsIndex без группы */}
{isPartsIndexCatalogOnly && (
<div className="flex flex-col items-center justify-center py-12">
<div className="text-gray-500 text-lg mb-4">Выберите подкатегорию</div>
<div className="text-gray-400 text-sm">Для просмотра товаров необходимо выбрать конкретную подкатегорию из меню.</div>
</div>
)}
{/* Обычные товары (не PartsAPI/PartsIndex) */}
{!isPartsAPIMode && !isPartsIndexMode && !isPartsIndexCatalogOnly && (
<div className="flex flex-col items-center justify-center py-12">
<div className="text-gray-500 text-lg mb-4">Раздел в разработке</div>
<div className="text-gray-400 text-sm">Данные для этой категории скоро появятся.</div>
</div>
)}
</div>
</div>
</div>
</section>
{!isPartsAPIMode && !isPartsIndexMode && <CatalogPagination />}
<section className="section-3">
<CatalogSubscribe />
</section>
<Footer />
<MobileMenuBottomSection />
</>
);
}