From f5089843385d42703599e48205e235688492ced0 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Wed, 29 Oct 2025 13:36:58 +0330 Subject: [PATCH] search --- src/app/products/hooks/useProductsData.ts | 9 ++ src/app/products/service/Service.ts | 9 +- src/app/products/types/Types.ts | 24 ++++ src/share/Header.tsx | 60 +++------ src/share/components/DesktopSearch.tsx | 143 ++++++++++++++++++++++ src/share/components/MobileSearch.tsx | 109 +++++++++++++++++ 6 files changed, 309 insertions(+), 45 deletions(-) create mode 100644 src/share/components/DesktopSearch.tsx create mode 100644 src/share/components/MobileSearch.tsx diff --git a/src/app/products/hooks/useProductsData.ts b/src/app/products/hooks/useProductsData.ts index f1c6083..338925b 100644 --- a/src/app/products/hooks/useProductsData.ts +++ b/src/app/products/hooks/useProductsData.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as api from "../service/Service"; import { ProductsResponse } from "@/types/products.types"; +import { ProductSearchResponse } from "../types/Types"; interface UseProductsDataParams { page?: number; @@ -35,6 +36,14 @@ export const useCategoryProductsData = ( }); }; +export const useProductSearch = (search: string) => { + return useQuery({ + queryKey: ["product-search", search], + queryFn: () => api.productSearch(search), + enabled: search.length > 0, + }); +}; + export const useAddWishlist = () => { const queryClient = useQueryClient(); diff --git a/src/app/products/service/Service.ts b/src/app/products/service/Service.ts index 87e8637..e72a738 100644 --- a/src/app/products/service/Service.ts +++ b/src/app/products/service/Service.ts @@ -1,6 +1,6 @@ import axios from "@/config/axios"; import { ProductsResponse } from "@/types/products.types"; -import { WishListType } from "../types/Types"; +import { ProductSearchResponse, WishListType } from "../types/Types"; export const getProducts = async (params?: { page?: number; @@ -62,3 +62,10 @@ export const removeWishlist = async (params: WishListType) => { ); return data; }; + +export const productSearch = async ( + search: string +): Promise => { + const { data } = await axios.get(`/product/search?q=${search}`); + return data; +}; diff --git a/src/app/products/types/Types.ts b/src/app/products/types/Types.ts index 8a8534c..ea8bbea 100644 --- a/src/app/products/types/Types.ts +++ b/src/app/products/types/Types.ts @@ -2,3 +2,27 @@ export type WishListType = { productId: string; variantId: string; }; + +export type ProductSearchItem = { + _id: number; + title_fa: string; + title_en: string; + model: string; + description: string; + variants: string[]; + imagesUrl: { + cover: string; + list: string[]; + }; + url: string; +}; + +export type ProductSearchResults = { + products: ProductSearchItem[]; +}; + +export type ProductSearchResponse = { + status: number; + success: boolean; + results: ProductSearchResults; +}; diff --git a/src/share/Header.tsx b/src/share/Header.tsx index 5b6d5b2..aef86d9 100644 --- a/src/share/Header.tsx +++ b/src/share/Header.tsx @@ -1,9 +1,8 @@ 'use client' -import Input from '@/components/Input' import MenuItem from '@/components/MenuItem' import { Separator } from '@/components/ui/separator' -import { DocumentText, Home, InfoCircle, Messages1, SearchNormal1 } from 'iconsax-react' -import { FC, Fragment, useEffect, useState } from 'react' +import { DocumentText, Home, InfoCircle, Messages1 } from 'iconsax-react' +import { FC, Fragment, useEffect } from 'react' import Menu from './components/Menu' import { useSharedStore } from './store/sharedStore' import { getToken } from '@/config/func' @@ -11,10 +10,11 @@ import Account from './components/Account' import Cart from './components/Cart' import Image from 'next/image' import Link from 'next/link' +import MobileSearch from './components/MobileSearch' +import DesktopSearch from './components/DesktopSearch' const Header: FC = () => { - const [isSearchOpen, setIsSearchOpen] = useState(false) const { setIsLogin } = useSharedStore() const handleSetIsLogin = async () => { @@ -31,41 +31,17 @@ const Header: FC = () => {
{/* موبایل: لوگو و آیکون جستجو */} -
- {!isSearchOpen ? ( -
- - logo - - -
- ) : ( -
- - -
- )} +
+ + logo + +
{/* دسکتاپ: هدر کامل */} @@ -80,11 +56,7 @@ const Header: FC = () => { className='max-w-[100px] w-[100px] max-h-[40px] object-contain' /> - +
diff --git a/src/share/components/DesktopSearch.tsx b/src/share/components/DesktopSearch.tsx new file mode 100644 index 0000000..9c2a3c9 --- /dev/null +++ b/src/share/components/DesktopSearch.tsx @@ -0,0 +1,143 @@ +'use client' +import Input from '@/components/Input' +import { FC, useState, useRef, useEffect } from 'react' +import { useProductSearch } from '@/app/products/hooks/useProductsData' +import { useRouter } from 'next/navigation' +import Image from 'next/image' +import { createPortal } from 'react-dom' + +const DesktopSearch: FC = () => { + const [searchQuery, setSearchQuery] = useState('') + const [isOpen, setIsOpen] = useState(false) + const [mounted, setMounted] = useState(false) + const router = useRouter() + const dropdownRef = useRef(null) + const inputRef = useRef(null) + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }) + + const { data: searchResults, isLoading } = useProductSearch(searchQuery) + + useEffect(() => { + setMounted(true) + }, []) + + const handleSearch = (value: string) => { + setSearchQuery(value) + setIsOpen(value.length > 0) + + if (value.length > 0 && inputRef.current) { + const rect = inputRef.current.getBoundingClientRect() + setDropdownPosition({ + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + width: rect.width + }) + } + } + + const handleProductClick = (url: string) => { + router.push(url) + setSearchQuery('') + setIsOpen(false) + } + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + if (inputRef.current && !inputRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + } + + const updatePosition = () => { + if (isOpen && inputRef.current) { + const rect = inputRef.current.getBoundingClientRect() + setDropdownPosition({ + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + width: rect.width + }) + } + } + + document.addEventListener('mousedown', handleClickOutside) + window.addEventListener('scroll', updatePosition, true) + window.addEventListener('resize', updatePosition) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + window.removeEventListener('scroll', updatePosition, true) + window.removeEventListener('resize', updatePosition) + } + }, [isOpen]) + + return ( + <> +
+ handleSearch(e.target.value)} + /> +
+ + {mounted && isOpen && searchQuery.length > 0 && createPortal( +
+ {isLoading ? ( +
+ در حال جستجو... +
+ ) : searchResults?.results?.products?.length ? ( +
+ {searchResults.results.products.map((product) => ( +
handleProductClick(product.url)} + className='flex items-center gap-3 px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors' + > +
+ {product.title_fa} +
+
+

+ {product.title_fa} +

+

+ {product.model} +

+
+
+ ))} +
+ ) : ( +
+ محصولی یافت نشد +
+ )} +
, + document.body + )} + + ) +} + +export default DesktopSearch + diff --git a/src/share/components/MobileSearch.tsx b/src/share/components/MobileSearch.tsx new file mode 100644 index 0000000..537c450 --- /dev/null +++ b/src/share/components/MobileSearch.tsx @@ -0,0 +1,109 @@ +'use client' +import Input from '@/components/Input' +import { SearchNormal1 } from 'iconsax-react' +import { FC, useState } from 'react' +import { useProductSearch } from '@/app/products/hooks/useProductsData' +import { useRouter } from 'next/navigation' +import Image from 'next/image' + +const MobileSearch: FC = () => { + const [isSearchOpen, setIsSearchOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const router = useRouter() + + const { data: searchResults, isLoading } = useProductSearch(searchQuery) + + const handleSearch = (value: string) => { + setSearchQuery(value) + } + + const handleClose = () => { + setIsSearchOpen(false) + setSearchQuery('') + } + + const handleProductClick = (url: string) => { + router.push(url) + handleClose() + } + + if (isSearchOpen) { + return ( +
+
+ handleSearch(e.target.value)} + /> + +
+ +
+ {searchQuery.length > 0 && ( + <> + {isLoading ? ( +
+ در حال جستجو... +
+ ) : searchResults?.results?.products?.length ? ( +
+ {searchResults.results.products.map((product) => ( +
handleProductClick(product.url)} + className='flex items-center gap-3 px-4 py-3 border-b border-gray-100 active:bg-gray-50' + > +
+ {product.title_fa} +
+
+

+ {product.title_fa} +

+

+ {product.model} +

+
+
+ ))} +
+ ) : ( +
+ محصولی یافت نشد +
+ )} + + )} +
+
+ ) + } + + return ( + + ) +} + +export default MobileSearch +