'use client'; import { useRef, useState, useEffect } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { Camera, Loader2, RotateCcw, AlertTriangle, CheckCircle2, Save } from 'lucide-react'; const LOGO_URL = 'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { useUIStore } from '@/stores/ui-store'; import { apiFetch } from '@/lib/api/client'; import { cn } from '@/lib/utils'; import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants'; import { runTesseract } from '@/lib/ocr/tesseract-client'; // Lazy-loaded compression — the worker bundle isn't on the critical path, // and most users won't reach this code without first granting camera/file // access, by which point the module is already paged in. async function compressReceiptIfHeavy(file: File): Promise { // Only compress raster images > ~1 MB. PDFs, SVGs, and small files pass // through untouched. Magic-byte check via mime type — the caller is the // file picker which trusts the picker output already. if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') return file; if (file.size < 1 * 1024 * 1024) return file; const { default: imageCompression } = await import('browser-image-compression'); try { const compressed = await imageCompression(file, { maxSizeMB: 0.5, // ~500 KB target — plenty of resolution for OCR maxWidthOrHeight: 2000, // tesseract.js's sweet spot for receipt text useWebWorker: true, // off the main thread; UI stays responsive // Auto-rotate to EXIF orientation, strip metadata. Phones often // store the rotation as EXIF rather than rotating pixels; without // this the receipt comes out sideways and OCR confidence tanks. preserveExif: false, fileType: file.type === 'image/png' ? 'image/jpeg' : file.type, initialQuality: 0.85, }); // Browser-image-compression typings always say `File`, but in some // runtimes the value comes through as a plain Blob. Belt-and-suspenders: // wrap in a File so downstream FormData uses the original filename. const blob = compressed as unknown as Blob; if (typeof File !== 'undefined' && blob instanceof File) return blob; return new File([blob], file.name, { type: blob.type }); } catch { // Fall back to the original — we don't want a corner-case compression // bug to block the user from saving an expense. return file; } } // ─── Types ──────────────────────────────────────────────────────────────────── interface ParsedReceipt { establishment: string | null; date: string | null; amount: number | null; currency: string | null; lineItems: Array<{ description: string; amount: number }>; confidence: number; } type ScanState = | { kind: 'idle' } | { kind: 'processing'; engine: 'tesseract' | 'ai' } | { kind: 'verify'; parsed: ParsedReceipt; source: 'ai' | 'tesseract' | 'manual'; reason?: string; providerError?: string; } | { kind: 'saving' } | { kind: 'saved'; expenseId: string } | { kind: 'error'; message: string }; interface ScanResp { data: { parsed: ParsedReceipt; source: 'ai' | 'manual'; reason?: string; provider?: string; model?: string; providerError?: string; }; } // ─── Form ───────────────────────────────────────────────────────────────────── interface VerifyFormProps { parsed: ParsedReceipt; imagePreview: string; imageFile: File; source: 'ai' | 'tesseract' | 'manual'; reason?: string; providerError?: string; onSubmit: (input: { establishmentName: string; amount: string; currency: string; expenseDate: string; category: string; paymentMethod: string; description: string; file: File; }) => void; onRetake: () => void; saving: boolean; } const TODAY = () => new Date().toISOString().slice(0, 10); function VerifyForm({ parsed, imagePreview, imageFile, source, reason: _reason, providerError, onSubmit, onRetake, saving, }: VerifyFormProps) { const [establishmentName, setEstablishment] = useState(parsed.establishment ?? ''); const [amount, setAmount] = useState(parsed.amount != null ? String(parsed.amount) : ''); const [currency, setCurrency] = useState((parsed.currency ?? 'USD').toUpperCase()); const [expenseDate, setExpenseDate] = useState(parsed.date ?? TODAY()); const [category, setCategory] = useState('other'); const [paymentMethod, setPaymentMethod] = useState('credit_card'); const [description, setDescription] = useState(''); const lowConfidence = source !== 'manual' && parsed.confidence < 0.6; const noOcr = source === 'manual'; const engineLabel = source === 'ai' ? 'AI' : source === 'tesseract' ? 'on-device OCR' : 'manual'; const banner = noOcr ? (

Manual entry mode

{providerError ? `We couldn't read the receipt automatically: ${providerError}.` : "We couldn't read the receipt automatically."}{' '} Fill in the details below to save the expense with the photo attached.

) : lowConfidence ? (

Low-confidence read - please double-check the fields

{engineLabel} returned {Math.round(parsed.confidence * 100)}% confidence.

) : (

Receipt parsed - confirm the fields and save

{engineLabel} · {Math.round(parsed.confidence * 100)}% confidence.

); return (
{ e.preventDefault(); onSubmit({ establishmentName, amount, currency, expenseDate, category, paymentMethod, description, file: imageFile, }); }} > {banner}
{/* eslint-disable-next-line @next/next/no-img-element */} Receipt preview
setEstablishment(e.target.value)} placeholder="e.g. Marina Fuel Station" />
setAmount(e.target.value)} required />
setCurrency(e.target.value.toUpperCase())} maxLength={3} required />
setExpenseDate(e.target.value)} required />