@12-apps/notifications 4.7.0 → 4.7.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.
Files changed (41) hide show
  1. package/dist/chunk-BW723CX2.js +214 -0
  2. package/dist/chunk-BW723CX2.js.map +1 -0
  3. package/dist/chunk-CQZMTFPY.js +76 -0
  4. package/dist/chunk-CQZMTFPY.js.map +1 -0
  5. package/dist/{chunk-QRAXX3GR.js → chunk-CUZW62JS.js} +2 -2
  6. package/dist/{chunk-HQU4R4SG.js → chunk-HHMRCMQU.js} +2 -2
  7. package/dist/chunk-M2TVBVH2.js +15 -0
  8. package/dist/chunk-M2TVBVH2.js.map +1 -0
  9. package/dist/chunk-MMLV4EZT.js +263 -0
  10. package/dist/chunk-MMLV4EZT.js.map +1 -0
  11. package/dist/chunk-O5BVUXPO.js +22 -0
  12. package/dist/chunk-O5BVUXPO.js.map +1 -0
  13. package/dist/{chunk-TIGTBSAQ.js → chunk-WHBMPHQE.js} +6 -4
  14. package/dist/{chunk-TIGTBSAQ.js.map → chunk-WHBMPHQE.js.map} +1 -1
  15. package/dist/{chunk-Y34FX24X.js → chunk-XE7HZVMH.js} +2 -10
  16. package/dist/chunk-XE7HZVMH.js.map +1 -0
  17. package/dist/{create-web-notifications-BpNR8qH3.d.ts → create-web-notifications-BHCzaU2y.d.ts} +13 -2
  18. package/dist/hono/index.js +4 -3
  19. package/dist/hono/index.js.map +1 -1
  20. package/dist/index.js +5 -3
  21. package/dist/manifest/server.js +5 -4
  22. package/dist/manifest/server.js.map +1 -1
  23. package/dist/manifest/web.d.ts +1 -1
  24. package/dist/manifest/web.js +3 -2
  25. package/dist/manifest/web.js.map +1 -1
  26. package/dist/panel-UFXNO4AF.js +243 -0
  27. package/dist/panel-UFXNO4AF.js.map +1 -0
  28. package/dist/preferences-screen-IOW6Y2H2.js +294 -0
  29. package/dist/preferences-screen-IOW6Y2H2.js.map +1 -0
  30. package/dist/react/index.d.ts +2 -2
  31. package/dist/react/index.js +17 -11
  32. package/dist/server/index.js +5 -4
  33. package/package.json +2 -2
  34. package/src/react/create-web-notifications.tsx +19 -10
  35. package/src/react/page-lazy.tsx +73 -0
  36. package/src/react/panel-lazy.tsx +74 -0
  37. package/dist/chunk-6HLHQDKS.js +0 -1022
  38. package/dist/chunk-6HLHQDKS.js.map +0 -1
  39. package/dist/chunk-Y34FX24X.js.map +0 -1
  40. /package/dist/{chunk-QRAXX3GR.js.map → chunk-CUZW62JS.js.map} +0 -0
  41. /package/dist/{chunk-HQU4R4SG.js.map → chunk-HHMRCMQU.js.map} +0 -0
