1'use client';23import Link from 'next/link';4import { useCallback, useRef, useState } from 'react';5import { Camera, ImagePlus, Link2, Loader2, Type, X } from 'lucide-react';6import { cn, fmtMoney, fmtPct, fmtDate, confidenceLabel } from '@/lib/format';7import { Badge, Card, CardHeader, EmptyState, Stat } from '@/components/ui/primitives';89type Mode = 'photo' | 'url' | 'text';1011interface Candidate {12 assetId: string;13 slug: string;14 title: string;15 categorySlug: string;16 heroImageUrl: string | null;17 year: number | null;18 setName: string | null;19 number: string | null;20 score: number;21 rivUsd: number | null;22 rivLowUsd: number | null;23 rivHighUsd: number | null;24 rivConfidence: number | null;25 rivSampleSize: number;26 latestSaleUsd: number | null;27 latestSaleAt: string | null;28 salesCount: number;29 activeListings: number;30 minAskUsd: number | null;31}32interface ScanResponse {33 sessionId: string;34 mode: Mode;35 guess: Record<string, unknown> & { confidence?: number; rationale?: string; warnings?: string[]; likelyGradeRange?: string | null; conditionNotes?: string | null } | null;36 guessConfidence: number | null;37 candidates: Candidate[];38 best: (Candidate & { context: { sales: Array<Record<string, unknown>>; listings: Array<Record<string, unknown>>; variants: Array<Record<string, unknown>> } }) | null;39 listing: { sourceId: string; sourceUrl: string; rawTitle: string; price: number | null; currency: string | null; grader: string | null; grade: string | null; imageUrls: string[]; discountToRiv: number | null; verdict: string } | null;40 model: string | null;41 usdEst: number;42 durationMs: number;43 notes: string[];44 quota: { used: number; limit: number };45 error?: string;46}4748async function downscale(file: File, max = 1600, quality = 0.86): Promise<{ data: string; mediaType: 'image/jpeg'; thumb: string }> {49 const bitmap = await createImageBitmap(file);50 const scale = Math.min(1, max / Math.max(bitmap.width, bitmap.height));51 const w = Math.round(bitmap.width * scale);52 const h = Math.round(bitmap.height * scale);53 const canvas = document.createElement('canvas');54 canvas.width = w;55 canvas.height = h;56 const ctx = canvas.getContext('2d')!;57 ctx.drawImage(bitmap, 0, 0, w, h);58 const dataUrl = canvas.toDataURL('image/jpeg', quality);59 const tscale = Math.min(1, 240 / Math.max(w, h));60 const tc = document.createElement('canvas');61 tc.width = Math.round(w * tscale);62 tc.height = Math.round(h * tscale);63 tc.getContext('2d')!.drawImage(canvas, 0, 0, tc.width, tc.height);64 return { data: dataUrl.split(',')[1]!, mediaType: 'image/jpeg', thumb: tc.toDataURL('image/jpeg', 0.7) };65}6667export function ScannerClient({ aiReady }: { aiReady: boolean }) {68 const [mode, setMode] = useState<Mode>('photo');69 const [images, setImages] = useState<Array<{ data: string; mediaType: 'image/jpeg'; thumb: string; name: string }>>([]);70 const [url, setUrl] = useState('');71 const [text, setText] = useState('');72 const [busy, setBusy] = useState(false);73 const [error, setError] = useState<string | null>(null);74 const [result, setResult] = useState<ScanResponse | null>(null);75 const [chosen, setChosen] = useState<string | null>(null);76 const fileRef = useRef<HTMLInputElement>(null);77 const camRef = useRef<HTMLInputElement>(null);7879 const addFiles = useCallback(async (files: FileList | null) => {80 if (!files) return;81 const next = [...images];82 for (const f of Array.from(files).slice(0, 5 - next.length)) {83 if (!f.type.startsWith('image/')) continue;84 const d = await downscale(f);85 next.push({ ...d, name: f.name });86 }87 setImages(next.slice(0, 5));88 }, [images]);8990 async function submit() {91 setBusy(true);92 setError(null);93 setResult(null);94 setChosen(null);95 try {96 const res = await fetch('/api/scanner', {97 method: 'POST',98 headers: { 'content-type': 'application/json' },99 body: JSON.stringify({100 mode,101 images: mode === 'photo' ? images.map((i) => ({ data: i.data, mediaType: i.mediaType })) : undefined,102 thumbnails: mode === 'photo' ? images.slice(0, 3).map((i) => i.thumb) : undefined,103 url: mode === 'url' ? url.trim() : undefined,104 text: mode === 'text' ? text.trim() : undefined,105 }),106 });107 const json = (await res.json()) as ScanResponse;108 if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);109 setResult(json);110 } catch (err) {111 setError(err instanceof Error ? err.message : String(err));112 } finally {113 setBusy(false);114 }115 }116117 async function choose(assetId: string | null) {118 if (!result) return;119 setChosen(assetId);120 await fetch(`/api/scanner/${result.sessionId}/choose`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assetId }) }).catch(() => {});121 }122123 const canSubmit = !busy && aiReady !== false && ((mode === 'photo' && images.length > 0) || (mode === 'url' && /^https?:\/\//.test(url.trim())) || (mode === 'text' && text.trim().length > 3));124125 return (126 <div className="grid gap-6 lg:grid-cols-[minmax(0,420px)_1fr]">127 <Card className="self-start">128 <div className="flex border-b border-border">129 {(130 [131 ['photo', 'Photos', Camera],132 ['url', 'URL', Link2],133 ['text', 'Describe', Type],134 ] as const135 ).map(([m, label, Icon]) => (136 <button key={m} type="button" onClick={() => setMode(m)} className={cn('flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-[13px] font-medium', mode === m ? 'border-b-2 border-accent text-fg' : 'text-muted hover:text-fg')}>137 <Icon className="h-3.5 w-3.5" /> {label}138 </button>139 ))}140 </div>141 <div className="space-y-3 p-4">142 {!aiReady ? <p className="rounded-md bg-alert-bg px-3 py-2 text-xs text-alert">AI provider not configured on this server. URL parsing still works; identification is disabled.</p> : null}143 {mode === 'photo' ? (144 <>145 <div146 className="flex min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border-strong bg-sunken p-4 text-center"147 onDragOver={(e) => e.preventDefault()}148 onDrop={(e) => {149 e.preventDefault();150 void addFiles(e.dataTransfer.files);151 }}152 >153 <p className="text-xs text-muted">Drop up to 5 photos: front, back, label, seal, serial.</p>154 <div className="flex gap-2">155 <button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border bg-elevated px-3 py-1.5 text-xs font-medium hover:bg-inset" onClick={() => fileRef.current?.click()}>156 <ImagePlus className="h-3.5 w-3.5" /> Upload157 </button>158 <button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border bg-elevated px-3 py-1.5 text-xs font-medium hover:bg-inset md:hidden" onClick={() => camRef.current?.click()}>159 <Camera className="h-3.5 w-3.5" /> Take photo160 </button>161 </div>162 <input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void addFiles(e.target.files)} />163 <input ref={camRef} type="file" accept="image/*" capture="environment" hidden onChange={(e) => void addFiles(e.target.files)} />164 </div>165 {images.length ? (166 <ul className="grid grid-cols-5 gap-2">167 {images.map((img, i) => (168 <li key={i} className="relative aspect-square overflow-hidden rounded-sm border border-border bg-inset">169 {/* eslint-disable-next-line @next/next/no-img-element */}170 <img src={img.thumb} alt={img.name} className="h-full w-full object-cover" />171 <button type="button" aria-label="Remove" className="absolute right-0.5 top-0.5 rounded-sm bg-black/60 p-0.5 text-white" onClick={() => setImages(images.filter((_, j) => j !== i))}>172 <X className="h-3 w-3" />173 </button>174 </li>175 ))}176 </ul>177 ) : null}178 </>179 ) : mode === 'url' ? (180 <label className="block text-xs text-muted">181 Marketplace or auction URL182 <input type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://…" className="mt-1 w-full rounded-md border border-border bg-sunken px-3 py-2 text-sm text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" />183 <span className="mt-1 block text-[11px] text-subtle">Supported sources are those with a lookup-capable connector; others fall back to text identification.</span>184 </label>185 ) : (186 <label className="block text-xs text-muted">187 Describe the item188 <textarea value={text} onChange={(e) => setText(e.target.value)} rows={5} placeholder="e.g. 1999 Pokémon Base Set Charizard holo, 1st edition stamp, PSA 9 slab #12345678" className="mt-1 w-full rounded-md border border-border bg-sunken px-3 py-2 text-sm text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" />189 </label>190 )}191 <button type="button" disabled={!canSubmit} onClick={() => void submit()} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-3 py-2 text-sm font-medium text-accent-fg disabled:opacity-50">192 {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null} {busy ? 'Identifying…' : 'Identify & price'}193 </button>194 {error ? <p className="rounded-md bg-loss-bg px-3 py-2 text-xs text-loss">{error}</p> : null}195 <p className="text-[11px] leading-relaxed text-subtle">Photos are downscaled in your browser before upload and are not stored; small thumbnails are kept for quality review. Valuations are estimates, not offers, appraisals or authentication.</p>196 </div>197 </Card>198199 <div className="min-w-0 space-y-4">200 {!result && !busy ? <EmptyState title="Results appear here" description="Identification, the closest catalog matches, RareIndex Valuation, comparable sales and live listings." /> : null}201 {busy ? (202 <Card className="p-6">203 <div className="flex items-center gap-3 text-sm text-muted">204 <Loader2 className="h-4 w-4 animate-spin" /> Analysing{mode === 'photo' ? ' photos' : mode === 'url' ? ' the listing' : ' the description'} and searching the catalog…205 </div>206 </Card>207 ) : null}208 {result ? <ScanResults r={result} chosen={chosen} onChoose={choose} /> : null}209 </div>210 </div>211 );212}213214function ScanResults({ r, chosen, onChoose }: { r: ScanResponse; chosen: string | null; onChoose: (id: string | null) => void }) {215 const g = r.guess;216 const best = r.best;217 return (218 <>219 {g ? (220 <Card>221 <CardHeader222 title="Identification"223 subtitle={r.model ? `Model ${r.model} · ${(r.durationMs / 1000).toFixed(1)} s` : undefined}224 action={<Badge tone={(g.confidence ?? 0) >= 0.75 ? 'gain' : (g.confidence ?? 0) >= 0.5 ? 'alert' : 'loss'}>Confidence {confidenceLabel(g.confidence)} · {Math.round((g.confidence ?? 0) * 100)}%</Badge>}225 />226 <div className="grid gap-x-6 gap-y-2 p-4 text-sm sm:grid-cols-2">227 {(228 [229 ['Category', g.categorySlug],230 ['Name', g.name],231 ['Brand / franchise', [g.brand, g.franchise].filter(Boolean).join(' · ')],232 ['Set', g.set],233 ['Number / reference', g.number],234 ['Year', g.year],235 ['Variant', g.variant],236 ['Language', g.language],237 ['Grade', g.grader ? `${String(g.grader).toUpperCase()} ${g.grade ?? ''}`.trim() : null],238 ['Certification #', g.certificationNumber],239 ['Likely grade range', g.likelyGradeRange],240 ['Condition notes', g.conditionNotes],241 ] as Array<[string, unknown]>242 ).map(([k, v]) => (243 <div key={k} className="flex justify-between gap-3 border-b border-border py-1 last:border-0">244 <span className="text-xs text-subtle">{k}</span>245 <span className="text-right">{v === null || v === undefined || v === '' ? <span className="text-subtle">—</span> : String(v)}</span>246 </div>247 ))}248 </div>249 {g.rationale ? <p className="border-t border-border px-4 py-3 text-xs text-muted">{g.rationale}</p> : null}250 {g.warnings?.length ? (251 <ul className="border-t border-border px-4 py-3 text-xs text-alert">252 {g.warnings.map((w) => (253 <li key={w}>⚠ {w}</li>254 ))}255 </ul>256 ) : null}257 </Card>258 ) : null}259260 {r.listing ? (261 <Card>262 <CardHeader title="Parsed listing" subtitle={r.listing.sourceId} action={<a href={r.listing.sourceUrl} target="_blank" rel="noreferrer noopener" className="text-muted hover:text-fg">Open source ↗</a>} />263 <div className="grid gap-4 p-4 sm:grid-cols-3">264 <Stat label="Asking price" value={r.listing.price !== null ? fmtMoney(r.listing.price, r.listing.currency ?? 'USD') : '—'} sub={r.listing.rawTitle} />265 <Stat label="vs RareIndex Valuation" value={r.listing.discountToRiv !== null ? <span className={r.listing.discountToRiv < 0 ? 'text-gain' : 'text-loss'}>{fmtPct(r.listing.discountToRiv, 1)}</span> : '—'} sub={{ below_fair_value: 'Below fair value range', in_range: 'Within fair value range', above_fair_value: 'Above fair value range', unknown: 'No valuation to compare' }[r.listing.verdict]} />266 <Stat label="Grade on listing" value={r.listing.grader ? `${r.listing.grader.toUpperCase()} ${r.listing.grade ?? ''}` : 'Raw / unknown'} />267 </div>268 </Card>269 ) : null}270271 {best ? (272 <Card>273 <CardHeader title="Best match" subtitle={`Match score ${Math.round(best.score * 100)}%`} action={<Link href={`/asset/${best.slug}`} className="font-medium text-fg hover:underline">Open asset →</Link>} />274 <div className="flex gap-4 p-4">275 {best.heroImageUrl ? (276 // eslint-disable-next-line @next/next/no-img-element277 <img src={best.heroImageUrl} alt="" className="h-28 w-20 shrink-0 rounded-sm object-cover" />278 ) : null}279 <div className="min-w-0 flex-1">280 <p className="text-sm font-semibold">{best.title}</p>281 <p className="text-xs text-muted">{[best.setName, best.number, best.year].filter(Boolean).join(' · ')}</p>282 <div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">283 <Stat label="RareIndex Valuation" value={best.rivUsd ? fmtMoney(best.rivUsd) : '—'} sub={best.rivUsd ? `${fmtMoney(best.rivLowUsd)} – ${fmtMoney(best.rivHighUsd)} · ${confidenceLabel(best.rivConfidence)} · ${best.rivSampleSize} sales` : 'Insufficient evidence'} />284 <Stat label="Latest sale" value={best.latestSaleUsd ? fmtMoney(best.latestSaleUsd) : '—'} sub={best.latestSaleAt ? fmtDate(best.latestSaleAt) : undefined} />285 <Stat label="Sales tracked" value={best.salesCount} />286 <Stat label="Active listings" value={best.activeListings} sub={best.minAskUsd ? `from ${fmtMoney(best.minAskUsd)}` : undefined} />287 </div>288 </div>289 </div>290 {best.context.sales.length ? (291 <div className="border-t border-border">292 <p className="px-4 pt-3 text-[11px] font-semibold uppercase tracking-wider text-subtle">Recent comparable sales</p>293 <ul className="divide-y divide-border px-4 pb-2 text-xs">294 {best.context.sales.map((s) => (295 <li key={String(s.id)} className="flex items-center justify-between gap-3 py-1.5">296 <span className="truncate text-muted">{fmtDate(String(s.saleDate))} · {String(s.sourceId)} {s.grader ? `· ${String(s.grader).toUpperCase()} ${String(s.grade ?? '')}` : ''}</span>297 <a href={String(s.sourceUrl)} target="_blank" rel="noreferrer noopener" className="num font-medium hover:underline">{fmtMoney(Number(s.priceUsd))}</a>298 </li>299 ))}300 </ul>301 </div>302 ) : null}303 {best.context.listings.length ? (304 <div className="border-t border-border">305 <p className="px-4 pt-3 text-[11px] font-semibold uppercase tracking-wider text-subtle">Live listings (asks, not sales)</p>306 <ul className="divide-y divide-border px-4 pb-2 text-xs">307 {best.context.listings.map((l) => (308 <li key={String(l.id)} className="flex items-center justify-between gap-3 py-1.5">309 <span className="truncate text-muted">{String(l.sourceId)} · {String(l.rawTitle)}</span>310 <a href={String(l.sourceUrl)} target="_blank" rel="noreferrer noopener" className="num font-medium hover:underline">{fmtMoney(Number(l.priceUsd))}</a>311 </li>312 ))}313 </ul>314 </div>315 ) : null}316 <div className="flex items-center justify-between border-t border-border px-4 py-2 text-xs">317 <span className="text-subtle">Is this the right item?</span>318 <div className="flex gap-2">319 <button type="button" onClick={() => onChoose(best.assetId)} className={cn('rounded-md border px-2 py-1', chosen === best.assetId ? 'border-gain bg-gain-bg text-gain' : 'border-border hover:bg-inset')}>Yes</button>320 <button type="button" onClick={() => onChoose(null)} className={cn('rounded-md border px-2 py-1', chosen === null && chosen !== undefined && r.candidates.length ? 'border-border hover:bg-inset' : 'border-border hover:bg-inset')}>Not this</button>321 </div>322 </div>323 </Card>324 ) : null}325326 {r.candidates.length > (best ? 1 : 0) ? (327 <Card>328 <CardHeader title="Other candidates" subtitle="Pick the correct one to improve future identifications" />329 <ul className="divide-y divide-border">330 {r.candidates.filter((c) => c.assetId !== best?.assetId).map((c) => (331 <li key={c.assetId} className="flex items-center gap-3 px-4 py-2 text-sm">332 <Link href={`/asset/${c.slug}`} className="min-w-0 flex-1 truncate hover:underline">{c.title}</Link>333 <span className="num text-xs text-muted">{c.rivUsd ? fmtMoney(c.rivUsd) : '—'}</span>334 <span className="num text-xs text-subtle">{Math.round(c.score * 100)}%</span>335 <button type="button" onClick={() => onChoose(c.assetId)} className={cn('rounded-md border px-2 py-0.5 text-xs', chosen === c.assetId ? 'border-gain bg-gain-bg text-gain' : 'border-border hover:bg-inset')}>This one</button>336 </li>337 ))}338 </ul>339 </Card>340 ) : null}341342 <ul className="space-y-1 text-[11px] leading-relaxed text-subtle">343 {r.notes.map((n) => (344 <li key={n}>· {n}</li>345 ))}346 <li>· Scans today: {r.quota.used}/{r.quota.limit}.</li>347 </ul>348 </>349 );350}351