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>
This commit is contained in:
Michilis
2026-09-05 20:48:48 +00:00
co-authored by Claude Opus 5
parent 4c39926483
commit e4eb1617d1
59 changed files with 2148 additions and 67 deletions
+101 -26
View File
@@ -7,13 +7,16 @@ 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. */
@@ -28,8 +31,13 @@ export function Bandeja({ locale }: { locale: string }) {
const queryClient = useQueryClient();
const [index, setIndex] = useState(0);
const [picking, setPicking] = useState(false);
const [drag, setDrag] = useState(0);
const [dragging, setDragging] = 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({
@@ -37,11 +45,23 @@ export function Bandeja({ locale }: { locale: string }) {
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'] }),
@@ -51,7 +71,7 @@ export function Bandeja({ locale }: { locale: string }) {
const confirm = useMutation({
mutationFn: (id: string) => api.confirmDocument(id),
onSuccess: async () => {
setDrag(0);
if (nothingConfirmedYet) setCelebrating(true);
await invalidate();
},
});
@@ -59,10 +79,7 @@ export function Bandeja({ locale }: { locale: string }) {
const reject = useMutation({
mutationFn: (args: { id: string; reason: 'not_mine' | 'duplicate' | 'other' }) =>
api.rejectDocument(args.id, { reason: args.reason }),
onSuccess: async () => {
setDrag(0);
await invalidate();
},
onSuccess: invalidate,
});
const reclassify = useMutation({
@@ -74,14 +91,55 @@ export function Bandeja({ locale }: { locale: string }) {
},
});
/**
* 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') confirm.mutate(current.id);
else if (event.key === 'x' || event.key === 'X') reject.mutate({ id: current.id, reason: 'other' });
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 });
@@ -91,7 +149,7 @@ export function Bandeja({ locale }: { locale: string }) {
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [current, documents.length, safeIndex, picking, confirm, reject, reclassify]);
}, [current, documents.length, safeIndex, picking, confirm, reject, reclassify, flyOut]);
if (pending.isPending) {
return (
@@ -124,7 +182,7 @@ export function Bandeja({ locale }: { locale: string }) {
<div className="py-4">
<Card>
<EmptyState
icon={<Check aria-hidden className="size-8" />}
icon={<CheckDraw className="size-8" />}
title={t('bandeja.empty')}
body={t('bandeja.emptyBody')}
action={
@@ -143,6 +201,8 @@ export function Bandeja({ locale }: { locale: string }) {
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)]">
@@ -150,31 +210,45 @@ export function Bandeja({ locale }: { locale: string }) {
</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="touch-pan-y space-y-4 select-none"
style={{
transform: `translateX(${drag}px) rotate(${drag / 40}deg)`,
transition: dragging ? 'none' : 'transform 200ms ease-out',
}}
className="relative touch-pan-y space-y-4 select-none"
onPointerDown={(event) => {
dragStart.current = event.clientX;
setDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
if (dragStart.current === null) return;
setDrag(event.clientX - dragStart.current);
const offset = event.clientX - dragStart.current;
gsap.set(card.current, { x: offset, rotation: offset / 40 });
}}
onPointerUp={() => {
const offset = drag;
onPointerUp={(event) => {
const start = dragStart.current;
dragStart.current = null;
setDragging(false);
if (offset > COMMIT_PX) confirm.mutate(current.id);
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);
setDrag(0);
} else setDrag(0);
} else settle();
}}
>
<div className="flex items-start justify-between gap-4">
@@ -208,6 +282,7 @@ export function Bandeja({ locale }: { locale: string }) {
</ul>
) : null}
</Card>
</div>
{picking ? (
<Card className="space-y-3">
@@ -229,7 +304,7 @@ export function Bandeja({ locale }: { locale: string }) {
size="lg"
block
disabled={confirm.isPending}
onClick={() => confirm.mutate(current.id)}
onClick={() => flyOut(1, () => confirm.mutate(current.id))}
>
<Check aria-hidden className="size-4" />
{t('bandeja.confirm')}
@@ -239,7 +314,7 @@ export function Bandeja({ locale }: { locale: string }) {
type="button"
variant="secondary"
disabled={reject.isPending}
onClick={() => reject.mutate({ id: current.id, reason: 'other' })}
onClick={() => flyOut(-1, () => reject.mutate({ id: current.id, reason: 'other' }))}
>
<X aria-hidden className="size-4" />
{t('scan.result.discard')}
@@ -4,9 +4,11 @@ import { isApiError, type DeclarationDto } from '@impuestos/contracts';
import { formatDateLong, formatGs, formatGsAmount, type Locale } from '@impuestos/i18n';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { ConfettiMoment } from '@/components/confetti-moment';
import { FormPreview } from '@/components/form-preview';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Reveal } from '@/components/ui/reveal';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
@@ -22,6 +24,7 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
const queryClient = useQueryClient();
const confirmDialog = useRef<HTMLDialogElement>(null);
const [staleWarning, setStaleWarning] = useState<string | null>(null);
const [celebrating, setCelebrating] = useState(false);
const declaration = useQuery({
queryKey: ['declarations', id],
@@ -32,6 +35,8 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
const approve = useMutation({
mutationFn: () => api.approveDeclaration(id),
onSuccess: async () => {
// One of the three moments FLOWS.md section 8 allows a celebration.
setCelebrating(true);
setStaleWarning(null);
confirmDialog.current?.close();
await queryClient.invalidateQueries({ queryKey: ['declarations'] });
@@ -75,7 +80,9 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
const officialTitle = `Formulario ${data.formCode}`;
return (
<div className="space-y-4 py-4 pb-28">
<Reveal className="space-y-4 py-4 pb-28">
<ConfettiMoment play={celebrating} />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{officialTitle}</h1>
<p className="tnum text-sm text-[var(--text-muted)]">{data.period}</p>
@@ -172,7 +179,7 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
{t('decl.approvedAt', { date: formatDateLong(locale as Locale, data.approvedAt.slice(0, 10)) })}
</p>
) : null}
</div>
</Reveal>
);
}
@@ -5,6 +5,7 @@ import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Check, Copy } from 'lucide-react';
import { useState } from 'react';
import { CelebrationCanvas } from '@/components/canvas/celebration-canvas';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { useT } from '@/i18n/t';
@@ -46,8 +47,10 @@ export function FilingChecklist({
if (filed) {
return (
<Card className="space-y-2">
{/* A short celebration belongs here (FLOWS.md D3); the motion pass is phase 7. */}
<Card className="relative isolate space-y-2 overflow-hidden">
{/* The second and last canvas spot (FLOWS.md D3), only in the moment it happens:
a reload of an already filed declaration is not a new success. */}
{markFiled.isSuccess ? <CelebrationCanvas /> : null}
<h2 className="text-xl font-semibold tracking-tight text-positive">
{t('decl.filed.title')}
</h2>
@@ -11,6 +11,7 @@ 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';
@@ -35,6 +36,16 @@ export function ScanScreen({ locale }: { locale: string }) {
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 }) => {
@@ -54,6 +65,16 @@ export function ScanScreen({ locale }: { locale: string }) {
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) });
},
@@ -200,6 +221,12 @@ export function ScanScreen({ locale }: { locale: string }) {
</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')}
@@ -8,6 +8,7 @@ import { TraceableNumber } from '@/components/traceable-number';
import { Button, buttonClasses } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { Reveal } from '@/components/ui/reveal';
import { Skeleton } from '@/components/ui/skeleton';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
@@ -53,7 +54,7 @@ export function Dashboard({ locale, fullName }: { locale: Locale; fullName: stri
const nothingYet = !data.hasDocuments;
return (
<div className="space-y-4 py-4">
<Reveal className="space-y-4 py-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1>
<p className="text-sm text-[var(--text-muted)]">{fullName}</p>
@@ -89,7 +90,7 @@ export function Dashboard({ locale, fullName }: { locale: Locale; fullName: stri
<InsightFeed insights={data.insights} locale={locale} />
</>
) : null}
</div>
</Reveal>
);
}
+4
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react';
import { AppShell } from '@/components/app-shell';
import { LanguageSwitcher } from '@/components/language-switcher';
import { OfflineSync } from '@/components/offline-sync';
import { PwaProvider } from '@/components/pwa-provider';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
@@ -22,6 +24,8 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{/* Bottom padding clears the tab bar and the scan button above it. */}
<main className="flex-1 px-5 pb-36">{children}</main>
<AppShell />
<OfflineSync />
<PwaProvider />
</div>
);
}
@@ -3,6 +3,8 @@
import type { ProfileDto } from '@impuestos/contracts';
import { SUPPORTED_LOCALES, type Locale } from '@impuestos/i18n';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { PushOptIn } from '@/components/push-optin';
import { ThemeToggle } from '@/components/theme-toggle';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
@@ -83,11 +85,14 @@ export function NotificationsSection({ profile }: { profile: ProfileDto }) {
</select>
</label>
{/* Push and Telegram are hidden until they are configured (FLOWS.md section 9).
They are wired with the notification channels in phase 4. */}
{/* Push shows itself only where it can work; Telegram is linked from the bot
rather than here (FLOWS.md section 9: unconfigured channels stay hidden). */}
<PushOptIn />
</div>
) : null}
<ThemeToggle />
<div className="flex items-center justify-between gap-4 border-t pt-4">
<span className="text-sm">{t('common.language')}</span>
<select
@@ -8,6 +8,8 @@ import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
import { DangerZone } from './danger-zone';
import { InstallPrompt } from '@/components/install-prompt';
import { Reveal } from '@/components/ui/reveal';
import { DependentsSection } from './dependents-section';
import { IdentitySection } from './identity-section';
import { NotificationsSection } from './notifications-section';
@@ -53,12 +55,13 @@ export function ProfileScreen() {
}
return (
<div className="space-y-4 py-4">
<Reveal className="space-y-4 py-4">
<h1 className="text-2xl font-semibold tracking-tight">{t('profile.title')}</h1>
<IdentitySection profile={profile.data} />
<DependentsSection />
<NotificationsSection profile={profile.data} />
<InstallPrompt />
<DangerZone />
</div>
</Reveal>
);
}
+5 -3
View File
@@ -1,4 +1,5 @@
import { setRequestLocale } from 'next-intl/server';
import { HeroCanvas } from '@/components/canvas/hero-canvas';
import { Card } from '@/components/ui/card';
import { getT } from '@/i18n/t';
import { RucHook } from './ruc-hook';
@@ -16,9 +17,10 @@ export default async function LandingPage({ params }: { params: Promise<{ locale
return (
<div className="mx-auto w-full max-w-5xl px-5">
{/* A subtle canvas effect belongs behind this hero (FLOWS.md A1). It lands with the
rest of the motion work in phase 7, reduced-motion safe and degrading to this. */}
<section className="py-14 sm:py-20">
{/* The one hero effect FLOWS.md A1 allows, behind the content and off under
reduced motion. */}
<section className="relative isolate py-14 sm:py-20">
<HeroCanvas />
<h1 className="max-w-2xl text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
{t('landing.hero.title')}
</h1>
+23 -2
View File
@@ -1,5 +1,5 @@
import { isLocale } from '@impuestos/i18n';
import type { Metadata } from 'next';
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import { NextIntlClientProvider } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
@@ -7,6 +7,7 @@ import { getT } from '@/i18n/t';
import { notFound } from 'next/navigation';
import type { ReactNode } from 'react';
import { Providers } from '@/components/providers';
import { ThemeScript } from '@/components/theme-script';
import { routing } from '@/i18n/routing';
import '../globals.css';
@@ -14,6 +15,17 @@ import '../globals.css';
// CSP tight (SPEC.md section 14).
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
/**
* The browser chrome follows the theme, and the accent is the same deep teal the icon and
* the manifest use.
*/
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#fbfbfc' },
{ media: '(prefers-color-scheme: dark)', color: '#1b1d20' },
],
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
@@ -23,7 +35,13 @@ export async function generateMetadata(props: {
}): Promise<Metadata> {
const { locale } = await props.params;
const t = await getT(locale);
return { title: t('common.appName') };
return {
title: t('common.appName'),
description: t('landing.hero.subtitle'),
applicationName: t('common.appName'),
appleWebApp: { capable: true, title: t('common.appName'), statusBarStyle: 'default' },
icons: { icon: '/icon-192.png', apple: '/apple-touch-icon.png' },
};
}
export default async function LocaleLayout({
@@ -39,6 +57,9 @@ export default async function LocaleLayout({
return (
<html lang={locale} className={inter.variable} suppressHydrationWarning>
<head>
<ThemeScript />
</head>
<body className="min-h-dvh font-sans antialiased">
<NextIntlClientProvider>
<Providers>{children}</Providers>
+31
View File
@@ -0,0 +1,31 @@
import type { MetadataRoute } from 'next';
/**
* The install manifest. Deliberately minimal: name, icons, and a start URL that lands on
* the position rather than the marketing page, since anyone installing this already has an
* account.
*
* The locale in `start_url` is the deployment default. Switching language rewrites the URL
* the reader is on, and the app remembers the choice against the profile, so an installed
* shortcut that opens `/es/inicio` still shows English to an English speaker.
*/
export default function manifest(): MetadataRoute.Manifest {
const locale = process.env['NEXT_PUBLIC_DEFAULT_LOCALE'] ?? 'es';
return {
name: 'Impuestos',
short_name: 'Impuestos',
description: 'Escanea tus comprobantes y no te pierdas ningun vencimiento.',
start_url: `/${locale}/inicio`,
scope: '/',
display: 'standalone',
orientation: 'portrait',
background_color: '#fbfbfc',
theme_color: '#0d6b6b',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
};
}
+24
View File
@@ -0,0 +1,24 @@
'use client';
import { DEFAULT_LOCALE, localeFromAcceptLanguage, t } from '@impuestos/i18n';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { useBrowserValue } from '@/lib/browser';
export function OfflineNotice() {
// This page has no request locale to read, so it takes the browser's own preference.
const locale = useBrowserValue(
() => localeFromAcceptLanguage(navigator.language),
DEFAULT_LOCALE,
);
return (
<Card className="w-full space-y-3">
<h1 className="text-xl font-semibold tracking-tight">{t(locale, 'offline.title')}</h1>
<p className="text-sm text-[var(--text-muted)] text-pretty">{t(locale, 'offline.body')}</p>
<Button type="button" block onClick={() => window.location.reload()}>
{t(locale, 'common.retry')}
</Button>
</Card>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from 'next';
import { OfflineNotice } from './offline-notice';
export const metadata: Metadata = { title: 'Impuestos' };
/**
* Rendered per request so it carries a live CSP nonce like every other page. Prerendered,
* its scripts would be stamped at build time and the policy would block them, leaving a
* page that renders but does not work. The service worker caches the response headers
* along with the body, so the copy it serves offline stays self consistent.
*/
export const dynamic = 'force-dynamic';
/**
* What the service worker serves when a navigation cannot reach the network.
*
* It lives outside `[locale]` on purpose: the worker caches exactly one URL, and a page
* that only exists per locale would mean caching one and showing it to everyone. The
* notice picks its language in the browser instead, from the two catalogs we ship.
*/
export default function OfflinePage() {
return (
<main className="mx-auto flex min-h-dvh w-full max-w-md items-center px-5">
<OfflineNotice />
</main>
);
}
+19
View File
@@ -8,6 +8,25 @@ const nextConfig: NextConfig = {
output: 'standalone',
// /api is proxied at runtime by app/api/[...path]/route.ts rather than by a rewrite,
// so API_INTERNAL_URL stays a runtime setting. See the comment in that file.
async headers() {
return [
{
// The bundler emits the service worker under /_next/static, and a worker may only
// claim a scope at or below its own path unless the response says otherwise.
// Security headers for everything else are set in proxy.ts, which does not run for
// /_next.
source: '/_next/static/service-worker/:path*',
headers: [
{ key: 'Service-Worker-Allowed', value: '/' },
// Production only: Next warns that a custom Cache-Control here interferes with
// how the dev server serves its own assets, and it does.
...(process.env.NODE_ENV === 'production'
? [{ key: 'Cache-Control', value: 'no-cache, no-store, must-revalidate' }]
: []),
],
},
];
},
};
export default createNextIntlPlugin('./src/i18n/request.ts')(nextConfig);
+1
View File
@@ -17,6 +17,7 @@
"better-auth": "^1.7.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"gsap": "^3.15.0",
"lucide-react": "^1.40.0",
"next": "^16.3.4",
"next-intl": "^4.14.2",
+67 -3
View File
@@ -1,10 +1,74 @@
import createMiddleware from 'next-intl/middleware';
import { type NextRequest, NextResponse } from 'next/server';
import { routing } from './src/i18n/routing';
export default createMiddleware(routing);
const intl = createMiddleware(routing);
// React reconstructs server stacks with eval in development and never in production.
const DEV = process.env.NODE_ENV === 'development';
/**
* Security headers (SPEC.md section 14). Everything is served from one origin: the app
* proxies `/api`, next/font self hosts the typeface and there is no third party script
* anywhere, so the policy names `'self'` and stops.
*
* Scripts are allowed by nonce rather than by `'unsafe-inline'`. A fresh nonce per request
* means an injected `<script>` cannot guess its way in, and `'strict-dynamic'` lets the one
* nonced bootstrap load the chunks it needs without listing them.
*/
function policy(nonce: string): string {
return [
"default-src 'self'",
// 'wasm-unsafe-eval' is the QR decoder: zxing runs as WebAssembly, self hosted.
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval'${DEV ? " 'unsafe-eval'" : ''}`,
// Styles stay inline-allowed: Tailwind ships a stylesheet, but React writes inline
// style attributes for things like a dragged card, and those are not noncable.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
// The dev server pushes updates over a websocket to the same origin.
`connect-src 'self'${DEV ? ' ws:' : ''}`,
"worker-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
'upgrade-insecure-requests',
].join('; ');
}
const STATIC_HEADERS: Record<string, string> = {
'x-content-type-options': 'nosniff',
'referrer-policy': 'strict-origin-when-cross-origin',
// The scanner needs the camera; nothing else needs anything.
'permissions-policy': 'camera=(self), microphone=(), geolocation=(), payment=()',
};
export default function proxy(request: NextRequest) {
const nonce = crypto.randomUUID();
const csp = policy(nonce);
// Next reads the nonce off the request headers and stamps it on the scripts it renders.
// Setting them on the incoming request is what carries them through next-intl's rewrite.
request.headers.set('x-nonce', nonce);
request.headers.set('content-security-policy', csp);
/**
* `/offline` is one page for every language: the service worker caches exactly one URL,
* so locale routing must leave it alone. It still gets the headers.
*/
const response =
request.nextUrl.pathname === '/offline'
? NextResponse.next({ request: { headers: request.headers } })
: intl(request);
response.headers.set('content-security-policy', csp);
for (const [key, value] of Object.entries(STATIC_HEADERS)) response.headers.set(key, value);
return response;
}
export const config = {
// Everything except /api (forwarded to the API by the rewrite), Next internals and
// static files. Locale routing must not touch API requests.
// Everything except /api (forwarded to the API by the route handler), Next internals and
// static files.
matcher: ['/((?!api|healthz|_next|_vercel|.*\\..*).*)'],
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+54
View File
@@ -0,0 +1,54 @@
/**
* Renders the app icons from one SVG so the set never drifts.
*
* node apps/web/scripts/generate-icons.mjs
*
* Chromium comes with Playwright, which the repository already has for the e2e suite, so
* this needs no image library. The output is committed: a build must not depend on a
* browser being installed.
*/
import { chromium } from '@playwright/test';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
/** Deep teal, the accent from globals.css, with the check the product is built around. */
const svg = (size, padded) => `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 512 512">
<rect width="512" height="512" rx="${padded ? 0 : 112}" fill="#0d6b6b"/>
<path d="M148 268 L222 342 L364 178"
fill="none" stroke="#ffffff" stroke-width="46"
stroke-linecap="round" stroke-linejoin="round"
transform="translate(256 256) scale(${padded ? 0.72 : 1}) translate(-256 -256)"/>
</svg>`;
const TARGETS = [
{ file: 'icon-192.png', size: 192, padded: false },
{ file: 'icon-512.png', size: 512, padded: false },
// Maskable icons are cropped to a circle on Android, so the mark sits inside a safe area
// and the background reaches every corner.
{ file: 'icon-maskable-512.png', size: 512, padded: true },
{ file: 'apple-touch-icon.png', size: 180, padded: true },
];
const browser = await chromium.launch();
try {
await mkdir(PUBLIC_DIR, { recursive: true });
for (const target of TARGETS) {
const page = await browser.newPage({
viewport: { width: target.size, height: target.size },
deviceScaleFactor: 1,
});
await page.setContent(
`<body style="margin:0">${svg(target.size, target.padded)}</body>`,
);
const png = await page.screenshot({ omitBackground: true });
await writeFile(join(PUBLIC_DIR, target.file), png);
await page.close();
console.log(`wrote ${target.file}`);
}
} finally {
await browser.close();
}
@@ -0,0 +1,72 @@
'use client';
import { useEffect, useRef } from 'react';
import { usePrefersReducedMotion } from '@/lib/browser';
/**
* The second and last canvas spot (FLOWS.md D3): a brief bloom on the screen that says the
* declaration is filed. It runs once, for well under a second, and then the canvas is
* transparent again. Under reduced motion it never renders.
*
* SPEC-GAP: same substitution as the hero effect. See hero-canvas.tsx and DECISIONS.md.
*/
const RINGS = 3;
const LIFETIME = 900;
export function CelebrationCanvas() {
const canvas = useRef<HTMLCanvasElement>(null);
const reduced = usePrefersReducedMotion();
useEffect(() => {
const node = canvas.current;
if (reduced || !node) return;
const context = node.getContext('2d');
if (!context) return;
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const rect = node.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
node.width = Math.round(width * ratio);
node.height = Math.round(height * ratio);
context.setTransform(ratio, 0, 0, ratio, 0, 0);
const started = performance.now();
let frame = 0;
function loop(now: number) {
if (!context) return;
const elapsed = now - started;
context.clearRect(0, 0, width, height);
for (let ring = 0; ring < RINGS; ring += 1) {
// Each ring leaves a little after the one before it.
const progress = Math.min(Math.max((elapsed - ring * 120) / LIFETIME, 0), 1);
if (progress <= 0 || progress >= 1) continue;
const eased = 1 - (1 - progress) ** 3;
const radius = eased * Math.max(width, height) * 0.6;
context.beginPath();
context.arc(width / 2, height / 2, radius, 0, Math.PI * 2);
context.strokeStyle = `hsl(165 55% 55% / ${(1 - progress) * 0.45})`;
context.lineWidth = 2;
context.stroke();
}
if (elapsed < LIFETIME + RINGS * 120) {
frame = window.requestAnimationFrame(loop);
} else {
context.clearRect(0, 0, width, height);
}
}
frame = window.requestAnimationFrame(loop);
return () => window.cancelAnimationFrame(frame);
}, [reduced]);
if (reduced) return null;
return (
<canvas ref={canvas} aria-hidden className="pointer-events-none absolute inset-0 size-full" />
);
}
@@ -0,0 +1,120 @@
'use client';
import { useEffect, useRef } from 'react';
import { usePrefersReducedMotion } from '@/lib/browser';
/**
* The one full hero effect FLOWS.md A1 allows: a slow liquid wash behind the headline.
*
* SPEC-GAP: SPEC.md names `canvas-ui` for this. The package under that name on npm is an
* unrelated and long abandoned design system, so the effect is drawn here instead. It is
* about sixty lines, has no dependency, and does exactly what FLOWS.md asks for: subtle,
* behind the content, off under reduced motion, with a static fallback that is the same
* picture standing still. See DECISIONS.md.
*/
/**
* The picture is blurred by CSS anyway, so it is drawn at a fraction of the screen's
* resolution: a third of the pixels in each direction is a ninth of the fill cost, and on
* a cheap phone that is the difference between free and noticeable.
*/
const RENDER_SCALE = 0.35;
/** Three blobs drifting over minutes. Twelve frames a second is more than enough. */
const FRAME_MS = 1000 / 12;
export function HeroCanvas() {
const canvas = useRef<HTMLCanvasElement>(null);
const reduced = usePrefersReducedMotion();
useEffect(() => {
const node = canvas.current;
if (!node) return;
const context = node.getContext('2d');
if (!context) return;
let frame = 0;
let width = 0;
let height = 0;
let onScreen = true;
let lastDrawn = 0;
function resize() {
if (!node || !context) return;
const rect = node.getBoundingClientRect();
width = rect.width * RENDER_SCALE;
height = rect.height * RENDER_SCALE;
node.width = Math.max(1, Math.round(width));
node.height = Math.max(1, Math.round(height));
context.setTransform(1, 0, 0, 1, 0, 0);
}
/** Three slow blobs in the accent family, blurred into each other by CSS. */
function draw(time: number) {
if (!context) return;
context.clearRect(0, 0, width, height);
context.globalCompositeOperation = 'lighter';
const blobs = [
{ hue: 190, x: 0.28, y: 0.34, r: 0.42, speed: 0.00013, alpha: 0.5 },
{ hue: 172, x: 0.72, y: 0.28, r: 0.36, speed: 0.00017, alpha: 0.42 },
{ hue: 205, x: 0.54, y: 0.72, r: 0.46, speed: 0.00011, alpha: 0.36 },
];
for (const blob of blobs) {
const drift = time * blob.speed;
const cx = (blob.x + Math.sin(drift) * 0.06) * width;
const cy = (blob.y + Math.cos(drift * 1.3) * 0.05) * height;
const radius = blob.r * Math.max(width, height);
const gradient = context.createRadialGradient(cx, cy, 0, cx, cy, radius);
gradient.addColorStop(0, `hsl(${blob.hue} 55% 62% / ${blob.alpha})`);
gradient.addColorStop(1, `hsl(${blob.hue} 55% 62% / 0)`);
context.fillStyle = gradient;
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.fill();
}
context.globalCompositeOperation = 'source-over';
}
resize();
window.addEventListener('resize', resize);
if (reduced) {
// The static fallback is the same picture, held still at t=0.
draw(0);
return () => window.removeEventListener('resize', resize);
}
// Scrolled past, or the tab is in the background: there is nothing to animate for.
const observer = new IntersectionObserver(
([entry]) => {
onScreen = entry?.isIntersecting ?? true;
},
{ threshold: 0 },
);
observer.observe(node);
const loop = (time: number) => {
frame = window.requestAnimationFrame(loop);
if (!onScreen || document.hidden || time - lastDrawn < FRAME_MS) return;
lastDrawn = time;
draw(time);
};
frame = window.requestAnimationFrame(loop);
return () => {
window.removeEventListener('resize', resize);
observer.disconnect();
window.cancelAnimationFrame(frame);
};
}, [reduced]);
return (
<canvas
ref={canvas}
aria-hidden
// Behind the content and out of the way of every pointer and screen reader.
className="pointer-events-none absolute inset-0 -z-10 size-full opacity-45 blur-2xl"
/>
);
}
@@ -0,0 +1,69 @@
'use client';
import { useEffect, useRef } from 'react';
import { gsap, usePrefersReducedMotion } from '@/lib/motion';
/**
* A short burst, used sparingly (FLOWS.md section 8: the first confirmed document and an
* approved declaration, nowhere else). It is decoration, so it is `aria-hidden` and never
* takes a pointer event, and it renders nothing at all under reduced motion.
*/
const PIECES = 18;
const COLORS = [
'var(--color-accent-500)',
'var(--color-positive)',
'var(--color-attention)',
'var(--color-accent-200)',
];
export function ConfettiMoment({ play }: { play: boolean }) {
const scope = useRef<HTMLDivElement>(null);
const reduced = usePrefersReducedMotion();
useEffect(() => {
if (!play || reduced || !scope.current) return;
const context = gsap.context(() => {
const pieces = gsap.utils.toArray<HTMLElement>('[data-piece]');
gsap.set(pieces, { opacity: 1, x: 0, y: 0, rotation: 0, scale: 1 });
gsap.to(pieces, {
// A fan upward and out, then gravity. Nothing lingers on screen.
x: (index: number) => (index / (PIECES - 1) - 0.5) * 320,
y: () => gsap.utils.random(-180, -60),
rotation: () => gsap.utils.random(-220, 220),
duration: 0.5,
ease: 'power2.out',
stagger: 0.01,
});
gsap.to(pieces, {
y: '+=260',
opacity: 0,
scale: 0.7,
duration: 0.7,
delay: 0.35,
ease: 'power1.in',
});
}, scope.current);
return () => context.revert();
}, [play, reduced]);
if (reduced || !play) return null;
return (
<div
ref={scope}
aria-hidden
className="pointer-events-none fixed inset-x-0 top-1/3 z-50 flex justify-center"
>
{Array.from({ length: PIECES }).map((_, index) => (
<span
key={index}
data-piece
className="absolute size-2 rounded-[2px] opacity-0"
style={{ backgroundColor: COLORS[index % COLORS.length] }}
/>
))}
</div>
);
}
@@ -0,0 +1,57 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { useT } from '@/i18n/t';
import { useBrowserValue, useMediaQuery } from '@/lib/browser';
interface InstallEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
/**
* Offered once, on the profile, and only where it can actually be taken: a browser that
* fires `beforeinstallprompt`, or iOS, which has no such event and needs the instruction
* instead. Already installed means nothing is shown at all.
*/
export function InstallPrompt() {
const t = useT();
const [event, setEvent] = useState<InstallEvent | null>(null);
const isIos = useBrowserValue(() => /iphone|ipad|ipod/i.test(navigator.userAgent), false);
// Assume installed on the server, so nothing flashes into view and then disappears.
const installed = useMediaQuery('(display-mode: standalone)', true);
useEffect(() => {
function onPrompt(nativeEvent: Event) {
// Holding it lets the offer sit next to an explanation instead of ambushing someone.
nativeEvent.preventDefault();
setEvent(nativeEvent as InstallEvent);
}
window.addEventListener('beforeinstallprompt', onPrompt);
return () => window.removeEventListener('beforeinstallprompt', onPrompt);
}, []);
if (installed || (!event && !isIos)) return null;
return (
<Card className="space-y-2">
<h2 className="font-semibold tracking-tight">{t('install.title')}</h2>
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('install.body')}</p>
{event ? (
<Button
type="button"
onClick={() => {
void event.prompt().then(() => setEvent(null));
}}
>
{t('install.action')}
</Button>
) : (
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('install.ios')}</p>
)}
</Card>
);
}
+80
View File
@@ -0,0 +1,80 @@
'use client';
import { useQueryClient } from '@tanstack/react-query';
import { CloudUpload } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
import { fileFor, listQueue, removeCapture, subscribeToQueue } from '@/lib/offline-queue';
/**
* The chip that says a capture is waiting, and the thing that clears it (FLOWS.md B2:
* sync silently, notify only on failure).
*
* Sending happens one at a time and in order, so a phone that queued five photographs on
* the bus does not open five uploads the moment it finds a signal.
*/
const RETRY_INTERVAL_MS = 10_000;
export function OfflineSync() {
const t = useT();
const queryClient = useQueryClient();
const [pending, setPending] = useState(0);
const [failed, setFailed] = useState(0);
const drain = useCallback(async () => {
const queue = await listQueue();
setPending(queue.length);
if (queue.length === 0 || !navigator.onLine) return;
let stillFailing = 0;
for (const item of queue) {
try {
await api.scanDocument(fileFor(item), item.qrPayload);
await removeCapture(item.id);
} catch {
// Leave it queued. The next connection, or the next visit, tries again.
stillFailing += 1;
}
}
setFailed(stillFailing);
setPending((await listQueue()).length);
if (stillFailing < queue.length) {
await queryClient.invalidateQueries({ queryKey: ['documents'] });
await queryClient.invalidateQueries({ queryKey: ['dashboard'] });
}
}, [queryClient]);
useEffect(() => {
const unsubscribe = subscribeToQueue(() => void drain());
// Off the render pass: the first look at the queue is work, not part of painting.
void Promise.resolve().then(drain);
return unsubscribe;
}, [drain]);
/**
* The `online` event is the usual signal, and it is not a reliable one: a phone that
* walks back into coverage does not always fire it. While anything is queued, look again
* every so often so a capture is never stranded waiting for an event that never came.
*/
useEffect(() => {
if (pending === 0) return;
const timer = window.setInterval(() => void drain(), RETRY_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [pending, drain]);
if (pending === 0) return null;
return (
<div
role="status"
className="fixed inset-x-0 bottom-20 z-20 mx-auto w-fit rounded-full border bg-[var(--surface-raised)] px-4 py-2 text-xs shadow-lg"
>
<span className="flex items-center gap-2">
<CloudUpload aria-hidden className="size-4 text-[var(--text-muted)]" />
{failed > 0 ? t('offline.syncFailed', { count: failed }) : t('offline.queued')}
</span>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
import { useBrowserValue } from '@/lib/browser';
import { serializeSubscription, urlBase64ToUint8Array } from '@/lib/push';
type State = 'checking' | 'off' | 'on' | 'denied';
/**
* The push row in the profile. Hidden entirely when the deployment has no VAPID keys, per
* the notification doctrine: an unconfigured channel is not offered (FLOWS.md section 9).
*/
export function PushOptIn() {
const t = useT();
const queryClient = useQueryClient();
const [state, setState] = useState<State>('checking');
// Support and permission are readable on the first client render, so they are values
// rather than state written back from an effect.
const supported = useBrowserValue(
() => 'serviceWorker' in navigator && 'PushManager' in window,
false,
);
const blocked = useBrowserValue(
() => 'Notification' in window && Notification.permission === 'denied',
false,
);
const config = useQuery({
queryKey: ['push', 'config'],
queryFn: ({ signal }) => api.getPushConfig(signal),
staleTime: Infinity,
});
// Whether this browser already holds a subscription is the one thing that has to be
// asked for, so it is the only thing the effect does.
useEffect(() => {
if (!supported || blocked) return;
let cancelled = false;
void navigator.serviceWorker.ready
.then((registration) => registration.pushManager.getSubscription())
.then((subscription) => {
if (!cancelled) setState(subscription ? 'on' : 'off');
})
.catch(() => {
if (!cancelled) setState('off');
});
return () => {
cancelled = true;
};
}, [supported, blocked]);
const enable = useMutation({
mutationFn: async () => {
const key = config.data?.publicKey;
if (!key) throw new Error('push is not configured');
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
setState(permission === 'denied' ? 'denied' : 'off');
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key),
});
const serialized = serializeSubscription(subscription);
if (!serialized) throw new Error('subscription had no keys');
await api.subscribeToPush(serialized);
// The subscription is only half of it: the row that decides whether we send.
await api.patchNotificationPrefs({ pushEnabled: true });
setState('on');
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me', 'notification-prefs'] }),
});
const disable = useMutation({
mutationFn: async () => {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await api.unsubscribeFromPush(subscription.endpoint).catch(() => undefined);
await subscription.unsubscribe();
}
await api.patchNotificationPrefs({ pushEnabled: false });
setState('off');
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me', 'notification-prefs'] }),
});
// No keys on this deployment, or the browser cannot do it: say nothing at all.
if (!config.data?.publicKey || !supported) return null;
if (!blocked && state === 'checking') return null;
return (
<div className="space-y-2 border-t pt-4">
<p className="text-sm font-medium">{t('push.title')}</p>
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('push.body')}</p>
{blocked || state === 'denied' ? (
<p className="text-sm text-attention text-pretty">{t('push.denied')}</p>
) : state === 'on' ? (
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-positive">{t('push.enabled')}</span>
<Button
type="button"
variant="secondary"
disabled={disable.isPending}
onClick={() => disable.mutate()}
>
{t('common.remove')}
</Button>
</div>
) : (
<Button type="button" disabled={enable.isPending} onClick={() => enable.mutate()}>
{enable.isPending ? t('common.saving') : t('push.enable')}
</Button>
)}
{enable.isError ? (
<p role="alert" className="text-sm text-overdue">
{t('common.error.generic')}
</p>
) : null}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
'use client';
import { useEffect } from 'react';
/**
* Registers the service worker once, from the app shell.
*
* The URL is built with `import.meta.url` so the bundler emits the worker and gives it a
* stable path; `updateViaCache: 'none'` means a deploy is picked up on the next visit
* rather than whenever the browser feels like re-checking.
*/
export function PwaProvider() {
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
void navigator.serviceWorker
.register(new URL('../lib/service-worker.js', import.meta.url), {
scope: '/',
updateViaCache: 'none',
})
// A browser that refuses to register one (private mode, an unsupported context) is
// not an error the reader can do anything about.
.catch(() => undefined);
}, []);
return null;
}
+19
View File
@@ -0,0 +1,19 @@
import Script from 'next/script';
/**
* Applies the stored theme before the first paint.
*
* It has to be inline and it has to run before anything renders: otherwise a reader who
* chose dark gets a white flash on every navigation. `beforeInteractive` is how the App
* Router says exactly that. A failure to read storage leaves the system preference in
* charge, which is the right default anyway.
*/
const SCRIPT = `try{var t=localStorage.getItem('impuestos-theme');if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t)}catch(e){}`;
export function ThemeScript() {
return (
<Script id="theme" strategy="beforeInteractive">
{SCRIPT}
</Script>
);
}
+37
View File
@@ -0,0 +1,37 @@
'use client';
import { useState } from 'react';
import { Select } from '@/components/ui/select';
import { useT } from '@/i18n/t';
import { useBrowserValue } from '@/lib/browser';
import { applyTheme, storedTheme, THEMES, type Theme } from '@/lib/theme';
/** FLOWS.md section 1: dark mode follows the system preference, and can be overridden. */
export function ThemeToggle() {
const initial = useBrowserValue(storedTheme, 'system' as Theme);
const [theme, setTheme] = useState<Theme | null>(null);
const value = theme ?? initial;
const t = useT();
return (
<div className="flex items-center justify-between gap-4 border-t pt-4">
<span className="text-sm">{t('theme.title')}</span>
<Select
aria-label={t('theme.title')}
value={value}
className="h-11 rounded-2xl px-3"
onChange={(event) => {
const next = event.target.value as Theme;
setTheme(next);
applyTheme(next);
}}
>
{THEMES.map((option) => (
<option key={option} value={option}>
{t(`theme.${option}` as const)}
</option>
))}
</Select>
</div>
);
}
+22 -3
View File
@@ -3,12 +3,13 @@
import type { TraceKind } from '@impuestos/contracts';
import { formatDateShort, formatGs, type Locale } from '@impuestos/i18n';
import { useQuery } from '@tanstack/react-query';
import { useRef } from 'react';
import { useCallback, useRef } from 'react';
import { Button } from '@/components/ui/button';
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, useCountUp, usePrefersReducedMotion } from '@/lib/motion';
import { cn } from '@/lib/utils';
/**
@@ -30,6 +31,24 @@ export function TraceableNumber({
}) {
const t = useT();
const dialog = useRef<HTMLDialogElement>(null);
const reduced = usePrefersReducedMotion();
// The headline figure rolls up on first paint and on every change (FLOWS.md C1).
const format = useCallback((amount: number) => formatGs(amount), []);
const figure = useCountUp(value, format);
function open() {
const node = dialog.current;
if (!node) return;
node.showModal();
if (reduced) return;
// Spring-ish, no bounce overdose (FLOWS.md section 8).
gsap.fromTo(
node,
{ autoAlpha: 0, y: 12, scale: 0.98 },
{ autoAlpha: 1, y: 0, scale: 1, duration: DURATION.base, ease: EASE },
);
}
const trace = useQuery({
queryKey: ['dashboard', 'trace', kind],
@@ -43,7 +62,7 @@ export function TraceableNumber({
type="button"
onClick={() => {
void trace.refetch();
dialog.current?.showModal();
open();
}}
className={cn(
'tnum text-left underline decoration-dotted underline-offset-4',
@@ -52,7 +71,7 @@ export function TraceableNumber({
className,
)}
>
{formatGs(value)}
<span ref={figure}>{formatGs(value)}</span>
</button>
<dialog
+8 -2
View File
@@ -1,9 +1,15 @@
import type { HTMLAttributes } from 'react';
import type { HTMLAttributes, Ref } from 'react';
import { cn } from '@/lib/utils';
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
/** `ref` is a plain prop in React 19, so a caller can hand the card to GSAP. */
export function Card({
className,
ref,
...props
}: HTMLAttributes<HTMLDivElement> & { ref?: Ref<HTMLDivElement> }) {
return (
<div
ref={ref}
className={cn(
'rounded-2xl border bg-[var(--surface-raised)] p-6 shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
className,
+40
View File
@@ -0,0 +1,40 @@
'use client';
import { useEffect, useRef } from 'react';
import { gsap, usePrefersReducedMotion } from '@/lib/motion';
/**
* The checkmark that draws itself on an empty bandeja (FLOWS.md B4). Decoration, so it is
* hidden from assistive technology; under reduced motion it is simply already drawn.
*/
export function CheckDraw({ className }: { className?: string }) {
const path = useRef<SVGPathElement>(null);
const reduced = usePrefersReducedMotion();
useEffect(() => {
const node = path.current;
if (reduced || !node) return;
const length = node.getTotalLength();
const tween = gsap.fromTo(
node,
{ strokeDasharray: length, strokeDashoffset: length },
{ strokeDashoffset: 0, duration: 0.45, ease: 'power2.out' },
);
return () => {
tween.kill();
};
}, [reduced]);
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden className={className}>
<path
ref={path}
d="M4 12.5 9.5 18 20 6.5"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+28 -7
View File
@@ -1,27 +1,48 @@
'use client';
import { formatGs } from '@impuestos/i18n';
import { useCallback } from 'react';
import { useCountUp } from '@/lib/motion';
import { cn } from '@/lib/utils';
/**
* The only way money reaches the screen. Always `Gs. 1.234.567`, never localized, always
* tabular so columns of figures line up (FLOWS.md section 1).
*
* `animate` rolls the figure up to its value and again whenever it changes. It belongs on
* headline numbers only: a list of forty rows all counting at once is noise, not motion.
*/
export function Money({
value,
size = 'md',
animate = false,
className,
}: {
value: number;
size?: 'md' | 'lg';
animate?: boolean;
className?: string;
}) {
const classes = cn(
'tnum',
size === 'lg' ? 'text-4xl font-semibold tracking-tight' : 'font-medium',
className,
);
return animate ? (
<AnimatedMoney value={value} className={classes} />
) : (
<span className={classes}>{formatGs(value)}</span>
);
}
function AnimatedMoney({ value, className }: { value: number; className: string }) {
const format = useCallback((amount: number) => formatGs(amount), []);
const ref = useCountUp(value, format);
// The server rendered value is the final one, so a reader with JavaScript off or a
// reduced motion preference sees the right number and never an empty box.
return (
<span
className={cn(
'tnum',
size === 'lg' ? 'text-4xl font-semibold tracking-tight' : 'font-medium',
className,
)}
>
<span ref={ref} className={className}>
{formatGs(value)}
</span>
);
+18
View File
@@ -0,0 +1,18 @@
'use client';
import type { ReactNode } from 'react';
import { useReveal } from '@/lib/motion';
/**
* Content crossfading in where a skeleton was (FLOWS.md section 8: 150ms, the skeleton
* holds the layout and GSAP does the fade). Mount it only when the content is ready:
* the fade runs once, when this appears.
*/
export function Reveal({ children, className }: { children: ReactNode; className?: string }) {
const ref = useReveal();
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { useSyncExternalStore } from 'react';
const NEVER_CHANGES = (): (() => void) => () => undefined;
/**
* Reads a value that only exists in the browser, without a second render.
*
* The alternative is state written from an effect, which renders once with a placeholder
* and again with the truth. For "is this iOS" or "which language does the browser want",
* the truth is available on the first client render and never changes afterwards.
*
* `read` must return the same primitive every time it is called.
*/
export function useBrowserValue<T>(read: () => T, serverValue: T): T {
return useSyncExternalStore(NEVER_CHANGES, read, () => serverValue);
}
/**
* Whether the reader has asked for less motion. The CSS in globals.css already flattens
* transitions and animations; this is the JavaScript half, so an animation never starts in
* the first place rather than running invisibly.
*
* It lives here rather than next to the GSAP helpers on purpose: the landing page needs it
* and must not pull an animation library in to ask one question.
*/
export function usePrefersReducedMotion(): boolean {
return useMediaQuery('(prefers-reduced-motion: reduce)', true);
}
/** A media query as a value, kept in step with the browser. */
export function useMediaQuery(query: string, serverValue = false): boolean {
return useSyncExternalStore(
(onChange) => {
const list = window.matchMedia(query);
list.addEventListener('change', onChange);
return () => list.removeEventListener('change', onChange);
},
() => window.matchMedia(query).matches,
() => serverValue,
);
}
+109
View File
@@ -0,0 +1,109 @@
'use client';
import gsap from 'gsap';
import { useEffect, useRef, type RefObject } from 'react';
import { usePrefersReducedMotion } from './browser';
/**
* Motion is fast, purposeful and interruptible (FLOWS.md section 8). Two durations and
* one ease, used everywhere, so nothing has to invent its own timing.
*/
export const DURATION = { quick: 0.15, base: 0.25, long: 0.4 } as const;
export const EASE = 'power2.out';
// Re-exported so a component that already imports the motion helpers has one place to
// look. The implementation lives in browser.ts, which carries no animation library.
export { usePrefersReducedMotion };
/**
* Runs a GSAP setup inside a context scoped to `scope`, so every tween it creates is
* reverted when the component unmounts or the dependencies change. Nothing is started at
* all when the reader has asked for less motion.
*/
export function useGsap(
scope: RefObject<HTMLElement | null>,
setup: (context: gsap.Context) => void,
deps: unknown[] = [],
): void {
const reduced = usePrefersReducedMotion();
useEffect(() => {
if (reduced || !scope.current) return;
const context = gsap.context((self) => setup(self), scope.current);
return () => context.revert();
// The setup closure is intentionally not a dependency: callers pass a fresh function
// on every render and re-running on identity alone would restart every animation.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduced, ...deps]);
}
/**
* Fades content in as it replaces its skeleton (FLOWS.md section 8: crossfade 150ms,
* boneyard handles the layout and GSAP the fade).
*/
export function useReveal(enabled = true): RefObject<HTMLDivElement | null> {
const ref = useRef<HTMLDivElement>(null);
const reduced = usePrefersReducedMotion();
useEffect(() => {
if (!enabled || reduced || !ref.current) return;
const tween = gsap.fromTo(
ref.current,
{ autoAlpha: 0, y: 4 },
{ autoAlpha: 1, y: 0, duration: DURATION.quick, ease: EASE },
);
return () => {
tween.kill();
};
}, [enabled, reduced]);
return ref;
}
/**
* Rolls a number up to its value, and again whenever it changes (FLOWS.md section 8:
* counter roll-ups on headline Money values). `format` keeps the display in the one
* money formatter rather than letting GSAP write raw digits.
*/
export function useCountUp(
value: number,
format: (value: number) => string,
): RefObject<HTMLSpanElement | null> {
const ref = useRef<HTMLSpanElement>(null);
const reduced = usePrefersReducedMotion();
const previous = useRef(value);
useEffect(() => {
const node = ref.current;
if (!node) return;
// Reduced motion, or a value that has not moved: write it and stop.
if (reduced || previous.current === value) {
node.textContent = format(value);
previous.current = value;
return;
}
const counter = { current: previous.current };
const tween = gsap.to(counter, {
current: value,
duration: DURATION.long,
ease: EASE,
onUpdate: () => {
node.textContent = format(Math.round(counter.current));
},
onComplete: () => {
node.textContent = format(value);
},
});
previous.current = value;
return () => {
tween.kill();
};
}, [value, format, reduced]);
return ref;
}
export { gsap };
+96
View File
@@ -0,0 +1,96 @@
'use client';
/**
* Captures taken with no network, kept until they can be sent (FLOWS.md B2, SPEC.md
* section 8). IndexedDB rather than localStorage because the payload is a photograph.
*
* The queue is per browser, not per account. A capture belongs to whoever was signed in
* when it was taken, and the API decides that from the session on the way in, so a queued
* item that syncs under a different session is refused there rather than misfiled here.
*/
const DB_NAME = 'impuestos-offline';
const STORE = 'captures';
const VERSION = 1;
export interface QueuedCapture {
id: string;
blob: Blob;
mime: string;
qrPayload: string | null;
queuedAt: string;
}
function open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, VERSION);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE)) {
request.result.createObjectStore(STORE, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function transact<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
return open().then(
(db) =>
new Promise<T>((resolve, reject) => {
const transaction = db.transaction(STORE, mode);
const request = run(transaction.objectStore(STORE));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
transaction.oncomplete = () => db.close();
}),
);
}
export async function enqueueCapture(file: File, qrPayload: string | null): Promise<void> {
const item: QueuedCapture = {
id: crypto.randomUUID(),
blob: file,
mime: file.type || 'image/jpeg',
qrPayload,
queuedAt: new Date().toISOString(),
};
await transact('readwrite', (store) => store.add(item));
notify();
}
export async function listQueue(): Promise<QueuedCapture[]> {
try {
return await transact<QueuedCapture[]>('readonly', (store) => store.getAll());
} catch {
// A browser with storage switched off has no queue, which is not an error the reader
// needs to hear about: the capture simply failed to send and said so.
return [];
}
}
export async function removeCapture(id: string): Promise<void> {
await transact('readwrite', (store) => store.delete(id));
notify();
}
/** Lets the shell re-read the count without polling. */
const CHANGED = 'impuestos:offline-queue';
export function notify(): void {
window.dispatchEvent(new Event(CHANGED));
}
export function subscribeToQueue(listener: () => void): () => void {
window.addEventListener(CHANGED, listener);
window.addEventListener('online', listener);
window.addEventListener('offline', listener);
return () => {
window.removeEventListener(CHANGED, listener);
window.removeEventListener('online', listener);
window.removeEventListener('offline', listener);
};
}
export function fileFor(item: QueuedCapture): File {
return new File([item.blob], `captura-${item.id}.jpg`, { type: item.mime });
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
/**
* Web push wants the VAPID key as bytes, and it travels as base64url. The buffer is
* allocated explicitly so the result is a plain `ArrayBuffer` view, which is what
* `pushManager.subscribe` accepts.
*/
export function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
const normal = padded.replace(/-/g, '+').replace(/_/g, '/');
const raw = window.atob(normal);
const bytes = new Uint8Array(new ArrayBuffer(raw.length));
for (let index = 0; index < raw.length; index += 1) bytes[index] = raw.charCodeAt(index);
return bytes;
}
export interface SerializedSubscription {
endpoint: string;
keys: { p256dh: string; auth: string };
}
/** The browser's PushSubscription in the shape the API stores. */
export function serializeSubscription(subscription: PushSubscription): SerializedSubscription | null {
const json = subscription.toJSON();
const p256dh = json.keys?.['p256dh'];
const auth = json.keys?.['auth'];
if (!json.endpoint || !p256dh || !auth) return null;
return { endpoint: json.endpoint, keys: { p256dh, auth } };
}
+94
View File
@@ -0,0 +1,94 @@
/**
* The service worker. Three jobs and no more:
*
* 1. show a push notification and open the screen it points at,
* 2. keep the app shell reachable when the network is not,
* 3. get out of the way of everything else.
*
* There is no caching of API responses. Tax figures that are quietly out of date are worse
* than figures that are honestly missing, so a failed data request fails and the screen
* says so.
*/
const CACHE = 'impuestos-shell-v1';
const OFFLINE_URL = '/offline';
const PRECACHE = [OFFLINE_URL, '/icon-192.png'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE)
// One at a time rather than addAll: a single miss must not throw away the install
// and leave the offline page uncached along with it.
.then((cache) =>
Promise.all(PRECACHE.map((url) => cache.add(url).catch(() => undefined))),
)
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
);
});
/**
* Navigations only. A page the network cannot deliver falls back to the offline screen,
* which explains itself and offers a retry. Everything else goes straight to the network.
*/
self.addEventListener('fetch', (event) => {
if (event.request.mode !== 'navigate') return;
event.respondWith(
fetch(event.request).catch(async () => {
const cached = await caches.match(OFFLINE_URL);
return cached ?? Response.error();
}),
);
});
self.addEventListener('push', (event) => {
if (!event.data) return;
let payload;
try {
payload = event.data.json();
} catch {
return;
}
event.waitUntil(
self.registration.showNotification(payload.title ?? 'Impuestos', {
body: payload.body ?? '',
icon: '/icon-192.png',
badge: '/icon-192.png',
// One number and one action (FLOWS.md section 9): the tag collapses a repeat of the
// same notification rather than stacking it.
tag: payload.tag ?? 'impuestos',
data: { url: payload.url ?? '/' },
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = event.notification.data?.url ?? '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windows) => {
// Reuse a tab that is already open rather than piling up new ones.
for (const client of windows) {
if ('focus' in client) {
client.navigate(target);
return client.focus();
}
}
return self.clients.openWindow(target);
}),
);
});
+31
View File
@@ -0,0 +1,31 @@
'use client';
export const THEMES = ['system', 'light', 'dark'] as const;
export type Theme = (typeof THEMES)[number];
export const THEME_KEY = 'impuestos-theme';
/**
* Applied by writing `data-theme` on the document element, which the CSS in globals.css
* treats as beating the system preference in both directions. `system` removes the
* attribute rather than picking a side, so the reader's own setting is followed as it
* changes.
*/
export function applyTheme(theme: Theme): void {
if (theme === 'system') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', theme);
try {
window.localStorage.setItem(THEME_KEY, theme);
} catch {
// A browser with storage blocked keeps the choice for this page and no longer.
}
}
export function storedTheme(): Theme {
try {
const value = window.localStorage.getItem(THEME_KEY);
return THEMES.includes(value as Theme) ? (value as Theme) : 'system';
} catch {
return 'system';
}
}