Files
impuestospy/apps/web/app/[locale]/(app)/bandeja/bandeja.tsx
T
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

342 lines
12 KiB
TypeScript

'use client';
import type { IrpCategory } from '@impuestos/contracts';
import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n';
import { IRP_CATEGORIES } from '@impuestos/rules';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { CategoryPicker } from '@/components/category-picker';
import { ConfettiMoment } from '@/components/confetti-moment';
import { Button, buttonClasses } from '@/components/ui/button';
import { CheckDraw } from '@/components/ui/check-draw';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { Skeleton } from '@/components/ui/skeleton';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
import { DURATION, EASE, gsap, usePrefersReducedMotion } from '@/lib/motion';
import { cn } from '@/lib/utils';
/** Past this many pixels, letting go commits the swipe. */
const COMMIT_PX = 96;
/**
* Flow B4. A stack of documents waiting for a yes. Every gesture has a button and a key,
* because a swipe is unusable with a keyboard and invisible to a screen reader.
*/
export function Bandeja({ locale }: { locale: string }) {
const t = useT();
const queryClient = useQueryClient();
const [index, setIndex] = useState(0);
const [picking, setPicking] = useState(false);
const [celebrating, setCelebrating] = useState(false);
const reduced = usePrefersReducedMotion();
// The drag lives on the element rather than in state: a re-render per pointer move is
// both slower and a fight with GSAP over who owns the transform.
const card = useRef<HTMLDivElement>(null);
const nextCard = useRef<HTMLDivElement>(null);
const dragStart = useRef<number | null>(null);
const pending = useQuery({
queryKey: ['documents', { status: 'needs_review' }],
queryFn: ({ signal }) => api.listDocuments({ status: 'needs_review' }, signal),
});
/**
* Whether anything has ever been confirmed. FLOWS.md A6 makes the first confirmed
* document the moment worth celebrating, and it is only the first one: the count is read
* before the confirm so the answer is about what the user had, not what they now have.
*/
const confirmed = useQuery({
queryKey: ['documents', { status: 'confirmed' }],
queryFn: ({ signal }) => api.listDocuments({ status: 'confirmed' }, signal),
});
const nothingConfirmedYet = confirmed.data?.total === 0;
const documents = pending.data?.items ?? [];
// Confirming the last card shortens the list under us. Clamping here rather than in an
// effect keeps the render consistent and avoids a cascading update.
const safeIndex = Math.min(index, Math.max(0, documents.length - 1));
const current = documents[safeIndex];
const next = documents[safeIndex + 1];
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: ['documents'] }),
[queryClient],
);
const confirm = useMutation({
mutationFn: (id: string) => api.confirmDocument(id),
onSuccess: async () => {
if (nothingConfirmedYet) setCelebrating(true);
await invalidate();
},
});
const reject = useMutation({
mutationFn: (args: { id: string; reason: 'not_mine' | 'duplicate' | 'other' }) =>
api.rejectDocument(args.id, { reason: args.reason }),
onSuccess: invalidate,
});
const reclassify = useMutation({
mutationFn: (args: { id: string; irpCategory: IrpCategory | 'none' }) =>
api.patchClassification(args.id, { irpCategory: args.irpCategory }),
onSuccess: async () => {
setPicking(false);
await invalidate();
},
});
/**
* Sends the card off the way it was pushed, brings the one behind it forward, and only
* then does the thing (FLOWS.md B4: fly out on commit, next card scales up). Under
* reduced motion the action happens immediately and nothing moves.
*/
const flyOut = useCallback(
(direction: 1 | -1, run: () => void) => {
const node = card.current;
if (reduced || !node) {
run();
return;
}
gsap.to(node, {
x: direction * 520,
rotation: direction * 16,
autoAlpha: 0,
duration: DURATION.base,
ease: 'power2.in',
onComplete: () => {
gsap.set(node, { clearProps: 'all' });
run();
},
});
if (nextCard.current) {
gsap.to(nextCard.current, { scale: 1, opacity: 1, duration: DURATION.base, ease: EASE });
}
},
[reduced],
);
const settle = useCallback(() => {
const node = card.current;
if (!node) return;
if (reduced) {
gsap.set(node, { x: 0, rotation: 0 });
return;
}
gsap.to(node, { x: 0, rotation: 0, duration: DURATION.base, ease: EASE });
}, [reduced]);
// Desktop keyboard mirrors of every gesture (FLOWS.md B4).
useEffect(() => {
function onKey(event: KeyboardEvent) {
if (!current || picking) return;
if (event.key === 'j') setIndex(Math.min(safeIndex + 1, documents.length - 1));
else if (event.key === 'k') setIndex(Math.max(safeIndex - 1, 0));
else if (event.key === 'Enter') flyOut(1, () => confirm.mutate(current.id));
else if (event.key === 'x' || event.key === 'X')
flyOut(-1, () => reject.mutate({ id: current.id, reason: 'other' }));
else if (/^[1-8]$/.test(event.key)) {
const category = IRP_CATEGORIES[Number(event.key) - 1];
if (category) reclassify.mutate({ id: current.id, irpCategory: category });
} else return;
event.preventDefault();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [current, documents.length, safeIndex, picking, confirm, reject, reclassify, flyOut]);
if (pending.isPending) {
return (
<div className="space-y-4 py-4">
<Card>
<Skeleton name="bandeja-card" />
</Card>
<Card>
<Skeleton name="bandeja-card" />
</Card>
</div>
);
}
if (pending.isError) {
return (
<Card className="my-4 space-y-3">
<p role="alert" className="text-sm">
{t('common.error.generic')}
</p>
<Button type="button" onClick={() => void pending.refetch()}>
{t('common.retry')}
</Button>
</Card>
);
}
if (!current) {
return (
<div className="py-4">
<Card>
<EmptyState
icon={<CheckDraw className="size-8" />}
title={t('bandeja.empty')}
body={t('bandeja.emptyBody')}
action={
<Link href="/escanear" className={buttonClasses({ size: 'lg' })}>
{t('scan.fab')}
</Link>
}
/>
</Card>
</div>
);
}
const category = current.classification?.irpCategory ?? 'none';
const highConfidence = (current.classification?.confidence ?? 0) >= 0.8;
return (
<div className="space-y-4 py-4">
<ConfettiMoment play={celebrating} />
<div className="flex items-baseline justify-between">
<h1 className="text-2xl font-semibold tracking-tight">{t('bandeja.title')}</h1>
<span className="text-sm text-[var(--text-muted)]">
{t('bandeja.count', { count: documents.length })}
</span>
</div>
<div className="relative">
{/*
The shoulder of the next card, so the stack is visible and committing this one
has somewhere to go. A shoulder rather than a whole card behind: the front card's
height depends on its content, and a full card would sit entirely hidden behind a
tall one and entirely exposed behind a short one.
*/}
{next ? (
<div
ref={nextCard}
aria-hidden
className="absolute inset-x-4 -top-2 h-8 origin-bottom scale-95 rounded-2xl border bg-[var(--surface-raised)] opacity-70"
/>
) : null}
<Card
ref={card}
data-testid="bandeja-card"
className="relative touch-pan-y space-y-4 select-none"
onPointerDown={(event) => {
dragStart.current = event.clientX;
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
if (dragStart.current === null) return;
const offset = event.clientX - dragStart.current;
gsap.set(card.current, { x: offset, rotation: offset / 40 });
}}
onPointerUp={(event) => {
const start = dragStart.current;
dragStart.current = null;
if (start === null) return;
const offset = event.clientX - start;
if (offset > COMMIT_PX) flyOut(1, () => confirm.mutate(current.id));
else if (offset < -COMMIT_PX) {
settle();
setPicking(true);
} else settle();
}}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="truncate text-lg font-medium">{current.emitterName}</p>
<p className="text-sm text-[var(--text-muted)]">
{formatDateLong(locale as Locale, current.issueDate)}
</p>
</div>
<span
className={cn(
'shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium',
highConfidence ? 'bg-positive-soft text-positive' : 'bg-attention-soft text-attention',
)}
>
{highConfidence ? t('bandeja.confidence.high') : t('bandeja.confidence.low')}
</span>
</div>
<p className="tnum text-4xl font-semibold tracking-tight">{formatGs(current.total)}</p>
<p className="text-sm text-[var(--text-muted)]">
{t('scan.result.suggested', { category: t(`categories.${category}` as const) })}
</p>
{current.classification?.reasons.length ? (
<ul className="space-y-1 text-xs text-[var(--text-muted)]">
{current.classification.reasons.map((reason) => (
<li key={reason}>{t(`classification.reason.${reason}` as 'classification.reason.irp_no_category')}</li>
))}
</ul>
) : null}
</Card>
</div>
{picking ? (
<Card className="space-y-3">
<h2 className="font-medium">{t('scan.result.changeCat')}</h2>
<CategoryPicker
value={category}
onSelect={(next) => reclassify.mutate({ id: current.id, irpCategory: next })}
/>
<Button type="button" variant="ghost" block onClick={() => setPicking(false)}>
{t('common.cancel')}
</Button>
</Card>
) : (
/* Confirm leads and takes the full width: it is the action on almost every card,
and three buttons across a 390px screen wraps the longest label. */
<div className="space-y-2">
<Button
type="button"
size="lg"
block
disabled={confirm.isPending}
onClick={() => flyOut(1, () => confirm.mutate(current.id))}
>
<Check aria-hidden className="size-4" />
{t('bandeja.confirm')}
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="secondary"
disabled={reject.isPending}
onClick={() => flyOut(-1, () => reject.mutate({ id: current.id, reason: 'other' }))}
>
<X aria-hidden className="size-4" />
{t('scan.result.discard')}
</Button>
<Button type="button" variant="secondary" onClick={() => setPicking(true)}>
{t('scan.result.changeCat')}
</Button>
</div>
</div>
)}
<div className="space-y-1 text-xs text-[var(--text-muted)]">
<p>{t('bandeja.swipeHint')}</p>
<p className="hidden sm:block">{t('bandeja.keyboardHint')}</p>
<p>
{t('bandeja.autoConfirmNote', { days: 7 })}{' '}
<Link href="/perfil" className="underline">
{t('bandeja.autoConfirmLink')}
</Link>
</p>
</div>
</div>
);
}