first commit

This commit is contained in:
Bivekich
2025-06-26 06:59:59 +03:00
commit d44874775c
450 changed files with 76635 additions and 0 deletions

View File

@ -0,0 +1,53 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery } from '@apollo/client';
import { GET_PARTSAPI_MAIN_IMAGE } from '@/lib/graphql';
import { PartsAPIMainImageData, PartsAPIMainImageVariables } from '@/types/partsapi';
interface UseArticleImageOptions {
enabled?: boolean;
fallbackImage?: string;
}
interface UseArticleImageReturn {
imageUrl: string;
isLoading: boolean;
error: boolean;
}
export const useArticleImage = (
artId: string | undefined | null,
options: UseArticleImageOptions = {}
): UseArticleImageReturn => {
const { enabled = true, fallbackImage = '' } = options;
const [imageUrl, setImageUrl] = useState<string>(fallbackImage);
// Проверяем что artId валидный
const shouldFetch = enabled && artId && artId.trim() !== '';
const { data, loading, error } = useQuery<PartsAPIMainImageData, PartsAPIMainImageVariables>(
GET_PARTSAPI_MAIN_IMAGE,
{
variables: { artId: artId || '' },
skip: !shouldFetch,
fetchPolicy: 'cache-first',
errorPolicy: 'all',
onCompleted: (data) => {
const url = data?.partsAPIMainImage;
if (url && url !== null) {
setImageUrl(url);
} else {
setImageUrl(fallbackImage);
}
},
onError: (error) => {
setImageUrl(fallbackImage);
}
}
);
return {
imageUrl,
isLoading: loading,
error: !!error
};
};

View File

