1'use client';23import { useRouter } from 'next/navigation';4import { Search } from 'lucide-react';5import { useEffect, useRef, useState } from 'react';67interface Suggestion {8 type: string;9 label: string;10 sublabel: string | null;11 href: string;12}1314/** Header search with instant suggestions (/api/search/suggest); full results at /search. */15export function HeaderSearch() {16 const router = useRouter();17 const [q, setQ] = useState('');18 const [items, setItems] = useState<Suggestion[]>([]);19 const [open, setOpen] = useState(false);20 const [active, setActive] = useState(-1);21 const box = useRef<HTMLFormElement>(null);2223 useEffect(() => {24 if (q.trim().length < 2) return;25 const ctrl = new AbortController();26 const t = setTimeout(() => {27 fetch(`/api/search/suggest?q=${encodeURIComponent(q)}`, { signal: ctrl.signal })28 .then((r) => r.json())29 .then((d: { items: Suggestion[] }) => {30 setItems(d.items ?? []);31 setOpen(true);32 setActive(-1);33 })34 .catch(() => {});35 }, 120);36 return () => {37 clearTimeout(t);38 ctrl.abort();39 };40 }, [q]);4142 useEffect(() => {43 const onDoc = (e: MouseEvent) => {44 if (!box.current?.contains(e.target as Node)) setOpen(false);45 };46 document.addEventListener('mousedown', onDoc);47 return () => document.removeEventListener('mousedown', onDoc);48 }, []);4950 const go = (href: string) => {51 setOpen(false);52 router.push(href);53 };5455 return (56 <form57 ref={box}58 role="search"59 className="relative hidden items-center md:flex"60 onSubmit={(e) => {61 e.preventDefault();62 if (active >= 0 && items[active]) return go(items[active]!.href);63 const query = q.trim();64 if (query) go(`/search?q=${encodeURIComponent(query)}`);65 }}66 >67 <Search className="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-subtle" />68 <input69 type="search"70 value={q}71 onChange={(e) => {72 setQ(e.target.value);73 if (e.target.value.trim().length < 2) {74 setItems([]);75 setOpen(false);76 }77 }}78 role="combobox"79 aria-controls="header-search-listbox"80 onFocus={() => items.length && setOpen(true)}81 onKeyDown={(e) => {82 if (e.key === 'ArrowDown') {83 e.preventDefault();84 setActive((a) => Math.min(items.length - 1, a + 1));85 } else if (e.key === 'ArrowUp') {86 e.preventDefault();87 setActive((a) => Math.max(-1, a - 1));88 } else if (e.key === 'Escape') setOpen(false);89 }}90 placeholder="Search cards, watches, games, sneakers, LEGO…"91 className="h-8 w-64 rounded-md border border-border bg-sunken pl-8 pr-2 text-[13px] text-fg placeholder:text-subtle transition-[width] focus:w-80 focus:border-border-strong focus:outline-none xl:w-80 xl:focus:w-96"92 aria-label="Search collectibles"93 aria-autocomplete="list"94 aria-expanded={open}95 autoComplete="off"96 />97 {open && items.length ? (98 <ul id="header-search-listbox" className="absolute left-0 top-full z-50 mt-1 w-[26rem] overflow-hidden rounded-md border border-border bg-elevated py-1 text-[13px] shadow-pop" role="listbox">99 {items.map((it, i) => (100 <li key={`${it.type}-${it.href}`} role="option" aria-selected={i === active}>101 <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => go(it.href)} className={`flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left ${i === active ? 'bg-inset' : 'hover:bg-sunken'}`}>102 <span className="truncate text-fg">{it.label}</span>103 <span className="shrink-0 text-[10px] uppercase tracking-wider text-subtle">{it.sublabel ?? it.type}</span>104 </button>105 </li>106 ))}107 <li>108 <button type="submit" className="w-full px-3 py-1.5 text-left text-[12px] text-muted hover:bg-sunken">109 Search all results for “{q}” →110 </button>111 </li>112 </ul>113 ) : null}114 </form>115 );116}117