TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1'use client';23import Link from 'next/link';4import { useRouter } from 'next/navigation';5import { useCallback, useEffect, useMemo, useRef, useState } from 'react';6import { ArrowUpRight, Clock, Search, TrendingUp, X } from 'lucide-react';7import { cn } from '@/lib/format';89export interface Suggestion {10 type: string;11 label: string;12 sublabel: string | null;13 href: string;14}1516const RECENT_KEY = 'ri-recent-searches';17const 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'];18const GROUP_LABEL: Record<string, string> = { asset: 'Assets', set: 'Sets & releases', category: 'Categories', brand: 'Brands', source: 'Sources', index: 'Indices' };19const GROUP_ORDER = ['asset', 'set', 'category', 'brand', 'index', 'source'];2021function readRecent(): string[] {22 try {23 const raw = localStorage.getItem(RECENT_KEY);24 const arr = raw ? (JSON.parse(raw) as unknown) : [];25 return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string').slice(0, 8) : [];26 } catch {27 return [];28 }29}30export function pushRecent(q: string) {31 try {32 const next = [q, ...readRecent().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8);33 localStorage.setItem(RECENT_KEY, JSON.stringify(next));34 } catch {35 /* ignore */36 }37}3839/** Highlight query tokens inside a label (case-insensitive, word-agnostic). */40export function Highlight({ text, q }: { text: string; q: string }) {41 const tokens = q.toLowerCase().split(/\s+/).filter((t) => t.length >= 2);42 if (!tokens.length) return <>{text}</>;43 const re = new RegExp(`(${tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'ig');44 const parts = text.split(re);45 return (46 <>47 {parts.map((p, i) => (re.test(p) ? <mark key={i} className="rounded-[2px] bg-index-bg text-fg">{p}</mark> : <span key={i}>{p}</span>))}48 </>49 );50}5152/**53 * Full-screen search sheet (mobile) / command palette (desktop, ⌘K). Grouped instant results from54 * /api/search/suggest, recent searches (localStorage), trending prompts and keyboard navigation.55 */56export function SearchSheet({ open, onClose, initialQuery = '' }: { open: boolean; onClose: () => void; initialQuery?: string }) {57 const router = useRouter();58 const [q, setQ] = useState(initialQuery);59 const [items, setItems] = useState<Suggestion[]>([]);60 const [loading, setLoading] = useState(false);61 const [active, setActive] = useState(-1);62 const inputRef = useRef<HTMLInputElement>(null);63 // Only rendered client-side once opened (SSR renders nothing), so reading localStorage here is safe.64 const recent = useMemo(() => (open && typeof window !== 'undefined' ? readRecent() : []), [open]);6566 useEffect(() => {67 if (!open) return;68 const t = setTimeout(() => inputRef.current?.focus(), 30);69 document.body.style.overflow = 'hidden';70 const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();71 window.addEventListener('keydown', onKey);72 return () => {73 clearTimeout(t);74 document.body.style.overflow = '';75 window.removeEventListener('keydown', onKey);76 };77 }, [open, onClose]);7879 useEffect(() => {80 if (!open) return;81 const query = q.trim();82 if (query.length < 2) return;83 const ctrl = new AbortController();84 const t = setTimeout(() => {85 setLoading(true);86 fetch(`/api/search/suggest?q=${encodeURIComponent(query)}`, { signal: ctrl.signal })87 .then((r) => r.json())88 .then((d: { items: Suggestion[] }) => {89 setItems(d.items ?? []);90 setActive(-1);91 })92 .catch(() => {})93 .finally(() => setLoading(false));94 }, 110);95 return () => {96 clearTimeout(t);97 ctrl.abort();98 };99 }, [q, open]);100101 const groups = useMemo(() => {102 const m = new Map<string, Suggestion[]>();103 for (const it of items) (m.get(it.type) ?? m.set(it.type, []).get(it.type)!).push(it);104 return [...m.entries()].sort((a, b) => GROUP_ORDER.indexOf(a[0]) - GROUP_ORDER.indexOf(b[0]));105 }, [items]);106 const flat = useMemo(() => groups.flatMap(([, g]) => g), [groups]);107108 const go = useCallback(109 (href: string, label?: string) => {110 if (label) pushRecent(label);111 onClose();112 router.push(href);113 },114 [onClose, router],115 );116 const submit = () => {117 const query = q.trim();118 if (active >= 0 && flat[active]) return go(flat[active]!.href, query || flat[active]!.label);119 if (query) go(`/search?q=${encodeURIComponent(query)}`, query);120 };121122 if (!open) return null;123 const showIdle = q.trim().length < 2;124 return (125 <div className="fixed inset-0 z-[70]" role="dialog" aria-modal="true" aria-label="Search">126 <div className="absolute inset-0 bg-black/45 backdrop-blur-[2px]" onClick={onClose} />127 <div className="sheet-in absolute inset-x-0 top-0 mx-auto flex h-[100dvh] w-full flex-col bg-elevated shadow-pop md:top-[8vh] md:h-auto md:max-h-[76vh] md:max-w-2xl md:rounded-xl md:border md:border-border">128 <form129 role="search"130 className="flex items-center gap-2 border-b border-border px-3 py-2 md:px-4"131 onSubmit={(e) => {132 e.preventDefault();133 submit();134 }}135 >136 <Search className="h-5 w-5 shrink-0 text-subtle" />137 <input138 ref={inputRef}139 value={q}140 onChange={(e) => {141 setQ(e.target.value);142 if (e.target.value.trim().length < 2) {143 setItems([]);144 setActive(-1);145 }146 }}147 onKeyDown={(e) => {148 if (e.key === 'ArrowDown') {149 e.preventDefault();150 setActive((a) => Math.min(flat.length - 1, a + 1));151 } else if (e.key === 'ArrowUp') {152 e.preventDefault();153 setActive((a) => Math.max(-1, a - 1));154 }155 }}156 type="search"157 enterKeyHint="search"158 autoComplete="off"159 autoCapitalize="off"160 spellCheck={false}161 placeholder="Search cards, watches, games, sneakers, LEGO, art…"162 aria-label="Search collectibles"163 aria-autocomplete="list"164 aria-controls="search-sheet-list"165 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]"166 />167 {q ? (168 <button type="button" onClick={() => setQ('')} className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted hover:bg-inset" aria-label="Clear">169 <X className="h-4 w-4" />170 </button>171 ) : null}172 <button type="button" onClick={onClose} className="inline-flex h-10 items-center rounded-md border border-border px-2.5 text-[12px] font-medium text-muted hover:bg-inset md:h-8">173 <span className="md:hidden">Cancel</span>174 <span className="hidden md:inline">Esc</span>175 </button>176 </form>177178 <div id="search-sheet-list" className="flex-1 overflow-y-auto overscroll-contain safe-bottom" role="listbox">179 {showIdle ? (180 <div className="px-3 py-3 md:px-4">181 {recent.length ? (182 <section className="mb-4">183 <p className="t-label mb-1.5 flex items-center gap-1.5">184 <Clock className="h-3 w-3" /> Recent185 </p>186 <ul className="flex flex-wrap gap-1.5">187 {recent.map((r) => (188 <li key={r}>189 <button type="button" onClick={() => go(`/search?q=${encodeURIComponent(r)}`, r)} className="chip">190 {r}191 </button>192 </li>193 ))}194 </ul>195 </section>196 ) : null}197 <section>198 <p className="t-label mb-1.5 flex items-center gap-1.5">199 <TrendingUp className="h-3 w-3" /> Try200 </p>201 <ul className="divide-y divide-border">202 {TRENDING.map((t) => (203 <li key={t}>204 <button type="button" onClick={() => go(`/search?q=${encodeURIComponent(t)}`, t)} className="flex min-h-[44px] w-full items-center justify-between gap-3 px-1 text-left text-[14px] text-fg hover:bg-sunken">205 <span>{t}</span>206 <ArrowUpRight className="h-4 w-4 text-subtle" />207 </button>208 </li>209 ))}210 </ul>211 </section>212 <p className="mt-4 text-[11px] leading-relaxed text-subtle">Understands graders and grades (“PSA 10”), years, price bounds (“under $5,000”), conditions (“sealed”, “CIB”) and set codes (“LOB-001”, “116500LN”).</p>213 </div>214 ) : (215 <div className="py-1">216 {groups.map(([type, g]) => (217 <section key={type} className="pb-1">218 <p className="t-label px-4 pb-1 pt-2">{GROUP_LABEL[type] ?? type}</p>219 <ul>220 {g.map((it) => {221 const idx = flat.indexOf(it);222 return (223 <li key={`${it.type}-${it.href}`} role="option" aria-selected={idx === active}>224 <button type="button" onMouseEnter={() => setActive(idx)} onClick={() => go(it.href, q.trim())} className={cn('flex min-h-[44px] w-full items-center justify-between gap-3 px-4 py-1.5 text-left', idx === active ? 'bg-inset' : 'hover:bg-sunken')}>225 <span className="min-w-0">226 <span className="block truncate text-[14px] text-fg">227 <Highlight text={it.label} q={q} />228 </span>229 {it.sublabel ? <span className="block truncate text-[11px] text-muted">{it.sublabel}</span> : null}230 </span>231 <ArrowUpRight className="h-4 w-4 shrink-0 text-subtle" />232 </button>233 </li>234 );235 })}236 </ul>237 </section>238 ))}239 {!loading && !flat.length ? <p className="px-4 py-6 text-center text-[13px] text-muted">No instant matches. Press Enter to run a full search.</p> : null}240 <button type="button" onClick={submit} className="mt-1 flex min-h-[48px] w-full items-center justify-between border-t border-border px-4 text-left text-[13px] text-muted hover:bg-sunken">241 <span>242 Search all results for <span className="font-medium text-fg">“{q.trim()}”</span>243 </span>244 <kbd className="rounded-sm border border-border px-1.5 text-[10px] text-subtle">↵</kbd>245 </button>246 </div>247 )}248 </div>249 </div>250 </div>251 );252}253254/** Icon/button trigger that opens the sheet; global ⌘K / Ctrl+K shortcut. */255export function SearchTrigger({ variant = 'icon', className, initialQuery }: { variant?: 'icon' | 'field' | 'hero'; className?: string; initialQuery?: string }) {256 const [open, setOpen] = useState(false);257 const close = useCallback(() => setOpen(false), []);258 useEffect(() => {259 const onKey = (e: KeyboardEvent) => {260 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {261 e.preventDefault();262 setOpen(true);263 }264 };265 window.addEventListener('keydown', onKey);266 return () => window.removeEventListener('keydown', onKey);267 }, []);268 return (269 <>270 {variant === 'icon' ? (271 <button type="button" onClick={() => setOpen(true)} className={cn('inline-flex h-10 w-10 items-center justify-center rounded-md text-fg hover:bg-inset md:h-8 md:w-8', className)} aria-label="Search">272 <Search className="h-5 w-5" />273 </button>274 ) : variant === 'field' ? (275 <button type="button" onClick={() => setOpen(true)} className={cn('group flex h-8 w-64 items-center gap-2 rounded-md border border-border bg-sunken px-2.5 text-left text-[13px] text-subtle hover:border-border-strong xl:w-80', className)} aria-label="Search (⌘K)">276 <Search className="h-3.5 w-3.5" />277 <span className="flex-1 truncate">Search cards, watches, games…</span>278 <kbd className="rounded-sm border border-border bg-elevated px-1 text-[10px] text-subtle">⌘K</kbd>279 </button>280 ) : (281 <button type="button" onClick={() => setOpen(true)} className={cn('flex h-13 w-full items-center gap-3 rounded-lg border border-border bg-elevated px-4 text-left shadow-card hover:border-border-strong', className)} aria-label="Search collectibles">282 <Search className="h-5 w-5 text-subtle" />283 <span className="flex-1 truncate text-[16px] text-subtle">Search cards, watches, games, sneakers, LEGO, art…</span>284 <span className="btn-primary btn-sm hidden h-8 sm:inline-flex">Search</span>285 </button>286 )}287 <SearchSheet open={open} onClose={close} initialQuery={initialQuery} />288 </>289 );290}291292/** Non-JS friendly hero search: a real form that submits to /search; enhanced with the sheet on focus. */293export function HeroSearch({ chips }: { chips: string[] }) {294 const [open, setOpen] = useState(false);295 const close = useCallback(() => setOpen(false), []);296 return (297 <div className="mx-auto w-full max-w-2xl">298 <form action="/search" role="search" className="relative flex items-center">299 <Search className="pointer-events-none absolute left-4 h-5 w-5 text-subtle" />300 <input301 name="q"302 type="search"303 autoComplete="off"304 enterKeyHint="search"305 placeholder="Search cards, watches, games, sneakers, LEGO, art…"306 aria-label="Search collectibles"307 onFocus={(e) => {308 // Use the rich sheet when JS is available; keep the plain form as the fallback.309 e.currentTarget.blur();310 setOpen(true);311 }}312 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"313 />314 <button type="submit" className="btn-primary absolute right-1.5 h-10 px-4 text-[13px]">315 Search316 </button>317 </form>318 <ul className="mt-3 flex flex-wrap items-center justify-center gap-1.5">319 {chips.map((c) => (320 <li key={c}>321 <Link href={`/search?q=${encodeURIComponent(c)}`} className="chip" onClick={() => pushRecent(c)}>322 {c}323 </Link>324 </li>325 ))}326 </ul>327 <SearchSheet open={open} onClose={close} />328 </div>329 );330}331