@12-apps/notifications 4.8.0 → 4.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ADOPTING.md +173 -1
- package/README.md +138 -0
- package/dist/catalog-BOVZ2b-b.d.ts +132 -0
- package/dist/{chunk-M5XTROYM.js → chunk-5Y7QRORV.js} +8 -4
- package/dist/chunk-5Y7QRORV.js.map +1 -0
- package/dist/{chunk-BGI4BQ66.js → chunk-6W7INOYQ.js} +1 -1
- package/dist/chunk-6W7INOYQ.js.map +1 -0
- package/dist/chunk-EKUSNUBT.js +222 -0
- package/dist/chunk-EKUSNUBT.js.map +1 -0
- package/dist/chunk-FTFLU6KH.js +114 -0
- package/dist/chunk-FTFLU6KH.js.map +1 -0
- package/dist/chunk-JCVRQ42B.js +226 -0
- package/dist/chunk-JCVRQ42B.js.map +1 -0
- package/dist/chunk-RTURLH5U.js +33 -0
- package/dist/chunk-RTURLH5U.js.map +1 -0
- package/dist/{chunk-GGAZOLNN.js → chunk-SWOWHIFE.js} +2 -2
- package/dist/{chunk-7234ROOP.js → chunk-WVRODNXQ.js} +28 -6
- package/dist/chunk-WVRODNXQ.js.map +1 -0
- package/dist/chunk-ZFIYBNZ7.js +502 -0
- package/dist/chunk-ZFIYBNZ7.js.map +1 -0
- package/dist/{chunk-KU4FVOFA.js → chunk-ZIR3ILFH.js} +2 -2
- package/dist/{create-api-notifications-WaexrZnn.d.ts → create-api-notifications-CcPYrM3p.d.ts} +35 -4
- package/dist/{create-web-notifications-DrgqtU4Q.d.ts → create-web-notifications-_NVYmlvy.d.ts} +89 -4
- package/dist/email/index.d.ts +26 -0
- package/dist/email/index.js +20 -0
- package/dist/email/index.js.map +1 -0
- package/dist/email/locales.d.ts +43 -0
- package/dist/email/locales.js +29 -0
- package/dist/email/locales.js.map +1 -0
- package/dist/email/previews/hono.d.ts +46 -0
- package/dist/email/previews/hono.js +33 -0
- package/dist/email/previews/hono.js.map +1 -0
- package/dist/email/previews/index.d.ts +67 -0
- package/dist/email/previews/index.js +12 -0
- package/dist/email/previews/index.js.map +1 -0
- package/dist/email/previews/react/index.d.ts +43 -0
- package/dist/email/previews/react/index.js +58 -0
- package/dist/email/previews/react/index.js.map +1 -0
- package/dist/{generators-CH5m8Ucj.d.ts → generators-qAD4fNPq.d.ts} +1 -1
- package/dist/hono/index.d.ts +6 -5
- package/dist/hono/index.js +5 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +15 -5
- package/dist/{jobs-CcsSA0YR.d.ts → jobs--fex87-q.d.ts} +1 -1
- package/dist/live-DYxEFO49.d.ts +158 -0
- package/dist/manifest/index.d.ts +39 -1
- package/dist/manifest/index.js +9 -0
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/server.d.ts +32 -7
- package/dist/manifest/server.js +16 -4
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +19 -4
- package/dist/manifest/web.js +9 -1
- package/dist/manifest/web.js.map +1 -1
- package/dist/{panel-UFXNO4AF.js → panel-T36JEMO3.js} +37 -13
- package/dist/panel-T36JEMO3.js.map +1 -0
- package/dist/{preferences-screen-V3ZBWP7A.js → preferences-screen-SXUIGECY.js} +2 -2
- package/dist/preview-screen-DYJRAnAY.d.ts +83 -0
- package/dist/react/index.d.ts +55 -6
- package/dist/react/index.js +16 -10
- package/dist/server/index.d.ts +8 -7
- package/dist/server/index.js +6 -4
- package/dist/template-Dlt5S54z.d.ts +196 -0
- package/dist/{types-CYJN1xCM.d.ts → types-BlqZkCWZ.d.ts} +11 -2
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-iER5StBm.d.ts → web-push-Dnyaha2z.d.ts} +21 -1
- package/dist/{wire-CmRrB3_s.d.ts → wire-BG1kuoXX.d.ts} +1 -1
- package/package.json +22 -2
- package/src/email/chrome.en-US.ts +8 -0
- package/src/email/chrome.pt-BR.ts +21 -0
- package/src/email/index.ts +43 -0
- package/src/email/locales.ts +29 -0
- package/src/email/previews/catalog.ts +208 -0
- package/src/email/previews/hono.ts +69 -0
- package/src/email/previews/index.ts +26 -0
- package/src/email/previews/react/copy.en-US.ts +24 -0
- package/src/email/previews/react/copy.pt-BR.ts +24 -0
- package/src/email/previews/react/copy.ts +42 -0
- package/src/email/previews/react/index.tsx +14 -0
- package/src/email/previews/react/loadable.tsx +83 -0
- package/src/email/previews/react/message-list.tsx +133 -0
- package/src/email/previews/react/message-view.tsx +167 -0
- package/src/email/previews/react/preview-screen.tsx +342 -0
- package/src/email/previews/react/transport.ts +60 -0
- package/src/email/previews/routes.ts +111 -0
- package/src/email/template.ts +365 -0
- package/src/email/text.ts +59 -0
- package/src/email/theme.ts +77 -0
- package/src/index.ts +10 -0
- package/src/live.ts +179 -0
- package/src/manifest/index.ts +37 -0
- package/src/manifest/server.ts +23 -0
- package/src/manifest/web.ts +13 -0
- package/src/react/create-web-notifications.tsx +15 -1
- package/src/react/index.ts +29 -0
- package/src/react/live-card.tsx +279 -0
- package/src/react/live-config.ts +79 -0
- package/src/react/live-section.tsx +141 -0
- package/src/react/panel-lazy.tsx +3 -0
- package/src/react/panel.tsx +57 -10
- package/src/react/relative-time.ts +14 -2
- package/src/server/transports/email.ts +62 -0
- package/src/server/transports/web-push.ts +22 -0
- package/src/types.ts +10 -1
- package/dist/chunk-7234ROOP.js.map +0 -1
- package/dist/chunk-BGI4BQ66.js.map +0 -1
- package/dist/chunk-M5XTROYM.js.map +0 -1
- package/dist/chunk-O5BVUXPO.js +0 -22
- package/dist/chunk-O5BVUXPO.js.map +0 -1
- package/dist/panel-UFXNO4AF.js.map +0 -1
- /package/dist/{chunk-GGAZOLNN.js.map → chunk-SWOWHIFE.js.map} +0 -0
- /package/dist/{chunk-KU4FVOFA.js.map → chunk-ZIR3ILFH.js.map} +0 -0
- /package/dist/{preferences-screen-V3ZBWP7A.js.map → preferences-screen-SXUIGECY.js.map} +0 -0
package/ADOPTING.md
CHANGED
|
@@ -22,6 +22,8 @@ sweep — endpoints and screens included.
|
|
|
22
22
|
| **Hono** | `@12-apps/notifications/hono` | `const notifications = notificationsRouter({ ...serverConfig, resolveActor }); app.route('/api/account', notifications.router)`. A one-call mount; `hono` is an OPTIONAL peer, so importing the root, `/server` or `/react` never resolves it. |
|
|
23
23
|
| **React** | `@12-apps/notifications/react` | Call `createWebNotifications({ apiBase })`. `BellWithPanel` is the whole feature as one element; `BellButton` + `Panel` are the pair for a host with its own chrome; `page` is the preferences screen you route to. pt-BR product copy and the origin host's test ids ship inside. |
|
|
24
24
|
| **Web Push** | `@12-apps/notifications/web-push` | `sender: vapidPushSender({ subject, publicKey, privateKey })` on the `WEB_PUSH` declaration. Its own subpath because it is the only piece that needs `web-push` — an OPTIONAL peer a host that never enables the channel never installs. |
|
|
25
|
+
| **Mail layout** | `@12-apps/notifications/email` | `renderEmail(document)` → `{ subject, html, text }`. Framework-free and dependency-free. Turn it on for the EMAIL transport by declaring `layout`; the copy packs are `@12-apps/notifications/email/locales`. |
|
|
26
|
+
| **Previews** | `@12-apps/notifications/email/previews` | The preview catalogue and its two route descriptors. `@12-apps/notifications/email/previews/hono` is the one-call mount; `.../previews/react` is the operator screen. A SECOND wiring manifest — see "The mail layout and its preview console". |
|
|
25
27
|
| **Prisma** | `prisma/notifications.prisma` + `prisma/migrations/*` | Run `pnpm --filter @12-apps/notifications prisma:sync -- <host schema dir>`: the partial is **COPIED** into the host's multi-file schema folder — never symlinked (a symlinked migration is silently skipped by Prisma; a symlinked partial dangles under `turbo prune`). Migrations are discovered structurally from the installed package's `prisma/migrations` by the host's plugin-migration sync. |
|
|
26
28
|
|
|
27
29
|
## Host wiring rules (the ones that bite)
|
|
@@ -339,10 +341,180 @@ There are **no foreign keys into host tables**: `user_id` and `client_id` are
|
|
|
339
341
|
by-value scalars. Add your own in a host migration — `ON DELETE CASCADE` on
|
|
340
342
|
both is the recommendation, so a deleted account takes its inbox with it.
|
|
341
343
|
|
|
344
|
+
## The mail layout and its preview console
|
|
345
|
+
|
|
346
|
+
Both are optional and independent of everything above: the layout is a pure
|
|
347
|
+
function, and the console declares no `db`, so adopting either touches no schema.
|
|
348
|
+
|
|
349
|
+
### 1. Turning the layout on for the EMAIL transport
|
|
350
|
+
|
|
351
|
+
Add `layout` to the driver declaration. Without it you keep the previous
|
|
352
|
+
rendering byte for byte — this is opt-in because `brand` and `chrome` are
|
|
353
|
+
REQUIRED with no default, so making them mandatory would break every host
|
|
354
|
+
already declaring EMAIL, at runtime, on the first send.
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
import { PT_BR_EMAIL_CHROME } from '@12-apps/notifications/email/locales';
|
|
358
|
+
|
|
359
|
+
{ channel: 'EMAIL', driver: 'resend', apiKey, from, linkLabel: 'Ver detalhes',
|
|
360
|
+
layout: { brand: 'Loja Exemplo', chrome: PT_BR_EMAIL_CHROME, locale: 'pt-BR' } }
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`theme` may be omitted; the layout ships a neutral grey-on-white palette. That
|
|
364
|
+
one asymmetry is deliberate and argued in `src/email/theme.ts`: a wrong-language
|
|
365
|
+
default is invisible until a customer complains, where a missing theme is
|
|
366
|
+
visible in the first preview.
|
|
367
|
+
|
|
368
|
+
For a host whose recipients do not share a language, resolve `chrome` per
|
|
369
|
+
message rather than at the mount — a mail's language is the RECIPIENT's, not the
|
|
370
|
+
request's.
|
|
371
|
+
|
|
372
|
+
### 2. YOU gate the console. The routes only ask
|
|
373
|
+
|
|
374
|
+
`emailPreviewRoutes` declares `kind: 'authenticated'` on both descriptors — the
|
|
375
|
+
wiring contract's word for "behind the host's session resolution and its RBAC".
|
|
376
|
+
It names no permission id, because the ids are yours. **So the descriptor asks
|
|
377
|
+
for a gate; it cannot supply one.**
|
|
378
|
+
|
|
379
|
+
The surface publishes your whole transactional-mail inventory and the exact
|
|
380
|
+
wording and link shape of your verification and password-reset mails — the
|
|
381
|
+
reference somebody writing a convincing phishing mail for your product would
|
|
382
|
+
want. Mount it behind whichever gate you already use for platform staff:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { emailPreviewsRouter } from '@12-apps/notifications/email/previews/hono';
|
|
386
|
+
|
|
387
|
+
app.use('/api/platform/email-previews/*', requirePlatformOperator);
|
|
388
|
+
app.route('/api/platform/email-previews', emailPreviewsRouter(previews).router);
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`allow` on the config is there for a host that would rather state the refusal
|
|
392
|
+
once in the config than mount middleware around the router. Either is fine;
|
|
393
|
+
neither is optional.
|
|
394
|
+
|
|
395
|
+
### 3. Declare the SOURCES — and expect to be asked repeatedly
|
|
396
|
+
|
|
397
|
+
A package cannot know that you send a "your quota is exhausted" notice, let
|
|
398
|
+
alone what data it renders from. So you declare sources, and each is asked
|
|
399
|
+
**per request**:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
const previews = {
|
|
403
|
+
sources: [authMailSource(), notificationMailSource()],
|
|
404
|
+
locales: ['pt-BR', 'en-US'],
|
|
405
|
+
defaultLocale: 'pt-BR',
|
|
406
|
+
};
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Per request rather than once at the mount, because domain modules typically
|
|
410
|
+
register their messages as an import side effect. A catalogue built once lists
|
|
411
|
+
whatever happened to be imported first — which looks exactly like a product that
|
|
412
|
+
sends fewer mails than it does.
|
|
413
|
+
|
|
414
|
+
Ids must be unique across every source; a duplicate THROWS, because the
|
|
415
|
+
alternative is one message unreachable and the other ambiguous, silently.
|
|
416
|
+
|
|
417
|
+
### 4. `coverage` is optional, and worth writing
|
|
418
|
+
|
|
419
|
+
A catalogue that quietly omits a message looks exactly like a product that does
|
|
420
|
+
not send it. Report what you cannot show — a message with no sample data, or
|
|
421
|
+
sample data for a message that no longer exists — and the screen renders it as a
|
|
422
|
+
warning strip. Pair it with a unit test asserting `missing` is empty, and a mail
|
|
423
|
+
added without a sample becomes a red test naming the type rather than a gap
|
|
424
|
+
nobody sees.
|
|
425
|
+
|
|
426
|
+
### 5. Adopt it as the SECOND manifest
|
|
427
|
+
|
|
428
|
+
`@12-apps/notifications-email-previews` is a separate manifest from the same
|
|
429
|
+
package, so a host mounts the inbox and declines the console (or vice versa) in
|
|
430
|
+
writing rather than silently getting both behind one gate:
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
host.adoptServer({
|
|
434
|
+
manifest: notificationEmailPreviewsManifest,
|
|
435
|
+
server: notificationEmailPreviewsServerManifest,
|
|
436
|
+
bindings: { http: { mountPath: '/api/platform/email-previews', config: previews } },
|
|
437
|
+
});
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
The web half binds `{ apiBase, copy }`, with `copy` a pack passed by name —
|
|
441
|
+
`EN_US_EMAIL_PREVIEW_COPY` or `PT_BR_EMAIL_PREVIEW_COPY` from
|
|
442
|
+
`@12-apps/notifications/email/previews/react`.
|
|
443
|
+
|
|
444
|
+
### 6. Nothing can be sent from the console
|
|
445
|
+
|
|
446
|
+
It holds no driver, no transport and no address, and `render` is pure by the
|
|
447
|
+
contract `EmailPreviewMessage` states. The one mistake a preview surface must be
|
|
448
|
+
incapable of — putting a sample in somebody's inbox — is not reachable from that
|
|
449
|
+
code at all, rather than merely not done.
|
|
450
|
+
|
|
451
|
+
## Live activities (optional)
|
|
452
|
+
|
|
453
|
+
The panel's second kind of entry: ONGOING STATE, pinned above the inbox list.
|
|
454
|
+
Off unless you configure it, and off means *nothing rendered* — no section, no
|
|
455
|
+
heading, no reserved space. `README.md` carries the argument and the full shape;
|
|
456
|
+
this is the adoption contract.
|
|
457
|
+
|
|
458
|
+
**You supply two things and this package owns the rest.**
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
createWebNotifications({
|
|
462
|
+
…,
|
|
463
|
+
liveActivities: {
|
|
464
|
+
useActivities: ({ active }) => useMyLiveThings({ enabled: active }),
|
|
465
|
+
messages: { sectionTitle, openActivity, updated },
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
1. **Where they come from.** A HOOK, not a fetcher and not a factory-time
|
|
471
|
+
subscribe — the answer almost always lives in React context (your tenant,
|
|
472
|
+
your session, your query client), which is the same reason
|
|
473
|
+
`NotificationsSignalHook` exists one seam over. `active` is `false` while the
|
|
474
|
+
panel is shut; pass it to your query's `enabled`. Ignoring it is correct and
|
|
475
|
+
merely costs money — and an unopened inbox is free either way, because the
|
|
476
|
+
panel is lazy and the drawer unmounts its content on close.
|
|
477
|
+
2. **Three sentences**, in their own pack rather than on
|
|
478
|
+
`NotificationMessages` — live activities are opt-in, and requiring copy for a
|
|
479
|
+
section you never render is the tax that gets a required-config change
|
|
480
|
+
reverted instead of adopted.
|
|
481
|
+
|
|
482
|
+
**Two rules that are ours, not yours:**
|
|
483
|
+
|
|
484
|
+
- A live activity NEVER touches `unread`. It is not news, and a number the bell
|
|
485
|
+
cannot clear is worse than no number.
|
|
486
|
+
- It leaves when your hook stops returning it. There is no dismiss, no read and
|
|
487
|
+
no delete — the subject finishing is the only exit, which is what stops the
|
|
488
|
+
section becoming a second inbox.
|
|
489
|
+
|
|
490
|
+
**On a phone**, put the activity's id on the notifications you already send
|
|
491
|
+
about the same subject:
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
import { LIVE_SUBJECT_KEY } from '@12-apps/notifications'; // the root entry
|
|
495
|
+
|
|
496
|
+
data: { [LIVE_SUBJECT_KEY]: `order:${orderId}` }
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
(`LiveActivity` itself comes from either entry — the root or `./react` — so the
|
|
500
|
+
hook and the type it returns are one import line.)
|
|
501
|
+
|
|
502
|
+
`formatWebPush` turns it into `tag` on the push payload; your service worker
|
|
503
|
+
passes `tag` to `showNotification` and the tray keeps ONE entry per subject
|
|
504
|
+
instead of one per stage. The worker is still yours (rule 12) — a worker that
|
|
505
|
+
ignores `tag` behaves exactly as it does today.
|
|
506
|
+
|
|
342
507
|
## What does NOT come with it
|
|
343
508
|
|
|
344
509
|
- **The events.** Generators are host code (see rule 6).
|
|
345
510
|
- **A queue.** `scheduleDispatch` is a seam, not an implementation.
|
|
346
511
|
- **A plan model.** `channelPolicy` answers; it does not decide.
|
|
347
512
|
- **An authorization engine.** `audience` answers; it does not decide.
|
|
348
|
-
- **A service worker.** The file is the host's (rule 12)
|
|
513
|
+
- **A service worker.** The file is the host's (rule 12) — including the four
|
|
514
|
+
lines that read `tag` off a live push.
|
|
515
|
+
- **Anything to BE live about.** `useActivities` is a seam; this package has no
|
|
516
|
+
idea what is happening in your product, which is the point.
|
|
517
|
+
- **The mail INVENTORY.** The preview console renders what you declare as
|
|
518
|
+
sources; it cannot discover what your product sends.
|
|
519
|
+
- **A brand, a palette or a sentence.** The layout requires the first, defaults
|
|
520
|
+
only the second, and ships the third as packs you pass by name.
|
package/README.md
CHANGED
|
@@ -121,6 +121,144 @@ a form B's keys decrypt) and the preferences screen derives "this browser is
|
|
|
121
121
|
receiving alerts" from the SERVER's answer, not from the browser's own
|
|
122
122
|
subscription object.
|
|
123
123
|
|
|
124
|
+
## One mail layout, and a console that previews it
|
|
125
|
+
|
|
126
|
+
`EMAIL` used to render a mail as three bare `<p>` tags. That is a **layout**
|
|
127
|
+
problem rather than a wording one — a paragraph with no document around it
|
|
128
|
+
inherits whatever the client decides: 13px Arial in Gmail, Times New Roman in
|
|
129
|
+
Outlook, no centring anywhere — so the layout ships here, beside the transport
|
|
130
|
+
that needed it.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { renderEmail } from '@12-apps/notifications/email';
|
|
134
|
+
import { PT_BR_EMAIL_CHROME } from '@12-apps/notifications/email/locales';
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`renderEmail(document)` takes STRUCTURE — a heading, paragraphs, an optional
|
|
138
|
+
facts table, at most one call to action — and never markup. Three things follow,
|
|
139
|
+
and each was a real defect in the renderers it replaces:
|
|
140
|
+
|
|
141
|
+
1. **Escaping cannot be forgotten.** It happens in one place.
|
|
142
|
+
2. **The plain-text twin cannot drift.** Both halves render from the same
|
|
143
|
+
object. A `text/html` part with no `text/plain` twin is scored by every major
|
|
144
|
+
spam filter.
|
|
145
|
+
3. **A preview is honest**, because the console renders exactly this.
|
|
146
|
+
|
|
147
|
+
The client constraints are encoded once so no caller has to know them: tables
|
|
148
|
+
rather than divs (Outlook lays HTML out with Word), inline styles only (Gmail
|
|
149
|
+
strips `<style>`), no web fonts or `color-mix()` or CSS variables, a hidden
|
|
150
|
+
preheader, a `bgcolor` attribute **and** a background style on the CTA.
|
|
151
|
+
|
|
152
|
+
**The EMAIL transport opts in.** Declare `layout` on the driver declaration and
|
|
153
|
+
`formatEmail` renders the shared document; omit it and you keep the previous
|
|
154
|
+
rendering byte for byte. Opt-in rather than automatic because `brand` and
|
|
155
|
+
`chrome` are required with no default — a package that defaulted them would sign
|
|
156
|
+
another company's mail, in a language nobody chose — so making them mandatory
|
|
157
|
+
would break every host already declaring EMAIL, at runtime, on the first send.
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
{ channel: 'EMAIL', driver: 'resend', apiKey, from, linkLabel: 'Ver detalhes',
|
|
161
|
+
layout: { brand: 'Loja Exemplo', chrome: PT_BR_EMAIL_CHROME, locale: 'pt-BR' } }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### The preview console
|
|
165
|
+
|
|
166
|
+
There is normally no way to *see* a transactional mail without triggering the
|
|
167
|
+
event that sends it — signing up with a throwaway address for the verification
|
|
168
|
+
mail, settling an order for the receipt. So nobody looks, and a product
|
|
169
|
+
rendering three ways does not find out. A layout nobody can see is one release
|
|
170
|
+
from being three again, which is why the console ships in the same package.
|
|
171
|
+
|
|
172
|
+
It is the package's **second wiring manifest**
|
|
173
|
+
(`@12-apps/notifications-email-previews`), because the first has already spent
|
|
174
|
+
`http` on the account inbox and `surface` on the bell. That split is deliberate:
|
|
175
|
+
the inbox ships to every signed-in user, and this console publishes the whole
|
|
176
|
+
mail inventory and the exact wording and link shape of the verification and
|
|
177
|
+
reset mails. **You gate the mount** — the routes declare `kind: 'authenticated'`
|
|
178
|
+
and name no permission id, because the ids are the host's.
|
|
179
|
+
|
|
180
|
+
WHICH messages exist is yours too, declared as `sources` that are asked **per
|
|
181
|
+
request** (a source backed by a registry that fills at import time would
|
|
182
|
+
otherwise answer with whatever was imported first). A source reports its own
|
|
183
|
+
`coverage` — what it cannot show — and the screen renders that as a warning
|
|
184
|
+
strip rather than pretending to be complete. Nothing can be sent from it: it
|
|
185
|
+
holds no driver, no transport and no address.
|
|
186
|
+
|
|
187
|
+
See **[ADOPTING.md](./ADOPTING.md)** for the mount, the gate and the sources.
|
|
188
|
+
|
|
189
|
+
## Live activities — the centre's second kind of entry
|
|
190
|
+
|
|
191
|
+
An inbox notification is an EVENT: it happened, it is stamped, it is read or
|
|
192
|
+
unread, and tomorrow it still says the same thing. A **live activity** is
|
|
193
|
+
ONGOING STATE — pinned above the list, no read/unread, nothing to delete, it
|
|
194
|
+
updates itself, and it is gone the moment the thing it tracks finishes.
|
|
195
|
+
|
|
196
|
+
The distinction is not cosmetic. "Your order is on its way", read an hour later,
|
|
197
|
+
is a claim about the past presented as news; the more reliable the inbox is, the
|
|
198
|
+
more of those a person accumulates, and somewhere in the pile is the question
|
|
199
|
+
they actually have — *where is it now*.
|
|
200
|
+
|
|
201
|
+
Opt-in, and absent means absent: a host that passes nothing renders the panel it
|
|
202
|
+
had, with no section, no heading and no reserved space.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
createWebNotifications({
|
|
206
|
+
apiBase: '/api/account',
|
|
207
|
+
messages,
|
|
208
|
+
liveActivities: {
|
|
209
|
+
// A HOOK, so it may read context — the tenant, the session, a query client.
|
|
210
|
+
useActivities: ({ active }) => useMyLiveThings({ enabled: active }),
|
|
211
|
+
messages: { sectionTitle, openActivity, updated },
|
|
212
|
+
renderIcon: (activity) => ICONS[activity.kind],
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`active` is whether anyone is looking — `false` while the panel is shut. Pass it
|
|
218
|
+
to your query's `enabled`. It is a hint about NEED, never about correctness, and
|
|
219
|
+
it is not what makes an unopened inbox free: the panel is fetched lazily and the
|
|
220
|
+
drawer unmounts its content on close, so a host that ignores `active` still
|
|
221
|
+
issues nothing until somebody opens the bell.
|
|
222
|
+
|
|
223
|
+
One activity is `{ id, kind, title, body, link, steps, activeStepId, updatedAt }`
|
|
224
|
+
— `LiveActivity`, importable from either entry (`@12-apps/notifications` or
|
|
225
|
+
`@12-apps/notifications/react`, so a host wiring the surface needs one import
|
|
226
|
+
line, not two). It is defined in `src/live.ts`. `steps` + `activeStepId` draw a lane, because "how far along is
|
|
227
|
+
this" is the shape almost every ongoing subject has; both are optional in effect
|
|
228
|
+
— an activity with no lane is a heading, a sentence and a timestamp that keeps
|
|
229
|
+
moving. An `activeStepId` naming no step draws NO lane rather than a lane with
|
|
230
|
+
nothing lit, which would read as a process that has stopped.
|
|
231
|
+
|
|
232
|
+
### On a phone: one tray entry, one buzz
|
|
233
|
+
|
|
234
|
+
The other half is the OS notification, and it is one field. A generator whose
|
|
235
|
+
event is about something also tracked live puts the activity's id in `data`:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
import { LIVE_SUBJECT_KEY } from '@12-apps/notifications'; // the root entry
|
|
239
|
+
|
|
240
|
+
data: { [LIVE_SUBJECT_KEY]: `order:${orderId}` }
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`formatWebPush` turns that into a `tag` on the push payload, and a tag makes the
|
|
244
|
+
next push about the same subject **replace** the one already in the tray —
|
|
245
|
+
silently — instead of stacking under it. Four stages then cost one entry and one
|
|
246
|
+
buzz, and the entry that remains is the current one. Without it a phone
|
|
247
|
+
accumulates one alert per stage, each still asserting a stage the subject has
|
|
248
|
+
since left.
|
|
249
|
+
|
|
250
|
+
`tag` is `null` for an ordinary event rather than absent, so a service worker
|
|
251
|
+
reads one payload shape. A worker that ignores it keeps today's behaviour
|
|
252
|
+
exactly — which is what makes the field safe to ship ahead of the workers:
|
|
253
|
+
|
|
254
|
+
```js
|
|
255
|
+
self.registration.showNotification(payload.title, {
|
|
256
|
+
body: payload.body,
|
|
257
|
+
data: { link: payload.link },
|
|
258
|
+
...(payload.tag ? { tag: payload.tag, renotify: false } : {}),
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
124
262
|
## The models
|
|
125
263
|
|
|
126
264
|
`prisma/notifications.prisma` — `Notification`, `NotificationDelivery`,
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { R as RenderedEmail } from './template-Dlt5S54z.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The preview catalogue: every message a host can send, as a navigable set.
|
|
5
|
+
*
|
|
6
|
+
* ## The problem it exists for
|
|
7
|
+
*
|
|
8
|
+
* There is normally no way to SEE a transactional mail without triggering the
|
|
9
|
+
* event that sends it — signing up with a throwaway address to look at the
|
|
10
|
+
* verification mail, settling an order to look at the receipt, exhausting a
|
|
11
|
+
* quota to look at that alert. So nobody looks, and a host that renders mail in
|
|
12
|
+
* three different ways does not find out.
|
|
13
|
+
*
|
|
14
|
+
* A layout nobody can see is one release from being three layouts again, which
|
|
15
|
+
* is why this ships in the same package as the layout rather than beside it.
|
|
16
|
+
*
|
|
17
|
+
* ## What is the package's and what is the host's
|
|
18
|
+
*
|
|
19
|
+
* The MECHANISM is here: group by owner, render one message for one reader,
|
|
20
|
+
* report what cannot be shown. WHICH messages exist is the host's, and it
|
|
21
|
+
* cannot be otherwise — a package cannot know that a host sends a
|
|
22
|
+
* "your quota is exhausted" notice, let alone what data that notice is
|
|
23
|
+
* rendered from.
|
|
24
|
+
*
|
|
25
|
+
* So a host declares SOURCES. Each names an owner and answers with its
|
|
26
|
+
* messages, and the catalogue is the union. That inverts the obvious design,
|
|
27
|
+
* where the package would hold a registry a host writes into: a source is
|
|
28
|
+
* asked, per request, so a source backed by a registry that fills at import
|
|
29
|
+
* time answers with whatever is registered NOW rather than with whatever had
|
|
30
|
+
* been registered when the mount was built.
|
|
31
|
+
*
|
|
32
|
+
* ## Why coverage is a first-class output
|
|
33
|
+
*
|
|
34
|
+
* A catalogue that quietly omits a message looks exactly like a product that
|
|
35
|
+
* does not send it. So a source may report what it knows it cannot show —
|
|
36
|
+
* a message with no sample data, or sample data for a message that no longer
|
|
37
|
+
* exists — and the surface returns that beside the list rather than swallowing
|
|
38
|
+
* it. A gap that is visible is a gap somebody closes.
|
|
39
|
+
*/
|
|
40
|
+
/** One previewable message. */
|
|
41
|
+
interface EmailPreviewMessage {
|
|
42
|
+
/**
|
|
43
|
+
* Stable id, and the path segment the console navigates by.
|
|
44
|
+
*
|
|
45
|
+
* The host chooses the spelling; the surface only requires it to be unique
|
|
46
|
+
* across every source. Ids that survive a rename are what make a link to one
|
|
47
|
+
* mail worth sending to a colleague.
|
|
48
|
+
*/
|
|
49
|
+
readonly id: string;
|
|
50
|
+
/** The wire identity — an event type, a template key. Shown beside the subject. */
|
|
51
|
+
readonly key: string;
|
|
52
|
+
/** Which mail path this belongs to, for grouping inside one owner. */
|
|
53
|
+
readonly family: string;
|
|
54
|
+
/** Render it for one reader. MUST be pure — nothing may be sent. */
|
|
55
|
+
render(locale: string): RenderedEmail;
|
|
56
|
+
}
|
|
57
|
+
/** What a source knows it cannot show. Both directions are worth reporting. */
|
|
58
|
+
interface EmailPreviewCoverage {
|
|
59
|
+
/** Messages that exist but have no sample data, so no preview. */
|
|
60
|
+
readonly missing: readonly string[];
|
|
61
|
+
/** Sample data for a message nothing produces any more. */
|
|
62
|
+
readonly orphan: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
/** One group of messages, owned by whichever code words them. */
|
|
65
|
+
interface EmailPreviewSource {
|
|
66
|
+
/**
|
|
67
|
+
* Who owns these messages — a package name, or the host's own module.
|
|
68
|
+
*
|
|
69
|
+
* This is the console's grouping, and it is the answer to "which parts of
|
|
70
|
+
* this system send mail", which is a question most hosts cannot otherwise
|
|
71
|
+
* answer at all.
|
|
72
|
+
*/
|
|
73
|
+
readonly owner: string;
|
|
74
|
+
/** Called PER REQUEST — see the docblock above for why that matters. */
|
|
75
|
+
list(): readonly EmailPreviewMessage[];
|
|
76
|
+
/** Optional: what this source cannot show. Absent means "nothing missing". */
|
|
77
|
+
coverage?(): EmailPreviewCoverage;
|
|
78
|
+
}
|
|
79
|
+
/** One catalogue row: everything the sidebar needs, and no rendered bytes. */
|
|
80
|
+
interface EmailPreviewRow {
|
|
81
|
+
readonly id: string;
|
|
82
|
+
readonly key: string;
|
|
83
|
+
readonly family: string;
|
|
84
|
+
readonly owner: string;
|
|
85
|
+
/** The subject in the requested language, so the list reads as an inbox. */
|
|
86
|
+
readonly subject: string;
|
|
87
|
+
}
|
|
88
|
+
/** One rendered message: what the vendor would be handed, verbatim. */
|
|
89
|
+
interface EmailPreviewDetail extends EmailPreviewRow {
|
|
90
|
+
readonly locale: string;
|
|
91
|
+
readonly html: string;
|
|
92
|
+
readonly text: string;
|
|
93
|
+
}
|
|
94
|
+
interface EmailPreviewIndex {
|
|
95
|
+
readonly locale: string;
|
|
96
|
+
readonly locales: readonly string[];
|
|
97
|
+
readonly items: readonly EmailPreviewRow[];
|
|
98
|
+
readonly coverage: EmailPreviewCoverage;
|
|
99
|
+
}
|
|
100
|
+
interface EmailPreviewsConfig {
|
|
101
|
+
/** The host's sources, in the order the console should list their owners. */
|
|
102
|
+
readonly sources: readonly EmailPreviewSource[];
|
|
103
|
+
/**
|
|
104
|
+
* The languages a preview may be asked for.
|
|
105
|
+
*
|
|
106
|
+
* REQUIRED, and not derived from the shipped packs: a host's own messages may
|
|
107
|
+
* be written in languages this package has never heard of, and a preview that
|
|
108
|
+
* silently refused one of them would look exactly like a product with no
|
|
109
|
+
* translation.
|
|
110
|
+
*/
|
|
111
|
+
readonly locales: readonly string[];
|
|
112
|
+
/** The language a preview opens in when the caller names none. */
|
|
113
|
+
readonly defaultLocale: string;
|
|
114
|
+
}
|
|
115
|
+
/** An id that appears in more than one source — the one thing that must not happen. */
|
|
116
|
+
declare class DuplicateEmailPreviewIdError extends Error {
|
|
117
|
+
constructor(id: string);
|
|
118
|
+
}
|
|
119
|
+
/** The catalogue, assembled from every source. */
|
|
120
|
+
interface ApiEmailPreviews {
|
|
121
|
+
/** Every message, with each subject rendered in `locale`. */
|
|
122
|
+
index(locale?: string): EmailPreviewIndex;
|
|
123
|
+
/** One rendered message, or `null` when no source owns that id. */
|
|
124
|
+
render(id: string, locale?: string): EmailPreviewDetail | null;
|
|
125
|
+
/** The union of every source's gaps. */
|
|
126
|
+
coverage(): EmailPreviewCoverage;
|
|
127
|
+
/** Is this a language the host said it writes in? */
|
|
128
|
+
supportsLocale(locale: string): boolean;
|
|
129
|
+
}
|
|
130
|
+
declare function createEmailPreviews(config: EmailPreviewsConfig): ApiEmailPreviews;
|
|
131
|
+
|
|
132
|
+
export { type ApiEmailPreviews as A, DuplicateEmailPreviewIdError as D, type EmailPreviewsConfig as E, type EmailPreviewCoverage as a, type EmailPreviewDetail as b, type EmailPreviewIndex as c, type EmailPreviewMessage as d, type EmailPreviewRow as e, type EmailPreviewSource as f, createEmailPreviews as g };
|
|
@@ -170,7 +170,7 @@ import { Suspense, lazy, useEffect, useState } from "react";
|
|
|
170
170
|
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
171
171
|
function lazyNotificationsPanel(parts) {
|
|
172
172
|
const Bound = lazy(async () => {
|
|
173
|
-
const { NotificationsPanel } = await import("./panel-
|
|
173
|
+
const { NotificationsPanel } = await import("./panel-T36JEMO3.js");
|
|
174
174
|
return {
|
|
175
175
|
default: /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx2(NotificationsPanel, { ...props, ...parts }), "default")
|
|
176
176
|
};
|
|
@@ -191,7 +191,7 @@ import { Suspense as Suspense2, lazy as lazy2 } from "react";
|
|
|
191
191
|
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
192
192
|
function lazyPreferencesPage(parts) {
|
|
193
193
|
const Bound = lazy2(async () => {
|
|
194
|
-
const { PreferencesScreen } = await import("./preferences-screen-
|
|
194
|
+
const { PreferencesScreen } = await import("./preferences-screen-SXUIGECY.js");
|
|
195
195
|
return {
|
|
196
196
|
default: /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx3(PreferencesScreen, { ...props, ...parts }), "default")
|
|
197
197
|
};
|
|
@@ -218,7 +218,11 @@ function createWebNotifications(config) {
|
|
|
218
218
|
...config.useSignal ? { useSignal: config.useSignal } : {}
|
|
219
219
|
};
|
|
220
220
|
const Bell = /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx4(BellButton, { ...props, store, messages, ...subscribeOption }), "Bell");
|
|
221
|
-
const Panel = lazyNotificationsPanel({
|
|
221
|
+
const Panel = lazyNotificationsPanel({
|
|
222
|
+
store,
|
|
223
|
+
messages,
|
|
224
|
+
...config.liveActivities ? { live: config.liveActivities } : {}
|
|
225
|
+
});
|
|
222
226
|
function useBoundUnreadCount(options = {}) {
|
|
223
227
|
return useUnreadCount(store, { ...options, ...subscribeOption });
|
|
224
228
|
}
|
|
@@ -260,4 +264,4 @@ export {
|
|
|
260
264
|
httpNotificationsTransport,
|
|
261
265
|
createWebNotifications
|
|
262
266
|
};
|
|
263
|
-
//# sourceMappingURL=chunk-
|
|
267
|
+
//# sourceMappingURL=chunk-5Y7QRORV.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/react/api.ts","../src/react/transport.ts","../src/react/create-web-notifications.tsx","../src/react/bell-button.tsx","../src/react/panel-lazy.tsx","../src/react/page-lazy.tsx"],"sourcesContent":["import type { ChannelRow } from '../preferences-core';\nimport type { NotificationChannel } from '../types';\nimport type { ListNotificationsResult } from '../wire';\n\nimport type { NotificationsResult, NotificationsTransport } from './transport';\n\n/**\n * The wire client, bound to one mount (12-15).\n *\n * Every path this package's screens can call, in one place — which is what\n * makes the api half's route table and the web half's URLs one contract instead\n * of two lists that drift.\n */\n\n/** `GET <mount>/notification-preferences` and the PUT's answer. */\nexport interface PreferencesPayload {\n preferences: Record<string, ChannelRow>;\n availability: Record<NotificationChannel, boolean>;\n /** The host's taxonomy, so the screen renders it without being told twice. */\n categories: string[];\n}\n\n/** `GET <mount>/push-subscriptions`. */\nexport interface PushRegistrationPayload {\n /** null = web push is not configured on this deployment. */\n vapidPublicKey: string | null;\n count: number;\n /**\n * Whether the endpoint asked about is still registered to the caller. Present\n * only when one was passed — see {@link NotificationsApiClient.getPushRegistration}.\n */\n registered?: boolean;\n}\n\nexport interface NotificationsApiClient {\n listNotifications(input: {\n cursor?: string | null;\n limit?: number;\n filter?: 'all' | 'unread';\n }): Promise<ListNotificationsResult>;\n unreadCount(): Promise<number>;\n markRead(ids: readonly string[]): Promise<NotificationsResult<{ updated: number }>>;\n markAllRead(): Promise<NotificationsResult<{ updated: number }>>;\n remove(ids: readonly string[]): Promise<NotificationsResult<{ deleted: number }>>;\n getPreferences(): Promise<PreferencesPayload>;\n savePreference(\n category: string,\n channel: NotificationChannel,\n enabled: boolean,\n ): Promise<NotificationsResult<PreferencesPayload>>;\n /**\n * The deployment's VAPID key and the caller's device count — and, when an\n * `endpoint` is passed, whether the SERVER still has that exact subscription\n * under the caller's id. The browser holding a subscription object is not\n * evidence of that: a re-own or a 404/410 prune drops the row and leaves the\n * browser's object in place.\n */\n getPushRegistration(input?: { endpoint?: string }): Promise<PushRegistrationPayload>;\n savePushSubscription(input: {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n }): Promise<NotificationsResult<{ count: number }>>;\n removePushSubscription(endpoint: string): Promise<NotificationsResult<{ count: number }>>;\n}\n\nexport function createNotificationsApiClient(\n apiBase: string,\n transport: NotificationsTransport,\n): NotificationsApiClient {\n const base = apiBase.replace(/\\/$/, '');\n const url = (path: string): string => `${base}${path}`;\n\n return {\n listNotifications({ cursor, limit, filter }) {\n const params = new URLSearchParams();\n if (limit !== undefined) params.set('limit', String(limit));\n if (cursor) params.set('cursor', cursor);\n if (filter) params.set('filter', filter);\n const query = params.toString();\n return transport.get<ListNotificationsResult>(\n url(`/notifications${query ? `?${query}` : ''}`),\n );\n },\n async unreadCount() {\n const { count } = await transport.get<{ count: number }>(\n url('/notifications/unread-count'),\n );\n return count;\n },\n markRead: (ids) =>\n transport.send(url('/notifications/mark-read'), 'POST', { ids: [...ids] }),\n markAllRead: () => transport.send(url('/notifications/mark-read'), 'POST', { all: true }),\n remove: (ids) => transport.send(url('/notifications/delete'), 'POST', { ids: [...ids] }),\n getPreferences: () => transport.get<PreferencesPayload>(url('/notification-preferences')),\n savePreference: (category, channel, enabled) =>\n transport.send(url('/notification-preferences'), 'PUT', {\n [category]: { [channel]: enabled },\n }),\n getPushRegistration: ({ endpoint } = {}) =>\n transport.get<PushRegistrationPayload>(\n url(\n endpoint\n ? `/push-subscriptions?endpoint=${encodeURIComponent(endpoint)}`\n : '/push-subscriptions',\n ),\n ),\n savePushSubscription: (input) => transport.send(url('/push-subscriptions'), 'POST', input),\n removePushSubscription: (endpoint) =>\n transport.send(url('/push-subscriptions'), 'DELETE', { endpoint }),\n };\n}\n","/**\n * How the notification screens reach their data (12-15) — the report-builder\n * transport doctrine: this is the ONLY way the surface performs I/O, so a\n * caller supplying one has substituted the entire backend without stubbing a\n * global. The default is same-origin `fetch` riding the browser's cookies.\n */\n\n/** A write outcome the screens branch on — never a thrown mutation. */\nexport type NotificationsResult<T> = { ok: true; data: T } | { ok: false; error: string };\n\n/** A failed read, carrying the status the screens branch on (401 = signed out). */\nexport class NotificationsHttpError extends Error {\n readonly status: number;\n constructor(status: number, message: string) {\n super(message);\n this.name = 'NotificationsHttpError';\n this.status = status;\n Object.setPrototypeOf(this, NotificationsHttpError.prototype);\n }\n}\n\nexport interface NotificationsTransport {\n /** A read. Returns the payload INSIDE the `{ data }` envelope. */\n get<T>(path: string): Promise<T>;\n /** A write. Returns a {@link NotificationsResult} rather than rejecting. */\n send<T>(path: string, method: string, body?: unknown): Promise<NotificationsResult<T>>;\n}\n\n/**\n * @param fallbackError What a failed write says when the server sent no\n * sentence of its own — REQUIRED, the host's words. `createWebNotifications`\n * already passes its (equally required) `messages.operationFailed`; only a\n * host constructing the transport directly writes it here. The old default\n * was one application's Portuguese, and the only string in this package the\n * required-messages port did not cover.\n */\nexport function httpNotificationsTransport(fallbackError: string): NotificationsTransport {\n return {\n async get<T>(path: string): Promise<T> {\n const response = await fetch(path, {\n credentials: 'same-origin',\n headers: { Accept: 'application/json' },\n });\n const payload = (await response.json().catch(() => null)) as\n | { data?: T; error?: string }\n | null;\n if (!response.ok) {\n throw new NotificationsHttpError(\n response.status,\n payload?.error ?? `HTTP ${response.status} for ${path}`,\n );\n }\n return (payload?.data ?? payload) as T;\n },\n\n async send<T>(path: string, method: string, body?: unknown): Promise<NotificationsResult<T>> {\n try {\n const response = await fetch(path, {\n method,\n credentials: 'same-origin',\n headers: {\n Accept: 'application/json',\n ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),\n },\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n });\n if (response.status === 204) return { ok: true, data: undefined as T };\n const payload = (await response.json().catch(() => null)) as\n | { data?: T; error?: string }\n | null;\n if (!response.ok) return { ok: false, error: payload?.error ?? fallbackError };\n return { ok: true, data: (payload?.data ?? payload) as T };\n } catch {\n return { ok: false, error: fallbackError };\n }\n },\n };\n}\n","import { useState, type ComponentType, type JSX } from 'react';\n\nimport { messagesOf, type NotificationMessages } from '../messages';\n\nimport { createNotificationsApiClient, type NotificationsApiClient } from './api';\nimport { BellButton, type BellButtonProps } from './bell-button';\nimport {\n useUnreadCount,\n type NotificationsSignalHook,\n type NotificationsSubscribe,\n} from './hooks';\nimport { createInboxStore, type InboxStore } from './inbox-state';\nimport type { LiveActivitiesConfig } from './live-config';\nimport { lazyNotificationsPanel } from './panel-lazy';\nimport type { NotificationsPanelProps } from './panel';\nimport { lazyPreferencesPage } from './page-lazy';\nimport type { PreferencesScreenProps } from './preferences-screen';\nimport { httpNotificationsTransport, type NotificationsTransport } from './transport';\nimport type { WebPushSetupConfig } from './web-push-setup';\n\n/**\n * The one thing this package exposes to a FRONTEND host (12-15).\n *\n * Everything the notification centre IS — the bell with its live badge, the\n * slide-over inbox with its optimistic mark-read / delete / mark-all and its\n * cursor pager, the preferences matrix with its availability hints and the\n * per-browser push enable step, and every wire call between them — lives inside\n * this package. The host names where the API is mounted, and that is the whole\n * wiring.\n *\n * `page` is the standalone surface (the preferences screen), which is the one\n * thing a host routes to. The bell and the panel are a PAIR a host drops into\n * its own chrome, and they share one store, so a read in the panel moves the\n * badge in the same tick.\n */\n\nexport interface NotificationsWebConfig {\n /** The account mount the routes live under, e.g. `/api/account`. */\n apiBase: string;\n /** How the surface reaches its data. Default: same-origin fetch. */\n transport?: NotificationsTransport;\n /** User-facing copy overrides (pt-BR product copy by default). */\n messages: NotificationMessages;\n /**\n * How the surface learns an inbox changed without asking — the host's message\n * bus. Without it the badge keeps its 60 s poll, which is the standing\n * contract rather than a fallback: a dropped event must cost latency, never\n * correctness.\n */\n subscribe?: NotificationsSubscribe;\n /**\n * The same wiring as a HOOK, for a host whose realtime connection lives in\n * React context — see `NotificationsSignalHook`. `subscribe` is read at\n * factory time, which such a host cannot reach.\n */\n useSignal?: NotificationsSignalHook;\n /** The browser push enable step's host seams (SW path, platform hint). */\n webPush?: WebPushSetupConfig;\n /**\n * LIVE ACTIVITIES — the ongoing-state entries pinned above the inbox list.\n *\n * Opt-in, and absent means absent: a host that passes nothing gets the panel\n * it had, with no section, no heading and no reserved space. See\n * `./live-config` for the two things a host has to supply (where they come\n * from, and what the section says) and `../live` for what one IS.\n */\n liveActivities?: LiveActivitiesConfig;\n}\n\nexport interface WebNotifications {\n /**\n * The routed surface: the preferences screen.\n *\n * Loaded on demand — see `page-lazy.tsx`. A host that mounts only the bell and\n * the panel never downloads it, and a host that routes to it fetches it while\n * entering that route.\n */\n page: ComponentType<PreferencesScreenProps>;\n /** The bell, already bound to the shared store. */\n BellButton: ComponentType<BellButtonProps>;\n /**\n * The inbox slide-over, sharing that store.\n *\n * Loaded the first time it is opened — see `panel-lazy.tsx`. Until then a\n * host's chrome carries the bell and nothing else.\n */\n Panel: ComponentType<NotificationsPanelProps>;\n /**\n * Bell + panel as ONE element, for a host that just wants the feature in its\n * header and does not want to own the open/closed state.\n */\n BellWithPanel: ComponentType<{\n enabled?: boolean;\n onNavigate?: (link: string) => void;\n }>;\n /** The badge number, for a host with its own trigger chrome. */\n useUnreadCount: (options?: { enabled?: boolean }) => number;\n /** The shared client state, for host glue. */\n store: InboxStore;\n /** The bound wire client. */\n api: NotificationsApiClient;\n /** The copy in force, so a host's own chrome can reuse a sentence. */\n messages: NotificationMessages;\n}\n\nexport function createWebNotifications(config: NotificationsWebConfig): WebNotifications {\n const messages = messagesOf(config);\n const api = createNotificationsApiClient(\n config.apiBase,\n config.transport ?? httpNotificationsTransport(messages.operationFailed),\n );\n const store = createInboxStore(api);\n const webPush = config.webPush ?? {};\n const subscribe = config.subscribe;\n const subscribeOption = {\n ...(subscribe ? { subscribe } : {}),\n ...(config.useSignal ? { useSignal: config.useSignal } : {}),\n };\n\n const Bell: ComponentType<BellButtonProps> = (props) => (\n <BellButton {...props} store={store} messages={messages} {...subscribeOption} />\n );\n const Panel = lazyNotificationsPanel({\n store,\n messages,\n ...(config.liveActivities ? { live: config.liveActivities } : {}),\n });\n\n function useBoundUnreadCount(options: { enabled?: boolean } = {}): number {\n return useUnreadCount(store, { ...options, ...subscribeOption });\n }\n\n function BellWithPanel({\n enabled = true,\n onNavigate,\n }: {\n enabled?: boolean;\n onNavigate?: (link: string) => void;\n }): JSX.Element {\n const [open, setOpen] = useState(false);\n return (\n <>\n <Bell enabled={enabled} onClick={() => setOpen(true)} />\n <Panel\n open={open}\n onClose={() => setOpen(false)}\n {...(onNavigate ? { onNavigate } : {})}\n />\n </>\n );\n }\n\n return {\n page: lazyPreferencesPage({ api, messages, webPush }),\n BellButton: Bell,\n Panel,\n BellWithPanel,\n useUnreadCount: useBoundUnreadCount,\n store,\n api,\n messages,\n };\n}\n","/**\n * Bare bell trigger with the live unread badge — for hosts that do not already\n * have a styled icon-button slot. A host with its own trigger chrome uses\n * `useUnreadCount` + `Panel` directly.\n */\nimport type { JSX } from 'react';\n\nimport { Badge } from '@12-apps/ui/data-display/Badge';\nimport { Box } from '@12-apps/ui/mui/Box';\n\nimport type { NotificationMessages } from '../messages';\n\nimport { BellIcon } from './bell-icon';\nimport { useUnreadCount, type NotificationsSignalHook, type NotificationsSubscribe } from './hooks';\nimport type { InboxStore } from './inbox-state';\n\nconst triggerSx = {\n display: 'inline-flex',\n alignItems: 'center',\n justifyContent: 'center',\n p: 0.5,\n border: 'none',\n background: 'none',\n cursor: 'pointer',\n color: 'text.primary',\n lineHeight: 0,\n '& *': { cursor: 'pointer' },\n '&:hover': { color: 'primary.main' },\n '&:focus-visible': {\n outline: '2px solid',\n outlineColor: 'primary.main',\n outlineOffset: '2px',\n borderRadius: '50%',\n },\n} as const;\n\nexport interface BellButtonProps {\n onClick: () => void;\n /** Signed-out hosts still mount the bell; `false` silences it. */\n enabled?: boolean;\n}\n\nexport function BellButton({\n onClick,\n enabled = true,\n store,\n messages,\n subscribe,\n useSignal,\n}: BellButtonProps & {\n store: InboxStore;\n messages: NotificationMessages;\n subscribe?: NotificationsSubscribe;\n useSignal?: NotificationsSignalHook;\n}): JSX.Element {\n const count = useUnreadCount(store, {\n enabled,\n ...(subscribe ? { subscribe } : {}),\n ...(useSignal ? { useSignal } : {}),\n });\n return (\n <Box\n component=\"button\"\n type=\"button\"\n onClick={onClick}\n aria-label={count > 0 ? messages.openBellWithUnread(count) : messages.openBell}\n data-testid=\"notifications-bell\"\n sx={triggerSx}\n >\n <Badge\n content={count > 0 ? count : undefined}\n color=\"primary\"\n variant=\"count\"\n max={99}\n data-testid=\"notifications-badge\"\n >\n <BellIcon size={28} />\n </Badge>\n </Box>\n );\n}\n","/**\n * The inbox slide-over, fetched the first time somebody opens it.\n *\n * The bell and the panel are a PAIR a host drops into its chrome, and that is\n * still true — but only the BELL is on screen when a page paints. The panel is\n * behind a tap, and a static import made every host pay for it up front: the\n * design-system `Drawer` and, through it, MUI's `SwipeableDrawer`, `Modal`,\n * `Slide` and the focus trap, plus the row, the empty state and the pager. On a\n * storefront that is a slide-over most visits never open, parsed before the\n * first screen can render.\n *\n * ## Why the gate is \"ever opened\" rather than `open`\n *\n * `lazy` fetches when a component first RENDERS, so a boundary that still\n * rendered the panel while closed would fetch immediately and buy nothing. This\n * renders `null` until the panel has been open once, which is what actually\n * defers the download to the tap.\n *\n * And once opened it STAYS mounted. Unmounting on close would throw away the\n * drawer's transition state, so the panel would vanish instead of sliding out,\n * and the entrance animation would re-run on every reopen — which someone\n * working through an inbox does repeatedly. The fetch happens once.\n *\n * The initial state reads `open` rather than starting at `false`, so a host that\n * mounts the panel already open renders it in the same commit instead of a frame\n * later.\n *\n * ## Why `null` for the fallback\n *\n * The only frame this can show anything is the one right after the tap, where a\n * spinner reads as a stall rather than as progress. The chunk is small and\n * same-origin.\n */\nimport { Suspense, lazy, useEffect, useState, type ComponentType, type JSX } from 'react';\n\nimport type { NotificationMessages } from '../messages';\n\nimport type { InboxStore } from './inbox-state';\nimport type { LiveActivitiesConfig } from './live-config';\nimport type { NotificationsPanelProps } from './panel';\n\n/** What the factory binds into the panel, and the host never passes. */\ninterface PanelParts {\n store: InboxStore;\n messages: NotificationMessages;\n /** Absent unless the host turned live activities on — see `./live-config`. */\n live?: LiveActivitiesConfig;\n}\n\nexport function lazyNotificationsPanel(\n parts: PanelParts,\n): ComponentType<NotificationsPanelProps> {\n const Bound = lazy(async () => {\n const { NotificationsPanel } = await import('./panel');\n return {\n default: (props: NotificationsPanelProps): JSX.Element => (\n <NotificationsPanel {...props} {...parts} />\n ),\n };\n });\n\n return function NotificationsPanelSlot(props: NotificationsPanelProps): JSX.Element | null {\n const [everOpened, setEverOpened] = useState(props.open);\n\n useEffect(() => {\n if (props.open) setEverOpened(true);\n }, [props.open]);\n\n if (!everOpened) return null;\n\n return (\n <Suspense fallback={null}>\n <Bound {...props} />\n </Suspense>\n );\n };\n}\n","/**\n * The routed preferences screen, fetched when a host actually routes to it.\n *\n * `createWebNotifications` returns two different KINDS of thing, and its own\n * docstring says so: `page` is \"the standalone surface … the one thing a host\n * routes to\", while the bell and the panel \"are a PAIR a host drops into its own\n * chrome\". Chrome is on screen from the first paint; a routed surface is not.\n *\n * A static import made that distinction invisible to a bundler. Every host that\n * put the bell in its header also shipped the preferences matrix — its channel\n * toggles, the per-browser push enable step, and the design-system `Switch`\n * behind them — in the same chunk as the header. A storefront paid for a\n * settings screen a shopper never opens, before its first screen could render;\n * a host that renders its OWN preferences page paid for this one twice.\n *\n * So `page` now loads on demand. Nothing else moves: the bell, the panel and\n * `BellWithPanel` stay exactly as eager as the chrome they belong to, because\n * that is what they are.\n *\n * NO PREFETCH, deliberately, and this is the opposite call from a surface a\n * host opens from chrome it already has. A routed surface is reached by\n * NAVIGATION, and every host here already code-splits its routes — so the\n * fetch happens while the route is being entered, which is the moment a\n * prefetch would have been trying to anticipate. Warming it at factory time\n * would put the screen back on the boot path of every app, which is the whole\n * cost this removes.\n */\nimport { Suspense, lazy, type ComponentType, type JSX } from 'react';\n\nimport type { NotificationMessages } from '../messages';\n\nimport type { NotificationsApiClient } from './api';\nimport type { PreferencesScreenProps } from './preferences-screen';\nimport type { WebPushSetupConfig } from './web-push-setup';\n\n/** What the factory binds into the screen, and the host never passes. */\ninterface PreferencesPageParts {\n api: NotificationsApiClient;\n messages: NotificationMessages;\n webPush: WebPushSetupConfig;\n}\n\n/**\n * The routed screen, bound and loaded on first render.\n *\n * `lazy` memoises its factory, so the binding below happens once however many\n * times a host mounts the page — the same guarantee the direct call gave.\n *\n * The fallback is `null` because a host routes to this: whatever it renders\n * around the route is already on screen, and a second spinner inside it would\n * be one more thing appearing and disappearing during a navigation the host is\n * already indicating.\n */\nexport function lazyPreferencesPage(\n parts: PreferencesPageParts,\n): ComponentType<PreferencesScreenProps> {\n const Bound = lazy(async () => {\n const { PreferencesScreen } = await import('./preferences-screen');\n return {\n default: (props: PreferencesScreenProps): JSX.Element => (\n <PreferencesScreen {...props} {...parts} />\n ),\n };\n });\n\n return function NotificationsPreferencesPage(props: PreferencesScreenProps): JSX.Element {\n return (\n <Suspense fallback={null}>\n <Bound {...props} />\n </Suspense>\n );\n };\n}\n"],"mappings":"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","names":["useState","jsx","Suspense","lazy","jsx","lazy","Suspense","jsx","useState"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/types.ts"],"sourcesContent":["/**\n * Core types of the channel-agnostic notification system (12-15).\n *\n * Three decoupled layers, each open for extension without touching the others:\n * - GENERATORS map a typed domain event to agnostic content (title/body/…).\n * - The CHANNEL ROUTER always writes the notification-centre inbox record,\n * then fans out one delivery per enabled channel.\n * - TRANSPORTS format the agnostic content for one channel and send it.\n *\n * Nothing here knows about a concrete channel's wire format — that lives\n * entirely inside each transport adapter — and nothing here knows about a\n * concrete DOMAIN either: the event `type` set, the preference categories and\n * the channel list are all host config (see {@link NotificationTaxonomy}).\n */\n\n/** Transport channels a notification can fan out to (DB CHECK mirrors this). */\nexport const NOTIFICATION_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'WEB_PUSH'] as const;\nexport type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number];\n\n/**\n * The preference categories are the HOST's, and required.\n *\n * There used to be a `NOTIFICATION_CATEGORIES = ['orders','payments','stock',\n * 'system']` here — one product's set — and `taxonomyOf` fell back to it\n * whenever a host passed none. The docstring argued the case itself: \"it is\n * product vocabulary, not machinery\", and then shipped the vocabulary anyway as\n * the default, which is the only part a forgetful host would ever see.\n *\n * The consequence was quiet rather than loud: the settings screen renders four\n * rows a foreign host never chose, its own categories are absent, and every\n * preference a user sets is filed against a taxonomy nothing else in that\n * system uses. Nothing throws, because `category` is deliberately a free string\n * — the packaged migration puts **no CHECK** on it, precisely because a closed\n * set would be wrong for every host but the first. That freedom is what made\n * the default undetectable.\n *\n * `channel` and `status` are different and keep their CHECKs: those ARE this\n * library's own closed sets.\n */\nexport type NotificationCategory = string;\n\n/**\n * Per-channel delivery lifecycle (DB CHECK mirrors this).\n *\n * `SENDING` is the CLAIM: exactly one dispatcher moves a row out of `QUEUED`,\n * so two dispatchers can never both send the same delivery. A row left\n * `SENDING` is a dispatcher that died mid-send, and the sweep reclaims it once\n * it is older than the cutoff.\n *\n * `DEAD` is terminal: the attempt ceiling was reached (or the recipient no\n * longer exists), and no sweep will pick the row up again. Without it a\n * permanently invalid destination is a billed provider call on every sweep,\n * forever, and the sweep's working set only grows.\n */\nexport type DeliveryStatus = 'QUEUED' | 'SENDING' | 'SENT' | 'FAILED' | 'DEAD';\n\n/**\n * Channel-agnostic content a generator produces. This is what the inbox stores\n * verbatim and what every transport's formatter receives — no channel may leak\n * its wire format into it.\n */\nexport interface NotificationContent {\n title: string;\n body: string;\n /** In-app deep link (a same-origin path such as `/orders/123`). */\n link?: string;\n /**\n * Structured extras for consumers that want more than text.\n *\n * ONE KEY IS RESERVED: `liveSubject` (`LIVE_SUBJECT_KEY` in `./live`) ties\n * this notification to a live activity, and the WEB_PUSH transport turns it\n * into the tray `tag` that collapses every push about one subject onto a\n * single entry. A host already using that name for something else acquires\n * that behaviour without asking for it — so it is named here, on the field a\n * generator actually writes, and not only where the feature is documented.\n */\n data?: Record<string, unknown>;\n}\n\n/** Who receives a notification. `clientId` scopes it to a tenant when set. */\nexport interface NotificationRecipient {\n userId: string;\n clientId?: string;\n}\n\n/**\n * A typed domain event handed to `notify`. `type` selects the registered\n * generator; `payload` is that generator's typed input. Callers never touch\n * channels, formatting, or preferences.\n */\nexport interface NotificationEvent<TPayload = unknown> {\n type: string;\n recipient: NotificationRecipient;\n payload: TPayload;\n}\n\n/**\n * Maps one domain event type to agnostic content. Registered through the\n * server config (or `registerGenerator` for a late arrival); adding a\n * generator never touches existing generators, the router, or any transport\n * (open/closed).\n */\n/**\n * Who the content is being rendered FOR — the reader, at the moment the\n * generator is asked.\n *\n * A notification is stored as rendered TEXT: title and body are columns, so\n * the language is chosen once, when the row is written, and never again. That\n * makes this the only honest place to ask. A generator is registered at BOOT —\n * a host that resolved its words there would pin every future reader to\n * whichever language the process happened to start in, invisibly, because a\n * single-locale host cannot tell the difference.\n *\n * The tag is the RECIPIENT's, never the request's. The person who triggers a\n * notification is routinely not the person who reads it: an invite is sent\n * because an administrator acted and is read by the invitee. Reading\n * `Accept-Language` here would be a bug that only ever surfaces as somebody\n * being told things in a language they do not speak.\n *\n * Absent means \"nobody said\" — a host with one audience, or one that stores no\n * per-user language, populates nothing and every generator answers with its\n * own default exactly as it did before this existed.\n */\nexport interface NotificationGenerateContext {\n readonly locale?: string | null;\n}\n\nexport interface NotificationGenerator<TPayload = unknown> {\n /** The event key, dot-namespaced (\"order.paid\"). One generator per type. */\n type: string;\n /** The preference category the router gates this type's fan-out on. */\n category: NotificationCategory;\n /**\n * Render this event's content for ONE recipient.\n *\n * `context` is OPTIONAL, and that is what keeps every generator written\n * before it working: a one-parameter `generate` is assignable to this\n * signature unchanged. A host that passes nothing is stating a fact — it has\n * no language for this reader — rather than asserting a default.\n */\n generate: (payload: TPayload, context?: NotificationGenerateContext) => NotificationContent;\n}\n\n/**\n * The recipient as a transport sees them: resolved destinations only. Built by\n * the router from the host's contact directory + the push subscriptions this\n * package owns; transports use it to answer\n * {@link NotificationTransport.supports}.\n */\nexport interface TransportRecipient {\n userId: string;\n email: string | null;\n /**\n * The recipient's own language, when the host's contact directory states\n * one. Absent means \"nobody said\" — see {@link NotificationGenerateContext}.\n */\n locale?: string | null;\n /** Phone as the host stores it (transports normalize per provider rules). */\n phone: string | null;\n /** How many active browser push subscriptions the user holds. */\n pushSubscriptionCount: number;\n}\n\n/**\n * One pluggable channel adapter: a FORMATTER (agnostic content → channel\n * message) plus a SENDER. Adding a channel = registering one of these; the\n * router dispatches through the registry and needs no change.\n *\n * `send` resolves on success and THROWS on failure — the router records the\n * error on the delivery row and isolates it from other channels. Sends must be\n * retry-safe: the router may re-dispatch a QUEUED/FAILED delivery.\n */\nexport interface NotificationTransport<TMessage = unknown> {\n channel: NotificationChannel;\n /**\n * Whether this recipient is addressable on this channel right now — the\n * destination exists (e-mail / phone / push subscription) AND the provider\n * is configured. `false` simply skips the channel (no delivery row).\n */\n supports(recipient: TransportRecipient): boolean;\n /** Transform the agnostic content into this channel's message shape. */\n format(content: NotificationContent): TMessage;\n /** Deliver the formatted message to the recipient. Throws on failure. */\n send(message: TMessage, recipient: TransportRecipient): Promise<void>;\n}\n\n/**\n * The host's product vocabulary. Everything below the surface (routing,\n * delivery rows, retries, the wire) is identical for every host; WHICH\n * categories exist and how they are labelled is not.\n */\nexport interface NotificationTaxonomy {\n /** The preference categories, in the order the settings screen lists them. */\n categories: readonly NotificationCategory[];\n}\n\n/**\n * The taxonomy in force. `categories` is REQUIRED — see above.\n *\n * The empty check was already here and stays: an empty list and a missing one\n * are the same mistake, and both now fail at assembly rather than rendering an\n * empty settings screen or somebody else's four rows.\n */\nexport function taxonomyOf(config: {\n categories: readonly NotificationCategory[];\n}): NotificationTaxonomy {\n const categories = config.categories;\n if (!categories || categories.length === 0) {\n throw new Error(\n '@12-apps/notifications: `categories` is required and must not be empty — ' +\n 'the preference categories are the host\\'s product vocabulary.',\n );\n }\n return { categories: [...categories] };\n}\n\n/** The host's logger. Defaults to the console (the @12-apps/jobs precedent). */\nexport interface NotificationLogger {\n info(message: string, ...meta: unknown[]): void;\n error(message: string, ...meta: unknown[]): void;\n}\n"],"mappings":";;;;;AAgBO,IAAM,wBAAwB,CAAC,SAAS,OAAO,YAAY,UAAU;AA2LrE,SAAS,WAAW,QAEF;AACvB,QAAM,aAAa,OAAO;AAC1B,MAAI,CAAC,cAAc,WAAW,WAAW,GAAG;AAC1C,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO,EAAE,YAAY,CAAC,GAAG,UAAU,EAAE;AACvC;AAXgB;","names":[]}
|