Files
MichilisandClaude Opus 5 e4eb1617d1 phase-7: motion, an installable app, and a capture that survives no signal
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>
2026-09-05 20:48:48 +00:00

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>
);
}