From 7abe016f0feead6ca78ed3ddca18598d14c33e5e Mon Sep 17 00:00:00 2001 From: Bivekich Date: Fri, 11 Jul 2025 01:43:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20rebase=20=D1=81=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B=D0=BC=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/UnitDetailsSection.tsx | 36 ++- src/components/vin/KnotIn.tsx | 171 ++++++++-- src/components/vin/KnotParts.tsx | 303 +++++++++++++++++- src/pages/search-result.tsx | 186 +++++++---- .../vehicle-search/[brand]/[vehicleId].tsx | 81 +++++ src/styles/globals.css | 277 ++++++++++++++++ 6 files changed, 958 insertions(+), 96 deletions(-) diff --git a/src/components/UnitDetailsSection.tsx b/src/components/UnitDetailsSection.tsx index 617fb2d..0ebf74f 100644 --- a/src/components/UnitDetailsSection.tsx +++ b/src/components/UnitDetailsSection.tsx @@ -28,6 +28,7 @@ const UnitDetailsSection: React.FC = ({ const [imageLoadTimeout, setImageLoadTimeout] = useState(null); const [isBrandModalOpen, setIsBrandModalOpen] = useState(false); const [selectedDetail, setSelectedDetail] = useState(null); + const [highlightedDetailId, setHighlightedDetailId] = useState(null); // Отладочная информация для SSD console.log('🔍 UnitDetailsSection получил SSD:', { @@ -165,11 +166,31 @@ const UnitDetailsSection: React.FC = ({ d.detailid === coord.codeonimage ); + if (detail) { + console.log('✅ Найдена деталь для выделения:', detail.name, 'ID:', detail.detailid); + // Выделяем деталь в списке + setHighlightedDetailId(detail.detailid); + } else { + console.log('⚠️ Деталь не найдена в списке по коду:', coord.codeonimage); + setHighlightedDetailId(null); + } + }; + + const handleCoordinateDoubleClick = (coord: LaximoImageCoordinate) => { + console.log('🖱️ Двойной клик по интерактивной области:', coord.codeonimage); + + // Сначала пытаемся найти деталь в списке + const detail = unitDetails.find(d => + d.detailid === coord.detailid || + d.codeonimage === coord.codeonimage || + d.detailid === coord.codeonimage + ); + if (detail && detail.oem) { console.log('✅ Найдена деталь для выбора бренда:', detail.name, 'OEM:', detail.oem); - // Показываем модал выбора бренда - setSelectedDetail(detail); - setIsBrandModalOpen(true); + // Переходим на страницу выбора бренда + const url = `/vehicle-search/${catalogCode}/${vehicleId}/part/${detail.oem}/brands?detailName=${encodeURIComponent(detail.name || '')}`; + router.push(url); } else { // Если деталь не найдена в списке, переходим к общему поиску по коду на изображении console.log('⚠️ Деталь не найдена в списке, переходим к поиску по коду:', coord.codeonimage); @@ -461,7 +482,8 @@ const UnitDetailsSection: React.FC = ({ borderRadius: coord.shape === 'circle' ? '50%' : '0' }} onClick={() => handleCoordinateClick(coord)} - title={detail ? `${coord.codeonimage}: ${detail.name}` : `Деталь ${coord.codeonimage}`} + onDoubleClick={() => handleCoordinateDoubleClick(coord)} + title={detail ? `${coord.codeonimage}: ${detail.name} (Клик - выделить, двойной клик - перейти к выбору бренда)` : `Деталь ${coord.codeonimage} (Клик - выделить, двойной клик - поиск)`} >
{coord.codeonimage} @@ -612,7 +634,11 @@ const UnitDetailsSection: React.FC = ({ {unitDetails.map((detail, index) => (
handleDetailClick(detail)} >
diff --git a/src/components/vin/KnotIn.tsx b/src/components/vin/KnotIn.tsx index 58533a1..79b19e9 100644 --- a/src/components/vin/KnotIn.tsx +++ b/src/components/vin/KnotIn.tsx @@ -21,6 +21,9 @@ interface KnotInProps { note?: string; attributes?: Array<{ key: string; name?: string; value: string }>; }>; + onPartSelect?: (codeOnImage: string | number | null) => void; // Коллбек для уведомления KnotParts о выделении детали + onPartsHighlight?: (codeOnImage: string | number | null) => void; // Коллбек для подсветки при hover + selectedParts?: Set; // Выбранные детали (множественный выбор) } // Функция для корректного формирования URL изображения @@ -34,12 +37,23 @@ const getImageUrl = (baseUrl: string, size: string) => { .replace('%size%', size); }; -const KnotIn: React.FC = ({ catalogCode, vehicleId, ssd, unitId, unitName, parts }) => { +const KnotIn: React.FC = ({ + catalogCode, + vehicleId, + ssd, + unitId, + unitName, + parts, + onPartSelect, + onPartsHighlight, + selectedParts = new Set() +}) => { const imgRef = useRef(null); const [imageScale, setImageScale] = useState({ x: 1, y: 1 }); const selectedImageSize = 'source'; const [isBrandModalOpen, setIsBrandModalOpen] = useState(false); const [selectedDetail, setSelectedDetail] = useState<{ oem: string; name: string } | null>(null); + const [hoveredCodeOnImage, setHoveredCodeOnImage] = useState(null); const router = useRouter(); // Получаем инфо об узле (для картинки) @@ -150,21 +164,62 @@ const KnotIn: React.FC = ({ catalogCode, vehicleId, ssd, unitId, un }); }; - // Клик по точке: найти part по codeonimage/detailid и открыть BrandSelectionModal - const handlePointClick = (codeonimage: string | number) => { + // Обработчик наведения на точку + const handlePointHover = (coord: any) => { + // Попробуем использовать разные поля для связи + const identifierToUse = coord.detailid || coord.codeonimage || coord.code; + + console.log('🔍 KnotIn - hover на точку:', { + coord, + detailid: coord.detailid, + codeonimage: coord.codeonimage, + code: coord.code, + identifierToUse, + type: typeof identifierToUse, + coordinatesLength: coordinates.length, + partsLength: parts?.length || 0, + firstCoord: coordinates[0], + firstPart: parts?.[0] + }); + + setHoveredCodeOnImage(identifierToUse); + if (onPartsHighlight) { + onPartsHighlight(identifierToUse); + } + }; + + // Клик по точке: выделить в списке деталей + const handlePointClick = (coord: any) => { if (!parts) return; - console.log('Клик по точке:', codeonimage, 'Все детали:', parts); + + const identifierToUse = coord.detailid || coord.codeonimage || coord.code; + console.log('Клик по точке:', identifierToUse, 'Координата:', coord, 'Все детали:', parts); + + // Уведомляем родительский компонент о выборе детали для выделения в списке + if (onPartSelect) { + onPartSelect(identifierToUse); + } + }; + + // Двойной клик по точке: переход на страницу выбора бренда + const handlePointDoubleClick = (coord: any) => { + if (!parts) return; + + const identifierToUse = coord.detailid || coord.codeonimage || coord.code; + console.log('Двойной клик по точке:', identifierToUse, 'Координата:', coord); + const part = parts.find( (p) => - (p.codeonimage && p.codeonimage.toString() === codeonimage.toString()) || - (p.detailid && p.detailid.toString() === codeonimage.toString()) + (p.detailid && p.detailid.toString() === identifierToUse?.toString()) || + (p.codeonimage && p.codeonimage.toString() === identifierToUse?.toString()) ); - console.log('Найдена деталь для точки:', part); + if (part?.oem) { - setSelectedDetail({ oem: part.oem, name: part.name || '' }); - setIsBrandModalOpen(true); + // Переходим на страницу выбора бренда вместо модального окна + const url = `/vehicle-search/${catalogCode}/${vehicleId}/part/${part.oem}/brands?detailName=${encodeURIComponent(part.name || '')}`; + router.push(url); } else { - console.warn('Нет артикула (oem) для выбранной точки:', codeonimage, part); + console.warn('Нет артикула (oem) для выбранной точки:', identifierToUse, part); } }; @@ -172,6 +227,40 @@ const KnotIn: React.FC = ({ catalogCode, vehicleId, ssd, unitId, un React.useEffect(() => { console.log('KnotIn parts:', parts); console.log('KnotIn coordinates:', coordinates); + if (coordinates.length > 0) { + console.log('🔍 Первые 5 координат:', coordinates.slice(0, 5).map((c: any) => ({ + code: c.code, + codeonimage: c.codeonimage, + detailid: c.detailid, + x: c.x, + y: c.y + }))); + } + if (parts && parts.length > 0) { + console.log('🔍 Первые 5 деталей:', parts.slice(0, 5).map(p => ({ + name: p.name, + codeonimage: p.codeonimage, + detailid: p.detailid, + oem: p.oem + }))); + } + + // Попытка связать координаты с деталями + if (coordinates.length > 0 && parts && parts.length > 0) { + console.log('🔗 Попытка связать координаты с деталями:'); + coordinates.forEach((coord: any, idx: number) => { + const matchingPart = parts.find(part => + part.detailid === coord.detailid || + part.codeonimage === coord.codeonimage || + part.codeonimage === coord.code + ); + if (matchingPart) { + console.log(` ✅ Координата ${idx}: detailid=${coord.detailid}, codeonimage=${coord.codeonimage} -> Деталь: ${matchingPart.name}`); + } else { + console.log(` ❌ Координата ${idx}: detailid=${coord.detailid}, codeonimage=${coord.codeonimage} -> НЕ НАЙДЕНА`); + } + }); + } }, [parts, coordinates]); if (unitInfoLoading || imageMapLoading) { @@ -222,11 +311,16 @@ const KnotIn: React.FC = ({ catalogCode, vehicleId, ssd, unitId, un return ( <> +<<<<<<< HEAD
{/* ВРЕМЕННО: выводим количество точек для быстрой проверки */} {/*
{coordinates.length} точек
*/} +======= +
+ +>>>>>>> c1e0d46 (Добавлены новые функции для выделения и выбора деталей в компонентах UnitDetailsSection и KnotIn. Реализованы обработчики кликов и наведения, а также улучшено взаимодействие с пользователем через подсветку выбранных деталей. Обновлены компоненты KnotParts и VehicleDetailsPage для поддержки множественного выбора деталей и логирования данных от API.) = ({ catalogCode, vehicleId, ssd, unitId, un const size = 22; const scaledX = coord.x * imageScale.x - size / 2; const scaledY = coord.y * imageScale.y - size / 2; + + // Используем code или codeonimage в зависимости от структуры данных + const codeValue = coord.code || coord.codeonimage; + + // Определяем состояние точки + const isSelected = selectedParts.has(codeValue); + const isHovered = hoveredCodeOnImage === codeValue; + + // Определяем цвета на основе состояния + let backgroundColor = '#B7CAE2'; // Базовый цвет + let textColor = '#000'; + + if (isSelected) { + backgroundColor = '#22C55E'; // Зеленый для выбранных + textColor = '#fff'; + } else if (isHovered) { + backgroundColor = '#EC1C24'; // Красный при наведении + textColor = '#fff'; + } + return (
{ - if (e.key === 'Enter' || e.key === ' ') handlePointClick(coord.codeonimage); + if (e.key === 'Enter' || e.key === ' ') handlePointClick(coord); }} - className="absolute flex items-center justify-center cursor-pointer transition-colors" + className="absolute flex items-center justify-center cursor-pointer transition-all duration-200 ease-in-out" style={{ left: scaledX, top: scaledY, width: size, height: size, - background: '#B7CAE2', + backgroundColor, borderRadius: '50%', - + border: isSelected ? '2px solid #16A34A' : 'none', + transform: isHovered || isSelected ? 'scale(1.1)' : 'scale(1)', + zIndex: isHovered || isSelected ? 10 : 1, pointerEvents: 'auto', }} - title={coord.codeonimage} - onClick={() => handlePointClick(coord.codeonimage)} - onMouseEnter={e => { - (e.currentTarget as HTMLDivElement).style.background = '#EC1C24'; - (e.currentTarget.querySelector('span') as HTMLSpanElement).style.color = '#fff'; - }} - onMouseLeave={e => { - (e.currentTarget as HTMLDivElement).style.background = '#B7CAE2'; - (e.currentTarget.querySelector('span') as HTMLSpanElement).style.color = '#000'; + title={`${codeValue} (Клик - выделить в списке, двойной клик - перейти к выбору бренда)`} + onClick={() => handlePointClick(coord)} + onDoubleClick={() => handlePointDoubleClick(coord)} + onMouseEnter={() => handlePointHover(coord)} + onMouseLeave={() => { + setHoveredCodeOnImage(null); + if (onPartsHighlight) { + onPartsHighlight(null); + } }} > - - {coord.codeonimage} + + {codeValue}
); diff --git a/src/components/vin/KnotParts.tsx b/src/components/vin/KnotParts.tsx index e1eaa2f..e3aaacd 100644 --- a/src/components/vin/KnotParts.tsx +++ b/src/components/vin/KnotParts.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { useRouter } from "next/router"; interface KnotPartsProps { @@ -16,10 +16,42 @@ interface KnotPartsProps { selectedCodeOnImage?: string | number; catalogCode?: string; vehicleId?: string; + highlightedCodeOnImage?: string | number | null; // Деталь подсвеченная при hover на изображении + selectedParts?: Set; // Выбранные детали (множественный выбор) + onPartSelect?: (codeOnImage: string | number | null) => void; // Коллбек для выбора детали + onPartHover?: (codeOnImage: string | number | null) => void; // Коллбек для подсветки при hover } -const KnotParts: React.FC = ({ parts = [], selectedCodeOnImage, catalogCode, vehicleId }) => { +const KnotParts: React.FC = ({ + parts = [], + selectedCodeOnImage, + catalogCode, + vehicleId, + highlightedCodeOnImage, + selectedParts = new Set(), + onPartSelect, + onPartHover +}) => { const router = useRouter(); + const [showTooltip, setShowTooltip] = useState(false); + const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); + const [tooltipPart, setTooltipPart] = useState(null); + const timeoutRef = useRef(null); + + // Отладочные логи для проверки данных + React.useEffect(() => { + console.log('🔍 KnotParts получил данные:', { + partsCount: parts.length, + firstPart: parts[0], + firstPartAttributes: parts[0]?.attributes?.length || 0, + allPartsWithAttributes: parts.map(part => ({ + name: part.name, + oem: part.oem, + attributesCount: part.attributes?.length || 0, + attributes: part.attributes + })) + }); + }, [parts]); const handlePriceClick = (part: any) => { if (part.oem && catalogCode && vehicleId !== undefined) { @@ -29,6 +61,98 @@ const KnotParts: React.FC = ({ parts = [], selectedCodeOnImage, } }; + // Обработчик клика по детали в списке + const handlePartClick = (part: any) => { + if (part.codeonimage && onPartSelect) { + onPartSelect(part.codeonimage); + } + }; + + // Обработчики наведения + const handlePartMouseEnter = (part: any) => { + if (part.codeonimage && onPartHover) { + onPartHover(part.codeonimage); + } + }; + + const handlePartMouseLeave = () => { + if (onPartHover) { + onPartHover(null); + } + }; + + // Вычисляем позицию tooltip + const calculateTooltipPosition = (iconElement: HTMLElement) => { + if (!iconElement) { + console.error('❌ calculateTooltipPosition: элемент не найден'); + return; + } + + const rect = iconElement.getBoundingClientRect(); + const tooltipWidth = 400; + const tooltipHeight = 300; // примерная высота + + let x = rect.left + rect.width / 2 - tooltipWidth / 2; + let y = rect.bottom + 8; + + // Проверяем, не выходит ли tooltip за границы экрана + if (x < 10) x = 10; + if (x + tooltipWidth > window.innerWidth - 10) { + x = window.innerWidth - tooltipWidth - 10; + } + + // Если tooltip не помещается снизу, показываем сверху + if (y + tooltipHeight > window.innerHeight - 10) { + y = rect.top - tooltipHeight - 8; + } + + setTooltipPosition({ x, y }); + }; + + const handleInfoIconMouseEnter = (event: React.MouseEvent, part: any) => { + event.stopPropagation(); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Сохраняем ссылку на элемент до setTimeout + const target = event.currentTarget as HTMLElement; + + timeoutRef.current = setTimeout(() => { + if (target && typeof target.getBoundingClientRect === 'function') { + calculateTooltipPosition(target); + setTooltipPart(part); + setShowTooltip(true); + console.log('🔍 Показываем тултип для детали:', part.name, 'Атрибуты:', part.attributes?.length || 0); + } else { + console.error('❌ handleInfoIconMouseEnter: элемент не поддерживает getBoundingClientRect:', target); + } + }, 300); // Задержка 300ms + }; + + const handleInfoIconMouseLeave = (event: React.MouseEvent) => { + event.stopPropagation(); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + setShowTooltip(false); + setTooltipPart(null); + }, 100); // Небольшая задержка перед скрытием + }; + + // Очищаем таймеры при размонтировании + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + // Если нет деталей, показываем заглушку if (!parts || parts.length === 0) { return ( @@ -41,29 +165,107 @@ const KnotParts: React.FC = ({ parts = [], selectedCodeOnImage, ); } + // Эффект для отслеживания изменений подсветки + useEffect(() => { + console.log('🔍 KnotParts - подсветка изменилась:', { + highlightedCodeOnImage, + highlightedType: typeof highlightedCodeOnImage, + partsCodeOnImages: parts.map(p => p.codeonimage), + partsDetailIds: parts.map(p => p.detailid), + willHighlight: parts.some(part => + (part.codeonimage && part.codeonimage.toString() === highlightedCodeOnImage?.toString()) || + (part.detailid && part.detailid.toString() === highlightedCodeOnImage?.toString()) + ), + willHighlightStrict: parts.some(part => + part.codeonimage === highlightedCodeOnImage || + part.detailid === highlightedCodeOnImage + ), + firstPartWithCodeOnImage: parts.find(p => p.codeonimage) + }); + + // Детальная информация о всех деталях + console.log('📋 Все детали с их codeonimage и detailid:'); + parts.forEach((part, idx) => { + console.log(` Деталь ${idx}: "${part.name}" codeonimage="${part.codeonimage}" (${typeof part.codeonimage}) detailid="${part.detailid}" (${typeof part.detailid})`); + }); + + console.log('🎯 Ищем подсветку для:', `"${highlightedCodeOnImage}" (${typeof highlightedCodeOnImage})`); + }, [highlightedCodeOnImage, parts]); + return ( <> + {/* Статус выбранных деталей */} + {selectedParts.size > 0 && ( +
+
+ + + + + Выбрано деталей: {selectedParts.size} + + + (Кликните по детали, чтобы убрать из выбранных) + +
+
+ )} +
{parts.map((part, idx) => { - const isSelected = part.codeonimage && part.codeonimage === selectedCodeOnImage; + const isHighlighted = highlightedCodeOnImage !== null && highlightedCodeOnImage !== undefined && ( + (part.codeonimage && part.codeonimage.toString() === highlightedCodeOnImage.toString()) || + (part.detailid && part.detailid.toString() === highlightedCodeOnImage.toString()) + ); + + const isSelected = selectedParts.has(part.detailid || part.codeonimage || idx.toString()); + + // Создаем уникальный ключ + const uniqueKey = `part-${idx}-${part.detailid || part.oem || part.name || 'unknown'}`; + return (
handlePartClick(part)} + onMouseEnter={() => handlePartMouseEnter(part)} + onMouseLeave={handlePartMouseLeave} + style={{ cursor: 'pointer' }} >
-
{part.codeonimage || idx + 1}
+
+ {part.codeonimage || idx + 1} +
{part.oem}
-
{part.name}
+
+ {part.name} +
-
+
handleInfoIconMouseEnter(e, part)} + onMouseLeave={handleInfoIconMouseLeave} + style={{ cursor: 'pointer' }} + > @@ -73,6 +275,89 @@ const KnotParts: React.FC = ({ parts = [], selectedCodeOnImage, ); })}
+ + {/* Красивый тултип с информацией о детали */} + {showTooltip && tooltipPart && ( +
+
+ {/* Стрелка тултипа */} +
+ + {/* Заголовок */} +
+
+ + + +
+
+

{tooltipPart.name}

+ {tooltipPart.oem && ( +
+ OEM + {tooltipPart.oem} +
+ )} +
+
+ + {/* Контент */} +
+ {tooltipPart.attributes && tooltipPart.attributes.length > 0 ? ( +
+
+ + + + Характеристики +
+
+ {tooltipPart.attributes.map((attr: any, index: number) => ( +
+
{attr.name || attr.key}
+
{attr.value}
+
+ ))} +
+
+ ) : ( +
+
+ + + +
+
+
Дополнительная информация
+
недоступна
+
+
+ )} + + {tooltipPart.note && ( +
+
+ + + + Примечание +
+
{tooltipPart.note}
+
+ )} +
+
+
+ )} ); }; diff --git a/src/pages/search-result.tsx b/src/pages/search-result.tsx index 60d591a..91c3281 100644 --- a/src/pages/search-result.tsx +++ b/src/pages/search-result.tsx @@ -54,26 +54,40 @@ const createFilters = (result: any, loadedAnalogs: any): FilterConfig[] => { }); } - // Фильтр по цене - const prices: number[] = []; + // Получаем все доступные предложения для расчета диапазонов + const allAvailableOffers: any[] = []; + + // Добавляем основные предложения result.internalOffers?.forEach((offer: any) => { - if (offer.price > 0) prices.push(offer.price); + allAvailableOffers.push(offer); }); result.externalOffers?.forEach((offer: any) => { - if (offer.price > 0) prices.push(offer.price); + allAvailableOffers.push(offer); }); - // Добавляем цены аналогов + // Добавляем предложения аналогов Object.values(loadedAnalogs).forEach((analog: any) => { analog.internalOffers?.forEach((offer: any) => { - if (offer.price > 0) prices.push(offer.price); + allAvailableOffers.push({ + ...offer, + deliveryDuration: offer.deliveryDays + }); }); analog.externalOffers?.forEach((offer: any) => { - if (offer.price > 0) prices.push(offer.price); + allAvailableOffers.push({ + ...offer, + deliveryDuration: offer.deliveryTime + }); }); }); - if (prices.length > 0) { + // Фильтр по цене - только если есть предложения с разными ценами + const prices: number[] = []; + allAvailableOffers.forEach((offer: any) => { + if (offer.price > 0) prices.push(offer.price); + }); + + if (prices.length > 1) { const minPrice = Math.min(...prices); const maxPrice = Math.max(...prices); @@ -87,26 +101,14 @@ const createFilters = (result: any, loadedAnalogs: any): FilterConfig[] => { } } - // Фильтр по сроку доставки + // Фильтр по сроку доставки - только если есть предложения с разными сроками const deliveryDays: number[] = []; - result.internalOffers?.forEach((offer: any) => { - if (offer.deliveryDays && offer.deliveryDays > 0) deliveryDays.push(offer.deliveryDays); - }); - result.externalOffers?.forEach((offer: any) => { - if (offer.deliveryTime && offer.deliveryTime > 0) deliveryDays.push(offer.deliveryTime); - }); - - // Добавляем сроки доставки аналогов - Object.values(loadedAnalogs).forEach((analog: any) => { - analog.internalOffers?.forEach((offer: any) => { - if (offer.deliveryDays && offer.deliveryDays > 0) deliveryDays.push(offer.deliveryDays); - }); - analog.externalOffers?.forEach((offer: any) => { - if (offer.deliveryTime && offer.deliveryTime > 0) deliveryDays.push(offer.deliveryTime); - }); + allAvailableOffers.forEach((offer: any) => { + const days = offer.deliveryDays || offer.deliveryTime || offer.deliveryDuration; + if (days && days > 0) deliveryDays.push(days); }); - if (deliveryDays.length > 0) { + if (deliveryDays.length > 1) { const minDays = Math.min(...deliveryDays); const maxDays = Math.max(...deliveryDays); @@ -120,26 +122,13 @@ const createFilters = (result: any, loadedAnalogs: any): FilterConfig[] => { } } - // Фильтр по количеству наличия + // Фильтр по количеству наличия - только если есть предложения с разными количествами const quantities: number[] = []; - result.internalOffers?.forEach((offer: any) => { + allAvailableOffers.forEach((offer: any) => { if (offer.quantity && offer.quantity > 0) quantities.push(offer.quantity); }); - result.externalOffers?.forEach((offer: any) => { - if (offer.quantity && offer.quantity > 0) quantities.push(offer.quantity); - }); - - // Добавляем количества аналогов - Object.values(loadedAnalogs).forEach((analog: any) => { - analog.internalOffers?.forEach((offer: any) => { - if (offer.quantity && offer.quantity > 0) quantities.push(offer.quantity); - }); - analog.externalOffers?.forEach((offer: any) => { - if (offer.quantity && offer.quantity > 0) quantities.push(offer.quantity); - }); - }); - if (quantities.length > 0) { + if (quantities.length > 1) { const minQuantity = Math.min(...quantities); const maxQuantity = Math.max(...quantities); @@ -163,35 +152,24 @@ const getBestOffers = (offers: any[]) => { if (validOffers.length === 0) return []; const result: { offer: any; type: string }[] = []; - const usedOfferIds = new Set(); // 1. Самая низкая цена (среди всех предложений) const lowestPriceOffer = [...validOffers].sort((a, b) => a.price - b.price)[0]; if (lowestPriceOffer) { result.push({ offer: lowestPriceOffer, type: 'Самая низкая цена' }); - usedOfferIds.add(`${lowestPriceOffer.articleNumber}-${lowestPriceOffer.price}-${lowestPriceOffer.deliveryDuration}`); } - // 2. Самый дешевый аналог (только среди аналогов) + // 2. Самый дешевый аналог (только среди аналогов) - всегда показываем если есть аналоги const analogOffers = validOffers.filter(offer => offer.isAnalog); if (analogOffers.length > 0) { const cheapestAnalogOffer = [...analogOffers].sort((a, b) => a.price - b.price)[0]; - const analogId = `${cheapestAnalogOffer.articleNumber}-${cheapestAnalogOffer.price}-${cheapestAnalogOffer.deliveryDuration}`; - - if (!usedOfferIds.has(analogId)) { - result.push({ offer: cheapestAnalogOffer, type: 'Самый дешевый аналог' }); - usedOfferIds.add(analogId); - } + result.push({ offer: cheapestAnalogOffer, type: 'Самый дешевый аналог' }); } // 3. Самая быстрая доставка (среди всех предложений) const fastestDeliveryOffer = [...validOffers].sort((a, b) => a.deliveryDuration - b.deliveryDuration)[0]; if (fastestDeliveryOffer) { - const fastestId = `${fastestDeliveryOffer.articleNumber}-${fastestDeliveryOffer.price}-${fastestDeliveryOffer.deliveryDuration}`; - - if (!usedOfferIds.has(fastestId)) { - result.push({ offer: fastestDeliveryOffer, type: 'Самая быстрая доставка' }); - } + result.push({ offer: fastestDeliveryOffer, type: 'Самая быстрая доставка' }); } return result; @@ -376,7 +354,101 @@ export default function SearchResult() { const hasOffers = result && (result.internalOffers.length > 0 || result.externalOffers.length > 0); const hasAnalogs = result && result.analogs.length > 0; - const searchResultFilters = createFilters(result, loadedAnalogs); + + // Создаем динамические фильтры на основе доступных данных с учетом активных фильтров + const searchResultFilters = useMemo(() => { + const baseFilters = createFilters(result, loadedAnalogs); + + // Если нет активных фильтров, возвращаем базовые фильтры + if (!filtersAreActive) { + return baseFilters; + } + + // Создаем динамические фильтры с учетом других активных фильтров + return baseFilters.map(filter => { + if (filter.type !== 'range') { + return filter; + } + + // Для каждого диапазонного фильтра пересчитываем границы на основе + // предложений, отфильтрованных другими фильтрами (исключая текущий) + let relevantOffers = allOffers; + + // Применяем все фильтры кроме текущего + relevantOffers = allOffers.filter(offer => { + // Фильтр по бренду (если это не фильтр производителя) + if (filter.title !== 'Производитель' && selectedBrands.length > 0 && !selectedBrands.includes(offer.brand)) { + return false; + } + // Фильтр по цене (если это не фильтр цены) + if (filter.title !== 'Цена (₽)' && priceRange && (offer.price < priceRange[0] || offer.price > priceRange[1])) { + return false; + } + // Фильтр по сроку доставки (если это не фильтр доставки) + if (filter.title !== 'Срок доставки (дни)' && deliveryRange) { + const deliveryDays = offer.deliveryDuration; + if (deliveryDays < deliveryRange[0] || deliveryDays > deliveryRange[1]) { + return false; + } + } + // Фильтр по количеству (если это не фильтр количества) + if (filter.title !== 'Количество (шт.)' && quantityRange) { + const quantity = offer.quantity; + if (quantity < quantityRange[0] || quantity > quantityRange[1]) { + return false; + } + } + // Фильтр по поисковой строке + if (filterSearchTerm) { + const searchTerm = filterSearchTerm.toLowerCase(); + const brandMatch = offer.brand.toLowerCase().includes(searchTerm); + const articleMatch = offer.articleNumber.toLowerCase().includes(searchTerm); + const nameMatch = offer.name.toLowerCase().includes(searchTerm); + if (!brandMatch && !articleMatch && !nameMatch) { + return false; + } + } + return true; + }); + + // Пересчитываем диапазон на основе отфильтрованных предложений + if (filter.title === 'Цена (₽)') { + const prices = relevantOffers.filter(o => o.price > 0).map(o => o.price); + if (prices.length > 0) { + return { + ...filter, + min: Math.floor(Math.min(...prices)), + max: Math.ceil(Math.max(...prices)) + }; + } + } else if (filter.title === 'Срок доставки (дни)') { + const deliveryDays = relevantOffers + .map(o => o.deliveryDuration) + .filter(d => d && d > 0); + if (deliveryDays.length > 0) { + return { + ...filter, + min: Math.min(...deliveryDays), + max: Math.max(...deliveryDays) + }; + } + } else if (filter.title === 'Количество (шт.)') { + const quantities = relevantOffers + .map(o => o.quantity) + .filter(q => q && q > 0); + if (quantities.length > 0) { + return { + ...filter, + min: Math.min(...quantities), + max: Math.max(...quantities) + }; + } + } + + return filter; + }); + }, [result, loadedAnalogs, filtersAreActive, allOffers, selectedBrands, priceRange, deliveryRange, quantityRange, filterSearchTerm]); + const bestOffersData = getBestOffers(filteredOffers); @@ -406,6 +478,8 @@ export default function SearchResult() { } }, [q, article, router.query]); + + // Удаляем старую заглушку - теперь обрабатываем все типы поиска const minPrice = useMemo(() => { diff --git a/src/pages/vehicle-search/[brand]/[vehicleId].tsx b/src/pages/vehicle-search/[brand]/[vehicleId].tsx index 569cc3e..afd1aa9 100644 --- a/src/pages/vehicle-search/[brand]/[vehicleId].tsx +++ b/src/pages/vehicle-search/[brand]/[vehicleId].tsx @@ -74,6 +74,8 @@ const VehicleDetailsPage = () => { }); const [selectedNode, setSelectedNode] = useState(null); const [selectedQuickGroup, setSelectedQuickGroup] = useState(null); + const [selectedParts, setSelectedParts] = useState>(new Set()); + const [highlightedPart, setHighlightedPart] = useState(null); // Получаем информацию о выбранном автомобиле const ssdFromQuery = Array.isArray(router.query.ssd) ? router.query.ssd[0] : router.query.ssd; @@ -138,6 +140,20 @@ const VehicleDetailsPage = () => { ); // Получаем детали выбранного узла, если он выбран + console.log('🔍 [vehicleId].tsx - Проверка условий для GET_LAXIMO_UNIT_DETAILS:', { + selectedNode: selectedNode ? { + unitid: selectedNode.unitid, + name: selectedNode.name, + hasSsd: !!selectedNode.ssd + } : null, + skipCondition: !selectedNode, + catalogCode: selectedNode?.catalogCode || selectedNode?.catalog || brand, + vehicleId: selectedNode?.vehicleId || vehicleId, + unitId: selectedNode?.unitid || selectedNode?.unitId, + ssd: selectedNode?.ssd || finalSsd || '', + finalSsd: finalSsd ? `${finalSsd.substring(0, 50)}...` : 'отсутствует' + }); + const { data: unitDetailsData, loading: unitDetailsLoading, @@ -155,6 +171,23 @@ const VehicleDetailsPage = () => { : { catalogCode: '', vehicleId: '', unitId: '', ssd: '' }, skip: !selectedNode, errorPolicy: 'all', + fetchPolicy: 'no-cache', + notifyOnNetworkStatusChange: true, + onCompleted: (data) => { + console.log('🔍 [vehicleId].tsx - GET_LAXIMO_UNIT_DETAILS completed:', { + detailsCount: data?.laximoUnitDetails?.length || 0, + firstDetail: data?.laximoUnitDetails?.[0], + allDetails: data?.laximoUnitDetails?.map((detail: any) => ({ + name: detail.name, + oem: detail.oem, + codeonimage: detail.codeonimage, + attributesCount: detail.attributes?.length || 0 + })) + }); + }, + onError: (error) => { + console.error('❌ [vehicleId].tsx - GET_LAXIMO_UNIT_DETAILS error:', error); + } } ); @@ -234,6 +267,22 @@ const VehicleDetailsPage = () => { const unitDetails = unitDetailsData?.laximoUnitDetails || []; + // Детальное логирование данных от API + React.useEffect(() => { + if (unitDetailsData?.laximoUnitDetails) { + console.log('🔍 [vehicleId].tsx - Полные данные unitDetails от API:', { + totalParts: unitDetailsData.laximoUnitDetails.length, + firstPart: unitDetailsData.laximoUnitDetails[0], + allCodeOnImages: unitDetailsData.laximoUnitDetails.map((part: any) => ({ + name: part.name, + codeonimage: part.codeonimage, + detailid: part.detailid, + oem: part.oem + })) + }); + } + }, [unitDetailsData]); + // Логируем ошибки if (vehicleError) { console.error('Vehicle GraphQL error:', vehicleError); @@ -382,6 +431,9 @@ const VehicleDetailsPage = () => { }); setSelectedNode(node); + // Сброс состояния выбранных деталей при открытии нового узла + setSelectedParts(new Set()); + setHighlightedPart(null); router.push( { pathname: router.pathname, query: { ...router.query, unitid: node.unitid || node.id } }, undefined, @@ -391,6 +443,9 @@ const VehicleDetailsPage = () => { // Закрыть KnotIn и удалить unitid из URL const closeKnot = () => { setSelectedNode(null); + // Сброс состояния выбранных деталей при закрытии узла + setSelectedParts(new Set()); + setHighlightedPart(null); const { unitid, ...rest } = router.query; router.push( { pathname: router.pathname, query: rest }, @@ -399,6 +454,25 @@ const VehicleDetailsPage = () => { ); }; + // Обработчик выбора детали (множественный выбор) + const handlePartSelect = (codeOnImage: string | number | null) => { + if (codeOnImage === null) return; // Игнорируем null значения + setSelectedParts(prev => { + const newSet = new Set(prev); + if (newSet.has(codeOnImage)) { + newSet.delete(codeOnImage); // Убираем если уже выбрана + } else { + newSet.add(codeOnImage); // Добавляем если не выбрана + } + return newSet; + }); + }; + + // Обработчик подсветки детали при наведении + const handlePartHighlight = (codeOnImage: string | number | null) => { + setHighlightedPart(codeOnImage); + }; + return ( <> @@ -551,6 +625,9 @@ const VehicleDetailsPage = () => { unitId={selectedNode.unitid} unitName={selectedNode.name} parts={unitDetails} + onPartSelect={handlePartSelect} + onPartsHighlight={handlePartHighlight} + selectedParts={selectedParts} /> {unitDetailsLoading ? (
Загружаем детали узла...
@@ -561,6 +638,10 @@ const VehicleDetailsPage = () => { parts={unitDetails} catalogCode={vehicleInfo.catalog} vehicleId={vehicleInfo.vehicleid} + highlightedCodeOnImage={highlightedPart} + selectedParts={selectedParts} + onPartSelect={handlePartSelect} + onPartHover={handlePartHighlight} /> ) : (
Детали не найдены
diff --git a/src/styles/globals.css b/src/styles/globals.css index 55a331c..9593366 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -100,4 +100,281 @@ input[type=number] { .cookie-consent-enter { animation: slideInFromBottom 0.3s ease-out; +} + +/* Анимации для тултипов */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes zoomIn { + from { + transform: scale(0.95); + } + to { + transform: scale(1); + } +} + +.animate-in { + animation-fill-mode: both; +} + +.fade-in-0 { + animation-name: fadeIn; +} + +.zoom-in-95 { + animation-name: zoomIn; +} + +.duration-200 { + animation-duration: 200ms; +} + +/* Стили для кнопок с курсором pointer */ +button, +.cursor-pointer, +[role="button"] { + cursor: pointer; +} + +/* ===== СОВРЕМЕННЫЕ СТИЛИ ДЛЯ КРАСИВОГО ТУЛТИПА ===== */ + +.tooltip-detail-modern { + animation: tooltip-modern-fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1); + filter: drop-shadow(0 25px 50px rgba(0, 0, 0, 0.15)); +} + +.tooltip-content-modern { + background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); + border: 1px solid #e2e8f0; + border-radius: 16px; + overflow: hidden; + max-width: 420px; + min-width: 280px; + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); + position: relative; +} + +.tooltip-arrow { + position: absolute; + top: -6px; + left: 50%; + transform: translateX(-50%); + width: 12px; + height: 12px; + background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); + border: 1px solid #e2e8f0; + border-bottom: none; + border-right: none; + transform: translateX(-50%) rotate(45deg); + z-index: 1; +} + +.tooltip-header-modern { + background: linear-gradient(135deg, #EC1C24 0%, #DC1C24 100%); + padding: 16px 20px; + display: flex; + align-items: flex-start; + gap: 12px; + position: relative; +} + +.tooltip-header-modern::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%); + pointer-events: none; +} + +.tooltip-icon { + color: white; + opacity: 0.9; + flex-shrink: 0; + margin-top: 2px; +} + +.tooltip-title-section { + flex: 1; + min-width: 0; +} + +.tooltip-title { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; + color: white; + line-height: 1.3; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.tooltip-oem-badge { + display: inline-flex; + align-items: center; + background: rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 4px 8px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.tooltip-oem-label { + font-size: 10px; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-right: 6px; +} + +.tooltip-oem-value { + font-size: 12px; + font-weight: 600; + color: white; + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; +} + +.tooltip-body-modern { + padding: 20px; +} + +.tooltip-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: #374151; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 2px solid #f1f5f9; +} + +.tooltip-section-title svg { + color: #EC1C24; +} + +.tooltip-attributes-grid { + display: grid; + gap: 8px; +} + +.tooltip-attribute-item { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 10px 12px; + transition: all 0.2s ease; +} + +.tooltip-attribute-item:hover { + background: #f1f5f9; + border-color: #cbd5e1; + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); +} + +.tooltip-attribute-key { + font-size: 12px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.tooltip-attribute-value { + font-size: 14px; + font-weight: 500; + color: #1e293b; + line-height: 1.4; +} + +.tooltip-note-modern { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #e2e8f0; +} + +.tooltip-note-text { + background: #fef3c7; + border: 1px solid #fbbf24; + border-radius: 8px; + padding: 12px; + font-size: 13px; + color: #92400e; + line-height: 1.5; + margin-top: 8px; +} + +.tooltip-no-data { + display: flex; + flex-direction: column; + align-items: center; + padding: 24px 16px; + text-align: center; +} + +.tooltip-no-data-icon { + color: #94a3b8; + margin-bottom: 12px; + opacity: 0.7; +} + +.tooltip-no-data-text { + color: #64748b; + font-size: 14px; + line-height: 1.4; +} + +.tooltip-no-data-text div:first-child { + font-weight: 500; + margin-bottom: 2px; +} + +.tooltip-no-data-text div:last-child { + opacity: 0.8; +} + +@keyframes tooltip-modern-fade-in { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Адаптивность для мобильных устройств */ +@media (max-width: 480px) { + .tooltip-content-modern { + max-width: 320px; + min-width: 260px; + } + + .tooltip-header-modern { + padding: 14px 16px; + } + + .tooltip-body-modern { + padding: 16px; + } + + .tooltip-title { + font-size: 15px; + } } \ No newline at end of file