'use client'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowUpRight, Clock, Search, TrendingUp, X } from 'lucide-react'; import { cn } from '@/lib/format'; export interface Suggestion { type: string; label: string; sublabel: string | null; href: string; } const RECENT_KEY = 'ri-recent-searches'; const TRENDING = ['1999 Charizard PSA 10', 'Rolex Daytona 116500LN', 'LEGO 10179', 'Black Lotus Alpha', 'Blue-Eyes White Dragon LOB', 'Super Mario 64 sealed', 'Amazing Spider-Man 300', 'Patek Philippe Nautilus 5711']; const GROUP_LABEL: Record = { asset: 'Assets', set: 'Sets & releases', category: 'Categories', brand: 'Brands', source: 'Sources', index: 'Indices' }; const GROUP_ORDER = ['asset', 'set', 'category', 'brand', 'index', 'source']; function readRecent(): string[] { try { const raw = localStorage.getItem(RECENT_KEY); const arr = raw ? (JSON.parse(raw) as unknown) : []; return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string').slice(0, 8) : []; } catch { return []; } } export function pushRecent(q: string) { try { const next = [q, ...readRecent().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8); localStorage.setItem(RECENT_KEY, JSON.stringify(next)); } catch { /* ignore */ } } /** Highlight query tokens inside a label (case-insensitive, word-agnostic). */ export function Highlight({ text, q }: { text: string; q: string }) { const tokens = q.toLowerCase().split(/\s+/).filter((t) => t.length >= 2); if (!tokens.length) return <>{text}; const re = new RegExp(`(${tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'ig'); const parts = text.split(re); return ( <> {parts.map((p, i) => (re.test(p) ? {p} : {p}))} ); } /** * Full-screen search sheet (mobile) / command palette (desktop, ⌘K). Grouped instant results from * /api/search/suggest, recent searches (localStorage), trending prompts and keyboard navigation. */ export function SearchSheet({ open, onClose, initialQuery = '' }: { open: boolean; onClose: () => void; initialQuery?: string }) { const router = useRouter(); const [q, setQ] = useState(initialQuery); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [active, setActive] = useState(-1); const inputRef = useRef(null); // Only rendered client-side once opened (SSR renders nothing), so reading localStorage here is safe. const recent = useMemo(() => (open && typeof window !== 'undefined' ? readRecent() : []), [open]); useEffect(() => { if (!open) return; const t = setTimeout(() => inputRef.current?.focus(), 30); document.body.style.overflow = 'hidden'; const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); window.addEventListener('keydown', onKey); return () => { clearTimeout(t); document.body.style.overflow = ''; window.removeEventListener('keydown', onKey); }; }, [open, onClose]); useEffect(() => { if (!open) return; const query = q.trim(); if (query.length < 2) return; const ctrl = new AbortController(); const t = setTimeout(() => { setLoading(true); fetch(`/api/search/suggest?q=${encodeURIComponent(query)}`, { signal: ctrl.signal }) .then((r) => r.json()) .then((d: { items: Suggestion[] }) => { setItems(d.items ?? []); setActive(-1); }) .catch(() => {}) .finally(() => setLoading(false)); }, 110); return () => { clearTimeout(t); ctrl.abort(); }; }, [q, open]); const groups = useMemo(() => { const m = new Map(); for (const it of items) (m.get(it.type) ?? m.set(it.type, []).get(it.type)!).push(it); return [...m.entries()].sort((a, b) => GROUP_ORDER.indexOf(a[0]) - GROUP_ORDER.indexOf(b[0])); }, [items]); const flat = useMemo(() => groups.flatMap(([, g]) => g), [groups]); const go = useCallback( (href: string, label?: string) => { if (label) pushRecent(label); onClose(); router.push(href); }, [onClose, router], ); const submit = () => { const query = q.trim(); if (active >= 0 && flat[active]) return go(flat[active]!.href, query || flat[active]!.label); if (query) go(`/search?q=${encodeURIComponent(query)}`, query); }; if (!open) return null; const showIdle = q.trim().length < 2; return (
{ e.preventDefault(); submit(); }} > { setQ(e.target.value); if (e.target.value.trim().length < 2) { setItems([]); setActive(-1); } }} onKeyDown={(e) => { if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(flat.length - 1, a + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(-1, a - 1)); } }} type="search" enterKeyHint="search" autoComplete="off" autoCapitalize="off" spellCheck={false} placeholder="Search cards, watches, games, sneakers, LEGO, art…" aria-label="Search collectibles" aria-autocomplete="list" aria-controls="search-sheet-list" className="h-12 min-w-0 flex-1 bg-transparent text-[16px] text-fg placeholder:text-subtle focus:outline-none md:h-11 md:text-[15px]" /> {q ? ( ) : null}
{showIdle ? (
{recent.length ? (

Recent

    {recent.map((r) => (
  • ))}
) : null}

Try

    {TRENDING.map((t) => (
  • ))}

Understands graders and grades (“PSA 10”), years, price bounds (“under $5,000”), conditions (“sealed”, “CIB”) and set codes (“LOB-001”, “116500LN”).

) : (
{groups.map(([type, g]) => (

{GROUP_LABEL[type] ?? type}

    {g.map((it) => { const idx = flat.indexOf(it); return (
  • ); })}
))} {!loading && !flat.length ?

No instant matches. Press Enter to run a full search.

: null}
)}
); } /** Icon/button trigger that opens the sheet; global ⌘K / Ctrl+K shortcut. */ export function SearchTrigger({ variant = 'icon', className, initialQuery }: { variant?: 'icon' | 'field' | 'hero'; className?: string; initialQuery?: string }) { const [open, setOpen] = useState(false); const close = useCallback(() => setOpen(false), []); useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setOpen(true); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); return ( <> {variant === 'icon' ? ( ) : variant === 'field' ? ( ) : ( )} ); } /** Non-JS friendly hero search: a real form that submits to /search; enhanced with the sheet on focus. */ export function HeroSearch({ chips }: { chips: string[] }) { const [open, setOpen] = useState(false); const close = useCallback(() => setOpen(false), []); return (
{ // Use the rich sheet when JS is available; keep the plain form as the fallback. e.currentTarget.blur(); setOpen(true); }} className="h-13 w-full rounded-lg border border-border bg-elevated pl-12 pr-24 text-[16px] text-fg shadow-card placeholder:text-subtle focus:border-border-strong focus:outline-none" />
    {chips.map((c) => (
  • pushRecent(c)}> {c}
  • ))}
); }