22 Commits

Author SHA1 Message Date
1da9c6ac09 Merge branch 'main' of https://gittea.biveki.ru/BivekiGroup/protekauto-frontend 2025-07-18 19:29:53 +03:00
649ddbfa8a prices and shit 2025-07-18 19:15:25 +03:00
f3d21959c9 prices optimised 2025-07-18 18:12:42 +03:00
61b50d10ba Merge pull request 'сonfidentiality' (#30) from сonfidentiality into main
Reviewed-on: #30
2025-07-18 13:43:52 +03:00
ea76106caa Confidentiality 2025-07-18 13:42:48 +03:00
b7edd73ce0 fixed prices, but still working on filters 2025-07-18 04:22:37 +03:00
b6f9d017d6 catalog prices fix 2025-07-17 21:22:45 +03:00
27d378154f fix1707 2025-07-17 16:35:45 +03:00
5fd2cf1b8c Merge pull request 'fix1607' (#29) from fix1607 into main
Reviewed-on: #29
2025-07-16 14:29:58 +03:00
2703137ca1 fix1607 2025-07-16 14:28:47 +03:00
3e98f8fed6 Добавлено получение баннеров для главного слайдера с использованием GraphQL. Обновлен компонент HeroSlider для отображения активных баннеров с сортировкой. Реализована логика отображения дефолтного баннера при отсутствии данных. Обновлены стили и структура компонента для улучшения пользовательского интерфейса. 2025-07-15 09:03:32 +03:00
9c152501db Merge pull request 'fix1407' (#28) from fix1407 into main
Reviewed-on: #28
2025-07-14 10:45:51 +03:00
47844749eb fix1407 2025-07-14 10:45:27 +03:00
074eb120b4 Merge remote changes, resolve conflicts in BottomHead.tsx 2025-07-14 10:03:35 +03:00
4dfc081214 Добавлено получение истории поиска с автодополнением в компоненте Header. Обновлены обработчики ввода для управления отображением истории и плейсхолдера. Внедрен запрос для получения последних поисковых запросов. Обновлены стили и логика отображения в компоненте Header. 2025-07-14 10:01:09 +03:00
d95d008c0c Merge pull request 'coolie' (#27) from cookie into main
Reviewed-on: #27
2025-07-14 01:07:32 +03:00
657016731c coolie 2025-07-14 01:06:42 +03:00
87339d577e Удалены временные стили и отладочные логи для навигационных иконок в компоненте BottomHead. Обновлен рендеринг иконок с использованием условного отображения для IMG и SVG. 2025-07-13 21:44:49 +03:00
ad5dcc03e3 Добавлено получение навигационных категорий с иконками и обновление логики отображения иконок в компоненте BottomHead. Обновлены типы данных и стили для навигационных иконок. Оптимизирована загрузка групп для категорий в компоненте BottomHeadPartsIndex. 2025-07-13 21:42:06 +03:00
132e39b87e Merge pull request 'fix1207' (#26) from fix1207 into main
Reviewed-on: #26
2025-07-12 22:40:15 +03:00
e22828039f Merge pull request 'footer' (#25) from footer into main
Reviewed-on: #25
2025-07-12 21:33:32 +03:00
320b7500e0 Merge pull request 'fix1207' (#24) from 1207 into main
Reviewed-on: #24
2025-07-12 18:22:10 +03:00
47 changed files with 3160 additions and 1317 deletions

92
package-lock.json generated
View File

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

View File

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

BIN
public/images/noimage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

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

View File

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

View File

@ -3,16 +3,17 @@ import React, { useState, useRef, useEffect } from 'react';
interface CatalogSortDropdownProps { interface CatalogSortDropdownProps {
active: number; active: number;
onChange: (index: number) => void; 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 [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@ -52,7 +53,7 @@ const CatalogSortDropdown: React.FC<CatalogSortDropdownProps> = ({ active, onCha
<div>Сортировка</div> <div>Сортировка</div>
</div> </div>
<nav className={`dropdown-list-2 w-dropdown-list${isOpen ? ' w--open' : ''}`} style={{ minWidth: 180, whiteSpace: 'normal' }}> <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 <a
key={index} key={index}
href="#" href="#"

View 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;

View File

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

View File

@ -3,6 +3,7 @@ import { useCart } from "@/contexts/CartContext";
import { useFavorites } from "@/contexts/FavoritesContext"; import { useFavorites } from "@/contexts/FavoritesContext";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import CartIcon from "./CartIcon"; import CartIcon from "./CartIcon";
import { isDeliveryDate } from "@/lib/utils";
const INITIAL_OFFERS_LIMIT = 5; const INITIAL_OFFERS_LIMIT = 5;
@ -50,6 +51,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
const { addItem } = useCart(); const { addItem } = useCart();
const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites(); const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
const [visibleOffersCount, setVisibleOffersCount] = useState(INITIAL_OFFERS_LIMIT); const [visibleOffersCount, setVisibleOffersCount] = useState(INITIAL_OFFERS_LIMIT);
const [sortBy, setSortBy] = useState<'stock' | 'delivery' | 'price'>('price'); // Локальная сортировка для каждого товара
const [quantities, setQuantities] = useState<{ [key: number]: number }>( const [quantities, setQuantities] = useState<{ [key: number]: number }>(
offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {}) offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {})
); );
@ -63,8 +65,52 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
setQuantities(offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {})); setQuantities(offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {}));
}, [offers.length]); }, [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( const isItemFavorite = isFavorite(
@ -74,24 +120,6 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
brand 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) => { const handleInputChange = (idx: number, val: string) => {
setInputValues(prev => ({ ...prev, [idx]: val })); setInputValues(prev => ({ ...prev, [idx]: val }));
if (val === "") return; if (val === "") return;
@ -273,6 +301,10 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
<> <>
<div className="w-layout-hflex core-product-search-s1"> <div className="w-layout-hflex core-product-search-s1">
<div className="w-layout-vflex flex-block-48-copy"> <div className="w-layout-vflex flex-block-48-copy">
<div className="w-layout-vflex product-list-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 product-list-search-s1">
<div className="w-layout-vflex core-product-s1"> <div className="w-layout-vflex core-product-s1">
<div className="w-layout-vflex flex-block-47"> <div className="w-layout-vflex flex-block-47">
@ -280,9 +312,9 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
<img src="/images/info.svg" loading="lazy" alt="info" className="image-9" /> <img src="/images/info.svg" loading="lazy" alt="info" className="image-9" />
</div> </div>
<div className="w-layout-vflex flex-block-50"> <div className="w-layout-vflex flex-block-50">
<div className="w-layout-hflex flex-block-79"> <div className="flex flex-row flex-nowrap items-center gap-2">
<h3 className="heading-10 name">{brand}</h3> <h3 className="heading-10 name" style={{marginRight: 8}}>{brand}</h3>
<h3 className="heading-10">{article}</h3> <h3 className="heading-10" style={{marginRight: 8}}>{article}</h3>
<div <div
className="favorite-icon w-embed" className="favorite-icon w-embed"
onClick={handleFavoriteClick} onClick={handleFavoriteClick}
@ -296,7 +328,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
</svg> </svg>
</div> </div>
</div> </div>
<div className="text-block-21">{name}</div> <div className="text-block-21 mt-1">{name}</div>
</div> </div>
</div> </div>
{image && ( {image && (
@ -310,14 +342,30 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
</div> </div>
)} )}
</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 sort-list-s1">
<div className="w-layout-hflex flex-block-49"> <div className="w-layout-hflex flex-block-49">
<div className="sort-item first">Наличие</div> <div
<div className="sort-item">Доставка</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>
<div className="sort-item price">Цена</div>
</div> </div>
{displayedOffers.map((offer, idx) => { {displayedOffers.map((offer, idx) => {
const isLast = idx === displayedOffers.length - 1; const isLast = idx === displayedOffers.length - 1;
@ -412,7 +460,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
className="w-layout-hflex show-more-search" className="w-layout-hflex show-more-search"
onClick={() => { onClick={() => {
if (hasMoreOffers) { if (hasMoreOffers) {
setVisibleOffersCount(prev => Math.min(prev + 10, offers.length)); setVisibleOffersCount(prev => Math.min(prev + 10, sortedOffers.length));
} else { } else {
setVisibleOffersCount(INITIAL_OFFERS_LIMIT); setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
} }
@ -420,11 +468,11 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
tabIndex={0} tabIndex={0}
role="button" role="button"
aria-label={hasMoreOffers ? `Еще ${offers.length - visibleOffersCount} предложений` : 'Скрыть предложения'} aria-label={hasMoreOffers ? `Еще ${sortedOffers.length - visibleOffersCount} предложений` : 'Скрыть предложения'}
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
if (hasMoreOffers) { if (hasMoreOffers) {
setVisibleOffersCount(prev => Math.min(prev + 10, offers.length)); setVisibleOffersCount(prev => Math.min(prev + 10, sortedOffers.length));
} else { } else {
setVisibleOffersCount(INITIAL_OFFERS_LIMIT); setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
} }
@ -432,7 +480,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
}} }}
> >
<div className="text-block-27"> <div className="text-block-27">
{hasMoreOffers ? `Еще ${offers.length - visibleOffersCount} предложений` : 'Скрыть'} {hasMoreOffers ? `Еще ${sortedOffers.length - visibleOffersCount} предложений` : 'Скрыть'}
</div> </div>
<img <img
src="/images/arrow_drop_down.svg" src="/images/arrow_drop_down.svg"

View File

@ -88,7 +88,7 @@ const Filters: React.FC<FiltersProps> = ({
if (filter.type === "range") { if (filter.type === "range") {
return ( return (
<FilterRange <FilterRange
key={filter.title + idx} key={filter.title + idx + JSON.stringify((filterValues && filterValues[filter.title]) || null)}
title={filter.title} title={filter.title}
min={filter.min} min={filter.min}
max={filter.max} max={filter.max}

View File

@ -63,6 +63,7 @@ const FiltersPanelMobile: React.FC<FiltersPanelMobileProps> = ({
setLocalFilterValues({}); setLocalFilterValues({});
onSearchChange(''); onSearchChange('');
// Сбрасываем фильтры в родительском компоненте // Сбрасываем фильтры в родительском компоненте
// Используем пустые массивы для правильной очистки
Object.keys(filterValues).forEach(key => { Object.keys(filterValues).forEach(key => {
onFilterChange?.(key, []); onFilterChange?.(key, []);
}); });
@ -134,7 +135,7 @@ const FiltersPanelMobile: React.FC<FiltersPanelMobileProps> = ({
if (filter.type === "range") { if (filter.type === "range") {
return ( return (
<FilterRange <FilterRange
key={filter.title + idx} key={filter.title + idx + JSON.stringify(localFilterValues[filter.title] || null)}
title={filter.title} title={filter.title}
min={filter.min} min={filter.min}
max={filter.max} max={filter.max}

View File

@ -115,7 +115,7 @@ const Footer = () => (
<button className="bg-[#23407A] rounded-lg py-2 px-6 font-medium mt-1 mb-2">Напиши нам</button> <button className="bg-[#23407A] rounded-lg py-2 px-6 font-medium mt-1 mb-2">Напиши нам</button>
</div> </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="flex flex-col gap-3 min-w-[150px]">
<div className="link">Подбор по марке авто</div> <div className="link">Подбор по марке авто</div>
<a href="#" className="link">Поиск по VIN</a> <a href="#" className="link">Поиск по VIN</a>
@ -178,7 +178,7 @@ const Footer = () => (
</a> </a>
</div> </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>
<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>

View File

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

View File

@ -0,0 +1,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;

View File

@ -2,6 +2,7 @@ import React, { useState } from "react";
import { useCart } from "@/contexts/CartContext"; import { useCart } from "@/contexts/CartContext";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import CartIcon from "../CartIcon"; import CartIcon from "../CartIcon";
import { isDeliveryDate } from "@/lib/utils";
interface ProductBuyBlockProps { interface ProductBuyBlockProps {
offer?: any; offer?: any;
@ -51,7 +52,9 @@ const ProductBuyBlock = ({ offer }: ProductBuyBlockProps) => {
brand: offer.brand, brand: offer.brand,
article: offer.articleNumber, article: offer.articleNumber,
supplier: offer.supplier || (offer.type === 'external' ? 'AutoEuro' : 'Внутренний'), 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' isExternal: offer.type === 'external'
}); });

View File

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { isDeliveryDate } from "@/lib/utils";
interface ProductInfoProps { interface ProductInfoProps {
offer?: any; offer?: any;
@ -17,6 +18,11 @@ const ProductInfo: React.FC<ProductInfoProps> = ({ offer }) => {
// Форматируем срок доставки // Форматируем срок доставки
const formatDeliveryTime = (deliveryTime: number | string) => { const formatDeliveryTime = (deliveryTime: number | string) => {
// Если это уже дата (содержит название месяца), возвращаем как есть
if (typeof deliveryTime === 'string' && isDeliveryDate(deliveryTime)) {
return deliveryTime;
}
const days = typeof deliveryTime === 'string' ? parseInt(deliveryTime) : deliveryTime; const days = typeof deliveryTime === 'string' ? parseInt(deliveryTime) : deliveryTime;
if (!days || days === 0) { if (!days || days === 0) {

View File

@ -24,20 +24,24 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const trackRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null);
// Обновляем локальное состояние при изменении внешнего значения // Обновляем локальное состояние при изменении внешнего значения или границ
useEffect(() => { useEffect(() => {
if (value) { let nextFrom = value ? value[0] : min;
setFrom(String(value[0])); let nextTo = value ? value[1] : max;
setTo(String(value[1])); let changed = false;
setConfirmedFrom(value[0]); // Корректируем значения, если они вне новых границ
setConfirmedTo(value[1]); if (nextFrom < min) { nextFrom = min; changed = true; }
} else { if (nextTo > max) { nextTo = max; changed = true; }
setFrom(String(min)); if (nextFrom > nextTo) { nextFrom = nextTo; changed = true; }
setTo(String(max)); setFrom(String(nextFrom));
setConfirmedFrom(min); setTo(String(nextTo));
setConfirmedTo(max); setConfirmedFrom(nextFrom);
setConfirmedTo(nextTo);
// Если значения были скорректированы, уведомляем родителя
if (changed && onChange) {
onChange([nextFrom, nextTo]);
} }
}, [value, min, max]); }, [value, min, max, onChange]);
// Обновляем ширину полосы при монтировании и ресайзе // Обновляем ширину полосы при монтировании и ресайзе
useLayoutEffect(() => { useLayoutEffect(() => {

View File

@ -111,12 +111,62 @@ const BestPriceSection: React.FC = () => {
<div className="text-block-58">Подборка лучших предложенийпо цене</div> <div className="text-block-58">Подборка лучших предложенийпо цене</div>
<a href="#" className="button-24 w-button">Показать все</a> <a href="#" className="button-24 w-button">Показать все</a>
</div> </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="Прокрутить влево"> <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"> <span className="arrow-circle">
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/> <svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.5 24L12.5 16L19.5 8" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/> <path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</span>
</button> </button>
<div className="w-layout-hflex flex-block-121 carousel-scroll" ref={scrollRef}> <div className="w-layout-hflex flex-block-121 carousel-scroll" ref={scrollRef}>
{bestPriceItems.map((item, i) => ( {bestPriceItems.map((item, i) => (
@ -124,10 +174,11 @@ const BestPriceSection: React.FC = () => {
))} ))}
</div> </div>
<button className="carousel-arrow carousel-arrow-right" onClick={scrollRight} aria-label="Прокрутить вправо"> <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"> <span className="arrow-circle">
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/> <svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 8L19.5 16L12.5 24" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/> <path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,39 +1,72 @@
import React from "react"; 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 = () => ( 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"> <section className="catnav">
<div className="w-layout-blockcontainer batd w-container"> <div className="w-layout-blockcontainer batd w-container">
<div className="w-layout-hflex flex-block-108-copy"> <div className="w-layout-hflex flex-block-108-copy">
<div className="ci1"> {categories.map((category, idx) => (
<div className="text-block-54-copy">Детали для ТО</div> <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 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>
</div> </div>
))}
</div> </div>
</div> </div>
</section> </section>
); );
};
export default CategoryNavSection; export default CategoryNavSection;

View File

@ -1,6 +1,23 @@
import React, { useEffect } from "react"; 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 HeroSlider = () => {
const { data, loading, error } = useQuery(GET_HERO_BANNERS, {
errorPolicy: 'all'
});
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined" && window.Webflow && window.Webflow.require) { if (typeof window !== "undefined" && window.Webflow && window.Webflow.require) {
if (window.Webflow.destroy) { if (window.Webflow.destroy) {
@ -12,8 +29,16 @@ 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 ( return (
<section className="section-5"> <section className="section-5" style={{ overflow: 'hidden' }}>
<div className="w-layout-blockcontainer container w-container"> <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" <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-hide-arrows="false" data-disable-swipe="false" data-autoplay-limit="0" data-nav-spacing="3"
@ -21,92 +46,35 @@ const HeroSlider = () => {
<div className="mask w-slider-mask"> <div className="mask w-slider-mask">
<div className="slide w-slide"> <div className="slide w-slide">
<div className="w-layout-vflex flex-block-100"> <div className="w-layout-vflex flex-block-100">
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy" <div className="div-block-35">
<img src="/images/imgfb.png" loading="lazy"
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px" 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="" srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w"
className="image-21" /></div> alt="Автозапчасти ProteK"
className="image-21" />
</div>
<div className="w-layout-vflex flex-block-99"> <div className="w-layout-vflex flex-block-99">
<h2 className="heading-17">ШИРОКИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2> <h2 className="heading-17">ШИРОКИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем <div className="text-block-51">
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div> Сотрудничаем только с проверенными поставщиками. Постоянно обновляем
ассортимент, чтобы предложить самые лучшие и актуальные детали.
</div>
</div> </div>
<div className="w-layout-hflex flex-block-101"> <div className="w-layout-hflex flex-block-101">
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt="" <div className="w-layout-hflex flex-block-102">
className="image-20" /> <img src="/images/1.png" loading="lazy" alt="" className="image-20" />
<div className="text-block-52">Быстрая доставка по всей стране</div> <div className="text-block-52">Быстрая доставка по всей стране</div>
</div> </div>
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt="" <div className="w-layout-hflex flex-block-102">
className="image-20" /> <img src="/images/2.png" loading="lazy" alt="" className="image-20" />
<div className="text-block-52">Высокое качество продукции</div> <div className="text-block-52">Высокое качество продукции</div>
</div> </div>
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt="" <div className="w-layout-hflex flex-block-102">
className="image-20" /> <img src="/images/3.png" loading="lazy" alt="" className="image-20" />
<div className="text-block-52">Выгодные цены</div> <div className="text-block-52">Выгодные цены</div>
</div> </div>
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt="" <div className="w-layout-hflex flex-block-102">
className="image-20" /> <img src="/images/4.png" loading="lazy" alt="" className="image-20" />
<div className="text-block-52">Профессиональная консультация</div>
</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 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 className="text-block-52">Профессиональная консультация</div>
</div> </div>
</div> </div>
@ -128,6 +96,89 @@ const HeroSlider = () => {
</div> </div>
</section> </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" style={{ overflow: 'hidden' }}>
<div className="w-layout-blockcontainer container w-container">
<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">
{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 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>
);
}; };
export default HeroSlider; export default HeroSlider;

View File

@ -84,16 +84,66 @@ const NewArrivalsSection: React.FC = () => {
<h2 className="heading-4">Новое поступление</h2> <h2 className="heading-4">Новое поступление</h2>
</div> </div>
<div className="carousel-row"> <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 <button
className="carousel-arrow carousel-arrow-left" className="carousel-arrow carousel-arrow-left"
onClick={scrollLeft} onClick={scrollLeft}
aria-label="Прокрутить влево" aria-label="Прокрутить влево"
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <span className="arrow-circle">
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/> <svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.5 24L12.5 16L19.5 8" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/> <path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</span>
</button> </button>
<div className="w-layout-hflex core-product-search carousel-scroll" ref={scrollRef}> <div className="w-layout-hflex core-product-search carousel-scroll" ref={scrollRef}>
@ -149,10 +199,11 @@ const NewArrivalsSection: React.FC = () => {
aria-label="Прокрутить вправо" aria-label="Прокрутить вправо"
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <span className="arrow-circle">
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/> <svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 8L19.5 16L12.5 24" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/> <path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -32,11 +32,61 @@ const NewsAndPromos = () => {
</div> </div>
</div> </div>
<div className="carousel-row"> <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="Прокрутить влево"> <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"> <span className="arrow-circle">
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/> <svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.5 24L12.5 16L19.5 8" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/> <path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</span>
</button> </button>
<div className="w-layout-hflex flex-block-6-copy-copy carousel-scroll" ref={scrollRef}> <div className="w-layout-hflex flex-block-6-copy-copy carousel-scroll" ref={scrollRef}>
<NewsCard <NewsCard
@ -69,10 +119,11 @@ const NewsAndPromos = () => {
/> />
</div> </div>
<button className="carousel-arrow carousel-arrow-right" onClick={scrollRight} aria-label="Прокрутить вправо"> <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"> <span className="arrow-circle">
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/> <svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 8L19.5 16L12.5 24" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/> <path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</span>
</button> </button>
</div> </div>
</div> </div>

View 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;

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect } from "react";
import { useQuery } from '@apollo/client'; import { useQuery } from '@apollo/client';
import { GET_DAILY_PRODUCTS, PARTS_INDEX_SEARCH_BY_ARTICLE } from '@/lib/graphql'; import { GET_DAILY_PRODUCTS, PARTS_INDEX_SEARCH_BY_ARTICLE } from '@/lib/graphql';
import Link from 'next/link'; import Link from 'next/link';
import ProductOfDayBanner from './ProductOfDayBanner';
interface DailyProduct { interface DailyProduct {
id: string; id: string;
@ -31,7 +32,6 @@ const ProductOfDaySection: React.FC = () => {
// Состояние для текущего слайда // Состояние для текущего слайда
const [currentSlide, setCurrentSlide] = useState(0); const [currentSlide, setCurrentSlide] = useState(0);
const sliderRef = useRef<HTMLDivElement>(null);
const { data, loading, error } = useQuery<{ dailyProducts: DailyProduct[] }>( const { data, loading, error } = useQuery<{ dailyProducts: DailyProduct[] }>(
GET_DAILY_PRODUCTS, GET_DAILY_PRODUCTS,
@ -49,6 +49,16 @@ const ProductOfDaySection: React.FC = () => {
.sort((a, b) => a.sortOrder - b.sortOrder); .sort((a, b) => a.sortOrder - b.sortOrder);
}, [data]); }, [data]);
// Корректный сброс currentSlide только если индекс вне диапазона
useEffect(() => {
if (currentSlide > activeProducts.length - 1) {
setCurrentSlide(activeProducts.length > 0 ? activeProducts.length - 1 : 0);
}
// Если товаров стало больше и текущий слайд = 0, ничего не делаем
// Если товаров стало меньше и текущий слайд в диапазоне, ничего не делаем
// Если товаров стало меньше и текущий слайд вне диапазона, сбрасываем на последний
}, [activeProducts.length]);
// Получаем данные из PartsIndex для текущего товара // Получаем данные из PartsIndex для текущего товара
const currentProduct = activeProducts[currentSlide]; const currentProduct = activeProducts[currentSlide];
const { data: partsIndexData } = useQuery( 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) => { const handlePrevSlide = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -132,11 +147,6 @@ const ProductOfDaySection: React.FC = () => {
setCurrentSlide(index); setCurrentSlide(index);
}; };
// Сброс слайда при изменении товаров
useEffect(() => {
setCurrentSlide(0);
}, [activeProducts]);
// Если нет активных товаров дня, не показываем секцию // Если нет активных товаров дня, не показываем секцию
if (loading || error || activeProducts.length === 0) { if (loading || error || activeProducts.length === 0) {
return null; return null;
@ -153,63 +163,7 @@ const ProductOfDaySection: React.FC = () => {
<section className="main"> <section className="main">
<div className="w-layout-blockcontainer batd w-container"> <div className="w-layout-blockcontainer batd w-container">
<div className="w-layout-hflex flex-block-108"> <div className="w-layout-hflex flex-block-108">
<div <ProductOfDayBanner />
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>
<div className="div-block-129"> <div className="div-block-129">
<div className="w-layout-hflex flex-block-109"> <div className="w-layout-hflex flex-block-109">
@ -244,7 +198,7 @@ const ProductOfDaySection: React.FC = () => {
</div> </div>
{productImage && ( {productImage && (
<div className="relative"> <div className="">
<img <img
width="Auto" width="Auto"
height="Auto" height="Auto"
@ -260,6 +214,11 @@ const ProductOfDaySection: React.FC = () => {
Parts Index Parts Index
</div> </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>
)} )}
</div> </div>

View File

@ -143,11 +143,61 @@ const TopSalesSection: React.FC = () => {
<h2 className="heading-4">Топ продаж</h2> <h2 className="heading-4">Топ продаж</h2>
</div> </div>
<div className="carousel-row"> <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="Прокрутить влево"> <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"> <span className="arrow-circle">
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/> <svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.5 24L12.5 16L19.5 8" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/> <path d="M16.6673 10H3.33398M3.33398 10L8.33398 5M3.33398 10L8.33398 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</span>
</button> </button>
<div className="w-layout-hflex core-product-search carousel-scroll" ref={scrollRef}> <div className="w-layout-hflex core-product-search carousel-scroll" ref={scrollRef}>
{activeTopSalesProducts.map((item: TopSalesProductData) => { {activeTopSalesProducts.map((item: TopSalesProductData) => {
@ -177,10 +227,11 @@ const TopSalesSection: React.FC = () => {
})} })}
</div> </div>
<button className="carousel-arrow carousel-arrow-right" onClick={scrollRight} aria-label="Прокрутить вправо"> <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"> <span className="arrow-circle">
<circle cx="16" cy="16" r="16" fill="#F3F4F6"/> <svg className="arrow-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 8L19.5 16L12.5 24" stroke="#222" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/> <path d="M3.33398 10H16.6673M16.6673 10L11.6673 5M16.6673 10L11.6673 15" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -284,7 +284,7 @@ const ProfileHistoryMain = () => {
if (loading && historyItems.length === 0) { if (loading && historyItems.length === 0) {
return ( 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="flex justify-center items-center h-40">
<div className="text-gray-500">Загрузка истории поиска...</div> <div className="text-gray-500">Загрузка истории поиска...</div>
</div> </div>
@ -294,7 +294,7 @@ const ProfileHistoryMain = () => {
if (error) { if (error) {
return ( 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="flex justify-center items-center h-40">
<div className="text-red-500">Ошибка загрузки истории поиска</div> <div className="text-red-500">Ошибка загрузки истории поиска</div>
</div> </div>
@ -303,7 +303,7 @@ const ProfileHistoryMain = () => {
} }
return ( 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 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"> <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 <SearchInput

View File

@ -55,6 +55,7 @@ const KnotIn: React.FC<KnotInProps> = ({
const [selectedDetail, setSelectedDetail] = useState<{ oem: string; name: string } | null>(null); const [selectedDetail, setSelectedDetail] = useState<{ oem: string; name: string } | null>(null);
const [hoveredCodeOnImage, setHoveredCodeOnImage] = useState<string | number | null>(null); const [hoveredCodeOnImage, setHoveredCodeOnImage] = useState<string | number | null>(null);
const router = useRouter(); const router = useRouter();
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
// Получаем инфо об узле (для картинки) // Получаем инфо об узле (для картинки)
console.log('🔍 KnotIn - GET_LAXIMO_UNIT_INFO запрос:', { 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) => { const handlePointHover = (coord: any) => {
// Попробуем использовать разные поля для связи // Попробуем использовать разные поля для связи
@ -318,8 +325,9 @@ const KnotIn: React.FC<KnotInProps> = ({
loading="lazy" loading="lazy"
alt={unitName || unitInfo?.name || "Изображение узла"} alt={unitName || unitInfo?.name || "Изображение узла"}
onLoad={handleImageLoad} 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' }} style={{ maxWidth: 400, display: 'block' }}
onClick={handleImageClick}
/> />
{/* Точки/области */} {/* Точки/области */}
{coordinates.map((coord: any, idx: number) => { {coordinates.map((coord: any, idx: number) => {
@ -369,8 +377,8 @@ const KnotIn: React.FC<KnotInProps> = ({
pointerEvents: 'auto', pointerEvents: 'auto',
}} }}
title={`${codeValue} (Клик - выделить в списке, двойной клик - перейти к выбору бренда)`} title={`${codeValue} (Клик - выделить в списке, двойной клик - перейти к выбору бренда)`}
onClick={() => handlePointClick(coord)} onClick={e => { e.stopPropagation(); handlePointClick(coord); }}
onDoubleClick={() => handlePointDoubleClick(coord)} onDoubleClick={e => { e.stopPropagation(); handlePointDoubleClick(coord); }}
onMouseEnter={() => handlePointHover(coord)} onMouseEnter={() => handlePointHover(coord)}
onMouseLeave={() => { onMouseLeave={() => {
setHoveredCodeOnImage(null); setHoveredCodeOnImage(null);
@ -389,6 +397,33 @@ const KnotIn: React.FC<KnotInProps> = ({
); );
})} })}
</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 <BrandSelectionModal
isOpen={isBrandModalOpen} isOpen={isBrandModalOpen}

View File

@ -36,7 +36,9 @@ const KnotParts: React.FC<KnotPartsProps> = ({
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
const [tooltipPart, setTooltipPart] = useState<any>(null); const [tooltipPart, setTooltipPart] = useState<any>(null);
const [clickedPart, setClickedPart] = useState<string | number | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Отладочные логи для проверки данных // Отладочные логи для проверки данных
React.useEffect(() => { React.useEffect(() => {
@ -63,8 +65,31 @@ const KnotParts: React.FC<KnotPartsProps> = ({
// Обработчик клика по детали в списке // Обработчик клика по детали в списке
const handlePartClick = (part: any) => { const handlePartClick = (part: any) => {
if (part.codeonimage && onPartSelect) { const codeOnImage = part.codeonimage || part.detailid;
onPartSelect(part.codeonimage); 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) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
} }
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
}
}; };
}, []); }, []);
@ -213,12 +241,17 @@ const KnotParts: React.FC<KnotPartsProps> = ({
<div className="knot-parts"> <div className="knot-parts">
{parts.map((part, idx) => { {parts.map((part, idx) => {
const codeOnImage = part.codeonimage || part.detailid;
const isHighlighted = highlightedCodeOnImage !== null && highlightedCodeOnImage !== undefined && ( const isHighlighted = highlightedCodeOnImage !== null && highlightedCodeOnImage !== undefined && (
(part.codeonimage && part.codeonimage.toString() === highlightedCodeOnImage.toString()) || (part.codeonimage && part.codeonimage.toString() === highlightedCodeOnImage.toString()) ||
(part.detailid && part.detailid.toString() === highlightedCodeOnImage.toString()) (part.detailid && part.detailid.toString() === highlightedCodeOnImage.toString())
); );
const isSelected = selectedParts.has(part.detailid || part.codeonimage || idx.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'}`; const uniqueKey = `part-${idx}-${part.detailid || part.oem || part.name || 'unknown'}`;
@ -226,9 +259,11 @@ const KnotParts: React.FC<KnotPartsProps> = ({
return ( return (
<div <div
key={uniqueKey} 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 isSelected
? 'bg-green-100 border-green-500' ? 'bg-green-100 border-green-500'
: isClicked
? 'bg-red-100 border-red-400 shadow-md'
: isHighlighted : isHighlighted
? 'bg-slate-200' ? 'bg-slate-200'
: 'bg-white border-gray-200 hover:border-gray-300' : 'bg-white border-gray-200 hover:border-gray-300'
@ -240,13 +275,37 @@ const KnotParts: React.FC<KnotPartsProps> = ({
> >
<div className="w-layout-hflex flex-block-116"> <div className="w-layout-hflex flex-block-116">
<div <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} {part.codeonimage || idx + 1}
</div> </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>
<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} {part.name}
</div> </div>
<div className="w-layout-hflex flex-block-117"> <div className="w-layout-hflex flex-block-117">

View File

@ -19,6 +19,7 @@ interface VehicleAttributesTooltipProps {
const VehicleAttributesTooltip: React.FC<VehicleAttributesTooltipProps> = ({ const VehicleAttributesTooltip: React.FC<VehicleAttributesTooltipProps> = ({
show, show,
position, position,
vehicleName,
vehicleAttributes, vehicleAttributes,
onMouseEnter, onMouseEnter,
onMouseLeave, onMouseLeave,
@ -27,7 +28,7 @@ const VehicleAttributesTooltip: React.FC<VehicleAttributesTooltipProps> = ({
if (!show) return null; if (!show) return null;
return ( return (
<div <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={{ style={{
left: `${position.x + 120}px`, left: `${position.x + 120}px`,
top: `${position.y}px`, top: `${position.y}px`,
@ -45,16 +46,33 @@ const VehicleAttributesTooltip: React.FC<VehicleAttributesTooltipProps> = ({
/> />
)} )}
<div className="flex relative flex-col w-full"> <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"> {vehicleName && (
<div className="self-stretch my-auto text-gray-400 w-[150px] truncate"> <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} {attr.name}
</div> </div>
<div className="self-stretch my-auto font-medium text-black truncate"> <div
className="font-medium text-black break-words whitespace-normal text-left justify-self-start"
style={{ textAlign: 'left' }}
>
{attr.value} {attr.value}
</div> </div>
</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>
</div> </div>
); );

View File

@ -88,10 +88,9 @@ const VinQuick: React.FC<VinQuickProps> = ({ quickGroup, catalogCode, vehicleId,
))} ))}
{total > 3 && shownCount < total && ( {total > 3 && shownCount < total && (
<div className="flex gap-2 mt-2 w-full"> <div className="flex gap-2 mt-2 w-full">
{shownCount + 3 < total && (
<button <button
className="expand-btn" className="expand-btn"
onClick={() => setShownCounts(prev => ({ ...prev, [unit.unitid]: shownCount + 3 }))} onClick={() => setShownCounts(prev => ({ ...prev, [unit.unitid]: total }))}
style={{ border: '1px solid #EC1C24', borderRadius: 8, background: '#fff', color: '#222', padding: '6px 18px', minWidth: 180 }} style={{ border: '1px solid #EC1C24', borderRadius: 8, background: '#fff', color: '#222', padding: '6px 18px', minWidth: 180 }}
> >
Развернуть Развернуть
@ -99,10 +98,9 @@ const VinQuick: React.FC<VinQuickProps> = ({ quickGroup, catalogCode, vehicleId,
<path d="M4 6l4 4 4-4" stroke="#222" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/> <path d="M4 6l4 4 4-4" stroke="#222" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</svg> </svg>
</button> </button>
)}
<button <button
className="showall-btn" 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'}} style={{ background: '#e9eef5', borderRadius: 8, color: '#222', padding: '6px 18px', border: 'none'}}
> >
Показать все Показать все

View File

@ -4,7 +4,7 @@ import React, { createContext, useContext, useReducer, useEffect, ReactNode } fr
import { useMutation, useQuery } from '@apollo/client' import { useMutation, useQuery } from '@apollo/client'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { GET_FAVORITES, ADD_TO_FAVORITES, REMOVE_FROM_FAVORITES, CLEAR_FAVORITES } from '@/lib/favorites-queries' 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 { export interface FavoriteItem {
@ -135,7 +135,7 @@ const FavoritesProvider: React.FC<FavoritesProviderProps> = ({ children }) => {
const [removeFavoriteMutation] = useMutation(REMOVE_FROM_FAVORITES, { const [removeFavoriteMutation] = useMutation(REMOVE_FROM_FAVORITES, {
onCompleted: () => { onCompleted: () => {
toast('Товар удален из избранного', { toast('Товар удален из избранного', {
icon: <DeleteCartIcon size={20} color="#ec1c24" />, icon: <CloseIcon size={20} color="#fff" />,
style: { style: {
background: '#6b7280', // Серый фон background: '#6b7280', // Серый фон
color: '#fff', // Белый текст color: '#fff', // Белый текст

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useCallback } from 'react';
import { useLazyQuery } from '@apollo/client'; import { useLazyQuery } from '@apollo/client';
import { SEARCH_PRODUCT_OFFERS } from '@/lib/graphql'; import { SEARCH_PRODUCT_OFFERS } from '@/lib/graphql';
@ -33,17 +33,19 @@ interface ProductPriceVariables {
brand: string; 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 [pricesMap, setPricesMap] = useState<Map<string, ProductOffer | null>>(new Map());
const [loadingPrices, setLoadingPrices] = useState<Set<string>>(new Set()); 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 [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}`; 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); console.log('💰 Загружаем цену для:', product.code, product.brand);
@ -87,35 +89,31 @@ export const useProductPrices = (products: Array<{ code: string; brand: string;
newSet.delete(key); newSet.delete(key);
return newSet; return newSet;
}); });
setLoadedPrices(prev => new Set([...prev, key]));
} }
}; }, [searchOffers, loadedPrices, loadingPrices]);
useEffect(() => { const getPrice = useCallback((product: { code: string; brand: string; id: string }) => {
// Загружаем цены для всех товаров с небольшой задержкой между запросами
products.forEach((product, index) => {
setTimeout(() => {
loadPrice(product);
}, index * 100); // Задержка 100мс между запросами
});
}, [products]);
const getPrice = (product: { code: string; brand: string; id: string }) => {
const key = `${product.id}_${product.code}_${product.brand}`; const key = `${product.id}_${product.code}_${product.brand}`;
return pricesMap.get(key); 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}`; const key = `${product.id}_${product.code}_${product.brand}`;
return loadingPrices.has(key); return loadingPrices.has(key);
}; }, [loadingPrices]);
const loadPriceOnDemand = (product: { code: string; brand: string; id: string }) => { 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(product);
}; }
}, [loadPrice, loadedPrices, loadingPrices]);
return { return {
getPrice, getPrice,
isLoadingPrice, isLoadingPrice,
loadPriceOnDemand loadPrice,
ensurePriceLoaded
}; };
}; };

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { PartsIndexCatalogsResponse, PartsIndexGroup, PartsIndexEntityInfoResponse } from '@/types/partsindex'; import { PartsIndexCatalogsResponse, PartsIndexGroup, PartsIndexEntityInfoResponse } from '@/types/partsindex';
const PARTS_INDEX_API_BASE = 'https://api.parts-index.com'; const PARTS_INDEX_API_BASE = process.env.PARTSAPI_URL+"/v1" || 'https://api.parts-index.com/v1';
const API_KEY = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE'; const API_KEY = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE';
class PartsIndexService { class PartsIndexService {

View File

@ -93,3 +93,13 @@ export const memoize = <T extends (...args: any[]) => any>(
export const clearMemoCache = () => { export const clearMemoCache = () => {
memoCache.clear(); memoCache.clear();
}; };
// Проверка, является ли строка датой доставки
export const isDeliveryDate = (dateString: string): boolean => {
const months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
];
return months.some(month => dateString.includes(month));
};

View File

@ -52,18 +52,20 @@ export default function App({ Component, pageProps }: AppProps) {
<Component {...pageProps} /> <Component {...pageProps} />
</Layout> </Layout>
<Toaster <Toaster
position="top-right" position="top-center"
toastOptions={{ toastOptions={{
duration: 4000, duration: 4000,
style: { style: {
background: '#363636', background: '#363636',
color: '#fff', color: '#fff',
marginTop: '80px', // Отступ сверху, чтобы не закрывать кнопки меню
}, },
success: { success: {
duration: 3000, duration: 3000,
style: { style: {
background: '#22c55e', // Зеленый фон для успешных уведомлений background: '#22c55e', // Зеленый фон для успешных уведомлений
color: '#fff', // Белый текст color: '#fff', // Белый текст
marginTop: '80px', // Отступ сверху для успешных уведомлений
}, },
iconTheme: { iconTheme: {
primary: '#22c55e', primary: '#22c55e',
@ -72,6 +74,9 @@ export default function App({ Component, pageProps }: AppProps) {
}, },
error: { error: {
duration: 5000, duration: 5000,
style: {
marginTop: '80px', // Отступ сверху для ошибок
},
iconTheme: { iconTheme: {
primary: '#ef4444', primary: '#ef4444',
secondary: '#fff', secondary: '#fff',

View File

@ -38,7 +38,8 @@ const mockData = Array(12).fill({
brand: "Borsehung", brand: "Borsehung",
}); });
const ITEMS_PER_PAGE = 20; const ITEMS_PER_PAGE = 50; // Уменьшено для быстрой загрузки и лучшего UX
const PARTSINDEX_PAGE_SIZE = 25; // Синхронизировано для оптимальной скорости
const MAX_BRANDS_DISPLAY = 10; // Сколько брендов показывать изначально const MAX_BRANDS_DISPLAY = 10; // Сколько брендов показывать изначально
export default function Catalog() { export default function Catalog() {
@ -55,6 +56,36 @@ export default function Catalog() {
const [showSortMobile, setShowSortMobile] = useState(false); const [showSortMobile, setShowSortMobile] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedFilters, setSelectedFilters] = useState<{[key: string]: string[]}>({}); const [selectedFilters, setSelectedFilters] = useState<{[key: string]: string[]}>({});
// Инициализация фильтров из URL при загрузке
useEffect(() => {
if (router.isReady) {
const urlFilters: {[key: string]: string[]} = {};
const urlSearchQuery = router.query.q as string || '';
// Восстанавливаем фильтры из URL
Object.keys(router.query).forEach(key => {
if (key.startsWith('filter_')) {
const filterName = key.replace('filter_', '');
const filterValue = router.query[key];
if (typeof filterValue === 'string') {
urlFilters[filterName] = [filterValue];
} else if (Array.isArray(filterValue)) {
urlFilters[filterName] = filterValue;
}
}
});
console.log('🔗 Восстанавливаем фильтры из URL:', { urlFilters, urlSearchQuery });
if (Object.keys(urlFilters).length > 0) {
setSelectedFilters(urlFilters);
}
if (urlSearchQuery) {
setSearchQuery(urlSearchQuery);
}
}
}, [router.isReady]);
const [visibleArticles, setVisibleArticles] = useState<PartsAPIArticle[]>([]); const [visibleArticles, setVisibleArticles] = useState<PartsAPIArticle[]>([]);
const [visibleEntities, setVisibleEntities] = useState<PartsIndexEntity[]>([]); const [visibleEntities, setVisibleEntities] = useState<PartsIndexEntity[]>([]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@ -73,6 +104,14 @@ export default function Catalog() {
const [partsIndexPage, setPartsIndexPage] = useState(1); // Текущая страница для PartsIndex const [partsIndexPage, setPartsIndexPage] = useState(1); // Текущая страница для PartsIndex
const [totalPages, setTotalPages] = useState(1); // Общее количество страниц 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 [isFilterChanging, setIsFilterChanging] = useState(false); // Флаг изменения фильтров
// Карта видимости товаров по индексу // Карта видимости товаров по индексу
const [visibilityMap, setVisibilityMap] = useState<Map<number, boolean>>(new Map()); const [visibilityMap, setVisibilityMap] = useState<Map<number, boolean>>(new Map());
@ -108,7 +147,8 @@ export default function Catalog() {
categoryName, categoryName,
isPartsAPIMode, isPartsAPIMode,
isPartsIndexMode, isPartsIndexMode,
isPartsIndexCatalogOnly isPartsIndexCatalogOnly,
'router.query': router.query
}); });
// Загружаем артикулы PartsAPI // Загружаем артикулы PartsAPI
@ -135,10 +175,10 @@ export default function Catalog() {
catalogId: catalogId as string, catalogId: catalogId as string,
groupId: groupId as string, groupId: groupId as string,
lang: 'ru', lang: 'ru',
limit: ITEMS_PER_PAGE, limit: PARTSINDEX_PAGE_SIZE,
page: partsIndexPage, page: partsIndexPage,
q: searchQuery || undefined, q: searchQuery || undefined,
params: Object.keys(selectedFilters).length > 0 ? JSON.stringify(selectedFilters) : undefined params: undefined // Будем обновлять через refetch
}, },
skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId
fetchPolicy: 'cache-and-network' fetchPolicy: 'cache-and-network'
@ -146,7 +186,7 @@ export default function Catalog() {
); );
// Загружаем параметры фильтрации для PartsIndex // Загружаем параметры фильтрации для PartsIndex
const { data: paramsData, loading: paramsLoading, error: paramsError } = useQuery<PartsIndexParamsData, PartsIndexParamsVariables>( const { data: paramsData, loading: paramsLoading, error: paramsError, refetch: refetchParams } = useQuery<PartsIndexParamsData, PartsIndexParamsVariables>(
GET_PARTSINDEX_CATALOG_PARAMS, GET_PARTSINDEX_CATALOG_PARAMS,
{ {
variables: { variables: {
@ -154,7 +194,7 @@ export default function Catalog() {
groupId: groupId as string, groupId: groupId as string,
lang: 'ru', lang: 'ru',
q: searchQuery || undefined, q: searchQuery || undefined,
params: Object.keys(selectedFilters).length > 0 ? JSON.stringify(selectedFilters) : undefined params: undefined // Будем обновлять через refetch
}, },
skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId skip: !isPartsIndexMode || !groupId, // Пропускаем запрос если нет groupId
fetchPolicy: 'cache-first' fetchPolicy: 'cache-first'
@ -164,12 +204,25 @@ export default function Catalog() {
// allEntities больше не используется - используем allLoadedEntities // allEntities больше не используется - используем allLoadedEntities
// Хук для загрузки цен товаров PartsIndex // Хук для загрузки цен товаров PartsIndex
const productsForPrices = visibleEntities.map(entity => ({ const { getPrice, isLoadingPrice, ensurePriceLoaded } = useProductPrices();
// Загружаем цены для видимых товаров PartsIndex (для отображения конкретных цен)
useEffect(() => {
if (isPartsIndexMode && visibleEntities.length > 0) {
// Загружаем цены только для видимых товаров для отображения точных цен
visibleEntities.forEach((entity, index) => {
const productForPrice = {
id: entity.id, id: entity.id,
code: entity.code, code: entity.code,
brand: entity.brand.name brand: entity.brand.name
})); };
const { getPrice, isLoadingPrice, loadPriceOnDemand } = useProductPrices(productsForPrices); // Загружаем с небольшой задержкой
setTimeout(() => {
ensurePriceLoaded(productForPrice);
}, index * 50);
});
}
}, [isPartsIndexMode, visibleEntities, ensurePriceLoaded]);
useEffect(() => { useEffect(() => {
if (articlesData?.partsAPIArticles) { if (articlesData?.partsAPIArticles) {
@ -187,15 +240,19 @@ export default function Catalog() {
console.log('📊 Обновляем entitiesData:', { console.log('📊 Обновляем entitiesData:', {
listLength: entitiesData.partsIndexCatalogEntities.list.length, listLength: entitiesData.partsIndexCatalogEntities.list.length,
pagination: entitiesData.partsIndexCatalogEntities.pagination, pagination: entitiesData.partsIndexCatalogEntities.pagination,
currentPage: entitiesData.partsIndexCatalogEntities.pagination?.page?.current || 1 currentPage: entitiesData.partsIndexCatalogEntities.pagination?.page?.current || 1,
isFilterChanging
}); });
// Если изменяются фильтры, сбрасываем флаг после получения новых данных
if (isFilterChanging) {
setIsFilterChanging(false);
console.log('🔄 Сброшен флаг isFilterChanging - получены новые отфильтрованные данные');
}
const newEntities = entitiesData.partsIndexCatalogEntities.list; const newEntities = entitiesData.partsIndexCatalogEntities.list;
const pagination = entitiesData.partsIndexCatalogEntities.pagination; const pagination = entitiesData.partsIndexCatalogEntities.pagination;
// Обновляем список товаров
setVisibleEntities(newEntities);
// Обновляем информацию о пагинации // Обновляем информацию о пагинации
const currentPage = pagination?.page?.current || 1; const currentPage = pagination?.page?.current || 1;
const hasNext = pagination?.page?.next !== null; const hasNext = pagination?.page?.next !== null;
@ -204,6 +261,24 @@ export default function Catalog() {
setPartsIndexPage(currentPage); setPartsIndexPage(currentPage);
setHasMoreEntities(hasNext); setHasMoreEntities(hasNext);
// Сохраняем в кэш
setEntitiesCache(prev => new Map(prev).set(currentPage, newEntities));
// Если это первая страница или сброс, заменяем накопленные товары
if (currentPage === 1) {
setAccumulatedEntities(newEntities);
// Устанавливаем visibleEntities сразу, только если не идет изменение фильтров
if (!isFilterChanging) {
setVisibleEntities(newEntities);
console.log('✅ Установлены visibleEntities для первой страницы:', newEntities.length);
} else {
console.log('🔄 Пропускаем установку visibleEntities - фильтры изменяются');
}
} else {
// Добавляем к накопленным товарам
setAccumulatedEntities(prev => [...prev, ...newEntities]);
}
// Вычисляем общее количество страниц (приблизительно) // Вычисляем общее количество страниц (приблизительно)
if (hasNext) { if (hasNext) {
setTotalPages(currentPage + 1); // Минимум еще одна страница setTotalPages(currentPage + 1); // Минимум еще одна страница
@ -213,7 +288,104 @@ export default function Catalog() {
console.log('✅ Пагинация обновлена:', { currentPage, hasNext, hasPrev }); console.log('✅ Пагинация обновлена:', { currentPage, hasNext, hasPrev });
} }
}, [entitiesData]); }, [entitiesData, isFilterChanging]);
// Преобразование выбранных фильтров в формат PartsIndex API
const convertFiltersToPartsIndexParams = useMemo((): Record<string, any> => {
if (!paramsData?.partsIndexCatalogParams?.list || Object.keys(selectedFilters).length === 0) {
return {};
}
const apiParams: Record<string, any> = {};
paramsData.partsIndexCatalogParams.list.forEach((param: any) => {
const selectedValues = selectedFilters[param.name];
if (selectedValues && selectedValues.length > 0) {
// Находим соответствующие значения из API данных
const matchingValues = param.values.filter((value: any) =>
selectedValues.includes(value.title || value.value)
);
if (matchingValues.length > 0) {
// Используем ID параметра из API и значения
apiParams[param.id] = matchingValues.map((v: any) => v.value);
}
}
});
return apiParams;
}, [paramsData, selectedFilters]);
// Функция автоматической подгрузки дополнительных страниц PartsIndex
const autoLoadMoreEntities = useCallback(async () => {
if (isAutoLoading || !hasMoreEntities || !isPartsIndexMode) {
return;
}
console.log('🔄 Автоподгрузка: проверяем товары с предложениями...');
// Восстанавливаем автоподгрузку
console.log('🔄 Автоподгрузка активна');
// Подсчитываем текущее количество товаров (все уже отфильтрованы на сервере)
const currentEntitiesCount = accumulatedEntities.length;
console.log('📊 Автоподгрузка: текущее состояние:', {
накопленоТоваров: currentEntitiesCount,
целевоеКоличество: ITEMS_PER_PAGE,
естьЕщеТовары: hasMoreEntities
});
// Если у нас уже достаточно товаров, не загружаем
if (currentEntitiesCount >= 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 // Генерация фильтров для PartsIndex на основе параметров API
const generatePartsIndexFilters = useCallback((): FilterConfig[] => { const generatePartsIndexFilters = useCallback((): FilterConfig[] => {
@ -221,12 +393,12 @@ export default function Catalog() {
return []; return [];
} }
return paramsData.partsIndexCatalogParams.list.map(param => { return paramsData.partsIndexCatalogParams.list.map((param: any) => {
if (param.type === 'range') { if (param.type === 'range') {
// Для range фильтров ищем min и max значения // Для range фильтров ищем min и max значения
const numericValues = param.values const numericValues = param.values
.map(v => parseFloat(v.value)) .map((v: any) => parseFloat(v.value))
.filter(v => !isNaN(v)); .filter((v: number) => !isNaN(v));
const min = numericValues.length > 0 ? Math.min(...numericValues) : 0; const min = numericValues.length > 0 ? Math.min(...numericValues) : 0;
const max = numericValues.length > 0 ? Math.max(...numericValues) : 100; const max = numericValues.length > 0 ? Math.max(...numericValues) : 100;
@ -243,8 +415,8 @@ export default function Catalog() {
type: 'dropdown' as const, type: 'dropdown' as const,
title: param.name, title: param.name,
options: param.values options: param.values
.filter(value => value.available) // Показываем только доступные .filter((value: any) => value.available) // Показываем только доступные
.map(value => value.title || value.value), .map((value: any) => value.title || value.value),
multi: true, multi: true,
showAll: true showAll: true
}; };
@ -252,6 +424,8 @@ export default function Catalog() {
}); });
}, [paramsData]); }, [paramsData]);
useEffect(() => { useEffect(() => {
if (isPartsIndexMode) { if (isPartsIndexMode) {
// Для PartsIndex генерируем фильтры на основе параметров API // Для PartsIndex генерируем фильтры на основе параметров API
@ -264,6 +438,91 @@ export default function Catalog() {
} }
}, [isPartsIndexMode, generatePartsIndexFilters, paramsLoading]); }, [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;
}
// Если фильтры изменяются, не обновляем отображение старых данных
if (isFilterChanging) {
console.log('🔄 Пропускаем обновление entitiesWithOffers - фильтры изменяются');
return;
}
// Все товары уже отфильтрованы на сервере - показываем все накопленные
const entitiesWithOffers = accumulatedEntities;
console.log('📊 Обновляем entitiesWithOffers (серверная фильтрация):', {
накопленоТоваров: accumulatedEntities.length,
отображаемыхТоваров: entitiesWithOffers.length,
целевоеКоличество: ITEMS_PER_PAGE,
isFilterChanging
});
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, isFilterChanging]);
// Генерируем динамические фильтры для PartsAPI // Генерируем динамические фильтры для PartsAPI
const generatePartsAPIFilters = useCallback((): FilterConfig[] => { const generatePartsAPIFilters = useCallback((): FilterConfig[] => {
if (!allArticles.length) return []; if (!allArticles.length) return [];
@ -349,27 +608,94 @@ export default function Catalog() {
// Функция для обновления URL с фильтрами
const updateUrlWithFilters = useCallback((filters: {[key: string]: string[]}, search: string) => {
const query: any = { ...router.query };
// Удаляем старые фильтры из URL
Object.keys(query).forEach(key => {
if (key.startsWith('filter_') || key === 'q') {
delete query[key];
}
});
// Добавляем новые фильтры
Object.entries(filters).forEach(([filterName, values]) => {
if (values.length > 0) {
query[`filter_${filterName}`] = values.length === 1 ? values[0] : values;
}
});
// Добавляем поисковый запрос
if (search.trim()) {
query.q = search;
}
// Обновляем URL без перезагрузки страницы
router.push({
pathname: router.pathname,
query
}, undefined, { shallow: true });
}, [router]);
const handleDesktopFilterChange = (filterTitle: string, value: string | string[]) => { const handleDesktopFilterChange = (filterTitle: string, value: string | string[]) => {
setSelectedFilters(prev => ({ setSelectedFilters(prev => {
...prev, const newFilters = { ...prev };
[filterTitle]: Array.isArray(value) ? value : [value]
})); // Если значение пустое (пустой массив или пустая строка), удаляем фильтр
if (Array.isArray(value) && value.length === 0) {
delete newFilters[filterTitle];
} else if (!value || (typeof value === 'string' && value.trim() === '')) {
delete newFilters[filterTitle];
} else {
// Иначе устанавливаем значение
newFilters[filterTitle] = Array.isArray(value) ? value : [value];
}
// Обновляем URL
updateUrlWithFilters(newFilters, searchQuery);
return newFilters;
});
}; };
const handleMobileFilterChange = (type: string, value: any) => { const handleMobileFilterChange = (type: string, value: any) => {
setSelectedFilters(prev => ({ setSelectedFilters(prev => {
...prev, const newFilters = { ...prev };
[type]: Array.isArray(value) ? value : [value]
})); // Если значение пустое (пустой массив или пустая строка), удаляем фильтр
if (Array.isArray(value) && value.length === 0) {
delete newFilters[type];
} else if (!value || (typeof value === 'string' && value.trim() === '')) {
delete newFilters[type];
} else {
// Иначе устанавливаем значение
newFilters[type] = Array.isArray(value) ? value : [value];
}
// Обновляем URL
updateUrlWithFilters(newFilters, searchQuery);
return newFilters;
});
}; };
// Обработчик изменения поискового запроса
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
updateUrlWithFilters(selectedFilters, value);
}, [selectedFilters, updateUrlWithFilters]);
// Функция для сброса всех фильтров // Функция для сброса всех фильтров
const handleResetFilters = useCallback(() => { const handleResetFilters = useCallback(() => {
setSearchQuery(''); setSearchQuery('');
setSelectedFilters({}); setSelectedFilters({});
setShowAllBrands(false); setShowAllBrands(false);
setPartsIndexPage(1); // Сбрасываем страницу PartsIndex на первую setPartsIndexPage(1); // Сбрасываем страницу PartsIndex на первую
}, []);
// Очищаем URL от фильтров
updateUrlWithFilters({}, '');
}, [updateUrlWithFilters]);
// Фильтрация по поиску и фильтрам для PartsAPI // Фильтрация по поиску и фильтрам для PartsAPI
const filteredArticles = useMemo(() => { const filteredArticles = useMemo(() => {
@ -403,9 +729,6 @@ export default function Catalog() {
}); });
}, [allArticles, searchQuery, selectedFilters]); }, [allArticles, searchQuery, selectedFilters]);
// Упрощенная логика - показываем все загруженные товары без клиентской фильтрации
const filteredEntities = visibleEntities;
// Обновляем видимые артикулы при изменении поиска или фильтров для PartsAPI // Обновляем видимые артикулы при изменении поиска или фильтров для PartsAPI
useEffect(() => { useEffect(() => {
if (isPartsAPIMode) { if (isPartsAPIMode) {
@ -426,16 +749,85 @@ export default function Catalog() {
// При изменении поиска или фильтров сбрасываем пагинацию // При изменении поиска или фильтров сбрасываем пагинацию
setShowEmptyState(false); setShowEmptyState(false);
// Если изменился поисковый запрос, нужно перезагрузить данные с сервера // Если изменился поисковый запрос или фильтры, нужно перезагрузить данные с сервера
if (searchQuery.trim() || Object.keys(selectedFilters).length > 0) { if (searchQuery.trim() || Object.keys(selectedFilters).length > 0) {
console.log('🔍 Поисковый запрос или фильтры изменились, сбрасываем пагинацию'); console.log('🔍 Поисковый запрос или фильтры изменились, сбрасываем пагинацию');
// Устанавливаем флаг изменения фильтров
setIsFilterChanging(true);
setPartsIndexPage(1); setPartsIndexPage(1);
setCurrentUserPage(1);
setHasMoreEntities(true); setHasMoreEntities(true);
// refetch будет автоматически вызван при изменении partsIndexPage setAccumulatedEntities([]);
setEntitiesWithOffers([]);
setEntitiesCache(new Map());
// Вычисляем параметры фильтрации прямо здесь, чтобы избежать зависимости от useMemo
let apiParams: Record<string, any> = {};
if (paramsData?.partsIndexCatalogParams?.list && Object.keys(selectedFilters).length > 0) {
paramsData.partsIndexCatalogParams.list.forEach((param: any) => {
const selectedValues = selectedFilters[param.name];
if (selectedValues && selectedValues.length > 0) {
// Находим соответствующие значения из API данных
const matchingValues = param.values.filter((value: any) =>
selectedValues.includes(value.title || value.value)
);
if (matchingValues.length > 0) {
// Используем ID параметра из API и значения
apiParams[param.id] = matchingValues.map((v: any) => v.value);
}
}
});
}
const paramsString = Object.keys(apiParams).length > 0 ? JSON.stringify(apiParams) : undefined;
console.log('🔄 Запуск refetch с новыми фильтрами:', {
searchQuery,
selectedFilters,
apiParams,
paramsString,
catalogId,
groupId
});
// Также обновляем параметры фильтрации
refetchParams({
catalogId: catalogId as string,
groupId: groupId as string,
lang: 'ru',
q: searchQuery || undefined,
params: paramsString
}).then(result => {
console.log('✅ refetchParams результат:', result);
}).catch(error => {
console.error('❌ refetchParams ошибка:', error);
});
refetchEntities({
catalogId: catalogId as string,
groupId: groupId as string,
lang: 'ru',
limit: PARTSINDEX_PAGE_SIZE,
page: 1,
q: searchQuery || undefined,
params: paramsString
}).then(result => {
console.log('✅ refetchEntities результат:', result.data?.partsIndexCatalogEntities?.list?.length || 0, 'товаров');
}).catch(error => {
console.error('❌ refetchEntities ошибка:', error);
});
} else {
// Если нет активных фильтров, сбрасываем флаг
if (isFilterChanging) {
setIsFilterChanging(false);
}
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPartsIndexMode, searchQuery, JSON.stringify(selectedFilters)]); }, [isPartsIndexMode, searchQuery, JSON.stringify(selectedFilters), paramsData]);
// Управляем показом пустого состояния с задержкой // Управляем показом пустого состояния с задержкой
useEffect(() => { useEffect(() => {
@ -453,26 +845,61 @@ export default function Catalog() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
} else if (isPartsIndexMode && !entitiesLoading && !entitiesError) { } else if (isPartsIndexMode && !entitiesLoading && !entitiesError) {
// Для PartsIndex показываем пустое состояние если нет товаров // Для PartsIndex показываем пустое состояние если нет товаров И данные уже загружены
setShowEmptyState(visibleEntities.length === 0); const hasLoadedData = accumulatedEntities.length > 0 || Boolean(entitiesData?.partsIndexCatalogEntities?.list);
// Показываем пустое состояние если данные загружены и нет видимых товаров
// (товары уже отфильтрованы на сервере, поэтому не нужно ждать загрузки цен)
const shouldShowEmpty = hasLoadedData && visibleEntities.length === 0;
setShowEmptyState(shouldShowEmpty);
console.log('📊 Определяем showEmptyState для PartsIndex (серверная фильтрация):', {
hasLoadedData,
visibleEntitiesLength: visibleEntities.length,
accumulatedEntitiesLength: accumulatedEntities.length,
shouldShowEmpty,
showEmptyState: shouldShowEmpty
});
} else { } else {
setShowEmptyState(false); setShowEmptyState(false);
} }
}, [isPartsAPIMode, articlesLoading, articlesError, visibleProductsCount, allArticles.length, }, [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(() => { const handleNextPage = useCallback(() => {
if (hasMoreEntities && !entitiesLoading) { const maxUserPage = Math.ceil(accumulatedEntities.length / ITEMS_PER_PAGE);
setPartsIndexPage(prev => prev + 1); 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(() => { const handlePrevPage = useCallback(() => {
if (partsIndexPage > 1 && !entitiesLoading) { console.log('🔄 Нажата кнопка "Назад":', {
setPartsIndexPage(prev => prev - 1); 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) // Функция для загрузки следующей порции товаров по кнопке (только для PartsAPI)
const handleLoadMorePartsAPI = useCallback(async () => { const handleLoadMorePartsAPI = useCallback(async () => {
@ -542,9 +969,7 @@ export default function Catalog() {
isPartsAPIMode ? isPartsAPIMode ?
(visibilityMap.size === 0 && allArticles.length > 0 ? undefined : visibleProductsCount) : (visibilityMap.size === 0 && allArticles.length > 0 ? undefined : visibleProductsCount) :
isPartsIndexMode ? isPartsIndexMode ?
(searchQuery.trim() || Object.keys(selectedFilters).length > 0 ? entitiesWithOffers.length :
filteredEntities.length :
entitiesData?.partsIndexCatalogEntities?.pagination?.limit || visibleEntities.length) :
3587 3587
} }
productName={ productName={
@ -588,7 +1013,7 @@ export default function Catalog() {
onFilterChange={handleDesktopFilterChange} onFilterChange={handleDesktopFilterChange}
filterValues={selectedFilters} filterValues={selectedFilters}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={handleSearchChange}
isLoading={filtersGenerating} isLoading={filtersGenerating}
/> />
</div> </div>
@ -599,7 +1024,7 @@ export default function Catalog() {
onFilterChange={handleDesktopFilterChange} onFilterChange={handleDesktopFilterChange}
filterValues={selectedFilters} filterValues={selectedFilters}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={handleSearchChange}
isLoading={filtersLoading} isLoading={filtersLoading}
/> />
</div> </div>
@ -610,7 +1035,7 @@ export default function Catalog() {
onFilterChange={handleDesktopFilterChange} onFilterChange={handleDesktopFilterChange}
filterValues={selectedFilters} filterValues={selectedFilters}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={handleSearchChange}
isLoading={filtersLoading} isLoading={filtersLoading}
/> />
</div> </div>
@ -620,7 +1045,7 @@ export default function Catalog() {
onClose={() => setShowFiltersMobile(false)} onClose={() => setShowFiltersMobile(false)}
filters={isPartsAPIMode ? dynamicFilters : catalogFilters} filters={isPartsAPIMode ? dynamicFilters : catalogFilters}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={handleSearchChange}
filterValues={selectedFilters} filterValues={selectedFilters}
onFilterChange={handleMobileFilterChange} onFilterChange={handleMobileFilterChange}
/> />
@ -640,6 +1065,8 @@ export default function Catalog() {
</div> </div>
)} )}
{/* Сообщение об ошибке */} {/* Сообщение об ошибке */}
{isPartsAPIMode && articlesError && ( {isPartsAPIMode && articlesError && (
<div className="flex justify-center items-center py-8"> <div className="flex justify-center items-center py-8">
@ -689,36 +1116,45 @@ export default function Catalog() {
</> </>
)} )}
{/* Показываем индикатор загрузки при изменении фильтров */}
{isPartsIndexMode && isFilterChanging && (
<div className="flex flex-col items-center justify-center py-12">
<LoadingSpinner />
<div className="text-gray-500 text-lg mt-4">Применяем фильтры...</div>
</div>
)}
{/* Отображение товаров PartsIndex */} {/* Отображение товаров PartsIndex */}
{isPartsIndexMode && filteredEntities.length > 0 && ( {isPartsIndexMode && !isFilterChanging && (() => {
console.log('🎯 Проверяем отображение PartsIndex товаров:', {
isPartsIndexMode,
visibleEntitiesLength: visibleEntities.length,
visibleEntities: visibleEntities.map(e => ({ id: e.id, code: e.code, brand: e.brand.name })),
isFilterChanging
});
return visibleEntities.length > 0;
})() && (
<> <>
{filteredEntities {visibleEntities
.map((entity, idx) => { .map((entity, idx) => {
const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name }; const productForPrice = { id: entity.id, code: entity.code, brand: entity.brand.name };
const priceData = getPrice(productForPrice); const priceData = getPrice(productForPrice);
const isLoadingPriceData = isLoadingPrice(productForPrice); const isLoadingPriceData = isLoadingPrice(productForPrice);
return { // Определяем цену для отображения (все товары уже отфильтрованы на сервере)
entity, let displayPrice = "";
idx,
productForPrice,
priceData,
isLoadingPriceData,
hasOffer: priceData !== null || isLoadingPriceData
};
})
.filter(item => item.hasOffer) // Показываем только товары с предложениями или загружающиеся
.map(({ entity, idx, productForPrice, priceData, isLoadingPriceData }) => {
// Определяем цену для отображения
let displayPrice = "Цена по запросу";
let displayCurrency = "RUB"; let displayCurrency = "RUB";
let priceElement; let priceElement;
if (isLoadingPriceData) { if (isLoadingPriceData) {
// Показываем скелетон загрузки вместо текста
priceElement = <PriceSkeleton />; priceElement = <PriceSkeleton />;
} else if (priceData && priceData.price) { } else if (priceData && priceData.price) {
displayPrice = `${priceData.price.toLocaleString('ru-RU')}`; displayPrice = `${priceData.price.toLocaleString('ru-RU')}`;
displayCurrency = priceData.currency || "RUB"; displayCurrency = priceData.currency || "RUB";
} else {
// Если нет данных о цене, показываем скелетон (товар должен загрузиться)
priceElement = <PriceSkeleton />;
} }
return ( return (
@ -729,7 +1165,7 @@ export default function Catalog() {
articleNumber={entity.code} articleNumber={entity.code}
brandName={entity.brand.name} brandName={entity.brand.name}
image={entity.images?.[0] || ''} image={entity.images?.[0] || ''}
price={isLoadingPriceData ? "" : displayPrice} price={priceElement ? "" : displayPrice}
priceElement={priceElement} priceElement={priceElement}
oldPrice="" oldPrice=""
discount="" discount=""
@ -740,7 +1176,7 @@ export default function Catalog() {
onAddToCart={async () => { onAddToCart={async () => {
// Если цена не загружена, загружаем её и добавляем в корзину // Если цена не загружена, загружаем её и добавляем в корзину
if (!priceData && !isLoadingPriceData) { if (!priceData && !isLoadingPriceData) {
loadPriceOnDemand(productForPrice); ensurePriceLoaded(productForPrice);
console.log('🔄 Загружаем цену для:', entity.code, entity.brand.name); console.log('🔄 Загружаем цену для:', entity.code, entity.brand.name);
return; return;
} }
@ -793,40 +1229,61 @@ export default function Catalog() {
{/* Пагинация для PartsIndex */} {/* Пагинация для PartsIndex */}
<div className="w-layout-hflex pagination"> <div className="w-layout-hflex pagination">
<button <button
onClick={handlePrevPage} onClick={() => {
disabled={partsIndexPage <= 1 || entitiesLoading} console.log('🖱️ Клик по кнопке "Назад"');
handlePrevPage();
}}
disabled={currentUserPage <= 1}
className="button_strock w-button mr-2" className="button_strock w-button mr-2"
> >
Назад Назад
</button> </button>
<span className="flex items-center px-4 text-gray-600"> <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> </span>
<button <button
onClick={handleNextPage} onClick={() => {
disabled={!hasMoreEntities || entitiesLoading} console.log('🖱️ Клик по кнопке "Вперед"');
handleNextPage();
}}
disabled={currentUserPage >= Math.ceil(accumulatedEntities.length / ITEMS_PER_PAGE)}
className="button_strock w-button ml-2" className="button_strock w-button ml-2"
> >
{entitiesLoading ? 'Загрузка...' : 'Вперед →'} Вперед
</button> </button>
</div> </div>
{/* Отладочная информация */} {/* Отладочная информация */}
{isPartsIndexMode && ( {isPartsIndexMode && (
<div className="text-xs text-gray-500 mt-4 p-2 bg-gray-100 rounded"> <div className="text-xs text-gray-500 mt-4 p-2 bg-gray-100 rounded">
<div>🔍 Отладка PartsIndex:</div> <div>🔍 Отладка PartsIndex (исправленная логика):</div>
<div> hasMoreItems: {hasMoreItems ? 'да' : 'нет'}</div> <div> accumulatedEntities: {accumulatedEntities.length}</div>
<div> hasMoreEntities: {hasMoreEntities ? 'да' : 'нет'}</div> <div> entitiesWithOffers: {entitiesWithOffers.length}</div>
<div> entitiesPage: {entitiesPage}</div>
<div> visibleEntities: {visibleEntities.length}</div> <div> visibleEntities: {visibleEntities.length}</div>
<div> filteredEntities: {filteredEntities.length}</div> <div> currentUserPage: {currentUserPage}</div>
<div> groupId: {groupId || 'отсутствует'}</div> <div> partsIndexPage (API): {partsIndexPage}</div>
<div> isLoadingMore: {isLoadingMore ? 'да' : 'нет'}</div> <div> isAutoLoading: {isAutoLoading ? 'да' : 'нет'}</div>
<div> hasMoreEntities: {hasMoreEntities ? 'да' : 'нет'}</div>
<div> entitiesLoading: {entitiesLoading ? 'да' : 'нет'}</div> <div> entitiesLoading: {entitiesLoading ? 'да' : 'нет'}</div>
<div> catalogId: {catalogId || 'отсутствует'}</div> <div> groupId: {groupId || 'отсутствует'}</div>
<div> Пагинация: {JSON.stringify(entitiesData?.partsIndexCatalogEntities?.pagination)}</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> </div>
)} )}
</> </>
@ -842,7 +1299,16 @@ export default function Catalog() {
)} )}
{/* Пустое состояние для PartsIndex */} {/* Пустое состояние для PartsIndex */}
{isPartsIndexMode && !entitiesLoading && !entitiesError && showEmptyState && ( {isPartsIndexMode && !entitiesLoading && !entitiesError && (() => {
console.log('🎯 Проверяем пустое состояние PartsIndex:', {
isPartsIndexMode,
entitiesLoading,
entitiesError,
showEmptyState,
visibleEntitiesLength: visibleEntities.length
});
return showEmptyState;
})() && (
<CatalogEmptyState <CatalogEmptyState
categoryName={decodeURIComponent(categoryName as string || 'товаров')} categoryName={decodeURIComponent(categoryName as string || 'товаров')}
hasFilters={searchQuery.trim() !== '' || Object.keys(selectedFilters).some(key => selectedFilters[key].length > 0)} hasFilters={searchQuery.trim() !== '' || Object.keys(selectedFilters).some(key => selectedFilters[key].length > 0)}

View File

@ -0,0 +1,146 @@
import React from 'react';
import Head from 'next/head';
import CatalogSubscribe from "@/components/CatalogSubscribe";
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
import NewsAndPromos from "@/components/index/NewsAndPromos";
import Footer from "@/components/Footer";
import IndexTopMenuNav from "@/components/index/IndexTopMenuNav";
import MetaTags from "@/components/MetaTags";
import { getMetaByPath } from "@/lib/meta-config";
import JsonLdScript from "@/components/JsonLdScript";
import { generateOrganizationSchema, generateWebSiteSchema, PROTEK_ORGANIZATION } from "@/lib/schema";
export default function Confidentiality() {
const metaData = getMetaByPath('/');
// Добавьте эти строки:
const organizationSchema = generateOrganizationSchema(PROTEK_ORGANIZATION);
const websiteSchema = generateWebSiteSchema(
"Protek - Автозапчасти и аксессуары",
"https://protek.ru",
"https://protek.ru/search"
);
return (
<>
<MetaTags {...metaData} />
<JsonLdScript schema={organizationSchema} />
<JsonLdScript schema={websiteSchema} />
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<a href="#" className="link-block w-inline-block">
<div>Главная</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block-2 w-inline-block">
<div>Политика конфиденциальности</div>
</a>
</div>
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">Политика конфиденциальности</h1>
</div>
</div>
</div>
</div>
</section>
<div className="flex relative gap-8 items-start self-stretch pt-10 pb-20 max-md:p-8 max-sm:gap-5 max-sm:p-5">
<div className="flex relative flex-col gap-8 items-start p-10 bg-white rounded-3xl flex-[1_0_0] max-w-[1580px] mx-auto max-md:p-8 max-sm:gap-5 max-sm:p-5">
<div className="flex relative flex-col gap-5 items-start self-stretch max-sm:gap-4">
<div
layer-name="Объявлен старт продаж электрических насосов"
className="relative self-stretch text-3xl font-bold leading-9 text-gray-950"
>
Объявлен старт продаж электрических насосов
</div>
<div
layer-name="Бренд вывел на рынок сразу широкий ассортимент, уже на старте продаж - более 100 артикулов и включает в себя позиции для брендов-лидеров автомобильного рынка, например: артикул 77WPE080 для Mercedes-Benz S-CLASS (W221, C216), артикул 77WPE096 Land Rover DISCOVERY V (L462) / Jaguar F-PACE (X761), артикул 77WPE014 Audi Q5 (8RB) / Volkswagen TOUAREG (7P5, 7P6)."
className="relative self-stretch text-base leading-6 text-gray-600 max-sm:text-sm"
>
Бренд вывел на рынок сразу широкий ассортимент, уже на старте
продаж - более 100 артикулов и включает в себя позиции для
брендов-лидеров автомобильного рынка, например: артикул 77WPE080
для Mercedes-Benz S-CLASS (W221, C216), артикул 77WPE096 Land
Rover DISCOVERY V (L462) / Jaguar F-PACE (X761), артикул 77WPE014
Audi Q5 (8RB) / Volkswagen TOUAREG (7P5, 7P6).
</div>
</div>
<div className="flex relative flex-col gap-8 items-start self-stretch max-sm:gap-5">
<div
layer-name="Преимущества электрических насосов охлаждающей жидкости MasterKit Electro:"
className="relative self-stretch text-3xl font-medium leading-9 text-gray-950"
>
Преимущества электрических насосов охлаждающей жидкости MasterKit
Electro:
</div>
<div className="flex relative flex-col gap-3.5 items-start self-stretch">
<div className="flex relative gap-10 items-start w-full max-md:gap-5 max-sm:gap-4">
<div className="relative shrink-0 mt-2 w-2 h-2 bg-gray-600 rounded-full" />
<div
layer-name="Отличная производительность за счёт применения компонентов известных мировых брендов."
className="relative text-base leading-6 text-gray-600 flex-[1_0_0] max-sm:text-sm"
>
Отличная производительность за счёт применения компонентов
известных мировых брендов.
</div>
</div>
<div className="flex relative gap-10 items-start w-full max-md:gap-5 max-sm:gap-4">
<div className="relative shrink-0 mt-2 w-2 h-2 bg-gray-600 rounded-full" />
<div
layer-name="Герметичность и устойчивость к коррозии"
className="relative text-base leading-6 text-gray-600 flex-[1_0_0] max-sm:text-sm"
>
Герметичность и устойчивость к коррозии
</div>
</div>
<div className="flex relative gap-10 items-start w-full max-md:gap-5 max-sm:gap-4">
<div className="relative shrink-0 mt-2 w-2 h-2 bg-gray-600 rounded-full" />
<div
layer-name="Высококачественные материалы компонентов, обеспечивающие долгий срок службы"
className="relative text-base leading-6 text-gray-600 flex-[1_0_0] max-sm:text-sm"
>
Высококачественные материалы компонентов, обеспечивающие
долгий срок службы
</div>
</div>
<div className="flex relative gap-10 items-start w-full max-md:gap-5 max-sm:gap-4">
<div className="relative shrink-0 mt-2 w-2 h-2 bg-gray-600 rounded-full" />
<div
layer-name="Широкий ассортимент более 100 артикулов"
className="relative text-base leading-6 text-gray-600 flex-[1_0_0] max-sm:text-sm"
>
Широкий ассортимент более 100 артикулов
</div>
</div>
</div>
<div
layer-name="На электрические насосы системы охлаждения MasterKit Electro предоставляется гарантия 1 год или 30.000 км пробега, в зависимости от того, что наступит раньше. Все новинки уже внесены в каталог подбора продукции и доступны для заказа."
className="relative self-stretch text-base leading-6 text-gray-600 max-sm:text-sm"
>
На электрические насосы системы охлаждения MasterKit Electro
предоставляется гарантия 1 год или 30.000 км пробега, в
зависимости от того, что наступит раньше. Все новинки уже внесены
в каталог подбора продукции и доступны для заказа.
</div>
<div
layer-name="ABig_Button"
data-component-name="ABig_Button"
data-variant-name="Button big=Default"
className="relative gap-2.5 px-10 py-6 text-lg font-medium leading-5 text-center text-white no-underline bg-red-600 rounded-xl transition-all cursor-pointer border-[none] duration-[0.2s] ease-[ease] w-fit max-sm:px-8 max-sm:py-5 max-sm:w-full hover:bg-red-700"
>
Перейти к товару
</div>
</div>
</div>
</div>
<section className="section-3">
<CatalogSubscribe />
</section>
<Footer />
<MobileMenuBottomSection />
</>
);
}

View File

@ -17,6 +17,7 @@ import MetaTags from "@/components/MetaTags";
import { getMetaByPath } from "@/lib/meta-config"; import { getMetaByPath } from "@/lib/meta-config";
import JsonLdScript from "@/components/JsonLdScript"; import JsonLdScript from "@/components/JsonLdScript";
import { generateOrganizationSchema, generateWebSiteSchema, PROTEK_ORGANIZATION } from "@/lib/schema"; import { generateOrganizationSchema, generateWebSiteSchema, PROTEK_ORGANIZATION } from "@/lib/schema";
import HeroSlider from "@/components/index/HeroSlider";
export default function Home() { export default function Home() {
const metaData = getMetaByPath('/'); const metaData = getMetaByPath('/');

View File

@ -36,10 +36,10 @@ const ProfileHistoryPage = () => {
return ( return (
<> <>
<MetaTags {...metaData} /> <MetaTags {...metaData} />
<div className="page-wrapper h-full flex flex-col flex-1"> <div className="page-wrapper">
<ProfileInfo /> <ProfileInfo />
<div className="flex flex-col pt-10 pb-16 max-md:px-5 h-full flex-1"> <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 min-h-[526px] max-w-[1580px] w-full h-full"> <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} /> <LKMenu ref={menuRef} />
<ProfileHistoryMain /> <ProfileHistoryMain />
</div> </div>

View File

@ -21,11 +21,23 @@ import { createProductMeta } from "@/lib/meta-config";
const ANALOGS_CHUNK_SIZE = 5; 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[] => { const createFilters = (result: any, loadedAnalogs: any): FilterConfig[] => {
@ -175,15 +187,18 @@ const getBestOffers = (offers: any[]) => {
return result; return result;
}; };
// Убрано: функция сортировки теперь в CoreProductCard
const transformOffersForCard = (offers: any[]) => { const transformOffersForCard = (offers: any[]) => {
return offers.map(offer => { return offers.map(offer => {
const isExternal = offer.type === 'external'; const isExternal = offer.type === 'external';
const deliveryDays = isExternal ? offer.deliveryTime : offer.deliveryDays;
return { return {
id: offer.id, id: offer.id,
productId: offer.productId, productId: offer.productId,
offerKey: offer.offerKey, offerKey: offer.offerKey,
pcs: `${offer.quantity} шт.`, pcs: `${offer.quantity} шт.`,
days: `${isExternal ? offer.deliveryTime : offer.deliveryDays} дн.`, days: deliveryDays ? calculateDeliveryDate(deliveryDays) : 'Уточняйте',
recommended: !isExternal && offer.available, recommended: !isExternal && offer.available,
price: `${offer.price.toLocaleString('ru-RU')}`, price: `${offer.price.toLocaleString('ru-RU')}`,
count: "1", count: "1",
@ -191,7 +206,7 @@ const transformOffersForCard = (offers: any[]) => {
currency: offer.currency || "RUB", currency: offer.currency || "RUB",
warehouse: offer.warehouse, warehouse: offer.warehouse,
supplier: offer.supplier, supplier: offer.supplier,
deliveryTime: isExternal ? offer.deliveryTime : offer.deliveryDays, deliveryTime: deliveryDays,
}; };
}); });
}; };
@ -200,7 +215,7 @@ export default function SearchResult() {
const router = useRouter(); const router = useRouter();
const { article, brand, q, artId } = router.query; const { article, brand, q, artId } = router.query;
const [sortActive, setSortActive] = useState(0); // Убрано: глобальная сортировка теперь не используется
const [showFiltersMobile, setShowFiltersMobile] = useState(false); const [showFiltersMobile, setShowFiltersMobile] = useState(false);
const [showSortMobile, setShowSortMobile] = useState(false); const [showSortMobile, setShowSortMobile] = useState(false);
const [searchQuery, setSearchQuery] = useState<string>(""); const [searchQuery, setSearchQuery] = useState<string>("");
@ -542,7 +557,7 @@ export default function SearchResult() {
<section className="main mobile-only"> <section className="main mobile-only">
<div className="w-layout-blockcontainer container w-container"> <div className="w-layout-blockcontainer container w-container">
<div className="w-layout-hflex flex-block-84"> <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)}> <div className="w-layout-hflex flex-block-85" onClick={() => setShowFiltersMobile((v) => !v)}>
<span className="code-embed-9 w-embed"> <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"> <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 && ( {bestOffersData.length > 0 && (
<section className="section-6"> <section className="main">
<div className="w-layout-blockcontainer container w-container"> <div className="w-layout-blockcontainer container w-container">
<div className="w-layout-vflex flex-block-36"> <div className="w-layout-vflex flex-block-36">
{bestOffersData.map(({ offer, type }, index) => ( {bestOffersData.map(({ offer, type }, index) => (
@ -584,7 +599,7 @@ export default function SearchResult() {
title={`${offer.brand} ${offer.articleNumber}${offer.isAnalog ? ' (аналог)' : ''}`} title={`${offer.brand} ${offer.articleNumber}${offer.isAnalog ? ' (аналог)' : ''}`}
description={offer.name} description={offer.name}
price={`${offer.price.toLocaleString()}`} price={`${offer.price.toLocaleString()}`}
delivery={`${offer.deliveryDuration} ${offer.deliveryDuration === 1 ? 'день' : 'дней'}`} delivery={offer.deliveryDuration ? calculateDeliveryDate(offer.deliveryDuration) : 'Уточняйте'}
stock={`${offer.quantity} шт.`} stock={`${offer.quantity} шт.`}
offer={offer} offer={offer}
/> />

View File

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

View File

@ -53,6 +53,7 @@
.flex-block-40 { .flex-block-40 {
background-color: #fff; background-color: #fff;
padding-top: 10px;
} }
input.text-block-31 { input.text-block-31 {
@ -364,6 +365,15 @@ input.input-receiver:focus {
display: block; display: block;
} }
.image-5-copy {
width: 97px !important;
height: 97px !important;
}
.flex-block-111 {
width: 172px !important;
}
.show-more-btn { .show-more-btn {
background-color: #ec1c24; background-color: #ec1c24;
color: #fff; color: #fff;
@ -481,7 +491,6 @@ input#VinSearchInput {
line-height: 1.4em; line-height: 1.4em;
} }
.heading-9-copy,
.text-block-21-copy { .text-block-21-copy {
width: 250px; width: 250px;
overflow: hidden; overflow: hidden;
@ -489,7 +498,27 @@ input#VinSearchInput {
white-space: nowrap; 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) { @media (max-width: 767px) {
.heading-9-copy {
text-align: left;
display: block;
}
.w-layout-hflex.flex-block-6 { .w-layout-hflex.flex-block-6 {
flex-direction: column !important; flex-direction: column !important;
} }
@ -516,6 +545,9 @@ input#VinSearchInput {
} }
} }
.div-block-19{
padding-left: 20px !important;
}
.dropdown-toggle-card { .dropdown-toggle-card {
align-self: stretch; align-self: stretch;
@ -906,15 +938,14 @@ a.link-block-2.w-inline-block {
max-width: 100%; max-width: 100%;
} }
.heading-9-copy {
min-width: 100px;
.flex-block-36 {
width: 100%;
} }
.flex-block-15-copy { .flex-block-15-copy {
width: 235px!important; width: 232px!important;
min-width: 235px!important; min-width: 232px!important;
} }
.nameitembp { .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) { @media (max-width: 767px) {
.topmenub { .topmenub {
display: none !important; display: none !important;
@ -1126,3 +1166,110 @@ a.link-block-2.w-inline-block {
align-items: 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;
}
}

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

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

View File

@ -9,7 +9,7 @@ async function testPartsIndexAPI() {
// Получаем каталоги // Получаем каталоги
console.log('\n📦 Получаем список каталогов...'); console.log('\n📦 Получаем список каталогов...');
const catalogsResponse = await fetch('https://api.parts-index.com/v1/catalogs?lang=ru', { const catalogsResponse = await fetch(process.env.PARTSAPI_URL+"/v1/catalogs?lang=ru", {
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
}, },
@ -31,7 +31,7 @@ async function testPartsIndexAPI() {
console.log(`\n🎯 Получаем группы для каталога "${firstCatalog.name}"...`); console.log(`\n🎯 Получаем группы для каталога "${firstCatalog.name}"...`);
const groupsResponse = await fetch( const groupsResponse = await fetch(
`https://api.parts-index.com/v1/catalogs/${firstCatalog.id}/groups?lang=ru`, `${process.env.PARTSAPI_URL}/v1/catalogs/${firstCatalog.id}/groups?lang=ru`,
{ {
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',

21
user_input.py Normal file
View File

@ -0,0 +1,21 @@
def main():
while True:
print("\n" + "="*50)
user_input = input("Please provide feedback or next task (type 'stop' to exit): ").strip()
if user_input.lower() == 'stop':
print("Exiting task loop. Thank you!")
break
elif user_input.lower() == '':
print("Please provide some input or type 'stop' to exit.")
continue
else:
print(f"\nReceived input: {user_input}")
print("Processing your request...")
# Here the main process would handle the user's input
return user_input
if __name__ == "__main__":
result = main()
if result and result.lower() != 'stop':
print(f"Next task received: {result}")