TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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 <button type="button" className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-md bg-accent px-4 text-sm font-semibold text-accent-fg active:opacity-90 md:hidden" onClick={() => camRef.current?.click()}>154 <Camera className="h-5 w-5" /> Take a photo155 </button>156 <p className="text-xs text-muted"><span className="hidden md:inline">Drop up to 5 photos here: </span><span className="md:hidden">Up to 5 photos: </span>front, back, label, seal, serial.</p>157 <button type="button" className="inline-flex h-10 items-center gap-1.5 rounded-md border border-border bg-elevated px-3 text-xs font-medium hover:bg-inset md:h-9" onClick={() => fileRef.current?.click()}>158 <ImagePlus className="h-3.5 w-3.5" /> Upload from library159 </button>160 <input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void addFiles(e.target.files)} />161 <input ref={camRef} type="file" accept="image/*" capture="environment" hidden onChange={(e) => void addFiles(e.target.files)} />162 </div>163 {images.length ? (164 <ul className="grid grid-cols-5 gap-2">165 {images.map((img, i) => (166 <li key={i} className="relative aspect-square overflow-hidden rounded-sm border border-border bg-inset">167 {/* eslint-disable-next-line @next/next/no-img-element */}168 <img src={img.thumb} alt={img.name} className="h-full w-full object-cover" />169 <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))}>170 <X className="h-3 w-3" />171 </button>172 </li>173 ))}174 </ul>175 ) : null}176 </>177 ) : mode === 'url' ? (178 <label className="block text-xs text-muted">179 Marketplace or auction URL180 <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" />181 <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>182 </label>183 ) : (184 <label className="block text-xs text-muted">185 Describe the item186 <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" />187 </label>188 )}189 <button type="button" disabled={!canSubmit} onClick={() => void submit()} className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-md bg-accent px-3 text-sm font-semibold text-accent-fg disabled:opacity-50">190 {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null} {busy ? 'Identifying…' : 'Identify & price'}191 </button>192 {error ? <p className="rounded-md bg-loss-bg px-3 py-2 text-xs text-loss">{error}</p> : null}193 <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>194 </div>195 </Card>196197 <div className="min-w-0 space-y-4">198 {!result && !busy ? <EmptyState title="Results appear here" description="Identification, the closest catalog matches, RareIndex Valuation, comparable sales and live listings." /> : null}199 {busy ? (200 <Card className="p-6">201 <div className="flex items-center gap-3 text-sm text-muted">202 <Loader2 className="h-4 w-4 animate-spin" /> Analysing{mode === 'photo' ? ' photos' : mode === 'url' ? ' the listing' : ' the description'} and searching the catalog…203 </div>204 </Card>205 ) : null}206 {result ? <ScanResults r={result} chosen={chosen} onChoose={choose} /> : null}207 </div>208 </div>209 );210}211212function ScanResults({ r, chosen, onChoose }: { r: ScanResponse; chosen: string | null; onChoose: (id: string | null) => void }) {213 const g = r.guess;214 const best = r.best;215 return (216 <>217 {g ? (218 <Card>219 <CardHeader220 title="Identification"221 subtitle={r.model ? `Model ${r.model} · ${(r.durationMs / 1000).toFixed(1)} s` : undefined}222 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>}223 />224 <div className="grid gap-x-6 gap-y-2 p-4 text-sm sm:grid-cols-2">225 {(226 [227 ['Category', g.categorySlug],228 ['Name', g.name],229 ['Brand / franchise', [g.brand, g.franchise].filter(Boolean).join(' · ')],230 ['Set', g.set],231 ['Number / reference', g.number],232 ['Year', g.year],233 ['Variant', g.variant],234 ['Language', g.language],235 ['Grade', g.grader ? `${String(g.grader).toUpperCase()} ${g.grade ?? ''}`.trim() : null],236 ['Certification #', g.certificationNumber],237 ['Likely grade range', g.likelyGradeRange],238 ['Condition notes', g.conditionNotes],239 ] as Array<[string, unknown]>240 ).map(([k, v]) => (241 <div key={k} className="flex justify-between gap-3 border-b border-border py-1 last:border-0">242 <span className="text-xs text-subtle">{k}</span>243 <span className="text-right">{v === null || v === undefined || v === '' ? <span className="text-subtle">—</span> : String(v)}</span>244 </div>245 ))}246 </div>247 {g.rationale ? <p className="border-t border-border px-4 py-3 text-xs text-muted">{g.rationale}</p> : null}248 {g.warnings?.length ? (249 <ul className="border-t border-border px-4 py-3 text-xs text-alert">250 {g.warnings.map((w) => (251 <li key={w}>⚠ {w}</li>252 ))}253 </ul>254 ) : null}255 </Card>256 ) : null}257258 {r.listing ? (259 <Card>260 <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>} />261 <div className="grid gap-4 p-4 sm:grid-cols-3">262 <Stat label="Asking price" value={r.listing.price !== null ? fmtMoney(r.listing.price, r.listing.currency ?? 'USD') : '—'} sub={r.listing.rawTitle} />263 <Stat label="vs RareIndex Valuation" value={r.listing.verdict === 'anomaly' ? <span className="rounded-sm bg-alert-bg px-1.5 py-0.5 text-[11px] font-medium uppercase tracking-wide text-alert">Data/identity anomaly</span> : r.listing.discountToRiv !== null ? <span className={r.listing.discountToRiv <= -0.1 ? 'text-gain' : r.listing.discountToRiv >= 0.1 ? 'text-loss' : 'text-flat'}>{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', anomaly: 'Implausible against the valuation — review the variant, lot, currency and identity', unknown: 'No valuation to compare' }[r.listing.verdict] ?? 'No valuation to compare'} />264 <Stat label="Grade on listing" value={r.listing.grader ? `${r.listing.grader.toUpperCase()} ${r.listing.grade ?? ''}` : 'Raw / unknown'} />265 </div>266 </Card>267 ) : null}268269 {best ? (270 <Card>271 <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>} />272 <div className="flex gap-4 p-4">273 {best.heroImageUrl ? (274 // eslint-disable-next-line @next/next/no-img-element275 <img src={best.heroImageUrl} alt="" className="h-28 w-20 shrink-0 rounded-sm object-cover" />276 ) : null}277 <div className="min-w-0 flex-1">278 <p className="text-sm font-semibold">{best.title}</p>279 <p className="text-xs text-muted">{[best.setName, best.number, best.year].filter(Boolean).join(' · ')}</p>280 <div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">281 <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'} />282 <Stat label="Latest sale" value={best.latestSaleUsd ? fmtMoney(best.latestSaleUsd) : '—'} sub={best.latestSaleAt ? fmtDate(best.latestSaleAt) : undefined} />283 <Stat label="Sales tracked" value={best.salesCount} />284 <Stat label="Active listings" value={best.activeListings} sub={best.minAskUsd ? `from ${fmtMoney(best.minAskUsd)}` : undefined} />285 </div>286 </div>287 </div>288 {best.context.sales.length ? (289 <div className="border-t border-border">290 <p className="px-4 pt-3 text-[11px] font-semibold uppercase tracking-wider text-subtle">Recent comparable sales</p>291 <ul className="divide-y divide-border px-4 pb-2 text-xs">292 {best.context.sales.map((s) => (293 <li key={String(s.id)} className="flex items-center justify-between gap-3 py-1.5">294 <span className="truncate text-muted">{fmtDate(String(s.saleDate))} · {String(s.sourceId)} {s.grader ? `· ${String(s.grader).toUpperCase()} ${String(s.grade ?? '')}` : ''}</span>295 <a href={String(s.sourceUrl)} target="_blank" rel="noreferrer noopener" className="num font-medium hover:underline">{fmtMoney(Number(s.priceUsd))}</a>296 </li>297 ))}298 </ul>299 </div>300 ) : null}301 {best.context.listings.length ? (302 <div className="border-t border-border">303 <p className="px-4 pt-3 text-[11px] font-semibold uppercase tracking-wider text-subtle">Live listings (asks, not sales)</p>304 <ul className="divide-y divide-border px-4 pb-2 text-xs">305 {best.context.listings.map((l) => (306 <li key={String(l.id)} className="flex items-center justify-between gap-3 py-1.5">307 <span className="truncate text-muted">{String(l.sourceId)} · {String(l.rawTitle)}</span>308 <a href={String(l.sourceUrl)} target="_blank" rel="noreferrer noopener" className="num font-medium hover:underline">{fmtMoney(Number(l.priceUsd))}</a>309 </li>310 ))}311 </ul>312 </div>313 ) : null}314 <div className="flex items-center justify-between border-t border-border px-4 py-2 text-xs">315 <span className="text-subtle">Is this the right item?</span>316 <div className="flex gap-2">317 <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>318 <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>319 </div>320 </div>321 </Card>322 ) : null}323324 {r.candidates.length > (best ? 1 : 0) ? (325 <Card>326 <CardHeader title="Other candidates" subtitle="Pick the correct one to improve future identifications" />327 <ul className="divide-y divide-border">328 {r.candidates.filter((c) => c.assetId !== best?.assetId).map((c) => (329 <li key={c.assetId} className="flex items-center gap-3 px-4 py-2 text-sm">330 <Link href={`/asset/${c.slug}`} className="min-w-0 flex-1 truncate hover:underline">{c.title}</Link>331 <span className="num text-xs text-muted">{c.rivUsd ? fmtMoney(c.rivUsd) : '—'}</span>332 <span className="num text-xs text-subtle">{Math.round(c.score * 100)}%</span>333 <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>334 </li>335 ))}336 </ul>337 </Card>338 ) : null}339340 <ul className="space-y-1 text-[11px] leading-relaxed text-subtle">341 {r.notes.map((n) => (342 <li key={n}>· {n}</li>343 ))}344 <li>· Scans today: {r.quota.used}/{r.quota.limit}.</li>345 </ul>346 </>347 );348}349