SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
19.7 KB · 349 lines tsx
Raw Blame History
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