'use client'; import Link from 'next/link'; import { useCallback, useRef, useState } from 'react'; import { Camera, ImagePlus, Link2, Loader2, Type, X } from 'lucide-react'; import { cn, fmtMoney, fmtPct, fmtDate, confidenceLabel } from '@/lib/format'; import { Badge, Card, CardHeader, EmptyState, Stat } from '@/components/ui/primitives'; type Mode = 'photo' | 'url' | 'text'; interface Candidate { assetId: string; slug: string; title: string; categorySlug: string; heroImageUrl: string | null; year: number | null; setName: string | null; number: string | null; score: number; rivUsd: number | null; rivLowUsd: number | null; rivHighUsd: number | null; rivConfidence: number | null; rivSampleSize: number; latestSaleUsd: number | null; latestSaleAt: string | null; salesCount: number; activeListings: number; minAskUsd: number | null; } interface ScanResponse { sessionId: string; mode: Mode; guess: Record & { confidence?: number; rationale?: string; warnings?: string[]; likelyGradeRange?: string | null; conditionNotes?: string | null } | null; guessConfidence: number | null; candidates: Candidate[]; best: (Candidate & { context: { sales: Array>; listings: Array>; variants: Array> } }) | null; 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; model: string | null; usdEst: number; durationMs: number; notes: string[]; quota: { used: number; limit: number }; error?: string; } async function downscale(file: File, max = 1600, quality = 0.86): Promise<{ data: string; mediaType: 'image/jpeg'; thumb: string }> { const bitmap = await createImageBitmap(file); const scale = Math.min(1, max / Math.max(bitmap.width, bitmap.height)); const w = Math.round(bitmap.width * scale); const h = Math.round(bitmap.height * scale); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d')!; ctx.drawImage(bitmap, 0, 0, w, h); const dataUrl = canvas.toDataURL('image/jpeg', quality); const tscale = Math.min(1, 240 / Math.max(w, h)); const tc = document.createElement('canvas'); tc.width = Math.round(w * tscale); tc.height = Math.round(h * tscale); tc.getContext('2d')!.drawImage(canvas, 0, 0, tc.width, tc.height); return { data: dataUrl.split(',')[1]!, mediaType: 'image/jpeg', thumb: tc.toDataURL('image/jpeg', 0.7) }; } export function ScannerClient({ aiReady }: { aiReady: boolean }) { const [mode, setMode] = useState('photo'); const [images, setImages] = useState>([]); const [url, setUrl] = useState(''); const [text, setText] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); const [chosen, setChosen] = useState(null); const fileRef = useRef(null); const camRef = useRef(null); const addFiles = useCallback(async (files: FileList | null) => { if (!files) return; const next = [...images]; for (const f of Array.from(files).slice(0, 5 - next.length)) { if (!f.type.startsWith('image/')) continue; const d = await downscale(f); next.push({ ...d, name: f.name }); } setImages(next.slice(0, 5)); }, [images]); async function submit() { setBusy(true); setError(null); setResult(null); setChosen(null); try { const res = await fetch('/api/scanner', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode, images: mode === 'photo' ? images.map((i) => ({ data: i.data, mediaType: i.mediaType })) : undefined, thumbnails: mode === 'photo' ? images.slice(0, 3).map((i) => i.thumb) : undefined, url: mode === 'url' ? url.trim() : undefined, text: mode === 'text' ? text.trim() : undefined, }), }); const json = (await res.json()) as ScanResponse; if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`); setResult(json); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } async function choose(assetId: string | null) { if (!result) return; setChosen(assetId); await fetch(`/api/scanner/${result.sessionId}/choose`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assetId }) }).catch(() => {}); } const canSubmit = !busy && aiReady !== false && ((mode === 'photo' && images.length > 0) || (mode === 'url' && /^https?:\/\//.test(url.trim())) || (mode === 'text' && text.trim().length > 3)); return (
{( [ ['photo', 'Photos', Camera], ['url', 'URL', Link2], ['text', 'Describe', Type], ] as const ).map(([m, label, Icon]) => ( ))}
{!aiReady ?

AI provider not configured on this server. URL parsing still works; identification is disabled.

: null} {mode === 'photo' ? ( <>
e.preventDefault()} onDrop={(e) => { e.preventDefault(); void addFiles(e.dataTransfer.files); }} >

Drop up to 5 photos here: Up to 5 photos: front, back, label, seal, serial.

void addFiles(e.target.files)} /> void addFiles(e.target.files)} />
{images.length ? (
    {images.map((img, i) => (
  • {/* eslint-disable-next-line @next/next/no-img-element */} {img.name}
  • ))}
) : null} ) : mode === 'url' ? ( ) : (