@@ -0,0 +1,294 @@
1
+ import {
2
+ enableWebPush,
3
+ getExistingPushSubscription
4
+ } from "./chunk-CQZMTFPY.js";
5
+ import {
6
+ NOTIFICATION_CHANNELS
7
+ } from "./chunk-XE7HZVMH.js";
8
+ import {
9
+ __name
10
+ } from "./chunk-7QVYU63E.js";
11
+
12
+ // src/react/preferences-screen.tsx
13
+ import { useCallback, useEffect as useEffect2, useState as useState2 } from "react";
14
+ import { LoadingState } from "@12-apps/ui/data-display/LoadingState";
15
+ import { Switch } from "@12-apps/ui/form/Switch";
16
+ import { Box as Box2 } from "@12-apps/ui/mui/Box";
17
+ import { Text as Text2 } from "@12-apps/ui/typography/Text";
18
+
19
+ // src/react/web-push-setup.tsx
20
+ import { useEffect, useState } from "react";
21
+ import { Button } from "@12-apps/ui/form/Button";
22
+ import { Box } from "@12-apps/ui/mui/Box";
23
+ import { Text } from "@12-apps/ui/typography/Text";
24
+ import { jsx, jsxs } from "react/jsx-runtime";
25
+ var cardSx = {
26
+ p: 2,
27
+ border: "1px solid",
28
+ borderColor: "divider",
29
+ borderRadius: 2
30
+ };
31
+ function InstallFirstHint({ hint }) {
32
+ return /* @__PURE__ */ jsxs(Box, { sx: cardSx, "data-testid": "web-push-install-hint", children: [
33
+ /* @__PURE__ */ jsx(Text, { variant: "body", size: "sm", weight: "semibold", as: "p", children: hint.title }),
34
+ /* @__PURE__ */ jsx(Text, { variant: "caption", size: "xs", color: "secondary", as: "p", children: hint.body })
35
+ ] });
36
+ }
37
+ __name(InstallFirstHint, "InstallFirstHint");
38
+ async function resolveState(api) {
39
+ const subscription = await getExistingPushSubscription();
40
+ if (!subscription) return "idle";
41
+ const registration = await api.getPushRegistration({ endpoint: subscription.endpoint }).catch(() => null);
42
+ return registration?.registered ? "on" : "idle";
43
+ }
44
+ __name(resolveState, "resolveState");
45
+ function statusText(state, messages) {
46
+ if (state === "on") return messages.devicePushOn;
47
+ if (state === "denied") return messages.devicePushDenied;
48
+ if (state === "failed") return messages.devicePushFailed;
49
+ return messages.devicePushIdle;
50
+ }
51
+ __name(statusText, "statusText");
52
+ function WebPushDeviceSetup({
53
+ available,
54
+ api,
55
+ messages,
56
+ config
57
+ }) {
58
+ const [state, setState] = useState("checking");
59
+ const [needsInstall] = useState(() => config.needsInstallFirst?.() ?? false);
60
+ useEffect(() => {
61
+ let cancelled = false;
62
+ void resolveState(api).then((next) => {
63
+ if (!cancelled) setState(next);
64
+ });
65
+ return () => {
66
+ cancelled = true;
67
+ };
68
+ }, [api]);
69
+ if (!available) return null;
70
+ if (needsInstall && config.installHint) return /* @__PURE__ */ jsx(InstallFirstHint, { hint: config.installHint });
71
+ const enable = /* @__PURE__ */ __name(async () => {
72
+ setState("busy");
73
+ const result = await enableWebPush(api, config.swPath);
74
+ if (result.ok) setState("on");
75
+ else setState(result.reason === "permission-denied" ? "denied" : "failed");
76
+ }, "enable");
77
+ return /* @__PURE__ */ jsxs(
78
+ Box,
79
+ {
80
+ sx: {
81
+ ...cardSx,
82
+ display: "flex",
83
+ alignItems: "center",
84
+ justifyContent: "space-between",
85
+ gap: 2
86
+ },
87
+ "data-testid": "web-push-device-setup",
88
+ children: [
89
+ /* @__PURE__ */ jsxs(Box, { sx: { minWidth: 0 }, children: [
90
+ /* @__PURE__ */ jsx(Text, { variant: "body", size: "sm", weight: "semibold", as: "p", children: messages.devicePushTitle }),
91
+ /* @__PURE__ */ jsx(Text, { variant: "caption", size: "xs", color: "secondary", as: "p", children: statusText(state, messages) })
92
+ ] }),
93
+ state !== "on" ? /* @__PURE__ */ jsx(
94
+ Button,
95
+ {
96
+ variant: "outline",
97
+ color: "primary",
98
+ size: "sm",
99
+ disabled: state === "busy" || state === "checking",
100
+ onClick: () => void enable(),
101
+ dataTestId: "web-push-enable",
102
+ children: state === "busy" ? messages.devicePushEnabling : messages.devicePushEnable
103
+ }
104
+ ) : null
105
+ ]
106
+ }
107
+ );
108
+ }
109
+ __name(WebPushDeviceSetup, "WebPushDeviceSetup");
110
+
111
+ // src/react/preferences-screen.tsx
112
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
113
+ function CategoryCard({
114
+ category,
115
+ channels,
116
+ availability,
117
+ messages,
118
+ onToggle
119
+ }) {
120
+ const labels = messages.categoryLabels[category];
121
+ return /* @__PURE__ */ jsxs2(
122
+ Box2,
123
+ {
124
+ sx: { p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2 },
125
+ "data-testid": `prefs-${category}`,
126
+ children: [
127
+ /* @__PURE__ */ jsx2(Text2, { variant: "body", size: "sm", weight: "semibold", as: "p", children: labels?.title ?? messages.categoryFallbackTitle(category) }),
128
+ labels?.description ? /* @__PURE__ */ jsx2(Text2, { variant: "caption", size: "xs", color: "secondary", as: "p", children: labels.description }) : null,
129
+ /* @__PURE__ */ jsx2(
130
+ Box2,
131
+ {
132
+ sx: {
133
+ mt: 1.5,
134
+ display: "grid",
135
+ gridTemplateColumns: { xs: "1fr 1fr", sm: "repeat(4, 1fr)" },
136
+ gap: 1
137
+ },
138
+ children: NOTIFICATION_CHANNELS.map((channel) => /* @__PURE__ */ jsx2(
139
+ Switch,
140
+ {
141
+ size: "sm",
142
+ color: "primary",
143
+ label: messages.channelLabels[channel] ?? channel,
144
+ checked: channels[channel] && availability[channel],
145
+ disabled: !availability[channel],
146
+ onChange: (_, checked) => onToggle(channel, checked),
147
+ dataTestId: `prefs-${category}-${channel}`
148
+ },
149
+ channel
150
+ ))
151
+ }
152
+ )
153
+ ]
154
+ }
155
+ );
156
+ }
157
+ __name(CategoryCard, "CategoryCard");
158
+ function UnavailableHints({
159
+ availability,
160
+ messages
161
+ }) {
162
+ const unavailable = NOTIFICATION_CHANNELS.filter((channel) => !availability[channel]);
163
+ if (unavailable.length === 0) return null;
164
+ return /* @__PURE__ */ jsx2(Box2, { sx: { display: "flex", flexDirection: "column", gap: 0.5 }, children: unavailable.map((channel) => /* @__PURE__ */ jsxs2(
165
+ Text2,
166
+ {
167
+ variant: "caption",
168
+ size: "xs",
169
+ color: "secondary",
170
+ as: "p",
171
+ "data-testid": `prefs-hint-${channel}`,
172
+ children: [
173
+ messages.channelLabels[channel] ?? channel,
174
+ ":",
175
+ " ",
176
+ messages.channelUnavailableHints[channel] ?? ""
177
+ ]
178
+ },
179
+ channel
180
+ )) });
181
+ }
182
+ __name(UnavailableHints, "UnavailableHints");
183
+ function usePreferences(api) {
184
+ const [payload, setPayload] = useState2(null);
185
+ useEffect2(() => {
186
+ let cancelled = false;
187
+ void api.getPreferences().then((next) => {
188
+ if (!cancelled) setPayload(next);
189
+ }).catch(() => void 0);
190
+ return () => {
191
+ cancelled = true;
192
+ };
193
+ }, [api]);
194
+ const toggle = useCallback(
195
+ (category, channel, enabled) => {
196
+ setPayload((current) => {
197
+ const row = current?.preferences[category];
198
+ if (!current || !row) return current;
199
+ return {
200
+ ...current,
201
+ preferences: { ...current.preferences, [category]: { ...row, [channel]: enabled } }
202
+ };
203
+ });
204
+ const reconcile = /* @__PURE__ */ __name(() => {
205
+ void api.getPreferences().then(setPayload).catch(() => void 0);
206
+ }, "reconcile");
207
+ void api.savePreference(category, channel, enabled).then((result) => {
208
+ if (result.ok) setPayload(result.data);
209
+ else reconcile();
210
+ }).catch(reconcile);
211
+ },
212
+ [api]
213
+ );
214
+ return { payload, toggle };
215
+ }
216
+ __name(usePreferences, "usePreferences");
217
+ function PreferencesScreen({
218
+ footer,
219
+ api,
220
+ messages,
221
+ webPush
222
+ }) {
223
+ const { payload, toggle } = usePreferences(api);
224
+ if (!payload) {
225
+ return /* @__PURE__ */ jsx2(
226
+ LoadingState,
227
+ {
228
+ variant: "spinner",
229
+ message: messages.loadingMore,
230
+ size: "md",
231
+ dataTestId: "notification-prefs-loading"
232
+ }
233
+ );
234
+ }
235
+ const { preferences, availability, categories } = payload;
236
+ return /* @__PURE__ */ jsxs2(
237
+ Box2,
238
+ {
239
+ component: "section",
240
+ "data-testid": "notification-prefs-view",
241
+ sx: {
242
+ maxWidth: 640,
243
+ mx: "auto",
244
+ width: "100%",
245
+ px: 2,
246
+ py: 4,
247
+ display: "flex",
248
+ flexDirection: "column",
249
+ gap: 2
250
+ },
251
+ children: [
252
+ /* @__PURE__ */ jsxs2(Box2, { children: [
253
+ /* @__PURE__ */ jsx2(Text2, { variant: "heading", size: "lg", as: "h1", children: messages.preferencesTitle }),
254
+ /* @__PURE__ */ jsxs2(Text2, { variant: "caption", size: "sm", color: "secondary", as: "p", children: [
255
+ messages.preferencesLead,
256
+ " ",
257
+ footer
258
+ ] })
259
+ ] }),
260
+ /* @__PURE__ */ jsx2(
261
+ WebPushDeviceSetup,
262
+ {
263
+ available: availability.WEB_PUSH,
264
+ api,
265
+ messages,
266
+ config: webPush
267
+ }
268
+ ),
269
+ categories.map((category) => /* @__PURE__ */ jsx2(
270
+ CategoryCard,
271
+ {
272
+ category,
273
+ channels: preferences[category] ?? {
274
+ EMAIL: false,
275
+ SMS: false,
276
+ WHATSAPP: false,
277
+ WEB_PUSH: false
278
+ },
279
+ availability,
280
+ messages,
281
+ onToggle: (channel, enabled) => toggle(category, channel, enabled)
282
+ },
283
+ category
284
+ )),
285
+ /* @__PURE__ */ jsx2(UnavailableHints, { availability, messages })
286
+ ]
287
+ }
288
+ );
289
+ }
290
+ __name(PreferencesScreen, "PreferencesScreen");
291
+ export {
292
+ PreferencesScreen
293
+ };
294
+ //# sourceMappingURL=preferences-screen-IOW6Y2H2.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react/preferences-screen.tsx","../src/react/web-push-setup.tsx"],"sourcesContent":["/**\n * The notification-preferences screen: the category × channel matrix over\n * `GET/PUT <mount>/notification-preferences`.\n *\n * Toggles auto-save (optimistic, per change); channels that cannot reach the\n * user right now (no phone on file / channel not declared) render disabled with\n * a hint. Web Push additionally carries the per-BROWSER enable step, since a\n * preference alone cannot reach a device that never subscribed.\n */\nimport { useCallback, useEffect, useState, type JSX } from 'react';\n\nimport { LoadingState } from '@12-apps/ui/data-display/LoadingState';\nimport { Switch } from '@12-apps/ui/form/Switch';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { NotificationMessages } from '../messages';\nimport { NOTIFICATION_CHANNELS, type NotificationChannel } from '../types';\n\nimport type { NotificationsApiClient, PreferencesPayload } from './api';\nimport { WebPushDeviceSetup, type WebPushSetupConfig } from './web-push-setup';\n\ntype Availability = Record<NotificationChannel, boolean>;\n\n/** One category's row of channel switches. */\nfunction CategoryCard({\n category,\n channels,\n availability,\n messages,\n onToggle,\n}: {\n category: string;\n channels: Record<NotificationChannel, boolean>;\n availability: Availability;\n messages: NotificationMessages;\n onToggle: (channel: NotificationChannel, enabled: boolean) => void;\n}): JSX.Element {\n const labels = messages.categoryLabels[category];\n return (\n <Box\n sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}\n data-testid={`prefs-${category}`}\n >\n <Text variant=\"body\" size=\"sm\" weight=\"semibold\" as=\"p\">\n {labels?.title ?? messages.categoryFallbackTitle(category)}\n </Text>\n {labels?.description ? (\n <Text variant=\"caption\" size=\"xs\" color=\"secondary\" as=\"p\">\n {labels.description}\n </Text>\n ) : null}\n <Box\n sx={{\n mt: 1.5,\n display: 'grid',\n gridTemplateColumns: { xs: '1fr 1fr', sm: 'repeat(4, 1fr)' },\n gap: 1,\n }}\n >\n {NOTIFICATION_CHANNELS.map((channel) => (\n <Switch\n key={channel}\n size=\"sm\"\n color=\"primary\"\n label={messages.channelLabels[channel] ?? channel}\n // An unavailable channel reads OFF regardless of the stored choice:\n // a toggle that says \"on\" for a channel that cannot reach you is a\n // promise the pipeline will not keep.\n checked={channels[channel] && availability[channel]}\n disabled={!availability[channel]}\n onChange={(_, checked) => onToggle(channel, checked)}\n // `dataTestId`, not `data-testid`: the UI Switch puts this one on\n // the INPUT (and derives `-container` / `-label` from it), which is\n // the element a click and a `disabled` assertion need.\n dataTestId={`prefs-${category}-${channel}`}\n />\n ))}\n </Box>\n </Box>\n );\n}\n\n/** Why disabled toggles are disabled, one line per unavailable channel. */\nfunction UnavailableHints({\n availability,\n messages,\n}: {\n availability: Availability;\n messages: NotificationMessages;\n}): JSX.Element | null {\n const unavailable = NOTIFICATION_CHANNELS.filter((channel) => !availability[channel]);\n if (unavailable.length === 0) return null;\n return (\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>\n {unavailable.map((channel) => (\n <Text\n key={channel}\n variant=\"caption\"\n size=\"xs\"\n color=\"secondary\"\n as=\"p\"\n data-testid={`prefs-hint-${channel}`}\n >\n {messages.channelLabels[channel] ?? channel}:{' '}\n {messages.channelUnavailableHints[channel] ?? ''}\n </Text>\n ))}\n </Box>\n );\n}\n\nexport interface PreferencesScreenProps {\n /** Rendered under the lead paragraph — a \"back to account\" link, typically. */\n footer?: JSX.Element;\n}\n\n/** Optimistic per-toggle auto-save; a failed PUT takes the server's answer. */\nfunction usePreferences(api: NotificationsApiClient): {\n payload: PreferencesPayload | null;\n toggle: (category: string, channel: NotificationChannel, enabled: boolean) => void;\n} {\n const [payload, setPayload] = useState<PreferencesPayload | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n void api\n .getPreferences()\n .then((next) => {\n if (!cancelled) setPayload(next);\n })\n .catch(() => undefined);\n return () => {\n cancelled = true;\n };\n }, [api]);\n\n const toggle = useCallback(\n (category: string, channel: NotificationChannel, enabled: boolean) => {\n setPayload((current) => {\n const row = current?.preferences[category];\n if (!current || !row) return current;\n return {\n ...current,\n preferences: { ...current.preferences, [category]: { ...row, [channel]: enabled } },\n };\n });\n const reconcile = (): void => {\n void api.getPreferences().then(setPayload).catch(() => undefined);\n };\n void api\n .savePreference(category, channel, enabled)\n .then((result) => {\n // The PUT answers with the whole matrix, so a success REPLACES the\n // optimistic guess with the server's own row — which is what catches a\n // save the server merged differently from the way the screen assumed.\n if (result.ok) setPayload(result.data);\n else reconcile();\n })\n // The packaged transport never rejects (it folds a failure into\n // `ok: false`), but a HOST transport may — and an unhandled rejection\n // would leave the toggle showing a choice the server never took.\n .catch(reconcile);\n },\n [api],\n );\n\n return { payload, toggle };\n}\n\nexport function PreferencesScreen({\n footer,\n api,\n messages,\n webPush,\n}: PreferencesScreenProps & {\n api: NotificationsApiClient;\n messages: NotificationMessages;\n webPush: WebPushSetupConfig;\n}): JSX.Element {\n const { payload, toggle } = usePreferences(api);\n\n if (!payload) {\n return (\n <LoadingState\n variant=\"spinner\"\n message={messages.loadingMore}\n size=\"md\"\n dataTestId=\"notification-prefs-loading\"\n />\n );\n }\n\n const { preferences, availability, categories } = payload;\n return (\n <Box\n component=\"section\"\n data-testid=\"notification-prefs-view\"\n sx={{\n maxWidth: 640,\n mx: 'auto',\n width: '100%',\n px: 2,\n py: 4,\n display: 'flex',\n flexDirection: 'column',\n gap: 2,\n }}\n >\n <Box>\n <Text variant=\"heading\" size=\"lg\" as=\"h1\">\n {messages.preferencesTitle}\n </Text>\n <Text variant=\"caption\" size=\"sm\" color=\"secondary\" as=\"p\">\n {messages.preferencesLead} {footer}\n </Text>\n </Box>\n <WebPushDeviceSetup\n available={availability.WEB_PUSH}\n api={api}\n messages={messages}\n config={webPush}\n />\n {categories.map((category) => (\n <CategoryCard\n key={category}\n category={category}\n channels={preferences[category] ?? {\n EMAIL: false,\n SMS: false,\n WHATSAPP: false,\n WEB_PUSH: false,\n }}\n availability={availability}\n messages={messages}\n onToggle={(channel, enabled) => toggle(category, channel, enabled)}\n />\n ))}\n <UnavailableHints availability={availability} messages={messages} />\n </Box>\n );\n}\n","/**\n * The per-BROWSER Web Push enable step, which sits above the preference matrix\n * because a preference alone cannot reach a device that never subscribed.\n */\nimport { useEffect, useState, type JSX } from 'react';\n\nimport { Button } from '@12-apps/ui/form/Button';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { NotificationMessages } from '../messages';\n\nimport type { NotificationsApiClient } from './api';\nimport { enableWebPush, getExistingPushSubscription } from './web-push-client';\n\n/** The panel's copy for a host whose platform blocks browser-level push. */\nexport interface WebPushPlatformHint {\n title: string;\n body: string;\n}\n\nexport interface WebPushSetupConfig {\n /**\n * The host's service-worker path. Path-routed SPAs each control their own\n * scope, and the file itself is the host's.\n */\n swPath?: string;\n /**\n * Whether THIS platform must be installed to the home screen before a\n * subscription can exist at all.\n *\n * iOS is the case: Safari has no browser-level Web Push, so \"Ativar\" there\n * asks no permission, creates no subscription, and fails with nothing a user\n * could act on. The check is a config seam rather than a dependency because\n * \"is this an installable iOS browser\" is a question a host's PWA layer\n * already answers (the origin passes\n * `() => isIosInstallable() && !isStandalone()` from `@12-apps/pwa`).\n */\n needsInstallFirst?: () => boolean;\n /** What to say instead of the button when the check above is true. */\n installHint?: WebPushPlatformHint;\n}\n\nconst cardSx = {\n p: 2,\n border: '1px solid',\n borderColor: 'divider',\n borderRadius: 2,\n} as const;\n\nfunction InstallFirstHint({ hint }: { hint: WebPushPlatformHint }): JSX.Element {\n return (\n <Box sx={cardSx} data-testid=\"web-push-install-hint\">\n <Text variant=\"body\" size=\"sm\" weight=\"semibold\" as=\"p\">\n {hint.title}\n </Text>\n <Text variant=\"caption\" size=\"xs\" color=\"secondary\" as=\"p\">\n {hint.body}\n </Text>\n </Box>\n );\n}\n\ntype SetupState = 'idle' | 'checking' | 'on' | 'busy' | 'failed' | 'denied';\n\n/**\n * \"Is this browser receiving alerts?\" — asked of the browser AND of the server.\n *\n * The browser alone is not enough, and answering from it alone is how a real\n * person is silenced while being told they are fine. Two users share a counter\n * PC: Ana enables push, then Otávio signs in on the same browser and enables it\n * too. `PushManager` hands out the SAME endpoint for the same browser profile,\n * so the POST re-owns the row and Ana's is gone. Ana signs back in, her browser\n * still holds the subscription object, and a browser-only check renders \"Este\n * navegador está recebendo alertas.\" with no button to fix it — forever. The\n * 404/410 prune reaches the same state from the other direction.\n *\n * So the server is asked whether it still has THIS endpoint under THIS user. A\n * `false` (or an unreachable server) shows *Ativar* again, and one click\n * re-registers the row — which is the same request the happy path makes, so the\n * recovery costs nothing to maintain.\n */\nasync function resolveState(api: NotificationsApiClient): Promise<SetupState> {\n const subscription = await getExistingPushSubscription();\n if (!subscription) return 'idle';\n const registration = await api\n .getPushRegistration({ endpoint: subscription.endpoint })\n .catch(() => null);\n return registration?.registered ? 'on' : 'idle';\n}\n\nfunction statusText(state: SetupState, messages: NotificationMessages): string {\n if (state === 'on') return messages.devicePushOn;\n if (state === 'denied') return messages.devicePushDenied;\n if (state === 'failed') return messages.devicePushFailed;\n return messages.devicePushIdle;\n}\n\nexport function WebPushDeviceSetup({\n available,\n api,\n messages,\n config,\n}: {\n available: boolean;\n api: NotificationsApiClient;\n messages: NotificationMessages;\n config: WebPushSetupConfig;\n}): JSX.Element | null {\n const [state, setState] = useState<SetupState>('checking');\n // Read once, at mount: neither can change without a new document.\n const [needsInstall] = useState(() => config.needsInstallFirst?.() ?? false);\n\n useEffect(() => {\n let cancelled = false;\n void resolveState(api).then((next) => {\n if (!cancelled) setState(next);\n });\n return () => {\n cancelled = true;\n };\n }, [api]);\n\n if (!available) return null;\n if (needsInstall && config.installHint) return <InstallFirstHint hint={config.installHint} />;\n\n const enable = async (): Promise<void> => {\n setState('busy');\n const result = await enableWebPush(api, config.swPath);\n if (result.ok) setState('on');\n else setState(result.reason === 'permission-denied' ? 'denied' : 'failed');\n };\n\n return (\n <Box\n sx={{\n ...cardSx,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'space-between',\n gap: 2,\n }}\n data-testid=\"web-push-device-setup\"\n >\n <Box sx={{ minWidth: 0 }}>\n <Text variant=\"body\" size=\"sm\" weight=\"semibold\" as=\"p\">\n {messages.devicePushTitle}\n </Text>\n <Text variant=\"caption\" size=\"xs\" color=\"secondary\" as=\"p\">\n {statusText(state, messages)}\n </Text>\n </Box>\n {state !== 'on' ? (\n <Button\n variant=\"outline\"\n color=\"primary\"\n size=\"sm\"\n disabled={state === 'busy' || state === 'checking'}\n onClick={() => void enable()}\n dataTestId=\"web-push-enable\"\n >\n {state === 'busy' ? messages.devicePushEnabling : messages.devicePushEnable}\n </Button>\n ) : null}\n </Box>\n );\n}\n"],"mappings":";;;;;;;;;;;;AASA,SAAS,aAAa,aAAAA,YAAW,YAAAC,iBAA0B;AAE3D,SAAS,oBAAoB;AAC7B,SAAS,cAAc;AACvB,SAAS,OAAAC,YAAW;AACpB,SAAS,QAAAC,aAAY;;;ACVrB,SAAS,WAAW,gBAA0B;AAE9C,SAAS,cAAc;AACvB,SAAS,WAAW;AACpB,SAAS,YAAY;AA4CjB,SACE,KADF;AATJ,IAAM,SAAS;AAAA,EACb,GAAG;AAAA,EACH,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,cAAc;AAChB;AAEA,SAAS,iBAAiB,EAAE,KAAK,GAA+C;AAC9E,SACE,qBAAC,OAAI,IAAI,QAAQ,eAAY,yBAC3B;AAAA,wBAAC,QAAK,SAAQ,QAAO,MAAK,MAAK,QAAO,YAAW,IAAG,KACjD,eAAK,OACR;AAAA,IACA,oBAAC,QAAK,SAAQ,WAAU,MAAK,MAAK,OAAM,aAAY,IAAG,KACpD,eAAK,MACR;AAAA,KACF;AAEJ;AAXS;AAgCT,eAAe,aAAa,KAAkD;AAC5E,QAAM,eAAe,MAAM,4BAA4B;AACvD,MAAI,CAAC,aAAc,QAAO;AAC1B,QAAM,eAAe,MAAM,IACxB,oBAAoB,EAAE,UAAU,aAAa,SAAS,CAAC,EACvD,MAAM,MAAM,IAAI;AACnB,SAAO,cAAc,aAAa,OAAO;AAC3C;AAPe;AASf,SAAS,WAAW,OAAmB,UAAwC;AAC7E,MAAI,UAAU,KAAM,QAAO,SAAS;AACpC,MAAI,UAAU,SAAU,QAAO,SAAS;AACxC,MAAI,UAAU,SAAU,QAAO,SAAS;AACxC,SAAO,SAAS;AAClB;AALS;AAOF,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAKuB;AACrB,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAqB,UAAU;AAEzD,QAAM,CAAC,YAAY,IAAI,SAAS,MAAM,OAAO,oBAAoB,KAAK,KAAK;AAE3E,YAAU,MAAM;AACd,QAAI,YAAY;AAChB,SAAK,aAAa,GAAG,EAAE,KAAK,CAAC,SAAS;AACpC,UAAI,CAAC,UAAW,UAAS,IAAI;AAAA,IAC/B,CAAC;AACD,WAAO,MAAM;AACX,kBAAY;AAAA,IACd;AAAA,EACF,GAAG,CAAC,GAAG,CAAC;AAER,MAAI,CAAC,UAAW,QAAO;AACvB,MAAI,gBAAgB,OAAO,YAAa,QAAO,oBAAC,oBAAiB,MAAM,OAAO,aAAa;AAE3F,QAAM,SAAS,mCAA2B;AACxC,aAAS,MAAM;AACf,UAAM,SAAS,MAAM,cAAc,KAAK,OAAO,MAAM;AACrD,QAAI,OAAO,GAAI,UAAS,IAAI;AAAA,QACvB,UAAS,OAAO,WAAW,sBAAsB,WAAW,QAAQ;AAAA,EAC3E,GALe;AAOf,SACE;AAAA,IAAC;AAAA;AAAA,MACC,IAAI;AAAA,QACF,GAAG;AAAA,QACH,SAAS;AAAA,QACT,YAAY;AAAA,QACZ,gBAAgB;AAAA,QAChB,KAAK;AAAA,MACP;AAAA,MACA,eAAY;AAAA,MAEZ;AAAA,6BAAC,OAAI,IAAI,EAAE,UAAU,EAAE,GACrB;AAAA,8BAAC,QAAK,SAAQ,QAAO,MAAK,MAAK,QAAO,YAAW,IAAG,KACjD,mBAAS,iBACZ;AAAA,UACA,oBAAC,QAAK,SAAQ,WAAU,MAAK,MAAK,OAAM,aAAY,IAAG,KACpD,qBAAW,OAAO,QAAQ,GAC7B;AAAA,WACF;AAAA,QACC,UAAU,OACT;AAAA,UAAC;AAAA;AAAA,YACC,SAAQ;AAAA,YACR,OAAM;AAAA,YACN,MAAK;AAAA,YACL,UAAU,UAAU,UAAU,UAAU;AAAA,YACxC,SAAS,MAAM,KAAK,OAAO;AAAA,YAC3B,YAAW;AAAA,YAEV,oBAAU,SAAS,SAAS,qBAAqB,SAAS;AAAA;AAAA,QAC7D,IACE;AAAA;AAAA;AAAA,EACN;AAEJ;AApEgB;;;AD1DZ,SAIE,OAAAC,MAJF,QAAAC,aAAA;AAfJ,SAAS,aAAa;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAMgB;AACd,QAAM,SAAS,SAAS,eAAe,QAAQ;AAC/C,SACE,gBAAAA;AAAA,IAACC;AAAA,IAAA;AAAA,MACC,IAAI,EAAE,GAAG,GAAG,QAAQ,aAAa,aAAa,WAAW,cAAc,EAAE;AAAA,MACzE,eAAa,SAAS,QAAQ;AAAA,MAE9B;AAAA,wBAAAF,KAACG,OAAA,EAAK,SAAQ,QAAO,MAAK,MAAK,QAAO,YAAW,IAAG,KACjD,kBAAQ,SAAS,SAAS,sBAAsB,QAAQ,GAC3D;AAAA,QACC,QAAQ,cACP,gBAAAH,KAACG,OAAA,EAAK,SAAQ,WAAU,MAAK,MAAK,OAAM,aAAY,IAAG,KACpD,iBAAO,aACV,IACE;AAAA,QACJ,gBAAAH;AAAA,UAACE;AAAA,UAAA;AAAA,YACC,IAAI;AAAA,cACF,IAAI;AAAA,cACJ,SAAS;AAAA,cACT,qBAAqB,EAAE,IAAI,WAAW,IAAI,iBAAiB;AAAA,cAC3D,KAAK;AAAA,YACP;AAAA,YAEC,gCAAsB,IAAI,CAAC,YAC1B,gBAAAF;AAAA,cAAC;AAAA;AAAA,gBAEC,MAAK;AAAA,gBACL,OAAM;AAAA,gBACN,OAAO,SAAS,cAAc,OAAO,KAAK;AAAA,gBAI1C,SAAS,SAAS,OAAO,KAAK,aAAa,OAAO;AAAA,gBAClD,UAAU,CAAC,aAAa,OAAO;AAAA,gBAC/B,UAAU,CAAC,GAAG,YAAY,SAAS,SAAS,OAAO;AAAA,gBAInD,YAAY,SAAS,QAAQ,IAAI,OAAO;AAAA;AAAA,cAbnC;AAAA,YAcP,CACD;AAAA;AAAA,QACH;AAAA;AAAA;AAAA,EACF;AAEJ;AAxDS;AA2DT,SAAS,iBAAiB;AAAA,EACxB;AAAA,EACA;AACF,GAGuB;AACrB,QAAM,cAAc,sBAAsB,OAAO,CAAC,YAAY,CAAC,aAAa,OAAO,CAAC;AACpF,MAAI,YAAY,WAAW,EAAG,QAAO;AACrC,SACE,gBAAAA,KAACE,MAAA,EAAI,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,IAAI,GAC3D,sBAAY,IAAI,CAAC,YAChB,gBAAAD;AAAA,IAACE;AAAA,IAAA;AAAA,MAEC,SAAQ;AAAA,MACR,MAAK;AAAA,MACL,OAAM;AAAA,MACN,IAAG;AAAA,MACH,eAAa,cAAc,OAAO;AAAA,MAEjC;AAAA,iBAAS,cAAc,OAAO,KAAK;AAAA,QAAQ;AAAA,QAAE;AAAA,QAC7C,SAAS,wBAAwB,OAAO,KAAK;AAAA;AAAA;AAAA,IARzC;AAAA,EASP,CACD,GACH;AAEJ;AA1BS;AAkCT,SAAS,eAAe,KAGtB;AACA,QAAM,CAAC,SAAS,UAAU,IAAIC,UAAoC,IAAI;AAEtE,EAAAC,WAAU,MAAM;AACd,QAAI,YAAY;AAChB,SAAK,IACF,eAAe,EACf,KAAK,CAAC,SAAS;AACd,UAAI,CAAC,UAAW,YAAW,IAAI;AAAA,IACjC,CAAC,EACA,MAAM,MAAM,MAAS;AACxB,WAAO,MAAM;AACX,kBAAY;AAAA,IACd;AAAA,EACF,GAAG,CAAC,GAAG,CAAC;AAER,QAAM,SAAS;AAAA,IACb,CAAC,UAAkB,SAA8B,YAAqB;AACpE,iBAAW,CAAC,YAAY;AACtB,cAAM,MAAM,SAAS,YAAY,QAAQ;AACzC,YAAI,CAAC,WAAW,CAAC,IAAK,QAAO;AAC7B,eAAO;AAAA,UACL,GAAG;AAAA,UACH,aAAa,EAAE,GAAG,QAAQ,aAAa,CAAC,QAAQ,GAAG,EAAE,GAAG,KAAK,CAAC,OAAO,GAAG,QAAQ,EAAE;AAAA,QACpF;AAAA,MACF,CAAC;AACD,YAAM,YAAY,6BAAY;AAC5B,aAAK,IAAI,eAAe,EAAE,KAAK,UAAU,EAAE,MAAM,MAAM,MAAS;AAAA,MAClE,GAFkB;AAGlB,WAAK,IACF,eAAe,UAAU,SAAS,OAAO,EACzC,KAAK,CAAC,WAAW;AAIhB,YAAI,OAAO,GAAI,YAAW,OAAO,IAAI;AAAA,YAChC,WAAU;AAAA,MACjB,CAAC,EAIA,MAAM,SAAS;AAAA,IACpB;AAAA,IACA,CAAC,GAAG;AAAA,EACN;AAEA,SAAO,EAAE,SAAS,OAAO;AAC3B;AAlDS;AAoDF,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAIgB;AACd,QAAM,EAAE,SAAS,OAAO,IAAI,eAAe,GAAG;AAE9C,MAAI,CAAC,SAAS;AACZ,WACE,gBAAAL;AAAA,MAAC;AAAA;AAAA,QACC,SAAQ;AAAA,QACR,SAAS,SAAS;AAAA,QAClB,MAAK;AAAA,QACL,YAAW;AAAA;AAAA,IACb;AAAA,EAEJ;AAEA,QAAM,EAAE,aAAa,cAAc,WAAW,IAAI;AAClD,SACE,gBAAAC;AAAA,IAACC;AAAA,IAAA;AAAA,MACC,WAAU;AAAA,MACV,eAAY;AAAA,MACZ,IAAI;AAAA,QACF,UAAU;AAAA,QACV,IAAI;AAAA,QACJ,OAAO;AAAA,QACP,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,SAAS;AAAA,QACT,eAAe;AAAA,QACf,KAAK;AAAA,MACP;AAAA,MAEA;AAAA,wBAAAD,MAACC,MAAA,EACC;AAAA,0BAAAF,KAACG,OAAA,EAAK,SAAQ,WAAU,MAAK,MAAK,IAAG,MAClC,mBAAS,kBACZ;AAAA,UACA,gBAAAF,MAACE,OAAA,EAAK,SAAQ,WAAU,MAAK,MAAK,OAAM,aAAY,IAAG,KACpD;AAAA,qBAAS;AAAA,YAAgB;AAAA,YAAE;AAAA,aAC9B;AAAA,WACF;AAAA,QACA,gBAAAH;AAAA,UAAC;AAAA;AAAA,YACC,WAAW,aAAa;AAAA,YACxB;AAAA,YACA;AAAA,YACA,QAAQ;AAAA;AAAA,QACV;AAAA,QACC,WAAW,IAAI,CAAC,aACf,gBAAAA;AAAA,UAAC;AAAA;AAAA,YAEC;AAAA,YACA,UAAU,YAAY,QAAQ,KAAK;AAAA,cACjC,OAAO;AAAA,cACP,KAAK;AAAA,cACL,UAAU;AAAA,cACV,UAAU;AAAA,YACZ;AAAA,YACA;AAAA,YACA;AAAA,YACA,UAAU,CAAC,SAAS,YAAY,OAAO,UAAU,SAAS,OAAO;AAAA;AAAA,UAV5D;AAAA,QAWP,CACD;AAAA,QACD,gBAAAA,KAAC,oBAAiB,cAA4B,UAAoB;AAAA;AAAA;AAAA,EACpE;AAEJ;AAvEgB;","names":["useEffect","useState","Box","Text","jsx","jsxs","Box","Text","useState","useEffect"]}
@@ -1,5 +1,5 @@
1
- import { N as NotificationsApiClient } from '../create-web-notifications-BpNR8qH3.js';
2
- export { B as BADGE_POLL_MS, a as BADGE_RECONCILE_MS, b as BellButtonProps, I as InboxListStatus, d as InboxState, e as InboxStore, f as NotificationsHttpError, g as NotificationsPanelProps, h as NotificationsResult, i as NotificationsSignalHook, j as NotificationsSubscribe, k as NotificationsTransport, l as NotificationsWebConfig, P as PAGE_SIZE, m as PreferencesPayload, n as PreferencesScreenProps, o as PushRegistrationPayload, W as WebNotifications, p as WebPushPlatformHint, q as WebPushSetupConfig, r as createInboxStore, s as createNotificationsApiClient, c as createWebNotifications, t as httpNotificationsTransport, u as useInboxList, v as useInboxState, w as useUnreadCount } from '../create-web-notifications-BpNR8qH3.js';
1
+ import { N as NotificationsApiClient } from '../create-web-notifications-BHCzaU2y.js';
2
+ export { B as BADGE_POLL_MS, a as BADGE_RECONCILE_MS, b as BellButtonProps, I as InboxListStatus, d as InboxState, e as InboxStore, f as NotificationsHttpError, g as NotificationsPanelProps, h as NotificationsResult, i as NotificationsSignalHook, j as NotificationsSubscribe, k as NotificationsTransport, l as NotificationsWebConfig, P as PAGE_SIZE, m as PreferencesPayload, n as PreferencesScreenProps, o as PushRegistrationPayload, W as WebNotifications, p as WebPushPlatformHint, q as WebPushSetupConfig, r as createInboxStore, s as createNotificationsApiClient, c as createWebNotifications, t as httpNotificationsTransport, u as useInboxList, v as useInboxState, w as useUnreadCount } from '../create-web-notifications-BHCzaU2y.js';
3
3
  import { JSX } from 'react';
