From 4dfc081214ebd2188d779f932a547ccac56f9e34 Mon Sep 17 00:00:00 2001 From: Bivekich Date: Mon, 14 Jul 2025 10:01:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20=D1=81=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=B2=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B5=20Header.=20=D0=9E=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20=D0=B2=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B8=20=D0=B8=20=D0=BF=D0=BB=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D1=85=D0=BE=D0=BB=D0=B4=D0=B5=D1=80=D0=B0.=20=D0=92?= =?UTF-8?q?=D0=BD=D0=B5=D0=B4=D1=80=D0=B5=D0=BD=20=D0=B7=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=D0=B4=D0=BD=D0=B8=D1=85=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D1=85=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=BE?= =?UTF-8?q?=D0=B2.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D1=81=D1=82=D0=B8=D0=BB=D0=B8=20=D0=B8=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D0=B5=20Header.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 92 ++++++++++++++ package.json | 1 + src/components/BottomHead.tsx | 152 ++++++++--------------- src/components/Header.tsx | 90 +++++++++++++- src/components/SearchHistoryDropdown.tsx | 93 ++++++++++++++ src/lib/graphql/search-history.ts | 14 +++ 6 files changed, 337 insertions(+), 105 deletions(-) create mode 100644 src/components/SearchHistoryDropdown.tsx diff --git a/package-lock.json b/package-lock.json index a9c2acf..aafa03d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/uuid": "^10.0.0", "graphql": "^16.11.0", "next": "15.3.3", + "node-fetch": "^3.3.2", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -1540,6 +1541,15 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -1602,6 +1612,29 @@ "node": ">=6" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1615,6 +1648,18 @@ "node": ">=8" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -2140,6 +2185,44 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -2690,6 +2773,15 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", diff --git a/package.json b/package.json index 220be71..01cf716 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/uuid": "^10.0.0", "graphql": "^16.11.0", "next": "15.3.3", + "node-fetch": "^3.3.2", "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/components/BottomHead.tsx b/src/components/BottomHead.tsx index 16a63ef..cced1ad 100644 --- a/src/components/BottomHead.tsx +++ b/src/components/BottomHead.tsx @@ -79,21 +79,29 @@ const transformPartsIndexToTabData = (catalogs: PartsIndexCatalog[]) => { 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); - } + group.subgroups.forEach(subgroup => { + if (links.length < 9) { + links.push(subgroup.name); + } + }); } }); + + // Если подгрупп не набралось достаточно, добавляем названия групп + if (links.length < 9) { + catalog.groups.forEach(group => { + if (links.length < 9 && !links.includes(group.name)) { + links.push(group.name); + } + }); + } } - // Если подкатегорий нет, показываем название категории как указано в требованиях + // Если подкатегорий всё ещё нет, добавляем название самой категории if (links.length === 0) { links = [catalog.name]; } @@ -258,20 +266,21 @@ const BottomHead = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => v {mobileCategory.label}
- {mobileCategory.links.length === 1 ? ( + {mobileCategory.links.map((link: string, linkIndex: number) => (
{ - let subcategoryId = `${mobileCategory.catalogId}_0`; + let subcategoryId = `${mobileCategory.catalogId}_${linkIndex}`; if (mobileCategory.groups) { for (const group of mobileCategory.groups) { if (group.subgroups && group.subgroups.length > 0) { - const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === mobileCategory.links[0]); + const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === link); if (foundSubgroup) { subcategoryId = foundSubgroup.id; break; } - } else if (group.name === mobileCategory.links[0]) { + } else if (group.name === link) { subcategoryId = group.id; break; } @@ -279,42 +288,12 @@ const BottomHead = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => v } const activeCatalog = catalogsData?.partsIndexCategoriesWithGroups?.[tabData.findIndex(tab => tab === mobileCategory)]; const catalogId = activeCatalog?.id || 'fallback'; - handleCategoryClick(catalogId, mobileCategory.links[0], subcategoryId); + handleCategoryClick(catalogId, link, subcategoryId); }} - style={{ cursor: "pointer" }} > - Показать все + {link}
- ) : ( - mobileCategory.links.map((link: string, linkIndex: number) => ( -
{ - let subcategoryId = `${mobileCategory.catalogId}_${linkIndex}`; - if (mobileCategory.groups) { - for (const group of mobileCategory.groups) { - if (group.subgroups && group.subgroups.length > 0) { - const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === link); - if (foundSubgroup) { - subcategoryId = foundSubgroup.id; - break; - } - } else if (group.name === link) { - subcategoryId = group.id; - break; - } - } - } - const activeCatalog = catalogsData?.partsIndexCategoriesWithGroups?.[tabData.findIndex(tab => tab === mobileCategory)]; - const catalogId = activeCatalog?.id || 'fallback'; - handleCategoryClick(catalogId, link, subcategoryId); - }} - > - {link} -
- )) - )} + ))}
) : ( @@ -518,66 +497,37 @@ const BottomHead = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => v

{tab.heading}

- {tab.links.length === 1 ? ( -
{ - const catalog = catalogsData?.partsIndexCategoriesWithGroups?.[idx]; - let subcategoryId = `fallback_${idx}_0`; - if (catalog?.groups) { - for (const group of catalog.groups) { - if (group.subgroups && group.subgroups.length > 0) { - const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === tab.links[0]); - if (foundSubgroup) { - subcategoryId = foundSubgroup.id; - break; - } - } else if (group.name === tab.links[0]) { - subcategoryId = group.id; - break; - } - } - } - const catalogId = catalog?.id || 'fallback'; - handleCategoryClick(catalogId, tab.links[0], subcategoryId); - }} - style={{ cursor: "pointer" }} - > - Показать все -
- ) : ( - tab.links.map((link: string, linkIndex: number) => { - const catalog = catalogsData?.partsIndexCategoriesWithGroups?.[idx]; - let subcategoryId = `fallback_${idx}_${linkIndex}`; - if (catalog?.groups) { - for (const group of catalog.groups) { - if (group.subgroups && group.subgroups.length > 0) { - const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === link); - if (foundSubgroup) { - subcategoryId = foundSubgroup.id; - break; - } - } else if (group.name === link) { - subcategoryId = group.id; + {tab.links.map((link: string, linkIndex: number) => { + const catalog = catalogsData?.partsIndexCategoriesWithGroups?.[idx]; + let subcategoryId = `fallback_${idx}_${linkIndex}`; + if (catalog?.groups) { + for (const group of catalog.groups) { + if (group.subgroups && group.subgroups.length > 0) { + const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === link); + if (foundSubgroup) { + subcategoryId = foundSubgroup.id; break; } + } else if (group.name === link) { + subcategoryId = group.id; + break; } } - return ( -
{ - const catalogId = catalog?.id || 'fallback'; - handleCategoryClick(catalogId, link, subcategoryId); - }} - style={{ cursor: "pointer" }} - > - {link} -
- ); - }) - )} + } + return ( +
{ + const catalogId = catalog?.id || 'fallback'; + handleCategoryClick(catalogId, link, subcategoryId); + }} + style={{ cursor: "pointer" }} + > + {link} +
+ ); + })}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e495215..a4533b3 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -9,6 +9,8 @@ import { FIND_LAXIMO_VEHICLE, DOC_FIND_OEM, FIND_LAXIMO_VEHICLE_BY_PLATE_GLOBAL, import { LaximoVehicleSearchResult, LaximoDocFindOEMResult, LaximoVehiclesByPartResult } from '@/types/laximo'; import Link from "next/link"; import CartButton from './CartButton'; +import SearchHistoryDropdown from './SearchHistoryDropdown'; +import { GET_RECENT_SEARCH_QUERIES, PartsSearchHistoryItem } from '@/lib/graphql/search-history'; interface HeaderProps { onOpenAuthModal?: () => void; @@ -25,9 +27,14 @@ const Header: React.FC = ({ onOpenAuthModal = () => console.log('Au const [vehiclesByPartResults, setVehiclesByPartResults] = useState(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([]); + const [inputFocused, setInputFocused] = useState(false); + const [showPlaceholder, setShowPlaceholder] = useState(true); const router = useRouter(); const searchFormRef = useRef(null); const searchDropdownRef = useRef(null); + const searchInputRef = useRef(null); const isClient = useIsClient(); // Эффект для восстановления поискового запроса из URL @@ -111,11 +118,28 @@ const Header: React.FC = ({ onOpenAuthModal = () => console.log('Au } }); + // Запрос для получения истории поиска + const [getSearchHistory, { loading: historyLoading }] = useLazyQuery(GET_RECENT_SEARCH_QUERIES, { + onCompleted: (data) => { + setSearchHistoryItems(data.partsSearchHistory?.items || []); + }, + onError: (error) => { + console.error('❌ Ошибка загрузки истории поиска:', error); + setSearchHistoryItems([]); + } + }); + // Закрытие результатов при клике вне области useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (searchDropdownRef.current && !searchDropdownRef.current.contains(event.target as Node)) { setShowResults(false); + setShowSearchHistory(false); + setInputFocused(false); + // Показываем placeholder обратно только если поле пустое + if (searchQuery.trim() === '') { + setShowPlaceholder(true); + } } }; @@ -356,6 +380,54 @@ const Header: React.FC = ({ 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) => { + 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 ( <> {/*
@@ -421,7 +493,7 @@ const Header: React.FC = ({ onOpenAuthModal = () => console.log('Au
-
+
= ({ onOpenAuthModal = () => console.log('Au
setSearchQuery(e.target.value)} + onChange={handleInputChange} + onFocus={handleInputFocus} + onBlur={handleInputBlur} disabled={isSearching} /> + {/* История поиска */} + + {/* Результаты поиска VIN */} {showResults && searchResults.length > 0 && (searchType === 'vin' || searchType === 'plate') && (
diff --git a/src/components/SearchHistoryDropdown.tsx b/src/components/SearchHistoryDropdown.tsx new file mode 100644 index 0000000..df2bc98 --- /dev/null +++ b/src/components/SearchHistoryDropdown.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { PartsSearchHistoryItem } from '@/lib/graphql/search-history'; + +interface SearchHistoryDropdownProps { + isVisible: boolean; + historyItems: PartsSearchHistoryItem[]; + onItemClick: (searchQuery: string) => void; + loading?: boolean; +} + +const SearchHistoryDropdown: React.FC = ({ + 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 ( +
+ {loading ? ( +
+
+ + + + + Загрузка истории... +
+
+ ) : uniqueQueries.length > 0 ? ( + <> +
+

+ Последние запросы +

+
+ {uniqueQueries.map((item) => ( + + ))} + + ) : ( +
+

История поиска пуста

+
+ )} +
+ ); +}; + +export default SearchHistoryDropdown; \ No newline at end of file diff --git a/src/lib/graphql/search-history.ts b/src/lib/graphql/search-history.ts index 76cfb9c..fe461ae 100644 --- a/src/lib/graphql/search-history.ts +++ b/src/lib/graphql/search-history.ts @@ -24,6 +24,20 @@ export const GET_PARTS_SEARCH_HISTORY = gql` } `; +// Запрос для получения последних поисковых запросов для автодополнения +export const GET_RECENT_SEARCH_QUERIES = gql` + query GetRecentSearchQueries($limit: Int = 5) { + partsSearchHistory(limit: $limit, offset: 0) { + items { + id + searchQuery + searchType + createdAt + } + } + } +`; + export const DELETE_SEARCH_HISTORY_ITEM = gql` mutation DeletePartsSearchHistoryItem($id: ID!) { deletePartsSearchHistoryItem(id: $id)