@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.
- package/dist/chunk-BW723CX2.js +214 -0
- package/dist/chunk-BW723CX2.js.map +1 -0
- package/dist/chunk-CQZMTFPY.js +76 -0
- package/dist/chunk-CQZMTFPY.js.map +1 -0
- package/dist/{chunk-QRAXX3GR.js → chunk-CUZW62JS.js} +2 -2
- package/dist/{chunk-HQU4R4SG.js → chunk-HHMRCMQU.js} +2 -2
- package/dist/chunk-M2TVBVH2.js +15 -0
- package/dist/chunk-M2TVBVH2.js.map +1 -0
- package/dist/chunk-MMLV4EZT.js +263 -0
- package/dist/chunk-MMLV4EZT.js.map +1 -0
- package/dist/chunk-O5BVUXPO.js +22 -0
- package/dist/chunk-O5BVUXPO.js.map +1 -0
- package/dist/{chunk-TIGTBSAQ.js → chunk-WHBMPHQE.js} +6 -4
- package/dist/{chunk-TIGTBSAQ.js.map → chunk-WHBMPHQE.js.map} +1 -1
- package/dist/{chunk-Y34FX24X.js → chunk-XE7HZVMH.js} +2 -10
- package/dist/chunk-XE7HZVMH.js.map +1 -0
- package/dist/{create-web-notifications-BpNR8qH3.d.ts → create-web-notifications-BHCzaU2y.d.ts} +13 -2
- package/dist/hono/index.js +4 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/index.js +5 -3
- package/dist/manifest/server.js +5 -4
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +1 -1
- package/dist/manifest/web.js +3 -2
- package/dist/manifest/web.js.map +1 -1
- package/dist/panel-UFXNO4AF.js +243 -0
- package/dist/panel-UFXNO4AF.js.map +1 -0
- package/dist/preferences-screen-IOW6Y2H2.js +294 -0
- package/dist/preferences-screen-IOW6Y2H2.js.map +1 -0
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.js +17 -11
- package/dist/server/index.js +5 -4
- package/package.json +2 -2
- package/src/react/create-web-notifications.tsx +19 -10
- package/src/react/page-lazy.tsx +73 -0
- package/src/react/panel-lazy.tsx +74 -0
- package/dist/chunk-6HLHQDKS.js +0 -1022
- package/dist/chunk-6HLHQDKS.js.map +0 -1
- package/dist/chunk-Y34FX24X.js.map +0 -1
- /package/dist/{chunk-QRAXX3GR.js.map → chunk-CUZW62JS.js.map} +0 -0
- /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"]}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { N as NotificationsApiClient } from '../create-web-notifications-
|
|
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-
|
|
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';
|
package/dist/react/index.js
CHANGED
|
@@ -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-
|
|
20
|
-
import "../chunk-
|
|
25
|
+
} from "../chunk-BW723CX2.js";
|
|
26
|
+
import "../chunk-M2TVBVH2.js";
|
|
21
27
|
import "../chunk-7QVYU63E.js";
|
|
22
28
|
export {
|
|
23
29
|
BADGE_POLL_MS,
|
package/dist/server/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
NOTIFICATIONS_JOBS,
|
|
5
5
|
NOTIFICATIONS_SWEEP_QUEUE,
|
|
6
6
|
wireNotifyPort
|
|
7
|
-
} from "../chunk-
|
|
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-
|
|
29
|
-
import "../chunk-
|
|
30
|
-
import "../chunk-
|
|
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.
|
|
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.
|
|
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 {
|
|
14
|
-
import {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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: (
|
|
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
|
+
}
|