1 Commits

Author SHA1 Message Date
47844749eb fix1407 2025-07-14 10:45:27 +03:00
12 changed files with 99 additions and 316 deletions

92
package-lock.json generated
View File

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

View File

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

View File

@ -274,7 +274,11 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
<div className="w-layout-hflex core-product-search-s1">
<div className="w-layout-vflex flex-block-48-copy">
<div className="w-layout-vflex product-list-search-s1">
<div className="w-layout-vflex core-product-s1">
<div className="w-layout-vflex flex-block-48-copy">
<div className="w-layout-vflex product-list-search-s1">
<div className="w-layout-vflex core-product-s1">
<div className="w-layout-vflex flex-block-47">
<div className="div-block-19">
<img src="/images/info.svg" loading="lazy" alt="info" className="image-9" />
@ -310,8 +314,6 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
</div>
)}
</div>
<div className="w-layout-vflex flex-block-48-copy">
<div className="w-layout-vflex product-list-search-s1">
<div className="w-layout-hflex sort-list-s1">
<div className="w-layout-hflex flex-block-49">
<div className="sort-item first">Наличие</div>

View File

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

View File

@ -134,7 +134,7 @@ const FiltersPanelMobile: React.FC<FiltersPanelMobileProps> = ({
if (filter.type === "range") {
return (
<FilterRange
key={filter.title + idx}
key={filter.title + idx + JSON.stringify(localFilterValues[filter.title] || null)}
title={filter.title}
min={filter.min}
max={filter.max}

View File

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

View File

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

View File

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

View File

@ -49,6 +49,16 @@ const ProductOfDaySection: React.FC = () => {
.sort((a, b) => a.sortOrder - b.sortOrder);
}, [data]);
// Корректный сброс currentSlide только если индекс вне диапазона
useEffect(() => {
if (currentSlide > activeProducts.length - 1) {
setCurrentSlide(activeProducts.length > 0 ? activeProducts.length - 1 : 0);
}
// Если товаров стало больше и текущий слайд = 0, ничего не делаем
// Если товаров стало меньше и текущий слайд в диапазоне, ничего не делаем
// Если товаров стало меньше и текущий слайд вне диапазона, сбрасываем на последний
}, [activeProducts.length]);
// Получаем данные из PartsIndex для текущего товара
const currentProduct = activeProducts[currentSlide];
const { data: partsIndexData } = useQuery(
@ -132,11 +142,6 @@ const ProductOfDaySection: React.FC = () => {
setCurrentSlide(index);
};
// Сброс слайда при изменении товаров
useEffect(() => {
setCurrentSlide(0);
}, [activeProducts]);
// Если нет активных товаров дня, не показываем секцию
if (loading || error || activeProducts.length === 0) {
return null;
@ -244,7 +249,7 @@ const ProductOfDaySection: React.FC = () => {
</div>
{productImage && (
<div className="relative">
<div className="">
<img
width="Auto"
height="Auto"

View File

@ -55,6 +55,7 @@ const KnotIn: React.FC<KnotInProps> = ({
const [selectedDetail, setSelectedDetail] = useState<{ oem: string; name: string } | null>(null);
const [hoveredCodeOnImage, setHoveredCodeOnImage] = useState<string | number | null>(null);
const router = useRouter();
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
// Получаем инфо об узле (для картинки)
console.log('🔍 KnotIn - GET_LAXIMO_UNIT_INFO запрос:', {
@ -164,6 +165,12 @@ const KnotIn: React.FC<KnotInProps> = ({
});
};
// Обработчик клика по картинке (zoom)
const handleImageClick = (e: React.MouseEvent<HTMLImageElement>) => {
// Если клик был по точке, не открываем модалку (точки выше по z-index)
setIsImageModalOpen(true);
};
// Обработчик наведения на точку
const handlePointHover = (coord: any) => {
// Попробуем использовать разные поля для связи
@ -318,8 +325,9 @@ const KnotIn: React.FC<KnotInProps> = ({
loading="lazy"
alt={unitName || unitInfo?.name || "Изображение узла"}
onLoad={handleImageLoad}
className="max-w-full h-auto mx-auto rounded"
className="max-w-full h-auto mx-auto rounded cursor-zoom-in"
style={{ maxWidth: 400, display: 'block' }}
onClick={handleImageClick}
/>
{/* Точки/области */}
{coordinates.map((coord: any, idx: number) => {
@ -369,8 +377,8 @@ const KnotIn: React.FC<KnotInProps> = ({
pointerEvents: 'auto',
}}
title={`${codeValue} (Клик - выделить в списке, двойной клик - перейти к выбору бренда)`}
onClick={() => handlePointClick(coord)}
onDoubleClick={() => handlePointDoubleClick(coord)}
onClick={e => { e.stopPropagation(); handlePointClick(coord); }}
onDoubleClick={e => { e.stopPropagation(); handlePointDoubleClick(coord); }}
onMouseEnter={() => handlePointHover(coord)}
onMouseLeave={() => {
setHoveredCodeOnImage(null);
@ -388,7 +396,31 @@ const KnotIn: React.FC<KnotInProps> = ({
</div>
);
})}
</div>
</div>
{/* Модалка увеличенного изображения */}
{isImageModalOpen && (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/20 bg-opacity-70"
onClick={() => setIsImageModalOpen(false)}
style={{ cursor: 'zoom-out' }}
>
<img
src={imageUrl}
alt={unitName || unitInfo?.name || "Изображение узла"}
className="max-h-[90vh] max-w-[90vw] rounded shadow-lg"
onClick={e => e.stopPropagation()}
style={{ background: '#fff' }}
/>
<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"
aria-label="Закрыть"
style={{ zIndex: 10000 }}
>
×
</button>
</div>
)}
{/* Модалка выбора бренда */}
<BrandSelectionModal
isOpen={isBrandModalOpen}

View File

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

View File

@ -53,6 +53,7 @@
.flex-block-40 {
background-color: #fff;
padding-top: 10px;
}
input.text-block-31 {
@ -364,6 +365,15 @@ input.input-receiver:focus {
display: block;
}
.image-5-copy {
width: 97px !important;
height: 97px !important;
}
.flex-block-111 {
width: 172px !important;
}
.show-more-btn {
background-color: #ec1c24;
color: #fff;
@ -516,6 +526,9 @@ input#VinSearchInput {
}
}
.div-block-19{
padding-left: 20px !important;
}
.dropdown-toggle-card {
align-self: stretch;
@ -913,8 +926,8 @@ a.link-block-2.w-inline-block {
.flex-block-15-copy {
width: 235px!important;
min-width: 235px!important;
width: 232px!important;
min-width: 232px!important;
}
.nameitembp {
@ -966,7 +979,16 @@ a.link-block-2.w-inline-block {
@media (max-width: 767px) {
.flex-block-110 {
flex-direction: row !important;
align-items: flex-start !important;
}
.image-5-copy {
width: 75px !important;
height: 75px !important;
}
}
@media (max-width: 767px) {
.topmenub {
display: none !important;