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:
co-authored by
Claude Opus 5
parent
4c39926483
commit
e4eb1617d1
@@ -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
|
||||
PUSH_VAPID_PUBLIC_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.
|
||||
SMTP_HOST=
|
||||
|
||||
@@ -10,6 +10,7 @@ import { documentRoutes } from './routes/documents';
|
||||
import { fileRoutes } from './routes/files';
|
||||
import { lookupRoutes } from './routes/lookup';
|
||||
import { meRoutes } from './routes/me';
|
||||
import { pushRoutes } from './routes/push';
|
||||
|
||||
export interface AppHandle {
|
||||
app: Hono<AppEnv>;
|
||||
@@ -59,6 +60,7 @@ export function createApp(deps: AppDeps): AppHandle {
|
||||
api.route('/deadlines', deadlineRoutes(deps));
|
||||
api.route('/declarations', declarationRoutes(deps));
|
||||
api.route('/admin', adminRoutes(deps));
|
||||
api.route('/push', pushRoutes(deps));
|
||||
|
||||
app.route('/api', api);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -44,6 +44,8 @@ const EnvObject = z.object({
|
||||
|
||||
PUSH_VAPID_PUBLIC_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_PORT: z.coerce.number().int().positive().max(65535).default(587),
|
||||
|
||||
@@ -19,6 +19,7 @@ function recordingChannels(): Channels & { sent: { channel: string; message: Out
|
||||
sent,
|
||||
push: async (_targets, message) => {
|
||||
sent.push({ channel: 'push', message });
|
||||
return { gone: [] };
|
||||
},
|
||||
email: async (_to, _subject, message) => {
|
||||
sent.push({ channel: 'email', message });
|
||||
|
||||
@@ -19,7 +19,8 @@ export interface PushTarget {
|
||||
* the fan-out skips it (FLOWS.md section 9).
|
||||
*/
|
||||
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;
|
||||
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'] {
|
||||
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) => {
|
||||
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.
|
||||
await Promise.allSettled(
|
||||
const results = await Promise.allSettled(
|
||||
targets.map((target) =>
|
||||
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 };
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,13 @@ export async function sendNotification(
|
||||
});
|
||||
|
||||
if (targets.length > 0) {
|
||||
await deps.channels.push(targets, message);
|
||||
delivered.push('push');
|
||||
const { gone } = await deps.channels.push(targets, message);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { CategoryPicker } from '@/components/category-picker';
|
||||
import { ConfettiMoment } from '@/components/confetti-moment';
|
||||
import { Button, buttonClasses } from '@/components/ui/button';
|
||||
import { CheckDraw } from '@/components/ui/check-draw';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/t';
|
||||
import { api } from '@/lib/api';
|
||||
import { DURATION, EASE, gsap, usePrefersReducedMotion } from '@/lib/motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Past this many pixels, letting go commits the swipe. */
|
||||
@@ -28,8 +31,13 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [drag, setDrag] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [celebrating, setCelebrating] = useState(false);
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
// The drag lives on the element rather than in state: a re-render per pointer move is
|
||||
// both slower and a fight with GSAP over who owns the transform.
|
||||
const card = useRef<HTMLDivElement>(null);
|
||||
const nextCard = useRef<HTMLDivElement>(null);
|
||||
const dragStart = useRef<number | null>(null);
|
||||
|
||||
const pending = useQuery({
|
||||
@@ -37,11 +45,23 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
queryFn: ({ signal }) => api.listDocuments({ status: 'needs_review' }, signal),
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether anything has ever been confirmed. FLOWS.md A6 makes the first confirmed
|
||||
* document the moment worth celebrating, and it is only the first one: the count is read
|
||||
* before the confirm so the answer is about what the user had, not what they now have.
|
||||
*/
|
||||
const confirmed = useQuery({
|
||||
queryKey: ['documents', { status: 'confirmed' }],
|
||||
queryFn: ({ signal }) => api.listDocuments({ status: 'confirmed' }, signal),
|
||||
});
|
||||
const nothingConfirmedYet = confirmed.data?.total === 0;
|
||||
|
||||
const documents = pending.data?.items ?? [];
|
||||
// Confirming the last card shortens the list under us. Clamping here rather than in an
|
||||
// effect keeps the render consistent and avoids a cascading update.
|
||||
const safeIndex = Math.min(index, Math.max(0, documents.length - 1));
|
||||
const current = documents[safeIndex];
|
||||
const next = documents[safeIndex + 1];
|
||||
|
||||
const invalidate = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: ['documents'] }),
|
||||
@@ -51,7 +71,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
const confirm = useMutation({
|
||||
mutationFn: (id: string) => api.confirmDocument(id),
|
||||
onSuccess: async () => {
|
||||
setDrag(0);
|
||||
if (nothingConfirmedYet) setCelebrating(true);
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
@@ -59,10 +79,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
const reject = useMutation({
|
||||
mutationFn: (args: { id: string; reason: 'not_mine' | 'duplicate' | 'other' }) =>
|
||||
api.rejectDocument(args.id, { reason: args.reason }),
|
||||
onSuccess: async () => {
|
||||
setDrag(0);
|
||||
await invalidate();
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const reclassify = useMutation({
|
||||
@@ -74,14 +91,55 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Sends the card off the way it was pushed, brings the one behind it forward, and only
|
||||
* then does the thing (FLOWS.md B4: fly out on commit, next card scales up). Under
|
||||
* reduced motion the action happens immediately and nothing moves.
|
||||
*/
|
||||
const flyOut = useCallback(
|
||||
(direction: 1 | -1, run: () => void) => {
|
||||
const node = card.current;
|
||||
if (reduced || !node) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
gsap.to(node, {
|
||||
x: direction * 520,
|
||||
rotation: direction * 16,
|
||||
autoAlpha: 0,
|
||||
duration: DURATION.base,
|
||||
ease: 'power2.in',
|
||||
onComplete: () => {
|
||||
gsap.set(node, { clearProps: 'all' });
|
||||
run();
|
||||
},
|
||||
});
|
||||
if (nextCard.current) {
|
||||
gsap.to(nextCard.current, { scale: 1, opacity: 1, duration: DURATION.base, ease: EASE });
|
||||
}
|
||||
},
|
||||
[reduced],
|
||||
);
|
||||
|
||||
const settle = useCallback(() => {
|
||||
const node = card.current;
|
||||
if (!node) return;
|
||||
if (reduced) {
|
||||
gsap.set(node, { x: 0, rotation: 0 });
|
||||
return;
|
||||
}
|
||||
gsap.to(node, { x: 0, rotation: 0, duration: DURATION.base, ease: EASE });
|
||||
}, [reduced]);
|
||||
|
||||
// Desktop keyboard mirrors of every gesture (FLOWS.md B4).
|
||||
useEffect(() => {
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (!current || picking) return;
|
||||
if (event.key === 'j') setIndex(Math.min(safeIndex + 1, documents.length - 1));
|
||||
else if (event.key === 'k') setIndex(Math.max(safeIndex - 1, 0));
|
||||
else if (event.key === 'Enter') confirm.mutate(current.id);
|
||||
else if (event.key === 'x' || event.key === 'X') reject.mutate({ id: current.id, reason: 'other' });
|
||||
else if (event.key === 'Enter') flyOut(1, () => confirm.mutate(current.id));
|
||||
else if (event.key === 'x' || event.key === 'X')
|
||||
flyOut(-1, () => reject.mutate({ id: current.id, reason: 'other' }));
|
||||
else if (/^[1-8]$/.test(event.key)) {
|
||||
const category = IRP_CATEGORIES[Number(event.key) - 1];
|
||||
if (category) reclassify.mutate({ id: current.id, irpCategory: category });
|
||||
@@ -91,7 +149,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [current, documents.length, safeIndex, picking, confirm, reject, reclassify]);
|
||||
}, [current, documents.length, safeIndex, picking, confirm, reject, reclassify, flyOut]);
|
||||
|
||||
if (pending.isPending) {
|
||||
return (
|
||||
@@ -124,7 +182,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
<div className="py-4">
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<Check aria-hidden className="size-8" />}
|
||||
icon={<CheckDraw className="size-8" />}
|
||||
title={t('bandeja.empty')}
|
||||
body={t('bandeja.emptyBody')}
|
||||
action={
|
||||
@@ -143,6 +201,8 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
<ConfettiMoment play={celebrating} />
|
||||
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('bandeja.title')}</h1>
|
||||
<span className="text-sm text-[var(--text-muted)]">
|
||||
@@ -150,31 +210,45 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/*
|
||||
The shoulder of the next card, so the stack is visible and committing this one
|
||||
has somewhere to go. A shoulder rather than a whole card behind: the front card's
|
||||
height depends on its content, and a full card would sit entirely hidden behind a
|
||||
tall one and entirely exposed behind a short one.
|
||||
*/}
|
||||
{next ? (
|
||||
<div
|
||||
ref={nextCard}
|
||||
aria-hidden
|
||||
className="absolute inset-x-4 -top-2 h-8 origin-bottom scale-95 rounded-2xl border bg-[var(--surface-raised)] opacity-70"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card
|
||||
ref={card}
|
||||
data-testid="bandeja-card"
|
||||
className="touch-pan-y space-y-4 select-none"
|
||||
style={{
|
||||
transform: `translateX(${drag}px) rotate(${drag / 40}deg)`,
|
||||
transition: dragging ? 'none' : 'transform 200ms ease-out',
|
||||
}}
|
||||
className="relative touch-pan-y space-y-4 select-none"
|
||||
onPointerDown={(event) => {
|
||||
dragStart.current = event.clientX;
|
||||
setDragging(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (dragStart.current === null) return;
|
||||
setDrag(event.clientX - dragStart.current);
|
||||
const offset = event.clientX - dragStart.current;
|
||||
gsap.set(card.current, { x: offset, rotation: offset / 40 });
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
const offset = drag;
|
||||
onPointerUp={(event) => {
|
||||
const start = dragStart.current;
|
||||
dragStart.current = null;
|
||||
setDragging(false);
|
||||
if (offset > COMMIT_PX) confirm.mutate(current.id);
|
||||
if (start === null) return;
|
||||
const offset = event.clientX - start;
|
||||
|
||||
if (offset > COMMIT_PX) flyOut(1, () => confirm.mutate(current.id));
|
||||
else if (offset < -COMMIT_PX) {
|
||||
settle();
|
||||
setPicking(true);
|
||||
setDrag(0);
|
||||
} else setDrag(0);
|
||||
} else settle();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -208,6 +282,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
</ul>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{picking ? (
|
||||
<Card className="space-y-3">
|
||||
@@ -229,7 +304,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
size="lg"
|
||||
block
|
||||
disabled={confirm.isPending}
|
||||
onClick={() => confirm.mutate(current.id)}
|
||||
onClick={() => flyOut(1, () => confirm.mutate(current.id))}
|
||||
>
|
||||
<Check aria-hidden className="size-4" />
|
||||
{t('bandeja.confirm')}
|
||||
@@ -239,7 +314,7 @@ export function Bandeja({ locale }: { locale: string }) {
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={reject.isPending}
|
||||
onClick={() => reject.mutate({ id: current.id, reason: 'other' })}
|
||||
onClick={() => flyOut(-1, () => reject.mutate({ id: current.id, reason: 'other' }))}
|
||||
>
|
||||
<X aria-hidden className="size-4" />
|
||||
{t('scan.result.discard')}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { isApiError, type DeclarationDto } from '@impuestos/contracts';
|
||||
import { formatDateLong, formatGs, formatGsAmount, type Locale } from '@impuestos/i18n';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { ConfettiMoment } from '@/components/confetti-moment';
|
||||
import { FormPreview } from '@/components/form-preview';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Reveal } from '@/components/ui/reveal';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useT } from '@/i18n/t';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -22,6 +24,7 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
|
||||
const queryClient = useQueryClient();
|
||||
const confirmDialog = useRef<HTMLDialogElement>(null);
|
||||
const [staleWarning, setStaleWarning] = useState<string | null>(null);
|
||||
const [celebrating, setCelebrating] = useState(false);
|
||||
|
||||
const declaration = useQuery({
|
||||
queryKey: ['declarations', id],
|
||||
@@ -32,6 +35,8 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
|
||||
const approve = useMutation({
|
||||
mutationFn: () => api.approveDeclaration(id),
|
||||
onSuccess: async () => {
|
||||
// One of the three moments FLOWS.md section 8 allows a celebration.
|
||||
setCelebrating(true);
|
||||
setStaleWarning(null);
|
||||
confirmDialog.current?.close();
|
||||
await queryClient.invalidateQueries({ queryKey: ['declarations'] });
|
||||
@@ -75,7 +80,9 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
|
||||
const officialTitle = `Formulario ${data.formCode}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-4 pb-28">
|
||||
<Reveal className="space-y-4 py-4 pb-28">
|
||||
<ConfettiMoment play={celebrating} />
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{officialTitle}</h1>
|
||||
<p className="tnum text-sm text-[var(--text-muted)]">{data.period}</p>
|
||||
@@ -172,7 +179,7 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string }
|
||||
{t('decl.approvedAt', { date: formatDateLong(locale as Locale, data.approvedAt.slice(0, 10)) })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { CelebrationCanvas } from '@/components/canvas/celebration-canvas';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useT } from '@/i18n/t';
|
||||
@@ -46,8 +47,10 @@ export function FilingChecklist({
|
||||
|
||||
if (filed) {
|
||||
return (
|
||||
<Card className="space-y-2">
|
||||
{/* A short celebration belongs here (FLOWS.md D3); the motion pass is phase 7. */}
|
||||
<Card className="relative isolate space-y-2 overflow-hidden">
|
||||
{/* The second and last canvas spot (FLOWS.md D3), only in the moment it happens:
|
||||
a reload of an already filed declaration is not a new success. */}
|
||||
{markFiled.isSuccess ? <CelebrationCanvas /> : null}
|
||||
<h2 className="text-xl font-semibold tracking-tight text-positive">
|
||||
{t('decl.filed.title')}
|
||||
</h2>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Link, useRouter } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/t';
|
||||
import { api } from '@/lib/api';
|
||||
import { enqueueCapture, listQueue, subscribeToQueue } from '@/lib/offline-queue';
|
||||
import { captureFrame, decodeQr, decodeQrFromVideo } from '@/lib/qr';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -35,6 +36,16 @@ export function ScanScreen({ locale }: { locale: string }) {
|
||||
const [showNoQrHint, setShowNoQrHint] = useState(false);
|
||||
const [torchOn, setTorchOn] = useState(false);
|
||||
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
||||
// Whether anything of this reader's is still waiting to be sent. It follows the queue
|
||||
// rather than being set once, so the notice goes away when the capture actually lands.
|
||||
const [queued, setQueued] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => void listQueue().then((items) => setQueued(items.length > 0));
|
||||
const unsubscribe = subscribeToQueue(check);
|
||||
void Promise.resolve().then(check);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (input: { file: File; qrPayload: string | null }) => {
|
||||
@@ -54,6 +65,16 @@ export function ScanScreen({ locale }: { locale: string }) {
|
||||
const submit = useCallback(
|
||||
(file: File, qrPayload: string | null) => {
|
||||
if (busy.current) return;
|
||||
|
||||
/**
|
||||
* Offline, the capture is kept rather than lost (FLOWS.md B2). The chip in the shell
|
||||
* sends it the moment there is a connection, so this screen can say so and move on.
|
||||
*/
|
||||
if (!navigator.onLine) {
|
||||
void enqueueCapture(file, qrPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
busy.current = true;
|
||||
upload.mutate({ file, qrPayload }, { onSettled: () => (busy.current = false) });
|
||||
},
|
||||
@@ -200,6 +221,12 @@ export function ScanScreen({ locale }: { locale: string }) {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{queued ? (
|
||||
<p role="status" className="text-center text-sm text-[var(--text-muted)]">
|
||||
{t('offline.queued')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{upload.isError ? (
|
||||
<p role="alert" className="text-center text-sm text-overdue">
|
||||
{t('common.error.generic')}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TraceableNumber } from '@/components/traceable-number';
|
||||
import { Button, buttonClasses } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { Reveal } from '@/components/ui/reveal';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/t';
|
||||
@@ -53,7 +54,7 @@ export function Dashboard({ locale, fullName }: { locale: Locale; fullName: stri
|
||||
const nothingYet = !data.hasDocuments;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
<Reveal className="space-y-4 py-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">{fullName}</p>
|
||||
@@ -89,7 +90,7 @@ export function Dashboard({ locale, fullName }: { locale: Locale; fullName: stri
|
||||
<InsightFeed insights={data.insights} locale={locale} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AppShell } from '@/components/app-shell';
|
||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||
import { OfflineSync } from '@/components/offline-sync';
|
||||
import { PwaProvider } from '@/components/pwa-provider';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/t';
|
||||
|
||||
@@ -22,6 +24,8 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
{/* Bottom padding clears the tab bar and the scan button above it. */}
|
||||
<main className="flex-1 px-5 pb-36">{children}</main>
|
||||
<AppShell />
|
||||
<OfflineSync />
|
||||
<PwaProvider />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import type { ProfileDto } from '@impuestos/contracts';
|
||||
import { SUPPORTED_LOCALES, type Locale } from '@impuestos/i18n';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { PushOptIn } from '@/components/push-optin';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -83,11 +85,14 @@ export function NotificationsSection({ profile }: { profile: ProfileDto }) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Push and Telegram are hidden until they are configured (FLOWS.md section 9).
|
||||
They are wired with the notification channels in phase 4. */}
|
||||
{/* Push shows itself only where it can work; Telegram is linked from the bot
|
||||
rather than here (FLOWS.md section 9: unconfigured channels stay hidden). */}
|
||||
<PushOptIn />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-4">
|
||||
<span className="text-sm">{t('common.language')}</span>
|
||||
<select
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useT } from '@/i18n/t';
|
||||
import { api } from '@/lib/api';
|
||||
import { DangerZone } from './danger-zone';
|
||||
import { InstallPrompt } from '@/components/install-prompt';
|
||||
import { Reveal } from '@/components/ui/reveal';
|
||||
import { DependentsSection } from './dependents-section';
|
||||
import { IdentitySection } from './identity-section';
|
||||
import { NotificationsSection } from './notifications-section';
|
||||
@@ -53,12 +55,13 @@ export function ProfileScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
<Reveal className="space-y-4 py-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('profile.title')}</h1>
|
||||
<IdentitySection profile={profile.data} />
|
||||
<DependentsSection />
|
||||
<NotificationsSection profile={profile.data} />
|
||||
<InstallPrompt />
|
||||
<DangerZone />
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { HeroCanvas } from '@/components/canvas/hero-canvas';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { getT } from '@/i18n/t';
|
||||
import { RucHook } from './ruc-hook';
|
||||
@@ -16,9 +17,10 @@ export default async function LandingPage({ params }: { params: Promise<{ locale
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl px-5">
|
||||
{/* A subtle canvas effect belongs behind this hero (FLOWS.md A1). It lands with the
|
||||
rest of the motion work in phase 7, reduced-motion safe and degrading to this. */}
|
||||
<section className="py-14 sm:py-20">
|
||||
{/* The one hero effect FLOWS.md A1 allows, behind the content and off under
|
||||
reduced motion. */}
|
||||
<section className="relative isolate py-14 sm:py-20">
|
||||
<HeroCanvas />
|
||||
<h1 className="max-w-2xl text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
|
||||
{t('landing.hero.title')}
|
||||
</h1>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isLocale } from '@impuestos/i18n';
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
@@ -7,6 +7,7 @@ import { getT } from '@/i18n/t';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Providers } from '@/components/providers';
|
||||
import { ThemeScript } from '@/components/theme-script';
|
||||
import { routing } from '@/i18n/routing';
|
||||
import '../globals.css';
|
||||
|
||||
@@ -14,6 +15,17 @@ import '../globals.css';
|
||||
// CSP tight (SPEC.md section 14).
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
|
||||
|
||||
/**
|
||||
* The browser chrome follows the theme, and the accent is the same deep teal the icon and
|
||||
* the manifest use.
|
||||
*/
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#fbfbfc' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#1b1d20' },
|
||||
],
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
@@ -23,7 +35,13 @@ export async function generateMetadata(props: {
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await props.params;
|
||||
const t = await getT(locale);
|
||||
return { title: t('common.appName') };
|
||||
return {
|
||||
title: t('common.appName'),
|
||||
description: t('landing.hero.subtitle'),
|
||||
applicationName: t('common.appName'),
|
||||
appleWebApp: { capable: true, title: t('common.appName'), statusBarStyle: 'default' },
|
||||
icons: { icon: '/icon-192.png', apple: '/apple-touch-icon.png' },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -39,6 +57,9 @@ export default async function LocaleLayout({
|
||||
|
||||
return (
|
||||
<html lang={locale} className={inter.variable} suppressHydrationWarning>
|
||||
<head>
|
||||
<ThemeScript />
|
||||
</head>
|
||||
<body className="min-h-dvh font-sans antialiased">
|
||||
<NextIntlClientProvider>
|
||||
<Providers>{children}</Providers>
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,25 @@ const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
// /api is proxied at runtime by app/api/[...path]/route.ts rather than by a rewrite,
|
||||
// so API_INTERNAL_URL stays a runtime setting. See the comment in that file.
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
// The bundler emits the service worker under /_next/static, and a worker may only
|
||||
// claim a scope at or below its own path unless the response says otherwise.
|
||||
// Security headers for everything else are set in proxy.ts, which does not run for
|
||||
// /_next.
|
||||
source: '/_next/static/service-worker/:path*',
|
||||
headers: [
|
||||
{ key: 'Service-Worker-Allowed', value: '/' },
|
||||
// Production only: Next warns that a custom Cache-Control here interferes with
|
||||
// how the dev server serves its own assets, and it does.
|
||||
...(process.env.NODE_ENV === 'production'
|
||||
? [{ key: 'Cache-Control', value: 'no-cache, no-store, must-revalidate' }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default createNextIntlPlugin('./src/i18n/request.ts')(nextConfig);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"better-auth": "^1.7.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"gsap": "^3.15.0",
|
||||
"lucide-react": "^1.40.0",
|
||||
"next": "^16.3.4",
|
||||
"next-intl": "^4.14.2",
|
||||
|
||||
+67
-3
@@ -1,10 +1,74 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { routing } from './src/i18n/routing';
|
||||
|
||||
export default createMiddleware(routing);
|
||||
const intl = createMiddleware(routing);
|
||||
|
||||
// React reconstructs server stacks with eval in development and never in production.
|
||||
const DEV = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* Security headers (SPEC.md section 14). Everything is served from one origin: the app
|
||||
* proxies `/api`, next/font self hosts the typeface and there is no third party script
|
||||
* anywhere, so the policy names `'self'` and stops.
|
||||
*
|
||||
* Scripts are allowed by nonce rather than by `'unsafe-inline'`. A fresh nonce per request
|
||||
* means an injected `<script>` cannot guess its way in, and `'strict-dynamic'` lets the one
|
||||
* nonced bootstrap load the chunks it needs without listing them.
|
||||
*/
|
||||
function policy(nonce: string): string {
|
||||
return [
|
||||
"default-src 'self'",
|
||||
// 'wasm-unsafe-eval' is the QR decoder: zxing runs as WebAssembly, self hosted.
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval'${DEV ? " 'unsafe-eval'" : ''}`,
|
||||
// Styles stay inline-allowed: Tailwind ships a stylesheet, but React writes inline
|
||||
// style attributes for things like a dragged card, and those are not noncable.
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self'",
|
||||
// The dev server pushes updates over a websocket to the same origin.
|
||||
`connect-src 'self'${DEV ? ' ws:' : ''}`,
|
||||
"worker-src 'self' blob:",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
'upgrade-insecure-requests',
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
const STATIC_HEADERS: Record<string, string> = {
|
||||
'x-content-type-options': 'nosniff',
|
||||
'referrer-policy': 'strict-origin-when-cross-origin',
|
||||
// The scanner needs the camera; nothing else needs anything.
|
||||
'permissions-policy': 'camera=(self), microphone=(), geolocation=(), payment=()',
|
||||
};
|
||||
|
||||
export default function proxy(request: NextRequest) {
|
||||
const nonce = crypto.randomUUID();
|
||||
const csp = policy(nonce);
|
||||
|
||||
// Next reads the nonce off the request headers and stamps it on the scripts it renders.
|
||||
// Setting them on the incoming request is what carries them through next-intl's rewrite.
|
||||
request.headers.set('x-nonce', nonce);
|
||||
request.headers.set('content-security-policy', csp);
|
||||
|
||||
/**
|
||||
* `/offline` is one page for every language: the service worker caches exactly one URL,
|
||||
* so locale routing must leave it alone. It still gets the headers.
|
||||
*/
|
||||
const response =
|
||||
request.nextUrl.pathname === '/offline'
|
||||
? NextResponse.next({ request: { headers: request.headers } })
|
||||
: intl(request);
|
||||
|
||||
response.headers.set('content-security-policy', csp);
|
||||
for (const [key, value] of Object.entries(STATIC_HEADERS)) response.headers.set(key, value);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Everything except /api (forwarded to the API by the rewrite), Next internals and
|
||||
// static files. Locale routing must not touch API requests.
|
||||
// Everything except /api (forwarded to the API by the route handler), Next internals and
|
||||
// static files.
|
||||
matcher: ['/((?!api|healthz|_next|_vercel|.*\\..*).*)'],
|
||||
};
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,13 @@
|
||||
import type { TraceKind } from '@impuestos/contracts';
|
||||
import { formatDateShort, formatGs, type Locale } from '@impuestos/i18n';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRef } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/t';
|
||||
import { api } from '@/lib/api';
|
||||
import { DURATION, EASE, gsap, useCountUp, usePrefersReducedMotion } from '@/lib/motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,24 @@ export function TraceableNumber({
|
||||
}) {
|
||||
const t = useT();
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
// The headline figure rolls up on first paint and on every change (FLOWS.md C1).
|
||||
const format = useCallback((amount: number) => formatGs(amount), []);
|
||||
const figure = useCountUp(value, format);
|
||||
|
||||
function open() {
|
||||
const node = dialog.current;
|
||||
if (!node) return;
|
||||
node.showModal();
|
||||
if (reduced) return;
|
||||
// Spring-ish, no bounce overdose (FLOWS.md section 8).
|
||||
gsap.fromTo(
|
||||
node,
|
||||
{ autoAlpha: 0, y: 12, scale: 0.98 },
|
||||
{ autoAlpha: 1, y: 0, scale: 1, duration: DURATION.base, ease: EASE },
|
||||
);
|
||||
}
|
||||
|
||||
const trace = useQuery({
|
||||
queryKey: ['dashboard', 'trace', kind],
|
||||
@@ -43,7 +62,7 @@ export function TraceableNumber({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void trace.refetch();
|
||||
dialog.current?.showModal();
|
||||
open();
|
||||
}}
|
||||
className={cn(
|
||||
'tnum text-left underline decoration-dotted underline-offset-4',
|
||||
@@ -52,7 +71,7 @@ export function TraceableNumber({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{formatGs(value)}
|
||||
<span ref={figure}>{formatGs(value)}</span>
|
||||
</button>
|
||||
|
||||
<dialog
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes, Ref } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
/** `ref` is a plain prop in React 19, so a caller can hand the card to GSAP. */
|
||||
export function Card({
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { ref?: Ref<HTMLDivElement> }) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-2xl border bg-[var(--surface-raised)] p-6 shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
|
||||
className,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { formatGs } from '@impuestos/i18n';
|
||||
import { useCallback } from 'react';
|
||||
import { useCountUp } from '@/lib/motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The only way money reaches the screen. Always `Gs. 1.234.567`, never localized, always
|
||||
* tabular so columns of figures line up (FLOWS.md section 1).
|
||||
*
|
||||
* `animate` rolls the figure up to its value and again whenever it changes. It belongs on
|
||||
* headline numbers only: a list of forty rows all counting at once is noise, not motion.
|
||||
*/
|
||||
export function Money({
|
||||
value,
|
||||
size = 'md',
|
||||
animate = false,
|
||||
className,
|
||||
}: {
|
||||
value: number;
|
||||
size?: 'md' | 'lg';
|
||||
animate?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const classes = cn(
|
||||
'tnum',
|
||||
size === 'lg' ? 'text-4xl font-semibold tracking-tight' : 'font-medium',
|
||||
className,
|
||||
);
|
||||
|
||||
return animate ? (
|
||||
<AnimatedMoney value={value} className={classes} />
|
||||
) : (
|
||||
<span className={classes}>{formatGs(value)}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedMoney({ value, className }: { value: number; className: string }) {
|
||||
const format = useCallback((amount: number) => formatGs(amount), []);
|
||||
const ref = useCountUp(value, format);
|
||||
// The server rendered value is the final one, so a reader with JavaScript off or a
|
||||
// reduced motion preference sees the right number and never an empty box.
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'tnum',
|
||||
size === 'lg' ? 'text-4xl font-semibold tracking-tight' : 'font-medium',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span ref={ref} className={className}>
|
||||
{formatGs(value)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user