TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1'use client';23import { useEffect, useRef, useState } from 'react';4import { Search, X } from 'lucide-react';5import { inputClass } from './form';6import { fmtMoney } from '@/lib/format';78export interface PickedAsset {9 id: string;10 title: string;11 categorySlug: string;12 year: number | null;13 heroImageUrl: string | null;14 rivUsd: number | null;15}16interface Variant {17 id: string;18 label: string;19 rivUsd: number | null;20}2122/**23 * Typeahead over RareIndex assets (server search) + variant picker. Emits hidden inputs24 * `assetId` and `variantId` for the surrounding form.25 */26export function AssetPicker({ name = 'assetId', initial, error, withVariant = true, label = 'Asset' }: { name?: string; initial?: PickedAsset | null; error?: string | null; withVariant?: boolean; label?: string }) {27 const [q, setQ] = useState('');28 const [hits, setHits] = useState<PickedAsset[]>([]);29 const [open, setOpen] = useState(false);30 const [picked, setPicked] = useState<PickedAsset | null>(initial ?? null);31 const [variants, setVariants] = useState<Variant[]>([]);32 const [loading, setLoading] = useState(false);33 const box = useRef<HTMLDivElement>(null);3435 useEffect(() => {36 if (q.trim().length < 2) return;37 const ctrl = new AbortController();38 const t = setTimeout(async () => {39 setLoading(true);40 try {41 const res = await fetch(`/api/account/assets/search?q=${encodeURIComponent(q.trim())}`, { signal: ctrl.signal });42 if (res.ok) setHits((await res.json()) as PickedAsset[]);43 } catch {44 /* aborted */45 } finally {46 setLoading(false);47 }48 }, 180);49 return () => {50 clearTimeout(t);51 ctrl.abort();52 };53 }, [q]);5455 useEffect(() => {56 if (!picked || !withVariant) return;57 fetch(`/api/account/assets/${picked.id}/variants`)58 .then((r) => (r.ok ? r.json() : []))59 .then((v: Variant[]) => setVariants(v))60 .catch(() => setVariants([]));61 }, [picked, withVariant]);6263 useEffect(() => {64 const onDoc = (e: MouseEvent) => {65 if (box.current && !box.current.contains(e.target as Node)) setOpen(false);66 };67 document.addEventListener('mousedown', onDoc);68 return () => document.removeEventListener('mousedown', onDoc);69 }, []);7071 return (72 <div className="space-y-1.5" ref={box}>73 <label className="block text-xs font-medium text-muted">{label}</label>74 <input type="hidden" name={name} value={picked?.id ?? ''} />75 {picked ? (76 <div className="flex items-center gap-3 rounded-md border border-border bg-sunken px-3 py-2">77 {picked.heroImageUrl ? (78 // eslint-disable-next-line @next/next/no-img-element79 <img src={picked.heroImageUrl} alt="" className="h-10 w-10 rounded-sm object-cover" />80 ) : (81 <span className="h-10 w-10 rounded-sm bg-inset" />82 )}83 <div className="min-w-0 flex-1">84 <p className="truncate text-sm font-medium">{picked.title}</p>85 <p className="text-xs text-muted">86 {picked.categorySlug.replace(/_/g, ' ')}87 {picked.year ? ` · ${picked.year}` : ''}88 {picked.rivUsd ? ` · RIV ${fmtMoney(picked.rivUsd)}` : ' · no valuation yet'}89 </p>90 </div>91 <button type="button" aria-label="Change asset" className="text-subtle hover:text-fg" onClick={() => setPicked(null)}>92 <X className="h-4 w-4" />93 </button>94 </div>95 ) : (96 <div className="relative">97 <Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-subtle" />98 <input value={q} onChange={(e) => { setQ(e.target.value); setOpen(true); if (e.target.value.trim().length < 2) setHits([]); }} onFocus={() => setOpen(true)} placeholder="Search RareIndex assets… e.g. 1999 Charizard, Rolex 116500LN, LEGO 75192" className={`${inputClass} pl-8`} autoComplete="off" aria-autocomplete="list" />99 {open && (hits.length > 0 || loading || q.trim().length >= 2) ? (100 <ul className="absolute z-20 mt-1 max-h-72 w-full overflow-auto rounded-md border border-border bg-elevated shadow-pop" role="listbox">101 {hits.map((h) => (102 <li key={h.id}>103 <button type="button" className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-inset" onClick={() => { setPicked(h); setOpen(false); setQ(''); }}>104 {h.heroImageUrl ? (105 // eslint-disable-next-line @next/next/no-img-element106 <img src={h.heroImageUrl} alt="" className="h-9 w-9 rounded-sm object-cover" />107 ) : (108 <span className="h-9 w-9 rounded-sm bg-inset" />109 )}110 <span className="min-w-0 flex-1">111 <span className="block truncate text-sm">{h.title}</span>112 <span className="block text-xs text-muted">113 {h.categorySlug.replace(/_/g, ' ')}114 {h.year ? ` · ${h.year}` : ''}115 </span>116 </span>117 <span className="num text-xs text-muted">{h.rivUsd ? fmtMoney(h.rivUsd) : '—'}</span>118 </button>119 </li>120 ))}121 {!loading && hits.length === 0 && q.trim().length >= 2 ? <li className="px-3 py-3 text-xs text-muted">No asset matches yet. RareIndex adds assets as connectors ingest them; try a different spelling or set name.</li> : null}122 {loading ? <li className="px-3 py-2 text-xs text-subtle">Searching…</li> : null}123 </ul>124 ) : null}125 </div>126 )}127 {error ? <p className="text-xs text-loss">{error}</p> : null}128 {withVariant && picked ? (129 <div className="pt-1">130 <label className="block text-xs font-medium text-muted" htmlFor="variantId">131 Variant / grade132 </label>133 <select name="variantId" id="variantId" className={`${inputClass} mt-1.5`} defaultValue="">134 <option value="">Not specified — use grader/grade fields below</option>135 {variants.map((v) => (136 <option key={v.id} value={v.id}>137 {v.label}138 {v.rivUsd ? ` · RIV ${fmtMoney(v.rivUsd)}` : ''}139 </option>140 ))}141 </select>142 </div>143 ) : null}144 </div>145 );146}147