'use client'; import type { DocumentDto, ScanResultDto } from '@impuestos/contracts'; import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Flashlight, Upload } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Button, buttonClasses } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { Link, useRouter } from '@/i18n/navigation'; import { useT } from '@/i18n/t'; import { api } from '@/lib/api'; import { enqueueCapture, listQueue, subscribeToQueue } from '@/lib/offline-queue'; import { captureFrame, decodeQr, decodeQrFromVideo } from '@/lib/qr'; import { cn } from '@/lib/utils'; /** How long to look for a QR before offering the plain photo path (FLOWS.md B1). */ const NO_QR_HINT_AFTER_MS = 4000; const DECODE_INTERVAL_MS = 250; type Outcome = | { kind: 'document'; document: DocumentDto } | { kind: 'needs_manual'; fileId: string }; export function ScanScreen({ locale }: { locale: string }) { const t = useT(); const router = useRouter(); const queryClient = useQueryClient(); const video = useRef(null); const stream = useRef(null); const busy = useRef(false); const [cameraFailed, setCameraFailed] = useState(false); const [showNoQrHint, setShowNoQrHint] = useState(false); const [torchOn, setTorchOn] = useState(false); const [outcome, setOutcome] = useState(null); // Whether anything of this reader's is still waiting to be sent. It follows the queue // rather than being set once, so the notice goes away when the capture actually lands. const [queued, setQueued] = useState(false); useEffect(() => { const check = () => void listQueue().then((items) => setQueued(items.length > 0)); const unsubscribe = subscribeToQueue(check); void Promise.resolve().then(check); return unsubscribe; }, []); const upload = useMutation({ mutationFn: async (input: { file: File; qrPayload: string | null }) => { const result: ScanResultDto = await api.scanDocument(input.file, input.qrPayload); return result; }, onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: ['documents'] }); if ('needsManual' in result) { setOutcome({ kind: 'needs_manual', fileId: result.fileId }); return; } setOutcome({ kind: 'document', document: result }); }, }); const submit = useCallback( (file: File, qrPayload: string | null) => { if (busy.current) return; /** * Offline, the capture is kept rather than lost (FLOWS.md B2). The chip in the shell * sends it the moment there is a connection, so this screen can say so and move on. */ if (!navigator.onLine) { void enqueueCapture(file, qrPayload); return; } busy.current = true; upload.mutate({ file, qrPayload }, { onSettled: () => (busy.current = false) }); }, [upload], ); // Live capture: the camera is only opened here, and always released on unmount. useEffect(() => { if (outcome) return; let cancelled = false; async function open() { try { const media = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false, }); if (cancelled) { media.getTracks().forEach((track) => track.stop()); return; } stream.current = media; if (video.current) { video.current.srcObject = media; await video.current.play().catch(() => undefined); } } catch { if (!cancelled) setCameraFailed(true); } } void open(); return () => { cancelled = true; stream.current?.getTracks().forEach((track) => track.stop()); stream.current = null; }; }, [outcome]); // A QR hit auto-captures: there is nothing for the user to decide once we have it. useEffect(() => { if (cameraFailed || outcome) return; const hintTimer = setTimeout(() => setShowNoQrHint(true), NO_QR_HINT_AFTER_MS); const timer = setInterval(() => { void (async () => { if (!video.current || busy.current) return; const payload = await decodeQrFromVideo(video.current); if (!payload || !video.current) return; const frame = await captureFrame(video.current); if (frame) { navigator.vibrate?.(40); submit(frame, payload); } })(); }, DECODE_INTERVAL_MS); return () => { clearTimeout(hintTimer); clearInterval(timer); }; }, [cameraFailed, outcome, submit]); async function toggleTorch() { const track = stream.current?.getVideoTracks()[0]; if (!track) return; try { const next = !torchOn; // `torch` is a real constraint on mobile but is not in the DOM lib's type yet. await track.applyConstraints({ advanced: [{ torch: next }] } as unknown as MediaTrackConstraints); setTorchOn(next); } catch { // Most desktop cameras have no torch. Nothing to say about it. } } async function onPick(file: File) { // Decoding before upload keeps the QR path working for a photo from the gallery. submit(file, await decodeQr(file)); } if (outcome?.kind === 'document') { return ( setOutcome(null)} /> ); } if (outcome?.kind === 'needs_manual') { router.push(`/comprobantes/nuevo?fileId=${encodeURIComponent(outcome.fileId)}`); return ( ); } return (

{t('scan.title')}

); } function ScanResult({ document, locale, onAnother, }: { document: DocumentDto; locale: Locale; onAnother: () => void; }) { const t = useT(); const merged = document.merged === true; return (

{merged ? t('scan.duplicate.title') : t('scan.result.title')}

{document.verifiedDnit ? t('scan.verified') : t('scan.registered')}
{merged ? (

{t('scan.duplicate.body', { date: formatDateLong(locale, document.issueDate) })}

) : null}
{document.classification ? ( ) : null}
{t('scan.goToBandeja')}
); } function Row({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); }