@12-apps/notifications 4.8.0 → 4.10.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 (114) hide show
  1. package/ADOPTING.md +173 -1
  2. package/README.md +138 -0
  3. package/dist/catalog-BOVZ2b-b.d.ts +132 -0
  4. package/dist/{chunk-M5XTROYM.js → chunk-5Y7QRORV.js} +8 -4
  5. package/dist/chunk-5Y7QRORV.js.map +1 -0
  6. package/dist/{chunk-BGI4BQ66.js → chunk-6W7INOYQ.js} +1 -1
  7. package/dist/chunk-6W7INOYQ.js.map +1 -0
  8. package/dist/chunk-EKUSNUBT.js +222 -0
  9. package/dist/chunk-EKUSNUBT.js.map +1 -0
  10. package/dist/chunk-FTFLU6KH.js +114 -0
  11. package/dist/chunk-FTFLU6KH.js.map +1 -0
  12. package/dist/chunk-JCVRQ42B.js +226 -0
  13. package/dist/chunk-JCVRQ42B.js.map +1 -0
  14. package/dist/chunk-RTURLH5U.js +33 -0
  15. package/dist/chunk-RTURLH5U.js.map +1 -0
  16. package/dist/{chunk-GGAZOLNN.js → chunk-SWOWHIFE.js} +2 -2
  17. package/dist/{chunk-7234ROOP.js → chunk-WVRODNXQ.js} +28 -6
  18. package/dist/chunk-WVRODNXQ.js.map +1 -0
  19. package/dist/chunk-ZFIYBNZ7.js +502 -0
  20. package/dist/chunk-ZFIYBNZ7.js.map +1 -0
  21. package/dist/{chunk-KU4FVOFA.js → chunk-ZIR3ILFH.js} +2 -2
  22. package/dist/{create-api-notifications-WaexrZnn.d.ts → create-api-notifications-CcPYrM3p.d.ts} +35 -4
  23. package/dist/{create-web-notifications-DrgqtU4Q.d.ts → create-web-notifications-_NVYmlvy.d.ts} +89 -4
  24. package/dist/email/index.d.ts +26 -0
  25. package/dist/email/index.js +20 -0
  26. package/dist/email/index.js.map +1 -0
  27. package/dist/email/locales.d.ts +43 -0
  28. package/dist/email/locales.js +29 -0
  29. package/dist/email/locales.js.map +1 -0
  30. package/dist/email/previews/hono.d.ts +46 -0
  31. package/dist/email/previews/hono.js +33 -0
  32. package/dist/email/previews/hono.js.map +1 -0
  33. package/dist/email/previews/index.d.ts +67 -0
  34. package/dist/email/previews/index.js +12 -0
  35. package/dist/email/previews/index.js.map +1 -0
  36. package/dist/email/previews/react/index.d.ts +43 -0
  37. package/dist/email/previews/react/index.js +58 -0
  38. package/dist/email/previews/react/index.js.map +1 -0
  39. package/dist/{generators-CH5m8Ucj.d.ts → generators-qAD4fNPq.d.ts} +1 -1
  40. package/dist/hono/index.d.ts +6 -5
  41. package/dist/hono/index.js +5 -3
  42. package/dist/hono/index.js.map +1 -1
  43. package/dist/index.d.ts +4 -3
  44. package/dist/index.js +15 -5
  45. package/dist/{jobs-CcsSA0YR.d.ts → jobs--fex87-q.d.ts} +1 -1
  46. package/dist/live-DYxEFO49.d.ts +158 -0
  47. package/dist/manifest/index.d.ts +39 -1
  48. package/dist/manifest/index.js +9 -0
  49. package/dist/manifest/index.js.map +1 -1
  50. package/dist/manifest/server.d.ts +32 -7
  51. package/dist/manifest/server.js +16 -4
  52. package/dist/manifest/server.js.map +1 -1
  53. package/dist/manifest/web.d.ts +19 -4
  54. package/dist/manifest/web.js +9 -1
  55. package/dist/manifest/web.js.map +1 -1
  56. package/dist/{panel-UFXNO4AF.js → panel-T36JEMO3.js} +37 -13
  57. package/dist/panel-T36JEMO3.js.map +1 -0
  58. package/dist/{preferences-screen-V3ZBWP7A.js → preferences-screen-SXUIGECY.js} +2 -2
  59. package/dist/preview-screen-DYJRAnAY.d.ts +83 -0
  60. package/dist/react/index.d.ts +55 -6
  61. package/dist/react/index.js +16 -10
  62. package/dist/server/index.d.ts +8 -7
  63. package/dist/server/index.js +6 -4
  64. package/dist/template-Dlt5S54z.d.ts +196 -0
  65. package/dist/{types-CYJN1xCM.d.ts → types-BlqZkCWZ.d.ts} +11 -2
  66. package/dist/web-push/index.d.ts +2 -2
  67. package/dist/{web-push-iER5StBm.d.ts → web-push-Dnyaha2z.d.ts} +21 -1
  68. package/dist/{wire-CmRrB3_s.d.ts → wire-BG1kuoXX.d.ts} +1 -1
  69. package/package.json +22 -2
  70. package/src/email/chrome.en-US.ts +8 -0
  71. package/src/email/chrome.pt-BR.ts +21 -0
  72. package/src/email/index.ts +43 -0
  73. package/src/email/locales.ts +29 -0
  74. package/src/email/previews/catalog.ts +208 -0
  75. package/src/email/previews/hono.ts +69 -0
  76. package/src/email/previews/index.ts +26 -0
  77. package/src/email/previews/react/copy.en-US.ts +24 -0
  78. package/src/email/previews/react/copy.pt-BR.ts +24 -0
  79. package/src/email/previews/react/copy.ts +42 -0
  80. package/src/email/previews/react/index.tsx +14 -0
  81. package/src/email/previews/react/loadable.tsx +83 -0
  82. package/src/email/previews/react/message-list.tsx +133 -0
  83. package/src/email/previews/react/message-view.tsx +167 -0
  84. package/src/email/previews/react/preview-screen.tsx +342 -0
  85. package/src/email/previews/react/transport.ts +60 -0
  86. package/src/email/previews/routes.ts +111 -0
  87. package/src/email/template.ts +365 -0
  88. package/src/email/text.ts +59 -0
  89. package/src/email/theme.ts +77 -0
  90. package/src/index.ts +10 -0
  91. package/src/live.ts +179 -0
  92. package/src/manifest/index.ts +37 -0
  93. package/src/manifest/server.ts +23 -0
  94. package/src/manifest/web.ts +13 -0
  95. package/src/react/create-web-notifications.tsx +15 -1
  96. package/src/react/index.ts +29 -0
  97. package/src/react/live-card.tsx +279 -0
  98. package/src/react/live-config.ts +79 -0
  99. package/src/react/live-section.tsx +141 -0
  100. package/src/react/panel-lazy.tsx +3 -0
  101. package/src/react/panel.tsx +57 -10
  102. package/src/react/relative-time.ts +14 -2
  103. package/src/server/transports/email.ts +62 -0
  104. package/src/server/transports/web-push.ts +22 -0
  105. package/src/types.ts +10 -1
  106. package/dist/chunk-7234ROOP.js.map +0 -1
  107. package/dist/chunk-BGI4BQ66.js.map +0 -1
  108. package/dist/chunk-M5XTROYM.js.map +0 -1
  109. package/dist/chunk-O5BVUXPO.js +0 -22
  110. package/dist/chunk-O5BVUXPO.js.map +0 -1
  111. package/dist/panel-UFXNO4AF.js.map +0 -1
  112. /package/dist/{chunk-GGAZOLNN.js.map → chunk-SWOWHIFE.js.map} +0 -0
  113. /package/dist/{chunk-KU4FVOFA.js.map → chunk-ZIR3ILFH.js.map} +0 -0
  114. /package/dist/{preferences-screen-V3ZBWP7A.js.map → preferences-screen-SXUIGECY.js.map} +0 -0