@ -0,0 +1,256 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import toast from 'react-hot-toast';
import { useCart } from '@/contexts/CartContext';
interface PriceData {
minPrice: number | null;
cheapestOffer: any | null;
isLoading: boolean;
hasOffers: boolean;
}
interface UseCatalogPricesReturn {
getPriceData: (articleNumber: string, brand: string) => PriceData;
addToCart: (articleNumber: string, brand: string) => Promise<void>;
}
export const useCatalogPrices = (): UseCatalogPricesReturn => {
const [priceCache, setPriceCache] = useState<Map<string, PriceData>>(new Map());
const loadingRequestsRef = useRef<Set<string>>(new Set());
const { addItem } = useCart();
const getOffersData = useCallback(async (articleNumber: string, brand: string) => {
const graphqlUri = process.env.NEXT_PUBLIC_CMS_GRAPHQL_URL || 'http://localhost:3000/api/graphql';
console.log('🔍 useCatalogPrices: запрос цен для:', { articleNumber, brand, graphqlUri });
try {
const response = await fetch(graphqlUri, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `
query SearchProductOffers($articleNumber: String!, $brand: String!) {
searchProductOffers(articleNumber: $articleNumber, brand: $brand) {
internalOffers {
id
productId
price
quantity
warehouse
deliveryDays
available
rating
supplier
}
externalOffers {
offerKey
brand
code
name
price
currency
deliveryTime
deliveryTimeMax
quantity
warehouse
warehouseName
rejects
supplier
comment
weight
volume
canPurchase
}
}
}
`,
variables: {
articleNumber,
brand
}
})
});
console.log('📡 useCatalogPrices: HTTP статус ответа:', response.status);
if (!response.ok) {
console.error('❌ useCatalogPrices: HTTP ошибка:', response.status, response.statusText);
return { minPrice: null, cheapestOffer: null, hasOffers: false };
}
const data = await response.json();
console.log('📦 useCatalogPrices: получен ответ:', data);
// Если есть ошибки GraphQL, логируем их
if (data.errors) {
console.error('❌ useCatalogPrices: GraphQL ошибки:', data.errors);
return { minPrice: null, cheapestOffer: null, hasOffers: false };
}
const offers = data?.data?.searchProductOffers;
console.log('🔍 useCatalogPrices: извлеченные предложения:', offers);
if (!offers) {
console.log('⚠️ useCatalogPrices: предложения не найдены');
return { minPrice: null, cheapestOffer: null, hasOffers: false };
}
const allOffers: any[] = [];
// Обрабатываем внутренние предложения
if (offers.internalOffers) {
console.log('📦 useCatalogPrices: обрабатываем внутренние предложения:', offers.internalOffers.length);
offers.internalOffers.forEach((offer: any) => {
if (offer.price && offer.price > 0) {
allOffers.push({
...offer,
type: 'internal',
id: offer.id,
supplierName: offer.supplier,
deliveryDays: offer.deliveryDays
});
}
});
}
// Обрабатываем внешние предложения
if (offers.externalOffers) {
console.log('🌐 useCatalogPrices: обрабатываем внешние предложения:', offers.externalOffers.length);
offers.externalOffers.forEach((offer: any) => {
if (offer.price && offer.price > 0) {
allOffers.push({
...offer,
type: 'external',
id: offer.offerKey,
supplierName: offer.supplier,
deliveryDays: offer.deliveryTime || offer.deliveryTimeMax || 0
});
}
});
}
console.log('🎯 useCatalogPrices: итого найдено предложений:', allOffers.length);
// Проверяем, есть ли вообще какие-то предложения (даже без цены)
const hasAnyOffers = (offers.internalOffers && offers.internalOffers.length > 0) ||
(offers.externalOffers && offers.externalOffers.length > 0);
if (allOffers.length === 0) {
console.log('⚠️ useCatalogPrices: нет валидных предложений с ценой > 0');
return { minPrice: null, cheapestOffer: null, hasOffers: hasAnyOffers };
}
// Находим самое дешевое предложение
const cheapestOffer = allOffers.reduce((cheapest, current) => {
return current.price < cheapest.price ? current : cheapest;
});
console.log('💰 useCatalogPrices: самое дешевое предложение:', {
price: cheapestOffer.price,
supplier: cheapestOffer.supplierName,
type: cheapestOffer.type
});
return {
minPrice: cheapestOffer.price,
cheapestOffer,
hasOffers: true
};
} catch (error) {
console.error('❌ useCatalogPrices: ошибка получения предложений:', error);
return { minPrice: null, cheapestOffer: null, hasOffers: false };
}
}, []);
const getPriceData = useCallback((articleNumber: string, brand: string): PriceData => {
if (!articleNumber || !brand) {
return { minPrice: null, cheapestOffer: null, isLoading: false, hasOffers: false };
}
const key = `${brand}-${articleNumber}`;
const cached = priceCache.get(key);
if (cached) {
return cached;
}
// Проверяем, не загружается ли уже этот товар
if (loadingRequestsRef.current.has(key)) {
return { minPrice: null, cheapestOffer: null, isLoading: true, hasOffers: false };
}
// Устанавливаем состояние загрузки
const loadingState: PriceData = { minPrice: null, cheapestOffer: null, isLoading: true, hasOffers: false };
setPriceCache(prev => new Map(prev).set(key, loadingState));
loadingRequestsRef.current.add(key);
// Загружаем данные асинхронно
getOffersData(articleNumber, brand).then(({ minPrice, cheapestOffer, hasOffers }) => {
const finalState: PriceData = { minPrice, cheapestOffer, isLoading: false, hasOffers };
setPriceCache(prev => new Map(prev).set(key, finalState));
loadingRequestsRef.current.delete(key);
}).catch((error) => {
console.error('❌ useCatalogPrices: ошибка загрузки цены:', error);
const errorState: PriceData = { minPrice: null, cheapestOffer: null, isLoading: false, hasOffers: false };
setPriceCache(prev => new Map(prev).set(key, errorState));
loadingRequestsRef.current.delete(key);
});
return loadingState;
}, [priceCache, getOffersData]);
const addToCart = useCallback(async (articleNumber: string, brand: string) => {
const key = `${brand}-${articleNumber}`;
const cached = priceCache.get(key);
let cheapestOffer = cached?.cheapestOffer;
// Если нет кэшированного предложения, загружаем
if (!cheapestOffer) {
const { cheapestOffer: offer } = await getOffersData(articleNumber, brand);
cheapestOffer = offer;
}
if (!cheapestOffer) {
toast.error('Не удалось найти предложения для этого товара');
return;
}
// Добавляем в корзину самое дешевое предложение
try {
const itemToAdd = {
productId: cheapestOffer.type === 'internal' ? cheapestOffer.id : undefined,
offerKey: cheapestOffer.type === 'external' ? cheapestOffer.id : undefined,
name: `${brand} ${articleNumber}`,
description: `${brand} ${articleNumber}`,
brand: brand,
article: articleNumber,
price: cheapestOffer.price,
currency: cheapestOffer.currency || 'RUB',
quantity: 1,
deliveryTime: cheapestOffer.deliveryDays?.toString() || '0',
warehouse: cheapestOffer.warehouse || 'Склад',
supplier: cheapestOffer.supplierName || 'Неизвестный поставщик',
isExternal: cheapestOffer.type === 'external',
image: '', // Убираем мокап-фотку, изображения будут загружаться отдельно
};
addItem(itemToAdd);
// Показываем уведомление
toast.success(`Товар "${brand} ${articleNumber}" добавлен в корзину за ${cheapestOffer.price}`);
} catch (error) {
console.error('Ошибка добавления в корзину:', error);
toast.error('Ошибка добавления товара в корзину');
}
}, [priceCache, getOffersData, addItem]);
return {
getPriceData,
addToCart
};
};

