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

678
src/pages/search-result.tsx Normal file
View File

@ -0,0 +1,678 @@
import Head from "next/head";
import { useRouter } from "next/router";
import { useEffect, useState, useMemo } from "react";
import { useQuery, useLazyQuery } from "@apollo/client";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import CatalogSubscribe from "@/components/CatalogSubscribe";
import Filters, { FilterConfig } from "@/components/Filters";
import BestPriceCard from "@/components/BestPriceCard";
import CoreProductCard from "@/components/CoreProductCard";
import AnalogueBlock from "@/components/AnalogueBlock";
import InfoSearch from "@/components/InfoSearch";
import FiltersPanelMobile from "@/components/FiltersPanelMobile";
import CatalogSortDropdown from "@/components/CatalogSortDropdown";
import MobileMenuBottomSection from '../components/MobileMenuBottomSection';
import { SEARCH_PRODUCT_OFFERS, GET_ANALOG_OFFERS } from "@/lib/graphql";
import { useArticleImage } from "@/hooks/useArticleImage";
const ANALOGS_CHUNK_SIZE = 5;
const sortOptions = [
"По цене",
"По рейтингу",
"По количеству"
];
// Функция для создания динамических фильтров
const createFilters = (result: any, loadedAnalogs: any): FilterConfig[] => {
const filters: FilterConfig[] = [];
if (result) {
// Фильтр по производителю
const brands = new Set<string>();
brands.add(result.brand);
result.externalOffers?.forEach((offer: any) => {
if (offer.brand) brands.add(offer.brand);
});
result.analogs?.forEach((analog: any) => {
if (analog.brand) brands.add(analog.brand);
});
if (brands.size > 1) {
filters.push({
type: "dropdown",
title: "Производитель",
options: Array.from(brands).sort(),
multi: true,
showAll: true,
});
}
// Фильтр по цене
const prices: number[] = [];
result.internalOffers?.forEach((offer: any) => {
if (offer.price > 0) prices.push(offer.price);
});
result.externalOffers?.forEach((offer: any) => {
if (offer.price > 0) prices.push(offer.price);
});
// Добавляем цены аналогов
Object.values(loadedAnalogs).forEach((analog: any) => {
analog.internalOffers?.forEach((offer: any) => {
if (offer.price > 0) prices.push(offer.price);
});
analog.externalOffers?.forEach((offer: any) => {
if (offer.price > 0) prices.push(offer.price);
});
});
if (prices.length > 0) {
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
if (maxPrice > minPrice) {
filters.push({
type: "range",
title: "Цена (₽)",
min: Math.floor(minPrice),
max: Math.ceil(maxPrice),
});
}
}
// Фильтр по сроку доставки
const deliveryDays: number[] = [];
result.internalOffers?.forEach((offer: any) => {
if (offer.deliveryDays && offer.deliveryDays > 0) deliveryDays.push(offer.deliveryDays);
});
result.externalOffers?.forEach((offer: any) => {
if (offer.deliveryTime && offer.deliveryTime > 0) deliveryDays.push(offer.deliveryTime);
});
// Добавляем сроки доставки аналогов
Object.values(loadedAnalogs).forEach((analog: any) => {
analog.internalOffers?.forEach((offer: any) => {
if (offer.deliveryDays && offer.deliveryDays > 0) deliveryDays.push(offer.deliveryDays);
});
analog.externalOffers?.forEach((offer: any) => {
if (offer.deliveryTime && offer.deliveryTime > 0) deliveryDays.push(offer.deliveryTime);
});
});
if (deliveryDays.length > 0) {
const minDays = Math.min(...deliveryDays);
const maxDays = Math.max(...deliveryDays);
if (maxDays > minDays) {
filters.push({
type: "range",
title: "Срок доставки (дни)",
min: minDays,
max: maxDays,
});
}
}
// Фильтр по количеству наличия
const quantities: number[] = [];
result.internalOffers?.forEach((offer: any) => {
if (offer.quantity && offer.quantity > 0) quantities.push(offer.quantity);
});
result.externalOffers?.forEach((offer: any) => {
if (offer.quantity && offer.quantity > 0) quantities.push(offer.quantity);
});
// Добавляем количества аналогов
Object.values(loadedAnalogs).forEach((analog: any) => {
analog.internalOffers?.forEach((offer: any) => {
if (offer.quantity && offer.quantity > 0) quantities.push(offer.quantity);
});
analog.externalOffers?.forEach((offer: any) => {
if (offer.quantity && offer.quantity > 0) quantities.push(offer.quantity);
});
});
if (quantities.length > 0) {
const minQuantity = Math.min(...quantities);
const maxQuantity = Math.max(...quantities);
if (maxQuantity > minQuantity) {
filters.push({
type: "range",
title: "Количество (шт.)",
min: minQuantity,
max: maxQuantity,
});
}
}
}
return filters;
};
// Функция для получения лучших предложений по заданным критериям
const getBestOffers = (offers: any[]) => {
const validOffers = offers.filter(offer => offer.price > 0 && typeof offer.deliveryDuration !== 'undefined');
if (validOffers.length === 0) return [];
const result: { offer: any; type: string }[] = [];
const usedOfferIds = new Set<string>();
// 1. Самая низкая цена (среди всех предложений)
const lowestPriceOffer = [...validOffers].sort((a, b) => a.price - b.price)[0];
if (lowestPriceOffer) {
result.push({ offer: lowestPriceOffer, type: 'Самая низкая цена' });
usedOfferIds.add(`${lowestPriceOffer.articleNumber}-${lowestPriceOffer.price}-${lowestPriceOffer.deliveryDuration}`);
}
// 2. Самый дешевый аналог (только среди аналогов)
const analogOffers = validOffers.filter(offer => offer.isAnalog);
if (analogOffers.length > 0) {
const cheapestAnalogOffer = [...analogOffers].sort((a, b) => a.price - b.price)[0];
const analogId = `${cheapestAnalogOffer.articleNumber}-${cheapestAnalogOffer.price}-${cheapestAnalogOffer.deliveryDuration}`;
if (!usedOfferIds.has(analogId)) {
result.push({ offer: cheapestAnalogOffer, type: 'Самый дешевый аналог' });
usedOfferIds.add(analogId);
}
}
// 3. Самая быстрая доставка (среди всех предложений)
const fastestDeliveryOffer = [...validOffers].sort((a, b) => a.deliveryDuration - b.deliveryDuration)[0];
if (fastestDeliveryOffer) {
const fastestId = `${fastestDeliveryOffer.articleNumber}-${fastestDeliveryOffer.price}-${fastestDeliveryOffer.deliveryDuration}`;
if (!usedOfferIds.has(fastestId)) {
result.push({ offer: fastestDeliveryOffer, type: 'Самая быстрая доставка' });
}
}
return result;
};
const transformOffersForCard = (offers: any[]) => {
return offers.map(offer => {
const isExternal = offer.type === 'external';
return {
id: offer.id,
productId: offer.productId,
offerKey: offer.offerKey,
pcs: `${offer.quantity} шт.`,
days: `${isExternal ? offer.deliveryTime : offer.deliveryDays} дн.`,
recommended: !isExternal && offer.available,
price: `${offer.price.toLocaleString('ru-RU')}`,
count: "1",
isExternal,
currency: offer.currency || "RUB",
warehouse: offer.warehouse,
supplier: offer.supplier,
deliveryTime: isExternal ? offer.deliveryTime : offer.deliveryDays,
};
});
};
export default function SearchResult() {
const router = useRouter();
const { article, brand, q, artId } = router.query;
const [sortActive, setSortActive] = useState(0);
const [showFiltersMobile, setShowFiltersMobile] = useState(false);
const [showSortMobile, setShowSortMobile] = useState(false);
const [searchQuery, setSearchQuery] = useState<string>("");
const [brandQuery, setBrandQuery] = useState<string>("");
const [loadedAnalogs, setLoadedAnalogs] = useState<{ [key: string]: any }>({});
const [visibleAnalogsCount, setVisibleAnalogsCount] = useState(ANALOGS_CHUNK_SIZE);
// Состояния для фильтров
const [selectedBrands, setSelectedBrands] = useState<string[]>([]);
const [priceRange, setPriceRange] = useState<[number, number] | null>(null);
const [deliveryRange, setDeliveryRange] = useState<[number, number] | null>(null);
const [quantityRange, setQuantityRange] = useState<[number, number] | null>(null);
const [filterSearchTerm, setFilterSearchTerm] = useState<string>('');
useEffect(() => {
if (article && typeof article === 'string') {
setSearchQuery(article.trim().toUpperCase());
}
if (brand && typeof brand === 'string') {
setBrandQuery(brand.trim());
}
setLoadedAnalogs({});
setVisibleAnalogsCount(ANALOGS_CHUNK_SIZE);
}, [article, brand]);
const { data, loading, error } = useQuery(SEARCH_PRODUCT_OFFERS, {
variables: {
articleNumber: searchQuery,
brand: brandQuery || '' // Используем пустую строку если бренд не указан
},
skip: !searchQuery,
errorPolicy: 'all'
});
const { imageUrl: mainImageUrl } = useArticleImage(artId as string, { enabled: !!artId });
const [
getAnalogOffers,
{ loading: analogsLoading, data: analogsData }
] = useLazyQuery(GET_ANALOG_OFFERS, {
onCompleted: (data) => {
if (data && data.getAnalogOffers) {
const newAnalogs = data.getAnalogOffers.reduce((acc: any, analog: any) => {
const key = `${analog.brand}-${analog.articleNumber}`;
// Сразу трансформируем, но пока не используем
// const offers = transformOffersForCard(analog.internalOffers, analog.externalOffers);
acc[key] = { ...analog }; // offers убрали, т.к. allOffers - единый источник
return acc;
}, {});
setLoadedAnalogs(prev => ({ ...prev, ...newAnalogs }));
}
}
});
// Эффект для автоматической загрузки предложений видимых аналогов
useEffect(() => {
if (data?.searchProductOffers?.analogs) {
const analogsToLoad = data.searchProductOffers.analogs
.slice(0, visibleAnalogsCount)
.filter((a: any) => !loadedAnalogs[`${a.brand}-${a.articleNumber}`])
.map((a: any) => ({ brand: a.brand, articleNumber: a.articleNumber }));
if (analogsToLoad.length > 0) {
getAnalogOffers({ variables: { analogs: analogsToLoad } });
}
}
}, [visibleAnalogsCount, data, getAnalogOffers, loadedAnalogs]);
const result = data?.searchProductOffers;
const allOffers = useMemo(() => {
if (!result) return [];
const offers: any[] = [];
// Основной товар
result.internalOffers.forEach((o: any) => offers.push({ ...o, deliveryDuration: o.deliveryDays, type: 'internal', brand: result.brand, articleNumber: result.articleNumber, name: result.name }));
result.externalOffers.forEach((o: any) => offers.push({ ...o, deliveryDuration: o.deliveryTime, type: 'external', articleNumber: o.code, name: o.name }));
// Аналоги
Object.values(loadedAnalogs).forEach((analog: any) => {
analog.internalOffers.forEach((o: any) => offers.push({ ...o, deliveryDuration: o.deliveryDays, type: 'internal', brand: analog.brand, articleNumber: analog.articleNumber, name: analog.name, isAnalog: true }));
analog.externalOffers.forEach((o: any) => offers.push({ ...o, deliveryDuration: o.deliveryTime, type: 'external', brand: o.brand || analog.brand, articleNumber: o.code || analog.articleNumber, name: o.name, isAnalog: true }));
});
return offers;
}, [result, loadedAnalogs]);
const filteredOffers = useMemo(() => {
return allOffers.filter(offer => {
// Фильтр по бренду
if (selectedBrands.length > 0 && !selectedBrands.includes(offer.brand)) {
return false;
}
// Фильтр по цене
if (priceRange && (offer.price < priceRange[0] || offer.price > priceRange[1])) {
return false;
}
// Фильтр по сроку доставки
if (deliveryRange) {
const deliveryDays = offer.deliveryDuration;
if (deliveryDays < deliveryRange[0] || deliveryDays > deliveryRange[1]) {
return false;
}
}
// Фильтр по количеству наличия
if (quantityRange) {
const quantity = offer.quantity;
if (quantity < quantityRange[0] || quantity > quantityRange[1]) {
return false;
}
}
// Фильтр по поисковой строке
if (filterSearchTerm) {
const searchTerm = filterSearchTerm.toLowerCase();
const brandMatch = offer.brand.toLowerCase().includes(searchTerm);
const articleMatch = offer.articleNumber.toLowerCase().includes(searchTerm);
const nameMatch = offer.name.toLowerCase().includes(searchTerm);
if (!brandMatch && !articleMatch && !nameMatch) {
return false;
}
}
return true;
});
}, [allOffers, selectedBrands, priceRange, deliveryRange, quantityRange, filterSearchTerm]);
const handleFilterChange = (type: string, value: any) => {
if (type === 'Производитель') {
setSelectedBrands(value);
} else if (type === 'Цена (₽)') {
setPriceRange(value);
} else if (type === 'Срок доставки (дни)') {
setDeliveryRange(value);
} else if (type === 'Количество (шт.)') {
setQuantityRange(value);
} else if (type === 'search') {
setFilterSearchTerm(value);
}
};
const initialOffersExist = allOffers.length > 0;
const filtersAreActive = selectedBrands.length > 0 || priceRange !== null || deliveryRange !== null || quantityRange !== null || filterSearchTerm !== '';
const hasOffers = result && (result.internalOffers.length > 0 || result.externalOffers.length > 0);
const hasAnalogs = result && result.analogs.length > 0;
const searchResultFilters = createFilters(result, loadedAnalogs);
const bestOffersData = getBestOffers(filteredOffers);
// Если это поиск по параметру q (из UnitDetailsSection), используем q как article
useEffect(() => {
if (q && typeof q === 'string' && !article) {
setSearchQuery(q.trim().toUpperCase());
// Определяем бренд из каталога или используем дефолтный
const catalogFromUrl = router.query.catalog as string;
const vehicleFromUrl = router.query.vehicle as string;
if (catalogFromUrl) {
// Маппинг каталогов к брендам
const catalogToBrandMap: { [key: string]: string } = {
'AU1587': 'AUDI',
'VW1587': 'VOLKSWAGEN',
'BMW1587': 'BMW',
'MB1587': 'MERCEDES-BENZ',
// Добавьте другие маппинги по необходимости
};
setBrandQuery(catalogToBrandMap[catalogFromUrl] || '');
} else {
setBrandQuery('');
}
}
}, [q, article, router.query]);
// Удаляем старую заглушку - теперь обрабатываем все типы поиска
const minPrice = useMemo(() => {
if (result && result.minPrice) return result.minPrice;
if (allOffers.length > 0) {
const prices = allOffers.filter(o => o.price > 0).map(o => o.price);
if (prices.length > 0) {
return `${Math.min(...prices).toLocaleString('ru-RU')}`;
}
}
return "-";
}, [result, allOffers]);
if (loading) {
return (
<>
<Head>
<title>Поиск предложений {searchQuery} - Protek</title>
</Head>
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-red-600 mx-auto"></div>
<p className="mt-4 text-lg text-gray-600">Поиск предложений...</p>
</div>
</main>
<Footer />
</>
);
}
return (
<>
<Head>
<title>{result ? `${result.brand} ${result.articleNumber} - ${result.name}` : `Результаты поиска`} - Protek</title>
<meta name="description" content={`Лучшие предложения и аналоги для ${result?.name}`} />
<meta content="Search result" property="og:title" />
<meta content="Search result" property="twitter:title" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<meta content="Webflow" name="generator" />
<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>
<InfoSearch
brand={result ? result.brand : brandQuery}
articleNumber={result ? result.articleNumber : searchQuery}
name={result ? result.name : "деталь"}
offersCount={result ? result.totalOffers : 0}
minPrice={minPrice}
/>
<section className="main">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-hflex flex-block-84">
{/* <CatalogSortDropdown active={sortActive} onChange={setSortActive} /> */}
<div className="w-layout-hflex flex-block-85" onClick={() => setShowFiltersMobile((v) => !v)}>
<span 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>
</span>
<div>Фильтры</div>
</div>
</div>
</div>
</section>
{/* Мобильная панель фильтров */}
<FiltersPanelMobile
filters={searchResultFilters}
open={showFiltersMobile}
onClose={() => setShowFiltersMobile(false)}
searchQuery={filterSearchTerm}
onSearchChange={(value) => handleFilterChange('search', value)}
/>
{/* Лучшие предложения */}
{bestOffersData.length > 0 && (
<section className="section-6">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-vflex flex-block-36">
{bestOffersData.map(({ offer, type }, index) => (
<BestPriceCard
key={`best-${type}-${index}`}
bestOfferType={type}
title={`${offer.brand} ${offer.articleNumber}${offer.isAnalog ? ' (аналог)' : ''}`}
description={offer.name}
price={`${offer.price.toLocaleString()}`}
delivery={`${offer.deliveryDuration} ${offer.deliveryDuration === 1 ? 'день' : 'дней'}`}
stock={`${offer.quantity} шт.`}
/>
))}
</div>
</div>
</section>
)}
{/* Если нет предложений после фильтрации, но они были изначально */}
{initialOffersExist && filteredOffers.length === 0 && !loading && (
<section>
<div className="w-layout-blockcontainer container w-container">
<div className="text-center py-12">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
Нет предложений, соответствующих вашим фильтрам
</h3>
<p className="text-gray-600">
Попробуйте изменить или сбросить фильтры.
</p>
</div>
</div>
</section>
)}
{/* Если изначально не было предложений */}
{!initialOffersExist && !loading && (
<section>
<div className="w-layout-blockcontainer container w-container">
<div className="text-center py-12">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
К сожалению, предложений по данному артикулу не найдено
</h3>
<p className="text-gray-600 mb-6">
Попробуйте изменить параметры поиска или обратитесь к нашим менеджерам
</p>
<div className="space-y-2">
<p className="text-sm text-gray-500">Телефон: +7 (495) 123-45-67</p>
<p className="text-sm text-gray-500">Email: info@protek.ru</p>
</div>
</div>
</div>
</section>
)}
<section className="main">
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-hflex flex-block-13-copy">
{/* Фильтры для десктопа */}
<div style={{ width: '300px', marginRight: '20px' }}>
<Filters
filters={searchResultFilters}
onFilterChange={handleFilterChange}
filterValues={{
'Производитель': selectedBrands,
'Цена (₽)': priceRange,
'Срок доставки (дни)': deliveryRange,
'Количество (шт.)': quantityRange
}}
searchQuery={filterSearchTerm}
onSearchChange={(value) => handleFilterChange('search', value)}
/>
</div>
{/* Основной товар */}
<div className="w-layout-vflex flex-block-14-copy">
{hasOffers && result && (() => {
const mainProductOffers = transformOffersForCard(
filteredOffers.filter(o => !o.isAnalog)
);
// Не показываем основной товар, если у него нет предложений
if (mainProductOffers.length === 0) {
return null;
}
return (
<>
<CoreProductCard
brand={result.brand}
article={result.articleNumber}
name={result.name}
image={mainImageUrl}
offers={mainProductOffers}
showMoreText={mainProductOffers.length < filteredOffers.filter(o => !o.isAnalog).length ? "Показать еще" : undefined}
/>
</>
);
})()}
{/* Аналоги */}
{hasAnalogs && result && (() => {
// Фильтруем аналоги с предложениями
const analogsWithOffers = result.analogs.slice(0, visibleAnalogsCount).filter((analog: any) => {
const analogKey = `${analog.brand}-${analog.articleNumber}`;
const loadedAnalogData = loadedAnalogs[analogKey];
if (!loadedAnalogData) {
return true; // Показываем загружающиеся аналоги
}
const analogOffers = transformOffersForCard(
filteredOffers.filter(o => o.isAnalog && o.articleNumber === analog.articleNumber)
);
// Показываем аналог только если у него есть предложения
return analogOffers.length > 0;
});
// Если нет аналогов с предложениями, не показываем секцию
if (analogsWithOffers.length === 0) {
return null;
}
return (
<>
<h2 className="heading-11">Аналоги</h2>
{analogsWithOffers.map((analog: any, index: number) => {
const analogKey = `${analog.brand}-${analog.articleNumber}`;
const loadedAnalogData = loadedAnalogs[analogKey];
const analogOffers = loadedAnalogData
? transformOffersForCard(
filteredOffers.filter(o => o.isAnalog && o.articleNumber === analog.articleNumber)
)
: [];
return (
<CoreProductCard
key={analogKey}
brand={analog.brand}
article={analog.articleNumber}
name={analog.name}
offers={analogOffers}
isAnalog
isLoadingOffers={!loadedAnalogData}
/>
)
})}
{(() => {
// Проверяем, есть ли еще аналоги с предложениями для загрузки
const remainingAnalogs = result.analogs.slice(visibleAnalogsCount);
const hasMoreAnalogsWithOffers = remainingAnalogs.some((analog: any) => {
const analogKey = `${analog.brand}-${analog.articleNumber}`;
const loadedAnalogData = loadedAnalogs[analogKey];
if (!loadedAnalogData) {
return true; // Могут быть предложения у незагруженных аналогов
}
const analogOffers = transformOffersForCard(
filteredOffers.filter(o => o.isAnalog && o.articleNumber === analog.articleNumber)
);
return analogOffers.length > 0;
});
return hasMoreAnalogsWithOffers && (
<div className="w-layout-hflex pagination">
<button
onClick={() => setVisibleAnalogsCount(prev => prev + ANALOGS_CHUNK_SIZE)}
disabled={analogsLoading}
className="button_strock w-button"
>
{analogsLoading ? "Загружаем..." : "Показать еще"}
</button>
</div>
);
})()}
</>
);
})()}
</div>
</div>
</div>
</section>
<section className="section-3">
<CatalogSubscribe />
</section>
<Footer />
<MobileMenuBottomSection />
</>
);
}