@@ -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
+ }
@@ -0,0 +1,342 @@
1
+ import { useCallback, useMemo, useState, type JSX } from 'react';
2
+
3
+ import { Alert } from '@12-apps/ui/data-display/Alert';
4
+ import { Input } from '@12-apps/ui/form/Input';
5
+ import { ToggleGroup } from '@12-apps/ui/form/ToggleGroup';
6
+ import { Box } from '@12-apps/ui/mui/Box';
7
+ import { Heading } from '@12-apps/ui/typography/Heading';
8
+ import { Text } from '@12-apps/ui/typography/Text';
9
+
10
+ import type { EmailPreviewCoverage, EmailPreviewDetail, EmailPreviewIndex } from '../catalog';
11
+
12
+ import type { EmailPreviewScreenCopy } from './copy';
13
+ import { Failure, useLoadable } from './loadable';
14
+ import { MessageList, matchesFilter } from './message-list';
15
+ import { MessageView, type PreviewTab, type PreviewWidth } from './message-view';
16
+ import { fetchEmailPreview, fetchEmailPreviewIndex } from './transport';
17
+
18
+ /**
19
+ * The operator screen over the `./email/previews` catalogue.
20
+ *
21
+ * ## What a host supplies, and what it does not
22
+ *
23
+ * `apiBase` — where the routes are mounted — and `copy`, by name. Nothing else:
24
+ * the owners, the languages, the messages and their subjects all arrive from
25
+ * the surface, because they are facts about the host's own mail that no prop
26
+ * could usefully restate.
27
+ *
28
+ * ## The selection lives in the URL, without a router
29
+ *
30
+ * `?id=` and `?locale=`, read and written through `history.replaceState`. A
31
+ * link to one mail in one language is the actual workflow this screen serves —
32
+ * "look at what the reset mail says now" — and local state would make every
33
+ * such conversation a set of instructions instead of a link.
34
+ *
35
+ * Deliberately NOT a router integration: this package cannot know whether a
36
+ * host runs react-router, TanStack Router or a framework's own, and a screen
37
+ * that imported one would be unmountable in the other two. `replaceState` is
38
+ * the one API all of them are built on, and `replace` rather than `push` so
39
+ * browsing twenty mails is not twenty back-button steps.
40
+ */
41
+
42
+ export interface EmailPreviewScreenConfig {
43
+ /** Where the routes are mounted, e.g. `/api/platform/email-previews`. */
44
+ readonly apiBase: string;
45
+ /** The screen's words. REQUIRED — see `./copy`. */
46
+ readonly copy: EmailPreviewScreenCopy;
47
+ }
48
+
49
+ /** Read one search param without assuming a router owns the URL. */
50
+ function searchParam(name: string): string | null {
51
+ if (typeof window === 'undefined') return null;
52
+ return new URLSearchParams(window.location.search).get(name);
53
+ }
54
+
55
+ /**
56
+ * Patch the query string in place, keeping everything else about the URL.
57
+ *
58
+ * The HASH is carried over deliberately, and it is not defensive coding: a host
59
+ * that routes on `location.hash` — the consumer harness does, and so does any
60
+ * SPA served from a static file — would otherwise be navigated off this screen
61
+ * by its own locale switch, because rebuilding the URL from `pathname` alone
62
+ * silently drops the fragment that says which page this is.
63
+ *
64
+ * `replaceState` rather than `pushState` for the reason the screen exists:
65
+ * browsing twenty messages is one place to come back from, not twenty
66
+ * back-button steps.
67
+ */
68
+ function patchSearch(patch: Record<string, string>): void {
69
+ if (typeof window === 'undefined') return;
70
+ const next = new URLSearchParams(window.location.search);
71
+ for (const [key, value] of Object.entries(patch)) next.set(key, value);
72
+ const { pathname, hash } = window.location;
73
+ window.history.replaceState({}, '', `${pathname}?${next.toString()}${hash}`);
74
+ }
75
+
76
+ /** The surface's honest report about what it cannot show. */
77
+ function CoverageNotice({
78
+ coverage,
79
+ copy,
80
+ }: {
81
+ coverage: EmailPreviewCoverage;
82
+ copy: EmailPreviewScreenCopy;
83
+ }): JSX.Element | null {
84
+ if (coverage.missing.length === 0 && coverage.orphan.length === 0) return null;
85
+ return (
86
+ <Alert severity="warning" data-testid="email-preview-coverage">
87
+ <Text as="p" size="sm" weight="medium">
88
+ {copy.coverageTitle}
89
+ </Text>
90
+ {coverage.missing.length > 0 ? (
91
+ <Text as="p" size="sm">
92
+ {copy.missingSamples(coverage.missing.join(', '))}
93
+ </Text>
94
+ ) : null}
95
+ {coverage.orphan.length > 0 ? (
96
+ <Text as="p" size="sm">
97
+ {copy.orphanSamples(coverage.orphan.join(', '))}
98
+ </Text>
99
+ ) : null}
100
+ </Alert>
101
+ );
102
+ }
103
+
104
+ /** The right-hand pane: the selected message, or an invitation to pick one. */
105
+ function PreviewPane({
106
+ apiBase,
107
+ id,
108
+ locale,
109
+ copy,
110
+ tab,
111
+ width,
112
+ onTabChange,
113
+ onWidthChange,
114
+ }: {
115
+ apiBase: string;
116
+ id: string | null;
117
+ locale: string;
118
+ copy: EmailPreviewScreenCopy;
119
+ tab: PreviewTab;
120
+ width: PreviewWidth;
121
+ onTabChange: (tab: PreviewTab) => void;
122
+ onWidthChange: (width: PreviewWidth) => void;
123
+ }): JSX.Element {
124
+ const load = useCallback(
125
+ () =>
126
+ id === null
127
+ ? Promise.resolve(null as EmailPreviewDetail | null)
128
+ : fetchEmailPreview(apiBase, id, locale),
129
+ [apiBase, id, locale],
130
+ );
131
+ const detail = useLoadable(load);
132
+
133
+ if (id === null) {
134
+ return (
135
+ <Text as="p" size="sm" color="secondary" data-testid="email-preview-empty">
136
+ {copy.pickOne}
137
+ </Text>
138
+ );
139
+ }
140
+ if (detail.error !== null) {
141
+ return <Failure message={detail.error} copy={copy} onRetry={detail.reload} />;
142
+ }
143
+ if (detail.data === null) {
144
+ return (
145
+ <Text as="p" size="sm" color="secondary" data-testid="email-preview-detail-loading">
146
+ {copy.loading}
147
+ </Text>
148
+ );
149
+ }
150
+ return (
151
+ <MessageView
152
+ detail={detail.data}
153
+ copy={copy}
154
+ tab={tab}
155
+ width={width}
156
+ onTabChange={onTabChange}
157
+ onWidthChange={onWidthChange}
158
+ />
159
+ );
160
+ }
161
+
162
+ /** The left column: the filter, and the rows under their owners. */
163
+ function CatalogueColumn({
164
+ index,
165
+ copy,
166
+ selectedId,
167
+ onSelect,
168
+ }: {
169
+ index: EmailPreviewIndex;
170
+ copy: EmailPreviewScreenCopy;
171
+ selectedId: string | null;
172
+ onSelect: (id: string) => void;
173
+ }): JSX.Element {
174
+ const [filter, setFilter] = useState('');
175
+ const visible = useMemo(
176
+ () => index.items.filter((row) => matchesFilter(row, filter)),
177
+ [index.items, filter],
178
+ );
179
+ return (
180
+ /*
181
+ The list owns its OWN scroll rather than growing the page.
182
+
183
+ A host's console is typically a fixed-viewport shell whose centre column
184
+ scrolls, and a twenty-mail catalogue then scrolls the preview frame off
185
+ the screen — the two things this screen exists to show side by side
186
+ cannot both be on it. `sticky` keeps the list put while the frame is
187
+ read; the height is the viewport minus the chrome above it, so the column
188
+ ends where the window does rather than at an arbitrary pixel count.
189
+ */
190
+ <Box
191
+ sx={{
192
+ width: 320,
193
+ flexShrink: 0,
194
+ display: 'flex',
195
+ flexDirection: 'column',
196
+ gap: 1.5,
197
+ position: 'sticky',
198
+ top: 0,
199
+ maxHeight: 'calc(100dvh - 220px)',
200
+ minHeight: 240,
201
+ }}
202
+ >
203
+ <Input
204
+ label={copy.searchLabel}
205
+ placeholder={copy.searchPlaceholder}
206
+ value={filter}
207
+ data-testid="email-preview-filter"
208
+ onChange={(event) => setFilter(event.target.value)}
209
+ fullWidth
210
+ />
211
+ {/* Only the ROWS scroll — the filter field stays reachable. */}
212
+ <Box sx={{ overflowY: 'auto', flex: 1, pr: 0.5 }}>
213
+ <MessageList rows={visible} selectedId={selectedId} copy={copy} onSelect={onSelect} />
214
+ </Box>
215
+ </Box>
216
+ );
217
+ }
218
+
219
+ /** The catalogue and the preview, once the index has loaded. */
220
+ function Browser({
221
+ apiBase,
222
+ index,
223
+ copy,
224
+ locale,
225
+ selectedId,
226
+ onPatch,
227
+ }: {
228
+ apiBase: string;
229
+ index: EmailPreviewIndex;
230
+ copy: EmailPreviewScreenCopy;
231
+ locale: string;
232
+ selectedId: string | null;
233
+ onPatch: (patch: Record<string, string>) => void;
234
+ }): JSX.Element {
235
+ const [tab, setTab] = useState<PreviewTab>('html');
236
+ const [width, setWidth] = useState<PreviewWidth>('desktop');
237
+
238
+ return (
239
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
240
+ <CoverageNotice coverage={index.coverage} copy={copy} />
241
+ <ToggleGroup
242
+ dataTestId="email-preview-locale"
243
+ exclusive
244
+ value={locale}
245
+ size="sm"
246
+ options={index.locales.map((tag) => ({ value: tag, label: tag }))}
247
+ onChange={(_event, value) => {
248
+ if (value) onPatch({ locale: String(value) });
249
+ }}
250
+ />
251
+ <Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', flexWrap: 'wrap' }}>
252
+ <CatalogueColumn
253
+ index={index}
254
+ copy={copy}
255
+ selectedId={selectedId}
256
+ onSelect={(id) => onPatch({ id })}
257
+ />
258
+ <Box sx={{ flex: 1, minWidth: 360 }}>
259
+ <PreviewPane
260
+ apiBase={apiBase}
261
+ id={selectedId}
262
+ locale={locale}
263
+ copy={copy}
264
+ tab={tab}
265
+ width={width}
266
+ onTabChange={setTab}
267
+ onWidthChange={setWidth}
268
+ />
269
+ </Box>
270
+ </Box>
271
+ </Box>
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Build the screen. One call, one config object — the shape every factory in
277
+ * this estate has.
278
+ */
279
+ export function createEmailPreviewScreen(config: EmailPreviewScreenConfig): {
280
+ page: () => JSX.Element;
281
+ } {
282
+ const { apiBase, copy } = config;
283
+
284
+ function EmailPreviewsPage(): JSX.Element {
285
+ // The VALUE is deliberately discarded: nothing reads the counter, and the
286
+ // only thing it has to do is change, so React re-renders and the URL is
287
+ // re-read below. It used to be a dependency of the catalogue fetch, which
288
+ // is what made every row click refetch the list.
289
+ const [, setUrlNonce] = useState(0);
290
+ const locale = searchParam('locale') ?? '';
291
+ const selectedId = searchParam('id');
292
+ // The catalogue depends on the LANGUAGE and on nothing else. `urlNonce` is
293
+ // deliberately absent: it counts every URL patch, selection included, and
294
+ // including it here refetched the whole catalogue on each row click — for a
295
+ // list whose contents cannot have changed. What the click actually needs is
296
+ // a re-RENDER, so `selectedId` is re-read, and `setUrlNonce` already does
297
+ // that on its own.
298
+ const load = useCallback(
299
+ () => fetchEmailPreviewIndex(apiBase, locale),
300
+ [apiBase, locale],
301
+ );
302
+ const index = useLoadable(load, { keepPrevious: true });
303
+
304
+ const patch = (next: Record<string, string>): void => {
305
+ patchSearch(next);
306
+ // `replaceState` does not notify React, so the screen re-reads the URL
307
+ // through this counter rather than through a router's own subscription.
308
+ setUrlNonce((n) => n + 1);
309
+ };
310
+
311
+ return (
312
+ <Box data-testid="page-email-previews" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
313
+ <Box>
314
+ <Heading level="h2">{copy.title}</Heading>
315
+ <Text as="p" size="sm" color="secondary">
316
+ {copy.description}
317
+ </Text>
318
+ </Box>
319
+ {index.error !== null ? (
320
+ <Failure message={index.error} copy={copy} onRetry={index.reload} />
321
+ ) : null}
322
+ {index.error === null && index.data === null ? (
323
+ <Text as="p" size="sm" color="secondary" data-testid="email-preview-index-loading">
324
+ {copy.loading}
325
+ </Text>
326
+ ) : null}
327
+ {index.data !== null ? (
328
+ <Browser
329
+ apiBase={apiBase}
330
+ index={index.data}
331
+ copy={copy}
332
+ locale={index.data.locale}
333
+ selectedId={selectedId}
334
+ onPatch={patch}
335
+ />
336
+ ) : null}
337
+ </Box>
338
+ );
339
+ }
340
+
341
+ return { page: EmailPreviewsPage };
342
+ }
@@ -0,0 +1,60 @@
1
+ import type { EmailPreviewDetail, EmailPreviewIndex } from '../catalog';
2
+
3
+ /**
4
+ * How the screen reaches its own endpoints.
5
+ *
6
+ * Plain `fetch` rather than a data library: this package cannot know whether a
7
+ * host runs react-query, SWR or nothing at all, and a screen that dragged one
8
+ * in would put a second cache beside whichever the host already has. The two
9
+ * calls here are a list and a document — neither needs invalidation, retries or
10
+ * shared state, which is most of what a data library is for.
11
+ *
12
+ * Both unwrap the `{ data }` envelope the routes write, and both surface a
13
+ * non-2xx as a thrown `Error` carrying whatever the surface said, so the screen
14
+ * can show the operator the real refusal (an unknown locale, a 403 from the
15
+ * host's own gate) rather than a generic failure.
16
+ */
17
+
18
+ /** The envelope every route in this package answers with. */
19
+ interface Envelope<T> {
20
+ data?: T;
21
+ error?: string;
22
+ }
23
+
24
+ async function get<T>(url: string): Promise<T> {
25
+ const response = await fetch(url, { headers: { Accept: 'application/json' } });
26
+ let body: Envelope<T> = {};
27
+ try {
28
+ body = (await response.json()) as Envelope<T>;
29
+ } catch {
30
+ // A gate that refuses before the router runs may answer HTML, not JSON.
31
+ // Falling through to the status line below is more useful than a parse
32
+ // error naming a character offset.
33
+ }
34
+ if (!response.ok || body.data === undefined) {
35
+ throw new Error(body.error ?? `The request failed (${response.status}).`);
36
+ }
37
+ return body.data;
38
+ }
39
+
40
+ const withLocale = (base: string, locale: string): string =>
41
+ `${base}?locale=${encodeURIComponent(locale)}`;
42
+
43
+ /** The catalogue, with every subject rendered in `locale`. */
44
+ export function fetchEmailPreviewIndex(
45
+ apiBase: string,
46
+ locale: string,
47
+ ): Promise<EmailPreviewIndex> {
48
+ return get<EmailPreviewIndex>(withLocale(apiBase, locale));
49
+ }
50
+
51
+ /** One rendered message. */
52
+ export function fetchEmailPreview(
53
+ apiBase: string,
54
+ id: string,
55
+ locale: string,
56
+ ): Promise<EmailPreviewDetail> {
57
+ return get<EmailPreviewDetail>(
58
+ withLocale(`${apiBase}/${encodeURIComponent(id)}`, locale),
59
+ );
60
+ }