View File

@ -0,0 +1,77 @@
import { useState, useEffect, useCallback, useRef } from 'react';
interface UseInfiniteScrollOptions {
threshold?: number;
rootMargin?: string;
hasMore?: boolean;
isLoading?: boolean;
debounceMs?: number;
}
interface UseInfiniteScrollReturn {
targetRef: React.RefObject<HTMLDivElement | null>;
isIntersecting: boolean;
}
// Debounce функция
const debounce = (func: Function, wait: number) => {
let timeout: NodeJS.Timeout;
return function executedFunction(...args: any[]) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
export const useInfiniteScroll = (
onLoadMore: () => void,
options: UseInfiniteScrollOptions = {}
): UseInfiniteScrollReturn => {
const {
threshold = 0.1,
rootMargin = '100px',
hasMore = true,
isLoading = false,
debounceMs = 200
} = options;
const [isIntersecting, setIsIntersecting] = useState(false);
const targetRef = useRef<HTMLDivElement | null>(null);
// Создаем debounced версию onLoadMore
const debouncedOnLoadMore = useCallback(
debounce(onLoadMore, debounceMs),
[onLoadMore, debounceMs]
);
const handleIntersection = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
setIsIntersecting(entry.isIntersecting);
if (entry.isIntersecting && hasMore && !isLoading) {
debouncedOnLoadMore();
}
},
[debouncedOnLoadMore, hasMore, isLoading]
);
useEffect(() => {
const target = targetRef.current;
if (!target) return;
const observer = new IntersectionObserver(handleIntersection, {
threshold,
rootMargin,
});
observer.observe(target);
return () => observer.disconnect();
}, [handleIntersection, threshold, rootMargin]);
return { targetRef, isIntersecting };
};

View File

