import { Image } from '@components/common/Image.js'; import { Input } from '@components/common/ui/Input.js'; import { InputGroup, InputGroupAddon, InputGroupInput } from '@components/common/ui/InputGroup.js'; import { _ } from '@evershop/evershop/lib/locale/translate/_'; import { Search, X } from 'lucide-react'; import React, { useRef, useState, ReactNode, useCallback } from 'react'; import { useClient } from 'urql'; const SEARCH_PRODUCTS_QUERY = ` query Query($filters: [FilterInput]) { products(filters: $filters) { items { ...Product } } } `; const PRODUCT_FRAGMENT = ` fragment Product on Product { productId name sku price { regular { value text } special { value text } } image { url alt } url inventory { isInStock } } `; export interface SearchResult { id: string; title: string; url?: string; image?: string; price?: string; type?: 'product' | 'category' | 'page'; [key: string]: unknown; } interface SearchBoxProps { searchPageUrl: string; enableAutocomplete?: boolean; autocompleteDelay?: number; minSearchLength?: number; maxResults?: number; onSearch?: (query: string) => Promise; renderSearchInput?: (props: { value: string; onChange: (value: string) => void; onKeyDown: (event: React.KeyboardEvent) => void; onFocus: () => void; onBlur: () => void; placeholder: string; ref: React.RefObject; }) => ReactNode; renderSearchResults?: (props: { results: SearchResult[]; query: string; onSelect: (result: SearchResult) => void; isLoading: boolean; }) => ReactNode; renderSearchIcon?: () => ReactNode; renderCloseIcon?: () => ReactNode; } export function SearchBox({ searchPageUrl, enableAutocomplete = false, autocompleteDelay = 300, minSearchLength = 2, maxResults = 10, onSearch, renderSearchInput, renderSearchResults, renderSearchIcon, renderCloseIcon }: SearchBoxProps) { const InputRef = useRef(null); const searchTimeoutRef = useRef(null); const client = useClient(); const [keyword, setKeyword] = useState(''); const [showing, setShowing] = useState(false); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [showResults, setShowResults] = useState(false); const defaultSearchFunction = useCallback( async (query: string): Promise => { try { const result = await client .query( ` ${PRODUCT_FRAGMENT} ${SEARCH_PRODUCTS_QUERY} `, { filters: [ { key: 'keyword', operation: 'eq', value: query }, { key: 'limit', operation: 'eq', value: `${maxResults}` } ] } ) .toPromise(); if (result.error) { return []; } if (!result.data?.products?.items) { return []; } return result.data.products.items.map((product: any) => ({ id: product.productId, title: product.name, url: product.url, image: product.image?.url, price: product.price?.special?.text || product.price?.regular?.text, type: 'product' as const, sku: product.sku, isInStock: product.inventory?.isInStock })); } catch (error) { return []; } }, [client] ); const searchFunction = onSearch || defaultSearchFunction; React.useEffect(() => { const url = new URL(window.location.href); const key = url.searchParams.get('keyword'); setKeyword(key || ''); }, []); React.useEffect(() => { if (showing) { InputRef.current?.focus(); } }, [showing]); const performSearch = useCallback( async (query: string) => { if (!enableAutocomplete || query.length < minSearchLength) { setSearchResults([]); setShowResults(false); return; } setIsSearching(true); try { const results = await searchFunction(query); setSearchResults(results.slice(0, maxResults)); setShowResults(true); } catch (error) { setSearchResults([]); } finally { setIsSearching(false); } }, [enableAutocomplete, searchFunction, minSearchLength, maxResults] ); const handleInputChange = useCallback( (value: string) => { setKeyword(value); if (enableAutocomplete) { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } searchTimeoutRef.current = setTimeout(() => { performSearch(value); }, autocompleteDelay); } }, [enableAutocomplete, autocompleteDelay, performSearch] ); const handleResultSelect = useCallback( (result: SearchResult) => { if (result.url) { window.location.href = result.url; } else { const url = new URL(searchPageUrl, window.location.origin); url.searchParams.set('keyword', result.title); window.location.href = url.toString(); } setShowing(false); setShowResults(false); }, [searchPageUrl] ); const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === 'Enter') { setShowResults(false); const url = new URL(searchPageUrl, window.location.origin); url.searchParams.set('keyword', keyword); window.location.href = url.toString(); } else if (event.key === 'Escape') { setShowResults(false); setShowing(false); } }, [searchPageUrl, keyword] ); const handleFocus = useCallback(() => { if ( enableAutocomplete && keyword.length >= minSearchLength && searchResults.length > 0 ) { setShowResults(true); } }, [enableAutocomplete, keyword, minSearchLength, searchResults.length]); const handleBlur = useCallback(() => { setTimeout(() => { setShowResults(false); }, 150); }, []); const defaultSearchIcon = () => ( ); const defaultCloseIcon = () => ( ); return (
{ e.preventDefault(); setShowing(!showing); }} > {renderSearchIcon ? renderSearchIcon() : defaultSearchIcon()} {showing && (
{renderSearchInput ? renderSearchInput({ value: keyword || '', onChange: handleInputChange, onKeyDown: handleKeyDown, onFocus: handleFocus, onBlur: handleBlur, placeholder: _('Search'), ref: InputRef }) : defaultSearchInput({ value: keyword || '', onChange: handleInputChange, onKeyDown: handleKeyDown, onFocus: handleFocus, onBlur: handleBlur, placeholder: _('Search'), ref: InputRef })} { e.preventDefault(); setShowing(false); setShowResults(false); }} > {renderCloseIcon ? renderCloseIcon() : defaultCloseIcon()} {enableAutocomplete && showResults && (renderSearchResults ? renderSearchResults({ results: searchResults, query: keyword || '', onSelect: handleResultSelect, isLoading: isSearching }) : defaultSearchResults({ results: searchResults, query: keyword || '', onSelect: handleResultSelect, isLoading: isSearching }))}
)}
); } const defaultSearchInput = (props: { value: string; onChange: (value: string) => void; onKeyDown: (event: React.KeyboardEvent) => void; onFocus: () => void; onBlur: () => void; placeholder: string; ref: React.RefObject; }) => (
props.onChange(e.target.value)} onKeyDown={props.onKeyDown} onFocus={props.onFocus} onBlur={props.onBlur} enterKeyHint="done" className="w-full focus:outline-none" />
); const defaultSearchResults = (props: { results: SearchResult[]; query: string; onSelect: (result: SearchResult) => void; isLoading: boolean; }) => { return (
{props.isLoading && (
Searching...
)} {!props.isLoading && props.results.length === 0 && (
No results found for “{props.query}”
)} {!props.isLoading && props.results.map((result) => (
{ e.preventDefault(); props.onSelect(result); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onSelect(result); } }} role="button" tabIndex={0} > {result.image && ( {result.title} )}
{result.title}
{result.price &&
{result.price}
} {result.type && (
{result.type}
)}
))}
); };