File size: 7,963 Bytes
9ff7e0c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | 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>
);
}
|