4
4
  import { N as NotificationMessages } from '../wire-6dzyfDE7.js';
5
5
  import '../types-CXLAG3UU.js';
@@ -1,23 +1,29 @@
1
+ import {
2
+ relativeTime
3
+ } from "../chunk-O5BVUXPO.js";
4
+ import {
5
+ disableWebPush,
6
+ enableWebPush,
7
+ getExistingPushSubscription,
8
+ pushSupported
9
+ } from "../chunk-CQZMTFPY.js";
10
+ import {
11
+ NotificationsHttpError,
12
+ createNotificationsApiClient,
13
+ createWebNotifications,
14
+ httpNotificationsTransport
15
+ } from "../chunk-MMLV4EZT.js";
1
16
  import {
2
17
  BADGE_POLL_MS,
3
18
  BADGE_RECONCILE_MS,
4
19
  BellIcon,
5
- NotificationsHttpError,
6
20
  PAGE_SIZE,
7
21
  createInboxStore,
8
- createNotificationsApiClient,
9
- createWebNotifications,
10
- disableWebPush,
11
- enableWebPush,
12
- getExistingPushSubscription,
13
- httpNotificationsTransport,
14
- pushSupported,
15
- relativeTime,
16
22
  useInboxList,
17
23
  useInboxState,
18
24
  useUnreadCount
19
- } from "../chunk-6HLHQDKS.js";
20
- import "../chunk-Y34FX24X.js";
25
+ } from "../chunk-BW723CX2.js";
26
+ import "../chunk-M2TVBVH2.js";
21
27
  import "../chunk-7QVYU63E.js";
