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

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

View File

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