'use client'; /** * Header search with autocomplete. * * The header itself is a server component (merchant-configured Content), so * the search box is a client island — the same bridge `HeaderAccount` uses. * * Behaviour: * - `useSearch()` supplies the suggestions (300ms debounce, 2-char minimum, * abort on re-query). This component owns only the input + dropdown. * - Submitting navigates to `/products?search=`, which is exactly * what `use-product-listing` reads back off the URL. The listing side * already works; this is the surface that writes the param. * - Picking a product suggestion goes straight to its PDP; picking a * category goes to the listing filtered by `?category=`, because a * CategorySuggestion carries `id` + `name` + `productCount` and no slug. * * The dropdown auto-hides: `suggestions` is `null` until a query clears the * 2-char minimum, and an empty store answers `{ products: [], categories: [] }` * which renders the "no results" line rather than an empty box. * * Keyboard: ArrowDown / ArrowUp move the active option, Enter opens it (or * submits the raw query when nothing is active), Escape closes the panel and * keeps focus in the field. A blur that lands outside the widget closes it. */ import * as React from 'react'; import { Search } from 'lucide-react'; import { useRouter } from '@/core/lib/navigation'; import { useSearch } from '@/core/hooks/use-search'; import { useTranslations } from '@/core/lib/translations'; import { cn } from '@/core/lib/utils'; interface HeaderSearchProps { className?: string; } /** One flattened dropdown row — products first, then categories. */ type Option = | { kind: 'product'; id: string; label: string; href: string } | { kind: 'category'; id: string; label: string; href: string }; export function HeaderSearch({ className }: HeaderSearchProps) { const router = useRouter(); const t = useTranslations('nav'); const tc = useTranslations('common'); const tp = useTranslations('products'); const [query, setQuery] = React.useState(''); const [open, setOpen] = React.useState(false); const [activeIndex, setActiveIndex] = React.useState(-1); const { suggestions, loading } = useSearch(query); const rootRef = React.useRef(null); const listboxId = React.useId(); // Products first, then categories — one flat list so the arrow keys can walk // the whole panel without caring which section a row came from. const options: Option[] = React.useMemo(() => { if (!suggestions) return []; const rows: Option[] = []; for (const product of suggestions.products) { rows.push({ kind: 'product', id: product.id, label: product.name, // `slug` is nullable on a ProductSuggestion — fall back to the id, // the same way the product cards do. href: `/products/${product.slug || product.id}`, }); } for (const category of suggestions.categories) { rows.push({ kind: 'category', id: category.id, label: category.name, // CategorySuggestion carries no slug, so filter the listing by id. href: `/products?category=${encodeURIComponent(category.id)}`, }); } return rows; }, [suggestions]); // A fresh result set invalidates whatever row was highlighted. React.useEffect(() => { setActiveIndex(-1); }, [options]); // Close when the click lands outside the widget. Pointerdown rather than // click so the panel is gone before a link underneath activates. React.useEffect(() => { if (!open) return; function onPointerDown(event: PointerEvent) { if (!rootRef.current) return; if (!rootRef.current.contains(event.target as Node)) setOpen(false); } document.addEventListener('pointerdown', onPointerDown); return () => document.removeEventListener('pointerdown', onPointerDown); }, [open]); function go(href: string) { setOpen(false); setActiveIndex(-1); router.push(href); } function submit() { const trimmed = query.trim(); if (!trimmed) return; go(`/products?search=${encodeURIComponent(trimmed)}`); } function handleKeyDown(event: React.KeyboardEvent) { if (event.key === 'Escape') { setOpen(false); setActiveIndex(-1); return; } if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { if (options.length === 0) return; event.preventDefault(); setOpen(true); setActiveIndex((prev) => { const step = event.key === 'ArrowDown' ? 1 : -1; const next = prev + step; if (next < 0) return options.length - 1; if (next >= options.length) return 0; return next; }); return; } if (event.key === 'Enter') { event.preventDefault(); const active = activeIndex >= 0 ? options[activeIndex] : undefined; if (active) { go(active.href); } else { submit(); } } } // The panel is only worth showing once the hook has something to say — // either rows, a spinner, or an explicit "nothing matched". const showPanel = open && (loading || suggestions !== null); const activeOptionId = activeIndex >= 0 ? `${listboxId}-${activeIndex}` : undefined; const productCount = suggestions?.products.length ?? 0; return (
{ event.preventDefault(); submit(); }} >
{showPanel && (
{loading && options.length === 0 ? (

{tc('loading')}

) : options.length === 0 ? (

{tc('noResults')}

) : (
    {options.map((option, index) => ( {index === 0 && productCount > 0 && (
  • {tp('pageTitle')}
  • )} {index === productCount && option.kind === 'category' && (
  • {t('categories')}
  • )}
  • { // Keep focus in the input so the blur handler cannot // close the panel before the navigation runs. event.preventDefault(); go(option.href); }} onMouseEnter={() => setActiveIndex(index)} > {option.label}
  • ))}
)}
)}
); }