22
28
  export {
23
29
  BADGE_POLL_MS,
@@ -4,7 +4,7 @@ import {
4
4
  NOTIFICATIONS_JOBS,
5
5
  NOTIFICATIONS_SWEEP_QUEUE,
6
6
  wireNotifyPort
7
- } from "../chunk-QRAXX3GR.js";
7
+ } from "../chunk-CUZW62JS.js";
8
8
  import {
9
9
  EMAIL_DRIVERS,
10
10
  NotificationProviderError,
@@ -25,9 +25,10 @@ import {
25
25
  smsTransport,
26
26
  webPushTransport,
27
27
  whatsAppTransport
28
- } from "../chunk-TIGTBSAQ.js";
29
- import "../chunk-HQU4R4SG.js";
30
- import "../chunk-Y34FX24X.js";
28
+ } from "../chunk-WHBMPHQE.js";
29
+ import "../chunk-HHMRCMQU.js";
30
+ import "../chunk-XE7HZVMH.js";
31
+ import "../chunk-M2TVBVH2.js";
31
32
  import "../chunk-7QVYU63E.js";
32
33
  export {
33
34
  EMAIL_DRIVERS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/notifications",
3
- "version": "4.7.0",
3
+ "version": "4.7.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Plug-and-play notification system (12-15): an always-on in-app inbox, per-user × per-category channel preferences, and email / SMS / WhatsApp / web-push transports behind vendor DRIVERS so a second provider is a config entry. Framework-free core (.), host-mounted backend surface (./server: inbox / preferences / push-subscription endpoints, the channel router with delivery records + retry sweep, the permission fan-out, duck-typed Prisma seam), Hono adapter (./hono), React surface (./react: bell + badge, inbox drawer, preferences screen), VAPID sender (./web-push) and the package-owned Prisma partial + migrations. Standardized adoption contract in ADOPTING.md.",
@@ -51,7 +51,7 @@
51
51
  "prisma:sync:check": "node scripts/sync-notifications-schema.mjs --check"
52
52
  },
