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