solvox / src /renderer /pages /ScanPage.tsx
muthuk1's picture
🚀 Final: +ContactsPage +ScanPage +Sparklines, types.ts synced, TS 0 errors, Coinbase design, complete README
9ff7e0c verified
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>
);
}