@12-apps/notifications 4.7.1 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/ADOPTING.md +145 -0
  2. package/README.md +65 -0
  3. package/dist/catalog-BOVZ2b-b.d.ts +132 -0
  4. package/dist/{chunk-XE7HZVMH.js → chunk-BGI4BQ66.js} +1 -1
  5. package/dist/chunk-BGI4BQ66.js.map +1 -0
  6. package/dist/chunk-EKUSNUBT.js +222 -0
  7. package/dist/chunk-EKUSNUBT.js.map +1 -0
  8. package/dist/chunk-FTFLU6KH.js +114 -0
  9. package/dist/chunk-FTFLU6KH.js.map +1 -0
  10. package/dist/{chunk-HHMRCMQU.js → chunk-GGAZOLNN.js} +2 -2
  11. package/dist/{chunk-CUZW62JS.js → chunk-KU4FVOFA.js} +2 -2
  12. package/dist/{chunk-WHBMPHQE.js → chunk-KZG7QGFA.js} +28 -6
  13. package/dist/chunk-KZG7QGFA.js.map +1 -0
  14. package/dist/{chunk-MMLV4EZT.js → chunk-M5XTROYM.js} +2 -2
  15. package/dist/chunk-ZFIYBNZ7.js +502 -0
  16. package/dist/chunk-ZFIYBNZ7.js.map +1 -0
  17. package/dist/{create-api-notifications-B3u6Kx3x.d.ts → create-api-notifications-B-VMezVB.d.ts} +43 -4
  18. package/dist/{create-web-notifications-BHCzaU2y.d.ts → create-web-notifications-B-xQ6R0B.d.ts} +2 -2
  19. package/dist/email/index.d.ts +26 -0
  20. package/dist/email/index.js +20 -0
  21. package/dist/email/index.js.map +1 -0
  22. package/dist/email/locales.d.ts +43 -0
  23. package/dist/email/locales.js +29 -0
  24. package/dist/email/locales.js.map +1 -0
  25. package/dist/email/previews/hono.d.ts +46 -0
  26. package/dist/email/previews/hono.js +33 -0
  27. package/dist/email/previews/hono.js.map +1 -0
  28. package/dist/email/previews/index.d.ts +67 -0
  29. package/dist/email/previews/index.js +12 -0
  30. package/dist/email/previews/index.js.map +1 -0
  31. package/dist/email/previews/react/index.d.ts +43 -0
  32. package/dist/email/previews/react/index.js +58 -0
  33. package/dist/email/previews/react/index.js.map +1 -0
  34. package/dist/{generators-B9xt3sRh.d.ts → generators-BsuXe8Mp.d.ts} +1 -1
  35. package/dist/hono/index.d.ts +6 -5
  36. package/dist/hono/index.js +4 -3
  37. package/dist/hono/index.js.map +1 -1
  38. package/dist/index.d.ts +3 -3
  39. package/dist/index.js +5 -5
  40. package/dist/{jobs-BDZ7aGHV.d.ts → jobs-D64B_bg7.d.ts} +1 -1
  41. package/dist/manifest/index.d.ts +39 -1
  42. package/dist/manifest/index.js +9 -0
  43. package/dist/manifest/index.js.map +1 -1
  44. package/dist/manifest/server.d.ts +32 -7
  45. package/dist/manifest/server.js +15 -4
  46. package/dist/manifest/server.js.map +1 -1
  47. package/dist/manifest/web.d.ts +18 -4
  48. package/dist/manifest/web.js +9 -1
  49. package/dist/manifest/web.js.map +1 -1
  50. package/dist/{preferences-screen-IOW6Y2H2.js → preferences-screen-V3ZBWP7A.js} +2 -2
  51. package/dist/preview-screen-DYJRAnAY.d.ts +83 -0
  52. package/dist/react/index.d.ts +4 -4
  53. package/dist/react/index.js +10 -10
  54. package/dist/server/index.d.ts +8 -7
  55. package/dist/server/index.js +5 -4
  56. package/dist/template-Dlt5S54z.d.ts +196 -0
  57. package/dist/{types-CXLAG3UU.d.ts → types-BFmAY0P5.d.ts} +39 -2
  58. package/dist/web-push/index.d.ts +2 -2
  59. package/dist/{web-push-Cs14Wp9u.d.ts → web-push-D-ysQd5h.d.ts} +1 -1
  60. package/dist/{wire-6dzyfDE7.d.ts → wire-_coV3tJk.d.ts} +1 -1
  61. package/package.json +23 -3
  62. package/src/email/chrome.en-US.ts +8 -0
  63. package/src/email/chrome.pt-BR.ts +21 -0
  64. package/src/email/index.ts +43 -0
  65. package/src/email/locales.ts +29 -0
  66. package/src/email/previews/catalog.ts +208 -0
  67. package/src/email/previews/hono.ts +69 -0
  68. package/src/email/previews/index.ts +26 -0
  69. package/src/email/previews/react/copy.en-US.ts +24 -0
  70. package/src/email/previews/react/copy.pt-BR.ts +24 -0
  71. package/src/email/previews/react/copy.ts +42 -0
  72. package/src/email/previews/react/index.tsx +14 -0
  73. package/src/email/previews/react/loadable.tsx +83 -0
  74. package/src/email/previews/react/message-list.tsx +133 -0
  75. package/src/email/previews/react/message-view.tsx +167 -0
  76. package/src/email/previews/react/preview-screen.tsx +342 -0
  77. package/src/email/previews/react/transport.ts +60 -0
  78. package/src/email/previews/routes.ts +111 -0
  79. package/src/email/template.ts +365 -0
  80. package/src/email/text.ts +59 -0
  81. package/src/email/theme.ts +77 -0
  82. package/src/index.ts +1 -0
  83. package/src/manifest/index.ts +37 -0
  84. package/src/manifest/server.ts +23 -0
  85. package/src/manifest/web.ts +13 -0
  86. package/src/server/db.ts +12 -1
  87. package/src/server/dispatch.ts +4 -0
  88. package/src/server/router.ts +17 -1
  89. package/src/server/transports/email.ts +62 -0
  90. package/src/types.ts +39 -1
  91. package/dist/chunk-WHBMPHQE.js.map +0 -1
  92. package/dist/chunk-XE7HZVMH.js.map +0 -1
  93. /package/dist/{chunk-HHMRCMQU.js.map → chunk-GGAZOLNN.js.map} +0 -0
  94. /package/dist/{chunk-CUZW62JS.js.map → chunk-KU4FVOFA.js.map} +0 -0
  95. /package/dist/{chunk-MMLV4EZT.js.map → chunk-M5XTROYM.js.map} +0 -0
  96. /package/dist/{preferences-screen-IOW6Y2H2.js.map → preferences-screen-V3ZBWP7A.js.map} +0 -0
