first commit
This commit is contained in:
850
src/pages/catalog.tsx
Normal file
850
src/pages/catalog.tsx
Normal file
@ -0,0 +1,850 @@
|
||||
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';
|
||||
|
||||
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<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); // Общее количество страниц
|
||||
|
||||
// Карта видимости товаров по индексу
|
||||
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
|
||||
});
|
||||
|
||||
// Загружаем артикулы 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: 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<PartsIndexParamsData, PartsIndexParamsVariables>(
|
||||
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<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]);
|
||||
|
||||
// Упрощенная логика - показываем все загруженные товары без клиентской фильтрации
|
||||
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 <div className="py-8 text-center">Загрузка фильтров...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Catalog</title>
|
||||
<meta name="description" content="Catalog" />
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect" />
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect" crossOrigin="anonymous" />
|
||||
<link href="images/favicon.png" rel="shortcut icon" type="image/x-icon" />
|
||||
<link href="images/webclip.png" rel="apple-touch-icon" />
|
||||
</Head>
|
||||
<CatalogInfoHeader
|
||||
title={
|
||||
isPartsAPIMode ? decodeURIComponent(categoryName as string || 'Запчасти') :
|
||||
isPartsIndexMode ? decodeURIComponent(categoryName as string || 'Товары') :
|
||||
"Аккумуляторы"
|
||||
}
|
||||
count={
|
||||
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) :
|
||||
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 && 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 = <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={() => {
|
||||
// Если цена не загружена, загружаем её и добавляем в корзину
|
||||
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')} ₽`);
|
||||
} else {
|
||||
toast.error('Цена товара еще загружается. Попробуйте снова через несколько секунд.');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Пагинация для PartsIndex */}
|
||||
<div className="w-layout-hflex pagination">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={partsIndexPage <= 1 || entitiesLoading}
|
||||
className="button_strock w-button mr-2"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
<span className="flex items-center px-4 text-gray-600">
|
||||
Страница {partsIndexPage} {totalPages > partsIndexPage && `из ${totalPages}+`}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMoreEntities || entitiesLoading}
|
||||
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>• visibleEntities: {visibleEntities.length}</div>
|
||||
<div>• filteredEntities: {filteredEntities.length}</div>
|
||||
<div>• groupId: {groupId || 'отсутствует'}</div>
|
||||
<div>• isLoadingMore: {isLoadingMore ? 'да' : 'нет'}</div>
|
||||
<div>• entitiesLoading: {entitiesLoading ? 'да' : 'нет'}</div>
|
||||
<div>• catalogId: {catalogId || 'отсутствует'}</div>
|
||||
<div>• Пагинация: {JSON.stringify(entitiesData?.partsIndexCatalogEntities?.pagination)}</div>
|
||||
</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 && 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 />
|
||||
</>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user