53
53
  "dependencies": {
54
- "@12-apps/ui": "^6.11.0"
54
+ "@12-apps/ui": "^6.11.1"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@12-apps/wiring": ">=1.3.0",
@@ -10,8 +10,10 @@ import {
10
10
  type NotificationsSubscribe,
11
11
  } from './hooks';
12
12
  import { createInboxStore, type InboxStore } from './inbox-state';
13
- import { NotificationsPanel, type NotificationsPanelProps } from './panel';
14
- import { PreferencesScreen, type PreferencesScreenProps } from './preferences-screen';
13
+ import { lazyNotificationsPanel } from './panel-lazy';
14
+ import type { NotificationsPanelProps } from './panel';
15
+ import { lazyPreferencesPage } from './page-lazy';
16
+ import type { PreferencesScreenProps } from './preferences-screen';
15
17
  import { httpNotificationsTransport, type NotificationsTransport } from './transport';
16
18
  import type { WebPushSetupConfig } from './web-push-setup';
17
19
 
@@ -56,11 +58,22 @@ export interface NotificationsWebConfig {
56
58
  }
57
59
 
58
60
  export interface WebNotifications {
59
- /** The routed surface: the preferences screen. */
61
+ /**
62
+ * The routed surface: the preferences screen.
63
+ *
64
+ * Loaded on demand — see `page-lazy.tsx`. A host that mounts only the bell and
65
+ * the panel never downloads it, and a host that routes to it fetches it while
66
+ * entering that route.
67
+ */
60
68
  page: ComponentType<PreferencesScreenProps>;
61
69
  /** The bell, already bound to the shared store. */
62
70
  BellButton: ComponentType<BellButtonProps>;
63
- /** The inbox slide-over, sharing that store. */
71
+ /**
72
+ * The inbox slide-over, sharing that store.
73
+ *
74
+ * Loaded the first time it is opened — see `panel-lazy.tsx`. Until then a
75
+ * host's chrome carries the bell and nothing else.
76
+ */
64
77
  Panel: ComponentType<NotificationsPanelProps>;
65
78
  /**
66
79
  * Bell + panel as ONE element, for a host that just wants the feature in its
@@ -97,9 +110,7 @@ export function createWebNotifications(config: NotificationsWebConfig): WebNotif
97
110
  const Bell: ComponentType<BellButtonProps> = (props) => (
98
111
  <BellButton {...props} store={store} messages={messages} {...subscribeOption} />
99
112
  );
100
- const Panel: ComponentType<NotificationsPanelProps> = (props) => (
101
- <NotificationsPanel {...props} store={store} messages={messages} />
102
- );
113
+ const Panel = lazyNotificationsPanel({ store, messages });
103
114
 
104
115
  function useBoundUnreadCount(options: { enabled?: boolean } = {}): number {
105
116
  return useUnreadCount(store, { ...options, ...subscribeOption });
@@ -126,9 +137,7 @@ export function createWebNotifications(config: NotificationsWebConfig): WebNotif
126
137
  }
127
138
 
128
139
  return {
129
- page: (props) => (
130
- <PreferencesScreen {...props} api={api} messages={messages} webPush={webPush} />
131
- ),
140
+ page: lazyPreferencesPage({ api, messages, webPush }),
132
141
  BellButton: Bell,
133
142
  Panel,
134
143
  BellWithPanel,
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The routed preferences screen, fetched when a host actually routes to it.
3
+ *
4
+ * `createWebNotifications` returns two different KINDS of thing, and its own
5
+ * docstring says so: `page` is "the standalone surface … the one thing a host
6
+ * routes to", while the bell and the panel "are a PAIR a host drops into its own
7
+ * chrome". Chrome is on screen from the first paint; a routed surface is not.
8
+ *
9
+ * A static import made that distinction invisible to a bundler. Every host that
10
+ * put the bell in its header also shipped the preferences matrix — its channel
11
+ * toggles, the per-browser push enable step, and the design-system `Switch`
12
+ * behind them — in the same chunk as the header. A storefront paid for a
13
+ * settings screen a shopper never opens, before its first screen could render;
14
+ * a host that renders its OWN preferences page paid for this one twice.
15
+ *
16
+ * So `page` now loads on demand. Nothing else moves: the bell, the panel and
17
+ * `BellWithPanel` stay exactly as eager as the chrome they belong to, because
18
+ * that is what they are.
19
+ *
20
+ * NO PREFETCH, deliberately, and this is the opposite call from a surface a
21
+ * host opens from chrome it already has. A routed surface is reached by
22
+ * NAVIGATION, and every host here already code-splits its routes — so the
23
+ * fetch happens while the route is being entered, which is the moment a
24
+ * prefetch would have been trying to anticipate. Warming it at factory time
25
+ * would put the screen back on the boot path of every app, which is the whole
26
+ * cost this removes.
27
+ */
28
+ import { Suspense, lazy, type ComponentType, type JSX } from 'react';
29
+
30
+ import type { NotificationMessages } from '../messages';
31
+
32
+ import type { NotificationsApiClient } from './api';
33
+ import type { PreferencesScreenProps } from './preferences-screen';
34
+ import type { WebPushSetupConfig } from './web-push-setup';
35
+
36
+ /** What the factory binds into the screen, and the host never passes. */
37
+ interface PreferencesPageParts {
38
+ api: NotificationsApiClient;
39
+ messages: NotificationMessages;
40
+ webPush: WebPushSetupConfig;
41
+ }
42
+
43
+ /**
44
+ * The routed screen, bound and loaded on first render.
45
+ *
46
+ * `lazy` memoises its factory, so the binding below happens once however many
47
+ * times a host mounts the page — the same guarantee the direct call gave.
48
+ *
49
+ * The fallback is `null` because a host routes to this: whatever it renders
50
+ * around the route is already on screen, and a second spinner inside it would
51
+ * be one more thing appearing and disappearing during a navigation the host is
52
+ * already indicating.
53
+ */
54
+ export function lazyPreferencesPage(
55
+ parts: PreferencesPageParts,
56
+ ): ComponentType<PreferencesScreenProps> {
57
+ const Bound = lazy(async () => {
58
+ const { PreferencesScreen } = await import('./preferences-screen');
59
+ return {
60
+ default: (props: PreferencesScreenProps): JSX.Element => (
61
+ <PreferencesScreen {...props} {...parts} />
62
+ ),
63
+ };
64
+ });
65
+
66
+ return function NotificationsPreferencesPage(props: PreferencesScreenProps): JSX.Element {
67
+ return (
68
+ <Suspense fallback={null}>
69
+ <Bound {...props} />
70
+ </Suspense>
71
+ );
72
+ };
73
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The inbox slide-over, fetched the first time somebody opens it.
3
+ *
4
+ * The bell and the panel are a PAIR a host drops into its chrome, and that is
5
+ * still true — but only the BELL is on screen when a page paints. The panel is
6
+ * behind a tap, and a static import made every host pay for it up front: the
7
+ * design-system `Drawer` and, through it, MUI's `SwipeableDrawer`, `Modal`,
8
+ * `Slide` and the focus trap, plus the row, the empty state and the pager. On a
9
+ * storefront that is a slide-over most visits never open, parsed before the
10
+ * first screen can render.
11
+ *
12
+ * ## Why the gate is "ever opened" rather than `open`
13
+ *
14
+ * `lazy` fetches when a component first RENDERS, so a boundary that still
15
+ * rendered the panel while closed would fetch immediately and buy nothing. This
16
+ * renders `null` until the panel has been open once, which is what actually
17
+ * defers the download to the tap.
18
+ *
19
+ * And once opened it STAYS mounted. Unmounting on close would throw away the
20
+ * drawer's transition state, so the panel would vanish instead of sliding out,
21
+ * and the entrance animation would re-run on every reopen — which someone
22
+ * working through an inbox does repeatedly. The fetch happens once.
23
+ *
24
+ * The initial state reads `open` rather than starting at `false`, so a host that
25
+ * mounts the panel already open renders it in the same commit instead of a frame
26
+ * later.
27
+ *
28
+ * ## Why `null` for the fallback
29
+ *
30
+ * The only frame this can show anything is the one right after the tap, where a
31
+ * spinner reads as a stall rather than as progress. The chunk is small and
32
+ * same-origin.
33
+ */
34
+ import { Suspense, lazy, useEffect, useState, type ComponentType, type JSX } from 'react';
35
+
36
+ import type { NotificationMessages } from '../messages';
37
+
38
+ import type { InboxStore } from './inbox-state';
39
+ import type { NotificationsPanelProps } from './panel';
40
+
41
+ /** What the factory binds into the panel, and the host never passes. */
42
+ interface PanelParts {
43
+ store: InboxStore;
44
+ messages: NotificationMessages;
45
+ }
46
+
47
+ export function lazyNotificationsPanel(
48
+ parts: PanelParts,
49
+ ): ComponentType<NotificationsPanelProps> {
50
+ const Bound = lazy(async () => {
51
+ const { NotificationsPanel } = await import('./panel');
52
+ return {
53
+ default: (props: NotificationsPanelProps): JSX.Element => (
54
+ <NotificationsPanel {...props} {...parts} />
55
+ ),
56
+ };
57
+ });
58
+
59
+ return function NotificationsPanelSlot(props: NotificationsPanelProps): JSX.Element | null {
60
+ const [everOpened, setEverOpened] = useState(props.open);
61
+
62
+ useEffect(() => {
63
+ if (props.open) setEverOpened(true);
64
+ }, [props.open]);
65
+
66
+ if (!everOpened) return null;
67
+
68
+ return (
69
+ <Suspense fallback={null}>
70
+ <Bound {...props} />
71
+ </Suspense>
72
+ );
73
+ };
74
+ }