Compare commits
10 Commits
87339d577e
...
fix1707
Author | SHA1 | Date | |
---|---|---|---|
27d378154f | |||
5fd2cf1b8c | |||
2703137ca1 | |||
3e98f8fed6 | |||
9c152501db | |||
47844749eb | |||
074eb120b4 | |||
4dfc081214 | |||
d95d008c0c | |||
657016731c |
92
package-lock.json
generated
92
package-lock.json
generated
@ -16,6 +16,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"graphql": "^16.11.0",
|
||||
"next": "15.3.3",
|
||||
"node-fetch": "^3.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@ -1540,6 +1541,15 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
@ -1602,6 +1612,29 @@
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
@ -1615,6 +1648,18 @@
|
||||
"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": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@ -2140,6 +2185,44 @@
|
||||
"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": {
|
||||
"version": "2.0.19",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||
@ -2690,6 +2773,15 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
|
@ -17,6 +17,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"graphql": "^16.11.0",
|
||||
"next": "15.3.3",
|
||||
"node-fetch": "^3.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
BIN
public/images/noimage.png
Normal file
BIN
public/images/noimage.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.5 KiB |
@ -1,244 +1,366 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { GET_PARTSINDEX_CATEGORIES, GET_NAVIGATION_CATEGORIES } from '@/lib/graphql';
|
||||
import { PartsIndexCatalogsData, PartsIndexCatalogsVariables, PartsIndexCatalog } from '@/types/partsindex';
|
||||
import { NavigationCategory } from '@/types';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { GET_PARTSINDEX_CATEGORIES, GET_NAVIGATION_CATEGORIES } from '@/lib/graphql';
|
||||
import { PartsIndexCatalogsData, PartsIndexCatalogsVariables, PartsIndexCatalog } from '@/types/partsindex';
|
||||
import { NavigationCategory } from '@/types';
|
||||
|
||||
function useIsMobile(breakpoint = 767) {
|
||||
const [isMobile, setIsMobile] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth <= breakpoint);
|
||||
check();
|
||||
window.addEventListener("resize", check);
|
||||
return () => window.removeEventListener("resize", check);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
function useIsMobile(breakpoint = 767) {
|
||||
const [isMobile, setIsMobile] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth <= breakpoint);
|
||||
check();
|
||||
window.addEventListener("resize", check);
|
||||
return () => window.removeEventListener("resize", check);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Fallback статичные данные
|
||||
const fallbackTabData = [
|
||||
{
|
||||
label: "Оригинальные каталоги",
|
||||
heading: "Оригинальные каталоги",
|
||||
links: [
|
||||
"Моторные масла",
|
||||
"Трансмиссионные масла",
|
||||
"Тормозные жидкости",
|
||||
"Смазки",
|
||||
"Дистиллированная вода",
|
||||
"Жидкости для стеклоомывателей",
|
||||
"Индустриальные жидкости",
|
||||
"Антифриз и охлаждающие жидкости",
|
||||
"Промывочные жидкости",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Масла и технические жидкости",
|
||||
heading: "Масла и технические жидкости",
|
||||
links: [
|
||||
"Моторные масла",
|
||||
"Трансмиссионные масла",
|
||||
"Тормозные жидкости",
|
||||
"Смазки",
|
||||
"Дистиллированная вода",
|
||||
"Жидкости для стеклоомывателей",
|
||||
"Индустриальные жидкости",
|
||||
"Антифриз и охлаждающие жидкости",
|
||||
"Промывочные жидкости",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Оборудование",
|
||||
heading: "Оборудование",
|
||||
links: [
|
||||
"Моторные масла",
|
||||
"Трансмиссионные масла",
|
||||
"Тормозные жидкости",
|
||||
"Смазки",
|
||||
"Дистиллированная вода",
|
||||
"Жидкости для стеклоомывателей",
|
||||
"Индустриальные жидкости",
|
||||
"Антифриз и охлаждающие жидкости",
|
||||
"Промывочные жидкости",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Преобразуем данные PartsIndex в формат нашего меню
|
||||
const transformPartsIndexToTabData = (catalogs: PartsIndexCatalog[]) => {
|
||||
console.log('🔄 Преобразуем каталоги PartsIndex:', catalogs.length, 'элементов');
|
||||
|
||||
const transformed = catalogs.map(catalog => {
|
||||
const groupsCount = catalog.groups?.length || 0;
|
||||
console.log(`📝 Каталог: "${catalog.name}" (${groupsCount} групп)`);
|
||||
|
||||
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 (links.length === 0) {
|
||||
links = [catalog.name];
|
||||
}
|
||||
|
||||
console.log(`🔗 Подкатегории для "${catalog.name}":`, links);
|
||||
|
||||
return {
|
||||
label: catalog.name,
|
||||
heading: catalog.name,
|
||||
links: links.slice(0, 9), // Ограничиваем максимум 9 элементов
|
||||
catalogId: catalog.id // Сохраняем ID каталога для навигации
|
||||
};
|
||||
});
|
||||
|
||||
console.log('✅ Преобразование завершено:', transformed.length, 'табов');
|
||||
return transformed;
|
||||
};
|
||||
|
||||
// Функция для поиска иконки для категории
|
||||
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 router = useRouter();
|
||||
const [mobileCategory, setMobileCategory] = useState<null | any>(null);
|
||||
const [tabData, setTabData] = useState(fallbackTabData);
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||
|
||||
console.log('🔄 BottomHead render:', {
|
||||
menuOpen,
|
||||
tabDataLength: tabData.length,
|
||||
activeTabIndex,
|
||||
isMobile
|
||||
});
|
||||
|
||||
// --- Overlay animation state ---
|
||||
const [showOverlay, setShowOverlay] = useState(false);
|
||||
useEffect(() => {
|
||||
if (menuOpen) {
|
||||
setShowOverlay(true);
|
||||
} else {
|
||||
// Ждём окончания transition перед удалением из DOM
|
||||
const timeout = setTimeout(() => setShowOverlay(false), 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [menuOpen]);
|
||||
// --- End overlay animation state ---
|
||||
|
||||
// Получаем каталоги PartsIndex
|
||||
const { data: catalogsData, loading, error } = useQuery<PartsIndexCatalogsData, PartsIndexCatalogsVariables>(
|
||||
GET_PARTSINDEX_CATEGORIES,
|
||||
// Fallback статичные данные
|
||||
const fallbackTabData = [
|
||||
{
|
||||
variables: {
|
||||
lang: 'ru'
|
||||
},
|
||||
errorPolicy: 'all',
|
||||
onCompleted: (data) => {
|
||||
console.log('🎉 Apollo Query onCompleted - данные получены:', data);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ Apollo Query onError:', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Получаем навигационные категории с иконками
|
||||
const { data: navigationData, loading: navigationLoading, error: navigationError } = useQuery<{ navigationCategories: NavigationCategory[] }>(
|
||||
GET_NAVIGATION_CATEGORIES,
|
||||
label: "Оригинальные каталоги",
|
||||
heading: "Оригинальные каталоги",
|
||||
links: [
|
||||
"Моторные масла",
|
||||
"Трансмиссионные масла",
|
||||
"Тормозные жидкости",
|
||||
"Смазки",
|
||||
"Дистиллированная вода",
|
||||
"Жидкости для стеклоомывателей",
|
||||
"Индустриальные жидкости",
|
||||
"Антифриз и охлаждающие жидкости",
|
||||
"Промывочные жидкости",
|
||||
],
|
||||
},
|
||||
{
|
||||
errorPolicy: 'all',
|
||||
onCompleted: (data) => {
|
||||
console.log('🎉 Навигационные категории получены:', data);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ Ошибка загрузки навигационных категорий:', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
label: "Масла и технические жидкости",
|
||||
heading: "Масла и технические жидкости",
|
||||
links: [
|
||||
"Моторные масла",
|
||||
"Трансмиссионные масла",
|
||||
"Тормозные жидкости",
|
||||
"Смазки",
|
||||
"Дистиллированная вода",
|
||||
"Жидкости для стеклоомывателей",
|
||||
"Индустриальные жидкости",
|
||||
"Антифриз и охлаждающие жидкости",
|
||||
"Промывочные жидкости",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Оборудование",
|
||||
heading: "Оборудование",
|
||||
links: [
|
||||
"Моторные масла",
|
||||
"Трансмиссионные масла",
|
||||
"Тормозные жидкости",
|
||||
"Смазки",
|
||||
"Дистиллированная вода",
|
||||
"Жидкости для стеклоомывателей",
|
||||
"Индустриальные жидкости",
|
||||
"Антифриз и охлаждающие жидкости",
|
||||
"Промывочные жидкости",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Обновляем данные табов когда получаем данные от API
|
||||
useEffect(() => {
|
||||
if (catalogsData?.partsIndexCategoriesWithGroups && catalogsData.partsIndexCategoriesWithGroups.length > 0) {
|
||||
console.log('✅ Обновляем меню с данными PartsIndex:', catalogsData.partsIndexCategoriesWithGroups.length, 'каталогов');
|
||||
console.log('🔍 Первые 3 каталога:', catalogsData.partsIndexCategoriesWithGroups.slice(0, 3).map(catalog => ({
|
||||
name: catalog.name,
|
||||
id: catalog.id,
|
||||
groupsCount: catalog.groups?.length || 0,
|
||||
groups: catalog.groups?.slice(0, 3).map(group => group.name)
|
||||
})));
|
||||
// Преобразуем данные PartsIndex в формат нашего меню
|
||||
const transformPartsIndexToTabData = (catalogs: PartsIndexCatalog[]) => {
|
||||
console.log('🔄 Преобразуем каталоги PartsIndex:', catalogs.length, 'элементов');
|
||||
|
||||
const transformed = catalogs.map(catalog => {
|
||||
const groupsCount = catalog.groups?.length || 0;
|
||||
console.log(`📝 Каталог: "${catalog.name}" (${groupsCount} групп)`);
|
||||
|
||||
const apiTabData = transformPartsIndexToTabData(catalogsData.partsIndexCategoriesWithGroups);
|
||||
setTabData(apiTabData);
|
||||
// Сбрасываем активный таб на первый при обновлении данных
|
||||
setActiveTabIndex(0);
|
||||
} else if (error) {
|
||||
console.warn('⚠️ Используем fallback данные из-за ошибки PartsIndex:', error);
|
||||
setTabData(fallbackTabData);
|
||||
setActiveTabIndex(0);
|
||||
}
|
||||
}, [catalogsData, error]);
|
||||
|
||||
// Логирование для отладки
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
console.log('🔄 Загружаем каталоги PartsIndex...');
|
||||
}
|
||||
if (error) {
|
||||
console.error('❌ Ошибка загрузки каталогов PartsIndex:', error);
|
||||
}
|
||||
}, [loading, error]);
|
||||
|
||||
// Обработка клика по категории для перехода в каталог с товарами
|
||||
const handleCategoryClick = (catalogId: string, categoryName: string, entityId?: string) => {
|
||||
console.log('🔍 Клик по категории:', { catalogId, categoryName, entityId });
|
||||
|
||||
// Закрываем меню
|
||||
onClose();
|
||||
|
||||
// Переходим на страницу каталога с параметрами PartsIndex
|
||||
router.push({
|
||||
pathname: '/catalog',
|
||||
query: {
|
||||
partsIndexCatalog: catalogId,
|
||||
categoryName: encodeURIComponent(categoryName),
|
||||
...(entityId && { partsIndexCategory: entityId })
|
||||
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 (links.length === 0) {
|
||||
links = [catalog.name];
|
||||
}
|
||||
|
||||
console.log(`🔗 Подкатегории для "${catalog.name}":`, links);
|
||||
|
||||
return {
|
||||
label: catalog.name,
|
||||
heading: catalog.name,
|
||||
links: links.slice(0, 9), // Ограничиваем максимум 9 элементов
|
||||
catalogId: catalog.id // Сохраняем ID каталога для навигации
|
||||
};
|
||||
});
|
||||
|
||||
console.log('✅ Преобразование завершено:', transformed.length, 'табов');
|
||||
return transformed;
|
||||
};
|
||||
|
||||
// Только мобильный UX
|
||||
if (isMobile && menuOpen) {
|
||||
// Оверлей для мобильного меню
|
||||
// Функция для поиска иконки для категории
|
||||
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 router = useRouter();
|
||||
const [mobileCategory, setMobileCategory] = useState<null | any>(null);
|
||||
const [tabData, setTabData] = useState(fallbackTabData);
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||
|
||||
console.log('🔄 BottomHead render:', {
|
||||
menuOpen,
|
||||
tabDataLength: tabData.length,
|
||||
activeTabIndex,
|
||||
isMobile
|
||||
});
|
||||
|
||||
// --- Overlay animation state ---
|
||||
const [showOverlay, setShowOverlay] = useState(false);
|
||||
useEffect(() => {
|
||||
if (menuOpen) {
|
||||
setShowOverlay(true);
|
||||
} else {
|
||||
// Ждём окончания transition перед удалением из DOM
|
||||
const timeout = setTimeout(() => setShowOverlay(false), 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [menuOpen]);
|
||||
// --- End overlay animation state ---
|
||||
|
||||
// Получаем каталоги PartsIndex
|
||||
const { data: catalogsData, loading, error } = useQuery<PartsIndexCatalogsData, PartsIndexCatalogsVariables>(
|
||||
GET_PARTSINDEX_CATEGORIES,
|
||||
{
|
||||
variables: {
|
||||
lang: 'ru'
|
||||
},
|
||||
errorPolicy: 'all',
|
||||
onCompleted: (data) => {
|
||||
console.log('🎉 Apollo Query onCompleted - данные получены:', data);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ Apollo Query onError:', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Получаем навигационные категории с иконками
|
||||
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
|
||||
useEffect(() => {
|
||||
if (catalogsData?.partsIndexCategoriesWithGroups && catalogsData.partsIndexCategoriesWithGroups.length > 0) {
|
||||
console.log('✅ Обновляем меню с данными PartsIndex:', catalogsData.partsIndexCategoriesWithGroups.length, 'каталогов');
|
||||
console.log('🔍 Первые 3 каталога:', catalogsData.partsIndexCategoriesWithGroups.slice(0, 3).map(catalog => ({
|
||||
name: catalog.name,
|
||||
id: catalog.id,
|
||||
groupsCount: catalog.groups?.length || 0,
|
||||
groups: catalog.groups?.slice(0, 3).map(group => group.name)
|
||||
})));
|
||||
|
||||
const apiTabData = transformPartsIndexToTabData(catalogsData.partsIndexCategoriesWithGroups);
|
||||
setTabData(apiTabData);
|
||||
// Сбрасываем активный таб на первый при обновлении данных
|
||||
setActiveTabIndex(0);
|
||||
} else if (error) {
|
||||
console.warn('⚠️ Используем fallback данные из-за ошибки PartsIndex:', error);
|
||||
setTabData(fallbackTabData);
|
||||
setActiveTabIndex(0);
|
||||
}
|
||||
}, [catalogsData, error]);
|
||||
|
||||
// Логирование для отладки
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
console.log('🔄 Загружаем каталоги PartsIndex...');
|
||||
}
|
||||
if (error) {
|
||||
console.error('❌ Ошибка загрузки каталогов PartsIndex:', error);
|
||||
}
|
||||
}, [loading, error]);
|
||||
|
||||
// Обработка клика по категории для перехода в каталог с товарами
|
||||
const handleCategoryClick = (catalogId: string, categoryName: string, entityId?: string) => {
|
||||
console.log('🔍 Клик по категории:', { catalogId, categoryName, entityId });
|
||||
|
||||
// Закрываем меню
|
||||
onClose();
|
||||
|
||||
// Переходим на страницу каталога с параметрами PartsIndex
|
||||
router.push({
|
||||
pathname: '/catalog',
|
||||
query: {
|
||||
partsIndexCatalog: catalogId,
|
||||
categoryName: encodeURIComponent(categoryName),
|
||||
...(entityId && { partsIndexCategory: entityId })
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Только мобильный UX
|
||||
if (isMobile && menuOpen) {
|
||||
// Оверлей для мобильного меню
|
||||
return (
|
||||
<>
|
||||
{showOverlay && (
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/7 z-40 transition-opacity duration-300 ${menuOpen ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть меню"
|
||||
/>
|
||||
)}
|
||||
{/* Экран подкатегорий */}
|
||||
{mobileCategory ? (
|
||||
<div className="mobile-category-overlay z-50">
|
||||
<div className="mobile-header">
|
||||
<button className="mobile-back-btn" onClick={() => setMobileCategory(null)}>
|
||||
←
|
||||
</button>
|
||||
<span>{mobileCategory.label}</span>
|
||||
</div>
|
||||
<div className="mobile-subcategories">
|
||||
{mobileCategory.links.length === 1 ? (
|
||||
<div
|
||||
className="mobile-subcategory"
|
||||
onClick={() => {
|
||||
let subcategoryId = `${mobileCategory.catalogId}_0`;
|
||||
if (mobileCategory.groups) {
|
||||
for (const group of mobileCategory.groups) {
|
||||
if (group.subgroups && group.subgroups.length > 0) {
|
||||
const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === mobileCategory.links[0]);
|
||||
if (foundSubgroup) {
|
||||
subcategoryId = foundSubgroup.id;
|
||||
break;
|
||||
}
|
||||
} else if (group.name === mobileCategory.links[0]) {
|
||||
subcategoryId = group.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const activeCatalog = catalogsData?.partsIndexCategoriesWithGroups?.[tabData.findIndex(tab => tab === mobileCategory)];
|
||||
const catalogId = activeCatalog?.id || 'fallback';
|
||||
handleCategoryClick(catalogId, mobileCategory.links[0], subcategoryId);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Показать все
|
||||
</div>
|
||||
) : (
|
||||
mobileCategory.links.map((link: string, linkIndex: number) => (
|
||||
<div
|
||||
className="mobile-subcategory"
|
||||
key={link}
|
||||
onClick={() => {
|
||||
let subcategoryId = `${mobileCategory.catalogId}_${linkIndex}`;
|
||||
if (mobileCategory.groups) {
|
||||
for (const group of mobileCategory.groups) {
|
||||
if (group.subgroups && group.subgroups.length > 0) {
|
||||
const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === link);
|
||||
if (foundSubgroup) {
|
||||
subcategoryId = foundSubgroup.id;
|
||||
break;
|
||||
}
|
||||
} else if (group.name === link) {
|
||||
subcategoryId = group.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const activeCatalog = catalogsData?.partsIndexCategoriesWithGroups?.[tabData.findIndex(tab => tab === mobileCategory)];
|
||||
const catalogId = activeCatalog?.id || 'fallback';
|
||||
handleCategoryClick(catalogId, link, subcategoryId);
|
||||
}}
|
||||
>
|
||||
{link}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Экран выбора категории
|
||||
<div className="mobile-category-overlay z-50">
|
||||
<div className="mobile-header">
|
||||
<button className="mobile-back-btn" onClick={onClose} aria-label="Закрыть меню">
|
||||
<svg width="24" height="24" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M4.11 2.697L2.698 4.11 6.586 8l-3.89 3.89 1.415 1.413L8 9.414l3.89 3.89 1.413-1.415L9.414 8l3.89-3.89-1.415-1.413L8 6.586l-3.89-3.89z" fill="currentColor"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<span>Категории</span>
|
||||
{loading && <span className="text-sm text-gray-500 ml-2">(загрузка...)</span>}
|
||||
</div>
|
||||
<div className="mobile-subcategories" style={{ maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{tabData.map((cat, index) => {
|
||||
// Получаем ID каталога из данных PartsIndex или создаем fallback ID
|
||||
const catalogId = catalogsData?.partsIndexCategoriesWithGroups?.[index]?.id || `fallback_${index}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mobile-subcategory"
|
||||
key={cat.label}
|
||||
onClick={() => {
|
||||
// Добавляем catalogId и groups для правильной обработки
|
||||
const categoryWithData = {
|
||||
...cat,
|
||||
catalogId,
|
||||
groups: catalogsData?.partsIndexCategoriesWithGroups?.[index]?.groups
|
||||
};
|
||||
setMobileCategory(categoryWithData);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{cat.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Десктоп: оставить всё как есть, но добавить оверлей
|
||||
return (
|
||||
<>
|
||||
{showOverlay && (
|
||||
@ -248,157 +370,126 @@ const BottomHead = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => v
|
||||
aria-label="Закрыть меню"
|
||||
/>
|
||||
)}
|
||||
{/* Экран подкатегорий */}
|
||||
{mobileCategory ? (
|
||||
<div className="mobile-category-overlay z-50">
|
||||
<div className="mobile-header">
|
||||
<button className="mobile-back-btn" onClick={() => setMobileCategory(null)}>
|
||||
←
|
||||
</button>
|
||||
<span>{mobileCategory.label}</span>
|
||||
</div>
|
||||
<div className="mobile-subcategories">
|
||||
{mobileCategory.links.length === 1 ? (
|
||||
<div
|
||||
className="mobile-subcategory"
|
||||
onClick={() => {
|
||||
let subcategoryId = `${mobileCategory.catalogId}_0`;
|
||||
if (mobileCategory.groups) {
|
||||
for (const group of mobileCategory.groups) {
|
||||
if (group.subgroups && group.subgroups.length > 0) {
|
||||
const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === mobileCategory.links[0]);
|
||||
if (foundSubgroup) {
|
||||
subcategoryId = foundSubgroup.id;
|
||||
break;
|
||||
}
|
||||
} else if (group.name === mobileCategory.links[0]) {
|
||||
subcategoryId = group.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const activeCatalog = catalogsData?.partsIndexCategoriesWithGroups?.[tabData.findIndex(tab => tab === mobileCategory)];
|
||||
const catalogId = activeCatalog?.id || 'fallback';
|
||||
handleCategoryClick(catalogId, mobileCategory.links[0], subcategoryId);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Показать все
|
||||
</div>
|
||||
) : (
|
||||
mobileCategory.links.map((link: string, linkIndex: number) => (
|
||||
<div
|
||||
className="mobile-subcategory"
|
||||
key={link}
|
||||
onClick={() => {
|
||||
let subcategoryId = `${mobileCategory.catalogId}_${linkIndex}`;
|
||||
if (mobileCategory.groups) {
|
||||
for (const group of mobileCategory.groups) {
|
||||
{showOverlay && (
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/7 z-1900 transition-opacity duration-300 ${menuOpen ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть меню"
|
||||
/>
|
||||
)}
|
||||
<nav
|
||||
role="navigation"
|
||||
className="nav-menu-3 w-nav-menu z-2000"
|
||||
style={{ display: menuOpen ? "block" : "none" }}
|
||||
onClick={e => e.stopPropagation()} // чтобы клик внутри меню не закрывал его
|
||||
>
|
||||
<div className="div-block-28">
|
||||
<div className="w-layout-hflex flex-block-90">
|
||||
<div className="w-layout-vflex flex-block-88" style={{ maxHeight: "60vh", overflowY: "auto" }}>
|
||||
{/* Меню с иконками - показываем все категории из API */}
|
||||
{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
|
||||
href="#"
|
||||
className={`link-block-7 w-inline-block${activeTabIndex === idx ? " w--current" : ""}`}
|
||||
key={tab.label}
|
||||
onClick={() => {
|
||||
setActiveTabIndex(idx);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div className="div-block-29">
|
||||
<div className="code-embed-12 w-embed">
|
||||
{icon ? (
|
||||
<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>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-block-47">{tab.label}</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Правая часть меню с подкатегориями и картинками */}
|
||||
<div className="w-layout-vflex flex-block-89">
|
||||
<h3 className="heading-16">{tabData[activeTabIndex]?.heading || tabData[0].heading}{loading && <span className="text-sm text-gray-500 ml-2">(обновление...)</span>}</h3>
|
||||
<div className="w-layout-hflex flex-block-92">
|
||||
<div className="w-layout-vflex flex-block-91">
|
||||
{(tabData[activeTabIndex]?.links || tabData[0].links).map((link, linkIndex) => {
|
||||
const activeCatalog = catalogsData?.partsIndexCategoriesWithGroups?.[activeTabIndex];
|
||||
|
||||
// Ищем соответствующую подгруппу по названию
|
||||
let subcategoryId = `fallback_${activeTabIndex}_${linkIndex}`;
|
||||
|
||||
if (activeCatalog?.groups) {
|
||||
for (const group of activeCatalog.groups) {
|
||||
// Проверяем в подгруппах
|
||||
if (group.subgroups && group.subgroups.length > 0) {
|
||||
const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === link);
|
||||
if (foundSubgroup) {
|
||||
subcategoryId = foundSubgroup.id;
|
||||
break;
|
||||
}
|
||||
} else if (group.name === link) {
|
||||
}
|
||||
// Если нет подгрупп, проверяем саму группу
|
||||
else if (group.name === link) {
|
||||
subcategoryId = group.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const activeCatalog = catalogsData?.partsIndexCategoriesWithGroups?.[tabData.findIndex(tab => tab === mobileCategory)];
|
||||
const catalogId = activeCatalog?.id || 'fallback';
|
||||
handleCategoryClick(catalogId, link, subcategoryId);
|
||||
}}
|
||||
>
|
||||
{link}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="link-2"
|
||||
key={link}
|
||||
onClick={() => {
|
||||
const catalogId = activeCatalog?.id || 'fallback';
|
||||
handleCategoryClick(catalogId, link, subcategoryId);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{link}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Экран выбора категории
|
||||
<div className="mobile-category-overlay z-50">
|
||||
<div className="mobile-header">
|
||||
<button className="mobile-back-btn" onClick={onClose} aria-label="Закрыть меню">
|
||||
<svg width="24" height="24" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M4.11 2.697L2.698 4.11 6.586 8l-3.89 3.89 1.415 1.413L8 9.414l3.89 3.89 1.413-1.415L9.414 8l3.89-3.89-1.415-1.413L8 6.586l-3.89-3.89z" fill="currentColor"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<span>Категории</span>
|
||||
{loading && <span className="text-sm text-gray-500 ml-2">(загрузка...)</span>}
|
||||
</div>
|
||||
<div className="mobile-subcategories" style={{ maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{tabData.map((cat, index) => {
|
||||
// Получаем ID каталога из данных PartsIndex или создаем fallback ID
|
||||
const catalogId = catalogsData?.partsIndexCategoriesWithGroups?.[index]?.id || `fallback_${index}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mobile-subcategory"
|
||||
key={cat.label}
|
||||
onClick={() => {
|
||||
// Добавляем catalogId и groups для правильной обработки
|
||||
const categoryWithData = {
|
||||
...cat,
|
||||
catalogId,
|
||||
groups: catalogsData?.partsIndexCategoriesWithGroups?.[index]?.groups
|
||||
};
|
||||
setMobileCategory(categoryWithData);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{cat.label}
|
||||
<div className="w-layout-vflex flex-block-91-copy">
|
||||
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
|
||||
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Десктоп: оставить всё как есть, но добавить оверлей
|
||||
return (
|
||||
<>
|
||||
{showOverlay && (
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/7 z-40 transition-opacity duration-300 ${menuOpen ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть меню"
|
||||
/>
|
||||
)}
|
||||
{showOverlay && (
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/7 z-1900 transition-opacity duration-300 ${menuOpen ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть меню"
|
||||
/>
|
||||
)}
|
||||
<nav
|
||||
role="navigation"
|
||||
className="nav-menu-3 w-nav-menu z-2000"
|
||||
style={{ display: menuOpen ? "block" : "none" }}
|
||||
onClick={e => e.stopPropagation()} // чтобы клик внутри меню не закрывал его
|
||||
>
|
||||
<div className="div-block-28">
|
||||
<div className="w-layout-hflex flex-block-90">
|
||||
<div className="w-layout-vflex flex-block-88" style={{ maxHeight: "60vh", overflowY: "auto" }}>
|
||||
{/* Меню с иконками - показываем все категории из API */}
|
||||
{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 (
|
||||
{/* Табы */}
|
||||
<div data-current="Tab 1" data-easing="ease" data-duration-in="300" data-duration-out="100" className="tabs w-tabs">
|
||||
<div className="tabs-menu w-tab-menu" style={{ maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{tabData.map((tab, idx) => (
|
||||
<a
|
||||
href="#"
|
||||
className={`link-block-7 w-inline-block${activeTabIndex === idx ? " w--current" : ""}`}
|
||||
key={tab.label}
|
||||
data-w-tab={`Tab ${idx + 1}`}
|
||||
className={`tab-link w-inline-block w-tab-link${activeTabIndex === idx ? " w--current" : ""}`}
|
||||
onClick={() => {
|
||||
setActiveTabIndex(idx);
|
||||
}}
|
||||
@ -406,193 +497,102 @@ const BottomHead = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => v
|
||||
>
|
||||
<div className="div-block-29">
|
||||
<div className="code-embed-12 w-embed">
|
||||
{icon ? (
|
||||
<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>
|
||||
</svg>
|
||||
)}
|
||||
<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>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-block-47">{tab.label}</div>
|
||||
<div className="text-block-49">{tab.label}</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Правая часть меню с подкатегориями и картинками */}
|
||||
<div className="w-layout-vflex flex-block-89">
|
||||
<h3 className="heading-16">{tabData[activeTabIndex]?.heading || tabData[0].heading}{loading && <span className="text-sm text-gray-500 ml-2">(обновление...)</span>}</h3>
|
||||
<div className="w-layout-hflex flex-block-92">
|
||||
<div className="w-layout-vflex flex-block-91">
|
||||
{(tabData[activeTabIndex]?.links || tabData[0].links).map((link, linkIndex) => {
|
||||
const activeCatalog = catalogsData?.partsIndexCategoriesWithGroups?.[activeTabIndex];
|
||||
|
||||
// Ищем соответствующую подгруппу по названию
|
||||
let subcategoryId = `fallback_${activeTabIndex}_${linkIndex}`;
|
||||
|
||||
if (activeCatalog?.groups) {
|
||||
for (const group of activeCatalog.groups) {
|
||||
// Проверяем в подгруппах
|
||||
if (group.subgroups && group.subgroups.length > 0) {
|
||||
const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === link);
|
||||
if (foundSubgroup) {
|
||||
subcategoryId = foundSubgroup.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Если нет подгрупп, проверяем саму группу
|
||||
else if (group.name === link) {
|
||||
subcategoryId = group.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="link-2"
|
||||
key={link}
|
||||
onClick={() => {
|
||||
const catalogId = activeCatalog?.id || 'fallback';
|
||||
handleCategoryClick(catalogId, link, subcategoryId);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{link}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="w-layout-vflex flex-block-91-copy">
|
||||
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
|
||||
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Табы */}
|
||||
<div data-current="Tab 1" data-easing="ease" data-duration-in="300" data-duration-out="100" className="tabs w-tabs">
|
||||
<div className="tabs-menu w-tab-menu" style={{ maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{tabData.map((tab, idx) => (
|
||||
<a
|
||||
key={tab.label}
|
||||
data-w-tab={`Tab ${idx + 1}`}
|
||||
className={`tab-link w-inline-block w-tab-link${activeTabIndex === idx ? " w--current" : ""}`}
|
||||
onClick={() => {
|
||||
setActiveTabIndex(idx);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div className="div-block-29">
|
||||
<div className="code-embed-12 w-embed">
|
||||
<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>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-block-49">{tab.label}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="tabs-content w-tab-content">
|
||||
{tabData.map((tab, idx) => (
|
||||
<div
|
||||
key={tab.label}
|
||||
data-w-tab={`Tab ${idx + 1}`}
|
||||
className={`tab-pane w-tab-pane${activeTabIndex === idx ? " w--tab-active" : ""}`}
|
||||
style={{ display: activeTabIndex === idx ? "block" : "none" }}
|
||||
>
|
||||
<div className="w-layout-vflex flex-block-89">
|
||||
<h3 className="heading-16">{tab.heading}</h3>
|
||||
<div className="w-layout-hflex flex-block-92">
|
||||
<div className="w-layout-vflex flex-block-91">
|
||||
{tab.links.length === 1 ? (
|
||||
<div
|
||||
className="link-2"
|
||||
onClick={() => {
|
||||
<div className="tabs-content w-tab-content">
|
||||
{tabData.map((tab, idx) => (
|
||||
<div
|
||||
key={tab.label}
|
||||
data-w-tab={`Tab ${idx + 1}`}
|
||||
className={`tab-pane w-tab-pane${activeTabIndex === idx ? " w--tab-active" : ""}`}
|
||||
style={{ display: activeTabIndex === idx ? "block" : "none" }}
|
||||
>
|
||||
<div className="w-layout-vflex flex-block-89">
|
||||
<h3 className="heading-16">{tab.heading}</h3>
|
||||
<div className="w-layout-hflex flex-block-92">
|
||||
<div className="w-layout-vflex flex-block-91">
|
||||
{tab.links.length === 1 ? (
|
||||
<div
|
||||
className="link-2"
|
||||
onClick={() => {
|
||||
const catalog = catalogsData?.partsIndexCategoriesWithGroups?.[idx];
|
||||
let subcategoryId = `fallback_${idx}_0`;
|
||||
if (catalog?.groups) {
|
||||
for (const group of catalog.groups) {
|
||||
if (group.subgroups && group.subgroups.length > 0) {
|
||||
const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === tab.links[0]);
|
||||
if (foundSubgroup) {
|
||||
subcategoryId = foundSubgroup.id;
|
||||
break;
|
||||
}
|
||||
} else if (group.name === tab.links[0]) {
|
||||
subcategoryId = group.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const catalogId = catalog?.id || 'fallback';
|
||||
handleCategoryClick(catalogId, tab.links[0], subcategoryId);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Показать все
|
||||
</div>
|
||||
) : (
|
||||
tab.links.map((link: string, linkIndex: number) => {
|
||||
const catalog = catalogsData?.partsIndexCategoriesWithGroups?.[idx];
|
||||
let subcategoryId = `fallback_${idx}_0`;
|
||||
let subcategoryId = `fallback_${idx}_${linkIndex}`;
|
||||
if (catalog?.groups) {
|
||||
for (const group of catalog.groups) {
|
||||
if (group.subgroups && group.subgroups.length > 0) {
|
||||
const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === tab.links[0]);
|
||||
const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === link);
|
||||
if (foundSubgroup) {
|
||||
subcategoryId = foundSubgroup.id;
|
||||
break;
|
||||
}
|
||||
} else if (group.name === tab.links[0]) {
|
||||
} else if (group.name === link) {
|
||||
subcategoryId = group.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const catalogId = catalog?.id || 'fallback';
|
||||
handleCategoryClick(catalogId, tab.links[0], subcategoryId);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Показать все
|
||||
</div>
|
||||
) : (
|
||||
tab.links.map((link: string, linkIndex: number) => {
|
||||
const catalog = catalogsData?.partsIndexCategoriesWithGroups?.[idx];
|
||||
let subcategoryId = `fallback_${idx}_${linkIndex}`;
|
||||
if (catalog?.groups) {
|
||||
for (const group of catalog.groups) {
|
||||
if (group.subgroups && group.subgroups.length > 0) {
|
||||
const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === link);
|
||||
if (foundSubgroup) {
|
||||
subcategoryId = foundSubgroup.id;
|
||||
break;
|
||||
}
|
||||
} else if (group.name === link) {
|
||||
subcategoryId = group.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="link-2"
|
||||
key={link}
|
||||
onClick={() => {
|
||||
const catalogId = catalog?.id || 'fallback';
|
||||
handleCategoryClick(catalogId, link, subcategoryId);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{link}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="w-layout-vflex flex-block-91-copy">
|
||||
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
|
||||
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
|
||||
return (
|
||||
<div
|
||||
className="link-2"
|
||||
key={link}
|
||||
onClick={() => {
|
||||
const catalogId = catalog?.id || 'fallback';
|
||||
handleCategoryClick(catalogId, link, subcategoryId);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{link}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="w-layout-vflex flex-block-91-copy">
|
||||
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
|
||||
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomHead;
|
||||
export default BottomHead;
|
@ -3,16 +3,17 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
interface CatalogSortDropdownProps {
|
||||
active: number;
|
||||
onChange: (index: number) => void;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
const sortOptions = [
|
||||
const defaultSortOptions = [
|
||||
'По популярности',
|
||||
'Сначала дешевле',
|
||||
'Сначала дороже',
|
||||
'Высокий рейтинг',
|
||||
];
|
||||
|
||||
const CatalogSortDropdown: React.FC<CatalogSortDropdownProps> = ({ active, onChange }) => {
|
||||
const CatalogSortDropdown: React.FC<CatalogSortDropdownProps> = ({ active, onChange, options = defaultSortOptions }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -52,7 +53,7 @@ const CatalogSortDropdown: React.FC<CatalogSortDropdownProps> = ({ active, onCha
|
||||
<div>Сортировка</div>
|
||||
</div>
|
||||
<nav className={`dropdown-list-2 w-dropdown-list${isOpen ? ' w--open' : ''}`} style={{ minWidth: 180, whiteSpace: 'normal' }}>
|
||||
{sortOptions.map((option, index) => (
|
||||
{options.map((option: string, index: number) => (
|
||||
<a
|
||||
key={index}
|
||||
href="#"
|
||||
|
28
src/components/CloseIcon.tsx
Normal file
28
src/components/CloseIcon.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CloseIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const CloseIcon: React.FC<CloseIconProps> = ({ size = 20, color = '#fff' }) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18 6L6 18M6 6L18 18"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloseIcon;
|
@ -1,270 +1,64 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CookiePreferences, initializeAnalytics, initializeMarketing } from '@/lib/cookie-utils';
|
||||
import * as React from "react";
|
||||
|
||||
interface CookieConsentProps {
|
||||
onAccept?: () => void;
|
||||
onDecline?: () => void;
|
||||
onConfigure?: (preferences: CookiePreferences) => void;
|
||||
}
|
||||
const CookieConsent: React.FC = () => {
|
||||
const [isVisible, setIsVisible] = React.useState(false);
|
||||
|
||||
const CookieConsent: React.FC<CookieConsentProps> = ({ onAccept, onDecline, onConfigure }) => {
|
||||
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
|
||||
React.useEffect(() => {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (!cookieConsent) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAcceptAll = () => {
|
||||
const allAccepted = {
|
||||
necessary: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
functional: true,
|
||||
};
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
localStorage.setItem('cookiePreferences', JSON.stringify(allAccepted));
|
||||
|
||||
// Инициализируем сервисы после согласия
|
||||
initializeAnalytics();
|
||||
initializeMarketing();
|
||||
|
||||
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;
|
||||
|
||||
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">
|
||||
{!showDetails ? (
|
||||
// Основной вид
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
{/* Текст согласия */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Иконка cookie */}
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="text-gray-600">
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-950 mb-2">
|
||||
Мы используем файлы cookie
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Наш сайт использует файлы cookie для улучшения работы сайта, персонализации контента и анализа трафика.
|
||||
Продолжая использовать сайт, вы соглашаетесь с нашей{' '}
|
||||
<a
|
||||
href="/privacy-policy"
|
||||
className="text-red-600 hover:text-red-700 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
политикой конфиденциальности
|
||||
</a>
|
||||
{' '}и использованием файлов cookie.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопки */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 md:flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowDetails(true)}
|
||||
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>
|
||||
<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 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 min-w-[120px]"
|
||||
>
|
||||
Принять все
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
<>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Onest:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<div
|
||||
layer-name="cookie"
|
||||
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"
|
||||
>
|
||||
<div
|
||||
layer-name="Мы используем cookie-файлы, чтобы получить статистику, которая помогает нам улучшать сайт для Вас. Нажимая Принять, вы даёте согласие на использование ваших cookie-файлов. Подробнее о том, как мы используем ваши персональные данные, в нашей Политике обработки персональных данных."
|
||||
className="flex-1 text-base font-medium leading-5 text-red-600 max-w-[933px] max-md:max-w-full max-sm:text-sm"
|
||||
>
|
||||
<span className="text-base text-gray-600">
|
||||
Мы используем cookie-файлы, чтобы получить статистику, которая
|
||||
помогает нам улучшать сайт для Вас. Нажимая Принять, вы даёте
|
||||
согласие на использование ваших cookie-файлов. Подробнее о том, как
|
||||
мы используем ваши персональные данные, в нашей{' '}
|
||||
</span>
|
||||
<a
|
||||
href="/privacy-policy"
|
||||
className="text-base text-red-600 underline hover:text-red-700"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Политике обработки персональных данных.
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
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
|
||||
layer-name="Принять"
|
||||
className="text-base font-semibold leading-5 text-center text-white max-sm:text-sm"
|
||||
>
|
||||
Принять
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -3,6 +3,7 @@ import { useCart } from "@/contexts/CartContext";
|
||||
import { useFavorites } from "@/contexts/FavoritesContext";
|
||||
import toast from "react-hot-toast";
|
||||
import CartIcon from "./CartIcon";
|
||||
import { isDeliveryDate } from "@/lib/utils";
|
||||
|
||||
const INITIAL_OFFERS_LIMIT = 5;
|
||||
|
||||
@ -50,6 +51,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
const { addItem } = useCart();
|
||||
const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
|
||||
const [visibleOffersCount, setVisibleOffersCount] = useState(INITIAL_OFFERS_LIMIT);
|
||||
const [sortBy, setSortBy] = useState<'stock' | 'delivery' | 'price'>('price'); // Локальная сортировка для каждого товара
|
||||
const [quantities, setQuantities] = useState<{ [key: number]: number }>(
|
||||
offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {})
|
||||
);
|
||||
@ -63,8 +65,52 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
setQuantities(offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {}));
|
||||
}, [offers.length]);
|
||||
|
||||
const displayedOffers = offers.slice(0, visibleOffersCount);
|
||||
const hasMoreOffers = visibleOffersCount < offers.length;
|
||||
// Функция для парсинга цены из строки
|
||||
const parsePrice = (priceStr: string): number => {
|
||||
const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.');
|
||||
return parseFloat(cleanPrice) || 0;
|
||||
};
|
||||
|
||||
// Функция для парсинга количества в наличии
|
||||
const parseStock = (stockStr: string): number => {
|
||||
const match = stockStr.match(/\d+/);
|
||||
return match ? parseInt(match[0]) : 0;
|
||||
};
|
||||
|
||||
// Функция для парсинга времени доставки
|
||||
const parseDeliveryTime = (daysStr: string): string => {
|
||||
// Если это дата (содержит название месяца), возвращаем как есть
|
||||
if (isDeliveryDate(daysStr)) {
|
||||
return daysStr;
|
||||
}
|
||||
// Иначе парсим как количество дней (для обратной совместимости)
|
||||
const match = daysStr.match(/\d+/);
|
||||
return match ? `${match[0]} дней` : daysStr;
|
||||
};
|
||||
|
||||
// Функция сортировки предложений
|
||||
const sortOffers = (offers: CoreProductCardOffer[]) => {
|
||||
const sorted = [...offers];
|
||||
|
||||
switch (sortBy) {
|
||||
case 'stock':
|
||||
return sorted.sort((a, b) => parseStock(b.pcs) - parseStock(a.pcs));
|
||||
case 'delivery':
|
||||
return sorted.sort((a, b) => {
|
||||
const aDelivery = a.deliveryTime || 999;
|
||||
const bDelivery = b.deliveryTime || 999;
|
||||
return aDelivery - bDelivery;
|
||||
});
|
||||
case 'price':
|
||||
return sorted.sort((a, b) => parsePrice(a.price) - parsePrice(b.price));
|
||||
default:
|
||||
return sorted;
|
||||
}
|
||||
};
|
||||
|
||||
const sortedOffers = sortOffers(offers);
|
||||
const displayedOffers = sortedOffers.slice(0, visibleOffersCount);
|
||||
const hasMoreOffers = visibleOffersCount < sortedOffers.length;
|
||||
|
||||
// Проверяем, есть ли товар в избранном
|
||||
const isItemFavorite = isFavorite(
|
||||
@ -74,24 +120,6 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
brand
|
||||
);
|
||||
|
||||
// Функция для парсинга цены из строки
|
||||
const parsePrice = (priceStr: string): number => {
|
||||
const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.');
|
||||
return parseFloat(cleanPrice) || 0;
|
||||
};
|
||||
|
||||
// Функция для парсинга времени доставки
|
||||
const parseDeliveryTime = (daysStr: string): string => {
|
||||
const match = daysStr.match(/\d+/);
|
||||
return match ? `${match[0]} дней` : daysStr;
|
||||
};
|
||||
|
||||
// Функция для парсинга количества в наличии
|
||||
const parseStock = (stockStr: string): number => {
|
||||
const match = stockStr.match(/\d+/);
|
||||
return match ? parseInt(match[0]) : 0;
|
||||
};
|
||||
|
||||
const handleInputChange = (idx: number, val: string) => {
|
||||
setInputValues(prev => ({ ...prev, [idx]: val }));
|
||||
if (val === "") return;
|
||||
@ -274,15 +302,19 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
<div className="w-layout-hflex core-product-search-s1">
|
||||
<div className="w-layout-vflex flex-block-48-copy">
|
||||
<div className="w-layout-vflex product-list-search-s1">
|
||||
<div className="w-layout-vflex core-product-s1">
|
||||
|
||||
<div className="w-layout-vflex flex-block-48-copy">
|
||||
|
||||
<div className="w-layout-vflex product-list-search-s1">
|
||||
<div className="w-layout-vflex core-product-s1">
|
||||
<div className="w-layout-vflex flex-block-47">
|
||||
<div className="div-block-19">
|
||||
<img src="/images/info.svg" loading="lazy" alt="info" className="image-9" />
|
||||
</div>
|
||||
<div className="w-layout-vflex flex-block-50">
|
||||
<div className="w-layout-hflex flex-block-79">
|
||||
<h3 className="heading-10 name">{brand}</h3>
|
||||
<h3 className="heading-10">{article}</h3>
|
||||
<div className="flex flex-row flex-nowrap items-center gap-2">
|
||||
<h3 className="heading-10 name" style={{marginRight: 8}}>{brand}</h3>
|
||||
<h3 className="heading-10" style={{marginRight: 8}}>{article}</h3>
|
||||
<div
|
||||
className="favorite-icon w-embed"
|
||||
onClick={handleFavoriteClick}
|
||||
@ -296,7 +328,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-block-21">{name}</div>
|
||||
<div className="text-block-21 mt-1">{name}</div>
|
||||
</div>
|
||||
</div>
|
||||
{image && (
|
||||
@ -310,14 +342,30 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-layout-vflex flex-block-48-copy">
|
||||
<div className="w-layout-vflex product-list-search-s1">
|
||||
<div className="w-layout-hflex sort-list-s1">
|
||||
<div className="w-layout-hflex flex-block-49">
|
||||
<div className="sort-item first">Наличие</div>
|
||||
<div className="sort-item">Доставка</div>
|
||||
<div
|
||||
className={`sort-item first ${sortBy === 'stock' ? 'active' : ''}`}
|
||||
onClick={() => setSortBy('stock')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
Наличие
|
||||
</div>
|
||||
<div
|
||||
className={`sort-item ${sortBy === 'delivery' ? 'active' : ''}`}
|
||||
onClick={() => setSortBy('delivery')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
Доставим
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`sort-item price ${sortBy === 'price' ? 'active' : ''}`}
|
||||
onClick={() => setSortBy('price')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
Цена
|
||||
</div>
|
||||
<div className="sort-item price">Цена</div>
|
||||
</div>
|
||||
{displayedOffers.map((offer, idx) => {
|
||||
const isLast = idx === displayedOffers.length - 1;
|
||||
@ -412,7 +460,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
className="w-layout-hflex show-more-search"
|
||||
onClick={() => {
|
||||
if (hasMoreOffers) {
|
||||
setVisibleOffersCount(prev => Math.min(prev + 10, offers.length));
|
||||
setVisibleOffersCount(prev => Math.min(prev + 10, sortedOffers.length));
|
||||
} else {
|
||||
setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
|
||||
}
|
||||
@ -420,11 +468,11 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
style={{ cursor: 'pointer' }}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={hasMoreOffers ? `Еще ${offers.length - visibleOffersCount} предложений` : 'Скрыть предложения'}
|
||||
aria-label={hasMoreOffers ? `Еще ${sortedOffers.length - visibleOffersCount} предложений` : 'Скрыть предложения'}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (hasMoreOffers) {
|
||||
setVisibleOffersCount(prev => Math.min(prev + 10, offers.length));
|
||||
setVisibleOffersCount(prev => Math.min(prev + 10, sortedOffers.length));
|
||||
} else {
|
||||
setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
|
||||
}
|
||||
@ -432,7 +480,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className="text-block-27">
|
||||
{hasMoreOffers ? `Еще ${offers.length - visibleOffersCount} предложений` : 'Скрыть'}
|
||||
{hasMoreOffers ? `Еще ${sortedOffers.length - visibleOffersCount} предложений` : 'Скрыть'}
|
||||
</div>
|
||||
<img
|
||||
src="/images/arrow_drop_down.svg"
|
||||
|
@ -88,7 +88,7 @@ const Filters: React.FC<FiltersProps> = ({
|
||||
if (filter.type === "range") {
|
||||
return (
|
||||
<FilterRange
|
||||
key={filter.title + idx}
|
||||
key={filter.title + idx + JSON.stringify((filterValues && filterValues[filter.title]) || null)}
|
||||
title={filter.title}
|
||||
min={filter.min}
|
||||
max={filter.max}
|
||||
|
@ -134,7 +134,7 @@ const FiltersPanelMobile: React.FC<FiltersPanelMobileProps> = ({
|
||||
if (filter.type === "range") {
|
||||
return (
|
||||
<FilterRange
|
||||
key={filter.title + idx}
|
||||
key={filter.title + idx + JSON.stringify(localFilterValues[filter.title] || null)}
|
||||
title={filter.title}
|
||||
min={filter.min}
|
||||
max={filter.max}
|
||||
|
@ -115,7 +115,7 @@ const Footer = () => (
|
||||
<button className="bg-[#23407A] rounded-lg py-2 px-6 font-medium mt-1 mb-2">Напиши нам</button>
|
||||
</div>
|
||||
{/* Центр: меню */}
|
||||
<div className="hidden md:flex flex-1 flex-wrap gap-10 justify-center min-w-[400px]">
|
||||
<div className="hidden md:flex flex-1 flex-wrap gap-30 justify-center min-w-[400px]">
|
||||
<div className="flex flex-col gap-3 min-w-[150px]">
|
||||
<div className="link">Подбор по марке авто</div>
|
||||
<a href="#" className="link">Поиск по VIN</a>
|
||||
@ -178,7 +178,7 @@ const Footer = () => (
|
||||
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-col items-center md:flex-row md:items-start md:justify-center flex-1 flex-wrap gap-4 md:gap-20 md:mt-6 md:min-w-[400px]">
|
||||
<div className="flex flex-col items-center md:flex-row md:items-start md:justify-center flex-1 flex-wrap gap-4 md:gap-37 md:mt-6 md:min-w-[400px]">
|
||||
<a href="#" className=" hover:underline text-xs opacity-70 text-center md:w-auto md:text-left">Политика конфиденциальности</a>
|
||||
|
||||
<a href="#" className=" hover:underline text-xs opacity-70 text-center md:w-auto md:text-left">Согласие на обработку персональных данных</a>
|
||||
|
@ -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 Link from "next/link";
|
||||
import CartButton from './CartButton';
|
||||
import SearchHistoryDropdown from './SearchHistoryDropdown';
|
||||
import { GET_RECENT_SEARCH_QUERIES, PartsSearchHistoryItem } from '@/lib/graphql/search-history';
|
||||
|
||||
interface HeaderProps {
|
||||
onOpenAuthModal?: () => void;
|
||||
@ -25,9 +27,14 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
||||
const [vehiclesByPartResults, setVehiclesByPartResults] = useState<LaximoVehiclesByPartResult | null>(null);
|
||||
const [searchType, setSearchType] = useState<'vin' | 'oem' | 'plate' | 'text'>('text');
|
||||
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 searchFormRef = useRef<HTMLFormElement>(null);
|
||||
const searchDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const isClient = useIsClient();
|
||||
|
||||
// Эффект для восстановления поискового запроса из 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(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (searchDropdownRef.current && !searchDropdownRef.current.contains(event.target as Node)) {
|
||||
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);
|
||||
};
|
||||
|
||||
// Обработчик фокуса на поле ввода
|
||||
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 (
|
||||
<>
|
||||
{/* <section className="top_head">
|
||||
@ -421,7 +493,7 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
||||
</svg></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="searcj w-form" style={{ position: 'relative' }}>
|
||||
<div className="searcj w-form" style={{ position: 'relative' }} ref={searchDropdownRef}>
|
||||
<form
|
||||
id="custom-search-form"
|
||||
name="custom-search-form"
|
||||
@ -444,23 +516,33 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="text-field w-input"
|
||||
maxLength={256}
|
||||
name="customSearch"
|
||||
data-custom-input="true"
|
||||
placeholder="Введите код запчасти, VIN номер или госномер автомобиля"
|
||||
placeholder={showPlaceholder ? "Введите код запчасти, VIN номер или госномер автомобиля" : ""}
|
||||
type="text"
|
||||
id="customSearchInput"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
disabled={isSearching}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{/* История поиска */}
|
||||
<SearchHistoryDropdown
|
||||
isVisible={showSearchHistory && !showResults}
|
||||
historyItems={searchHistoryItems}
|
||||
onItemClick={handleHistoryItemClick}
|
||||
loading={historyLoading}
|
||||
/>
|
||||
|
||||
{/* Результаты поиска VIN */}
|
||||
{showResults && searchResults.length > 0 && (searchType === 'vin' || searchType === 'plate') && (
|
||||
<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"
|
||||
>
|
||||
<div className="p-3 border-b border-gray-100">
|
||||
|
170
src/components/SearchHistoryDropdown.tsx
Normal file
170
src/components/SearchHistoryDropdown.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
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="search-history-dropdown-custom">
|
||||
{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 ? (
|
||||
<>
|
||||
{uniqueQueries.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onItemClick(item.searchQuery)}
|
||||
className="search-history-item-custom"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="search-history-icon-custom">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4l3 3" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="search-history-inline">
|
||||
<span className="search-history-query-custom">{item.searchQuery}</span>
|
||||
<span className="search-history-type-custom">{getSearchTypeLabel(item.searchType)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
<p className="text-sm">История поиска пуста</p>
|
||||
</div>
|
||||
)}
|
||||
<style>{`
|
||||
.search-history-dropdown-custom {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(44,62,80,0.10), 0 1.5px 4px rgba(44,62,80,0.08);
|
||||
margin-top: 12px;
|
||||
z-index: 50;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
padding: 6px 0;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE и Edge */
|
||||
}
|
||||
.search-history-dropdown-custom::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
.search-history-item-custom {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 0;
|
||||
transition: background 0.18s;
|
||||
display: block;
|
||||
}
|
||||
.search-history-item-custom:hover, .search-history-item-custom:focus {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.search-history-item-custom .flex {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.search-history-icon-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
color: #222;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
margin-right: 0;
|
||||
}
|
||||
.search-history-item-custom:hover .search-history-icon-custom,
|
||||
.search-history-item-custom:focus .search-history-icon-custom {
|
||||
background: #ec1c24;
|
||||
color: #fff;
|
||||
}
|
||||
.search-history-inline {
|
||||
display: flex;
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.search-history-query-custom {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
}
|
||||
.search-history-type-custom {
|
||||
font-size: 12px;
|
||||
color: #8e9aac;
|
||||
margin: 0 0 0 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchHistoryDropdown;
|
@ -2,6 +2,7 @@ import React, { useState } from "react";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import { toast } from "react-hot-toast";
|
||||
import CartIcon from "../CartIcon";
|
||||
import { isDeliveryDate } from "@/lib/utils";
|
||||
|
||||
interface ProductBuyBlockProps {
|
||||
offer?: any;
|
||||
@ -51,7 +52,9 @@ const ProductBuyBlock = ({ offer }: ProductBuyBlockProps) => {
|
||||
brand: offer.brand,
|
||||
article: offer.articleNumber,
|
||||
supplier: offer.supplier || (offer.type === 'external' ? 'AutoEuro' : 'Внутренний'),
|
||||
deliveryTime: offer.deliveryTime ? String(offer.deliveryTime) + ' дней' : '1 день',
|
||||
deliveryTime: offer.deliveryTime ? (typeof offer.deliveryTime === 'string' && isDeliveryDate(offer.deliveryTime)
|
||||
? offer.deliveryTime
|
||||
: String(offer.deliveryTime) + ' дней') : '1 день',
|
||||
isExternal: offer.type === 'external'
|
||||
});
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { isDeliveryDate } from "@/lib/utils";
|
||||
|
||||
interface ProductInfoProps {
|
||||
offer?: any;
|
||||
@ -17,6 +18,11 @@ const ProductInfo: React.FC<ProductInfoProps> = ({ offer }) => {
|
||||
|
||||
// Форматируем срок доставки
|
||||
const formatDeliveryTime = (deliveryTime: number | string) => {
|
||||
// Если это уже дата (содержит название месяца), возвращаем как есть
|
||||
if (typeof deliveryTime === 'string' && isDeliveryDate(deliveryTime)) {
|
||||
return deliveryTime;
|
||||
}
|
||||
|
||||
const days = typeof deliveryTime === 'string' ? parseInt(deliveryTime) : deliveryTime;
|
||||
|
||||
if (!days || days === 0) {
|
||||
|
@ -24,20 +24,24 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
|
||||
const [open, setOpen] = useState(true);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Обновляем локальное состояние при изменении внешнего значения
|
||||
// Обновляем локальное состояние при изменении внешнего значения или границ
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setFrom(String(value[0]));
|
||||
setTo(String(value[1]));
|
||||
setConfirmedFrom(value[0]);
|
||||
setConfirmedTo(value[1]);
|
||||
} else {
|
||||
setFrom(String(min));
|
||||
setTo(String(max));
|
||||
setConfirmedFrom(min);
|
||||
setConfirmedTo(max);
|
||||
let nextFrom = value ? value[0] : min;
|
||||
let nextTo = value ? value[1] : max;
|
||||
let changed = false;
|
||||
// Корректируем значения, если они вне новых границ
|
||||
if (nextFrom < min) { nextFrom = min; changed = true; }
|
||||
if (nextTo > max) { nextTo = max; changed = true; }
|
||||
if (nextFrom > nextTo) { nextFrom = nextTo; changed = true; }
|
||||
setFrom(String(nextFrom));
|
||||
setTo(String(nextTo));
|
||||
setConfirmedFrom(nextFrom);
|
||||
setConfirmedTo(nextTo);
|
||||
// Если значения были скорректированы, уведомляем родителя
|
||||
if (changed && onChange) {
|
||||
onChange([nextFrom, nextTo]);
|
||||
}
|
||||
}, [value, min, max]);
|
||||
}, [value, min, max, onChange]);
|
||||
|
||||
// Обновляем ширину полосы при монтировании и ресайзе
|
||||
useLayoutEffect(() => {
|
||||
|
@ -111,12 +111,62 @@ const BestPriceSection: React.FC = () => {
|
||||
<div className="text-block-58">Подборка лучших предложенийпо цене</div>
|
||||
<a href="#" className="button-24 w-button">Показать все</a>
|
||||
</div>
|
||||
<div className="carousel-row">
|
||||
<div className="carousel-row" style={{ position: 'relative' }}>
|
||||
{/* Стили для стрелок как в ProductOfDayBanner, но без абсолютного позиционирования */}
|
||||
<style>{`
|
||||
.carousel-arrow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s;
|
||||
cursor: pointer;
|
||||
margin: 0 8px;
|
||||
}
|
||||
.carousel-arrow-left {}
|
||||
.carousel-arrow-right {}
|
||||
.carousel-arrow .arrow-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.carousel-arrow:hover .arrow-circle,
|
||||
.carousel-arrow:focus .arrow-circle {
|
||||
background: #ec1c24;
|
||||
}
|
||||
.carousel-arrow .arrow-svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
transition: stroke 0.2s;
|
||||
stroke: #222;
|
||||
}
|
||||
.carousel-arrow:hover .arrow-svg,
|
||||
.carousel-arrow:focus .arrow-svg {
|
||||
stroke: #fff;
|
||||
}
|
||||
.carousel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
`}</style>
|
||||
<button className="carousel-arrow carousel-arrow-left" onClick={scrollLeft} aria-label="Прокрутить влево">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
||||
<path d="M19.5 24L12.5 16L19.5 8" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span className="arrow-circle">
|
||||
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div className="w-layout-hflex flex-block-121 carousel-scroll" ref={scrollRef}>
|
||||
{bestPriceItems.map((item, i) => (
|
||||
@ -124,10 +174,11 @@ const BestPriceSection: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
<button className="carousel-arrow carousel-arrow-right" onClick={scrollRight} aria-label="Прокрутить вправо">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
||||
<path d="M12.5 8L19.5 16L12.5 24" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span className="arrow-circle">
|
||||
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,39 +1,72 @@
|
||||
import React from "react";
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { GET_PARTSINDEX_CATEGORIES } from '@/lib/graphql';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const CategoryNavSection: React.FC = () => (
|
||||
<section className="catnav">
|
||||
<div className="w-layout-blockcontainer batd w-container">
|
||||
<div className="w-layout-hflex flex-block-108-copy">
|
||||
<div className="ci1">
|
||||
<div className="text-block-54-copy">Детали для ТО</div>
|
||||
</div>
|
||||
<div className="ci2">
|
||||
<div className="text-block-54">Шины</div>
|
||||
</div>
|
||||
<div className="ci3">
|
||||
<div className="text-block-54">Диски</div>
|
||||
</div>
|
||||
<div className="ci4">
|
||||
<div className="text-block-54">Масла и жидкости</div>
|
||||
</div>
|
||||
<div className="ci5">
|
||||
<div className="text-block-54">Инструменты</div>
|
||||
</div>
|
||||
<div className="ci6">
|
||||
<div className="text-block-54">Автохимия</div>
|
||||
</div>
|
||||
<div className="ci7">
|
||||
<div className="text-block-54">Аксессуары</div>
|
||||
</div>
|
||||
<div className="ci8">
|
||||
<div className="text-block-54">Электрика</div>
|
||||
</div>
|
||||
<div className="ci9">
|
||||
<div className="text-block-54">АКБ</div>
|
||||
interface CategoryNavItem {
|
||||
id: string;
|
||||
name: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const FALLBACK_CATEGORIES: CategoryNavItem[] = [
|
||||
{ id: '1', name: 'Детали для ТО', image: '/images/catalog_item.png' },
|
||||
{ id: '2', name: 'Шины', image: '/images/catalog_item2.png' },
|
||||
{ id: '3', name: 'Диски', image: '/images/catalog_item3.png' },
|
||||
{ id: '4', name: 'Масла и жидкости', image: '/images/catalog_item4.png' },
|
||||
{ id: '5', name: 'Инструменты', image: '/images/catalog_item5.png' },
|
||||
{ id: '6', name: 'Автохимия', image: '/images/catalog_item6.png' },
|
||||
{ id: '7', name: 'Аксессуары', image: '/images/catalog_item7.png' },
|
||||
{ id: '8', name: 'Электрика', image: '/images/catalog_item8.png' },
|
||||
{ id: '9', name: 'АКБ', image: '/images/catalog_item9.png' },
|
||||
];
|
||||
|
||||
const CategoryNavSection: React.FC = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { data } = useQuery<{ partsIndexCategoriesWithGroups: CategoryNavItem[] }>(
|
||||
GET_PARTSINDEX_CATEGORIES,
|
||||
{
|
||||
variables: { lang: 'ru' },
|
||||
errorPolicy: 'all',
|
||||
fetchPolicy: 'cache-first',
|
||||
}
|
||||
);
|
||||
|
||||
const categories = (data?.partsIndexCategoriesWithGroups && data.partsIndexCategoriesWithGroups.length > 0)
|
||||
? data.partsIndexCategoriesWithGroups.slice(0, 9)
|
||||
: FALLBACK_CATEGORIES;
|
||||
|
||||
const handleCategoryClick = (category: CategoryNavItem) => {
|
||||
router.push({
|
||||
pathname: '/catalog',
|
||||
query: {
|
||||
categoryId: category.id,
|
||||
categoryName: encodeURIComponent(category.name)
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="catnav">
|
||||
<div className="w-layout-blockcontainer batd w-container">
|
||||
<div className="w-layout-hflex flex-block-108-copy">
|
||||
{categories.map((category, idx) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className={`ci${idx + 1}`}
|
||||
style={category.image ? { cursor: 'pointer', backgroundImage: `url('${category.image}')`, backgroundSize: 'cover', backgroundPosition: 'center' } : { cursor: 'pointer' }}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
>
|
||||
<div className={idx === 0 ? 'text-block-54-copy' : 'text-block-54'} style={{ textAlign: 'center' }}>
|
||||
{category.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryNavSection;
|
@ -1,6 +1,23 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { GET_HERO_BANNERS } from '@/lib/graphql';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface HeroBanner {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageUrl: string;
|
||||
linkUrl?: string;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
const HeroSlider = () => {
|
||||
const { data, loading, error } = useQuery(GET_HERO_BANNERS, {
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && window.Webflow && window.Webflow.require) {
|
||||
if (window.Webflow.destroy) {
|
||||
@ -12,118 +29,152 @@ const HeroSlider = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Фильтруем только активные баннеры и сортируем их
|
||||
const banners: HeroBanner[] = data?.heroBanners
|
||||
?.filter((banner: HeroBanner) => banner.isActive)
|
||||
?.slice()
|
||||
?.sort((a: HeroBanner, b: HeroBanner) => a.sortOrder - b.sortOrder) || [];
|
||||
|
||||
// Если нет данных или происходит загрузка, показываем дефолтный баннер
|
||||
if (loading || error || banners.length === 0) {
|
||||
return (
|
||||
<section className="section-5" style={{ overflow: 'hidden' }}>
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div data-delay="4000" data-animation="slide" className="slider w-slider" data-autoplay="false" data-easing="ease"
|
||||
data-hide-arrows="false" data-disable-swipe="false" data-autoplay-limit="0" data-nav-spacing="3"
|
||||
data-duration="500" data-infinite="true">
|
||||
<div className="mask w-slider-mask">
|
||||
<div className="slide w-slide">
|
||||
<div className="w-layout-vflex flex-block-100">
|
||||
<div className="div-block-35">
|
||||
<img src="/images/imgfb.png" loading="lazy"
|
||||
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
|
||||
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w"
|
||||
alt="Автозапчасти ProteK"
|
||||
className="image-21" />
|
||||
</div>
|
||||
<div className="w-layout-vflex flex-block-99">
|
||||
<h2 className="heading-17">ШИРОКИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
|
||||
<div className="text-block-51">
|
||||
Сотрудничаем только с проверенными поставщиками. Постоянно обновляем
|
||||
ассортимент, чтобы предложить самые лучшие и актуальные детали.
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-101">
|
||||
<div className="w-layout-hflex flex-block-102">
|
||||
<img src="/images/1.png" loading="lazy" alt="" className="image-20" />
|
||||
<div className="text-block-52">Быстрая доставка по всей стране</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-102">
|
||||
<img src="/images/2.png" loading="lazy" alt="" className="image-20" />
|
||||
<div className="text-block-52">Высокое качество продукции</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-102">
|
||||
<img src="/images/3.png" loading="lazy" alt="" className="image-20" />
|
||||
<div className="text-block-52">Выгодные цены</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-102">
|
||||
<img src="/images/4.png" loading="lazy" alt="" className="image-20" />
|
||||
<div className="text-block-52">Профессиональная консультация</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="left-arrow w-slider-arrow-left">
|
||||
<div className="div-block-34">
|
||||
<div className="icon-2 w-icon-slider-left"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="right-arrow w-slider-arrow-right">
|
||||
<div className="div-block-34">
|
||||
<div className="icon-2 w-icon-slider-right"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="slide-nav w-slider-nav w-slider-nav-invert w-round"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const renderSlide = (banner: HeroBanner) => {
|
||||
const slideContent = (
|
||||
<div className="w-layout-vflex flex-block-100">
|
||||
<div className="div-block-35">
|
||||
<img
|
||||
src={banner.imageUrl}
|
||||
loading="lazy"
|
||||
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
|
||||
alt={banner.title}
|
||||
className="image-21"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-layout-vflex flex-block-99">
|
||||
<h2 className="heading-17">{banner.title}</h2>
|
||||
{banner.subtitle && (
|
||||
<div className="text-block-51">{banner.subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Если есть ссылка, оборачиваем в Link
|
||||
if (banner.linkUrl) {
|
||||
return (
|
||||
<Link href={banner.linkUrl} className="slide w-slide" style={{ cursor: 'pointer' }}>
|
||||
{slideContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="slide w-slide">
|
||||
{slideContent}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section-5">
|
||||
<section className="section-5" style={{ overflow: 'hidden' }}>
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div data-delay="4000" data-animation="slide" className="slider w-slider" data-autoplay="false" data-easing="ease"
|
||||
data-hide-arrows="false" data-disable-swipe="false" data-autoplay-limit="0" data-nav-spacing="3"
|
||||
data-duration="500" data-infinite="true">
|
||||
<div
|
||||
data-delay="4000"
|
||||
data-animation="slide"
|
||||
className="slider w-slider"
|
||||
data-autoplay="true"
|
||||
data-easing="ease"
|
||||
data-hide-arrows="false"
|
||||
data-disable-swipe="false"
|
||||
data-autoplay-limit="0"
|
||||
data-nav-spacing="3"
|
||||
data-duration="500"
|
||||
data-infinite="true"
|
||||
>
|
||||
<div className="mask w-slider-mask">
|
||||
<div className="slide w-slide">
|
||||
<div className="w-layout-vflex flex-block-100">
|
||||
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy"
|
||||
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
|
||||
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w" alt=""
|
||||
className="image-21" /></div>
|
||||
<div className="w-layout-vflex flex-block-99">
|
||||
<h2 className="heading-17">ШИРОКИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
|
||||
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем
|
||||
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-101">
|
||||
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt=""
|
||||
className="image-20" />
|
||||
<div className="text-block-52">Быстрая доставка по всей стране</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt=""
|
||||
className="image-20" />
|
||||
<div className="text-block-52">Высокое качество продукции</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt=""
|
||||
className="image-20" />
|
||||
<div className="text-block-52">Выгодные цены</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt=""
|
||||
className="image-20" />
|
||||
<div className="text-block-52">Профессиональная консультация</div>
|
||||
</div>
|
||||
{banners.map((banner) => (
|
||||
<React.Fragment key={banner.id}>
|
||||
{renderSlide(banner)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Показываем стрелки и навигацию только если баннеров больше одного */}
|
||||
{banners.length > 1 && (
|
||||
<>
|
||||
<div className="left-arrow w-slider-arrow-left">
|
||||
<div className="div-block-34">
|
||||
<div className="icon-2 w-icon-slider-left"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-slide">
|
||||
<div className="w-layout-vflex flex-block-100">
|
||||
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy"
|
||||
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
|
||||
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w" alt=""
|
||||
className="image-21" /></div>
|
||||
<div className="w-layout-vflex flex-block-99">
|
||||
<h2 className="heading-17">УЗКИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
|
||||
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем
|
||||
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-101">
|
||||
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt=""
|
||||
className="image-20" />
|
||||
<div className="text-block-52">Быстрая доставка по всей стране</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt=""
|
||||
className="image-20" />
|
||||
<div className="text-block-52">Высокое качество продукции</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt=""
|
||||
className="image-20" />
|
||||
<div className="text-block-52">Выгодные цены</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt=""
|
||||
className="image-20" />
|
||||
<div className="text-block-52">Профессиональная консультация</div>
|
||||
</div>
|
||||
<div className="right-arrow w-slider-arrow-right">
|
||||
<div className="div-block-34">
|
||||
<div className="icon-2 w-icon-slider-right"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-slide">
|
||||
<div className="w-layout-vflex flex-block-100">
|
||||
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy"
|
||||
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
|
||||
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w" alt=""
|
||||
className="image-21" /></div>
|
||||
<div className="w-layout-vflex flex-block-99">
|
||||
<h2 className="heading-17">ЛУЧШИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
|
||||
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем
|
||||
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-101">
|
||||
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt=""
|
||||
className="image-20" />
|
||||
<div className="text-block-52">Быстрая доставка по всей стране</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt=""
|
||||
className="image-20" />
|
||||
<div className="text-block-52">Высокое качество продукции</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt=""
|
||||
className="image-20" />
|
||||
<div className="text-block-52">Выгодные цены</div>
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt=""
|
||||
className="image-20" />
|
||||
<div className="text-block-52">Профессиональная консультация</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="left-arrow w-slider-arrow-left">
|
||||
<div className="div-block-34">
|
||||
<div className="icon-2 w-icon-slider-left"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="right-arrow w-slider-arrow-right">
|
||||
<div className="div-block-34">
|
||||
<div className="icon-2 w-icon-slider-right"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="slide-nav w-slider-nav w-slider-nav-invert w-round"></div>
|
||||
<div className="slide-nav w-slider-nav w-slider-nav-invert w-round"></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -84,16 +84,66 @@ const NewArrivalsSection: React.FC = () => {
|
||||
<h2 className="heading-4">Новое поступление</h2>
|
||||
</div>
|
||||
<div className="carousel-row">
|
||||
{/* Стили для стрелок как в BestPriceSection и TopSalesSection */}
|
||||
<style>{`
|
||||
.carousel-arrow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s;
|
||||
cursor: pointer;
|
||||
margin: 0 8px;
|
||||
}
|
||||
.carousel-arrow-left {}
|
||||
.carousel-arrow-right {}
|
||||
.carousel-arrow .arrow-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.carousel-arrow:hover .arrow-circle,
|
||||
.carousel-arrow:focus .arrow-circle {
|
||||
background: #ec1c24;
|
||||
}
|
||||
.carousel-arrow .arrow-svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
transition: stroke 0.2s;
|
||||
stroke: #222;
|
||||
}
|
||||
.carousel-arrow:hover .arrow-svg,
|
||||
.carousel-arrow:focus .arrow-svg {
|
||||
stroke: #fff;
|
||||
}
|
||||
.carousel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
`}</style>
|
||||
<button
|
||||
className="carousel-arrow carousel-arrow-left"
|
||||
onClick={scrollLeft}
|
||||
aria-label="Прокрутить влево"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
||||
<path d="M19.5 24L12.5 16L19.5 8" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span className="arrow-circle">
|
||||
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="w-layout-hflex core-product-search carousel-scroll" ref={scrollRef}>
|
||||
@ -149,10 +199,11 @@ const NewArrivalsSection: React.FC = () => {
|
||||
aria-label="Прокрутить вправо"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
||||
<path d="M12.5 8L19.5 16L12.5 24" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span className="arrow-circle">
|
||||
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -32,11 +32,61 @@ const NewsAndPromos = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="carousel-row">
|
||||
{/* Стили для стрелок как в других секциях */}
|
||||
<style>{`
|
||||
.carousel-arrow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s;
|
||||
cursor: pointer;
|
||||
margin: 0 8px;
|
||||
}
|
||||
.carousel-arrow-left {}
|
||||
.carousel-arrow-right {}
|
||||
.carousel-arrow .arrow-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.carousel-arrow:hover .arrow-circle,
|
||||
.carousel-arrow:focus .arrow-circle {
|
||||
background: #ec1c24;
|
||||
}
|
||||
.carousel-arrow .arrow-svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
transition: stroke 0.2s;
|
||||
stroke: #222;
|
||||
}
|
||||
.carousel-arrow:hover .arrow-svg,
|
||||
.carousel-arrow:focus .arrow-svg {
|
||||
stroke: #fff;
|
||||
}
|
||||
.carousel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
`}</style>
|
||||
<button className="carousel-arrow carousel-arrow-left" onClick={scrollLeft} aria-label="Прокрутить влево">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
||||
<path d="M19.5 24L12.5 16L19.5 8" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span className="arrow-circle">
|
||||
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div className="w-layout-hflex flex-block-6-copy-copy carousel-scroll" ref={scrollRef}>
|
||||
<NewsCard
|
||||
@ -69,10 +119,11 @@ const NewsAndPromos = () => {
|
||||
/>
|
||||
</div>
|
||||
<button className="carousel-arrow carousel-arrow-right" onClick={scrollRight} aria-label="Прокрутить вправо">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
||||
<path d="M12.5 8L19.5 16L12.5 24" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span className="arrow-circle">
|
||||
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
248
src/components/index/ProductOfDayBanner.tsx
Normal file
248
src/components/index/ProductOfDayBanner.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { GET_HERO_BANNERS } from '@/lib/graphql';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface HeroBanner {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageUrl: string;
|
||||
linkUrl?: string;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
// Добавим CSS для стрелок
|
||||
const arrowStyles = `
|
||||
.pod-slider-arrow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pod-slider-arrow-left { left: 12px; }
|
||||
.pod-slider-arrow-right { right: 12px; }
|
||||
.pod-slider-arrow .arrow-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.pod-slider-arrow:hover .arrow-circle,
|
||||
.pod-slider-arrow:focus .arrow-circle {
|
||||
background: #ec1c24;
|
||||
}
|
||||
.pod-slider-arrow .arrow-svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
transition: stroke 0.2s;
|
||||
stroke: #222;
|
||||
}
|
||||
.pod-slider-arrow:hover .arrow-svg,
|
||||
.pod-slider-arrow:focus .arrow-svg {
|
||||
stroke: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const slideStyles = `
|
||||
.pod-slider-slide {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(40px) scale(0.98);
|
||||
transition: opacity 0.5s cubic-bezier(.4,0,.2,1), transform 0.5s cubic-bezier(.4,0,.2,1);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.pod-slider-slide.active {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
pointer-events: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
.pod-slider-slide.prev {
|
||||
opacity: 0;
|
||||
transform: translateX(-40px) scale(0.98);
|
||||
z-index: 1;
|
||||
}
|
||||
.pod-slider-slide.next {
|
||||
opacity: 0;
|
||||
transform: translateX(40px) scale(0.98);
|
||||
z-index: 1;
|
||||
}
|
||||
.mask.w-slider-mask { position: relative; }
|
||||
`;
|
||||
|
||||
const ProductOfDayBanner: React.FC = () => {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [showArrows, setShowArrows] = useState(false);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const { data } = useQuery(GET_HERO_BANNERS, { errorPolicy: 'all' });
|
||||
|
||||
const banners: HeroBanner[] = data?.heroBanners
|
||||
?.filter((banner: HeroBanner) => banner.isActive)
|
||||
?.slice()
|
||||
?.sort((a: HeroBanner, b: HeroBanner) => a.sortOrder - b.sortOrder) || [];
|
||||
|
||||
const allBanners = banners.length > 0 ? banners : [{
|
||||
id: 'default',
|
||||
title: 'ДОСТАВИМ БЫСТРО!',
|
||||
subtitle: 'Дополнительная скидка на товары с местного склада',
|
||||
imageUrl: '/images/imgfb.png',
|
||||
linkUrl: '',
|
||||
isActive: true,
|
||||
sortOrder: 0
|
||||
}];
|
||||
|
||||
useEffect(() => {
|
||||
if (allBanners.length > 1) {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSlide(prev => (prev + 1) % allBanners.length);
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [allBanners.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSlide >= allBanners.length) {
|
||||
setCurrentSlide(0);
|
||||
}
|
||||
}, [allBanners.length, currentSlide]);
|
||||
|
||||
const handlePrevSlide = () => {
|
||||
setCurrentSlide(prev => prev === 0 ? allBanners.length - 1 : prev - 1);
|
||||
};
|
||||
|
||||
const handleNextSlide = () => {
|
||||
setCurrentSlide(prev => (prev + 1) % allBanners.length);
|
||||
};
|
||||
|
||||
const handleSlideIndicator = (index: number) => {
|
||||
setCurrentSlide(index);
|
||||
};
|
||||
|
||||
// Показывать стрелки при наведении на слайдер или стрелки
|
||||
const handleMouseEnter = () => setShowArrows(true);
|
||||
const handleMouseLeave = () => setShowArrows(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="slider w-slider"
|
||||
ref={sliderRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
tabIndex={0}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
{/* Вставляем стили для стрелок */}
|
||||
<style>{arrowStyles}{slideStyles}</style>
|
||||
<div className="mask w-slider-mask">
|
||||
{allBanners.map((banner, idx) => {
|
||||
let slideClass = 'pod-slider-slide';
|
||||
if (idx === currentSlide) slideClass += ' active';
|
||||
else if (idx === (currentSlide === 0 ? allBanners.length - 1 : currentSlide - 1)) slideClass += ' prev';
|
||||
else if (idx === (currentSlide + 1) % allBanners.length) slideClass += ' next';
|
||||
const slideContent = (
|
||||
<div
|
||||
className="div-block-128"
|
||||
style={{
|
||||
backgroundImage: `url(${banner.imageUrl})`,
|
||||
// backgroundSize: 'cover',
|
||||
// backgroundPosition: 'center',
|
||||
// backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
{/* Можно добавить текст поверх баннера, если нужно */}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={slideClass + ' slide w-slide'}
|
||||
key={banner.id}
|
||||
// style={{ display: idx === currentSlide ? 'block' : 'none', position: 'relative' }}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
|
||||
>
|
||||
{banner.linkUrl ? (
|
||||
<Link href={banner.linkUrl} style={{ display: 'block', width: '100%', height: '100%' }}>{slideContent}</Link>
|
||||
) : slideContent}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* SVG-стрелки как в Webflow, поверх баннера, с hover-эффектом */}
|
||||
<button
|
||||
className="pod-slider-arrow pod-slider-arrow-left"
|
||||
onClick={handlePrevSlide}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
opacity: showArrows ? 1 : 0,
|
||||
pointerEvents: showArrows ? 'auto' : 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-label="Предыдущий баннер"
|
||||
>
|
||||
<span className="arrow-circle">
|
||||
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="pod-slider-arrow pod-slider-arrow-right"
|
||||
onClick={handleNextSlide}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
opacity: showArrows ? 1 : 0,
|
||||
pointerEvents: showArrows ? 'auto' : 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-label="Следующий баннер"
|
||||
>
|
||||
<span className="arrow-circle">
|
||||
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div className="slide-nav w-slider-nav w-slider-nav-invert w-round">
|
||||
{allBanners.map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="w-slider-dot"
|
||||
style={{
|
||||
background: idx === currentSlide ? 'white' : 'rgba(255,255,255,0.5)',
|
||||
borderRadius: '50%',
|
||||
width: 10,
|
||||
height: 10,
|
||||
margin: 4,
|
||||
display: 'inline-block',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => handleSlideIndicator(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductOfDayBanner;
|
@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { GET_DAILY_PRODUCTS, PARTS_INDEX_SEARCH_BY_ARTICLE } from '@/lib/graphql';
|
||||
import Link from 'next/link';
|
||||
import ProductOfDayBanner from './ProductOfDayBanner';
|
||||
|
||||
interface DailyProduct {
|
||||
id: string;
|
||||
@ -31,7 +32,6 @@ const ProductOfDaySection: React.FC = () => {
|
||||
|
||||
// Состояние для текущего слайда
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data, loading, error } = useQuery<{ dailyProducts: DailyProduct[] }>(
|
||||
GET_DAILY_PRODUCTS,
|
||||
@ -49,6 +49,16 @@ const ProductOfDaySection: React.FC = () => {
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}, [data]);
|
||||
|
||||
// Корректный сброс currentSlide только если индекс вне диапазона
|
||||
useEffect(() => {
|
||||
if (currentSlide > activeProducts.length - 1) {
|
||||
setCurrentSlide(activeProducts.length > 0 ? activeProducts.length - 1 : 0);
|
||||
}
|
||||
// Если товаров стало больше и текущий слайд = 0, ничего не делаем
|
||||
// Если товаров стало меньше и текущий слайд в диапазоне, ничего не делаем
|
||||
// Если товаров стало меньше и текущий слайд вне диапазона, сбрасываем на последний
|
||||
}, [activeProducts.length]);
|
||||
|
||||
// Получаем данные из PartsIndex для текущего товара
|
||||
const currentProduct = activeProducts[currentSlide];
|
||||
const { data: partsIndexData } = useQuery(
|
||||
@ -100,10 +110,15 @@ const ProductOfDaySection: React.FC = () => {
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
// Если нет ни одной картинки, возвращаем noimage.png
|
||||
return {
|
||||
url: '/images/noimage.png',
|
||||
alt: product.name,
|
||||
source: 'noimage'
|
||||
};
|
||||
};
|
||||
|
||||
// Обработчики для слайдера
|
||||
// Обработчики для навигации по товарам дня
|
||||
const handlePrevSlide = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@ -132,11 +147,6 @@ const ProductOfDaySection: React.FC = () => {
|
||||
setCurrentSlide(index);
|
||||
};
|
||||
|
||||
// Сброс слайда при изменении товаров
|
||||
useEffect(() => {
|
||||
setCurrentSlide(0);
|
||||
}, [activeProducts]);
|
||||
|
||||
// Если нет активных товаров дня, не показываем секцию
|
||||
if (loading || error || activeProducts.length === 0) {
|
||||
return null;
|
||||
@ -153,63 +163,7 @@ const ProductOfDaySection: React.FC = () => {
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer batd w-container">
|
||||
<div className="w-layout-hflex flex-block-108">
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className="slider w-slider"
|
||||
>
|
||||
<div className="mask w-slider-mask">
|
||||
{activeProducts.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`slide w-slide ${index === currentSlide ? 'w--current' : ''}`}
|
||||
style={{
|
||||
display: index === currentSlide ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
<div className="div-block-128"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Стрелки слайдера (показываем только если товаров больше 1) */}
|
||||
{activeProducts.length > 1 && (
|
||||
<>
|
||||
<div className="left-arrow w-slider-arrow-left">
|
||||
<div className="div-block-34">
|
||||
<div className="code-embed-14 w-embed">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="right-arrow w-slider-arrow-right">
|
||||
<div className="div-block-34 right">
|
||||
<div className="code-embed-14 w-embed">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Индикаторы слайдов */}
|
||||
{activeProducts.length > 1 && (
|
||||
<div className="slide-nav w-slider-nav w-slider-nav-invert w-round">
|
||||
{activeProducts.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-slider-dot ${index === currentSlide ? 'w--current' : ''}`}
|
||||
onClick={() => handleSlideIndicator(index)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
style={{ cursor: 'pointer', zIndex: 10 }}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ProductOfDayBanner />
|
||||
|
||||
<div className="div-block-129">
|
||||
<div className="w-layout-hflex flex-block-109">
|
||||
@ -244,7 +198,7 @@ const ProductOfDaySection: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{productImage && (
|
||||
<div className="relative">
|
||||
<div className="">
|
||||
<img
|
||||
width="Auto"
|
||||
height="Auto"
|
||||
@ -260,6 +214,11 @@ const ProductOfDaySection: React.FC = () => {
|
||||
Parts Index
|
||||
</div>
|
||||
)}
|
||||
{productImage.source === 'noimage' && (
|
||||
<div className="absolute bottom-0 right-0 bg-gray-400 text-white text-xs px-2 py-1 rounded-tl">
|
||||
Нет изображения
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -143,11 +143,61 @@ const TopSalesSection: React.FC = () => {
|
||||
<h2 className="heading-4">Топ продаж</h2>
|
||||
</div>
|
||||
<div className="carousel-row">
|
||||
{/* Стили для стрелок как в BestPriceSection */}
|
||||
<style>{`
|
||||
.carousel-arrow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s;
|
||||
cursor: pointer;
|
||||
margin: 0 8px;
|
||||
}
|
||||
.carousel-arrow-left {}
|
||||
.carousel-arrow-right {}
|
||||
.carousel-arrow .arrow-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.carousel-arrow:hover .arrow-circle,
|
||||
.carousel-arrow:focus .arrow-circle {
|
||||
background: #ec1c24;
|
||||
}
|
||||
.carousel-arrow .arrow-svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
transition: stroke 0.2s;
|
||||
stroke: #222;
|
||||
}
|
||||
.carousel-arrow:hover .arrow-svg,
|
||||
.carousel-arrow:focus .arrow-svg {
|
||||
stroke: #fff;
|
||||
}
|
||||
.carousel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
`}</style>
|
||||
<button className="carousel-arrow carousel-arrow-left" onClick={scrollLeft} aria-label="Прокрутить влево">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
||||
<path d="M19.5 24L12.5 16L19.5 8" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span className="arrow-circle">
|
||||
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div className="w-layout-hflex core-product-search carousel-scroll" ref={scrollRef}>
|
||||
{activeTopSalesProducts.map((item: TopSalesProductData) => {
|
||||
@ -177,10 +227,11 @@ const TopSalesSection: React.FC = () => {
|
||||
})}
|
||||
</div>
|
||||
<button className="carousel-arrow carousel-arrow-right" onClick={scrollRight} aria-label="Прокрутить вправо">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/>
|
||||
<path d="M12.5 8L19.5 16L12.5 24" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span className="arrow-circle">
|
||||
<svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -284,7 +284,7 @@ const ProfileHistoryMain = () => {
|
||||
|
||||
if (loading && historyItems.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center text-base min-h-[526px] h-full">
|
||||
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full min-h-[526px] h-full">
|
||||
<div className="flex justify-center items-center h-40">
|
||||
<div className="text-gray-500">Загрузка истории поиска...</div>
|
||||
</div>
|
||||
@ -294,7 +294,7 @@ const ProfileHistoryMain = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center text-base min-h-[526px]">
|
||||
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full min-h-[526px]">
|
||||
<div className="flex justify-center items-center h-40">
|
||||
<div className="text-red-500">Ошибка загрузки истории поиска</div>
|
||||
</div>
|
||||
@ -303,7 +303,7 @@ const ProfileHistoryMain = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-[526px]">
|
||||
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full min-h-[526px]">
|
||||
<div className="flex gap-5 items-center px-8 py-3 w-full leading-snug text-gray-400 whitespace-nowrap bg-white rounded-lg max-md:px-5 max-md:max-w-full">
|
||||
<div className="flex-1 shrink self-stretch my-auto text-gray-400 basis-0 text-ellipsis max-md:max-w-full max-md:w-full">
|
||||
<SearchInput
|
||||
|
@ -55,6 +55,7 @@ const KnotIn: React.FC<KnotInProps> = ({
|
||||
const [selectedDetail, setSelectedDetail] = useState<{ oem: string; name: string } | null>(null);
|
||||
const [hoveredCodeOnImage, setHoveredCodeOnImage] = useState<string | number | null>(null);
|
||||
const router = useRouter();
|
||||
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
|
||||
|
||||
// Получаем инфо об узле (для картинки)
|
||||
console.log('🔍 KnotIn - GET_LAXIMO_UNIT_INFO запрос:', {
|
||||
@ -164,6 +165,12 @@ const KnotIn: React.FC<KnotInProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
// Обработчик клика по картинке (zoom)
|
||||
const handleImageClick = (e: React.MouseEvent<HTMLImageElement>) => {
|
||||
// Если клик был по точке, не открываем модалку (точки выше по z-index)
|
||||
setIsImageModalOpen(true);
|
||||
};
|
||||
|
||||
// Обработчик наведения на точку
|
||||
const handlePointHover = (coord: any) => {
|
||||
// Попробуем использовать разные поля для связи
|
||||
@ -318,8 +325,9 @@ const KnotIn: React.FC<KnotInProps> = ({
|
||||
loading="lazy"
|
||||
alt={unitName || unitInfo?.name || "Изображение узла"}
|
||||
onLoad={handleImageLoad}
|
||||
className="max-w-full h-auto mx-auto rounded"
|
||||
className="max-w-full h-auto mx-auto rounded cursor-zoom-in"
|
||||
style={{ maxWidth: 400, display: 'block' }}
|
||||
onClick={handleImageClick}
|
||||
/>
|
||||
{/* Точки/области */}
|
||||
{coordinates.map((coord: any, idx: number) => {
|
||||
@ -369,8 +377,8 @@ const KnotIn: React.FC<KnotInProps> = ({
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
title={`${codeValue} (Клик - выделить в списке, двойной клик - перейти к выбору бренда)`}
|
||||
onClick={() => handlePointClick(coord)}
|
||||
onDoubleClick={() => handlePointDoubleClick(coord)}
|
||||
onClick={e => { e.stopPropagation(); handlePointClick(coord); }}
|
||||
onDoubleClick={e => { e.stopPropagation(); handlePointDoubleClick(coord); }}
|
||||
onMouseEnter={() => handlePointHover(coord)}
|
||||
onMouseLeave={() => {
|
||||
setHoveredCodeOnImage(null);
|
||||
@ -388,7 +396,34 @@ const KnotIn: React.FC<KnotInProps> = ({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Модалка увеличенного изображения */}
|
||||
{isImageModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/20 bg-opacity-70"
|
||||
onClick={() => setIsImageModalOpen(false)}
|
||||
style={{ cursor: 'zoom-out' }}
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={unitName || unitInfo?.name || "Изображение узла"}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded shadow-lg"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ background: '#fff' }}
|
||||
/>
|
||||
{/* Убираем интерактивные точки в модальном окне */}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsImageModalOpen(false)}
|
||||
className="absolute top-4 right-4 text-white text-3xl font-bold bg-black bg-opacity-40 rounded-full w-10 h-10 flex items-center justify-center hover:bg-black hover:bg-opacity-60 transition-colors"
|
||||
aria-label="Закрыть"
|
||||
style={{ zIndex: 10000 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Модалка выбора бренда */}
|
||||
<BrandSelectionModal
|
||||
isOpen={isBrandModalOpen}
|
||||
|
@ -36,7 +36,9 @@ const KnotParts: React.FC<KnotPartsProps> = ({
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
|
||||
const [tooltipPart, setTooltipPart] = useState<any>(null);
|
||||
const [clickedPart, setClickedPart] = useState<string | number | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Отладочные логи для проверки данных
|
||||
React.useEffect(() => {
|
||||
@ -63,8 +65,31 @@ const KnotParts: React.FC<KnotPartsProps> = ({
|
||||
|
||||
// Обработчик клика по детали в списке
|
||||
const handlePartClick = (part: any) => {
|
||||
if (part.codeonimage && onPartSelect) {
|
||||
onPartSelect(part.codeonimage);
|
||||
const codeOnImage = part.codeonimage || part.detailid;
|
||||
if (codeOnImage && onPartSelect) {
|
||||
onPartSelect(codeOnImage);
|
||||
}
|
||||
|
||||
// Также подсвечиваем деталь на схеме при клике
|
||||
if (codeOnImage && onPartHover) {
|
||||
// Очищаем предыдущий таймер, если он есть
|
||||
if (clickTimeoutRef.current) {
|
||||
clearTimeout(clickTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Устанавливаем состояние кликнутой детали
|
||||
setClickedPart(codeOnImage);
|
||||
|
||||
// Подсвечиваем на схеме
|
||||
onPartHover(codeOnImage);
|
||||
|
||||
// Убираем подсветку через интервал
|
||||
clickTimeoutRef.current = setTimeout(() => {
|
||||
setClickedPart(null);
|
||||
if (onPartHover) {
|
||||
onPartHover(null);
|
||||
}
|
||||
}, 1500); // Подсветка будет видна 1.5 секунды
|
||||
}
|
||||
};
|
||||
|
||||
@ -150,6 +175,9 @@ const KnotParts: React.FC<KnotPartsProps> = ({
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (clickTimeoutRef.current) {
|
||||
clearTimeout(clickTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -213,12 +241,17 @@ const KnotParts: React.FC<KnotPartsProps> = ({
|
||||
|
||||
<div className="knot-parts">
|
||||
{parts.map((part, idx) => {
|
||||
const codeOnImage = part.codeonimage || part.detailid;
|
||||
const isHighlighted = highlightedCodeOnImage !== null && highlightedCodeOnImage !== undefined && (
|
||||
(part.codeonimage && part.codeonimage.toString() === highlightedCodeOnImage.toString()) ||
|
||||
(part.detailid && part.detailid.toString() === highlightedCodeOnImage.toString())
|
||||
);
|
||||
|
||||
const isSelected = selectedParts.has(part.detailid || part.codeonimage || idx.toString());
|
||||
const isClicked = clickedPart !== null && (
|
||||
(part.codeonimage && part.codeonimage.toString() === clickedPart.toString()) ||
|
||||
(part.detailid && part.detailid.toString() === clickedPart.toString())
|
||||
);
|
||||
|
||||
// Создаем уникальный ключ
|
||||
const uniqueKey = `part-${idx}-${part.detailid || part.oem || part.name || 'unknown'}`;
|
||||
@ -226,12 +259,14 @@ const KnotParts: React.FC<KnotPartsProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
className={`w-layout-hflex knotlistitem rounded-lg cursor-pointer transition-colors ${
|
||||
className={`w-layout-hflex knotlistitem rounded-lg cursor-pointer transition-all duration-300 ${
|
||||
isSelected
|
||||
? 'bg-green-100 border-green-500'
|
||||
: isHighlighted
|
||||
? 'bg-slate-200'
|
||||
: 'bg-white border-gray-200 hover:border-gray-300'
|
||||
: isClicked
|
||||
? 'bg-red-100 border-red-400 shadow-md'
|
||||
: isHighlighted
|
||||
? 'bg-slate-200'
|
||||
: 'bg-white border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => handlePartClick(part)}
|
||||
onMouseEnter={() => handlePartMouseEnter(part)}
|
||||
@ -240,13 +275,37 @@ const KnotParts: React.FC<KnotPartsProps> = ({
|
||||
>
|
||||
<div className="w-layout-hflex flex-block-116">
|
||||
<div
|
||||
className={`nuberlist ${isSelected ? 'text-green-700 font-bold' : isHighlighted ? ' font-bold' : ''}`}
|
||||
className={`nuberlist ${
|
||||
isSelected
|
||||
? 'text-green-700 font-bold'
|
||||
: isClicked
|
||||
? 'text-red-700 font-bold'
|
||||
: isHighlighted
|
||||
? 'font-bold'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{part.codeonimage || idx + 1}
|
||||
</div>
|
||||
<div className={`oemnuber ${isSelected ? 'text-green-800 font-semibold' : isHighlighted ? ' font-semibold' : ''}`}>{part.oem}</div>
|
||||
<div className={`oemnuber ${
|
||||
isSelected
|
||||
? 'text-green-800 font-semibold'
|
||||
: isClicked
|
||||
? 'text-red-800 font-semibold'
|
||||
: isHighlighted
|
||||
? 'font-semibold'
|
||||
: ''
|
||||
}`}>{part.oem}</div>
|
||||
</div>
|
||||
<div className={`partsname ${isSelected ? 'text-green-800 font-semibold' : isHighlighted ? ' font-semibold' : ''}`}>
|
||||
<div className={`partsname ${
|
||||
isSelected
|
||||
? 'text-green-800 font-semibold'
|
||||
: isClicked
|
||||
? 'text-red-800 font-semibold'
|
||||
: isHighlighted
|
||||
? 'font-semibold'
|
||||
: ''
|
||||
}`}>
|
||||
{part.name}
|
||||
</div>
|
||||
<div className="w-layout-hflex flex-block-117">
|
||||
|
@ -19,6 +19,7 @@ interface VehicleAttributesTooltipProps {
|
||||
const VehicleAttributesTooltip: React.FC<VehicleAttributesTooltipProps> = ({
|
||||
show,
|
||||
position,
|
||||
vehicleName,
|
||||
vehicleAttributes,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
@ -27,7 +28,7 @@ const VehicleAttributesTooltip: React.FC<VehicleAttributesTooltipProps> = ({
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div
|
||||
className="flex overflow-hidden flex-col items-center px-8 py-8 bg-slate-50 shadow-[0px_0px_20px_rgba(0,0,0,0.15)] rounded-2xl w-[450px] min-h-[365px] max-w-full fixed z-[9999]"
|
||||
className="flex overflow-hidden flex-col items-center px-8 py-8 bg-slate-50 shadow-[0px_0px_20px_rgba(0,0,0,0.15)] rounded-2xl w-[450px] max-w-full fixed z-[9999]"
|
||||
style={{
|
||||
left: `${position.x + 120}px`,
|
||||
top: `${position.y}px`,
|
||||
@ -45,16 +46,33 @@ const VehicleAttributesTooltip: React.FC<VehicleAttributesTooltipProps> = ({
|
||||
/>
|
||||
)}
|
||||
<div className="flex relative flex-col w-full">
|
||||
{vehicleAttributes.map((attr, idx) => (
|
||||
<div key={idx} className="flex gap-5 items-center mt-2 w-full whitespace-nowrap first:mt-0">
|
||||
<div className="self-stretch my-auto text-gray-400 w-[150px] truncate">
|
||||
{attr.name}
|
||||
</div>
|
||||
<div className="self-stretch my-auto font-medium text-black truncate">
|
||||
{attr.value}
|
||||
{/* Заголовок */}
|
||||
{vehicleName && (
|
||||
<div className="font-semibold text-lg text-black mb-3 truncate">{vehicleName}</div>
|
||||
)}
|
||||
{/* Список характеристик или сообщение */}
|
||||
{vehicleAttributes.length > 0 ? (
|
||||
vehicleAttributes.map((attr, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="grid grid-cols-[150px_1fr] gap-x-5 items-start mt-2 w-full first:mt-0"
|
||||
>
|
||||
<div className="text-gray-400 break-words whitespace-normal text-left">
|
||||
{attr.name}
|
||||
</div>
|
||||
<div
|
||||
className="font-medium text-black break-words whitespace-normal text-left justify-self-start"
|
||||
style={{ textAlign: 'left' }}
|
||||
>
|
||||
{attr.value}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full py-8">
|
||||
<div className="text-gray-400 mb-2">Дополнительная информация недоступна</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -88,21 +88,19 @@ const VinQuick: React.FC<VinQuickProps> = ({ quickGroup, catalogCode, vehicleId,
|
||||
))}
|
||||
{total > 3 && shownCount < total && (
|
||||
<div className="flex gap-2 mt-2 w-full">
|
||||
{shownCount + 3 < total && (
|
||||
<button
|
||||
className="expand-btn"
|
||||
onClick={() => setShownCounts(prev => ({ ...prev, [unit.unitid]: shownCount + 3 }))}
|
||||
style={{ border: '1px solid #EC1C24', borderRadius: 8, background: '#fff', color: '#222', padding: '6px 18px', minWidth: 180 }}
|
||||
>
|
||||
Развернуть
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" style={{ display: 'inline', verticalAlign: 'middle', marginLeft: 4 }}>
|
||||
<path d="M4 6l4 4 4-4" stroke="#222" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="expand-btn"
|
||||
onClick={() => setShownCounts(prev => ({ ...prev, [unit.unitid]: total }))}
|
||||
style={{ border: '1px solid #EC1C24', borderRadius: 8, background: '#fff', color: '#222', padding: '6px 18px', minWidth: 180 }}
|
||||
>
|
||||
Развернуть
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" style={{ display: 'inline', verticalAlign: 'middle', marginLeft: 4 }}>
|
||||
<path d="M4 6l4 4 4-4" stroke="#222" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="showall-btn"
|
||||
onClick={() => setShownCounts(prev => ({ ...prev, [unit.unitid]: total }))}
|
||||
onClick={() => handleUnitClick(unit)}
|
||||
style={{ background: '#e9eef5', borderRadius: 8, color: '#222', padding: '6px 18px', border: 'none'}}
|
||||
>
|
||||
Показать все
|
||||
|
@ -4,7 +4,7 @@ import React, { createContext, useContext, useReducer, useEffect, ReactNode } fr
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import toast from 'react-hot-toast'
|
||||
import { GET_FAVORITES, ADD_TO_FAVORITES, REMOVE_FROM_FAVORITES, CLEAR_FAVORITES } from '@/lib/favorites-queries'
|
||||
import DeleteCartIcon from '@/components/DeleteCartIcon'
|
||||
import CloseIcon from '@/components/CloseIcon'
|
||||
|
||||
// Типы
|
||||
export interface FavoriteItem {
|
||||
@ -135,7 +135,7 @@ const FavoritesProvider: React.FC<FavoritesProviderProps> = ({ children }) => {
|
||||
const [removeFavoriteMutation] = useMutation(REMOVE_FROM_FAVORITES, {
|
||||
onCompleted: () => {
|
||||
toast('Товар удален из избранного', {
|
||||
icon: <DeleteCartIcon size={20} color="#ec1c24" />,
|
||||
icon: <CloseIcon size={20} color="#fff" />,
|
||||
style: {
|
||||
background: '#6b7280', // Серый фон
|
||||
color: '#fff', // Белый текст
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useLazyQuery } from '@apollo/client';
|
||||
import { SEARCH_PRODUCT_OFFERS } from '@/lib/graphql';
|
||||
|
||||
@ -33,17 +33,19 @@ interface ProductPriceVariables {
|
||||
brand: string;
|
||||
}
|
||||
|
||||
export const useProductPrices = (products: Array<{ code: string; brand: string; id: string }>) => {
|
||||
export const useProductPrices = () => {
|
||||
const [pricesMap, setPricesMap] = useState<Map<string, ProductOffer | null>>(new Map());
|
||||
const [loadingPrices, setLoadingPrices] = useState<Set<string>>(new Set());
|
||||
const [loadedPrices, setLoadedPrices] = useState<Set<string>>(new Set());
|
||||
|
||||
const [searchOffers] = useLazyQuery<ProductPriceData, ProductPriceVariables>(SEARCH_PRODUCT_OFFERS);
|
||||
|
||||
const loadPrice = async (product: { code: string; brand: string; id: string }) => {
|
||||
const loadPrice = useCallback(async (product: { code: string; brand: string; id: string }) => {
|
||||
const key = `${product.id}_${product.code}_${product.brand}`;
|
||||
|
||||
if (pricesMap.has(key) || loadingPrices.has(key)) {
|
||||
return; // Уже загружено или загружается
|
||||
// Если уже загружено или загружается - не делаем повторный запрос
|
||||
if (loadedPrices.has(key) || loadingPrices.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('💰 Загружаем цену для:', product.code, product.brand);
|
||||
@ -87,35 +89,31 @@ export const useProductPrices = (products: Array<{ code: string; brand: string;
|
||||
newSet.delete(key);
|
||||
return newSet;
|
||||
});
|
||||
setLoadedPrices(prev => new Set([...prev, key]));
|
||||
}
|
||||
};
|
||||
}, [searchOffers, loadedPrices, loadingPrices]);
|
||||
|
||||
useEffect(() => {
|
||||
// Загружаем цены для всех товаров с небольшой задержкой между запросами
|
||||
products.forEach((product, index) => {
|
||||
setTimeout(() => {
|
||||
loadPrice(product);
|
||||
}, index * 100); // Задержка 100мс между запросами
|
||||
});
|
||||
}, [products]);
|
||||
|
||||
const getPrice = (product: { code: string; brand: string; id: string }) => {
|
||||
const getPrice = useCallback((product: { code: string; brand: string; id: string }) => {
|
||||
const key = `${product.id}_${product.code}_${product.brand}`;
|
||||
return pricesMap.get(key);
|
||||
};
|
||||
}, [pricesMap]);
|
||||
|
||||
const isLoadingPrice = (product: { code: string; brand: string; id: string }) => {
|
||||
const isLoadingPrice = useCallback((product: { code: string; brand: string; id: string }) => {
|
||||
const key = `${product.id}_${product.code}_${product.brand}`;
|
||||
return loadingPrices.has(key);
|
||||
};
|
||||
}, [loadingPrices]);
|
||||
|
||||
const loadPriceOnDemand = (product: { code: string; brand: string; id: string }) => {
|
||||
loadPrice(product);
|
||||
};
|
||||
const ensurePriceLoaded = useCallback((product: { code: string; brand: string; id: string }) => {
|
||||
const key = `${product.id}_${product.code}_${product.brand}`;
|
||||
if (!loadedPrices.has(key) && !loadingPrices.has(key)) {
|
||||
loadPrice(product);
|
||||
}
|
||||
}, [loadPrice, loadedPrices, loadingPrices]);
|
||||
|
||||
return {
|
||||
getPrice,
|
||||
isLoadingPrice,
|
||||
loadPriceOnDemand
|
||||
loadPrice,
|
||||
ensurePriceLoaded
|
||||
};
|
||||
};
|
@ -45,6 +45,20 @@ export const GET_TOP_SALES_PRODUCTS = gql`
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_HERO_BANNERS = gql`
|
||||
query GetHeroBanners {
|
||||
heroBanners {
|
||||
id
|
||||
title
|
||||
subtitle
|
||||
imageUrl
|
||||
linkUrl
|
||||
isActive
|
||||
sortOrder
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CHECK_CLIENT_BY_PHONE = gql`
|
||||
mutation CheckClientByPhone($phone: String!) {
|
||||
checkClientByPhone(phone: $phone) {
|
||||
|
@ -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`
|
||||
mutation DeletePartsSearchHistoryItem($id: ID!) {
|
||||
deletePartsSearchHistoryItem(id: $id)
|
||||
|
@ -92,4 +92,14 @@ export const memoize = <T extends (...args: any[]) => any>(
|
||||
// Очистка кэша мемоизации
|
||||
export const clearMemoCache = () => {
|
||||
memoCache.clear();
|
||||
};
|
||||
|
||||
// Проверка, является ли строка датой доставки
|
||||
export const isDeliveryDate = (dateString: string): boolean => {
|
||||
const months = [
|
||||
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
|
||||
];
|
||||
|
||||
return months.some(month => dateString.includes(month));
|
||||
};
|
@ -52,18 +52,20 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
<Component {...pageProps} />
|
||||
</Layout>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
marginTop: '80px', // Отступ сверху, чтобы не закрывать кнопки меню
|
||||
},
|
||||
success: {
|
||||
duration: 3000,
|
||||
style: {
|
||||
background: '#22c55e', // Зеленый фон для успешных уведомлений
|
||||
color: '#fff', // Белый текст
|
||||
marginTop: '80px', // Отступ сверху для успешных уведомлений
|
||||
},
|
||||
iconTheme: {
|
||||
primary: '#22c55e',
|
||||
@ -72,6 +74,9 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
},
|
||||
error: {
|
||||
duration: 5000,
|
||||
style: {
|
||||
marginTop: '80px', // Отступ сверху для ошибок
|
||||
},
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
|
@ -38,7 +38,8 @@ const mockData = Array(12).fill({
|
||||
brand: "Borsehung",
|
||||
});
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
const ITEMS_PER_PAGE = 50; // Целевое количество товаров на странице
|
||||
const PARTSINDEX_PAGE_SIZE = 25; // Размер страницы PartsIndex API (фиксированный)
|
||||
const MAX_BRANDS_DISPLAY = 10; // Сколько брендов показывать изначально
|
||||
|
||||
export default function Catalog() {
|
||||
@ -72,6 +73,13 @@ export default function Catalog() {
|
||||
const [showEmptyState, setShowEmptyState] = useState(false);
|
||||
const [partsIndexPage, setPartsIndexPage] = useState(1); // Текущая страница для PartsIndex
|
||||
const [totalPages, setTotalPages] = useState(1); // Общее количество страниц
|
||||
|
||||
// Новые состояния для логики автоподгрузки PartsIndex
|
||||
const [accumulatedEntities, setAccumulatedEntities] = useState<PartsIndexEntity[]>([]); // Все накопленные товары
|
||||
const [entitiesWithOffers, setEntitiesWithOffers] = useState<PartsIndexEntity[]>([]); // Товары с предложениями
|
||||
const [isAutoLoading, setIsAutoLoading] = useState(false); // Автоматическая подгрузка в процессе
|
||||
const [currentUserPage, setCurrentUserPage] = useState(1); // Текущая пользовательская страница
|
||||
const [entitiesCache, setEntitiesCache] = useState<Map<number, PartsIndexEntity[]>>(new Map()); // Кэш страниц
|
||||
|
||||
// Карта видимости товаров по индексу
|
||||
const [visibilityMap, setVisibilityMap] = useState<Map<number, boolean>>(new Map());
|
||||
@ -108,7 +116,8 @@ export default function Catalog() {
|
||||
categoryName,
|
||||
isPartsAPIMode,
|
||||
isPartsIndexMode,
|
||||
isPartsIndexCatalogOnly
|
||||
isPartsIndexCatalogOnly,
|
||||
'router.query': router.query
|
||||
});
|
||||
|
||||
// Загружаем артикулы PartsAPI
|
||||
@ -135,7 +144,7 @@ export default function Catalog() {
|
||||
catalogId: catalogId as string,
|
||||
groupId: groupId as string,
|
||||
lang: 'ru',
|
||||
limit: ITEMS_PER_PAGE,
|
||||
limit: PARTSINDEX_PAGE_SIZE,
|
||||
page: partsIndexPage,
|
||||
q: searchQuery || undefined,
|
||||
params: undefined // Будем обновлять через refetch
|
||||
@ -164,12 +173,24 @@ export default function Catalog() {
|
||||
// allEntities больше не используется - используем allLoadedEntities
|
||||
|
||||
// Хук для загрузки цен товаров PartsIndex
|
||||
const productsForPrices = visibleEntities.map(entity => ({
|
||||
id: entity.id,
|
||||
code: entity.code,
|
||||
brand: entity.brand.name
|
||||
}));
|
||||
const { getPrice, isLoadingPrice, loadPriceOnDemand } = useProductPrices(productsForPrices);
|
||||
const { getPrice, isLoadingPrice, ensurePriceLoaded } = useProductPrices();
|
||||
|
||||
// Загружаем цены для видимых товаров PartsIndex
|
||||
useEffect(() => {
|
||||
if (isPartsIndexMode && visibleEntities.length > 0) {
|
||||
visibleEntities.forEach((entity, index) => {
|
||||
const productForPrice = {
|
||||
id: entity.id,
|
||||
code: entity.code,
|
||||
brand: entity.brand.name
|
||||
};
|
||||
// Загружаем с небольшой задержкой чтобы не перегружать сервер
|
||||
setTimeout(() => {
|
||||
ensurePriceLoaded(productForPrice);
|
||||
}, index * 50);
|
||||
});
|
||||
}
|
||||
}, [isPartsIndexMode, visibleEntities, ensurePriceLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (articlesData?.partsAPIArticles) {
|
||||
@ -193,9 +214,6 @@ export default function Catalog() {
|
||||
const newEntities = entitiesData.partsIndexCatalogEntities.list;
|
||||
const pagination = entitiesData.partsIndexCatalogEntities.pagination;
|
||||
|
||||
// Обновляем список товаров
|
||||
setVisibleEntities(newEntities);
|
||||
|
||||
// Обновляем информацию о пагинации
|
||||
const currentPage = pagination?.page?.current || 1;
|
||||
const hasNext = pagination?.page?.next !== null;
|
||||
@ -204,6 +222,20 @@ export default function Catalog() {
|
||||
setPartsIndexPage(currentPage);
|
||||
setHasMoreEntities(hasNext);
|
||||
|
||||
// Сохраняем в кэш
|
||||
setEntitiesCache(prev => new Map(prev).set(currentPage, newEntities));
|
||||
|
||||
// Если это первая страница или сброс, заменяем накопленные товары
|
||||
if (currentPage === 1) {
|
||||
setAccumulatedEntities(newEntities);
|
||||
// Устанавливаем visibleEntities сразу, не дожидаясь проверки цен
|
||||
setVisibleEntities(newEntities);
|
||||
console.log('✅ Установлены visibleEntities для первой страницы:', newEntities.length);
|
||||
} else {
|
||||
// Добавляем к накопленным товарам
|
||||
setAccumulatedEntities(prev => [...prev, ...newEntities]);
|
||||
}
|
||||
|
||||
// Вычисляем общее количество страниц (приблизительно)
|
||||
if (hasNext) {
|
||||
setTotalPages(currentPage + 1); // Минимум еще одна страница
|
||||
@ -216,7 +248,7 @@ export default function Catalog() {
|
||||
}, [entitiesData]);
|
||||
|
||||
// Преобразование выбранных фильтров в формат PartsIndex API
|
||||
const convertFiltersToPartsIndexParams = useCallback((): Record<string, any> => {
|
||||
const convertFiltersToPartsIndexParams = useMemo((): Record<string, any> => {
|
||||
if (!paramsData?.partsIndexCatalogParams?.list || Object.keys(selectedFilters).length === 0) {
|
||||
return {};
|
||||
}
|
||||
@ -241,6 +273,84 @@ export default function Catalog() {
|
||||
return apiParams;
|
||||
}, [paramsData, selectedFilters]);
|
||||
|
||||
// Функция автоматической подгрузки дополнительных страниц PartsIndex
|
||||
const autoLoadMoreEntities = useCallback(async () => {
|
||||
if (isAutoLoading || !hasMoreEntities || !isPartsIndexMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔄 Автоподгрузка: проверяем товары с предложениями...');
|
||||
|
||||
// Восстанавливаем автоподгрузку
|
||||
console.log('🔄 Автоподгрузка активна');
|
||||
|
||||
// Подсчитываем текущее количество товаров с предложениями
|
||||
const currentEntitiesWithOffers = accumulatedEntities.filter(entity => {
|
||||
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
|
||||
const priceData = getPrice(productForPrice);
|
||||
const isLoadingPriceData = isLoadingPrice(productForPrice);
|
||||
// Товар считается "с предложениями" если у него есть реальная цена (не null и не undefined)
|
||||
return (priceData && priceData.price && priceData.price > 0) || isLoadingPriceData;
|
||||
});
|
||||
|
||||
console.log('📊 Автоподгрузка: текущее состояние:', {
|
||||
накопленоТоваров: accumulatedEntities.length,
|
||||
сПредложениями: currentEntitiesWithOffers.length,
|
||||
целевоеКоличество: ITEMS_PER_PAGE,
|
||||
естьЕщеТовары: hasMoreEntities
|
||||
});
|
||||
|
||||
// Если у нас уже достаточно товаров с предложениями, не загружаем
|
||||
if (currentEntitiesWithOffers.length >= ITEMS_PER_PAGE) {
|
||||
console.log('✅ Автоподгрузка: достаточно товаров с предложениями');
|
||||
return;
|
||||
}
|
||||
|
||||
// Даем время на загрузку цен товаров, если их слишком много загружается
|
||||
const loadingCount = accumulatedEntities.filter(entity => {
|
||||
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
|
||||
return isLoadingPrice(productForPrice);
|
||||
}).length;
|
||||
|
||||
// Ждем только если загружается больше 5 товаров одновременно
|
||||
if (loadingCount > 5) {
|
||||
console.log('⏳ Автоподгрузка: ждем загрузки цен для', loadingCount, 'товаров (больше 5)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Если накопили уже много товаров, но мало с предложениями - прекращаем попытки
|
||||
if (accumulatedEntities.length >= ITEMS_PER_PAGE * 8) { // Увеличили лимит с 4 до 8 страниц
|
||||
console.log('⚠️ Автоподгрузка: достигли лимита попыток, прекращаем');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAutoLoading(true);
|
||||
|
||||
try {
|
||||
console.log('🔄 Автоподгрузка: загружаем следующую страницу PartsIndex...');
|
||||
|
||||
const apiParams = convertFiltersToPartsIndexParams;
|
||||
const paramsString = Object.keys(apiParams).length > 0 ? JSON.stringify(apiParams) : undefined;
|
||||
|
||||
const result = await refetchEntities({
|
||||
catalogId: catalogId as string,
|
||||
groupId: groupId as string,
|
||||
lang: 'ru',
|
||||
limit: PARTSINDEX_PAGE_SIZE,
|
||||
page: partsIndexPage + 1,
|
||||
q: searchQuery || undefined,
|
||||
params: paramsString
|
||||
});
|
||||
|
||||
console.log('✅ Автоподгрузка: страница загружена, результат:', result.data?.partsIndexCatalogEntities?.list?.length || 0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Автоподгрузка: ошибка загрузки следующей страницы:', error);
|
||||
} finally {
|
||||
setIsAutoLoading(false);
|
||||
}
|
||||
}, [isAutoLoading, hasMoreEntities, isPartsIndexMode, accumulatedEntities.length, partsIndexPage, refetchEntities, catalogId, groupId, searchQuery]);
|
||||
|
||||
// Генерация фильтров для PartsIndex на основе параметров API
|
||||
const generatePartsIndexFilters = useCallback((): FilterConfig[] => {
|
||||
if (!paramsData?.partsIndexCatalogParams?.list) {
|
||||
@ -292,6 +402,114 @@ export default function Catalog() {
|
||||
}
|
||||
}, [isPartsIndexMode, generatePartsIndexFilters, paramsLoading]);
|
||||
|
||||
// Автоматическая подгрузка товаров с задержкой для загрузки цен
|
||||
useEffect(() => {
|
||||
if (!isPartsIndexMode || accumulatedEntities.length === 0 || isAutoLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Даем время на загрузку цен (3 секунды после последнего изменения)
|
||||
const timer = setTimeout(() => {
|
||||
autoLoadMoreEntities();
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isPartsIndexMode, accumulatedEntities.length, isAutoLoading]);
|
||||
|
||||
// Дополнительный триггер автоподгрузки при изменении количества товаров с предложениями
|
||||
useEffect(() => {
|
||||
console.log('🔍 Проверка триггера автоподгрузки:', {
|
||||
isPartsIndexMode,
|
||||
entitiesWithOffersLength: entitiesWithOffers.length,
|
||||
isAutoLoading,
|
||||
hasMoreEntities,
|
||||
targetItemsPerPage: ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
if (!isPartsIndexMode || entitiesWithOffers.length === 0 || isAutoLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Если товаров с предложениями мало, запускаем автоподгрузку через 1 секунду
|
||||
if (entitiesWithOffers.length < ITEMS_PER_PAGE && hasMoreEntities) {
|
||||
console.log('🚀 Запускаем автоподгрузку: товаров', entitiesWithOffers.length, 'из', ITEMS_PER_PAGE);
|
||||
const timer = setTimeout(() => {
|
||||
console.log('🚀 Дополнительная автоподгрузка: недостаточно товаров с предложениями');
|
||||
autoLoadMoreEntities();
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
console.log('✅ Автоподгрузка не нужна: товаров достаточно или нет больше данных');
|
||||
}
|
||||
}, [isPartsIndexMode, entitiesWithOffers.length, hasMoreEntities, isAutoLoading]);
|
||||
|
||||
// Обновляем список товаров с предложениями при изменении накопленных товаров или цен
|
||||
useEffect(() => {
|
||||
if (!isPartsIndexMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем все товары, но отдельно считаем те, у которых есть цены
|
||||
const entitiesWithOffers = accumulatedEntities;
|
||||
|
||||
// Подсчитываем количество товаров с реальными ценами для автоподгрузки
|
||||
const entitiesWithRealPrices = accumulatedEntities.filter(entity => {
|
||||
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
|
||||
const priceData = getPrice(productForPrice);
|
||||
return priceData && priceData.price && priceData.price > 0;
|
||||
});
|
||||
|
||||
console.log('📊 Обновляем entitiesWithOffers:', {
|
||||
накопленоТоваров: accumulatedEntities.length,
|
||||
отображаемыхТоваров: entitiesWithOffers.length,
|
||||
сРеальнымиЦенами: entitiesWithRealPrices.length,
|
||||
целевоеКоличество: ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
setEntitiesWithOffers(entitiesWithOffers);
|
||||
|
||||
// Показываем товары для текущей пользовательской страницы
|
||||
const startIndex = (currentUserPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
const visibleForCurrentPage = entitiesWithOffers.slice(startIndex, endIndex);
|
||||
|
||||
console.log('📊 Обновляем visibleEntities:', {
|
||||
currentUserPage,
|
||||
startIndex,
|
||||
endIndex,
|
||||
visibleForCurrentPage: visibleForCurrentPage.length,
|
||||
entitiesWithOffers: entitiesWithOffers.length
|
||||
});
|
||||
|
||||
setVisibleEntities(visibleForCurrentPage);
|
||||
|
||||
}, [isPartsIndexMode, accumulatedEntities, currentUserPage]);
|
||||
|
||||
// Отдельный useEffect для обновления статистики цен (без влияния на visibleEntities)
|
||||
useEffect(() => {
|
||||
if (!isPartsIndexMode || accumulatedEntities.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Обновляем статистику каждые 2 секунды
|
||||
const timer = setTimeout(() => {
|
||||
const entitiesWithRealPrices = accumulatedEntities.filter(entity => {
|
||||
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
|
||||
const priceData = getPrice(productForPrice);
|
||||
return priceData && priceData.price && priceData.price > 0;
|
||||
});
|
||||
|
||||
console.log('💰 Обновление статистики цен:', {
|
||||
накопленоТоваров: accumulatedEntities.length,
|
||||
сРеальнымиЦенами: entitiesWithRealPrices.length,
|
||||
процентЗагрузки: Math.round((entitiesWithRealPrices.length / accumulatedEntities.length) * 100)
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isPartsIndexMode, accumulatedEntities.length, getPrice]);
|
||||
|
||||
// Генерируем динамические фильтры для PartsAPI
|
||||
const generatePartsAPIFilters = useCallback((): FilterConfig[] => {
|
||||
if (!allArticles.length) return [];
|
||||
@ -431,9 +649,6 @@ export default function Catalog() {
|
||||
});
|
||||
}, [allArticles, searchQuery, selectedFilters]);
|
||||
|
||||
// Упрощенная логика - показываем все загруженные товары без клиентской фильтрации
|
||||
const filteredEntities = visibleEntities;
|
||||
|
||||
// Обновляем видимые артикулы при изменении поиска или фильтров для PartsAPI
|
||||
useEffect(() => {
|
||||
if (isPartsAPIMode) {
|
||||
@ -458,10 +673,14 @@ export default function Catalog() {
|
||||
if (searchQuery.trim() || Object.keys(selectedFilters).length > 0) {
|
||||
console.log('🔍 Поисковый запрос или фильтры изменились, сбрасываем пагинацию');
|
||||
setPartsIndexPage(1);
|
||||
setCurrentUserPage(1);
|
||||
setHasMoreEntities(true);
|
||||
setAccumulatedEntities([]);
|
||||
setEntitiesWithOffers([]);
|
||||
setEntitiesCache(new Map());
|
||||
|
||||
// Перезагружаем данные с новыми параметрами фильтрации
|
||||
const apiParams = convertFiltersToPartsIndexParams();
|
||||
const apiParams = convertFiltersToPartsIndexParams;
|
||||
const paramsString = Object.keys(apiParams).length > 0 ? JSON.stringify(apiParams) : undefined;
|
||||
|
||||
// Также обновляем параметры фильтрации
|
||||
@ -477,7 +696,7 @@ export default function Catalog() {
|
||||
catalogId: catalogId as string,
|
||||
groupId: groupId as string,
|
||||
lang: 'ru',
|
||||
limit: ITEMS_PER_PAGE,
|
||||
limit: PARTSINDEX_PAGE_SIZE,
|
||||
page: 1,
|
||||
q: searchQuery || undefined,
|
||||
params: paramsString
|
||||
@ -503,26 +722,55 @@ export default function Catalog() {
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
} else if (isPartsIndexMode && !entitiesLoading && !entitiesError) {
|
||||
// Для PartsIndex показываем пустое состояние если нет товаров
|
||||
setShowEmptyState(visibleEntities.length === 0);
|
||||
// Для PartsIndex показываем пустое состояние если нет товаров И данные уже загружены
|
||||
const hasLoadedData = accumulatedEntities.length > 0 || Boolean(entitiesData?.partsIndexCatalogEntities?.list);
|
||||
setShowEmptyState(hasLoadedData && visibleEntities.length === 0);
|
||||
console.log('📊 Определяем showEmptyState для PartsIndex:', {
|
||||
hasLoadedData,
|
||||
visibleEntitiesLength: visibleEntities.length,
|
||||
accumulatedEntitiesLength: accumulatedEntities.length,
|
||||
showEmptyState: hasLoadedData && visibleEntities.length === 0
|
||||
});
|
||||
} else {
|
||||
setShowEmptyState(false);
|
||||
}
|
||||
}, [isPartsAPIMode, articlesLoading, articlesError, visibleProductsCount, allArticles.length,
|
||||
isPartsIndexMode, entitiesLoading, entitiesError, visibleEntities.length, filteredEntities.length]);
|
||||
isPartsIndexMode, entitiesLoading, entitiesError, visibleEntities.length, accumulatedEntities.length, entitiesData]);
|
||||
|
||||
// Функции для навигации по страницам PartsIndex
|
||||
// Функции для навигации по пользовательским страницам
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (hasMoreEntities && !entitiesLoading) {
|
||||
setPartsIndexPage(prev => prev + 1);
|
||||
const maxUserPage = Math.ceil(accumulatedEntities.length / ITEMS_PER_PAGE);
|
||||
console.log('🔄 Нажата кнопка "Вперед":', {
|
||||
currentUserPage,
|
||||
maxUserPage,
|
||||
accumulatedEntitiesLength: accumulatedEntities.length,
|
||||
ITEMS_PER_PAGE
|
||||
});
|
||||
if (currentUserPage < maxUserPage) {
|
||||
setCurrentUserPage(prev => {
|
||||
console.log('✅ Переходим на страницу:', prev + 1);
|
||||
return prev + 1;
|
||||
});
|
||||
} else {
|
||||
console.log('⚠️ Нельзя перейти вперед: уже на последней странице');
|
||||
}
|
||||
}, [hasMoreEntities, entitiesLoading]);
|
||||
}, [currentUserPage, accumulatedEntities.length]);
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
if (partsIndexPage > 1 && !entitiesLoading) {
|
||||
setPartsIndexPage(prev => prev - 1);
|
||||
console.log('🔄 Нажата кнопка "Назад":', {
|
||||
currentUserPage,
|
||||
accumulatedEntitiesLength: accumulatedEntities.length
|
||||
});
|
||||
if (currentUserPage > 1) {
|
||||
setCurrentUserPage(prev => {
|
||||
const newPage = prev - 1;
|
||||
console.log('✅ Переходим на страницу:', newPage);
|
||||
return newPage;
|
||||
});
|
||||
} else {
|
||||
console.log('⚠️ Нельзя перейти назад: уже на первой странице');
|
||||
}
|
||||
}, [partsIndexPage, entitiesLoading]);
|
||||
}, [currentUserPage, accumulatedEntities.length]);
|
||||
|
||||
// Функция для загрузки следующей порции товаров по кнопке (только для PartsAPI)
|
||||
const handleLoadMorePartsAPI = useCallback(async () => {
|
||||
@ -592,9 +840,7 @@ export default function Catalog() {
|
||||
isPartsAPIMode ?
|
||||
(visibilityMap.size === 0 && allArticles.length > 0 ? undefined : visibleProductsCount) :
|
||||
isPartsIndexMode ?
|
||||
(searchQuery.trim() || Object.keys(selectedFilters).length > 0 ?
|
||||
filteredEntities.length :
|
||||
entitiesData?.partsIndexCatalogEntities?.pagination?.limit || visibleEntities.length) :
|
||||
entitiesWithOffers.length :
|
||||
3587
|
||||
}
|
||||
productName={
|
||||
@ -740,25 +986,21 @@ export default function Catalog() {
|
||||
)}
|
||||
|
||||
{/* Отображение товаров PartsIndex */}
|
||||
{isPartsIndexMode && filteredEntities.length > 0 && (
|
||||
{isPartsIndexMode && (() => {
|
||||
console.log('🎯 Проверяем отображение PartsIndex товаров:', {
|
||||
isPartsIndexMode,
|
||||
visibleEntitiesLength: visibleEntities.length,
|
||||
visibleEntities: visibleEntities.map(e => ({ id: e.id, code: e.code, brand: e.brand.name }))
|
||||
});
|
||||
return visibleEntities.length > 0;
|
||||
})() && (
|
||||
<>
|
||||
{filteredEntities
|
||||
{visibleEntities
|
||||
.map((entity, idx) => {
|
||||
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
|
||||
const priceData = getPrice(productForPrice);
|
||||
const isLoadingPriceData = isLoadingPrice(productForPrice);
|
||||
|
||||
return {
|
||||
entity,
|
||||
idx,
|
||||
productForPrice,
|
||||
priceData,
|
||||
isLoadingPriceData,
|
||||
hasOffer: priceData !== null || isLoadingPriceData
|
||||
};
|
||||
})
|
||||
.filter(item => item.hasOffer) // Показываем только товары с предложениями или загружающиеся
|
||||
.map(({ entity, idx, productForPrice, priceData, isLoadingPriceData }) => {
|
||||
// Определяем цену для отображения
|
||||
let displayPrice = "Цена по запросу";
|
||||
let displayCurrency = "RUB";
|
||||
@ -790,7 +1032,7 @@ export default function Catalog() {
|
||||
onAddToCart={async () => {
|
||||
// Если цена не загружена, загружаем её и добавляем в корзину
|
||||
if (!priceData && !isLoadingPriceData) {
|
||||
loadPriceOnDemand(productForPrice);
|
||||
ensurePriceLoaded(productForPrice);
|
||||
console.log('🔄 Загружаем цену для:', entity.code, entity.brand.name);
|
||||
return;
|
||||
}
|
||||
@ -843,40 +1085,61 @@ export default function Catalog() {
|
||||
{/* Пагинация для PartsIndex */}
|
||||
<div className="w-layout-hflex pagination">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={partsIndexPage <= 1 || entitiesLoading}
|
||||
onClick={() => {
|
||||
console.log('🖱️ Клик по кнопке "Назад"');
|
||||
handlePrevPage();
|
||||
}}
|
||||
disabled={currentUserPage <= 1}
|
||||
className="button_strock w-button mr-2"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
<span className="flex items-center px-4 text-gray-600">
|
||||
Страница {partsIndexPage} {totalPages > partsIndexPage && `из ${totalPages}+`}
|
||||
Страница {currentUserPage} из {Math.ceil(accumulatedEntities.length / ITEMS_PER_PAGE) || 1}
|
||||
{isAutoLoading && ' (загружаем...)'}
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
(товаров: {accumulatedEntities.length})
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMoreEntities || entitiesLoading}
|
||||
onClick={() => {
|
||||
console.log('🖱️ Клик по кнопке "Вперед"');
|
||||
handleNextPage();
|
||||
}}
|
||||
disabled={currentUserPage >= Math.ceil(accumulatedEntities.length / ITEMS_PER_PAGE)}
|
||||
className="button_strock w-button ml-2"
|
||||
>
|
||||
{entitiesLoading ? 'Загрузка...' : 'Вперед →'}
|
||||
Вперед →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Отладочная информация */}
|
||||
{isPartsIndexMode && (
|
||||
<div className="text-xs text-gray-500 mt-4 p-2 bg-gray-100 rounded">
|
||||
<div>🔍 Отладка PartsIndex:</div>
|
||||
<div>• hasMoreItems: {hasMoreItems ? 'да' : 'нет'}</div>
|
||||
<div>• hasMoreEntities: {hasMoreEntities ? 'да' : 'нет'}</div>
|
||||
<div>• entitiesPage: {entitiesPage}</div>
|
||||
<div>🔍 Отладка PartsIndex (исправленная логика):</div>
|
||||
<div>• accumulatedEntities: {accumulatedEntities.length}</div>
|
||||
<div>• entitiesWithOffers: {entitiesWithOffers.length}</div>
|
||||
<div>• visibleEntities: {visibleEntities.length}</div>
|
||||
<div>• filteredEntities: {filteredEntities.length}</div>
|
||||
<div>• groupId: {groupId || 'отсутствует'}</div>
|
||||
<div>• isLoadingMore: {isLoadingMore ? 'да' : 'нет'}</div>
|
||||
<div>• currentUserPage: {currentUserPage}</div>
|
||||
<div>• partsIndexPage (API): {partsIndexPage}</div>
|
||||
<div>• isAutoLoading: {isAutoLoading ? 'да' : 'нет'}</div>
|
||||
<div>• hasMoreEntities: {hasMoreEntities ? 'да' : 'нет'}</div>
|
||||
<div>• entitiesLoading: {entitiesLoading ? 'да' : 'нет'}</div>
|
||||
<div>• catalogId: {catalogId || 'отсутствует'}</div>
|
||||
<div>• Пагинация: {JSON.stringify(entitiesData?.partsIndexCatalogEntities?.pagination)}</div>
|
||||
<div>• groupId: {groupId || 'отсутствует'}</div>
|
||||
<div>• Target: {ITEMS_PER_PAGE} товаров на страницу</div>
|
||||
<div>• showEmptyState: {showEmptyState ? 'да' : 'нет'}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('🔧 Ручной запуск автоподгрузки');
|
||||
autoLoadMoreEntities();
|
||||
}}
|
||||
className="mt-2 px-3 py-1 bg-blue-500 text-white text-xs rounded"
|
||||
disabled={isAutoLoading}
|
||||
>
|
||||
{isAutoLoading ? 'Загружаем...' : 'Загрузить еще'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@ -892,7 +1155,16 @@ export default function Catalog() {
|
||||
)}
|
||||
|
||||
{/* Пустое состояние для PartsIndex */}
|
||||
{isPartsIndexMode && !entitiesLoading && !entitiesError && showEmptyState && (
|
||||
{isPartsIndexMode && !entitiesLoading && !entitiesError && (() => {
|
||||
console.log('🎯 Проверяем пустое состояние PartsIndex:', {
|
||||
isPartsIndexMode,
|
||||
entitiesLoading,
|
||||
entitiesError,
|
||||
showEmptyState,
|
||||
visibleEntitiesLength: visibleEntities.length
|
||||
});
|
||||
return showEmptyState;
|
||||
})() && (
|
||||
<CatalogEmptyState
|
||||
categoryName={decodeURIComponent(categoryName as string || 'товаров')}
|
||||
hasFilters={searchQuery.trim() !== '' || Object.keys(selectedFilters).some(key => selectedFilters[key].length > 0)}
|
||||
|
@ -17,6 +17,7 @@ import MetaTags from "@/components/MetaTags";
|
||||
import { getMetaByPath } from "@/lib/meta-config";
|
||||
import JsonLdScript from "@/components/JsonLdScript";
|
||||
import { generateOrganizationSchema, generateWebSiteSchema, PROTEK_ORGANIZATION } from "@/lib/schema";
|
||||
import HeroSlider from "@/components/index/HeroSlider";
|
||||
|
||||
export default function Home() {
|
||||
const metaData = getMetaByPath('/');
|
||||
|
@ -36,10 +36,10 @@ const ProfileHistoryPage = () => {
|
||||
return (
|
||||
<>
|
||||
<MetaTags {...metaData} />
|
||||
<div className="page-wrapper h-full flex flex-col flex-1">
|
||||
<div className="page-wrapper">
|
||||
<ProfileInfo />
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5 h-full flex-1">
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto min-h-[526px] max-w-[1580px] w-full h-full">
|
||||
<div className="flex flex-col pt-10 pb-16 max-md:px-5">
|
||||
<div className="flex relative gap-8 items-start self-stretch max-md:gap-5 max-sm:flex-col max-sm:gap-4 justify-center mx-auto max-w-[1580px] w-full h-full">
|
||||
<LKMenu ref={menuRef} />
|
||||
<ProfileHistoryMain />
|
||||
</div>
|
||||
|
@ -21,11 +21,23 @@ import { createProductMeta } from "@/lib/meta-config";
|
||||
|
||||
const ANALOGS_CHUNK_SIZE = 5;
|
||||
|
||||
const sortOptions = [
|
||||
"По цене",
|
||||
"По рейтингу",
|
||||
"По количеству"
|
||||
];
|
||||
// Функция для расчета даты доставки
|
||||
const calculateDeliveryDate = (deliveryDays: number): string => {
|
||||
const today = new Date();
|
||||
const deliveryDate = new Date(today);
|
||||
deliveryDate.setDate(today.getDate() + deliveryDays);
|
||||
|
||||
const months = [
|
||||
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
|
||||
];
|
||||
|
||||
const day = deliveryDate.getDate();
|
||||
const month = months[deliveryDate.getMonth()];
|
||||
const year = deliveryDate.getFullYear();
|
||||
|
||||
return `${day} ${month} ${year}`;
|
||||
};
|
||||
|
||||
// Функция для создания динамических фильтров
|
||||
const createFilters = (result: any, loadedAnalogs: any): FilterConfig[] => {
|
||||
@ -175,15 +187,18 @@ const getBestOffers = (offers: any[]) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
// Убрано: функция сортировки теперь в CoreProductCard
|
||||
|
||||
const transformOffersForCard = (offers: any[]) => {
|
||||
return offers.map(offer => {
|
||||
const isExternal = offer.type === 'external';
|
||||
const deliveryDays = isExternal ? offer.deliveryTime : offer.deliveryDays;
|
||||
return {
|
||||
id: offer.id,
|
||||
productId: offer.productId,
|
||||
offerKey: offer.offerKey,
|
||||
pcs: `${offer.quantity} шт.`,
|
||||
days: `${isExternal ? offer.deliveryTime : offer.deliveryDays} дн.`,
|
||||
days: deliveryDays ? calculateDeliveryDate(deliveryDays) : 'Уточняйте',
|
||||
recommended: !isExternal && offer.available,
|
||||
price: `${offer.price.toLocaleString('ru-RU')} ₽`,
|
||||
count: "1",
|
||||
@ -191,7 +206,7 @@ const transformOffersForCard = (offers: any[]) => {
|
||||
currency: offer.currency || "RUB",
|
||||
warehouse: offer.warehouse,
|
||||
supplier: offer.supplier,
|
||||
deliveryTime: isExternal ? offer.deliveryTime : offer.deliveryDays,
|
||||
deliveryTime: deliveryDays,
|
||||
};
|
||||
});
|
||||
};
|
||||
@ -200,7 +215,7 @@ 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>("");
|
||||
@ -542,7 +557,7 @@ export default function SearchResult() {
|
||||
<section className="main mobile-only">
|
||||
<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">
|
||||
@ -574,7 +589,7 @@ export default function SearchResult() {
|
||||
)}
|
||||
{/* Лучшие предложения */}
|
||||
{bestOffersData.length > 0 && (
|
||||
<section className="section-6">
|
||||
<section className="main">
|
||||
<div className="w-layout-blockcontainer container w-container">
|
||||
<div className="w-layout-vflex flex-block-36">
|
||||
{bestOffersData.map(({ offer, type }, index) => (
|
||||
@ -584,7 +599,7 @@ export default function SearchResult() {
|
||||
title={`${offer.brand} ${offer.articleNumber}${offer.isAnalog ? ' (аналог)' : ''}`}
|
||||
description={offer.name}
|
||||
price={`${offer.price.toLocaleString()} ₽`}
|
||||
delivery={`${offer.deliveryDuration} ${offer.deliveryDuration === 1 ? 'день' : 'дней'}`}
|
||||
delivery={offer.deliveryDuration ? calculateDeliveryDate(offer.deliveryDuration) : 'Уточняйте'}
|
||||
stock={`${offer.quantity} шт.`}
|
||||
offer={offer}
|
||||
/>
|
||||
|
@ -53,6 +53,7 @@
|
||||
|
||||
.flex-block-40 {
|
||||
background-color: #fff;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
input.text-block-31 {
|
||||
@ -364,6 +365,15 @@ input.input-receiver:focus {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-5-copy {
|
||||
width: 97px !important;
|
||||
height: 97px !important;
|
||||
}
|
||||
.flex-block-111 {
|
||||
width: 172px !important;
|
||||
}
|
||||
|
||||
|
||||
.show-more-btn {
|
||||
background-color: #ec1c24;
|
||||
color: #fff;
|
||||
@ -481,7 +491,6 @@ input#VinSearchInput {
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.heading-9-copy,
|
||||
.text-block-21-copy {
|
||||
width: 250px;
|
||||
overflow: hidden;
|
||||
@ -489,7 +498,27 @@ input#VinSearchInput {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.heading-9-copy {
|
||||
|
||||
text-align: right;
|
||||
margin-left: auto;
|
||||
display: block;
|
||||
}
|
||||
.pcs-search {
|
||||
color: var(--_fonts---color--black);
|
||||
font-size: var(--_fonts---font-size--core);
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.heading-9-copy {
|
||||
text-align: left;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
.w-layout-hflex.flex-block-6 {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
@ -516,6 +545,9 @@ input#VinSearchInput {
|
||||
}
|
||||
}
|
||||
|
||||
.div-block-19{
|
||||
padding-left: 20px !important;
|
||||
}
|
||||
|
||||
.dropdown-toggle-card {
|
||||
align-self: stretch;
|
||||
@ -906,15 +938,14 @@ a.link-block-2.w-inline-block {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.heading-9-copy {
|
||||
min-width: 100px;
|
||||
|
||||
|
||||
.flex-block-36 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.flex-block-15-copy {
|
||||
width: 235px!important;
|
||||
min-width: 235px!important;
|
||||
width: 232px!important;
|
||||
min-width: 232px!important;
|
||||
}
|
||||
|
||||
.nameitembp {
|
||||
@ -966,7 +997,16 @@ a.link-block-2.w-inline-block {
|
||||
|
||||
|
||||
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.flex-block-110 {
|
||||
flex-direction: row !important;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
.image-5-copy {
|
||||
width: 75px !important;
|
||||
height: 75px !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.topmenub {
|
||||
display: none !important;
|
||||
@ -1125,4 +1165,111 @@ a.link-block-2.w-inline-block {
|
||||
justify-content: flex-start !important;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mask.w-slider-mask {
|
||||
height: 100px !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.search-history-dropdown,
|
||||
.search-results-dropdown,
|
||||
.dropdown-search,
|
||||
.dropdown-list-3.w--open {
|
||||
position: fixed !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
top: 72px !important; /* подберите под ваш header */
|
||||
width: 100vw !important;
|
||||
z-index: 9999 !important;
|
||||
border-radius: 0 0 16px 16px !important;
|
||||
margin: 0 !important;
|
||||
max-width: 100vw !important;
|
||||
background: white !important;
|
||||
box-shadow: 0 8px 32px rgba(44,62,80,0.10), 0 1.5px 4px rgba(44,62,80,0.08) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.pricecartbp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px; /* или другой нужный вам отступ */
|
||||
}
|
||||
|
||||
.bestpriceitem {
|
||||
height: 279px;
|
||||
}
|
||||
|
||||
|
||||
.flex-block-49 {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.pcs-search-s1,
|
||||
.sort-item.first {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.pcs-search-s1,
|
||||
.sort-item.first {
|
||||
width: 60px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 479px) {
|
||||
.pcs-search-s1,
|
||||
.sort-item.first {
|
||||
width: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.w-layout-vflex.flex-block-36 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.w-layout-vflex.flex-block-44 {
|
||||
flex: 1 1 calc(33.333% - 16px);
|
||||
max-width: calc(33.333% - 16px);
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.w-layout-vflex.flex-block-44 {
|
||||
flex: 1 1 calc(50% - 12px);
|
||||
max-width: calc(50% - 12px);
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.w-layout-vflex.flex-block-44 {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.w-layout-vflex.flex-block-36 {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
gap: 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.w-layout-vflex.flex-block-44 {
|
||||
min-width: 160px;
|
||||
max-width: 160px;
|
||||
flex: 0 0 160px;
|
||||
}
|
||||
.heading-9-copy {
|
||||
text-align: left !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user