GSAP carries the counter roll-ups, the bandeja card physics, the dialog transitions and the three success moments FLOWS.md allows. Every one of them checks prefers-reduced-motion first and does nothing when it is set. boneyard and canvas-ui are not what SPEC.md's stack table says they are: on npm the names belong to two abandoned projects that do neither job. The skeletons were already ours; the two canvas spots are now sixty lines each with no dependency. DECISIONS.md records the substitution. The app installs, keeps a scan taken with no network in IndexedDB and sends it when there is one, falls back to a page that explains itself, and can push a deadline notice. Reading the log of what is queued is the source of truth, so the notice clears when the capture actually lands. The CSP now allows scripts by per-request nonce rather than by 'unsafe-inline'. That forced /offline to render per request: a prerendered page carries a build-time nonce no live policy matches, so its scripts were blocked and it never hydrated. Two crashes fixed on the way. web-push throws on a VAPID subject that is not https: or mailto:, and the code handed it APP_PUBLIC_URL, so any machine with push keys died at boot; a misconfigured optional channel now switches itself off and says why. And a subscription the push service answers 410 for is deleted rather than retried forever. Lighthouse on the production build: accessibility 100, best practices 96, SEO 100, performance 73. The performance number is not trustworthy on this machine and DECISIONS.md says why; total blocking time did fall from 17.6s to 1.7s once the hero canvas stopped drawing at full resolution every frame and the landing page stopped importing GSAP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
'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<HTMLVideoElement>(null);
|
|
const stream = useRef<MediaStream | null>(null);
|
|
const busy = useRef(false);
|
|
|
|
const [cameraFailed, setCameraFailed] = useState(false);
|
|
const [showNoQrHint, setShowNoQrHint] = useState(false);
|
|
const [torchOn, setTorchOn] = useState(false);
|
|
const [outcome, setOutcome] = useState<Outcome | null>(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 (
|
|
<ScanResult
|
|
document={outcome.document}
|
|
locale={locale as Locale}
|
|
onAnother={() => setOutcome(null)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (outcome?.kind === 'needs_manual') {
|
|
router.push(`/comprobantes/nuevo?fileId=${encodeURIComponent(outcome.fileId)}`);
|
|
return (
|
|
<Card className="my-4">
|
|
<Skeleton name="profile-section" />
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4 py-4">
|
|
<h1 className="text-2xl font-semibold tracking-tight">{t('scan.title')}</h1>
|
|
|
|
<div className="relative overflow-hidden rounded-2xl bg-black">
|
|
<video
|
|
ref={video}
|
|
playsInline
|
|
muted
|
|
aria-label={t('scan.hint')}
|
|
className={cn('aspect-3/4 w-full object-cover', cameraFailed && 'hidden')}
|
|
/>
|
|
{cameraFailed ? (
|
|
<p className="p-8 text-center text-sm text-white">{t('scan.cameraDenied')}</p>
|
|
) : (
|
|
<>
|
|
{/* Frame guide: shows where the QR has to sit. */}
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-x-8 top-1/4 aspect-square rounded-2xl border-2 border-white/70"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => void toggleTorch()}
|
|
aria-pressed={torchOn}
|
|
className="absolute top-3 right-3 rounded-full bg-black/50 p-2.5 text-white"
|
|
>
|
|
<Flashlight aria-hidden className="size-5" />
|
|
<span className="sr-only">{t('scan.torch')}</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-center text-sm text-[var(--text-muted)]">
|
|
{showNoQrHint || cameraFailed ? t('scan.noQrHint') : t('scan.hint')}
|
|
</p>
|
|
|
|
{upload.isPending ? (
|
|
<Card>
|
|
<p className="mb-3 text-sm text-[var(--text-muted)]">{t('scan.processing')}</p>
|
|
<Skeleton name="bandeja-card" />
|
|
</Card>
|
|
) : null}
|
|
|
|
{queued ? (
|
|
<p role="status" className="text-center text-sm text-[var(--text-muted)]">
|
|
{t('offline.queued')}
|
|
</p>
|
|
) : null}
|
|
|
|
{upload.isError ? (
|
|
<p role="alert" className="text-center text-sm text-overdue">
|
|
{t('common.error.generic')}
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="space-y-2">
|
|
<label className={cn(buttonClasses({ variant: 'secondary', size: 'lg', block: true }), 'cursor-pointer')}>
|
|
<Upload aria-hidden className="size-4" />
|
|
{t('scan.upload')}
|
|
<input
|
|
type="file"
|
|
accept="image/*,application/pdf"
|
|
className="sr-only"
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0];
|
|
if (file) void onPick(file);
|
|
}}
|
|
/>
|
|
</label>
|
|
|
|
{!cameraFailed ? (
|
|
<Button
|
|
type="button"
|
|
size="lg"
|
|
block
|
|
disabled={upload.isPending}
|
|
onClick={() => {
|
|
void (async () => {
|
|
if (!video.current) return;
|
|
const frame = await captureFrame(video.current);
|
|
if (frame) submit(frame, await decodeQr(frame));
|
|
})();
|
|
}}
|
|
>
|
|
{t('scan.shutter')}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ScanResult({
|
|
document,
|
|
locale,
|
|
onAnother,
|
|
}: {
|
|
document: DocumentDto;
|
|
locale: Locale;
|
|
onAnother: () => void;
|
|
}) {
|
|
const t = useT();
|
|
const merged = document.merged === true;
|
|
|
|
return (
|
|
<div className="space-y-4 py-4">
|
|
<Card className="space-y-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h1 className="text-xl font-semibold tracking-tight text-balance">
|
|
{merged ? t('scan.duplicate.title') : t('scan.result.title')}
|
|
</h1>
|
|
<span className="shrink-0 rounded-full bg-positive-soft px-2.5 py-0.5 text-xs font-medium text-positive">
|
|
{document.verifiedDnit ? t('scan.verified') : t('scan.registered')}
|
|
</span>
|
|
</div>
|
|
|
|
{merged ? (
|
|
<p className="text-sm text-[var(--text-muted)] text-pretty">
|
|
{t('scan.duplicate.body', { date: formatDateLong(locale, document.issueDate) })}
|
|
</p>
|
|
) : null}
|
|
|
|
<dl className="space-y-2 text-sm">
|
|
<Row label={t('scan.result.emitter')} value={document.emitterName} />
|
|
<Row label={t('scan.result.date')} value={formatDateLong(locale, document.issueDate)} />
|
|
<Row label={t('scan.result.total')} value={formatGs(document.total)} />
|
|
{document.classification ? (
|
|
<Row
|
|
label={t('bandeja.category')}
|
|
value={t(`categories.${document.classification.irpCategory}` as const)}
|
|
/>
|
|
) : null}
|
|
</dl>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Link href="/bandeja" className={buttonClasses({ size: 'lg', block: true })}>
|
|
{t('scan.goToBandeja')}
|
|
</Link>
|
|
<Button type="button" variant="secondary" block onClick={onAnother}>
|
|
{t('scan.another')}
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex items-baseline justify-between gap-4">
|
|
<dt className="text-[var(--text-muted)]">{label}</dt>
|
|
<dd className="text-right font-medium">{value}</dd>
|
|
</div>
|
|
);
|
|
}
|