@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,1030 @@
1
+ import {
2
+ NOTIFICATION_CHANNELS,
3
+ messagesOf
4
+ } from "../chunk-4TTYQVPK.js";
5
+ import {
6
+ __name
7
+ } from "../chunk-7QVYU63E.js";
8
+
9
+ // src/react/create-web-notifications.tsx
10
+ import { useState as useState3 } from "react";
11
+
12
+ // src/react/api.ts
13
+ function createNotificationsApiClient(apiBase, transport) {
14
+ const base = apiBase.replace(/\/$/, "");
15
+ const url = /* @__PURE__ */ __name((path) => `${base}${path}`, "url");
16
+ return {
17
+ listNotifications({ cursor, limit, filter }) {
18
+ const params = new URLSearchParams();
19
+ if (limit !== void 0) params.set("limit", String(limit));
20
+ if (cursor) params.set("cursor", cursor);
21
+ if (filter) params.set("filter", filter);
22
+ const query = params.toString();
23
+ return transport.get(
24
+ url(`/notifications${query ? `?${query}` : ""}`)
25
+ );
26
+ },
27
+ async unreadCount() {
28
+ const { count } = await transport.get(
29
+ url("/notifications/unread-count")
30
+ );
31
+ return count;
32
+ },
33
+ markRead: /* @__PURE__ */ __name((ids) => transport.send(url("/notifications/mark-read"), "POST", { ids: [...ids] }), "markRead"),
34
+ markAllRead: /* @__PURE__ */ __name(() => transport.send(url("/notifications/mark-read"), "POST", { all: true }), "markAllRead"),
35
+ remove: /* @__PURE__ */ __name((ids) => transport.send(url("/notifications/delete"), "POST", { ids: [...ids] }), "remove"),
36
+ getPreferences: /* @__PURE__ */ __name(() => transport.get(url("/notification-preferences")), "getPreferences"),
37
+ savePreference: /* @__PURE__ */ __name((category, channel, enabled) => transport.send(url("/notification-preferences"), "PUT", {
38
+ [category]: { [channel]: enabled }
39
+ }), "savePreference"),
40
+ getPushRegistration: /* @__PURE__ */ __name(({ endpoint } = {}) => transport.get(
41
+ url(
42
+ endpoint ? `/push-subscriptions?endpoint=${encodeURIComponent(endpoint)}` : "/push-subscriptions"
43
+ )
44
+ ), "getPushRegistration"),
45
+ savePushSubscription: /* @__PURE__ */ __name((input) => transport.send(url("/push-subscriptions"), "POST", input), "savePushSubscription"),
46
+ removePushSubscription: /* @__PURE__ */ __name((endpoint) => transport.send(url("/push-subscriptions"), "DELETE", { endpoint }), "removePushSubscription")
47
+ };
48
+ }
49
+ __name(createNotificationsApiClient, "createNotificationsApiClient");
50
+
51
+ // src/react/bell-button.tsx
52
+ import { Badge } from "@12-apps/ui/data-display/Badge";
53
+ import { Box as Box2 } from "@12-apps/ui/mui/Box";
54
+
55
+ // src/react/bell-icon.tsx
56
+ import { Box } from "@12-apps/ui/mui/Box";
57
+ import { jsx, jsxs } from "react/jsx-runtime";
58
+ function BellIcon({
59
+ size = 28,
60
+ dim = false
61
+ }) {
62
+ return /* @__PURE__ */ jsxs(
63
+ Box,
64
+ {
65
+ component: "svg",
66
+ viewBox: "0 0 24 24",
67
+ "aria-hidden": true,
68
+ sx: {
69
+ width: size,
70
+ height: size,
71
+ fill: "none",
72
+ stroke: "currentColor",
73
+ opacity: dim ? 0.4 : 1
74
+ },
75
+ strokeWidth: 1.8,
76
+ strokeLinecap: "round",
77
+ strokeLinejoin: "round",
78
+ children: [
79
+ /* @__PURE__ */ jsx("path", { d: "M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6" }),
80
+ /* @__PURE__ */ jsx("path", { d: "M10 20a2 2 0 0 0 4 0" })
81
+ ]
82
+ }
83
+ );
84
+ }
85
+ __name(BellIcon, "BellIcon");
86
+
87
+ // src/react/hooks.ts
88
+ import { useEffect, useSyncExternalStore } from "react";
89
+
90
+ // src/react/inbox-state.ts
91
+ var PAGE_SIZE = 20;
92
+ var BADGE_POLL_MS = 6e4;
93
+ var BADGE_RECONCILE_MS = 3e5;
94
+ var EMPTY = {
95
+ unread: 0,
96
+ items: [],
97
+ status: "idle",
98
+ nextCursor: null,
99
+ loadingMore: false
100
+ };
101
+ function patch(cell, next) {
102
+ cell.state = { ...cell.state, ...next };
103
+ for (const listener of cell.listeners) listener();
104
+ }
105
+ __name(patch, "patch");
106
+ function refreshBadge(cell, api) {
107
+ void api.unreadCount().then((unread) => patch(cell, { unread })).catch(() => void 0);
108
+ }
109
+ __name(refreshBadge, "refreshBadge");
110
+ function reloadList(cell, api) {
111
+ const token = cell.request += 1;
112
+ patch(cell, { status: cell.state.items.length > 0 ? cell.state.status : "pending" });
113
+ void api.listNotifications({ limit: PAGE_SIZE }).then((page) => {
114
+ if (token !== cell.request) return;
115
+ patch(cell, { items: page.items, nextCursor: page.nextCursor, status: "ready" });
116
+ }).catch(() => {
117
+ if (token !== cell.request) return;
118
+ patch(cell, { status: "error" });
119
+ });
120
+ }
121
+ __name(reloadList, "reloadList");
122
+ function invalidate(cell, api) {
123
+ refreshBadge(cell, api);
124
+ if (cell.state.status !== "idle") reloadList(cell, api);
125
+ }
126
+ __name(invalidate, "invalidate");
127
+ function write(cell, api, apply, send) {
128
+ apply();
129
+ void send().then((result) => {
130
+ if (!result.ok) invalidate(cell, api);
131
+ }).catch(() => invalidate(cell, api));
132
+ }
133
+ __name(write, "write");
134
+ function bumpUnread(cell, delta) {
135
+ patch(cell, { unread: Math.max(0, cell.state.unread + delta) });
136
+ }
137
+ __name(bumpUnread, "bumpUnread");
138
+ function loadMore(cell, api) {
139
+ const cursor = cell.state.nextCursor;
140
+ if (!cursor || cell.state.loadingMore) return;
141
+ patch(cell, { loadingMore: true });
142
+ void api.listNotifications({ cursor, limit: PAGE_SIZE }).then((page) => {
143
+ patch(cell, {
144
+ items: [...cell.state.items, ...page.items],
145
+ nextCursor: page.nextCursor,
146
+ loadingMore: false
147
+ });
148
+ }).catch(() => patch(cell, { loadingMore: false }));
149
+ }
150
+ __name(loadMore, "loadMore");
151
+ function markRead(cell, api, ids) {
152
+ const readAt = (/* @__PURE__ */ new Date()).toISOString();
153
+ let flipped = 0;
154
+ const items = cell.state.items.map((item) => {
155
+ if (!ids.includes(item.id) || item.readAt !== null) return item;
156
+ flipped += 1;
157
+ return { ...item, readAt };
158
+ });
159
+ if (flipped === 0) return;
160
+ write(
161
+ cell,
162
+ api,
163
+ () => {
164
+ patch(cell, { items });
165
+ bumpUnread(cell, -flipped);
166
+ },
167
+ () => api.markRead(ids)
168
+ );
169
+ }
170
+ __name(markRead, "markRead");
171
+ function remove(cell, api, id) {
172
+ const target = cell.state.items.find((item) => item.id === id);
173
+ if (!target) return;
174
+ const items = cell.state.items.filter((item) => item.id !== id);
175
+ write(
176
+ cell,
177
+ api,
178
+ () => {
179
+ patch(cell, { items });
180
+ if (target.readAt === null) bumpUnread(cell, -1);
181
+ },
182
+ () => api.remove([id])
183
+ );
184
+ }
185
+ __name(remove, "remove");
186
+ function createInboxStore(api) {
187
+ const cell = { state: EMPTY, listeners: /* @__PURE__ */ new Set(), request: 0 };
188
+ return {
189
+ getState: /* @__PURE__ */ __name(() => cell.state, "getState"),
190
+ subscribe(listener) {
191
+ cell.listeners.add(listener);
192
+ return () => cell.listeners.delete(listener);
193
+ },
194
+ open() {
195
+ if (cell.state.status === "idle") reloadList(cell, api);
196
+ },
197
+ refreshBadge: /* @__PURE__ */ __name(() => refreshBadge(cell, api), "refreshBadge"),
198
+ invalidate: /* @__PURE__ */ __name(() => invalidate(cell, api), "invalidate"),
199
+ loadMore: /* @__PURE__ */ __name(() => loadMore(cell, api), "loadMore"),
200
+ markRead: /* @__PURE__ */ __name((ids) => markRead(cell, api, ids), "markRead"),
201
+ markAllRead() {
202
+ const readAt = (/* @__PURE__ */ new Date()).toISOString();
203
+ const items = cell.state.items.map((item) => ({ ...item, readAt: item.readAt ?? readAt }));
204
+ write(
205
+ cell,
206
+ api,
207
+ () => patch(cell, { items, unread: 0 }),
208
+ () => api.markAllRead()
209
+ );
210
+ },
211
+ remove: /* @__PURE__ */ __name((id) => remove(cell, api, id), "remove")
212
+ };
213
+ }
214
+ __name(createInboxStore, "createInboxStore");
215
+
216
+ // src/react/hooks.ts
217
+ function useInboxState(store) {
218
+ return useSyncExternalStore(store.subscribe, store.getState, store.getState);
219
+ }
220
+ __name(useInboxState, "useInboxState");
221
+ function useUnreadCount(store, options = {}) {
222
+ const enabled = options.enabled ?? true;
223
+ const subscribe = options.subscribe;
224
+ const { unread } = useInboxState(store);
225
+ options.useSignal?.(() => {
226
+ if (enabled) store.invalidate();
227
+ });
228
+ useEffect(() => {
229
+ if (!enabled) return;
230
+ store.refreshBadge();
231
+ const unsubscribe = subscribe?.(() => store.invalidate());
232
+ const interval = setInterval(
233
+ () => store.refreshBadge(),
234
+ subscribe ? BADGE_RECONCILE_MS : BADGE_POLL_MS
235
+ );
236
+ const onFocus = /* @__PURE__ */ __name(() => store.refreshBadge(), "onFocus");
237
+ globalThis.addEventListener?.("focus", onFocus);
238
+ return () => {
239
+ clearInterval(interval);
240
+ globalThis.removeEventListener?.("focus", onFocus);
241
+ unsubscribe?.();
242
+ };
243
+ }, [store, enabled, subscribe]);
244
+ return enabled ? unread : 0;
245
+ }
246
+ __name(useUnreadCount, "useUnreadCount");
247
+ function useInboxList(store, open) {
248
+ const state = useInboxState(store);
249
+ useEffect(() => {
250
+ if (open) store.open();
251
+ }, [store, open]);
252
+ return state;
253
+ }
254
+ __name(useInboxList, "useInboxList");
255
+
256
+ // src/react/bell-button.tsx
257
+ import { jsx as jsx2 } from "react/jsx-runtime";
258
+ var triggerSx = {
259
+ display: "inline-flex",
260
+ alignItems: "center",
261
+ justifyContent: "center",
262
+ p: 0.5,
263
+ border: "none",
264
+ background: "none",
265
+ cursor: "pointer",
266
+ color: "text.primary",
267
+ lineHeight: 0,
268
+ "& *": { cursor: "pointer" },
269
+ "&:hover": { color: "primary.main" },
270
+ "&:focus-visible": {
271
+ outline: "2px solid",
272
+ outlineColor: "primary.main",
273
+ outlineOffset: "2px",
274
+ borderRadius: "50%"
275
+ }
276
+ };
277
+ function BellButton({
278
+ onClick,
279
+ enabled = true,
280
+ store,
281
+ messages,
282
+ subscribe,
283
+ useSignal
284
+ }) {
285
+ const count = useUnreadCount(store, {
286
+ enabled,
287
+ ...subscribe ? { subscribe } : {},
288
+ ...useSignal ? { useSignal } : {}
289
+ });
290
+ return /* @__PURE__ */ jsx2(
291
+ Box2,
292
+ {
293
+ component: "button",
294
+ type: "button",
295
+ onClick,
296
+ "aria-label": count > 0 ? messages.openBellWithUnread(count) : messages.openBell,
297
+ "data-testid": "notifications-bell",
298
+ sx: triggerSx,
299
+ children: /* @__PURE__ */ jsx2(
300
+ Badge,
301
+ {
302
+ content: count > 0 ? count : void 0,
303
+ color: "primary",
304
+ variant: "count",
305
+ max: 99,
306
+ "data-testid": "notifications-badge",
307
+ children: /* @__PURE__ */ jsx2(BellIcon, { size: 28 })
308
+ }
309
+ )
310
+ }
311
+ );
312
+ }
313
+ __name(BellButton, "BellButton");
314
+
315
+ // src/react/panel.tsx
316
+ import { useCallback } from "react";
317
+ import { EmptyState } from "@12-apps/ui/data-display/EmptyState";
318
+ import { LoadingState } from "@12-apps/ui/data-display/LoadingState";
319
+ import { Button as Button2 } from "@12-apps/ui/form/Button";
320
+ import { Drawer, DrawerContent, DrawerHeader } from "@12-apps/ui/layout/Drawer";
321
+ import { Box as Box4 } from "@12-apps/ui/mui/Box";
322
+ import { useMediaQuery } from "@12-apps/ui/mui/useMediaQuery";
323
+ import { useTheme } from "@12-apps/ui/mui/styles";
324
+
325
+ // src/react/row.tsx
326
+ import { Button } from "@12-apps/ui/form/Button";
327
+ import { Box as Box3 } from "@12-apps/ui/mui/Box";
328
+ import { alpha } from "@12-apps/ui/mui/styles";
329
+ import { Text } from "@12-apps/ui/typography/Text";
330
+
331
+ // src/react/relative-time.ts
332
+ function relativeTime(iso, messages) {
333
+ const elapsedMs = Date.now() - new Date(iso).getTime();
334
+ const minutes = Math.round(elapsedMs / 6e4);
335
+ if (minutes < 1) return messages.justNow;
336
+ if (minutes < 60) return messages.minutesAgo(minutes);
337
+ const hours = Math.round(minutes / 60);
338
+ if (hours < 24) return messages.hoursAgo(hours);
339
+ const days = Math.round(hours / 24);
340
+ if (days < 7) return messages.daysAgo(days);
341
+ return new Date(iso).toLocaleDateString(messages.dateLocale);
342
+ }
343
+ __name(relativeTime, "relativeTime");
344
+
345
+ // src/react/row.tsx
346
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
347
+ var contentButtonSx = {
348
+ flex: 1,
349
+ minWidth: 0,
350
+ display: "flex",
351
+ flexDirection: "column",
352
+ gap: 0.25,
353
+ textAlign: "left",
354
+ border: "none",
355
+ background: "none",
356
+ p: 0,
357
+ cursor: "pointer",
358
+ color: "text.primary",
359
+ fontFamily: "inherit"
360
+ };
361
+ var unreadDotSx = {
362
+ width: 8,
363
+ height: 8,
364
+ borderRadius: "50%",
365
+ bgcolor: "primary.main",
366
+ flex: "0 0 auto"
367
+ };
368
+ function NotificationRow({
369
+ notification,
370
+ messages,
371
+ onOpen,
372
+ onDelete
373
+ }) {
374
+ const unread = notification.readAt === null;
375
+ return /* @__PURE__ */ jsxs2(
376
+ Box3,
377
+ {
378
+ "data-testid": `notification-${notification.id}`,
379
+ sx: {
380
+ display: "flex",
381
+ alignItems: "flex-start",
382
+ gap: 1,
383
+ py: 1.5,
384
+ px: 1,
385
+ borderBottom: "1px solid",
386
+ borderColor: "divider",
387
+ bgcolor: unread ? (t) => alpha(t.palette.primary.main, 0.06) : "transparent"
388
+ },
389
+ children: [
390
+ /* @__PURE__ */ jsxs2(
391
+ Box3,
392
+ {
393
+ component: "button",
394
+ type: "button",
395
+ onClick: () => onOpen(notification),
396
+ "aria-label": unread ? `${notification.title} (${messages.unreadSuffix})` : notification.title,
397
+ sx: contentButtonSx,
398
+ children: [
399
+ /* @__PURE__ */ jsxs2(Box3, { sx: { display: "flex", alignItems: "center", gap: 0.75 }, children: [
400
+ unread ? /* @__PURE__ */ jsx3(Box3, { "aria-hidden": true, sx: unreadDotSx }) : null,
401
+ /* @__PURE__ */ jsx3(Text, { variant: "body", size: "sm", weight: unread ? "bold" : "medium", as: "span", children: notification.title })
402
+ ] }),
403
+ /* @__PURE__ */ jsx3(Text, { variant: "caption", size: "xs", color: "secondary", as: "span", children: notification.body }),
404
+ /* @__PURE__ */ jsx3(Text, { variant: "caption", size: "xs", color: "secondary", as: "span", italic: true, children: relativeTime(notification.createdAt, messages) })
405
+ ]
406
+ }
407
+ ),
408
+ /* @__PURE__ */ jsx3(
409
+ Button,
410
+ {
411
+ variant: "ghost",
412
+ color: "neutral",
413
+ size: "xs",
414
+ "aria-label": messages.deleteOne(notification.title),
415
+ onClick: () => onDelete(notification.id),
416
+ dataTestId: `notification-delete-${notification.id}`,
417
+ children: "\u2715"
418
+ }
419
+ )
420
+ ]
421
+ }
422
+ );
423
+ }
424
+ __name(NotificationRow, "NotificationRow");
425
+
426
+ // src/react/panel.tsx
427
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
428
+ function PanelBody({
429
+ state,
430
+ messages,
431
+ onRetry,
432
+ onLoadMore,
433
+ onOpen,
434
+ onDelete
435
+ }) {
436
+ if (state.status === "pending" || state.status === "idle") {
437
+ return /* @__PURE__ */ jsx4(
438
+ LoadingState,
439
+ {
440
+ variant: "spinner",
441
+ message: messages.loading,
442
+ size: "md",
443
+ dataTestId: "notifications-loading"
444
+ }
445
+ );
446
+ }
447
+ if (state.status === "error") {
448
+ return /* @__PURE__ */ jsx4(
449
+ EmptyState,
450
+ {
451
+ variant: "minimal",
452
+ title: messages.loadFailedTitle,
453
+ description: messages.loadFailedBody,
454
+ onRefresh: onRetry,
455
+ refreshLabel: messages.retry,
456
+ dataTestId: "notifications-error"
457
+ }
458
+ );
459
+ }
460
+ if (state.items.length === 0) {
461
+ return /* @__PURE__ */ jsx4(
462
+ EmptyState,
463
+ {
464
+ variant: "illustrated",
465
+ illustration: /* @__PURE__ */ jsx4(BellIcon, { size: 44, dim: true }),
466
+ title: messages.emptyTitle,
467
+ description: messages.emptyBody,
468
+ dataTestId: "notifications-empty"
469
+ }
470
+ );
471
+ }
472
+ return /* @__PURE__ */ jsxs3(Box4, { children: [
473
+ state.items.map((notification) => /* @__PURE__ */ jsx4(
474
+ NotificationRow,
475
+ {
476
+ notification,
477
+ messages,
478
+ onOpen,
479
+ onDelete
480
+ },
481
+ notification.id
482
+ )),
483
+ state.nextCursor ? /* @__PURE__ */ jsx4(Box4, { sx: { display: "flex", justifyContent: "center", py: 1.5 }, children: /* @__PURE__ */ jsx4(
484
+ Button2,
485
+ {
486
+ variant: "outline",
487
+ color: "neutral",
488
+ size: "sm",
489
+ disabled: state.loadingMore,
490
+ onClick: onLoadMore,
491
+ dataTestId: "notifications-load-more",
492
+ children: state.loadingMore ? messages.loadingMore : messages.loadMore
493
+ }
494
+ ) }) : null
495
+ ] });
496
+ }
497
+ __name(PanelBody, "PanelBody");
498
+ function NotificationsPanel({
499
+ open,
500
+ onClose,
501
+ onNavigate,
502
+ store,
503
+ messages
504
+ }) {
505
+ const theme = useTheme();
506
+ const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
507
+ const state = useInboxList(store, open);
508
+ const openNotification = useCallback(
509
+ (notification) => {
510
+ if (notification.readAt === null) store.markRead([notification.id]);
511
+ if (notification.link && onNavigate) {
512
+ onClose();
513
+ onNavigate(notification.link);
514
+ }
515
+ },
516
+ [store, onClose, onNavigate]
517
+ );
518
+ const hasUnread = state.items.some((item) => item.readAt === null);
519
+ return /* @__PURE__ */ jsxs3(
520
+ Drawer,
521
+ {
522
+ open,
523
+ onClose,
524
+ anchor: "right",
525
+ variant: "right",
526
+ width: isMobile ? "100vw" : 400,
527
+ dataTestId: "notifications-panel",
528
+ children: [
529
+ /* @__PURE__ */ jsx4(DrawerHeader, { onClose, children: messages.panelTitle }),
530
+ /* @__PURE__ */ jsxs3(DrawerContent, { children: [
531
+ hasUnread ? /* @__PURE__ */ jsx4(Box4, { sx: { display: "flex", justifyContent: "flex-end", pb: 1 }, children: /* @__PURE__ */ jsx4(
532
+ Button2,
533
+ {
534
+ variant: "ghost",
535
+ color: "primary",
536
+ size: "xs",
537
+ onClick: () => store.markAllRead(),
538
+ dataTestId: "notifications-mark-all-read",
539
+ children: messages.markAllRead
540
+ }
541
+ ) }) : null,
542
+ /* @__PURE__ */ jsx4(
543
+ PanelBody,
544
+ {
545
+ state,
546
+ messages,
547
+ onRetry: () => store.invalidate(),
548
+ onLoadMore: () => store.loadMore(),
549
+ onOpen: openNotification,
550
+ onDelete: (id) => store.remove(id)
551
+ }
552
+ )
553
+ ] })
554
+ ]
555
+ }
556
+ );
557
+ }
558
+ __name(NotificationsPanel, "NotificationsPanel");
559
+
560
+ // src/react/preferences-screen.tsx
561
+ import { useCallback as useCallback2, useEffect as useEffect3, useState as useState2 } from "react";
562
+ import { LoadingState as LoadingState2 } from "@12-apps/ui/data-display/LoadingState";
563
+ import { Switch } from "@12-apps/ui/form/Switch";
564
+ import { Box as Box6 } from "@12-apps/ui/mui/Box";
565
+ import { Text as Text3 } from "@12-apps/ui/typography/Text";
566
+
567
+ // src/react/web-push-setup.tsx
568
+ import { useEffect as useEffect2, useState } from "react";
569
+ import { Button as Button3 } from "@12-apps/ui/form/Button";
570
+ import { Box as Box5 } from "@12-apps/ui/mui/Box";
571
+ import { Text as Text2 } from "@12-apps/ui/typography/Text";
572
+
573
+ // src/react/web-push-client.ts
574
+ function base64UrlToUint8Array(base64Url) {
575
+ const padding = "=".repeat((4 - base64Url.length % 4) % 4);
576
+ const base64 = (base64Url + padding).replaceAll("-", "+").replaceAll("_", "/");
577
+ const raw = atob(base64);
578
+ return Uint8Array.from(raw, (char) => char.charCodeAt(0));
579
+ }
580
+ __name(base64UrlToUint8Array, "base64UrlToUint8Array");
581
+ function pushSupported() {
582
+ return typeof navigator !== "undefined" && "serviceWorker" in navigator && typeof window !== "undefined" && "PushManager" in window && "Notification" in window;
583
+ }
584
+ __name(pushSupported, "pushSupported");
585
+ async function getExistingPushSubscription() {
586
+ if (!pushSupported()) return null;
587
+ const registration = await navigator.serviceWorker.getRegistration();
588
+ if (!registration) return null;
589
+ return registration.pushManager.getSubscription();
590
+ }
591
+ __name(getExistingPushSubscription, "getExistingPushSubscription");
592
+ async function obtainSubscription(swPath, vapidPublicKey) {
593
+ const registration = await navigator.serviceWorker.register(swPath);
594
+ await navigator.serviceWorker.ready;
595
+ return await registration.pushManager.getSubscription() ?? registration.pushManager.subscribe({
596
+ userVisibleOnly: true,
597
+ applicationServerKey: base64UrlToUint8Array(vapidPublicKey)
598
+ });
599
+ }
600
+ __name(obtainSubscription, "obtainSubscription");
601
+ async function persist(api, subscription) {
602
+ const json = subscription.toJSON();
603
+ if (!json.endpoint || !json.keys?.p256dh || !json.keys.auth) {
604
+ return { ok: false, reason: "error" };
605
+ }
606
+ const saved = await api.savePushSubscription({
607
+ endpoint: json.endpoint,
608
+ keys: { p256dh: json.keys.p256dh, auth: json.keys.auth }
609
+ });
610
+ return saved.ok ? { ok: true } : { ok: false, reason: "error" };
611
+ }
612
+ __name(persist, "persist");
613
+ async function enableWebPush(api, swPath = "/sw.js") {
614
+ if (!pushSupported()) return { ok: false, reason: "unsupported" };
615
+ const registration = await api.getPushRegistration().catch(() => null);
616
+ if (!registration?.vapidPublicKey) return { ok: false, reason: "unconfigured" };
617
+ const permission = await Notification.requestPermission();
618
+ if (permission !== "granted") return { ok: false, reason: "permission-denied" };
619
+ try {
620
+ return await persist(
621
+ api,
622
+ await obtainSubscription(swPath, registration.vapidPublicKey)
623
+ );
624
+ } catch {
625
+ return { ok: false, reason: "error" };
626
+ }
627
+ }
628
+ __name(enableWebPush, "enableWebPush");
629
+ async function disableWebPush(api) {
630
+ const subscription = await getExistingPushSubscription();
631
+ if (!subscription) return;
632
+ const endpoint = subscription.endpoint;
633
+ await subscription.unsubscribe().catch(() => false);
634
+ await api.removePushSubscription(endpoint).catch(() => void 0);
635
+ }
636
+ __name(disableWebPush, "disableWebPush");
637
+
638
+ // src/react/web-push-setup.tsx
639
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
640
+ var cardSx = {
641
+ p: 2,
642
+ border: "1px solid",
643
+ borderColor: "divider",
644
+ borderRadius: 2
645
+ };
646
+ function InstallFirstHint({ hint }) {
647
+ return /* @__PURE__ */ jsxs4(Box5, { sx: cardSx, "data-testid": "web-push-install-hint", children: [
648
+ /* @__PURE__ */ jsx5(Text2, { variant: "body", size: "sm", weight: "semibold", as: "p", children: hint.title }),
649
+ /* @__PURE__ */ jsx5(Text2, { variant: "caption", size: "xs", color: "secondary", as: "p", children: hint.body })
650
+ ] });
651
+ }
652
+ __name(InstallFirstHint, "InstallFirstHint");
653
+ async function resolveState(api) {
654
+ const subscription = await getExistingPushSubscription();
655
+ if (!subscription) return "idle";
656
+ const registration = await api.getPushRegistration({ endpoint: subscription.endpoint }).catch(() => null);
657
+ return registration?.registered ? "on" : "idle";
658
+ }
659
+ __name(resolveState, "resolveState");
660
+ function statusText(state, messages) {
661
+ if (state === "on") return messages.devicePushOn;
662
+ if (state === "denied") return messages.devicePushDenied;
663
+ if (state === "failed") return messages.devicePushFailed;
664
+ return messages.devicePushIdle;
665
+ }
666
+ __name(statusText, "statusText");
667
+ function WebPushDeviceSetup({
668
+ available,
669
+ api,
670
+ messages,
671
+ config
672
+ }) {
673
+ const [state, setState] = useState("checking");
674
+ const [needsInstall] = useState(() => config.needsInstallFirst?.() ?? false);
675
+ useEffect2(() => {
676
+ let cancelled = false;
677
+ void resolveState(api).then((next) => {
678
+ if (!cancelled) setState(next);
679
+ });
680
+ return () => {
681
+ cancelled = true;
682
+ };
683
+ }, [api]);
684
+ if (!available) return null;
685
+ if (needsInstall && config.installHint) return /* @__PURE__ */ jsx5(InstallFirstHint, { hint: config.installHint });
686
+ const enable = /* @__PURE__ */ __name(async () => {
687
+ setState("busy");
688
+ const result = await enableWebPush(api, config.swPath);
689
+ if (result.ok) setState("on");
690
+ else setState(result.reason === "permission-denied" ? "denied" : "failed");
691
+ }, "enable");
692
+ return /* @__PURE__ */ jsxs4(
693
+ Box5,
694
+ {
695
+ sx: {
696
+ ...cardSx,
697
+ display: "flex",
698
+ alignItems: "center",
699
+ justifyContent: "space-between",
700
+ gap: 2
701
+ },
702
+ "data-testid": "web-push-device-setup",
703
+ children: [
704
+ /* @__PURE__ */ jsxs4(Box5, { sx: { minWidth: 0 }, children: [
705
+ /* @__PURE__ */ jsx5(Text2, { variant: "body", size: "sm", weight: "semibold", as: "p", children: messages.devicePushTitle }),
706
+ /* @__PURE__ */ jsx5(Text2, { variant: "caption", size: "xs", color: "secondary", as: "p", children: statusText(state, messages) })
707
+ ] }),
708
+ state !== "on" ? /* @__PURE__ */ jsx5(
709
+ Button3,
710
+ {
711
+ variant: "outline",
712
+ color: "primary",
713
+ size: "sm",
714
+ disabled: state === "busy" || state === "checking",
715
+ onClick: () => void enable(),
716
+ dataTestId: "web-push-enable",
717
+ children: state === "busy" ? messages.devicePushEnabling : messages.devicePushEnable
718
+ }
719
+ ) : null
720
+ ]
721
+ }
722
+ );
723
+ }
724
+ __name(WebPushDeviceSetup, "WebPushDeviceSetup");
725
+
726
+ // src/react/preferences-screen.tsx
727
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
728
+ function CategoryCard({
729
+ category,
730
+ channels,
731
+ availability,
732
+ messages,
733
+ onToggle
734
+ }) {
735
+ const labels = messages.categoryLabels[category];
736
+ return /* @__PURE__ */ jsxs5(
737
+ Box6,
738
+ {
739
+ sx: { p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2 },
740
+ "data-testid": `prefs-${category}`,
741
+ children: [
742
+ /* @__PURE__ */ jsx6(Text3, { variant: "body", size: "sm", weight: "semibold", as: "p", children: labels?.title ?? messages.categoryFallbackTitle(category) }),
743
+ labels?.description ? /* @__PURE__ */ jsx6(Text3, { variant: "caption", size: "xs", color: "secondary", as: "p", children: labels.description }) : null,
744
+ /* @__PURE__ */ jsx6(
745
+ Box6,
746
+ {
747
+ sx: {
748
+ mt: 1.5,
749
+ display: "grid",
750
+ gridTemplateColumns: { xs: "1fr 1fr", sm: "repeat(4, 1fr)" },
751
+ gap: 1
752
+ },
753
+ children: NOTIFICATION_CHANNELS.map((channel) => /* @__PURE__ */ jsx6(
754
+ Switch,
755
+ {
756
+ size: "sm",
757
+ color: "primary",
758
+ label: messages.channelLabels[channel] ?? channel,
759
+ checked: channels[channel] && availability[channel],
760
+ disabled: !availability[channel],
761
+ onChange: (_, checked) => onToggle(channel, checked),
762
+ dataTestId: `prefs-${category}-${channel}`
763
+ },
764
+ channel
765
+ ))
766
+ }
767
+ )
768
+ ]
769
+ }
770
+ );
771
+ }
772
+ __name(CategoryCard, "CategoryCard");
773
+ function UnavailableHints({
774
+ availability,
775
+ messages
776
+ }) {
777
+ const unavailable = NOTIFICATION_CHANNELS.filter((channel) => !availability[channel]);
778
+ if (unavailable.length === 0) return null;
779
+ return /* @__PURE__ */ jsx6(Box6, { sx: { display: "flex", flexDirection: "column", gap: 0.5 }, children: unavailable.map((channel) => /* @__PURE__ */ jsxs5(
780
+ Text3,
781
+ {
782
+ variant: "caption",
783
+ size: "xs",
784
+ color: "secondary",
785
+ as: "p",
786
+ "data-testid": `prefs-hint-${channel}`,
787
+ children: [
788
+ messages.channelLabels[channel] ?? channel,
789
+ ":",
790
+ " ",
791
+ messages.channelUnavailableHints[channel] ?? ""
792
+ ]
793
+ },
794
+ channel
795
+ )) });
796
+ }
797
+ __name(UnavailableHints, "UnavailableHints");
798
+ function usePreferences(api) {
799
+ const [payload, setPayload] = useState2(null);
800
+ useEffect3(() => {
801
+ let cancelled = false;
802
+ void api.getPreferences().then((next) => {
803
+ if (!cancelled) setPayload(next);
804
+ }).catch(() => void 0);
805
+ return () => {
806
+ cancelled = true;
807
+ };
808
+ }, [api]);
809
+ const toggle = useCallback2(
810
+ (category, channel, enabled) => {
811
+ setPayload((current) => {
812
+ const row = current?.preferences[category];
813
+ if (!current || !row) return current;
814
+ return {
815
+ ...current,
816
+ preferences: { ...current.preferences, [category]: { ...row, [channel]: enabled } }
817
+ };
818
+ });
819
+ const reconcile = /* @__PURE__ */ __name(() => {
820
+ void api.getPreferences().then(setPayload).catch(() => void 0);
821
+ }, "reconcile");
822
+ void api.savePreference(category, channel, enabled).then((result) => {
823
+ if (result.ok) setPayload(result.data);
824
+ else reconcile();
825
+ }).catch(reconcile);
826
+ },
827
+ [api]
828
+ );
829
+ return { payload, toggle };
830
+ }
831
+ __name(usePreferences, "usePreferences");
832
+ function PreferencesScreen({
833
+ footer,
834
+ api,
835
+ messages,
836
+ webPush
837
+ }) {
838
+ const { payload, toggle } = usePreferences(api);
839
+ if (!payload) {
840
+ return /* @__PURE__ */ jsx6(
841
+ LoadingState2,
842
+ {
843
+ variant: "spinner",
844
+ message: messages.loadingMore,
845
+ size: "md",
846
+ dataTestId: "notification-prefs-loading"
847
+ }
848
+ );
849
+ }
850
+ const { preferences, availability, categories } = payload;
851
+ return /* @__PURE__ */ jsxs5(
852
+ Box6,
853
+ {
854
+ component: "section",
855
+ "data-testid": "notification-prefs-view",
856
+ sx: {
857
+ maxWidth: 640,
858
+ mx: "auto",
859
+ width: "100%",
860
+ px: 2,
861
+ py: 4,
862
+ display: "flex",
863
+ flexDirection: "column",
864
+ gap: 2
865
+ },
866
+ children: [
867
+ /* @__PURE__ */ jsxs5(Box6, { children: [
868
+ /* @__PURE__ */ jsx6(Text3, { variant: "heading", size: "lg", as: "h1", children: messages.preferencesTitle }),
869
+ /* @__PURE__ */ jsxs5(Text3, { variant: "caption", size: "sm", color: "secondary", as: "p", children: [
870
+ messages.preferencesLead,
871
+ " ",
872
+ footer
873
+ ] })
874
+ ] }),
875
+ /* @__PURE__ */ jsx6(
876
+ WebPushDeviceSetup,
877
+ {
878
+ available: availability.WEB_PUSH,
879
+ api,
880
+ messages,
881
+ config: webPush
882
+ }
883
+ ),
884
+ categories.map((category) => /* @__PURE__ */ jsx6(
885
+ CategoryCard,
886
+ {
887
+ category,
888
+ channels: preferences[category] ?? {
889
+ EMAIL: false,
890
+ SMS: false,
891
+ WHATSAPP: false,
892
+ WEB_PUSH: false
893
+ },
894
+ availability,
895
+ messages,
896
+ onToggle: (channel, enabled) => toggle(category, channel, enabled)
897
+ },
898
+ category
899
+ )),
900
+ /* @__PURE__ */ jsx6(UnavailableHints, { availability, messages })
901
+ ]
902
+ }
903
+ );
904
+ }
905
+ __name(PreferencesScreen, "PreferencesScreen");
906
+
907
+ // src/react/transport.ts
908
+ var NotificationsHttpError = class _NotificationsHttpError extends Error {
909
+ static {
910
+ __name(this, "NotificationsHttpError");
911
+ }
912
+ status;
913
+ constructor(status, message) {
914
+ super(message);
915
+ this.name = "NotificationsHttpError";
916
+ this.status = status;
917
+ Object.setPrototypeOf(this, _NotificationsHttpError.prototype);
918
+ }
919
+ };
920
+ var FALLBACK_ERROR = "N\xE3o foi poss\xEDvel concluir a opera\xE7\xE3o.";
921
+ function httpNotificationsTransport(fallbackError = FALLBACK_ERROR) {
922
+ return {
923
+ async get(path) {
924
+ const response = await fetch(path, {
925
+ credentials: "same-origin",
926
+ headers: { Accept: "application/json" }
927
+ });
928
+ const payload = await response.json().catch(() => null);
929
+ if (!response.ok) {
930
+ throw new NotificationsHttpError(
931
+ response.status,
932
+ payload?.error ?? `HTTP ${response.status} for ${path}`
933
+ );
934
+ }
935
+ return payload?.data ?? payload;
936
+ },
937
+ async send(path, method, body) {
938
+ try {
939
+ const response = await fetch(path, {
940
+ method,
941
+ credentials: "same-origin",
942
+ headers: {
943
+ Accept: "application/json",
944
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
945
+ },
946
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
947
+ });
948
+ if (response.status === 204) return { ok: true, data: void 0 };
949
+ const payload = await response.json().catch(() => null);
950
+ if (!response.ok) return { ok: false, error: payload?.error ?? fallbackError };
951
+ return { ok: true, data: payload?.data ?? payload };
952
+ } catch {
953
+ return { ok: false, error: fallbackError };
954
+ }
955
+ }
956
+ };
957
+ }
958
+ __name(httpNotificationsTransport, "httpNotificationsTransport");
959
+
960
+ // src/react/create-web-notifications.tsx
961
+ import { Fragment, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
962
+ function createWebNotifications(config) {
963
+ const messages = messagesOf(config);
964
+ const api = createNotificationsApiClient(
965
+ config.apiBase,
966
+ config.transport ?? httpNotificationsTransport(messages.operationFailed)
967
+ );
968
+ const store = createInboxStore(api);
969
+ const webPush = config.webPush ?? {};
970
+ const subscribe = config.subscribe;
971
+ const subscribeOption = {
972
+ ...subscribe ? { subscribe } : {},
973
+ ...config.useSignal ? { useSignal: config.useSignal } : {}
974
+ };
975
+ const Bell = /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx7(BellButton, { ...props, store, messages, ...subscribeOption }), "Bell");
976
+ const Panel = /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx7(NotificationsPanel, { ...props, store, messages }), "Panel");
977
+ function useBoundUnreadCount(options = {}) {
978
+ return useUnreadCount(store, { ...options, ...subscribeOption });
979
+ }
980
+ __name(useBoundUnreadCount, "useBoundUnreadCount");
981
+ function BellWithPanel({
982
+ enabled = true,
983
+ onNavigate
984
+ }) {
985
+ const [open, setOpen] = useState3(false);
986
+ return /* @__PURE__ */ jsxs6(Fragment, { children: [
987
+ /* @__PURE__ */ jsx7(Bell, { enabled, onClick: () => setOpen(true) }),
988
+ /* @__PURE__ */ jsx7(
989
+ Panel,
990
+ {
991
+ open,
992
+ onClose: () => setOpen(false),
993
+ ...onNavigate ? { onNavigate } : {}
994
+ }
995
+ )
996
+ ] });
997
+ }
998
+ __name(BellWithPanel, "BellWithPanel");
999
+ return {
1000
+ page: /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx7(PreferencesScreen, { ...props, api, messages, webPush }), "page"),
1001
+ BellButton: Bell,
1002
+ Panel,
1003
+ BellWithPanel,
1004
+ useUnreadCount: useBoundUnreadCount,
1005
+ store,
1006
+ api,
1007
+ messages
1008
+ };
1009
+ }
1010
+ __name(createWebNotifications, "createWebNotifications");
1011
+ export {
1012
+ BADGE_POLL_MS,
1013
+ BADGE_RECONCILE_MS,
1014
+ BellIcon,
1015
+ NotificationsHttpError,
1016
+ PAGE_SIZE,
1017
+ createInboxStore,
1018
+ createNotificationsApiClient,
1019
+ createWebNotifications,
1020
+ disableWebPush,
1021
+ enableWebPush,
1022
+ getExistingPushSubscription,
1023
+ httpNotificationsTransport,
1024
+ pushSupported,
1025
+ relativeTime,
1026
+ useInboxList,
1027
+ useInboxState,
1028
+ useUnreadCount
1029
+ };
1030
+ //# sourceMappingURL=index.js.map