9 Commits

Author SHA1 Message Date
074eb120b4 Merge remote changes, resolve conflicts in BottomHead.tsx 2025-07-14 10:03:35 +03:00
4dfc081214 Добавлено получение истории поиска с автодополнением в компоненте Header. Обновлены обработчики ввода для управления отображением истории и плейсхолдера. Внедрен запрос для получения последних поисковых запросов. Обновлены стили и логика отображения в компоненте Header. 2025-07-14 10:01:09 +03:00
d95d008c0c Merge pull request 'coolie' (#27) from cookie into main
Reviewed-on: #27
2025-07-14 01:07:32 +03:00
657016731c coolie 2025-07-14 01:06:42 +03:00
87339d577e Удалены временные стили и отладочные логи для навигационных иконок в компоненте BottomHead. Обновлен рендеринг иконок с использованием условного отображения для IMG и SVG. 2025-07-13 21:44:49 +03:00
ad5dcc03e3 Добавлено получение навигационных категорий с иконками и обновление логики отображения иконок в компоненте BottomHead. Обновлены типы данных и стили для навигационных иконок. Оптимизирована загрузка групп для категорий в компоненте BottomHeadPartsIndex. 2025-07-13 21:42:06 +03:00
132e39b87e Merge pull request 'fix1207' (#26) from fix1207 into main
Reviewed-on: #26
2025-07-12 22:40:15 +03:00
e22828039f Merge pull request 'footer' (#25) from footer into main
Reviewed-on: #25
2025-07-12 21:33:32 +03:00
320b7500e0 Merge pull request 'fix1207' (#24) from 1207 into main
Reviewed-on: #24
2025-07-12 18:22:10 +03:00
12 changed files with 1157 additions and 860 deletions

92
package-lock.json generated
View File

@ -16,6 +16,7 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"graphql": "^16.11.0", "graphql": "^16.11.0",
"next": "15.3.3", "next": "15.3.3",
"node-fetch": "^3.3.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -1540,6 +1541,15 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/decamelize": { "node_modules/decamelize": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
@ -1602,6 +1612,29 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@ -1615,6 +1648,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -2140,6 +2185,44 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@ -2690,6 +2773,15 @@
"uuid": "dist/esm/bin/uuid" "uuid": "dist/esm/bin/uuid"
} }
}, },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/which-module": { "node_modules/which-module": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",

View File

@ -17,6 +17,7 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"graphql": "^16.11.0", "graphql": "^16.11.0",
"next": "15.3.3", "next": "15.3.3",
"node-fetch": "^3.3.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@ -1,11 +1,12 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useQuery } from '@apollo/client'; import { useQuery } from '@apollo/client';
import { GET_PARTSINDEX_CATEGORIES } from '@/lib/graphql'; import { GET_PARTSINDEX_CATEGORIES, GET_NAVIGATION_CATEGORIES } from '@/lib/graphql';
import { PartsIndexCatalogsData, PartsIndexCatalogsVariables, PartsIndexCatalog } from '@/types/partsindex'; import { PartsIndexCatalogsData, PartsIndexCatalogsVariables, PartsIndexCatalog } from '@/types/partsindex';
import { NavigationCategory } from '@/types';
function useIsMobile(breakpoint = 767) { function useIsMobile(breakpoint = 767) {
const [isMobile, setIsMobile] = React.useState(false); const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
const check = () => setIsMobile(window.innerWidth <= breakpoint); const check = () => setIsMobile(window.innerWidth <= breakpoint);
@ -14,12 +15,12 @@ function useIsMobile(breakpoint = 767) {
return () => window.removeEventListener("resize", check); return () => window.removeEventListener("resize", check);
}, [breakpoint]); }, [breakpoint]);
return isMobile; return isMobile;
} }
// Fallback статичные данные // Fallback статичные данные
const fallbackTabData = [ const fallbackTabData = [
{ {
label: "Оригинальные каталоги", label: "Оригинальные каталоги",
heading: "Оригинальные каталоги", heading: "Оригинальные каталоги",
@ -65,10 +66,10 @@ const fallbackTabData = [
"Промывочные жидкости", "Промывочные жидкости",
], ],
}, },
]; ];
// Преобразуем данные PartsIndex в формат нашего меню // Преобразуем данные PartsIndex в формат нашего меню
const transformPartsIndexToTabData = (catalogs: PartsIndexCatalog[]) => { const transformPartsIndexToTabData = (catalogs: PartsIndexCatalog[]) => {
console.log('🔄 Преобразуем каталоги PartsIndex:', catalogs.length, 'элементов'); console.log('🔄 Преобразуем каталоги PartsIndex:', catalogs.length, 'элементов');
const transformed = catalogs.map(catalog => { const transformed = catalogs.map(catalog => {
@ -109,9 +110,25 @@ const transformPartsIndexToTabData = (catalogs: PartsIndexCatalog[]) => {
console.log('✅ Преобразование завершено:', transformed.length, 'табов'); console.log('✅ Преобразование завершено:', transformed.length, 'табов');
return transformed; return transformed;
}; };
const BottomHead = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => void }) => { // Функция для поиска иконки для категории
const findCategoryIcon = (catalogId: string, navigationCategories: NavigationCategory[]): string | null => {
console.log('🔍 Ищем иконку для catalogId:', catalogId);
console.log('📋 Доступные навигационные категории:', navigationCategories);
// Ищем навигационную категорию для данного каталога (без группы)
const categoryIcon = navigationCategories.find(
nav => nav.partsIndexCatalogId === catalogId && (!nav.partsIndexGroupId || nav.partsIndexGroupId === '')
);
console.log('🎯 Найденная категория:', categoryIcon);
console.log('🖼️ Возвращаемая иконка:', categoryIcon?.icon || null);
return categoryIcon?.icon || null;
};
const BottomHead = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => void }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const router = useRouter(); const router = useRouter();
const [mobileCategory, setMobileCategory] = useState<null | any>(null); const [mobileCategory, setMobileCategory] = useState<null | any>(null);
@ -155,6 +172,20 @@ const BottomHead = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => v
} }
); );
// Получаем навигационные категории с иконками
const { data: navigationData, loading: navigationLoading, error: navigationError } = useQuery<{ navigationCategories: NavigationCategory[] }>(
GET_NAVIGATION_CATEGORIES,
{
errorPolicy: 'all',
onCompleted: (data) => {
console.log('🎉 Навигационные категории получены:', data);
},
onError: (error) => {
console.error('❌ Ошибка загрузки навигационных категорий:', error);
}
}
);
// Обновляем данные табов когда получаем данные от API // Обновляем данные табов когда получаем данные от API
useEffect(() => { useEffect(() => {
if (catalogsData?.partsIndexCategoriesWithGroups && catalogsData.partsIndexCategoriesWithGroups.length > 0) { if (catalogsData?.partsIndexCategoriesWithGroups && catalogsData.partsIndexCategoriesWithGroups.length > 0) {
@ -356,7 +387,14 @@ const BottomHead = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => v
<div className="w-layout-hflex flex-block-90"> <div className="w-layout-hflex flex-block-90">
<div className="w-layout-vflex flex-block-88" style={{ maxHeight: "60vh", overflowY: "auto" }}> <div className="w-layout-vflex flex-block-88" style={{ maxHeight: "60vh", overflowY: "auto" }}>
{/* Меню с иконками - показываем все категории из API */} {/* Меню с иконками - показываем все категории из API */}
{tabData.map((tab, idx) => ( {tabData.map((tab, idx) => {
// Получаем catalogId для поиска иконки
const catalogId = catalogsData?.partsIndexCategoriesWithGroups?.[idx]?.id || `fallback_${idx}`;
console.log(`🏷️ Обрабатываем категорию ${idx}: "${tab.label}" с catalogId: "${catalogId}"`);
const icon = navigationData?.navigationCategories ? findCategoryIcon(catalogId, navigationData.navigationCategories) : null;
console.log(`🎨 Для категории "${tab.label}" будет показана ${icon ? 'иконка: ' + icon : 'звездочка (fallback)'}`);
return (
<a <a
href="#" href="#"
className={`link-block-7 w-inline-block${activeTabIndex === idx ? " w--current" : ""}`} className={`link-block-7 w-inline-block${activeTabIndex === idx ? " w--current" : ""}`}
@ -368,15 +406,30 @@ const BottomHead = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => v
> >
<div className="div-block-29"> <div className="div-block-29">
<div className="code-embed-12 w-embed"> <div className="code-embed-12 w-embed">
{/* SVG-звезда */} {icon ? (
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <img
src={icon}
alt={tab.label}
width="21"
height="20"
/>
) : (
<svg
width="21"
height="20"
viewBox="0 0 21 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M10.3158 0.643914C10.4674 0.365938 10.8666 0.365938 11.0182 0.643914L14.0029 6.11673C14.0604 6.22222 14.1623 6.29626 14.2804 6.31838L20.4077 7.46581C20.7189 7.52409 20.8423 7.9037 20.6247 8.13378L16.3421 12.6636C16.2595 12.7509 16.2206 12.8707 16.2361 12.9899L17.0382 19.1718C17.079 19.4858 16.7561 19.7204 16.47 19.5847L10.8385 16.9114C10.73 16.8599 10.604 16.8599 10.4955 16.9114L4.86394 19.5847C4.5779 19.7204 4.25499 19.4858 4.29573 19.1718L5.0979 12.9899C5.11336 12.8707 5.07444 12.7509 4.99189 12.6636L0.709252 8.13378C0.491728 7.9037 0.615069 7.52409 0.926288 7.46581L7.05357 6.31838C7.17168 6.29626 7.27358 6.22222 7.33112 6.11673L10.3158 0.643914Z" fill="CurrentColor"></path> <path d="M10.3158 0.643914C10.4674 0.365938 10.8666 0.365938 11.0182 0.643914L14.0029 6.11673C14.0604 6.22222 14.1623 6.29626 14.2804 6.31838L20.4077 7.46581C20.7189 7.52409 20.8423 7.9037 20.6247 8.13378L16.3421 12.6636C16.2595 12.7509 16.2206 12.8707 16.2361 12.9899L17.0382 19.1718C17.079 19.4858 16.7561 19.7204 16.47 19.5847L10.8385 16.9114C10.73 16.8599 10.604 16.8599 10.4955 16.9114L4.86394 19.5847C4.5779 19.7204 4.25499 19.4858 4.29573 19.1718L5.0979 12.9899C5.11336 12.8707 5.07444 12.7509 4.99189 12.6636L0.709252 8.13378C0.491728 7.9037 0.615069 7.52409 0.926288 7.46581L7.05357 6.31838C7.17168 6.29626 7.27358 6.22222 7.33112 6.11673L10.3158 0.643914Z" fill="CurrentColor"></path>
</svg> </svg>
)}
</div> </div>
</div> </div>
<div className="text-block-47">{tab.label}</div> <div className="text-block-47">{tab.label}</div>
</a> </a>
))} );
})}
</div> </div>
{/* Правая часть меню с подкатегориями и картинками */} {/* Правая часть меню с подкатегориями и картинками */}
<div className="w-layout-vflex flex-block-89"> <div className="w-layout-vflex flex-block-89">
@ -540,6 +593,6 @@ const BottomHead = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => v
</nav> </nav>
</> </>
); );
}; };
export default BottomHead; export default BottomHead;

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useQuery, useLazyQuery } from '@apollo/client';
import { GET_PARTSINDEX_CATEGORIES } from '@/lib/graphql';
function useIsMobile(breakpoint = 767) { function useIsMobile(breakpoint = 767) {
const [isMobile, setIsMobile] = React.useState(false); const [isMobile, setIsMobile] = React.useState(false);
@ -13,35 +14,38 @@ function useIsMobile(breakpoint = 767) {
return isMobile; return isMobile;
} }
// Типы для Parts Index API // Типы данных
interface PartsIndexCatalog {
id: string;
name: string;
image: string;
}
interface PartsIndexEntityName {
id: string;
name: string;
}
interface PartsIndexGroup {
id: string;
name: string;
lang: string;
image: string;
lft: number;
rgt: number;
entityNames: PartsIndexEntityName[];
subgroups: PartsIndexGroup[];
}
interface PartsIndexTabData { interface PartsIndexTabData {
label: string; label: string;
heading: string; heading: string;
links: string[]; links: string[];
catalogId: string; catalogId: string;
group?: PartsIndexGroup; group?: any;
groupsLoaded?: boolean; // флаг что группы загружены
}
interface PartsIndexCatalog {
id: string;
name: string;
image?: string;
groups?: PartsIndexGroup[];
}
interface PartsIndexGroup {
id: string;
name: string;
image?: string;
entityNames?: { id: string; name: string }[];
subgroups?: { id: string; name: string }[];
}
// GraphQL типы
interface PartsIndexCatalogsData {
partsIndexCategoriesWithGroups: PartsIndexCatalog[];
}
interface PartsIndexCatalogsVariables {
lang?: 'ru' | 'en';
} }
// Fallback статичные данные // Fallback статичные данные
@ -51,57 +55,66 @@ const fallbackTabData: PartsIndexTabData[] = [
heading: "Детали ТО", heading: "Детали ТО",
catalogId: "parts_to", catalogId: "parts_to",
links: ["Детали ТО"], links: ["Детали ТО"],
groupsLoaded: false,
}, },
{ {
label: "Масла", label: "Масла",
heading: "Масла", heading: "Масла",
catalogId: "oils", catalogId: "oils",
links: ["Масла"], links: ["Масла"],
groupsLoaded: false,
}, },
{ {
label: "Шины", label: "Шины",
heading: "Шины", heading: "Шины",
catalogId: "tyres", catalogId: "tyres",
links: ["Шины"], links: ["Шины"],
groupsLoaded: false,
}, },
]; ];
// Сервис для работы с Parts Index API // Создаем базовые табы только с названиями каталогов
const PARTS_INDEX_API_BASE = 'https://api.parts-index.com'; const createBaseTabData = (catalogs: PartsIndexCatalog[]): PartsIndexTabData[] => {
const API_KEY = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE'; console.log('🔄 Создаем базовые табы из каталогов:', catalogs.length, 'элементов');
async function fetchCatalogs(): Promise<PartsIndexCatalog[]> { return catalogs.map(catalog => ({
try { label: catalog.name,
const response = await fetch(`${PARTS_INDEX_API_BASE}/v1/catalogs?lang=ru`, { heading: catalog.name,
headers: { 'Accept': 'application/json' }, links: [catalog.name], // Изначально показываем только название каталога
catalogId: catalog.id,
groupsLoaded: false, // Группы еще не загружены
}));
};
// Преобразуем данные PartsIndex в формат нашего меню с группами
const transformPartsIndexToTabData = (catalog: PartsIndexCatalog): string[] => {
console.log(`📝 Обрабатываем группы каталога: "${catalog.name}"`);
let links: string[] = [];
if (catalog.groups && catalog.groups.length > 0) {
// Для каждой группы проверяем есть ли подгруппы
catalog.groups.forEach(group => {
if (group.subgroups && group.subgroups.length > 0) {
// Если есть подгруппы, добавляем их названия
links.push(...group.subgroups.slice(0, 9 - links.length).map(subgroup => subgroup.name));
} else {
// Если подгрупп нет, добавляем название самой группы
if (links.length < 9) {
links.push(group.name);
}
}
}); });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
return data.list;
} catch (error) {
console.error('Ошибка получения каталогов Parts Index:', error);
return [];
} }
}
async function fetchCatalogGroup(catalogId: string): Promise<PartsIndexGroup | null> { // Если подкатегорий нет, показываем название категории
try { if (links.length === 0) {
const response = await fetch( links = [catalog.name];
`${PARTS_INDEX_API_BASE}/v1/catalogs/${catalogId}/groups?lang=ru`,
{
headers: {
'Accept': 'application/json',
'Authorization': API_KEY,
},
} }
);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); console.log(`🔗 Подкатегории для "${catalog.name}":`, links);
return await response.json(); return links.slice(0, 9); // Ограничиваем максимум 9 элементов
} catch (error) { };
console.error(`Ошибка получения группы каталога ${catalogId}:`, error);
return null;
}
}
const BottomHeadPartsIndex = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => void }) => { const BottomHeadPartsIndex = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => void }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -109,7 +122,7 @@ const BottomHeadPartsIndex = ({ menuOpen, onClose }: { menuOpen: boolean; onClos
const [mobileCategory, setMobileCategory] = useState<null | any>(null); const [mobileCategory, setMobileCategory] = useState<null | any>(null);
const [tabData, setTabData] = useState<PartsIndexTabData[]>(fallbackTabData); const [tabData, setTabData] = useState<PartsIndexTabData[]>(fallbackTabData);
const [activeTabIndex, setActiveTabIndex] = useState(0); const [activeTabIndex, setActiveTabIndex] = useState(0);
const [loading, setLoading] = useState(false); const [loadingGroups, setLoadingGroups] = useState<Set<number>>(new Set());
// Пагинация категорий // Пагинация категорий
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
@ -126,52 +139,116 @@ const BottomHeadPartsIndex = ({ menuOpen, onClose }: { menuOpen: boolean; onClos
} }
}, [menuOpen]); }, [menuOpen]);
// Загрузка каталогов и их групп // Получаем только каталоги PartsIndex (без групп для начальной загрузки)
const { data: catalogsData, loading, error } = useQuery<PartsIndexCatalogsData, PartsIndexCatalogsVariables>(
GET_PARTSINDEX_CATEGORIES,
{
variables: {
lang: 'ru'
},
errorPolicy: 'all',
fetchPolicy: 'cache-first', // Используем кэширование агрессивно
nextFetchPolicy: 'cache-first', // Продолжаем использовать кэш
notifyOnNetworkStatusChange: false,
onCompleted: (data) => {
console.log('🎉 PartsIndex каталоги получены через GraphQL (базовые):', data);
},
onError: (error) => {
console.error('❌ Ошибка загрузки PartsIndex каталогов:', error);
}
}
);
// Ленивый запрос для загрузки групп конкретного каталога
const [loadCatalogGroups, { loading: groupsLoading }] = useLazyQuery<PartsIndexCatalogsData, PartsIndexCatalogsVariables>(
GET_PARTSINDEX_CATEGORIES,
{
errorPolicy: 'all',
fetchPolicy: 'cache-first',
nextFetchPolicy: 'cache-first',
notifyOnNetworkStatusChange: false,
onCompleted: (data) => {
console.log('🎉 Группы каталога загружены:', data);
},
onError: (error) => {
console.error('❌ Ошибка загрузки групп каталога:', error);
}
}
);
// Обновляем базовые данные табов когда получаем каталоги
useEffect(() => { useEffect(() => {
const loadData = async () => { if (catalogsData?.partsIndexCategoriesWithGroups && catalogsData.partsIndexCategoriesWithGroups.length > 0) {
if (tabData === fallbackTabData) { // Загружаем только если еще не загружали console.log('✅ Обновляем базовое меню PartsIndex:', catalogsData.partsIndexCategoriesWithGroups.length, 'каталогов');
setLoading(true);
try {
console.log('🔄 Загружаем каталоги Parts Index...');
const catalogs = await fetchCatalogs();
if (catalogs.length > 0) { const baseTabData = createBaseTabData(catalogsData.partsIndexCategoriesWithGroups);
console.log(`✅ Получено ${catalogs.length} каталогов`); setTabData(baseTabData);
setActiveTabIndex(0);
// Загружаем группы для первых нескольких каталогов } else if (error) {
const catalogsToLoad = catalogs.slice(0, 10); console.warn('⚠️ Используем fallback данные из-за ошибки PartsIndex:', error);
const tabDataPromises = catalogsToLoad.map(async (catalog) => { setTabData(fallbackTabData);
const group = await fetchCatalogGroup(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
};
});
const apiTabData = await Promise.all(tabDataPromises);
console.log('✅ Данные обновлены:', apiTabData.length, 'категорий');
setTabData(apiTabData as PartsIndexTabData[]);
setActiveTabIndex(0); setActiveTabIndex(0);
} }
} catch (error) { }, [catalogsData, error]);
console.error('Ошибка загрузки данных Parts Index:', error);
} finally { // Функция для ленивой загрузки групп при наведении на таб
setLoading(false); const loadGroupsForTab = async (tabIndex: number) => {
const tab = tabData[tabIndex];
if (!tab || tab.groupsLoaded || loadingGroups.has(tabIndex)) {
return; // Группы уже загружены или загружаются
} }
console.log('🔄 Загружаем группы для каталога:', tab.catalogId);
setLoadingGroups(prev => new Set([...prev, tabIndex]));
try {
const result = await loadCatalogGroups({
variables: {
lang: 'ru'
}
});
if (result.data?.partsIndexCategoriesWithGroups) {
const catalog = result.data.partsIndexCategoriesWithGroups.find(c => c.id === tab.catalogId);
if (catalog) {
const links = transformPartsIndexToTabData(catalog);
// Обновляем конкретный таб с загруженными группами
setTabData(prevTabs => {
const newTabs = [...prevTabs];
newTabs[tabIndex] = {
...newTabs[tabIndex],
links,
group: catalog.groups?.[0],
groupsLoaded: true
};
return newTabs;
});
}
}
} catch (error) {
console.error('Ошибка загрузки групп для каталога:', tab.catalogId, error);
} finally {
setLoadingGroups(prev => {
const newSet = new Set(prev);
newSet.delete(tabIndex);
return newSet;
});
} }
}; };
loadData(); // Обработчик наведения на таб - загружаем группы
}, []); const handleTabHover = (tabIndex: number) => {
loadGroupsForTab(tabIndex);
};
// Обработчик клика на таб
const handleTabClick = (tabIndex: number) => {
setActiveTabIndex(tabIndex);
// Загружаем группы если еще не загружены
loadGroupsForTab(tabIndex);
};
// Обработка клика по категории для перехода в каталог // Обработка клика по категории для перехода в каталог
const handleCategoryClick = (catalogId: string, categoryName: string, entityId?: string) => { const handleCategoryClick = (catalogId: string, categoryName: string, entityId?: string) => {
@ -184,7 +261,7 @@ const BottomHeadPartsIndex = ({ menuOpen, onClose }: { menuOpen: boolean; onClos
query: { query: {
partsIndexCatalog: catalogId, partsIndexCatalog: catalogId,
categoryName: encodeURIComponent(categoryName), categoryName: encodeURIComponent(categoryName),
...(entityId && { entityId }) ...(entityId && { partsIndexCategory: entityId })
} }
}); });
}; };
@ -294,6 +371,12 @@ const BottomHeadPartsIndex = ({ menuOpen, onClose }: { menuOpen: boolean; onClos
className="mobile-subcategory" className="mobile-subcategory"
key={cat.catalogId} key={cat.catalogId}
onClick={() => { onClick={() => {
// Загружаем группы для категории если нужно
const catIndex = tabData.findIndex(tab => tab.catalogId === cat.catalogId);
if (catIndex !== -1) {
loadGroupsForTab(catIndex);
}
const categoryWithData = { const categoryWithData = {
...cat, ...cat,
catalogId: cat.catalogId, catalogId: cat.catalogId,
@ -304,6 +387,9 @@ const BottomHeadPartsIndex = ({ menuOpen, onClose }: { menuOpen: boolean; onClos
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
{cat.label} {cat.label}
{loadingGroups.has(tabData.findIndex(tab => tab.catalogId === cat.catalogId)) && (
<span className="text-xs text-gray-500 ml-2">(загрузка...)</span>
)}
</div> </div>
))} ))}
</div> </div>
@ -367,9 +453,8 @@ const BottomHeadPartsIndex = ({ menuOpen, onClose }: { menuOpen: boolean; onClos
href="#" href="#"
className={`link-block-7 w-inline-block${activeTabIndex === idx ? " w--current" : ""}`} className={`link-block-7 w-inline-block${activeTabIndex === idx ? " w--current" : ""}`}
key={tab.catalogId} key={tab.catalogId}
onClick={() => { onClick={() => handleTabClick(idx)}
setActiveTabIndex(idx); onMouseEnter={() => handleTabHover(idx)}
}}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
<div className="div-block-29"> <div className="div-block-29">
@ -388,6 +473,7 @@ const BottomHeadPartsIndex = ({ menuOpen, onClose }: { menuOpen: boolean; onClos
<h3 className="heading-16"> <h3 className="heading-16">
{currentPageCategories[activeTabIndex]?.heading || currentPageCategories[0]?.heading} {currentPageCategories[activeTabIndex]?.heading || currentPageCategories[0]?.heading}
{loading && <span className="text-sm text-gray-500 ml-2">(обновление...)</span>} {loading && <span className="text-sm text-gray-500 ml-2">(обновление...)</span>}
{loadingGroups.has(activeTabIndex) && <span className="text-sm text-gray-500 ml-2">(загрузка групп...)</span>}
</h3> </h3>
<div className="w-layout-hflex flex-block-92"> <div className="w-layout-hflex flex-block-92">
<div className="w-layout-vflex flex-block-91"> <div className="w-layout-vflex flex-block-91">

View File

@ -1,270 +1,64 @@
import React, { useState, useEffect } from 'react'; import * as React from "react";
import { CookiePreferences, initializeAnalytics, initializeMarketing } from '@/lib/cookie-utils';
interface CookieConsentProps { const CookieConsent: React.FC = () => {
onAccept?: () => void; const [isVisible, setIsVisible] = React.useState(false);
onDecline?: () => void;
onConfigure?: (preferences: CookiePreferences) => void;
}
const CookieConsent: React.FC<CookieConsentProps> = ({ onAccept, onDecline, onConfigure }) => { React.useEffect(() => {
const [isVisible, setIsVisible] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [preferences, setPreferences] = useState<CookiePreferences>({
necessary: true, // Всегда включены
analytics: false,
marketing: false,
functional: false,
});
useEffect(() => {
// Проверяем, есть ли уже согласие в localStorage
const cookieConsent = localStorage.getItem('cookieConsent'); const cookieConsent = localStorage.getItem('cookieConsent');
if (!cookieConsent) { if (!cookieConsent) {
setIsVisible(true); setIsVisible(true);
} }
}, []); }, []);
const handleAcceptAll = () => { const handleAccept = () => {
const allAccepted = {
necessary: true,
analytics: true,
marketing: true,
functional: true,
};
localStorage.setItem('cookieConsent', 'accepted'); localStorage.setItem('cookieConsent', 'accepted');
localStorage.setItem('cookiePreferences', JSON.stringify(allAccepted));
// Инициализируем сервисы после согласия
initializeAnalytics();
initializeMarketing();
setIsVisible(false); setIsVisible(false);
onAccept?.();
};
const handleDeclineAll = () => {
const onlyNecessary = {
necessary: true,
analytics: false,
marketing: false,
functional: false,
};
localStorage.setItem('cookieConsent', 'declined');
localStorage.setItem('cookiePreferences', JSON.stringify(onlyNecessary));
setIsVisible(false);
onDecline?.();
};
const handleSavePreferences = () => {
localStorage.setItem('cookieConsent', 'configured');
localStorage.setItem('cookiePreferences', JSON.stringify(preferences));
// Инициализируем сервисы согласно настройкам
if (preferences.analytics) {
initializeAnalytics();
}
if (preferences.marketing) {
initializeMarketing();
}
setIsVisible(false);
onConfigure?.(preferences);
};
const togglePreference = (key: keyof CookiePreferences) => {
if (key === 'necessary') return; // Необходимые cookies нельзя отключить
setPreferences(prev => ({
...prev,
[key]: !prev[key]
}));
}; };
if (!isVisible) return null; if (!isVisible) return null;
return ( return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 shadow-lg cookie-consent-enter"> <>
<div className="max-w-7xl mx-auto p-6 max-md:p-4"> <link
{!showDetails ? ( href="https://fonts.googleapis.com/css2?family=Onest:wght@400;500;600;700&display=swap"
// Основной вид rel="stylesheet"
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> />
{/* Текст согласия */} <div
<div className="flex-1"> layer-name="cookie"
<div className="flex items-start gap-3"> className="box-border flex gap-16 justify-between items-center px-16 py-10 mx-auto my-0 w-full bg-white rounded-3xl shadow-sm max-w-[1240px] max-md:flex-col max-md:gap-10 max-md:px-10 max-md:py-8 max-md:text-center max-sm:gap-5 max-sm:p-5 max-sm:rounded-2xl fixed bottom-6 left-1/2 -translate-x-1/2 z-5000"
{/* Иконка cookie */} >
<div className="flex-shrink-0 mt-1"> <div
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="text-gray-600"> layer-name="Мы используем cookie-файлы, чтобы получить статистику, которая помогает нам улучшать сайт для Вас. Нажимая Принять, вы даёте согласие на использование ваших cookie-файлов. Подробнее о том, как мы используем ваши персональные данные, в нашей Политике обработки персональных данных."
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1.5 3.5c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm3 2c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm-6 1c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm2.5 3c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm4.5-1c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm-2 4c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm-3.5-2c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1z" fill="currentColor"/> className="flex-1 text-base font-medium leading-5 text-red-600 max-w-[933px] max-md:max-w-full max-sm:text-sm"
</svg> >
</div> <span className="text-base text-gray-600">
Мы используем cookie-файлы, чтобы получить статистику, которая
<div> помогает нам улучшать сайт для Вас. Нажимая Принять, вы даёте
<h3 className="text-lg font-semibold text-gray-950 mb-2"> согласие на использование ваших cookie-файлов. Подробнее о том, как
Мы используем файлы cookie мы используем ваши персональные данные, в нашей{' '}
</h3> </span>
<p className="text-sm text-gray-600 leading-relaxed">
Наш сайт использует файлы cookie для улучшения работы сайта, персонализации контента и анализа трафика.
Продолжая использовать сайт, вы соглашаетесь с нашей{' '}
<a <a
href="/privacy-policy" href="/privacy-policy"
className="text-red-600 hover:text-red-700 underline" className="text-base text-red-600 underline hover:text-red-700"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
политикой конфиденциальности Политике обработки персональных данных.
</a> </a>
{' '}и использованием файлов cookie.
</p>
</div> </div>
</div>
</div>
{/* Кнопки */}
<div className="flex flex-col sm:flex-row gap-3 md:flex-shrink-0">
<button <button
onClick={() => setShowDetails(true)} onClick={handleAccept}
className="px-6 py-3 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors duration-200 min-w-[120px]" className="box-border flex gap-5 justify-center items-center px-8 py-4 bg-red-600 hover:bg-red-700 rounded-xl h-[51px] min-w-[126px] max-md:w-full max-md:max-w-[200px] max-sm:px-5 max-sm:py-3.5 max-sm:w-full max-sm:h-auto focus:outline-none focus:ring-2 focus:ring-red-400 transition-colors duration-200"
> >
Настроить <span
</button> layer-name="Принять"
<button className="text-base font-semibold leading-5 text-center text-white max-sm:text-sm"
onClick={handleDeclineAll}
className="px-6 py-3 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors duration-200 min-w-[120px]"
> >
Отклонить Принять
</button> </span>
<button
onClick={handleAcceptAll}
className="px-6 py-3 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors duration-200 min-w-[120px]"
>
Принять все
</button> </button>
</div> </div>
</div> </>
) : (
// Детальный вид с настройками
<div className="space-y-6">
{/* Заголовок */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-950">
Настройки файлов cookie
</h3>
<button
onClick={() => setShowDetails(false)}
className="text-gray-500 hover:text-gray-700 p-1"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M15 5L5 15M5 5l10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{/* Настройки cookies */}
<div className="space-y-4">
{/* Необходимые cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="font-medium text-gray-950">Необходимые cookies</h4>
<span className="text-xs px-2 py-1 bg-gray-200 text-gray-600 rounded">Обязательные</span>
</div>
<p className="text-sm text-gray-600">
Эти файлы cookie необходимы для работы сайта и не могут быть отключены.
</p>
</div>
<div className="flex-shrink-0 ml-4">
<div className="w-12 h-6 bg-red-600 rounded-full flex items-center justify-end px-1">
<div className="w-4 h-4 bg-white rounded-full"></div>
</div>
</div>
</div>
{/* Аналитические cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex-1">
<h4 className="font-medium text-gray-950 mb-2">Аналитические cookies</h4>
<p className="text-sm text-gray-600">
Помогают нам понять, как посетители взаимодействуют с сайтом.
</p>
</div>
<div className="flex-shrink-0 ml-4">
<button
onClick={() => togglePreference('analytics')}
className={`w-12 h-6 rounded-full flex items-center transition-colors duration-200 ${
preferences.analytics ? 'bg-red-600 justify-end' : 'bg-gray-300 justify-start'
} px-1`}
>
<div className="w-4 h-4 bg-white rounded-full"></div>
</button>
</div>
</div>
{/* Маркетинговые cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex-1">
<h4 className="font-medium text-gray-950 mb-2">Маркетинговые cookies</h4>
<p className="text-sm text-gray-600">
Используются для отслеживания посетителей и показа релевантной рекламы.
</p>
</div>
<div className="flex-shrink-0 ml-4">
<button
onClick={() => togglePreference('marketing')}
className={`w-12 h-6 rounded-full flex items-center transition-colors duration-200 ${
preferences.marketing ? 'bg-red-600 justify-end' : 'bg-gray-300 justify-start'
} px-1`}
>
<div className="w-4 h-4 bg-white rounded-full"></div>
</button>
</div>
</div>
{/* Функциональные cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex-1">
<h4 className="font-medium text-gray-950 mb-2">Функциональные cookies</h4>
<p className="text-sm text-gray-600">
Обеспечивают расширенную функциональность и персонализацию.
</p>
</div>
<div className="flex-shrink-0 ml-4">
<button
onClick={() => togglePreference('functional')}
className={`w-12 h-6 rounded-full flex items-center transition-colors duration-200 ${
preferences.functional ? 'bg-red-600 justify-end' : 'bg-gray-300 justify-start'
} px-1`}
>
<div className="w-4 h-4 bg-white rounded-full"></div>
</button>
</div>
</div>
</div>
{/* Кнопки действий */}
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-gray-200">
<button
onClick={handleDeclineAll}
className="px-6 py-3 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors duration-200 flex-1 sm:flex-initial min-w-[120px]"
>
Только необходимые
</button>
<button
onClick={handleSavePreferences}
className="px-6 py-3 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors duration-200 flex-1 sm:flex-initial min-w-[120px]"
>
Сохранить настройки
</button>
<button
onClick={handleAcceptAll}
className="px-6 py-3 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors duration-200 flex-1 sm:flex-initial min-w-[120px]"
>
Принять все
</button>
</div>
</div>
)}
</div>
</div>
); );
}; };

View File

@ -9,6 +9,8 @@ import { FIND_LAXIMO_VEHICLE, DOC_FIND_OEM, FIND_LAXIMO_VEHICLE_BY_PLATE_GLOBAL,
import { LaximoVehicleSearchResult, LaximoDocFindOEMResult, LaximoVehiclesByPartResult } from '@/types/laximo'; import { LaximoVehicleSearchResult, LaximoDocFindOEMResult, LaximoVehiclesByPartResult } from '@/types/laximo';
import Link from "next/link"; import Link from "next/link";
import CartButton from './CartButton'; import CartButton from './CartButton';
import SearchHistoryDropdown from './SearchHistoryDropdown';
import { GET_RECENT_SEARCH_QUERIES, PartsSearchHistoryItem } from '@/lib/graphql/search-history';
interface HeaderProps { interface HeaderProps {
onOpenAuthModal?: () => void; onOpenAuthModal?: () => void;
@ -25,9 +27,14 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
const [vehiclesByPartResults, setVehiclesByPartResults] = useState<LaximoVehiclesByPartResult | null>(null); const [vehiclesByPartResults, setVehiclesByPartResults] = useState<LaximoVehiclesByPartResult | null>(null);
const [searchType, setSearchType] = useState<'vin' | 'oem' | 'plate' | 'text'>('text'); const [searchType, setSearchType] = useState<'vin' | 'oem' | 'plate' | 'text'>('text');
const [oemSearchMode, setOemSearchMode] = useState<'parts' | 'vehicles'>('parts'); const [oemSearchMode, setOemSearchMode] = useState<'parts' | 'vehicles'>('parts');
const [showSearchHistory, setShowSearchHistory] = useState(false);
const [searchHistoryItems, setSearchHistoryItems] = useState<PartsSearchHistoryItem[]>([]);
const [inputFocused, setInputFocused] = useState(false);
const [showPlaceholder, setShowPlaceholder] = useState(true);
const router = useRouter(); const router = useRouter();
const searchFormRef = useRef<HTMLFormElement>(null); const searchFormRef = useRef<HTMLFormElement>(null);
const searchDropdownRef = useRef<HTMLDivElement>(null); const searchDropdownRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const isClient = useIsClient(); const isClient = useIsClient();
// Эффект для восстановления поискового запроса из URL // Эффект для восстановления поискового запроса из URL
@ -111,11 +118,28 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
} }
}); });
// Запрос для получения истории поиска
const [getSearchHistory, { loading: historyLoading }] = useLazyQuery(GET_RECENT_SEARCH_QUERIES, {
onCompleted: (data) => {
setSearchHistoryItems(data.partsSearchHistory?.items || []);
},
onError: (error) => {
console.error('❌ Ошибка загрузки истории поиска:', error);
setSearchHistoryItems([]);
}
});
// Закрытие результатов при клике вне области // Закрытие результатов при клике вне области
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (searchDropdownRef.current && !searchDropdownRef.current.contains(event.target as Node)) { if (searchDropdownRef.current && !searchDropdownRef.current.contains(event.target as Node)) {
setShowResults(false); setShowResults(false);
setShowSearchHistory(false);
setInputFocused(false);
// Показываем placeholder обратно только если поле пустое
if (searchQuery.trim() === '') {
setShowPlaceholder(true);
}
} }
}; };
@ -356,6 +380,54 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
router.push(url); router.push(url);
}; };
// Обработчик фокуса на поле ввода
const handleInputFocus = () => {
setInputFocused(true);
setShowResults(false);
setShowPlaceholder(false);
if (searchQuery.trim() === '') {
setShowSearchHistory(true);
getSearchHistory({ variables: { limit: 5 } });
}
};
// Обработчик изменения значения поля ввода
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchQuery(value);
// Управляем placeholder в зависимости от наличия текста
if (value.trim() === '') {
setShowPlaceholder(false); // Скрываем placeholder пока в фокусе
setShowSearchHistory(true);
setShowResults(false);
getSearchHistory({ variables: { limit: 5 } });
} else {
setShowPlaceholder(false); // Скрываем placeholder когда есть текст
setShowSearchHistory(false);
}
};
// Обработчик потери фокуса
const handleInputBlur = () => {
// Показываем placeholder обратно только если поле пустое
if (searchQuery.trim() === '') {
setShowPlaceholder(true);
}
};
// Обработчик клика по элементу истории
const handleHistoryItemClick = (searchQuery: string) => {
setSearchQuery(searchQuery);
setShowSearchHistory(false);
setInputFocused(false);
setShowPlaceholder(false); // Скрываем placeholder так как теперь есть текст
// Фокусируем поле ввода для возможности редактирования
if (searchInputRef.current) {
searchInputRef.current.focus();
}
};
return ( return (
<> <>
{/* <section className="top_head"> {/* <section className="top_head">
@ -421,7 +493,7 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
</svg></div> </svg></div>
</div> </div>
</div> </div>
<div className="searcj w-form" style={{ position: 'relative' }}> <div className="searcj w-form" style={{ position: 'relative' }} ref={searchDropdownRef}>
<form <form
id="custom-search-form" id="custom-search-form"
name="custom-search-form" name="custom-search-form"
@ -444,23 +516,33 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
</div> </div>
</div> </div>
<input <input
ref={searchInputRef}
className="text-field w-input" className="text-field w-input"
maxLength={256} maxLength={256}
name="customSearch" name="customSearch"
data-custom-input="true" data-custom-input="true"
placeholder="Введите код запчасти, VIN номер или госномер автомобиля" placeholder={showPlaceholder ? "Введите код запчасти, VIN номер или госномер автомобиля" : ""}
type="text" type="text"
id="customSearchInput" id="customSearchInput"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={handleInputChange}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
disabled={isSearching} disabled={isSearching}
/> />
</form> </form>
{/* История поиска */}
<SearchHistoryDropdown
isVisible={showSearchHistory && !showResults}
historyItems={searchHistoryItems}
onItemClick={handleHistoryItemClick}
loading={historyLoading}
/>
{/* Результаты поиска VIN */} {/* Результаты поиска VIN */}
{showResults && searchResults.length > 0 && (searchType === 'vin' || searchType === 'plate') && ( {showResults && searchResults.length > 0 && (searchType === 'vin' || searchType === 'plate') && (
<div <div
ref={searchDropdownRef}
className="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg mt-2 z-50 max-h-80 overflow-y-auto" className="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg mt-2 z-50 max-h-80 overflow-y-auto"
> >
<div className="p-3 border-b border-gray-100"> <div className="p-3 border-b border-gray-100">

View File

@ -0,0 +1,93 @@
import React from 'react';
import { PartsSearchHistoryItem } from '@/lib/graphql/search-history';
interface SearchHistoryDropdownProps {
isVisible: boolean;
historyItems: PartsSearchHistoryItem[];
onItemClick: (searchQuery: string) => void;
loading?: boolean;
}
const SearchHistoryDropdown: React.FC<SearchHistoryDropdownProps> = ({
isVisible,
historyItems,
onItemClick,
loading = false
}) => {
if (!isVisible) return null;
// Фильтруем уникальные запросы
const uniqueQueries = Array.from(
new Map(
historyItems.map(item => [item.searchQuery.toLowerCase(), item])
).values()
);
const getSearchTypeLabel = (type: string) => {
switch (type) {
case 'VIN':
return 'VIN';
case 'PLATE':
return 'Госномер';
case 'OEM':
case 'ARTICLE':
return 'Артикул';
default:
return 'Поиск';
}
};
return (
<div className="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg mt-2 z-50 max-h-60 overflow-y-auto">
{loading ? (
<div className="p-4 text-center text-gray-500">
<div className="flex items-center justify-center">
<svg className="animate-spin w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Загрузка истории...
</div>
</div>
) : uniqueQueries.length > 0 ? (
<>
<div className="p-3 border-b border-gray-100">
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Последние запросы
</h3>
</div>
{uniqueQueries.map((item) => (
<button
key={item.id}
onClick={() => onItemClick(item.searchQuery)}
className="w-full text-left p-3 hover:bg-gray-50 border-b border-gray-100 last:border-b-0 transition-colors cursor-pointer"
style={{ cursor: 'pointer' }}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{item.searchQuery}
</p>
<p className="text-xs text-gray-500">
{getSearchTypeLabel(item.searchType)}
</p>
</div>
<div className="ml-2 flex-shrink-0">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</button>
))}
</>
) : (
<div className="p-4 text-center text-gray-500">
<p className="text-sm">История поиска пуста</p>
</div>
)}
</div>
);
};
export default SearchHistoryDropdown;

View File

@ -373,6 +373,8 @@ export const CREATE_PAYMENT = gql`
} }
` `
export const GET_ORDERS = gql` export const GET_ORDERS = gql`
query GetOrders($clientId: String, $status: OrderStatus, $search: String, $limit: Int, $offset: Int) { query GetOrders($clientId: String, $status: OrderStatus, $search: String, $limit: Int, $offset: Int) {
orders(clientId: $clientId, status: $status, search: $search, limit: $limit, offset: $offset) { orders(clientId: $clientId, status: $status, search: $search, limit: $limit, offset: $offset) {
@ -1367,6 +1369,23 @@ export const GET_PARTSINDEX_CATEGORIES = gql`
} }
`; `;
// Навигационные категории с иконками
export const GET_NAVIGATION_CATEGORIES = gql`
query GetNavigationCategories {
navigationCategories {
id
partsIndexCatalogId
partsIndexGroupId
name
catalogName
groupName
icon
sortOrder
isHidden
}
}
`;
// Новый запрос для получения товаров каталога PartsIndex // Новый запрос для получения товаров каталога PartsIndex
export const GET_PARTSINDEX_CATALOG_ENTITIES = gql` export const GET_PARTSINDEX_CATALOG_ENTITIES = gql`
query GetPartsIndexCatalogEntities( query GetPartsIndexCatalogEntities(

View File

@ -24,6 +24,20 @@ export const GET_PARTS_SEARCH_HISTORY = gql`
} }
`; `;
// Запрос для получения последних поисковых запросов для автодополнения
export const GET_RECENT_SEARCH_QUERIES = gql`
query GetRecentSearchQueries($limit: Int = 5) {
partsSearchHistory(limit: $limit, offset: 0) {
items {
id
searchQuery
searchType
createdAt
}
}
}
`;
export const DELETE_SEARCH_HISTORY_ITEM = gql` export const DELETE_SEARCH_HISTORY_ITEM = gql`
mutation DeletePartsSearchHistoryItem($id: ID!) { mutation DeletePartsSearchHistoryItem($id: ID!) {
deletePartsSearchHistoryItem(id: $id) deletePartsSearchHistoryItem(id: $id)

View File

@ -138,7 +138,7 @@ export default function Catalog() {
limit: ITEMS_PER_PAGE, limit: ITEMS_PER_PAGE,
page: partsIndexPage, page: partsIndexPage,
q: searchQuery || undefined, q: searchQuery || undefined,
params: Object.keys(selectedFilters).length > 0 ? JSON.stringify(selectedFilters) : undefined params: undefined // Будем обновлять через refetch
}, },
skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId
fetchPolicy: 'cache-and-network' fetchPolicy: 'cache-and-network'
@ -146,7 +146,7 @@ export default function Catalog() {
); );
// Загружаем параметры фильтрации для PartsIndex // Загружаем параметры фильтрации для PartsIndex
const { data: paramsData, loading: paramsLoading, error: paramsError } = useQuery<PartsIndexParamsData, PartsIndexParamsVariables>( const { data: paramsData, loading: paramsLoading, error: paramsError, refetch: refetchParams } = useQuery<PartsIndexParamsData, PartsIndexParamsVariables>(
GET_PARTSINDEX_CATALOG_PARAMS, GET_PARTSINDEX_CATALOG_PARAMS,
{ {
variables: { variables: {
@ -154,7 +154,7 @@ export default function Catalog() {
groupId: groupId as string, groupId: groupId as string,
lang: 'ru', lang: 'ru',
q: searchQuery || undefined, q: searchQuery || undefined,
params: Object.keys(selectedFilters).length > 0 ? JSON.stringify(selectedFilters) : undefined params: undefined // Будем обновлять через refetch
}, },
skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId
fetchPolicy: 'cache-first' fetchPolicy: 'cache-first'
@ -215,18 +215,44 @@ export default function Catalog() {
} }
}, [entitiesData]); }, [entitiesData]);
// Преобразование выбранных фильтров в формат PartsIndex API
const convertFiltersToPartsIndexParams = useCallback((): Record<string, any> => {
if (!paramsData?.partsIndexCatalogParams?.list || Object.keys(selectedFilters).length === 0) {
return {};
}
const apiParams: Record<string, any> = {};
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);
}
}
});
return apiParams;
}, [paramsData, selectedFilters]);
// Генерация фильтров для PartsIndex на основе параметров API // Генерация фильтров для PartsIndex на основе параметров API
const generatePartsIndexFilters = useCallback((): FilterConfig[] => { const generatePartsIndexFilters = useCallback((): FilterConfig[] => {
if (!paramsData?.partsIndexCatalogParams?.list) { if (!paramsData?.partsIndexCatalogParams?.list) {
return []; return [];
} }
return paramsData.partsIndexCatalogParams.list.map(param => { return paramsData.partsIndexCatalogParams.list.map((param: any) => {
if (param.type === 'range') { if (param.type === 'range') {
// Для range фильтров ищем min и max значения // Для range фильтров ищем min и max значения
const numericValues = param.values const numericValues = param.values
.map(v => parseFloat(v.value)) .map((v: any) => parseFloat(v.value))
.filter(v => !isNaN(v)); .filter((v: number) => !isNaN(v));
const min = numericValues.length > 0 ? Math.min(...numericValues) : 0; const min = numericValues.length > 0 ? Math.min(...numericValues) : 0;
const max = numericValues.length > 0 ? Math.max(...numericValues) : 100; const max = numericValues.length > 0 ? Math.max(...numericValues) : 100;
@ -243,8 +269,8 @@ export default function Catalog() {
type: 'dropdown' as const, type: 'dropdown' as const,
title: param.name, title: param.name,
options: param.values options: param.values
.filter(value => value.available) // Показываем только доступные .filter((value: any) => value.available) // Показываем только доступные
.map(value => value.title || value.value), .map((value: any) => value.title || value.value),
multi: true, multi: true,
showAll: true showAll: true
}; };
@ -252,6 +278,8 @@ export default function Catalog() {
}); });
}, [paramsData]); }, [paramsData]);
useEffect(() => { useEffect(() => {
if (isPartsIndexMode) { if (isPartsIndexMode) {
// Для PartsIndex генерируем фильтры на основе параметров API // Для PartsIndex генерируем фильтры на основе параметров API
@ -426,16 +454,38 @@ export default function Catalog() {
// При изменении поиска или фильтров сбрасываем пагинацию // При изменении поиска или фильтров сбрасываем пагинацию
setShowEmptyState(false); setShowEmptyState(false);
// Если изменился поисковый запрос, нужно перезагрузить данные с сервера // Если изменился поисковый запрос или фильтры, нужно перезагрузить данные с сервера
if (searchQuery.trim() || Object.keys(selectedFilters).length > 0) { if (searchQuery.trim() || Object.keys(selectedFilters).length > 0) {
console.log('🔍 Поисковый запрос или фильтры изменились, сбрасываем пагинацию'); console.log('🔍 Поисковый запрос или фильтры изменились, сбрасываем пагинацию');
setPartsIndexPage(1); setPartsIndexPage(1);
setHasMoreEntities(true); setHasMoreEntities(true);
// refetch будет автоматически вызван при изменении partsIndexPage
// Перезагружаем данные с новыми параметрами фильтрации
const apiParams = convertFiltersToPartsIndexParams();
const paramsString = Object.keys(apiParams).length > 0 ? JSON.stringify(apiParams) : undefined;
// Также обновляем параметры фильтрации
refetchParams({
catalogId: catalogId as string,
groupId: groupId as string,
lang: 'ru',
q: searchQuery || undefined,
params: paramsString
});
refetchEntities({
catalogId: catalogId as string,
groupId: groupId as string,
lang: 'ru',
limit: ITEMS_PER_PAGE,
page: 1,
q: searchQuery || undefined,
params: paramsString
});
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPartsIndexMode, searchQuery, JSON.stringify(selectedFilters)]); }, [isPartsIndexMode, searchQuery, JSON.stringify(selectedFilters), refetchEntities, refetchParams, convertFiltersToPartsIndexParams]);
// Управляем показом пустого состояния с задержкой // Управляем показом пустого состояния с задержкой
useEffect(() => { useEffect(() => {

View File

@ -378,3 +378,4 @@ button,
font-size: 15px; font-size: 15px;
} }
} }

12
src/types/index.ts Normal file
View File

@ -0,0 +1,12 @@
// Навигационные категории
export interface NavigationCategory {
id: string
partsIndexCatalogId: string
partsIndexGroupId: string | null
name: string
catalogName: string
groupName: string | null
icon: string | null
sortOrder: number
isHidden: boolean
}