SPB Git

spb/rareindex

Public
19.3 KB · 351 lines tsx
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
Files · ad20030daddfa217412dc2fa1b9beb342d281233
Menu

Theme