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>
342 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|