@12-apps/notifications 4.0.0 → 4.1.1

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.
@@ -0,0 +1,44 @@
1
+ import { Hono, Context } from 'hono';
2
+ import { ApiNotifications, NotificationsServerConfig, NotificationsActor } from '../server/index.js';
3
+ import '../generators-FATT537X.js';
4
+ import '../types-yq_o4N01.js';
5
+ import '../wire-SDUtscGu.js';
6
+ import '../web-push-KLY6UMRT.js';
7
+
8
+ /**
9
+ * `@12-apps/notifications/hono` — the account notification endpoints as a
10
+ * mountable router.
11
+ *
12
+ * The framework-neutral descriptors in `/server` are the contract; this is the
13
+ * adapter for the framework we happen to use, behind its own subpath with
14
+ * `hono` as an OPTIONAL peer (the report-builder precedent — a host on Express,
15
+ * or one that only wants the React surface, never resolves Hono).
16
+ *
17
+ * A host writes:
18
+ *
19
+ * const notifications = notificationsRouter({ …config, resolveActor });
20
+ * app.route('/api/account', notifications.router);
21
+ *
22
+ * and keeps what is genuinely its own: who the caller is. Everything after
23
+ * that — parsing, status codes, the envelope, the pt-BR copy — is the
24
+ * package's.
25
+ */
26
+ /**
27
+ * Resolve the caller. Returning `null` means unauthenticated, which answers 401
28
+ * before any handler runs.
29
+ *
30
+ * Note the 401 is self-guarded HERE rather than assumed from middleware: these
31
+ * paths sit under an API prefix that a host's page middleware typically does not
32
+ * match, and an unauthenticated inbox read that fell through would answer
33
+ * somebody else's rows or none at all — both worse than a 401.
34
+ */
35
+ type ResolveNotificationsActor = (c: Context) => Promise<NotificationsActor | null> | NotificationsActor | null;
36
+ interface NotificationsHonoConfig extends NotificationsServerConfig {
37
+ resolveActor: ResolveNotificationsActor;
38
+ }
39
+ interface NotificationsHono extends ApiNotifications {
40
+ router: Hono;
41
+ }
42
+ declare function notificationsRouter(config: NotificationsHonoConfig): NotificationsHono;
43
+
44
+ export { type NotificationsHono, type NotificationsHonoConfig, type ResolveNotificationsActor, notificationsRouter };
@@ -0,0 +1,60 @@
1
+ import {
2
+ createApiNotifications
3
+ } from "../chunk-4PSUZ7X5.js";
4
+ import "../chunk-AHNRSA6U.js";
5
+ import {
6
+ messagesOf
7
+ } from "../chunk-4TTYQVPK.js";
8
+ import {
9
+ __name
10
+ } from "../chunk-7QVYU63E.js";
11
+
12
+ // src/hono/index.ts
13
+ import { Hono } from "hono";
14
+ function saysJson(c) {
15
+ const type = c.req.header("content-type");
16
+ if (!type) return false;
17
+ const mime = (type.split(";")[0] ?? "").trim().toLowerCase();
18
+ return mime === "application/json" || mime.endsWith("+json");
19
+ }
20
+ __name(saysJson, "saysJson");
21
+ async function readBody(c) {
22
+ if (c.req.method === "GET") return void 0;
23
+ if (!saysJson(c)) return void 0;
24
+ try {
25
+ return await c.req.json();
26
+ } catch {
27
+ return void 0;
28
+ }
29
+ }
30
+ __name(readBody, "readBody");
31
+ function notificationsRouter(config) {
32
+ const api = createApiNotifications(config);
33
+ const messages = messagesOf(config);
34
+ const router = new Hono();
35
+ for (const route of api.routes) {
36
+ const handler = /* @__PURE__ */ __name(async (c) => {
37
+ const actor = await config.resolveActor(c);
38
+ if (!actor) return c.json({ error: messages.unauthenticated }, 401);
39
+ const response = await route.handle({
40
+ actor,
41
+ params: c.req.param(),
42
+ query: c.req.query(),
43
+ body: await readBody(c),
44
+ headers: { "user-agent": c.req.header("user-agent") }
45
+ });
46
+ if (response.body === void 0) return c.body(null, response.status);
47
+ return c.json(response.body, response.status);
48
+ }, "handler");
49
+ if (route.method === "GET") router.get(route.path, handler);
50
+ else if (route.method === "POST") router.post(route.path, handler);
51
+ else if (route.method === "PUT") router.put(route.path, handler);
52
+ else router.delete(route.path, handler);
53
+ }
54
+ return { ...api, router };
55
+ }
56
+ __name(notificationsRouter, "notificationsRouter");
57
+ export {
58
+ notificationsRouter
59
+ };
60
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/hono/index.ts"],"sourcesContent":["import { Hono } from 'hono';\nimport type { Context } from 'hono';\n\nimport { messagesOf } from '../messages';\n\nimport {\n createApiNotifications,\n type ApiNotifications,\n type NotificationsServerConfig,\n} from '../server/create-api-notifications';\nimport type { NotificationsActor } from '../server/context';\n\n/**\n * `@12-apps/notifications/hono` — the account notification endpoints as a\n * mountable router.\n *\n * The framework-neutral descriptors in `/server` are the contract; this is the\n * adapter for the framework we happen to use, behind its own subpath with\n * `hono` as an OPTIONAL peer (the report-builder precedent — a host on Express,\n * or one that only wants the React surface, never resolves Hono).\n *\n * A host writes:\n *\n * const notifications = notificationsRouter({ …config, resolveActor });\n * app.route('/api/account', notifications.router);\n *\n * and keeps what is genuinely its own: who the caller is. Everything after\n * that — parsing, status codes, the envelope, the pt-BR copy — is the\n * package's.\n */\n\n/**\n * Resolve the caller. Returning `null` means unauthenticated, which answers 401\n * before any handler runs.\n *\n * Note the 401 is self-guarded HERE rather than assumed from middleware: these\n * paths sit under an API prefix that a host's page middleware typically does not\n * match, and an unauthenticated inbox read that fell through would answer\n * somebody else's rows or none at all — both worse than a 401.\n */\nexport type ResolveNotificationsActor = (\n c: Context,\n) => Promise<NotificationsActor | null> | NotificationsActor | null;\n\nexport interface NotificationsHonoConfig extends NotificationsServerConfig {\n resolveActor: ResolveNotificationsActor;\n}\n\nexport interface NotificationsHono extends ApiNotifications {\n router: Hono;\n}\n\n/**\n * Reads the JSON body, tolerating an absent or malformed one — and only when\n * the caller SAID it was JSON.\n *\n * The content-type check is a CSRF speed bump, not a defence (see ADOPTING rule\n * 13, which names the actual one). `text/plain`, `multipart/form-data` and\n * `application/x-www-form-urlencoded` are the three types a cross-site `fetch`\n * or a plain `<form>` can send with NO preflight, so parsing a body regardless\n * of its type is what lets such a request reach these handlers at all. Refusing\n * them means a cross-site write has to earn a preflight first, which the browser\n * will then refuse on its own. The price is nil: every client of this surface,\n * the packaged one included, sends `application/json`.\n */\nfunction saysJson(c: Context): boolean {\n const type = c.req.header('content-type');\n if (!type) return false;\n const mime = (type.split(';')[0] ?? '').trim().toLowerCase();\n return mime === 'application/json' || mime.endsWith('+json');\n}\n\nasync function readBody(c: Context): Promise<unknown> {\n if (c.req.method === 'GET') return undefined;\n if (!saysJson(c)) return undefined;\n try {\n return await c.req.json();\n } catch {\n // A malformed body is the caller's error; the handler's own validation\n // reports it far better than a parse failure would.\n return undefined;\n }\n}\n\nexport function notificationsRouter(config: NotificationsHonoConfig): NotificationsHono {\n const api = createApiNotifications(config);\n const messages = messagesOf(config);\n const router = new Hono();\n\n // Mounted IN DESCRIPTOR ORDER, which any adapter must preserve. Hono resolves\n // by registration order, so a host route shaped `/notifications/:id` under the\n // same prefix must be registered AFTER this router or it captures\n // `/notifications/unread-count`.\n for (const route of api.routes) {\n const handler = async (c: Context): Promise<Response> => {\n const actor = await config.resolveActor(c);\n if (!actor) return c.json({ error: messages.unauthenticated }, 401);\n\n const response = await route.handle({\n actor,\n params: c.req.param() as Record<string, string | undefined>,\n query: c.req.query() as Record<string, string | undefined>,\n body: await readBody(c),\n headers: { 'user-agent': c.req.header('user-agent') },\n });\n\n // A handler that chose NO body means exactly that (204).\n if (response.body === undefined) return c.body(null, response.status as 204);\n // The status travels with the body the handler chose; the adapter never\n // reinterprets either.\n return c.json(response.body as Record<string, unknown>, response.status as 200);\n };\n\n if (route.method === 'GET') router.get(route.path, handler);\n else if (route.method === 'POST') router.post(route.path, handler);\n else if (route.method === 'PUT') router.put(route.path, handler);\n else router.delete(route.path, handler);\n }\n\n return { ...api, router };\n}\n"],"mappings":";;;;;;;;;;;;AAAA,SAAS,YAAY;AAiErB,SAAS,SAAS,GAAqB;AACrC,QAAM,OAAO,EAAE,IAAI,OAAO,cAAc;AACxC,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,QAAQ,KAAK,MAAM,GAAG,EAAE,CAAC,KAAK,IAAI,KAAK,EAAE,YAAY;AAC3D,SAAO,SAAS,sBAAsB,KAAK,SAAS,OAAO;AAC7D;AALS;AAOT,eAAe,SAAS,GAA8B;AACpD,MAAI,EAAE,IAAI,WAAW,MAAO,QAAO;AACnC,MAAI,CAAC,SAAS,CAAC,EAAG,QAAO;AACzB,MAAI;AACF,WAAO,MAAM,EAAE,IAAI,KAAK;AAAA,EAC1B,QAAQ;AAGN,WAAO;AAAA,EACT;AACF;AAVe;AAYR,SAAS,oBAAoB,QAAoD;AACtF,QAAM,MAAM,uBAAuB,MAAM;AACzC,QAAM,WAAW,WAAW,MAAM;AAClC,QAAM,SAAS,IAAI,KAAK;AAMxB,aAAW,SAAS,IAAI,QAAQ;AAC9B,UAAM,UAAU,8BAAO,MAAkC;AACvD,YAAM,QAAQ,MAAM,OAAO,aAAa,CAAC;AACzC,UAAI,CAAC,MAAO,QAAO,EAAE,KAAK,EAAE,OAAO,SAAS,gBAAgB,GAAG,GAAG;AAElE,YAAM,WAAW,MAAM,MAAM,OAAO;AAAA,QAClC;AAAA,QACA,QAAQ,EAAE,IAAI,MAAM;AAAA,QACpB,OAAO,EAAE,IAAI,MAAM;AAAA,QACnB,MAAM,MAAM,SAAS,CAAC;AAAA,QACtB,SAAS,EAAE,cAAc,EAAE,IAAI,OAAO,YAAY,EAAE;AAAA,MACtD,CAAC;AAGD,UAAI,SAAS,SAAS,OAAW,QAAO,EAAE,KAAK,MAAM,SAAS,MAAa;AAG3E,aAAO,EAAE,KAAK,SAAS,MAAiC,SAAS,MAAa;AAAA,IAChF,GAjBgB;AAmBhB,QAAI,MAAM,WAAW,MAAO,QAAO,IAAI,MAAM,MAAM,OAAO;AAAA,aACjD,MAAM,WAAW,OAAQ,QAAO,KAAK,MAAM,MAAM,OAAO;AAAA,aACxD,MAAM,WAAW,MAAO,QAAO,IAAI,MAAM,MAAM,OAAO;AAAA,QAC1D,QAAO,OAAO,MAAM,MAAM,OAAO;AAAA,EACxC;AAEA,SAAO,EAAE,GAAG,KAAK,OAAO;AAC1B;AApCgB;","names":[]}
@@ -0,0 +1,50 @@
1
+ export { D as DeliveryStatus, a as NOTIFICATION_CHANNELS, b as NotificationCategory, c as NotificationChannel, d as NotificationContent, e as NotificationEvent, N as NotificationGenerator, f as NotificationLogger, g as NotificationRecipient, h as NotificationTaxonomy, i as NotificationTransport, T as TransportRecipient, t as taxonomyOf } from './types-yq_o4N01.js';
2
+ export { N as NotificationGeneratorRegistry, c as createGeneratorRegistry } from './generators-FATT537X.js';
3
+ export { C as ChannelMatrix, a as ChannelRow, D as DEFAULT_CHANNEL_ROW, I as InboxNotification, L as ListNotificationsResult, N as NotificationMessages, b as NotificationRow, c as NotificationWireMessages, d as defaultChannelMatrix, e as enabledChannelsOf, i as inboxWire, m as mergeChoices, f as mergeStoredRow, g as messagesOf } from './wire-SDUtscGu.js';
4
+
5
+ /** Thrown by `notify` when no generator is registered for the event type. */
6
+ declare class UnknownNotificationTypeError extends Error {
7
+ readonly type: string;
8
+ constructor(type: string);
9
+ }
10
+ /** Thrown by `notify` when the recipient has no contact record in the host. */
11
+ declare class UnknownNotificationRecipientError extends Error {
12
+ readonly userId: string;
13
+ constructor(userId: string);
14
+ }
15
+
16
+ /**
17
+ * Shared phone-destination rules for the SMS + WhatsApp transports.
18
+ *
19
+ * Providers need E.164 (`+5531999998888`); a host stores the phone as the user
20
+ * entered it. Best-effort normalization: an explicit `+` prefix is trusted; a
21
+ * bare 10/11-digit number is assumed to belong to `defaultCountryCode` and
22
+ * prefixed; anything else is unusable and makes the channel unavailable for
23
+ * that recipient.
24
+ *
25
+ * `defaultCountryCode` is REQUIRED, and that is the whole point of it being a
26
+ * parameter. It used to default to `55` (Brazil, the first host's market),
27
+ * which a published package must not do: a US adopter that never set it turned
28
+ * `4155552671` into `+554155552671` — a plausible Brazilian mobile — and sent a
29
+ * stranger the customer's order details. There is no country this package could
30
+ * assume that is not wrong for every other adopter, so it assumes none and the
31
+ * omission is a compile error rather than a wrong number. the origin passes
32
+ * `'55'` explicitly.
33
+ *
34
+ * NOTE: "verified phone" is approximated by "has a normalizable phone on
35
+ * file" — a host with a real verification flow should tighten its contact
36
+ * directory to only return verified numbers, which is the single seam both
37
+ * transports funnel through.
38
+ */
39
+ /** Options for {@link normalizePhoneE164}. */
40
+ interface PhoneNormalizeOptions {
41
+ /**
42
+ * Country calling code for a bare local number, digits only (`'55'`, `'1'`).
43
+ * Required: see the module docstring for why there is no default.
44
+ */
45
+ defaultCountryCode: string;
46
+ }
47
+ /** Normalize a stored phone to E.164, or null when it can't be inferred. */
48
+ declare function normalizePhoneE164(raw: string | null | undefined, options: PhoneNormalizeOptions): string | null;
49
+
50
+ export { type PhoneNormalizeOptions, UnknownNotificationRecipientError, UnknownNotificationTypeError, normalizePhoneE164 };
package/dist/index.js ADDED
@@ -0,0 +1,34 @@
1
+ import {
2
+ DEFAULT_CHANNEL_ROW,
3
+ UnknownNotificationRecipientError,
4
+ UnknownNotificationTypeError,
5
+ createGeneratorRegistry,
6
+ defaultChannelMatrix,
7
+ enabledChannelsOf,
8
+ inboxWire,
9
+ mergeChoices,
10
+ mergeStoredRow,
11
+ normalizePhoneE164
12
+ } from "./chunk-AHNRSA6U.js";
13
+ import {
14
+ NOTIFICATION_CHANNELS,
15
+ messagesOf,
16
+ taxonomyOf
17
+ } from "./chunk-4TTYQVPK.js";
18
+ import "./chunk-7QVYU63E.js";
19
+ export {
20
+ DEFAULT_CHANNEL_ROW,
21
+ NOTIFICATION_CHANNELS,
22
+ UnknownNotificationRecipientError,
23
+ UnknownNotificationTypeError,
24
+ createGeneratorRegistry,
25
+ defaultChannelMatrix,
26
+ enabledChannelsOf,
27
+ inboxWire,
28
+ mergeChoices,
29
+ mergeStoredRow,
30
+ messagesOf,
31
+ normalizePhoneE164,
32
+ taxonomyOf
33
+ };
34
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,386 @@
1
+ import { JSX, ComponentType } from 'react';
2
+ import { L as ListNotificationsResult, a as ChannelRow, I as InboxNotification, N as NotificationMessages } from '../wire-SDUtscGu.js';
3
+ import { c as NotificationChannel } from '../types-yq_o4N01.js';
4
+
5
+ /**
6
+ * How the notification screens reach their data (12-15) — the report-builder
7
+ * transport doctrine: this is the ONLY way the surface performs I/O, so a
8
+ * caller supplying one has substituted the entire backend without stubbing a
9
+ * global. The default is same-origin `fetch` riding the browser's cookies.
10
+ */
11
+ /** A write outcome the screens branch on — never a thrown mutation. */
12
+ type NotificationsResult<T> = {
13
+ ok: true;
14
+ data: T;
15
+ } | {
16
+ ok: false;
17
+ error: string;
18
+ };
19
+ /** A failed read, carrying the status the screens branch on (401 = signed out). */
20
+ declare class NotificationsHttpError extends Error {
21
+ readonly status: number;
22
+ constructor(status: number, message: string);
23
+ }
24
+ interface NotificationsTransport {
25
+ /** A read. Returns the payload INSIDE the `{ data }` envelope. */
26
+ get<T>(path: string): Promise<T>;
27
+ /** A write. Returns a {@link NotificationsResult} rather than rejecting. */
28
+ send<T>(path: string, method: string, body?: unknown): Promise<NotificationsResult<T>>;
29
+ }
30
+ declare function httpNotificationsTransport(fallbackError?: string): NotificationsTransport;
31
+
32
+ /**
33
+ * The wire client, bound to one mount (12-15).
34
+ *
35
+ * Every path this package's screens can call, in one place — which is what
36
+ * makes the api half's route table and the web half's URLs one contract instead
37
+ * of two lists that drift.
38
+ */
39
+ /** `GET <mount>/notification-preferences` and the PUT's answer. */
40
+ interface PreferencesPayload {
41
+ preferences: Record<string, ChannelRow>;
42
+ availability: Record<NotificationChannel, boolean>;
43
+ /** The host's taxonomy, so the screen renders it without being told twice. */
44
+ categories: string[];
45
+ }
46
+ /** `GET <mount>/push-subscriptions`. */
47
+ interface PushRegistrationPayload {
48
+ /** null = web push is not configured on this deployment. */
49
+ vapidPublicKey: string | null;
50
+ count: number;
51
+ /**
52
+ * Whether the endpoint asked about is still registered to the caller. Present
53
+ * only when one was passed — see {@link NotificationsApiClient.getPushRegistration}.
54
+ */
55
+ registered?: boolean;
56
+ }
57
+ interface NotificationsApiClient {
58
+ listNotifications(input: {
59
+ cursor?: string | null;
60
+ limit?: number;
61
+ filter?: 'all' | 'unread';
62
+ }): Promise<ListNotificationsResult>;
63
+ unreadCount(): Promise<number>;
64
+ markRead(ids: readonly string[]): Promise<NotificationsResult<{
65
+ updated: number;
66
+ }>>;
67
+ markAllRead(): Promise<NotificationsResult<{
68
+ updated: number;
69
+ }>>;
70
+ remove(ids: readonly string[]): Promise<NotificationsResult<{
71
+ deleted: number;
72
+ }>>;
73
+ getPreferences(): Promise<PreferencesPayload>;
74
+ savePreference(category: string, channel: NotificationChannel, enabled: boolean): Promise<NotificationsResult<PreferencesPayload>>;
75
+ /**
76
+ * The deployment's VAPID key and the caller's device count — and, when an
77
+ * `endpoint` is passed, whether the SERVER still has that exact subscription
78
+ * under the caller's id. The browser holding a subscription object is not
79
+ * evidence of that: a re-own or a 404/410 prune drops the row and leaves the
80
+ * browser's object in place.
81
+ */
82
+ getPushRegistration(input?: {
83
+ endpoint?: string;
84
+ }): Promise<PushRegistrationPayload>;
85
+ savePushSubscription(input: {
86
+ endpoint: string;
87
+ keys: {
88
+ p256dh: string;
89
+ auth: string;
90
+ };
91
+ }): Promise<NotificationsResult<{
92
+ count: number;
93
+ }>>;
94
+ removePushSubscription(endpoint: string): Promise<NotificationsResult<{
95
+ count: number;
96
+ }>>;
97
+ }
98
+ declare function createNotificationsApiClient(apiBase: string, transport: NotificationsTransport): NotificationsApiClient;
99
+
100
+ /**
101
+ * The inbox's client state, as ONE store shared by the bell and the panel.
102
+ *
103
+ * They have to share it: marking a row read in the panel must move the badge in
104
+ * the same tick, and an arrival must add a row to the list AND to the count.
105
+ * the origin got that for free from a react-query cache the host had already
106
+ * mounted; a published package cannot assume one — a query client is a host
107
+ * decision, and requiring a particular one (or a particular version of one) is
108
+ * the kind of dependency that keeps a package out of a host that made the other
109
+ * choice. So the sharing is explicit and dependency-free: one subscribable
110
+ * store, read through `useSyncExternalStore`.
111
+ *
112
+ * Optimistic on every write, with invalidate-on-error: the badge and the list
113
+ * update instantly, and a failed write refetches the server truth rather than
114
+ * leaving the screen asserting something the database does not say.
115
+ */
116
+ declare const PAGE_SIZE = 20;
117
+ /** The badge's poll while nothing is pushing to us. */
118
+ declare const BADGE_POLL_MS = 60000;
119
+ /**
120
+ * The badge's interval while a realtime connection is live.
121
+ *
122
+ * Five minutes, not "never": this is the reconcile that catches an event the bus
123
+ * dropped, and it costs one COUNT per open tab per five minutes. Deliberately
124
+ * far slower than an operational screen's — a bell badge is ambient, and the
125
+ * arrival that matters is pushed within milliseconds anyway. The poll does NOT
126
+ * stop, which is the standing contract: a dropped event must cost latency and
127
+ * never correctness.
128
+ */
129
+ declare const BADGE_RECONCILE_MS = 300000;
130
+ type InboxListStatus = 'idle' | 'pending' | 'ready' | 'error';
131
+ interface InboxState {
132
+ unread: number;
133
+ items: InboxNotification[];
134
+ status: InboxListStatus;
135
+ /** A cursor means there is another page. */
136
+ nextCursor: string | null;
137
+ loadingMore: boolean;
138
+ }
139
+ interface InboxStore {
140
+ getState(): InboxState;
141
+ subscribe(listener: () => void): () => void;
142
+ /** Load the first page (idempotent while one is in flight). */
143
+ open(): void;
144
+ /** Refetch the badge count. */
145
+ refreshBadge(): void;
146
+ /** Refetch both — what a realtime hint or a failed write triggers. */
147
+ invalidate(): void;
148
+ loadMore(): void;
149
+ markRead(ids: readonly string[]): void;
150
+ markAllRead(): void;
151
+ remove(id: string): void;
152
+ }
153
+ declare function createInboxStore(api: NotificationsApiClient): InboxStore;
154
+
155
+ /**
156
+ * The two hooks the bell and the panel use, and the realtime seam between them.
157
+ *
158
+ * A host that has a message bus passes `subscribe`; one that has not passes
159
+ * nothing and keeps the 60 s poll. The bell ships in this package and mounts in
160
+ * whatever embeds it, so it must not require the host to have adopted anything.
161
+ */
162
+ /**
163
+ * How the surface learns an inbox changed without asking.
164
+ *
165
+ * Called once per mounted bell with a callback that means only "ask again" — no
166
+ * payload, so the number on screen is always one the server just gave us.
167
+ * Returns its own teardown. A host wires this to whatever it already has.
168
+ */
169
+ type NotificationsSubscribe = (onHint: () => void) => () => void;
170
+ /**
171
+ * The same wiring, as a HOOK — for a host whose realtime connection lives in
172
+ * React context rather than in a module.
173
+ *
174
+ * `subscribe` above is supplied at FACTORY time, which is module scope, and a
175
+ * context-bound connection cannot be reached from there: the provider holding
176
+ * it is inside the tree. A host in that shape (a `<UserRealtimeProvider>` and a
177
+ * `useUserTopics` hook, which is the common one) had no way to pass anything at
178
+ * all, and the badge simply never heard an event.
179
+ *
180
+ * So this is the second door, and it is the one `@12-apps/app-shell` already
181
+ * uses for the same problem — its consent dialog takes a `useSignal` hook for
182
+ * exactly this reason. Two packages solving one problem two ways is how an
183
+ * adopter ends up believing the feature is unavailable to it.
184
+ *
185
+ * Called during render, so it may use context and hooks freely. Pass one or
186
+ * the other; passing both runs both, which is a host's business.
187
+ */
188
+ type NotificationsSignalHook = (onHint: () => void) => void;
189
+ declare function useInboxState(store: InboxStore): InboxState;
190
+ /**
191
+ * The bell badge number: pushed while a subscription is live, polled otherwise.
192
+ *
193
+ * `enabled` gates the poll AND the subscription. A signed-out header still
194
+ * mounts the bell, and there is nothing for it to hear.
195
+ */
196
+ declare function useUnreadCount(store: InboxStore, options?: {
197
+ enabled?: boolean;
198
+ subscribe?: NotificationsSubscribe;
199
+ useSignal?: NotificationsSignalHook;
200
+ }): number;
201
+ /** The panel's list — only fetches while the panel is open. */
202
+ declare function useInboxList(store: InboxStore, open: boolean): InboxState;
203
+
204
+ /**
205
+ * Bare bell trigger with the live unread badge — for hosts that do not already
206
+ * have a styled icon-button slot. A host with its own trigger chrome uses
207
+ * `useUnreadCount` + `Panel` directly.
208
+ */
209
+
210
+ interface BellButtonProps {
211
+ onClick: () => void;
212
+ /** Signed-out hosts still mount the bell; `false` silences it. */
213
+ enabled?: boolean;
214
+ }
215
+
216
+ /**
217
+ * The notification-centre slide-over: newest-first list with unread styling,
218
+ * per-item open (marks read + deep-links), soft delete, mark-all, empty /
219
+ * loading / error states and a "load more" cursor pager.
220
+ *
221
+ * Rendering is app-agnostic — the host passes `onNavigate` (its router's
222
+ * navigate) for deep links. Without one a link is simply not followed, which is
223
+ * what lets the panel mount in a host that has no router at all.
224
+ */
225
+
226
+ interface NotificationsPanelProps {
227
+ open: boolean;
228
+ onClose: () => void;
229
+ /** Navigate to a notification's in-app link (the host's router). */
230
+ onNavigate?: (link: string) => void;
231
+ }
232
+
233
+ /**
234
+ * The per-BROWSER Web Push enable step, which sits above the preference matrix
235
+ * because a preference alone cannot reach a device that never subscribed.
236
+ */
237
+
238
+ /** The panel's copy for a host whose platform blocks browser-level push. */
239
+ interface WebPushPlatformHint {
240
+ title: string;
241
+ body: string;
242
+ }
243
+ interface WebPushSetupConfig {
244
+ /**
245
+ * The host's service-worker path. Path-routed SPAs each control their own
246
+ * scope, and the file itself is the host's.
247
+ */
248
+ swPath?: string;
249
+ /**
250
+ * Whether THIS platform must be installed to the home screen before a
251
+ * subscription can exist at all.
252
+ *
253
+ * iOS is the case: Safari has no browser-level Web Push, so "Ativar" there
254
+ * asks no permission, creates no subscription, and fails with nothing a user
255
+ * could act on. The check is a config seam rather than a dependency because
256
+ * "is this an installable iOS browser" is a question a host's PWA layer
257
+ * already answers (the origin passes
258
+ * `() => isIosInstallable() && !isStandalone()` from `@12-apps/pwa`).
259
+ */
260
+ needsInstallFirst?: () => boolean;
261
+ /** What to say instead of the button when the check above is true. */
262
+ installHint?: WebPushPlatformHint;
263
+ }
264
+
265
+ /**
266
+ * The notification-preferences screen: the category × channel matrix over
267
+ * `GET/PUT <mount>/notification-preferences`.
268
+ *
269
+ * Toggles auto-save (optimistic, per change); channels that cannot reach the
270
+ * user right now (no phone on file / channel not declared) render disabled with
271
+ * a hint. Web Push additionally carries the per-BROWSER enable step, since a
272
+ * preference alone cannot reach a device that never subscribed.
273
+ */
274
+
275
+ interface PreferencesScreenProps {
276
+ /** Rendered under the lead paragraph — a "back to account" link, typically. */
277
+ footer?: JSX.Element;
278
+ }
279
+
280
+ /**
281
+ * The one thing this package exposes to a FRONTEND host (12-15).
282
+ *
283
+ * Everything the notification centre IS — the bell with its live badge, the
284
+ * slide-over inbox with its optimistic mark-read / delete / mark-all and its
285
+ * cursor pager, the preferences matrix with its availability hints and the
286
+ * per-browser push enable step, and every wire call between them — lives inside
287
+ * this package. The host names where the API is mounted, and that is the whole
288
+ * wiring.
289
+ *
290
+ * `page` is the standalone surface (the preferences screen), which is the one
291
+ * thing a host routes to. The bell and the panel are a PAIR a host drops into
292
+ * its own chrome, and they share one store, so a read in the panel moves the
293
+ * badge in the same tick.
294
+ */
295
+ interface NotificationsWebConfig {
296
+ /** The account mount the routes live under, e.g. `/api/account`. */
297
+ apiBase: string;
298
+ /** How the surface reaches its data. Default: same-origin fetch. */
299
+ transport?: NotificationsTransport;
300
+ /** User-facing copy overrides (pt-BR product copy by default). */
301
+ messages: NotificationMessages;
302
+ /**
303
+ * How the surface learns an inbox changed without asking — the host's message
304
+ * bus. Without it the badge keeps its 60 s poll, which is the standing
305
+ * contract rather than a fallback: a dropped event must cost latency, never
306
+ * correctness.
307
+ */
308
+ subscribe?: NotificationsSubscribe;
309
+ /**
310
+ * The same wiring as a HOOK, for a host whose realtime connection lives in
311
+ * React context — see `NotificationsSignalHook`. `subscribe` is read at
312
+ * factory time, which such a host cannot reach.
313
+ */
314
+ useSignal?: NotificationsSignalHook;
315
+ /** The browser push enable step's host seams (SW path, platform hint). */
316
+ webPush?: WebPushSetupConfig;
317
+ }
318
+ interface WebNotifications {
319
+ /** The routed surface: the preferences screen. */
320
+ page: ComponentType<PreferencesScreenProps>;
321
+ /** The bell, already bound to the shared store. */
322
+ BellButton: ComponentType<BellButtonProps>;
323
+ /** The inbox slide-over, sharing that store. */
324
+ Panel: ComponentType<NotificationsPanelProps>;
325
+ /**
326
+ * Bell + panel as ONE element, for a host that just wants the feature in its
327
+ * header and does not want to own the open/closed state.
328
+ */
329
+ BellWithPanel: ComponentType<{
330
+ enabled?: boolean;
331
+ onNavigate?: (link: string) => void;
332
+ }>;
333
+ /** The badge number, for a host with its own trigger chrome. */
334
+ useUnreadCount: (options?: {
335
+ enabled?: boolean;
336
+ }) => number;
337
+ /** The shared client state, for host glue. */
338
+ store: InboxStore;
339
+ /** The bound wire client. */
340
+ api: NotificationsApiClient;
341
+ /** The copy in force, so a host's own chrome can reuse a sentence. */
342
+ messages: NotificationMessages;
343
+ }
344
+ declare function createWebNotifications(config: NotificationsWebConfig): WebNotifications;
345
+
346
+ /** Inline SVG bell (no icon-library dependency in this package). */
347
+
348
+ declare function BellIcon({ size, dim, }: {
349
+ size?: number;
350
+ dim?: boolean;
351
+ }): JSX.Element;
352
+
353
+ /**
354
+ * "há 5 min"-style relative timestamp, falling back to an absolute date for
355
+ * anything older than a week. Every word comes from the messages table, so a
356
+ * host in another locale changes the copy and the locale together.
357
+ */
358
+ declare function relativeTime(iso: string, messages: NotificationMessages): string;
359
+
360
+ /**
361
+ * The browser half of Web Push: register the service worker, ask permission,
362
+ * subscribe with the deployment's VAPID public key (read from the packaged
363
+ * `GET <mount>/push-subscriptions`) and persist the subscription so the
364
+ * WEB_PUSH transport can reach this browser.
365
+ *
366
+ * A preference alone cannot reach a device that never subscribed, which is why
367
+ * this ships with the preferences screen rather than being left to the host.
368
+ * The one thing that IS the host's is the service-worker path — path-routed SPAs
369
+ * each control their own scope, and the file itself lives in the host's public
370
+ * directory.
371
+ */
372
+ /** Why enabling push failed, mapped to a user-facing hint by the caller. */
373
+ type PushSetupResult = {
374
+ ok: true;
375
+ } | {
376
+ ok: false;
377
+ reason: 'unsupported' | 'unconfigured' | 'permission-denied' | 'error';
378
+ };
379
+ declare function pushSupported(): boolean;
380
+ /** Whether this browser currently holds an active push subscription. */
381
+ declare function getExistingPushSubscription(): Promise<PushSubscription | null>;
382
+ declare function enableWebPush(api: NotificationsApiClient, swPath?: string): Promise<PushSetupResult>;
383
+ /** Disable flow: unsubscribe the browser and drop the server-side row. */
384
+ declare function disableWebPush(api: NotificationsApiClient): Promise<void>;
385
+
386
+ export { BADGE_POLL_MS, BADGE_RECONCILE_MS, type BellButtonProps, BellIcon, type InboxListStatus, type InboxState, type InboxStore, type NotificationsApiClient, NotificationsHttpError, type NotificationsPanelProps, type NotificationsResult, type NotificationsSignalHook, type NotificationsSubscribe, type NotificationsTransport, type NotificationsWebConfig, PAGE_SIZE, type PreferencesPayload, type PreferencesScreenProps, type PushRegistrationPayload, type PushSetupResult, type WebNotifications, type WebPushPlatformHint, type WebPushSetupConfig, createInboxStore, createNotificationsApiClient, createWebNotifications, disableWebPush, enableWebPush, getExistingPushSubscription, httpNotificationsTransport, pushSupported, relativeTime, useInboxList, useInboxState, useUnreadCount };