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
+107
View File
@@ -621,3 +621,110 @@ were on screen next to each other.
- **Filtering the error queue by account.** The overview lists a user's own open errors and - **Filtering the error queue by account.** The overview lists a user's own open errors and
links to the queue. CONTRACTS.md gives `/admin/errors` a stage and status filter and no links to the queue. CONTRACTS.md gives `/admin/errors` a stage and status filter and no
user filter, and one was not invented. user filter, and one was not invented.
## Phase 7
### boneyard and canvas-ui do not exist as SPEC.md describes them
SPEC.md section 3 names both in the stack table. On npm, `boneyard` is an abandoned 2015
"architectural toolkit" and `canvas-ui` is Mesosphere's abandoned design system. Neither
does what FLOWS.md asks of the name, so both were built here instead, to the behaviour
FLOWS.md specifies rather than to the package name:
- **Skeletons** are `components/ui/skeleton.tsx`, shipped since phase 0, with exactly the
named shapes FLOWS.md section 1 lists. No content load anywhere shows a spinner.
- **The two canvas spots** are `components/canvas/`: a slow liquid wash behind the landing
hero (A1) and a brief bloom on the screen that says a declaration is filed (D3). Sixty
lines each, no dependency, off under reduced motion with the same picture held still as
the fallback.
Installing either package would have added dead weight and done none of the work. This is
recorded here rather than buried in a comment because it contradicts the stack table.
### Motion is one module and one preference
`lib/motion.ts` holds two durations, one ease, and the four helpers everything uses:
`useGsap` (a context that reverts on unmount), `useReveal`, `useCountUp` and the reduced
motion read. Every animation checks the preference, so a reader who has asked for less
motion gets none rather than a fast one.
`usePrefersReducedMotion` lives in `lib/browser.ts`, not next to the GSAP helpers. The
landing page needs the answer and must not pull an animation library in to ask the
question; the motion module re-exports it so callers have one place to look.
### Reading a browser-only value is not state
`lib/browser.ts` reads through `useSyncExternalStore`. "Is this iOS", "which language does
the browser want", "is the app installed", "is reduced motion on": all are true on the
first client render and none needs a second pass. Writing them from an effect renders once
with a placeholder and again with the truth, which for reduced motion means an animation
that starts and is then told not to.
### The stack shows a shoulder, not a card
FLOWS.md B4 wants the next card to scale up as the current one flies out. A whole card
behind sits entirely hidden behind a tall front card and entirely exposed behind a short
one, since the front card's height follows its content. A shoulder above the top edge reads
as a stack at every height.
### The service worker caches the shell and nothing else
No API response is cached. A tax figure that is quietly out of date is worse than one that
is honestly missing, so a data request that fails, fails, and the screen says so. What is
cached is one offline page, which explains itself and offers a retry.
`/offline` sits outside `[locale]`, because the worker caches exactly one URL and a page
that only existed per locale would mean caching one and showing it to everybody. It picks
its language in the browser from the two catalogs we already ship.
### Offline captures wait in IndexedDB, and the queue is the source of truth
A capture taken with no network is stored whole and sent later. Both the scanner and the
shell read the queue rather than remembering that something was queued, so the notice
disappears when the capture actually lands rather than when the screen guesses it has.
Sending retries on an interval as well as on the `online` event: a phone walking back into
coverage does not reliably fire that event, and a stranded photograph is the one failure
this feature exists to prevent.
### CSP by nonce, styles still inline
The proxy mints a nonce per request and Next stamps it on the scripts it renders, with
`'strict-dynamic'` for the chunks they load. `style-src` keeps `'unsafe-inline'`: React
writes inline `style` attributes for things like a dragged card and there is no way to
nonce those.
This forced one change: `/offline` is rendered per request rather than prerendered. A
prerendered page carries a build-time nonce that no live policy matches, so its scripts
were blocked and the page rendered without ever hydrating. The service worker caches
headers along with the body, so the copy it serves offline stays self consistent.
### A crash at boot, from an optional channel
`webpush.setVapidDetails` throws when the VAPID subject is not an `https:` or a `mailto:`
URL, and the code handed it `APP_PUBLIC_URL`, which is `http://localhost:3005` in
development. Any machine with push keys configured therefore died at boot. There is now a
`PUSH_VAPID_SUBJECT` variable, a fallback to `APP_PUBLIC_URL` when it is https and then to
`mailto:SMTP_FROM`, and with none of the three push switches itself off and says so. A
misconfigured optional channel must never take the API down.
### Subscriptions the push service has given up on are deleted
A 404 or a 410 from the push service means the browser threw the subscription away, and the
row is removed. Anything else is transient and the row stays: a network blip is not a
reason to stop notifying someone forever.
### Lighthouse: measured, not met
The target is a mobile score of 90 or better. Measured here on the production build:
accessibility 100, best practices 96, SEO 100, performance 73. The performance number is
not trustworthy on this machine. Lighthouse reports a `benchmarkIndex` of about 560 (a
healthy development machine is 1000 or more) on a shared, loaded box, and then applies a
4x CPU multiplier on top of that. The same page with the multiplier removed scores 89.
What the phase actually fixed is real and measurable: total blocking time went from
17,590ms to 1,700ms once the hero canvas stopped drawing at full resolution on every frame
and the landing page stopped importing GSAP. First contentful paint 0.9s, largest
contentful paint 1.9s, cumulative layout shift 0. The remaining 4 points of best practices
are Chrome flagging `style-src 'unsafe-inline'`, which is the deliberate choice above.
**A 90+ mobile score cannot be verified in this environment.** It belongs on the list with
Docker.
### The four states, screen by screen
Every data screen ships a skeleton, an error with a retry and content. The two detail
screens (a comprobante, a declaration) have no empty state on purpose: a detail screen
either has its subject or is a 404, and there is no third case. The crossfade from skeleton
to content is applied where the skeleton is a separate early return; the three list screens
render their skeleton inside the same tree as their heading, where fading the whole tree
would also fade a heading that never changed.
+28 -3
View File
@@ -7,11 +7,12 @@ and ready to file yourself.
Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the
way and the gaps that still need answers. way and the gaps that still need answers.
> **Status: phase 6 of 8.** The whole taxpayer path works: scan a comprobante, confirm it, > **Status: phase 7 of 8.** The whole taxpayer path works: scan a comprobante, confirm it,
> watch the position move, and take the resulting Formulario 120 or 515 from review to > watch the position move, and take the resulting Formulario 120 or 515 from review to
> approved to a PDF you file yourself in Marangatu. Staff have a console: look an account > approved to a PDF you file yourself in Marangatu. Staff have a console: look an account
> up, work the ingestion error queue, read and export the audit log. The PWA polish and the > up, work the ingestion error queue, read and export the audit log. It installs to a home
> scale-out work are still ahead. > screen, keeps a capture taken with no signal and sends it later, and can send a push when
> a deadline is close. The scale-out work is the last phase.
--- ---
@@ -130,6 +131,30 @@ S3. The k8s manifests assume both.
Rate limiting is in memory and therefore per replica. At this scale that is deliberate; Rate limiting is in memory and therefore per replica. At this scale that is deliberate;
the limiter is behind an interface for when it is not. the limiter is behind an interface for when it is not.
## Installing it, offline and push
`app/manifest.ts` and the icons in `apps/web/public` make the web app installable; the
icons are generated from one SVG by `apps/web/scripts/generate-icons.mjs` and committed, so
a build never needs a browser.
The service worker (`apps/web/src/lib/service-worker.js`) caches the app shell and the
`/offline` page and nothing else. No API response is cached: a tax figure that is quietly
out of date is worse than one that is honestly missing.
A scan taken with no network is stored whole in IndexedDB and sent when there is one. The
chip in the shell says so and clears itself.
Push needs VAPID keys on the API:
```bash
npx web-push generate-vapid-keys
```
Put them in `apps/api/.env` as `PUSH_VAPID_PUBLIC_KEY` and `PUSH_VAPID_PRIVATE_KEY`, plus
`PUSH_VAPID_SUBJECT` (an `https:` or `mailto:` URL) unless `APP_PUBLIC_URL` is already
https. Without keys the offer is hidden everywhere; with keys and no usable subject, push
switches itself off and logs why rather than taking the API down.
## Adding a locale ## Adding a locale
1. Add the code to `SUPPORTED_LOCALES` in `packages/i18n/src/locales.ts`. 1. Add the code to `SUPPORTED_LOCALES` in `packages/i18n/src/locales.ts`.
+4
View File
@@ -48,6 +48,10 @@ OCR_MODEL=claude-sonnet-4-6
# Optional. Web push is hidden in the UI when unset. Generate: npx web-push generate-vapid-keys # Optional. Web push is hidden in the UI when unset. Generate: npx web-push generate-vapid-keys
PUSH_VAPID_PUBLIC_KEY= PUSH_VAPID_PUBLIC_KEY=
PUSH_VAPID_PRIVATE_KEY= PUSH_VAPID_PRIVATE_KEY=
# Who the push service can contact about this deployment. An https: or mailto: URL, which
# is what RFC 8292 allows. Defaults to APP_PUBLIC_URL when that is https, then to
# mailto:SMTP_FROM. With none of the three, push stays off and says so at boot.
PUSH_VAPID_SUBJECT=
# Optional. Without SMTP_HOST, verification codes and emails are logged to stdout. # Optional. Without SMTP_HOST, verification codes and emails are logged to stdout.
SMTP_HOST= SMTP_HOST=
+2
View File
@@ -10,6 +10,7 @@ import { documentRoutes } from './routes/documents';
import { fileRoutes } from './routes/files'; import { fileRoutes } from './routes/files';
import { lookupRoutes } from './routes/lookup'; import { lookupRoutes } from './routes/lookup';
import { meRoutes } from './routes/me'; import { meRoutes } from './routes/me';
import { pushRoutes } from './routes/push';
export interface AppHandle { export interface AppHandle {
app: Hono<AppEnv>; app: Hono<AppEnv>;
@@ -59,6 +60,7 @@ export function createApp(deps: AppDeps): AppHandle {
api.route('/deadlines', deadlineRoutes(deps)); api.route('/deadlines', deadlineRoutes(deps));
api.route('/declarations', declarationRoutes(deps)); api.route('/declarations', declarationRoutes(deps));
api.route('/admin', adminRoutes(deps)); api.route('/admin', adminRoutes(deps));
api.route('/push', pushRoutes(deps));
app.route('/api', api); app.route('/api', api);
+196
View File
@@ -0,0 +1,196 @@
import { PushConfigDto } from '@impuestos/contracts';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createChannels, sendNotification, type Channels } from '../../modules/notifications';
import { parseEnv } from '../../lib/env';
import { createHarness, TEST_ENV, type Harness } from '../../test/harness';
const VAPID = {
PUSH_VAPID_PUBLIC_KEY:
'BLJMd-C-nT92I_geF-K4yHFAudpBu4HEzUbV6Y4CBrobIv4F5oNhyc_GR7e8jb5rXA9KT6PtBEf5h6K_q-Wqogo',
PUSH_VAPID_PRIVATE_KEY: 'Q4_ibICYoI43BnVVIX2AJeI7-E8uHhFIpQNFtThwJAw',
};
const SUBSCRIPTION = {
endpoint: 'https://push.example.test/subscription/abc',
keys: { p256dh: 'BOrOaGVLBTEjaHVsY2FrZXM', auth: 'c2VjcmV0LWF1dGg' },
};
let h: Harness;
let cookie: string;
beforeAll(async () => {
h = await createHarness({ env: VAPID });
cookie = await h.signIn('maria@demo.local', 'demo-maria-1');
});
afterAll(async () => {
await h.close();
});
const json = (path: string, init: RequestInit = {}) =>
h.app.request(path, {
...init,
headers: { cookie, 'content-type': 'application/json', ...(init.headers ?? {}) },
});
describe('GET /push/config', () => {
it('hands out the public key without a session, because it is public', async () => {
const response = await h.app.request('/api/push/config');
expect(response.status).toBe(200);
const config = PushConfigDto.parse(await response.json());
expect(config.publicKey).toBe(VAPID.PUSH_VAPID_PUBLIC_KEY);
});
it('is null on a deployment with no keys, so the UI can hide the offer', async () => {
const plain = await createHarness();
try {
const config = PushConfigDto.parse(await (await plain.app.request('/api/push/config')).json());
expect(config.publicKey).toBeNull();
} finally {
await plain.close();
}
});
});
describe('POST /push/subscribe', () => {
it('needs a session', async () => {
const response = await h.app.request('/api/push/subscribe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(SUBSCRIPTION),
});
expect(response.status).toBe(401);
});
it('rejects a body that is not a subscription', async () => {
const response = await json('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify({ endpoint: 'not-a-url' }),
});
expect(response.status).toBe(400);
});
it('stores one row per endpoint, however many times the browser subscribes', async () => {
expect((await json('/api/push/subscribe', { method: 'POST', body: JSON.stringify(SUBSCRIPTION) })).status).toBe(200);
expect(
(
await json('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify({ ...SUBSCRIPTION, keys: { ...SUBSCRIPTION.keys, auth: 'rotated' } }),
})
).status,
).toBe(200);
const rows = await h.deps.handle.db
.selectFrom('push_subscriptions')
.selectAll()
.where('endpoint', '=', SUBSCRIPTION.endpoint)
.execute();
expect(rows).toHaveLength(1);
// The second subscribe refreshed the keys rather than leaving a stale row behind.
expect(JSON.parse(rows[0]?.keys ?? '{}')).toEqual({ ...SUBSCRIPTION.keys, auth: 'rotated' });
});
it('forgets the subscription when the browser gives it up', async () => {
const response = await json('/api/push/subscribe', {
method: 'DELETE',
body: JSON.stringify({ endpoint: SUBSCRIPTION.endpoint }),
});
expect(response.status).toBe(200);
const rows = await h.deps.handle.db
.selectFrom('push_subscriptions')
.selectAll()
.where('endpoint', '=', SUBSCRIPTION.endpoint)
.execute();
expect(rows).toHaveLength(0);
});
});
describe('a subscription the push service says is gone', () => {
it('is deleted, and does not count as a delivery', async () => {
const db = h.deps.handle.db;
const user = await db
.selectFrom('user')
.select('id')
.where('email', '=', 'maria@demo.local')
.executeTakeFirstOrThrow();
await db
.updateTable('notification_prefs')
.set({ push_enabled: 1 })
.where('user_id', '=', user.id)
.execute();
await db
.insertInto('push_subscriptions')
.values({
id: 'wiped-phone',
user_id: user.id,
endpoint: 'https://push.example.test/subscription/wiped',
keys: JSON.stringify(SUBSCRIPTION.keys),
created_at: new Date().toISOString(),
})
.execute();
// A phone that was reset: the push service answers 410 for good.
const channels: Channels = {
push: async (targets) => ({ gone: targets.map((target) => target.endpoint) }),
email: null,
telegram: null,
};
const result = await sendNotification(
{ db, channels },
{
userId: user.id,
notification: {
kind: 'declaration_ready',
form: '120',
period: '2026-08',
declarationId: 'whatever',
},
},
);
expect(result.delivered).not.toContain('push');
const rows = await db.selectFrom('push_subscriptions').selectAll().where('user_id', '=', user.id).execute();
expect(rows).toHaveLength(0);
});
});
describe('the VAPID subject', () => {
function channelsFor(overrides: Record<string, string>) {
const parsed = parseEnv({ ...TEST_ENV, ...VAPID, ...overrides });
if (!parsed.ok || !parsed.env) throw new Error(parsed.message);
return createChannels(parsed.env);
}
/**
* The regression: web-push throws on a subject that is not https: or mailto:, and
* APP_PUBLIC_URL is http in development. Taking it unchecked killed the API at boot.
*/
it('switches push off rather than crashing when there is no usable subject', () => {
expect(channelsFor({ APP_PUBLIC_URL: 'http://localhost:3005' }).push).toBeNull();
});
it('takes an https APP_PUBLIC_URL', () => {
expect(channelsFor({ APP_PUBLIC_URL: 'https://impuestos.example' }).push).not.toBeNull();
});
it('falls back to the sending address', () => {
expect(
channelsFor({ APP_PUBLIC_URL: 'http://localhost:3005', SMTP_FROM: 'avisos@impuestos.example' })
.push,
).not.toBeNull();
});
it('prefers an explicit subject over both', () => {
expect(
channelsFor({
APP_PUBLIC_URL: 'http://localhost:3005',
PUSH_VAPID_SUBJECT: 'mailto:soporte@impuestos.example',
}).push,
).not.toBeNull();
});
});
+74
View File
@@ -0,0 +1,74 @@
import { PushSubscriptionInput } from '@impuestos/contracts';
import { Hono } from 'hono';
import { uuidv7 } from 'uuidv7';
import type { AppDeps, AppEnv } from '../context';
import { HttpError } from '../errors';
import { requireUser } from '../middleware';
/**
* Browser push subscriptions. The key a browser needs to subscribe is public by
* definition, so reading it needs no session; storing a subscription does.
*/
export function pushRoutes(deps: AppDeps): Hono<AppEnv> {
const routes = new Hono<AppEnv>();
const db = deps.handle.db;
routes.get('/config', (c) =>
c.json({ publicKey: deps.env.PUSH_VAPID_PUBLIC_KEY ?? null }),
);
routes.post('/subscribe', async (c) => {
const user = requireUser(c);
const parsed = PushSubscriptionInput.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) throw new HttpError('validation_error', { detail: parsed.error.issues });
// One row per endpoint: re-subscribing the same browser refreshes its keys rather than
// leaving a second row that will be pushed to twice.
const existing = await db
.selectFrom('push_subscriptions')
.select('id')
.where('endpoint', '=', parsed.data.endpoint)
.executeTakeFirst();
if (existing) {
await db
.updateTable('push_subscriptions')
.set({ user_id: user.id, keys: JSON.stringify(parsed.data.keys) })
.where('id', '=', existing.id)
.execute();
} else {
await db
.insertInto('push_subscriptions')
.values({
id: uuidv7(),
user_id: user.id,
endpoint: parsed.data.endpoint,
keys: JSON.stringify(parsed.data.keys),
created_at: new Date().toISOString(),
})
.execute();
}
return c.json({ ok: true } as const);
});
routes.delete('/subscribe', async (c) => {
const user = requireUser(c);
const body: unknown = await c.req.json().catch(() => null);
const endpoint =
typeof body === 'object' && body !== null && 'endpoint' in body
? (body as { endpoint: unknown }).endpoint
: null;
if (typeof endpoint !== 'string') throw new HttpError('validation_error', { field: 'endpoint' });
await db
.deleteFrom('push_subscriptions')
.where('user_id', '=', user.id)
.where('endpoint', '=', endpoint)
.execute();
return c.json({ ok: true } as const);
});
return routes;
}
+2
View File
@@ -44,6 +44,8 @@ const EnvObject = z.object({
PUSH_VAPID_PUBLIC_KEY: optionalString, PUSH_VAPID_PUBLIC_KEY: optionalString,
PUSH_VAPID_PRIVATE_KEY: optionalString, PUSH_VAPID_PRIVATE_KEY: optionalString,
/** Contact for the push service. Must be an https: or mailto: URL, per RFC 8292. */
PUSH_VAPID_SUBJECT: optionalString,
SMTP_HOST: optionalString, SMTP_HOST: optionalString,
SMTP_PORT: z.coerce.number().int().positive().max(65535).default(587), SMTP_PORT: z.coerce.number().int().positive().max(65535).default(587),
+1
View File
@@ -19,6 +19,7 @@ function recordingChannels(): Channels & { sent: { channel: string; message: Out
sent, sent,
push: async (_targets, message) => { push: async (_targets, message) => {
sent.push({ channel: 'push', message }); sent.push({ channel: 'push', message });
return { gone: [] };
}, },
email: async (_to, _subject, message) => { email: async (_to, _subject, message) => {
sent.push({ channel: 'email', message }); sent.push({ channel: 'email', message });
+38 -3
View File
@@ -19,7 +19,8 @@ export interface PushTarget {
* the fan-out skips it (FLOWS.md section 9). * the fan-out skips it (FLOWS.md section 9).
*/ */
export interface Channels { export interface Channels {
push: ((targets: PushTarget[], message: OutgoingMessage) => Promise<void>) | null; /** Resolves with the endpoints the push service says are gone, so they can be pruned. */
push: ((targets: PushTarget[], message: OutgoingMessage) => Promise<{ gone: string[] }>) | null;
email: ((to: string, subject: string, message: OutgoingMessage) => Promise<void>) | null; email: ((to: string, subject: string, message: OutgoingMessage) => Promise<void>) | null;
telegram: ((chatId: string, message: OutgoingMessage) => Promise<void>) | null; telegram: ((chatId: string, message: OutgoingMessage) => Promise<void>) | null;
} }
@@ -32,10 +33,31 @@ export function createChannels(env: Env): Channels {
}; };
} }
/**
* RFC 8292 requires the VAPID subject to be an `https:` or a `mailto:` URL, and web-push
* throws rather than warns when it is not. `APP_PUBLIC_URL` is http in development, so
* taking it unchecked crashes the API at boot on any machine that has push keys set.
*/
function vapidSubject(env: Env): string | null {
if (env.PUSH_VAPID_SUBJECT) return env.PUSH_VAPID_SUBJECT;
if (env.APP_PUBLIC_URL.startsWith('https:')) return env.APP_PUBLIC_URL;
if (env.SMTP_FROM) return `mailto:${env.SMTP_FROM.replace(/^.*</, '').replace(/>.*$/, '')}`;
return null;
}
function createPush(env: Env): Channels['push'] { function createPush(env: Env): Channels['push'] {
if (!env.PUSH_VAPID_PUBLIC_KEY || !env.PUSH_VAPID_PRIVATE_KEY) return null; if (!env.PUSH_VAPID_PUBLIC_KEY || !env.PUSH_VAPID_PRIVATE_KEY) return null;
webpush.setVapidDetails(env.APP_PUBLIC_URL, env.PUSH_VAPID_PUBLIC_KEY, env.PUSH_VAPID_PRIVATE_KEY); const subject = vapidSubject(env);
if (!subject) {
// A misconfigured optional channel switches itself off. It does not take the API down.
console.warn(
'[boot] push is configured but PUSH_VAPID_SUBJECT is not, and APP_PUBLIC_URL is not https. Push is off.',
);
return null;
}
webpush.setVapidDetails(subject, env.PUSH_VAPID_PUBLIC_KEY, env.PUSH_VAPID_PRIVATE_KEY);
return async (targets, message) => { return async (targets, message) => {
const payload = JSON.stringify({ const payload = JSON.stringify({
@@ -45,11 +67,24 @@ function createPush(env: Env): Channels['push'] {
}); });
// One dead subscription must not stop the others: a phone that was wiped is normal. // One dead subscription must not stop the others: a phone that was wiped is normal.
await Promise.allSettled( const results = await Promise.allSettled(
targets.map((target) => targets.map((target) =>
webpush.sendNotification({ endpoint: target.endpoint, keys: target.keys }, payload), webpush.sendNotification({ endpoint: target.endpoint, keys: target.keys }, payload),
), ),
); );
// 404 and 410 mean the browser threw the subscription away. Anything else is a
// transient failure and the row stays: a network blip is not a reason to stop
// notifying someone forever.
const gone: string[] = [];
results.forEach((result, index) => {
const endpoint = targets[index]?.endpoint;
if (!endpoint || result.status !== 'rejected') return;
const status = (result.reason as { statusCode?: number } | undefined)?.statusCode;
if (status === 404 || status === 410) gone.push(endpoint);
});
return { gone };
}; };
} }
+7 -2
View File
@@ -57,8 +57,13 @@ export async function sendNotification(
}); });
if (targets.length > 0) { if (targets.length > 0) {
await deps.channels.push(targets, message); const { gone } = await deps.channels.push(targets, message);
delivered.push('push'); if (gone.length > 0) {
await deps.db.deleteFrom('push_subscriptions').where('endpoint', 'in', gone).execute();
}
// Everything the push service did not reject is a delivery. A wiped phone is not a
// failure of this send, it is a subscription that no longer exists.
if (gone.length < targets.length) delivered.push('push');
} }
} }
+101 -26
View File
@@ -7,13 +7,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, X } from 'lucide-react'; import { Check, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { CategoryPicker } from '@/components/category-picker'; import { CategoryPicker } from '@/components/category-picker';
import { ConfettiMoment } from '@/components/confetti-moment';
import { Button, buttonClasses } from '@/components/ui/button'; import { Button, buttonClasses } from '@/components/ui/button';
import { CheckDraw } from '@/components/ui/check-draw';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t'; import { useT } from '@/i18n/t';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { DURATION, EASE, gsap, usePrefersReducedMotion } from '@/lib/motion';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** Past this many pixels, letting go commits the swipe. */ /** Past this many pixels, letting go commits the swipe. */
@@ -28,8 +31,13 @@ export function Bandeja({ locale }: { locale: string }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [picking, setPicking] = useState(false); const [picking, setPicking] = useState(false);
const [drag, setDrag] = useState(0); const [celebrating, setCelebrating] = useState(false);
const [dragging, setDragging] = 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 dragStart = useRef<number | null>(null);
const pending = useQuery({ const pending = useQuery({
@@ -37,11 +45,23 @@ export function Bandeja({ locale }: { locale: string }) {
queryFn: ({ signal }) => api.listDocuments({ status: 'needs_review' }, signal), 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 ?? []; const documents = pending.data?.items ?? [];
// Confirming the last card shortens the list under us. Clamping here rather than in an // 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. // effect keeps the render consistent and avoids a cascading update.
const safeIndex = Math.min(index, Math.max(0, documents.length - 1)); const safeIndex = Math.min(index, Math.max(0, documents.length - 1));
const current = documents[safeIndex]; const current = documents[safeIndex];
const next = documents[safeIndex + 1];
const invalidate = useCallback( const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: ['documents'] }), () => queryClient.invalidateQueries({ queryKey: ['documents'] }),
@@ -51,7 +71,7 @@ export function Bandeja({ locale }: { locale: string }) {
const confirm = useMutation({ const confirm = useMutation({
mutationFn: (id: string) => api.confirmDocument(id), mutationFn: (id: string) => api.confirmDocument(id),
onSuccess: async () => { onSuccess: async () => {
setDrag(0); if (nothingConfirmedYet) setCelebrating(true);
await invalidate(); await invalidate();
}, },
}); });
@@ -59,10 +79,7 @@ export function Bandeja({ locale }: { locale: string }) {
const reject = useMutation({ const reject = useMutation({
mutationFn: (args: { id: string; reason: 'not_mine' | 'duplicate' | 'other' }) => mutationFn: (args: { id: string; reason: 'not_mine' | 'duplicate' | 'other' }) =>
api.rejectDocument(args.id, { reason: args.reason }), api.rejectDocument(args.id, { reason: args.reason }),
onSuccess: async () => { onSuccess: invalidate,
setDrag(0);
await invalidate();
},
}); });
const reclassify = useMutation({ 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). // Desktop keyboard mirrors of every gesture (FLOWS.md B4).
useEffect(() => { useEffect(() => {
function onKey(event: KeyboardEvent) { function onKey(event: KeyboardEvent) {
if (!current || picking) return; if (!current || picking) return;
if (event.key === 'j') setIndex(Math.min(safeIndex + 1, documents.length - 1)); 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 === 'k') setIndex(Math.max(safeIndex - 1, 0));
else if (event.key === 'Enter') confirm.mutate(current.id); else if (event.key === 'Enter') flyOut(1, () => confirm.mutate(current.id));
else if (event.key === 'x' || event.key === 'X') reject.mutate({ id: current.id, reason: 'other' }); else if (event.key === 'x' || event.key === 'X')
flyOut(-1, () => reject.mutate({ id: current.id, reason: 'other' }));
else if (/^[1-8]$/.test(event.key)) { else if (/^[1-8]$/.test(event.key)) {
const category = IRP_CATEGORIES[Number(event.key) - 1]; const category = IRP_CATEGORIES[Number(event.key) - 1];
if (category) reclassify.mutate({ id: current.id, irpCategory: category }); if (category) reclassify.mutate({ id: current.id, irpCategory: category });
@@ -91,7 +149,7 @@ export function Bandeja({ locale }: { locale: string }) {
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('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) { if (pending.isPending) {
return ( return (
@@ -124,7 +182,7 @@ export function Bandeja({ locale }: { locale: string }) {
<div className="py-4"> <div className="py-4">
<Card> <Card>
<EmptyState <EmptyState
icon={<Check aria-hidden className="size-8" />} icon={<CheckDraw className="size-8" />}
title={t('bandeja.empty')} title={t('bandeja.empty')}
body={t('bandeja.emptyBody')} body={t('bandeja.emptyBody')}
action={ action={
@@ -143,6 +201,8 @@ export function Bandeja({ locale }: { locale: string }) {
return ( return (
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<ConfettiMoment play={celebrating} />
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<h1 className="text-2xl font-semibold tracking-tight">{t('bandeja.title')}</h1> <h1 className="text-2xl font-semibold tracking-tight">{t('bandeja.title')}</h1>
<span className="text-sm text-[var(--text-muted)]"> <span className="text-sm text-[var(--text-muted)]">
@@ -150,31 +210,45 @@ export function Bandeja({ locale }: { locale: string }) {
</span> </span>
</div> </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 <Card
ref={card}
data-testid="bandeja-card" data-testid="bandeja-card"
className="touch-pan-y space-y-4 select-none" className="relative touch-pan-y space-y-4 select-none"
style={{
transform: `translateX(${drag}px) rotate(${drag / 40}deg)`,
transition: dragging ? 'none' : 'transform 200ms ease-out',
}}
onPointerDown={(event) => { onPointerDown={(event) => {
dragStart.current = event.clientX; dragStart.current = event.clientX;
setDragging(true);
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
}} }}
onPointerMove={(event) => { onPointerMove={(event) => {
if (dragStart.current === null) return; 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={() => { onPointerUp={(event) => {
const offset = drag; const start = dragStart.current;
dragStart.current = null; dragStart.current = null;
setDragging(false); if (start === null) return;
if (offset > COMMIT_PX) confirm.mutate(current.id); const offset = event.clientX - start;
if (offset > COMMIT_PX) flyOut(1, () => confirm.mutate(current.id));
else if (offset < -COMMIT_PX) { else if (offset < -COMMIT_PX) {
settle();
setPicking(true); setPicking(true);
setDrag(0); } else settle();
} else setDrag(0);
}} }}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
@@ -208,6 +282,7 @@ export function Bandeja({ locale }: { locale: string }) {
</ul> </ul>
) : null} ) : null}
</Card> </Card>
</div>
{picking ? ( {picking ? (
<Card className="space-y-3"> <Card className="space-y-3">
@@ -229,7 +304,7 @@ export function Bandeja({ locale }: { locale: string }) {
size="lg" size="lg"
block block
disabled={confirm.isPending} disabled={confirm.isPending}
onClick={() => confirm.mutate(current.id)} onClick={() => flyOut(1, () => confirm.mutate(current.id))}
> >
<Check aria-hidden className="size-4" /> <Check aria-hidden className="size-4" />
{t('bandeja.confirm')} {t('bandeja.confirm')}
@@ -239,7 +314,7 @@ export function Bandeja({ locale }: { locale: string }) {
type="button" type="button"
variant="secondary" variant="secondary"
disabled={reject.isPending} 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" /> <X aria-hidden className="size-4" />
{t('scan.result.discard')} {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 { formatDateLong, formatGs, formatGsAmount, type Locale } from '@impuestos/i18n';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { ConfettiMoment } from '@/components/confetti-moment';
import { FormPreview } from '@/components/form-preview'; import { FormPreview } from '@/components/form-preview';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Reveal } from '@/components/ui/reveal';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n/t'; import { useT } from '@/i18n/t';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@@ -22,6 +24,7 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const confirmDialog = useRef<HTMLDialogElement>(null); const confirmDialog = useRef<HTMLDialogElement>(null);
const [staleWarning, setStaleWarning] = useState<string | null>(null); const [staleWarning, setStaleWarning] = useState<string | null>(null);
const [celebrating, setCelebrating] = useState(false);
const declaration = useQuery({ const declaration = useQuery({
queryKey: ['declarations', id], queryKey: ['declarations', id],
@@ -32,6 +35,8 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
const approve = useMutation({ const approve = useMutation({
mutationFn: () => api.approveDeclaration(id), mutationFn: () => api.approveDeclaration(id),
onSuccess: async () => { onSuccess: async () => {
// One of the three moments FLOWS.md section 8 allows a celebration.
setCelebrating(true);
setStaleWarning(null); setStaleWarning(null);
confirmDialog.current?.close(); confirmDialog.current?.close();
await queryClient.invalidateQueries({ queryKey: ['declarations'] }); await queryClient.invalidateQueries({ queryKey: ['declarations'] });
@@ -75,7 +80,9 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
const officialTitle = `Formulario ${data.formCode}`; const officialTitle = `Formulario ${data.formCode}`;
return ( return (
<div className="space-y-4 py-4 pb-28"> <Reveal className="space-y-4 py-4 pb-28">
<ConfettiMoment play={celebrating} />
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">{officialTitle}</h1> <h1 className="text-2xl font-semibold tracking-tight">{officialTitle}</h1>
<p className="tnum text-sm text-[var(--text-muted)]">{data.period}</p> <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)) })} {t('decl.approvedAt', { date: formatDateLong(locale as Locale, data.approvedAt.slice(0, 10)) })}
</p> </p>
) : null} ) : null}
</div> </Reveal>
); );
} }
@@ -5,6 +5,7 @@ import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Check, Copy } from 'lucide-react'; import { Check, Copy } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { CelebrationCanvas } from '@/components/canvas/celebration-canvas';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { useT } from '@/i18n/t'; import { useT } from '@/i18n/t';
@@ -46,8 +47,10 @@ export function FilingChecklist({
if (filed) { if (filed) {
return ( return (
<Card className="space-y-2"> <Card className="relative isolate space-y-2 overflow-hidden">
{/* A short celebration belongs here (FLOWS.md D3); the motion pass is phase 7. */} {/* 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"> <h2 className="text-xl font-semibold tracking-tight text-positive">
{t('decl.filed.title')} {t('decl.filed.title')}
</h2> </h2>
@@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Link, useRouter } from '@/i18n/navigation'; import { Link, useRouter } from '@/i18n/navigation';
import { useT } from '@/i18n/t'; import { useT } from '@/i18n/t';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { enqueueCapture, listQueue, subscribeToQueue } from '@/lib/offline-queue';
import { captureFrame, decodeQr, decodeQrFromVideo } from '@/lib/qr'; import { captureFrame, decodeQr, decodeQrFromVideo } from '@/lib/qr';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -35,6 +36,16 @@ export function ScanScreen({ locale }: { locale: string }) {
const [showNoQrHint, setShowNoQrHint] = useState(false); const [showNoQrHint, setShowNoQrHint] = useState(false);
const [torchOn, setTorchOn] = useState(false); const [torchOn, setTorchOn] = useState(false);
const [outcome, setOutcome] = useState<Outcome | null>(null); 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({ const upload = useMutation({
mutationFn: async (input: { file: File; qrPayload: string | null }) => { mutationFn: async (input: { file: File; qrPayload: string | null }) => {
@@ -54,6 +65,16 @@ export function ScanScreen({ locale }: { locale: string }) {
const submit = useCallback( const submit = useCallback(
(file: File, qrPayload: string | null) => { (file: File, qrPayload: string | null) => {
if (busy.current) return; 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; busy.current = true;
upload.mutate({ file, qrPayload }, { onSettled: () => (busy.current = false) }); upload.mutate({ file, qrPayload }, { onSettled: () => (busy.current = false) });
}, },
@@ -200,6 +221,12 @@ export function ScanScreen({ locale }: { locale: string }) {
</Card> </Card>
) : null} ) : null}
{queued ? (
<p role="status" className="text-center text-sm text-[var(--text-muted)]">
{t('offline.queued')}
</p>
) : null}
{upload.isError ? ( {upload.isError ? (
<p role="alert" className="text-center text-sm text-overdue"> <p role="alert" className="text-center text-sm text-overdue">
{t('common.error.generic')} {t('common.error.generic')}
@@ -8,6 +8,7 @@ import { TraceableNumber } from '@/components/traceable-number';
import { Button, buttonClasses } from '@/components/ui/button'; import { Button, buttonClasses } from '@/components/ui/button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { Reveal } from '@/components/ui/reveal';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t'; import { useT } from '@/i18n/t';
@@ -53,7 +54,7 @@ export function Dashboard({ locale, fullName }: { locale: Locale; fullName: stri
const nothingYet = !data.hasDocuments; const nothingYet = !data.hasDocuments;
return ( return (
<div className="space-y-4 py-4"> <Reveal className="space-y-4 py-4">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1> <h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1>
<p className="text-sm text-[var(--text-muted)]">{fullName}</p> <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} /> <InsightFeed insights={data.insights} locale={locale} />
</> </>
) : null} ) : null}
</div> </Reveal>
); );
} }
+4
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { AppShell } from '@/components/app-shell'; import { AppShell } from '@/components/app-shell';
import { LanguageSwitcher } from '@/components/language-switcher'; import { LanguageSwitcher } from '@/components/language-switcher';
import { OfflineSync } from '@/components/offline-sync';
import { PwaProvider } from '@/components/pwa-provider';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t'; 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. */} {/* Bottom padding clears the tab bar and the scan button above it. */}
<main className="flex-1 px-5 pb-36">{children}</main> <main className="flex-1 px-5 pb-36">{children}</main>
<AppShell /> <AppShell />
<OfflineSync />
<PwaProvider />
</div> </div>
); );
} }
@@ -3,6 +3,8 @@
import type { ProfileDto } from '@impuestos/contracts'; import type { ProfileDto } from '@impuestos/contracts';
import { SUPPORTED_LOCALES, type Locale } from '@impuestos/i18n'; import { SUPPORTED_LOCALES, type Locale } from '@impuestos/i18n';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 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 { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@@ -83,11 +85,14 @@ export function NotificationsSection({ profile }: { profile: ProfileDto }) {
</select> </select>
</label> </label>
{/* Push and Telegram are hidden until they are configured (FLOWS.md section 9). {/* Push shows itself only where it can work; Telegram is linked from the bot
They are wired with the notification channels in phase 4. */} rather than here (FLOWS.md section 9: unconfigured channels stay hidden). */}
<PushOptIn />
</div> </div>
) : null} ) : null}
<ThemeToggle />
<div className="flex items-center justify-between gap-4 border-t pt-4"> <div className="flex items-center justify-between gap-4 border-t pt-4">
<span className="text-sm">{t('common.language')}</span> <span className="text-sm">{t('common.language')}</span>
<select <select
@@ -8,6 +8,8 @@ import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n/t'; import { useT } from '@/i18n/t';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { DangerZone } from './danger-zone'; import { DangerZone } from './danger-zone';
import { InstallPrompt } from '@/components/install-prompt';
import { Reveal } from '@/components/ui/reveal';
import { DependentsSection } from './dependents-section'; import { DependentsSection } from './dependents-section';
import { IdentitySection } from './identity-section'; import { IdentitySection } from './identity-section';
import { NotificationsSection } from './notifications-section'; import { NotificationsSection } from './notifications-section';
@@ -53,12 +55,13 @@ export function ProfileScreen() {
} }
return ( 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> <h1 className="text-2xl font-semibold tracking-tight">{t('profile.title')}</h1>
<IdentitySection profile={profile.data} /> <IdentitySection profile={profile.data} />
<DependentsSection /> <DependentsSection />
<NotificationsSection profile={profile.data} /> <NotificationsSection profile={profile.data} />
<InstallPrompt />
<DangerZone /> <DangerZone />
</div> </Reveal>
); );
} }
+5 -3
View File
@@ -1,4 +1,5 @@
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import { HeroCanvas } from '@/components/canvas/hero-canvas';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { getT } from '@/i18n/t'; import { getT } from '@/i18n/t';
import { RucHook } from './ruc-hook'; import { RucHook } from './ruc-hook';
@@ -16,9 +17,10 @@ export default async function LandingPage({ params }: { params: Promise<{ locale
return ( return (
<div className="mx-auto w-full max-w-5xl px-5"> <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 {/* The one hero effect FLOWS.md A1 allows, behind the content and off under
rest of the motion work in phase 7, reduced-motion safe and degrading to this. */} reduced motion. */}
<section className="py-14 sm:py-20"> <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"> <h1 className="max-w-2xl text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
{t('landing.hero.title')} {t('landing.hero.title')}
</h1> </h1>
+23 -2
View File
@@ -1,5 +1,5 @@
import { isLocale } from '@impuestos/i18n'; import { isLocale } from '@impuestos/i18n';
import type { Metadata } from 'next'; import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
@@ -7,6 +7,7 @@ import { getT } from '@/i18n/t';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Providers } from '@/components/providers'; import { Providers } from '@/components/providers';
import { ThemeScript } from '@/components/theme-script';
import { routing } from '@/i18n/routing'; import { routing } from '@/i18n/routing';
import '../globals.css'; import '../globals.css';
@@ -14,6 +15,17 @@ import '../globals.css';
// CSP tight (SPEC.md section 14). // CSP tight (SPEC.md section 14).
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' }); 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() { export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale })); return routing.locales.map((locale) => ({ locale }));
} }
@@ -23,7 +35,13 @@ export async function generateMetadata(props: {
}): Promise<Metadata> { }): Promise<Metadata> {
const { locale } = await props.params; const { locale } = await props.params;
const t = await getT(locale); 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({ export default async function LocaleLayout({
@@ -39,6 +57,9 @@ export default async function LocaleLayout({
return ( return (
<html lang={locale} className={inter.variable} suppressHydrationWarning> <html lang={locale} className={inter.variable} suppressHydrationWarning>
<head>
<ThemeScript />
</head>
<body className="min-h-dvh font-sans antialiased"> <body className="min-h-dvh font-sans antialiased">
<NextIntlClientProvider> <NextIntlClientProvider>
<Providers>{children}</Providers> <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', output: 'standalone',
// /api is proxied at runtime by app/api/[...path]/route.ts rather than by a rewrite, // /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. // 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); export default createNextIntlPlugin('./src/i18n/request.ts')(nextConfig);
+1
View File
@@ -17,6 +17,7 @@
"better-auth": "^1.7.2", "better-auth": "^1.7.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"gsap": "^3.15.0",
"lucide-react": "^1.40.0", "lucide-react": "^1.40.0",
"next": "^16.3.4", "next": "^16.3.4",
"next-intl": "^4.14.2", "next-intl": "^4.14.2",
+67 -3
View File
@@ -1,10 +1,74 @@
import createMiddleware from 'next-intl/middleware'; import createMiddleware from 'next-intl/middleware';
import { type NextRequest, NextResponse } from 'next/server';
import { routing } from './src/i18n/routing'; 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 = { export const config = {
// Everything except /api (forwarded to the API by the rewrite), Next internals and // Everything except /api (forwarded to the API by the route handler), Next internals and
// static files. Locale routing must not touch API requests. // static files.
matcher: ['/((?!api|healthz|_next|_vercel|.*\\..*).*)'], 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 type { TraceKind } from '@impuestos/contracts';
import { formatDateShort, formatGs, type Locale } from '@impuestos/i18n'; import { formatDateShort, formatGs, type Locale } from '@impuestos/i18n';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useRef } from 'react'; import { useCallback, useRef } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t'; import { useT } from '@/i18n/t';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { DURATION, EASE, gsap, useCountUp, usePrefersReducedMotion } from '@/lib/motion';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** /**
@@ -30,6 +31,24 @@ export function TraceableNumber({
}) { }) {
const t = useT(); const t = useT();
const dialog = useRef<HTMLDialogElement>(null); 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({ const trace = useQuery({
queryKey: ['dashboard', 'trace', kind], queryKey: ['dashboard', 'trace', kind],
@@ -43,7 +62,7 @@ export function TraceableNumber({
type="button" type="button"
onClick={() => { onClick={() => {
void trace.refetch(); void trace.refetch();
dialog.current?.showModal(); open();
}} }}
className={cn( className={cn(
'tnum text-left underline decoration-dotted underline-offset-4', 'tnum text-left underline decoration-dotted underline-offset-4',
@@ -52,7 +71,7 @@ export function TraceableNumber({
className, className,
)} )}
> >
{formatGs(value)} <span ref={figure}>{formatGs(value)}</span>
</button> </button>
<dialog <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'; 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 ( return (
<div <div
ref={ref}
className={cn( className={cn(
'rounded-2xl border bg-[var(--surface-raised)] p-6 shadow-[0_1px_2px_rgba(0,0,0,0.04)]', 'rounded-2xl border bg-[var(--surface-raised)] p-6 shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
className, 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>
);
}
+26 -5
View File
@@ -1,27 +1,48 @@
'use client';
import { formatGs } from '@impuestos/i18n'; import { formatGs } from '@impuestos/i18n';
import { useCallback } from 'react';
import { useCountUp } from '@/lib/motion';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** /**
* The only way money reaches the screen. Always `Gs. 1.234.567`, never localized, always * 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). * 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({ export function Money({
value, value,
size = 'md', size = 'md',
animate = false,
className, className,
}: { }: {
value: number; value: number;
size?: 'md' | 'lg'; size?: 'md' | 'lg';
animate?: boolean;
className?: string; className?: string;
}) { }) {
return ( const classes = cn(
<span
className={cn(
'tnum', 'tnum',
size === 'lg' ? 'text-4xl font-semibold tracking-tight' : 'font-medium', size === 'lg' ? 'text-4xl font-semibold tracking-tight' : 'font-medium',
className, 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 ref={ref} className={className}>
{formatGs(value)} {formatGs(value)}
</span> </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';
}
}
+53
View File
@@ -0,0 +1,53 @@
import { fileURLToPath } from 'node:url';
import { es } from '@impuestos/i18n';
import { expect, test, type Page } from '@playwright/test';
/**
* SPEC.md section 13: a scan taken with no network is queued and syncs when there is one.
*
* Serial and desktop only, like the other ingestion paths: it adds a document to the shared
* demo account.
*/
test.describe.configure({ mode: 'serial' });
const fixture = (name: string) =>
fileURLToPath(new URL(`../fixtures/${name}.png`, import.meta.url));
async function signIn(page: Page): Promise<void> {
await page.goto('/es/login');
await page.getByLabel(es['auth.register.email']).fill('maria@demo.local');
await page.getByLabel(es['auth.login.password']).fill('demo-maria-1');
await page.getByRole('button', { name: es['auth.login.submit'] }).click();
await expect(page).not.toHaveURL(/\/login$/, { timeout: 20_000 });
}
test.describe('offline capture', () => {
test.beforeEach(() => {
test.skip(test.info().project.name !== 'desktop', 'adds a document to shared seed data');
});
test('queues a scan with no network and sends it when there is one', async ({ page, context }) => {
await signIn(page);
await page.goto('/es/escanear');
// The scanner has to be up before the network goes: it is a client screen either way,
// but waiting on it avoids racing the first paint.
await expect(page.locator('input[type="file"]')).toBeAttached();
await context.setOffline(true);
await page.locator('input[type="file"]').setInputFiles(fixture('factura-qr-farmacia'));
// Nothing is lost and the reader is told so, on the screen and in the shell.
await expect(page.getByText(es['offline.queued']).first()).toBeVisible({ timeout: 20_000 });
await context.setOffline(false);
// The chip clears itself once the capture has gone.
await expect(page.getByText(es['offline.queued'])).toHaveCount(0, { timeout: 30_000 });
// And the document is really there, whether this run was the first to scan it or not.
await page.goto('/es/comprobantes');
await expect(page.getByText('FARMACIA', { exact: false }).first()).toBeVisible({
timeout: 20_000,
});
});
});
+6
View File
@@ -109,6 +109,12 @@ export default tseslint.config(
}, },
}, },
// The service worker runs in its own global scope, not the window's.
{
files: ['apps/web/src/lib/service-worker.js'],
languageOptions: { globals: globals.serviceworker },
},
// Plain Node scripts: no JSX, no browser globals. // Plain Node scripts: no JSX, no browser globals.
{ {
files: ['**/scripts/**/*.mjs', '*.config.{js,mjs,ts}', '**/*.config.{js,mjs,ts}'], files: ['**/scripts/**/*.mjs', '*.config.{js,mjs,ts}', '**/*.config.{js,mjs,ts}'],
+10
View File
@@ -29,6 +29,8 @@ import {
OkDto, OkDto,
ProfileDto, ProfileDto,
type ProfileInput, type ProfileInput,
PushConfigDto,
type PushSubscriptionInput,
type RejectInput, type RejectInput,
type RoleChangeInput, type RoleChangeInput,
SessionDto, SessionDto,
@@ -228,6 +230,14 @@ export function createApiClient(options: ApiClientOptions = {}) {
/** The PDF is a stream, so it is linked to rather than fetched. */ /** The PDF is a stream, so it is linked to rather than fetched. */
declarationPdfUrl: (id: string) => `/api/declarations/${encodeURIComponent(id)}/pdf`, declarationPdfUrl: (id: string) => `/api/declarations/${encodeURIComponent(id)}/pdf`,
// Push
getPushConfig: (signal?: AbortSignal) =>
request('GET', '/push/config', { schema: PushConfigDto, ...(signal ? { signal } : {}) }),
subscribeToPush: (body: PushSubscriptionInput) =>
request('POST', '/push/subscribe', { schema: OkDto, body }),
unsubscribeFromPush: (endpoint: string) =>
request('DELETE', '/push/subscribe', { schema: OkDto, body: { endpoint } }),
// Notifications // Notifications
getNotificationPrefs: (signal?: AbortSignal) => getNotificationPrefs: (signal?: AbortSignal) =>
request('GET', '/me/notification-prefs', { request('GET', '/me/notification-prefs', {
+17
View File
@@ -390,6 +390,23 @@ export const SessionDto = z.object({
}); });
export type SessionDto = z.infer<typeof SessionDto>; export type SessionDto = z.infer<typeof SessionDto>;
/**
* `POST /push/subscribe`. The shape a browser's PushSubscription serialises to.
*/
export const PushSubscriptionInput = z.object({
endpoint: z.string().url(),
keys: z.object({ p256dh: z.string().min(1), auth: z.string().min(1) }),
});
export type PushSubscriptionInput = z.infer<typeof PushSubscriptionInput>;
/**
* SPEC-GAP: the browser needs the VAPID public key to subscribe, and CONTRACTS.md has no
* endpoint that carries it. It is public by definition, so this is unauthenticated; a null
* key means the deployment has push switched off and the UI hides the offer.
*/
export const PushConfigDto = z.object({ publicKey: z.string().nullable() });
export type PushConfigDto = z.infer<typeof PushConfigDto>;
/* Admin (CONTRACTS.md section 3, FLOWS.md Flow H). Staff and superadmin only. */ /* Admin (CONTRACTS.md section 3, FLOWS.md Flow H). Staff and superadmin only. */
export const AdminUserDto = z.object({ export const AdminUserDto = z.object({
+2
View File
@@ -58,6 +58,8 @@ export {
HealthDto, HealthDto,
ReadyDto, ReadyDto,
SessionDto, SessionDto,
PushSubscriptionInput,
PushConfigDto,
AdminUserDto, AdminUserDto,
AdminUserSearchDto, AdminUserSearchDto,
AdminUserOverviewDto, AdminUserOverviewDto,
+20
View File
@@ -490,4 +490,24 @@ export const en: Record<MessageKey, string> = {
"admin.audit.apply": "Filter", "admin.audit.apply": "Filter",
"admin.audit.clear": "Clear", "admin.audit.clear": "Clear",
"admin.audit.total": "{count} entries", "admin.audit.total": "{count} entries",
// Offline and install (phase 7)
"offline.title": "No connection",
"offline.body": "We could not load this screen. Anything you saved syncs on its own once you are back online.",
"offline.queued": "Syncs when you reconnect",
"offline.queuedCount": "{count} waiting to sync",
"offline.syncFailed": "We could not sync {count}. Try again.",
"install.title": "Install the app",
"install.body": "Add it to your home screen and scan faster.",
"install.action": "Install",
"install.ios": "On iPhone: tap Share, then Add to Home Screen.",
"push.title": "Alerts on your phone",
"push.body": "Only what matters: a deadline coming up and a declaration ready to review.",
"push.enable": "Turn on alerts",
"push.enabled": "Alerts are on",
"push.denied": "Alerts are blocked in the browser. Turn them on from the site settings.",
"push.unsupported": "This browser does not support alerts.",
"theme.title": "Theme",
"theme.system": "Match the system",
"theme.light": "Light",
"theme.dark": "Dark",
}; };
+22
View File
@@ -280,6 +280,28 @@ export const esExtra = {
"admin.audit.clear": "Limpiar", "admin.audit.clear": "Limpiar",
"admin.audit.total": "{count} movimientos", "admin.audit.total": "{count} movimientos",
// Offline and install (phase 7)
"offline.title": "Sin conexion",
"offline.body": "No pudimos cargar esta pantalla. Tus comprobantes guardados se sincronizan solos cuando vuelva la señal.",
"offline.queued": "Se sincroniza al conectarte",
"offline.queuedCount": "{count} para sincronizar",
"offline.syncFailed": "No pudimos sincronizar {count}. Probá de nuevo.",
"install.title": "Instalá la app",
"install.body": "Agregala a tu pantalla de inicio y escanea mas rapido.",
"install.action": "Instalar",
"install.ios": "En iPhone: tocá Compartir y despues Agregar a inicio.",
"push.title": "Avisos en el celular",
"push.body": "Te avisamos solo lo importante: un vencimiento cerca y una declaracion lista.",
"push.enable": "Activar avisos",
"push.enabled": "Avisos activados",
"push.denied": "Los avisos estan bloqueados en el navegador. Activalos desde la configuracion del sitio.",
"push.unsupported": "Este navegador no soporta avisos.",
"theme.title": "Tema",
"theme.system": "Como el sistema",
"theme.light": "Claro",
"theme.dark": "Oscuro",
// Chrome // Chrome
"common.language": "Idioma", "common.language": "Idioma",
"common.languageEs": "Español", "common.languageEs": "Español",
+11 -3
View File
@@ -76,7 +76,7 @@ importers:
version: link:../../packages/rules version: link:../../packages/rules
better-auth: better-auth:
specifier: ^1.7.2 specifier: ^1.7.2
version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0)
better-sqlite3: better-sqlite3:
specifier: ^13.0.3 specifier: ^13.0.3
version: 13.0.3 version: 13.0.3
@@ -158,13 +158,16 @@ importers:
version: 5.102.8(react@19.2.8) version: 5.102.8(react@19.2.8)
better-auth: better-auth:
specifier: ^1.7.2 specifier: ^1.7.2
version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0)
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
gsap:
specifier: ^3.15.0
version: 3.15.0
lucide-react: lucide-react:
specifier: ^1.40.0 specifier: ^1.40.0
version: 1.40.0(react@19.2.8) version: 1.40.0(react@19.2.8)
@@ -2383,6 +2386,9 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
gsap@3.15.0:
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
has-bigints@1.1.0: has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5035,7 +5041,7 @@ snapshots:
baseline-browser-mapping@2.11.21: {} baseline-browser-mapping@2.11.21: {}
better-auth@1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))): better-auth@1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0):
dependencies: dependencies:
'@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3)
'@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)
@@ -5632,6 +5638,8 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
gsap@3.15.0: {}
has-bigints@1.1.0: {} has-bigints@1.1.0: {}
has-property-descriptors@1.0.2: has-property-descriptors@1.0.2: