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'; 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 = 20; 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([]); const [visibleEntities, setVisibleEntities] = useState([]); 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([]); 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); // Общее количество страниц // Карта видимости товаров по индексу const [visibilityMap, setVisibilityMap] = useState>(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 }); // Загружаем артикулы PartsAPI const { data: articlesData, loading: articlesLoading, error: articlesError } = useQuery( 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( GET_PARTSINDEX_CATALOG_ENTITIES, { variables: { catalogId: catalogId as string, groupId: groupId as string, lang: 'ru', limit: ITEMS_PER_PAGE, page: partsIndexPage, q: searchQuery || undefined, params: Object.keys(selectedFilters).length > 0 ? JSON.stringify(selectedFilters) : undefined }, skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId fetchPolicy: 'cache-and-network' } ); // Загружаем параметры фильтрации для PartsIndex const { data: paramsData, loading: paramsLoading, error: paramsError } = useQuery( GET_PARTSINDEX_CATALOG_PARAMS, { variables: { catalogId: catalogId as string, groupId: groupId as string, lang: 'ru', q: searchQuery || undefined, params: Object.keys(selectedFilters).length > 0 ? JSON.stringify(selectedFilters) : undefined }, skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId fetchPolicy: 'cache-first' } ); // allEntities больше не используется - используем allLoadedEntities // Хук для загрузки цен товаров PartsIndex const productsForPrices = visibleEntities.map(entity => ({ id: entity.id, code: entity.code, brand: entity.brand.name })); const { getPrice, isLoadingPrice, loadPriceOnDemand } = useProductPrices(productsForPrices); 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; // Обновляем список товаров setVisibleEntities(newEntities); // Обновляем информацию о пагинации const currentPage = pagination?.page?.current || 1; const hasNext = pagination?.page?.next !== null; const hasPrev = pagination?.page?.prev !== null; setPartsIndexPage(currentPage); setHasMoreEntities(hasNext); // Вычисляем общее количество страниц (приблизительно) if (hasNext) { setTotalPages(currentPage + 1); // Минимум еще одна страница } else { setTotalPages(currentPage); // Это последняя страница } console.log('✅ Пагинация обновлена:', { currentPage, hasNext, hasPrev }); } }, [entitiesData]); // Генерация фильтров для PartsIndex на основе параметров API const generatePartsIndexFilters = useCallback((): FilterConfig[] => { if (!paramsData?.partsIndexCatalogParams?.list) { return []; } return paramsData.partsIndexCatalogParams.list.map(param => { if (param.type === 'range') { // Для range фильтров ищем min и max значения const numericValues = param.values .map(v => parseFloat(v.value)) .filter(v => !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 => value.available) // Показываем только доступные .map(value => 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]); // Генерируем динамические фильтры для 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(); const productGroups = new Set(); // Подсчитываем количество товаров для каждого бренда (только видимые) 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]); // Упрощенная логика - показываем все загруженные товары без клиентской фильтрации const filteredEntities = visibleEntities; // Обновляем видимые артикулы при изменении поиска или фильтров для 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); setHasMoreEntities(true); // refetch будет автоматически вызван при изменении partsIndexPage } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isPartsIndexMode, searchQuery, JSON.stringify(selectedFilters)]); // Управляем показом пустого состояния с задержкой 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 показываем пустое состояние если нет товаров setShowEmptyState(visibleEntities.length === 0); } else { setShowEmptyState(false); } }, [isPartsAPIMode, articlesLoading, articlesError, visibleProductsCount, allArticles.length, isPartsIndexMode, entitiesLoading, entitiesError, visibleEntities.length, filteredEntities.length]); // Функции для навигации по страницам PartsIndex const handleNextPage = useCallback(() => { if (hasMoreEntities && !entitiesLoading) { setPartsIndexPage(prev => prev + 1); } }, [hasMoreEntities, entitiesLoading]); const handlePrevPage = useCallback(() => { if (partsIndexPage > 1 && !entitiesLoading) { setPartsIndexPage(prev => prev - 1); } }, [partsIndexPage, entitiesLoading]); // Функция для загрузки следующей порции товаров по кнопке (только для 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
Загрузка фильтров...
; } return ( <> Catalog 0 ? undefined : visibleProductsCount) : isPartsIndexMode ? (searchQuery.trim() || Object.keys(selectedFilters).length > 0 ? filteredEntities.length : entitiesData?.partsIndexCatalogEntities?.pagination?.limit || visibleEntities.length) : 3587 } productName={ isPartsAPIMode ? "запчасть" : isPartsIndexMode ? "товар" : "аккумулятор" } breadcrumbs={[ { label: "Главная", href: "/" }, { label: "Каталог" }, ...((isPartsAPIMode || isPartsIndexMode) ? [{ label: decodeURIComponent(categoryName as string || 'Товары') }] : []) ]} showCount={true} showProductHelp={true} />
setShowFiltersMobile((v) => !v)}>
Фильтры
{isPartsAPIMode ? (
) : isPartsIndexMode ? (
) : (
)} setShowFiltersMobile(false)} filters={isPartsAPIMode ? dynamicFilters : catalogFilters} searchQuery={searchQuery} onSearchChange={setSearchQuery} filterValues={selectedFilters} onFilterChange={handleMobileFilterChange} />
{/* Индикатор загрузки для PartsAPI */} {isPartsAPIMode && articlesLoading && (
)} {/* Индикатор загрузки для PartsIndex */} {isPartsIndexMode && entitiesLoading && (
)} {/* Сообщение об ошибке */} {isPartsAPIMode && articlesError && (
Ошибка загрузки артикулов: {articlesError.message}
)} {/* Сообщение об ошибке для PartsIndex */} {isPartsIndexMode && entitiesError && (
Ошибка загрузки товаров: {entitiesError.message}
)} {/* Отображение артикулов PartsAPI */} {isPartsAPIMode && visibleArticles.length > 0 && ( <> {visibleArticles.map((article, idx) => ( ))} {/* Кнопка "Показать еще" */} {hasMoreItems && (
)} )} {/* Отображение товаров PartsIndex */} {isPartsIndexMode && filteredEntities.length > 0 && ( <> {filteredEntities .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"; let priceElement; if (isLoadingPriceData) { priceElement = ; } else if (priceData && priceData.price) { displayPrice = `${priceData.price.toLocaleString('ru-RU')} ₽`; displayCurrency = priceData.currency || "RUB"; } return ( { // Если цена не загружена, загружаем её и добавляем в корзину if (!priceData && !isLoadingPriceData) { loadPriceOnDemand(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, deliveryTime: '1-3 дня', warehouse: 'Parts Index', supplier: 'Parts Index', isExternal: true, image: entity.images?.[0] || '', }; addItem(itemToAdd); // Показываем уведомление toast.success(
Товар добавлен в корзину!
{`${entity.brand.name} ${entity.code} за ${priceData.price.toLocaleString('ru-RU')} ₽`}
, { duration: 3000, icon: , } ); } else { toast.error('Цена товара еще загружается. Попробуйте снова через несколько секунд.'); } }} /> ); })} {/* Пагинация для PartsIndex */}
Страница {partsIndexPage} {totalPages > partsIndexPage && `из ${totalPages}+`}
{/* Отладочная информация */} {isPartsIndexMode && (
🔍 Отладка PartsIndex:
• hasMoreItems: {hasMoreItems ? 'да' : 'нет'}
• hasMoreEntities: {hasMoreEntities ? 'да' : 'нет'}
• entitiesPage: {entitiesPage}
• visibleEntities: {visibleEntities.length}
• filteredEntities: {filteredEntities.length}
• groupId: {groupId || 'отсутствует'}
• isLoadingMore: {isLoadingMore ? 'да' : 'нет'}
• entitiesLoading: {entitiesLoading ? 'да' : 'нет'}
• catalogId: {catalogId || 'отсутствует'}
• Пагинация: {JSON.stringify(entitiesData?.partsIndexCatalogEntities?.pagination)}
)} )} {/* Пустое состояние для PartsAPI */} {isPartsAPIMode && !articlesLoading && !articlesError && showEmptyState && ( selectedFilters[key].length > 0)} onResetFilters={handleResetFilters} /> )} {/* Пустое состояние для PartsIndex */} {isPartsIndexMode && !entitiesLoading && !entitiesError && showEmptyState && ( selectedFilters[key].length > 0)} onResetFilters={handleResetFilters} /> )} {/* Каталог PartsIndex без группы */} {isPartsIndexCatalogOnly && (
Выберите подкатегорию
Для просмотра товаров необходимо выбрать конкретную подкатегорию из меню.
)} {/* Обычные товары (не PartsAPI/PartsIndex) */} {!isPartsAPIMode && !isPartsIndexMode && !isPartsIndexCatalogOnly && (
Раздел в разработке
Данные для этой категории скоро появятся.
)}
{!isPartsAPIMode && !isPartsIndexMode && }