@ -38,8 +38,8 @@ const mockData = Array(12).fill({
brand : "Borsehung" ,
} ) ;
const ITEMS_PER_PAGE = 50 ; // Целевое количество товаров на странице
const PARTSINDEX_PAGE_SIZE = 25 ; // Размер страницы PartsIndex API (фиксированный)
const ITEMS_PER_PAGE = 50 ; // Уменьшено для быстрой загрузки и лучшего UX
const PARTSINDEX_PAGE_SIZE = 25 ; // Синхронизировано для оптимальной скорости
const MAX_BRANDS_DISPLAY = 10 ; // Сколько брендов показывать изначально
export default function Catalog() {
@ -56,6 +56,36 @@ export default function Catalog() {
const [ showSortMobile , setShowSortMobile ] = useState ( false ) ;
const [ searchQuery , setSearchQuery ] = useState ( '' ) ;
const [ selectedFilters , setSelectedFilters ] = useState < { [ key : string ] : string [ ] } > ( { } ) ;
// Инициализация фильтров из URL при загрузке
useEffect ( ( ) = > {
if ( router . isReady ) {
const urlFilters : { [ key : string ] : string [ ] } = { } ;
const urlSearchQuery = router . query . q as string || '' ;
// Восстанавливаем фильтры из URL
Object . keys ( router . query ) . forEach ( key = > {
if ( key . startsWith ( 'filter_' ) ) {
const filterName = key . replace ( 'filter_' , '' ) ;
const filterValue = router . query [ key ] ;
if ( typeof filterValue === 'string' ) {
urlFilters [ filterName ] = [ filterValue ] ;
} else if ( Array . isArray ( filterValue ) ) {
urlFilters [ filterName ] = filterValue ;
}
}
} ) ;
console . log ( '🔗 Восстанавливаем фильтры из URL:' , { urlFilters , urlSearchQuery } ) ;
if ( Object . keys ( urlFilters ) . length > 0 ) {
setSelectedFilters ( urlFilters ) ;
}
if ( urlSearchQuery ) {
setSearchQuery ( urlSearchQuery ) ;
}
}
} , [ router . isReady ] ) ;
const [ visibleArticles , setVisibleArticles ] = useState < PartsAPIArticle [ ] > ( [ ] ) ;
const [ visibleEntities , setVisibleEntities ] = useState < PartsIndexEntity [ ] > ( [ ] ) ;
const [ currentPage , setCurrentPage ] = useState ( 1 ) ;
@ -80,6 +110,7 @@ export default function Catalog() {
const [ isAutoLoading , setIsAutoLoading ] = useState ( false ) ; // Автоматическая подгрузка в процессе
const [ currentUserPage , setCurrentUserPage ] = useState ( 1 ) ; // Текущая пользовательская страница
const [ entitiesCache , setEntitiesCache ] = useState < Map < number , PartsIndexEntity [ ] > > ( new Map ( ) ) ; // Кэш страниц
const [ isFilterChanging , setIsFilterChanging ] = useState ( false ) ; // Флаг изменения фильтров
// Карта видимости товаров по индексу
const [ visibilityMap , setVisibilityMap ] = useState < Map < number , boolean > > ( new Map ( ) ) ;
@ -175,16 +206,17 @@ export default function Catalog() {
// Хук для загрузки цен товаров PartsIndex
const { getPrice , isLoadingPrice , ensurePriceLoaded } = useProductPrices ( ) ;
// Загружаем цены для видимых товаров PartsIndex
// Загружаем цены для видимых товаров 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 ) ;
@ -208,9 +240,16 @@ export default function Catalog() {
console . log ( '📊 Обновляем entitiesData:' , {
listLength : entitiesData.partsIndexCatalogEntities.list.length ,
pagination : entitiesData.partsIndexCatalogEntities.pagination ,
currentPage : entitiesData.partsIndexCatalogEntities.pagination?.page?.current || 1
currentPage : entitiesData.partsIndexCatalogEntities.pagination?.page?.current || 1 ,
isFilterChanging
} ) ;
// Если изменяются фильтры, сбрасываем флаг после получения новых данных
if ( isFilterChanging ) {
setIsFilterChanging ( false ) ;
console . log ( '🔄 Сброшен флаг isFilterChanging - получены новые отфильтрованные данные' ) ;
}
const newEntities = entitiesData . partsIndexCatalogEntities . list ;
const pagination = entitiesData . partsIndexCatalogEntities . pagination ;
@ -228,9 +267,13 @@ export default function Catalog() {
// Если это первая страница или с б р о с , заменяем накопленные товары
if ( currentPage === 1 ) {
setAccumulatedEntities ( newEntities ) ;
// Устанавливаем visibleEntities сразу, не дожидаясь проверки цен
// Устанавливаем visibleEntities сразу, только если не идет изменение фильтров
if ( ! isFilterChanging ) {
setVisibleEntities ( newEntities ) ;
console . log ( '✅ Установлены visibleEntities для первой страницы:' , newEntities . length ) ;
} else {
console . log ( '🔄 Пропускаем установку visibleEntities - фильтры изменяются' ) ;
}
} else {
// Добавляем к накопленным товарам
setAccumulatedEntities ( prev = > [ . . . prev , . . . newEntities ] ) ;
@ -245,7 +288,7 @@ export default function Catalog() {
console . log ( '✅ Пагинация обновлена:' , { currentPage , hasNext , hasPrev } ) ;
}
} , [ entitiesData ] ) ;
} , [ entitiesData , isFilterChanging ]) ;
// Преобразование выбранных фильтров в формат PartsIndex API
const convertFiltersToPartsIndexParams = useMemo ( ( ) : Record < string , any > = > {
@ -284,25 +327,18 @@ export default function Catalog() {
// Восстанавливаем автоподгрузку
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 ;
} ) ;
// Подсчитываем текущее количество товаров (все уже отфильтрованы на сервере)
const currentEntitiesCount = accumulatedEntities . length ;
console . log ( '📊 Автоподгрузка: текущее состояние:' , {
накопленоТоваров : accumulated Entities.length ,
сПредложениями : currentEntitiesWithOffers.length ,
накопленоТоваров : current EntitiesCount ,
целевоеКоличество : ITEMS_PER_PAGE ,
естьЕщеТовары : hasMoreEntities
} ) ;
// Если у нас уже достаточно товаров с предложениями , не загружаем
if ( currentEntitiesWithOffers . length >= ITEMS_PER_PAGE ) {
console . log ( '✅ Автоподгрузка: достаточно товаров с предложениями ' ) ;
// Если у нас уже достаточно товаров, не загружаем
if ( currentEntitiesCount >= ITEMS_PER_PAGE ) {
console . log ( '✅ Автоподгрузка: достаточно товаров' ) ;
return ;
}
@ -444,27 +480,26 @@ export default function Catalog() {
}
} , [ isPartsIndexMode , entitiesWithOffers . length , hasMoreEntities , isAutoLoading ] ) ;
// Обновляем список товаров с предложениями при изменении накопленных товаров или цен
// Обновляем список товаров при изменении накопленных товаров (серверная фильтрация)
useEffect ( ( ) = > {
if ( ! isPartsIndexMode ) {
return ;
}
// Показываем все товары , но о тдельно считаем те, у которых есть це ны
// Если фильтры изменяются , не о бновляем отображение старых дан ных
if ( isFilterChanging ) {
console . log ( '🔄 Пропускаем обновление entitiesWithOffers - фильтры изменяются' ) ;
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:' , {
console . log ( '📊 Обновляем entitiesWithOffers (серверная фильтрация):' , {
накопленоТоваров : accumulatedEntities.length ,
отображаемыхТоваров : entitiesWithOffers.length ,
сРеальнымиЦенами : entitiesWithRealPrices.length ,
целевоеКоличество : ITEMS_PER_PAGE
целевоеКоличество : ITEMS_PER_PAGE ,
isFilterChanging
} ) ;
setEntitiesWithOffers ( entitiesWithOffers ) ;
@ -484,31 +519,9 @@ export default function Catalog() {
setVisibleEntities ( visibleForCurrentPage ) ;
} , [ isPartsIndexMode , accumulatedEntities , currentUserPage ] ) ;
} , [ isPartsIndexMode , accumulatedEntities , currentUserPage , isFilterChanging ]) ;
// Отдельный 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 [ ] = > {
@ -595,27 +608,94 @@ export default function Catalog() {
// Функция для обновления URL с фильтрами
const updateUrlWithFilters = useCallback ( ( filters : { [ key : string ] : string [ ] } , search : string ) = > {
const query : any = { . . . router . query } ;
// Удаляем старые фильтры из URL
Object . keys ( query ) . forEach ( key = > {
if ( key . startsWith ( 'filter_' ) || key === 'q' ) {
delete query [ key ] ;
}
} ) ;
// Добавляем новые фильтры
Object . entries ( filters ) . forEach ( ( [ filterName , values ] ) = > {
if ( values . length > 0 ) {
query [ ` filter_ ${ filterName } ` ] = values . length === 1 ? values [ 0 ] : values ;
}
} ) ;
// Добавляем поисковый запрос
if ( search . trim ( ) ) {
query . q = search ;
}
// Обновляем URL без перезагрузки страницы
router . push ( {
pathname : router.pathname ,
query
} , undefined , { shallow : true } ) ;
} , [ router ] ) ;
const handleDesktopFilterChange = ( filterTitle : string , value : string | string [ ] ) = > {
setSelectedFilters ( prev = > ( {
. . . prev ,
[ filterTitle ] : Array . isArray ( value ) ? value : [ value ]
} ) ) ;
setSelectedFilters ( prev = > {
const newFilters = { . . . prev } ;
// Если значение пустое (пустой массив или пустая строка), удаляем фильтр
if ( Array . isArray ( value ) && value . length === 0 ) {
delete newFilters [ filterTitle ] ;
} else if ( ! value || ( typeof value === 'string' && value . trim ( ) === '' ) ) {
delete newFilters [ filterTitle ] ;
} else {
// Иначе устанавливаем значение
newFilters [ filterTitle ] = Array . isArray ( value ) ? value : [ value ] ;
}
// Обновляем URL
updateUrlWithFilters ( newFilters , searchQuery ) ;
return newFilters ;
} ) ;
} ;
const handleMobileFilterChange = ( type : string , value : any ) = > {
setSelectedFilters ( prev = > ( {
. . . prev ,
[ type ] : Array . isArray ( value ) ? value : [ value ]
} ) ) ;
setSelectedFilters ( prev = > {
const newFilters = { . . . prev } ;
// Если значение пустое (пустой массив или пустая строка), удаляем фильтр
if ( Array . isArray ( value ) && value . length === 0 ) {
delete newFilters [ type ] ;
} else if ( ! value || ( typeof value === 'string' && value . trim ( ) === '' ) ) {
delete newFilters [ type ] ;
} else {
// Иначе устанавливаем значение
newFilters [ type ] = Array . isArray ( value ) ? value : [ value ] ;
}
// Обновляем URL
updateUrlWithFilters ( newFilters , searchQuery ) ;
return newFilters ;
} ) ;
} ;
// Обработчик изменения поискового запроса
const handleSearchChange = useCallback ( ( value : string ) = > {
setSearchQuery ( value ) ;
updateUrlWithFilters ( selectedFilters , value ) ;
} , [ selectedFilters , updateUrlWithFilters ] ) ;
// Функция для с б р о с а всех фильтров
const handleResetFilters = useCallback ( ( ) = > {
setSearchQuery ( '' ) ;
setSelectedFilters ( { } ) ;
setShowAllBrands ( false ) ;
setPartsIndexPage ( 1 ) ; // Сбрасываем страницу PartsIndex на первую
} , [ ] ) ;
// Очищаем URL от фильтров
updateUrlWithFilters ( { } , '' ) ;
} , [ updateUrlWithFilters ] ) ;
// Фильтрация по поиску и фильтрам для PartsAPI
const filteredArticles = useMemo ( ( ) = > {
@ -672,6 +752,10 @@ export default function Catalog() {
// Если изменился поисковый запрос или фильтры, нужно перезагрузить данные с сервера
if ( searchQuery . trim ( ) || Object . keys ( selectedFilters ) . length > 0 ) {
console . log ( '🔍 Поисковый запрос или фильтры изменились, сбрасываем пагинацию' ) ;
// Устанавливаем флаг изменения фильтров
setIsFilterChanging ( true ) ;
setPartsIndexPage ( 1 ) ;
setCurrentUserPage ( 1 ) ;
setHasMoreEntities ( true ) ;
@ -679,10 +763,36 @@ export default function Catalog() {
setEntitiesWithOffers ( [ ] ) ;
setEntitiesCache ( new Map ( ) ) ;
// Перезагружаем данные с новыми параметрами фильтрации
cons t apiParams = convertFiltersToPartsIndexParams ;
// Вычисляем параметры фильтрации прямо здесь, чтобы избежать зависимости от useMemo
le t apiParams : Record < string , any > = { } ;
if ( paramsData ? . partsIndexCatalogParams ? . list && Object . keys ( selectedFilters ) . length > 0 ) {
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 ) ;
}
}
} ) ;
}
const paramsString = Object . keys ( apiParams ) . length > 0 ? JSON . stringify ( apiParams ) : undefined ;
console . log ( '🔄 Запуск refetch с новыми фильтрами:' , {
searchQuery ,
selectedFilters ,
apiParams ,
paramsString ,
catalogId ,
groupId
} ) ;
// Также обновляем параметры фильтрации
refetchParams ( {
catalogId : catalogId as string ,
@ -690,6 +800,10 @@ export default function Catalog() {
lang : 'ru' ,
q : searchQuery || undefined ,
params : paramsString
} ) . then ( result = > {
console . log ( '✅ refetchParams результат:' , result ) ;
} ) . catch ( error = > {
console . error ( '❌ refetchParams ошибка:' , error ) ;
} ) ;
refetchEntities ( {
@ -700,11 +814,20 @@ export default function Catalog() {
page : 1 ,
q : searchQuery || undefined ,
params : paramsString
} ) . then ( result = > {
console . log ( '✅ refetchEntities результат:' , result . data ? . partsIndexCatalogEntities ? . list ? . length || 0 , 'товаров' ) ;
} ) . catch ( error = > {
console . error ( '❌ refetchEntities ошибка:' , error ) ;
} ) ;
} else {
// Если нет активных фильтров, сбрасываем флаг
if ( isFilterChanging ) {
setIsFilterChanging ( false ) ;
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ isPartsIndexMode , searchQuery , JSON . stringify ( selectedFilters ) , refetchEntities , refetchParams , convertFiltersToPartsIndexParams ] ) ;
} , [ isPartsIndexMode , searchQuery , JSON . stringify ( selectedFilters ) , paramsData ] ) ;
// Управляем показом пустого состояния с задержкой
useEffect ( ( ) = > {
@ -724,12 +847,18 @@ export default function Catalog() {
} else if ( isPartsIndexMode && ! entitiesLoading && ! entitiesError ) {
// Для PartsIndex показываем пустое состояние если нет товаров И данные уже загружены
const hasLoadedData = accumulatedEntities . length > 0 || Boolean ( entitiesData ? . partsIndexCatalogEntities ? . list ) ;
setShowEmptyState ( hasLoadedData && visibleEntities . length === 0 ) ;
console . log ( '📊 Определяем showEmptyState для PartsIndex:' , {
// Показываем пустое состояние если данные загружены и нет видимых товаров
// (товары уже отфильтрованы на сервере, поэтому не нужно ждать загрузки цен)
const shouldShowEmpty = hasLoadedData && visibleEntities . length === 0 ;
setShowEmptyState ( shouldShowEmpty ) ;
console . log ( '📊 Определяем showEmptyState для PartsIndex (серверная фильтрация):' , {
hasLoadedData ,
visibleEntitiesLength : visibleEntities.length ,
accumulatedEntitiesLength : accumulatedEntities.length ,
showEmptyState : hasLoadedData && visibleEntities . length === 0
shouldShowEmpty ,
showEmptyState : shouldShowEmpty
} ) ;
} else {
setShowEmptyState ( false ) ;
@ -884,7 +1013,7 @@ export default function Catalog() {
onFilterChange = { handleDesktopFilterChange }
filterValues = { selectedFilters }
searchQuery = { searchQuery }
onSearchChange = { setSearchQuery }
onSearchChange = { handleSearchChange }
isLoading = { filtersGenerating }
/ >
< / div >
@ -895,7 +1024,7 @@ export default function Catalog() {
onFilterChange = { handleDesktopFilterChange }
filterValues = { selectedFilters }
searchQuery = { searchQuery }
onSearchChange = { setSearchQuery }
onSearchChange = { handleSearchChange }
isLoading = { filtersLoading }
/ >
< / div >
@ -906,7 +1035,7 @@ export default function Catalog() {
onFilterChange = { handleDesktopFilterChange }
filterValues = { selectedFilters }
searchQuery = { searchQuery }
onSearchChange = { setSearchQuery }
onSearchChange = { handleSearchChange }
isLoading = { filtersLoading }
/ >
< / div >
@ -916,7 +1045,7 @@ export default function Catalog() {
onClose = { ( ) = > setShowFiltersMobile ( false ) }
filters = { isPartsAPIMode ? dynamicFilters : catalogFilters }
searchQuery = { searchQuery }
onSearchChange = { setSearchQuery }
onSearchChange = { handleSearchChange }
filterValues = { selectedFilters }
onFilterChange = { handleMobileFilterChange }
/ >
@ -936,6 +1065,8 @@ export default function Catalog() {
< / div >
) }
{ /* Сообщение о б ошибке */ }
{ isPartsAPIMode && articlesError && (
< div className = "flex justify-center items-center py-8" >
@ -985,12 +1116,21 @@ export default function Catalog() {
< / >
) }
{ /* Показываем индикатор загрузки при изменении фильтров */ }
{ isPartsIndexMode && isFilterChanging && (
< div className = "flex flex-col items-center justify-center py-12" >
< LoadingSpinner / >
< div className = "text-gray-500 text-lg mt-4" > П р и м е н я е м ф и л ь т р ы . . . < / div >
< / div >
) }
{ /* Отображение товаров PartsIndex */ }
{ isPartsIndexMode && ( ( ) = > {
{ isPartsIndexMode && ! isFilterChanging && ( ( ) = > {
console . log ( '🎯 Проверяем отображение PartsIndex товаров:' , {
isPartsIndexMode ,
visibleEntitiesLength : visibleEntities.length ,
visibleEntities : visibleEntities.map ( e = > ( { id : e.id , code : e.code , brand : e.brand.name } ) )
visibleEntities : visibleEntities.map ( e = > ( { id : e.id , code : e.code , brand : e.brand.name } ) ) ,
isFilterChanging
} ) ;
return visibleEntities . length > 0 ;
} ) ( ) && (
@ -1001,16 +1141,20 @@ export default function Catalog() {
const priceData = getPrice ( productForPrice ) ;
const isLoadingPriceData = isLoadingPrice ( productForPrice ) ;
// Определяем цену для отображения
let displayPrice = "Цена по запросу " ;
// Определяем цену для отображения (все товары уже отфильтрованы на сервере)
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" ;
} else {
// Если нет данных о цене, показываем скелетон (товар должен загрузиться)
priceElement = < PriceSkeleton / > ;
}
return (
@ -1021,7 +1165,7 @@ export default function Catalog() {
articleNumber = { entity . code }
brandName = { entity . brand . name }
image = { entity . images ? . [ 0 ] || '' }
price = { isLoadingPriceData ? "" : displayPrice }
price = { priceElement ? "" : displayPrice }
priceElement = { priceElement }
oldPrice = ""
discount = ""