@@ -0,0 +1,83 @@
1
+ import type { JSX } from 'react';
2
+ import { useEffect, useState } from 'react';
3
+
4
+ import { Alert } from '@12-apps/ui/data-display/Alert';
5
+ import { Button } from '@12-apps/ui/form/Button';
6
+ import { Box } from '@12-apps/ui/mui/Box';
7
+ import { Text } from '@12-apps/ui/typography/Text';
8
+
9
+ import type { EmailPreviewScreenCopy } from './copy';
10
+
11
+ /**
12
+ * The screen's async plumbing: one hook, and the state it cannot render itself.
13
+ *
14
+ * Its own module because it is the half with no opinion about e-mail. What is
15
+ * left in `preview-screen.tsx` is composition — which columns, which panes,
16
+ * what the URL says — and this is the machinery underneath all of it.
17
+ *
18
+ * `keepPrevious` is the load-bearing option and its docblock says why: a
19
+ * consumer that renders children only while `data` is non-null gets those
20
+ * children UNMOUNTED by a blanking reload, which silently discards whatever
21
+ * state they held.
22
+ */
23
+
24
+ /** A load that can fail, in the two states a screen has to render. */
25
+ interface Loadable<T> {
26
+ data: T | null;
27
+ error: string | null;
28
+ }
29
+
30
+ export function useLoadable<T>(
31
+ load: () => Promise<T>,
32
+ options: { keepPrevious?: boolean } = {},
33
+ ): Loadable<T> & { reload: () => void } {
34
+ const [state, setState] = useState<Loadable<T>>({ data: null, error: null });
35
+ const [nonce, setNonce] = useState(0);
36
+ const { keepPrevious = false } = options;
37
+ useEffect(() => {
38
+ let live = true;
39
+ // `keepPrevious` holds the last good answer on screen while the next one is
40
+ // in flight, and it is not a nicety. The consumer of this hook renders its
41
+ // children only while `data` is non-null, so blanking here UNMOUNTS them —
42
+ // taking the filter text, the open tab and the chosen width with it. On a
43
+ // fast connection the refetch lands before anyone notices; on a slow one
44
+ // the operator watches what they just typed disappear.
45
+ setState((previous) => (keepPrevious ? { ...previous, error: null } : { data: null, error: null }));
46
+ load()
47
+ .then((data) => live && setState({ data, error: null }))
48
+ .catch(
49
+ (error: unknown) =>
50
+ live &&
51
+ setState({ data: null, error: error instanceof Error ? error.message : String(error) }),
52
+ );
53
+ return () => {
54
+ // A language switched twice in a second must not let the FIRST answer
55
+ // land last — the screen would show a document the operator did not ask
56
+ // for, with the toggle disagreeing.
57
+ live = false;
58
+ };
59
+ }, [load, nonce]);
60
+ return { ...state, reload: () => setNonce((n) => n + 1) };
61
+ }
62
+
63
+ export function Failure({
64
+ message,
65
+ copy,
66
+ onRetry,
67
+ }: {
68
+ message: string;
69
+ copy: EmailPreviewScreenCopy;
70
+ onRetry: () => void;
71
+ }): JSX.Element {
72
+ return (
73
+ <Box data-testid="email-preview-error" sx={{ display: 'flex', flexDirection: 'column', gap: 1, alignItems: 'flex-start' }}>
74
+ <Alert severity="error">
75
+ <Text as="p" size="sm">{copy.loadError}</Text>
76
+ <Text as="p" size="sm">{message}</Text>
77
+ </Alert>
78
+ <Button size="sm" variant="outline" onClick={onRetry}>
79
+ {copy.retry}
80
+ </Button>
81
+ </Box>
82
+ );
83
+ }
@@ -0,0 +1,133 @@
1
+ import type { JSX } from 'react';
2
+
3
+ import { Chip } from '@12-apps/ui/data-display/Chip';
4
+ import { Box } from '@12-apps/ui/mui/Box';
5
+ import { Text } from '@12-apps/ui/typography/Text';
6
+
7
+ import type { EmailPreviewRow } from '../catalog';
8
+
9
+ import type { EmailPreviewScreenCopy } from './copy';
10
+
11
+ /**
12
+ * The catalogue, grouped by the PACKAGE that owns each message.
13
+ *
14
+ * Grouping by owner rather than by family is the whole answer to "which parts
15
+ * of this system send mail": the section headers ARE that list, derived from
16
+ * the rows the surface sent rather than written down anywhere, so a package
17
+ * that starts sending mail appears the day it does.
18
+ *
19
+ * The family stays visible as a chip on each row, because it is the other
20
+ * question an operator asks — and one owner can span two families.
21
+ */
22
+
23
+ interface OwnerGroup {
24
+ owner: string;
25
+ rows: EmailPreviewRow[];
26
+ }
27
+
28
+ /** Group in FIRST-SEEN order, so the list does not reshuffle between renders. */
29
+ function groupByOwner(rows: readonly EmailPreviewRow[]): OwnerGroup[] {
30
+ const groups = new Map<string, EmailPreviewRow[]>();
31
+ for (const row of rows) {
32
+ const bucket = groups.get(row.owner);
33
+ if (bucket) bucket.push(row);
34
+ else groups.set(row.owner, [row]);
35
+ }
36
+ return [...groups].map(([owner, ownerRows]) => ({ owner, rows: ownerRows }));
37
+ }
38
+
39
+ /** Does this row match what was typed? Subject, key, owner and family all count. */
40
+ export function matchesFilter(row: EmailPreviewRow, filter: string): boolean {
41
+ const needle = filter.trim().toLowerCase();
42
+ if (needle === '') return true;
43
+ return [row.subject, row.key, row.owner, row.family].some((field) =>
44
+ field.toLowerCase().includes(needle),
45
+ );
46
+ }
47
+
48
+ function MessageRow({
49
+ row,
50
+ selected,
51
+ onSelect,
52
+ }: {
53
+ row: EmailPreviewRow;
54
+ selected: boolean;
55
+ onSelect: (id: string) => void;
56
+ }): JSX.Element {
57
+ return (
58
+ <Box
59
+ component="button"
60
+ type="button"
61
+ data-testid={`email-preview-row-${row.id}`}
62
+ aria-current={selected}
63
+ onClick={() => onSelect(row.id)}
64
+ sx={{
65
+ appearance: 'none',
66
+ textAlign: 'left',
67
+ width: '100%',
68
+ cursor: 'pointer',
69
+ border: '1px solid',
70
+ borderColor: selected ? 'primary.main' : 'divider',
71
+ background: selected ? 'action.selected' : 'background.paper',
72
+ borderRadius: 1.5,
73
+ p: 1.25,
74
+ display: 'flex',
75
+ flexDirection: 'column',
76
+ gap: 0.5,
77
+ }}
78
+ >
79
+ <Text as="span" size="sm" weight="medium">
80
+ {row.subject}
81
+ </Text>
82
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
83
+ <Chip label={row.family} size="sm" variant="outlined" color="neutral" />
84
+ <Text as="span" size="xs" color="secondary">
85
+ {row.key}
86
+ </Text>
87
+ </Box>
88
+ </Box>
89
+ );
90
+ }
91
+
92
+ export function MessageList({
93
+ rows,
94
+ selectedId,
95
+ copy,
96
+ onSelect,
97
+ }: {
98
+ rows: readonly EmailPreviewRow[];
99
+ selectedId: string | null;
100
+ copy: EmailPreviewScreenCopy;
101
+ onSelect: (id: string) => void;
102
+ }): JSX.Element {
103
+ if (rows.length === 0) {
104
+ return (
105
+ <Text as="p" size="sm" color="secondary" data-testid="email-preview-no-matches">
106
+ {copy.noMatches}
107
+ </Text>
108
+ );
109
+ }
110
+ return (
111
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
112
+ {groupByOwner(rows).map((group) => (
113
+ <Box
114
+ key={group.owner}
115
+ data-testid={`email-preview-owner-${group.owner}`}
116
+ sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}
117
+ >
118
+ <Text as="p" size="xs" weight="medium" color="secondary">
119
+ {group.owner}
120
+ </Text>
121
+ {group.rows.map((row) => (
122
+ <MessageRow
123
+ key={row.id}
124
+ row={row}
125
+ selected={row.id === selectedId}
126
+ onSelect={onSelect}
127
+ />
128
+ ))}
129
+ </Box>
130
+ ))}
131
+ </Box>
132
+ );
133
+ }
@@ -0,0 +1,167 @@
1
+ import type { JSX } from 'react';
2
+
3
+ import { ToggleGroup } from '@12-apps/ui/form/ToggleGroup';
4
+ import { Box } from '@12-apps/ui/mui/Box';
5
+ import { Text } from '@12-apps/ui/typography/Text';
6
+
7
+ import type { EmailPreviewDetail } from '../catalog';
8
+
9
+ import type { EmailPreviewScreenCopy } from './copy';
10
+
11
+ /**
12
+ * One rendered message, in the three ways it is worth looking at.
13
+ *
14
+ * - **HTML** — what most people will see, in a sandboxed frame at either of the
15
+ * two widths that matter. A phone is not a nice-to-have: more than half of
16
+ * transactional mail is opened on one, and the 600px card is exactly the
17
+ * thing that either survives that or does not.
18
+ * - **Text** — the plain-text twin. Worth its own tab because it is what a spam
19
+ * filter scores, what a watch shows, and what a screen reader in plain-text
20
+ * mode reads — and because it is the half nobody ever looks at, which is how
21
+ * it drifts out of step with the HTML.
22
+ * - **Source** — the markup itself, for the moment somebody is debugging why a
23
+ * client rendered it oddly.
24
+ *
25
+ * ## Why an iframe, and why sandboxed
26
+ *
27
+ * The mail is a whole document with its own `<body>` background, and rendering
28
+ * that inside the console's DOM would both break the mail (the console's CSS
29
+ * reaches it) and break the console (the mail's body styles reach the page). A
30
+ * frame is the only honest preview.
31
+ *
32
+ * `sandbox=""` — no scripts, no forms, no top-level navigation. These documents
33
+ * come from the host's own renderer and carry no script, so this is less a
34
+ * containment measure than a statement that the preview is INERT: a click on a
35
+ * CTA inside a previewed mail must never navigate the operator anywhere, least
36
+ * of all to a sample verification link.
37
+ */
38
+
39
+ /** The two widths the HTML view renders at. */
40
+ export type PreviewWidth = 'desktop' | 'mobile';
41
+
42
+ /** Which of the three views is showing. */
43
+ export type PreviewTab = 'html' | 'text' | 'source';
44
+
45
+ /** A phone. 390px is a common iPhone CSS width, and among the narrowest. */
46
+ const MOBILE_WIDTH = 390;
47
+
48
+ function Monospace({ children, testId }: { children: string; testId: string }): JSX.Element {
49
+ return (
50
+ <Box
51
+ component="pre"
52
+ data-testid={testId}
53
+ sx={{
54
+ m: 0,
55
+ p: 2,
56
+ borderRadius: 1.5,
57
+ border: '1px solid',
58
+ borderColor: 'divider',
59
+ background: 'background.default',
60
+ fontSize: 13,
61
+ lineHeight: 1.6,
62
+ whiteSpace: 'pre-wrap',
63
+ wordBreak: 'break-word',
64
+ maxHeight: 720,
65
+ overflow: 'auto',
66
+ }}
67
+ >
68
+ {children}
69
+ </Box>
70
+ );
71
+ }
72
+
73
+ function HtmlFrame({
74
+ detail,
75
+ width,
76
+ title,
77
+ }: {
78
+ detail: EmailPreviewDetail;
79
+ width: PreviewWidth;
80
+ title: string;
81
+ }): JSX.Element {
82
+ return (
83
+ <Box
84
+ sx={{
85
+ display: 'flex',
86
+ justifyContent: 'center',
87
+ borderRadius: 1.5,
88
+ border: '1px solid',
89
+ borderColor: 'divider',
90
+ overflow: 'hidden',
91
+ }}
92
+ >
93
+ <Box
94
+ component="iframe"
95
+ data-testid="email-preview-frame"
96
+ title={title}
97
+ srcDoc={detail.html}
98
+ sandbox=""
99
+ sx={{
100
+ border: 0,
101
+ width: width === 'mobile' ? MOBILE_WIDTH : '100%',
102
+ height: 760,
103
+ background: '#fff',
104
+ }}
105
+ />
106
+ </Box>
107
+ );
108
+ }
109
+
110
+ interface MessageViewProps {
111
+ detail: EmailPreviewDetail;
112
+ copy: EmailPreviewScreenCopy;
113
+ tab: PreviewTab;
114
+ width: PreviewWidth;
115
+ onTabChange: (tab: PreviewTab) => void;
116
+ onWidthChange: (width: PreviewWidth) => void;
117
+ }
118
+
119
+ export function MessageView(props: MessageViewProps): JSX.Element {
120
+ const { detail, copy, tab, width, onTabChange, onWidthChange } = props;
121
+ return (
122
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }} data-testid="email-preview-view">
123
+ <Box>
124
+ <Text as="p" size="xs" color="secondary">
125
+ {copy.subjectLabel}
126
+ </Text>
127
+ <Text as="p" size="md" weight="medium" data-testid="email-preview-subject">
128
+ {detail.subject}
129
+ </Text>
130
+ </Box>
131
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>
132
+ <ToggleGroup
133
+ dataTestId="email-preview-tabs"
134
+ exclusive
135
+ value={tab}
136
+ size="sm"
137
+ options={[
138
+ { value: 'html', label: copy.tabHtml },
139
+ { value: 'text', label: copy.tabText },
140
+ { value: 'source', label: copy.tabSource },
141
+ ]}
142
+ onChange={(_event, value) => {
143
+ if (value) onTabChange(value as PreviewTab);
144
+ }}
145
+ />
146
+ {tab === 'html' ? (
147
+ <ToggleGroup
148
+ dataTestId="email-preview-width"
149
+ exclusive
150
+ value={width}
151
+ size="sm"
152
+ options={[
153
+ { value: 'desktop', label: copy.widthDesktop },
154
+ { value: 'mobile', label: copy.widthMobile },
155
+ ]}
156
+ onChange={(_event, value) => {
157
+ if (value) onWidthChange(value as PreviewWidth);
158
+ }}
159
+ />
160
+ ) : null}
161
+ </Box>
162
+ {tab === 'html' ? <HtmlFrame detail={detail} width={width} title={copy.frameTitle} /> : null}
163
+ {tab === 'text' ? <Monospace testId="email-preview-text">{detail.text}</Monospace> : null}
164
+ {tab === 'source' ? <Monospace testId="email-preview-source">{detail.html}</Monospace> : null}
165
+ </Box>
166
+ );
167
+ }