@ -0,0 +1,83 @@
import { useState, useEffect } from 'react';
import { partsIndexService } from '@/lib/partsindex-service';
import { PartsIndexCatalog, PartsIndexGroup, PartsIndexTabData } from '@/types/partsindex';
export const usePartsIndexCatalogs = () => {
const [catalogs, setCatalogs] = useState<PartsIndexCatalog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchCatalogs = async () => {
try {
setLoading(true);
setError(null);
const response = await partsIndexService.getCatalogs('ru');
setCatalogs(response.list);
} catch (err) {
setError(err as Error);
console.error('Ошибка загрузки каталогов:', err);
} finally {
setLoading(false);
}
};
fetchCatalogs();
}, []);
return { catalogs, loading, error };
};
export const usePartsIndexCatalogGroups = (catalogId: string | null) => {
const [group, setGroup] = useState<PartsIndexGroup | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!catalogId) {
setGroup(null);
return;
}
const fetchGroup = async () => {
try {
setLoading(true);
setError(null);
const groupData = await partsIndexService.getCatalogGroups(catalogId, 'ru');
setGroup(groupData);
} catch (err) {
setError(err as Error);
console.error(`Ошибка загрузки группы каталога ${catalogId}:`, err);
} finally {
setLoading(false);
}
};
fetchGroup();
}, [catalogId]);
return { group, loading, error };
};
// Функция для преобразования данных Parts Index в формат меню
export const transformPartsIndexToTabData = (
catalogs: PartsIndexCatalog[],
catalogGroups: Map<string, PartsIndexGroup>
): PartsIndexTabData[] => {
return catalogs.map(catalog => {
const group = catalogGroups.get(catalog.id);
// Получаем подкатегории из entityNames или повторяем название категории
const links = group?.entityNames && group.entityNames.length > 0
? group.entityNames.slice(0, 9).map(entity => entity.name)
: [catalog.name]; // Если нет подкатегорий, повторяем название категории
return {
label: catalog.name,
heading: catalog.name,
links,
catalogId: catalog.id,
group
};
});
};

View File

@ -0,0 +1,121 @@
import { useState, useEffect } from 'react';
import { useLazyQuery } from '@apollo/client';
import { SEARCH_PRODUCT_OFFERS } from '@/lib/graphql';
interface ProductOffer {
offerKey: string;
brand: string;
code: string;
name: string;
price: number;
currency: string;
deliveryTime: number;
deliveryTimeMax: number;
quantity: number;
warehouse: string;
supplier: string;
canPurchase: boolean;
}
interface ProductPriceData {
searchProductOffers: {
articleNumber: string;
brand: string;
internalOffers: ProductOffer[];
externalOffers: ProductOffer[];
analogs: number;
hasInternalStock: boolean;
};
}
interface ProductPriceVariables {
articleNumber: string;
brand: string;
}
export const useProductPrices = (products: Array<{ code: string; brand: string; id: string }>) => {
const [pricesMap, setPricesMap] = useState<Map<string, ProductOffer | null>>(new Map());
const [loadingPrices, setLoadingPrices] = useState<Set<string>>(new Set());
const [searchOffers] = useLazyQuery<ProductPriceData, ProductPriceVariables>(SEARCH_PRODUCT_OFFERS);
const loadPrice = async (product: { code: string; brand: string; id: string }) => {
const key = `${product.id}_${product.code}_${product.brand}`;
if (pricesMap.has(key) || loadingPrices.has(key)) {
return; // Уже загружено или загружается
}
console.log('💰 Загружаем цену для:', product.code, product.brand);
setLoadingPrices(prev => new Set([...prev, key]));
try {
const result = await searchOffers({
variables: {
articleNumber: product.code,
brand: product.brand
}
});
if (result.data?.searchProductOffers) {
const offers = result.data.searchProductOffers;
console.log('📊 Получены предложения для', product.code, ':', {
internal: offers.internalOffers?.length || 0,
external: offers.externalOffers?.length || 0
});
// Берем первое доступное предложение (внутреннее или внешнее)
const bestOffer = offers.internalOffers?.[0] || offers.externalOffers?.[0];
if (bestOffer) {
console.log('✅ Найдена цена для', product.code, ':', bestOffer.price, bestOffer.currency);
setPricesMap(prev => new Map([...prev, [key, bestOffer]]));
} else {
console.log('⚠️ Предложения не найдены для', product.code);
setPricesMap(prev => new Map([...prev, [key, null]]));
}
} else {
console.log('❌ Нет данных от API для', product.code);
setPricesMap(prev => new Map([...prev, [key, null]]));
}
} catch (error) {
console.error('❌ Ошибка загрузки цены для', product.code, error);
setPricesMap(prev => new Map([...prev, [key, null]]));
} finally {
setLoadingPrices(prev => {
const newSet = new Set(prev);
newSet.delete(key);
return newSet;
});
}
};
useEffect(() => {
// Загружаем цены для всех товаров с небольшой задержкой между запросами
products.forEach((product, index) => {
setTimeout(() => {
loadPrice(product);
}, index * 100); // Задержка 100мс между запросами
});
}, [products]);
const getPrice = (product: { code: string; brand: string; id: string }) => {
const key = `${product.id}_${product.code}_${product.brand}`;
return pricesMap.get(key);
};
const isLoadingPrice = (product: { code: string; brand: string; id: string }) => {
const key = `${product.id}_${product.code}_${product.brand}`;
return loadingPrices.has(key);
};
const loadPriceOnDemand = (product: { code: string; brand: string; id: string }) => {
loadPrice(product);
};
return {
getPrice,
isLoadingPrice,
loadPriceOnDemand
};
};

