| import React, { useState, useRef } from 'react'; |
| import { PipelineTrace, useToast } from '../components/ui/index'; |
|
|
| export default function ScanPage() { |
| const [imageData, setImageData] = useState<ArrayBuffer | null>(null); |
| const [preview, setPreview] = useState<string>(''); |
| const [result, setResult] = useState<any>(null); |
| const [loading, setLoading] = useState(false); |
| const fileRef = useRef<HTMLInputElement>(null); |
| const { addToast } = useToast(); |
|
|
| const handleFile = async (file: File) => { |
| const buffer = await file.arrayBuffer(); |
| setImageData(buffer); |
| setPreview(URL.createObjectURL(file)); |
| setResult(null); |
| }; |
|
|
| const handleDrop = (e: React.DragEvent) => { |
| e.preventDefault(); |
| const file = e.dataTransfer.files[0]; |
| if (file && file.type.startsWith('image/')) handleFile(file); |
| }; |
|
|
| const process = async () => { |
| if (!imageData) return; |
| setLoading(true); |
| try { |
| if (window.solvox) { |
| const r = await window.solvox.ai.ocrPayment(imageData); |
| if (r.success) { |
| setResult(r); |
| addToast({ type: 'success', title: 'Document scanned', message: 'Payment data extracted via OCR + LLM' }); |
| } else { |
| addToast({ type: 'error', title: 'Scan failed', message: r.error }); |
| } |
| } else { |
| setResult({ |
| rawText: '[Dev mode] OCR requires @qvac/ocr-onnx model', |
| extractedData: { amount: 25.50, token: 'USDT', recipient: null, memo: 'Invoice #1234', confidence: 0.85 }, |
| pipelineSteps: [ |
| { module: '@qvac/ocr-onnx', operation: 'Image → Text', input: 'uploaded image', output: 'Invoice text…', durationMs: 340 }, |
| { module: '@qvac/llm-llamacpp', operation: 'Extract payment data', input: 'OCR text', output: '{"amount": 25.50}', durationMs: 210 }, |
| ], |
| }); |
| } |
| } catch (e: any) { addToast({ type: 'error', title: 'Error', message: e.message }); } |
| setLoading(false); |
| }; |
|
|
| const reset = () => { setImageData(null); setPreview(''); setResult(null); }; |
|
|
| return ( |
| <div className="max-w-2xl mx-auto px-8 py-section"> |
| <div className="mb-8"> |
| <h2 className="display-text text-title-lg text-ink">Scan & Pay</h2> |
| <p className="text-body-sm text-body mt-1">Upload an invoice, QR code, or screenshot. QVAC extracts payment data locally.</p> |
| </div> |
| |
| {/* Upload Area */} |
| {!preview && ( |
| <div className="card page-enter" style={{ padding: 0 }}> |
| <div |
| onDrop={handleDrop} |
| onDragOver={e => e.preventDefault()} |
| onClick={() => fileRef.current?.click()} |
| className="flex flex-col items-center justify-center py-16 px-8 cursor-pointer hover:bg-surface-soft transition-colors rounded-xl" |
| > |
| <div className="w-16 h-16 rounded-full bg-surface-strong flex items-center justify-center mb-4"> |
| <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#0052ff" strokeWidth="1.5" strokeLinecap="round"> |
| <rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" /> |
| </svg> |
| </div> |
| <div className="text-title-md text-ink mb-1">Drop an image or click to upload</div> |
| <div className="text-body-sm text-muted">Invoice, QR code, screenshot, receipt</div> |
| <div className="flex items-center gap-1.5 mt-4"> |
| <div className="w-1 h-1 rounded-full bg-primary" /> |
| <span className="text-caption text-muted">@qvac/ocr-onnx → @qvac/llm-llamacpp pipeline</span> |
| </div> |
| </div> |
| <input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f); }} /> |
| </div> |
| )} |
| |
| {/* Preview + Process */} |
| {preview && !result && ( |
| <div className="card page-enter space-y-5"> |
| <div className="rounded-xl overflow-hidden bg-surface-soft"> |
| <img src={preview} alt="Uploaded" className="w-full max-h-[300px] object-contain" /> |
| </div> |
| <div className="flex gap-3"> |
| <button onClick={reset} className="btn-secondary flex-1">Cancel</button> |
| <button onClick={process} disabled={loading} className="btn-primary flex-1 disabled:opacity-50"> |
| {loading ? 'Scanning…' : 'Extract payment data'} |
| </button> |
| </div> |
| {loading && ( |
| <div className="text-center"> |
| <div className="inline-flex items-center gap-2 px-4 py-2 bg-surface-soft rounded-pill"> |
| <div className="w-2 h-2 rounded-full bg-primary animate-pulse" /> |
| <span className="text-body-sm text-muted">Running OCR → LLM pipeline locally…</span> |
| </div> |
| </div> |
| )} |
| </div> |
| )} |
| |
| {/* Results */} |
| {result && ( |
| <div className="space-y-6 page-enter"> |
| {/* Extracted Data */} |
| <div className="card"> |
| <div className="text-caption-strong text-muted uppercase tracking-wider mb-4">Extracted payment data</div> |
| <div className="space-y-3"> |
| {result.extractedData?.amount && ( |
| <div className="flex justify-between items-center py-2 border-b border-hairline-soft"> |
| <span className="text-body-sm text-muted">Amount</span> |
| <span className="number-mono text-number-display text-ink">{result.extractedData.amount} {result.extractedData.token || '—'}</span> |
| </div> |
| )} |
| {result.extractedData?.recipient && ( |
| <div className="flex justify-between items-center py-2 border-b border-hairline-soft"> |
| <span className="text-body-sm text-muted">Recipient</span> |
| <span className="font-mono text-caption text-ink">{result.extractedData.recipient}</span> |
| </div> |
| )} |
| {result.extractedData?.memo && ( |
| <div className="flex justify-between items-center py-2 border-b border-hairline-soft"> |
| <span className="text-body-sm text-muted">Memo</span> |
| <span className="text-body-sm text-ink">{result.extractedData.memo}</span> |
| </div> |
| )} |
| <div className="flex justify-between items-center py-2"> |
| <span className="text-body-sm text-muted">Confidence</span> |
| <span className={`badge-pill text-[10px] ${(result.extractedData?.confidence || 0) > 0.7 ? 'badge-pill-green' : 'badge-pill-red'}`}> |
| {((result.extractedData?.confidence || 0) * 100).toFixed(0)}% |
| </span> |
| </div> |
| </div> |
| |
| {result.extractedData?.amount && ( |
| <button className="btn-primary w-full mt-4"> |
| Send {result.extractedData.amount} {result.extractedData.token || 'tokens'} → |
| </button> |
| )} |
| </div> |
| |
| {/* Raw OCR Text */} |
| <div className="card"> |
| <div className="text-caption-strong text-muted uppercase tracking-wider mb-2">Raw OCR output</div> |
| <div className="bg-surface-soft rounded-lg p-3 text-caption font-mono text-muted whitespace-pre-wrap max-h-[200px] overflow-y-auto"> |
| {result.rawText} |
| </div> |
| </div> |
| |
| {/* Pipeline Trace */} |
| {result.pipelineSteps && <PipelineTrace steps={result.pipelineSteps} />} |
| |
| {/* Preview */} |
| <div className="rounded-xl overflow-hidden bg-surface-soft"> |
| <img src={preview} alt="Scanned" className="w-full max-h-[200px] object-contain opacity-60" /> |
| </div> |
| |
| <button onClick={reset} className="btn-secondary w-full">Scan another document</button> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|