View File

@ -0,0 +1,89 @@
import { useMemo } from 'react';
import { useQuery } from '@apollo/client';
import { GET_CATEGORY_PRODUCTS_WITH_OFFERS } from '@/lib/graphql';
interface RecommendedProduct {
brand: string;
articleNumber: string;
name?: string;
artId?: string;
minPrice?: number | null;
}
/**
* Хук для получения рекомендуемых товаров из той же категории с AutoEuro предложениями
*/
export const useRecommendedProducts = (
productName: string = '',
excludeArticle: string = '',
excludeBrand: string = ''
) => {
// Определяем категорию товара из названия
const categoryName = useMemo(() => {
const name = productName.toLowerCase()
// Простое определение категории по ключевым словам в названии
if (name.includes('шина') || name.includes('покрышка') || name.includes('резина')) {
return 'шины'
}
if (name.includes('масло') || name.includes('oil')) {
return 'масла'
}
if (name.includes('фильтр')) {
return 'фильтры'
}
if (name.includes('тормоз') || name.includes('колодка')) {
return 'тормоза'
}
if (name.includes('аккумулятор') || name.includes('батарея')) {
return 'аккумуляторы'
}
if (name.includes('свеча')) {
return 'свечи'
}
if (name.includes('стартер')) {
return 'стартеры'
}
if (name.includes('генератор')) {
return 'генераторы'
}
if (name.includes('амортизатор') || name.includes('стойка')) {
return 'амортизаторы'
}
// Если категория не определена, используем первое слово из названия
const words = productName.split(' ')
return words[0] || 'автотовары'
}, [productName])
// Запрос товаров из категории
const { data, loading, error } = useQuery(GET_CATEGORY_PRODUCTS_WITH_OFFERS, {
variables: {
categoryName,
excludeArticle,
excludeBrand,
limit: 5
},
skip: !categoryName || !excludeArticle, // Пропускаем запрос если нет необходимых данных
fetchPolicy: 'cache-first'
})
// Мемоизируем обработку результатов
const recommendedProducts = useMemo(() => {
if (!data?.getCategoryProductsWithOffers) return [];
return data.getCategoryProductsWithOffers.map((product: any) => ({
brand: product.brand || '',
articleNumber: product.articleNumber || '',
name: product.name || `${product.brand || ''} ${product.articleNumber || ''}`,
artId: product.artId || '',
minPrice: product.minPrice
})).filter((product: any) => product.brand && product.articleNumber); // Фильтруем только валидные
}, [data])
return {
recommendedProducts,
isLoading: loading,
error: error?.message
};
};