@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.
- package/ADOPTING.md +173 -1
- package/README.md +138 -0
- package/dist/catalog-BOVZ2b-b.d.ts +132 -0
- package/dist/{chunk-M5XTROYM.js → chunk-5Y7QRORV.js} +8 -4
- package/dist/chunk-5Y7QRORV.js.map +1 -0
- package/dist/{chunk-BGI4BQ66.js → chunk-6W7INOYQ.js} +1 -1
- package/dist/chunk-6W7INOYQ.js.map +1 -0
- package/dist/chunk-EKUSNUBT.js +222 -0
- package/dist/chunk-EKUSNUBT.js.map +1 -0
- package/dist/chunk-FTFLU6KH.js +114 -0
- package/dist/chunk-FTFLU6KH.js.map +1 -0
- package/dist/chunk-JCVRQ42B.js +226 -0
- package/dist/chunk-JCVRQ42B.js.map +1 -0
- package/dist/chunk-RTURLH5U.js +33 -0
- package/dist/chunk-RTURLH5U.js.map +1 -0
- package/dist/{chunk-GGAZOLNN.js → chunk-SWOWHIFE.js} +2 -2
- package/dist/{chunk-7234ROOP.js → chunk-WVRODNXQ.js} +28 -6
- package/dist/chunk-WVRODNXQ.js.map +1 -0
- package/dist/chunk-ZFIYBNZ7.js +502 -0
- package/dist/chunk-ZFIYBNZ7.js.map +1 -0
- package/dist/{chunk-KU4FVOFA.js → chunk-ZIR3ILFH.js} +2 -2
- package/dist/{create-api-notifications-WaexrZnn.d.ts → create-api-notifications-CcPYrM3p.d.ts} +35 -4
- package/dist/{create-web-notifications-DrgqtU4Q.d.ts → create-web-notifications-_NVYmlvy.d.ts} +89 -4
- package/dist/email/index.d.ts +26 -0
- package/dist/email/index.js +20 -0
- package/dist/email/index.js.map +1 -0
- package/dist/email/locales.d.ts +43 -0
- package/dist/email/locales.js +29 -0
- package/dist/email/locales.js.map +1 -0
- package/dist/email/previews/hono.d.ts +46 -0
- package/dist/email/previews/hono.js +33 -0
- package/dist/email/previews/hono.js.map +1 -0
- package/dist/email/previews/index.d.ts +67 -0
- package/dist/email/previews/index.js +12 -0
- package/dist/email/previews/index.js.map +1 -0
- package/dist/email/previews/react/index.d.ts +43 -0
- package/dist/email/previews/react/index.js +58 -0
- package/dist/email/previews/react/index.js.map +1 -0
- package/dist/{generators-CH5m8Ucj.d.ts → generators-qAD4fNPq.d.ts} +1 -1
- package/dist/hono/index.d.ts +6 -5
- package/dist/hono/index.js +5 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +15 -5
- package/dist/{jobs-CcsSA0YR.d.ts → jobs--fex87-q.d.ts} +1 -1
- package/dist/live-DYxEFO49.d.ts +158 -0
- package/dist/manifest/index.d.ts +39 -1
- package/dist/manifest/index.js +9 -0
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/server.d.ts +32 -7
- package/dist/manifest/server.js +16 -4
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +19 -4
- package/dist/manifest/web.js +9 -1
- package/dist/manifest/web.js.map +1 -1
- package/dist/{panel-UFXNO4AF.js → panel-T36JEMO3.js} +37 -13
- package/dist/panel-T36JEMO3.js.map +1 -0
- package/dist/{preferences-screen-V3ZBWP7A.js → preferences-screen-SXUIGECY.js} +2 -2
- package/dist/preview-screen-DYJRAnAY.d.ts +83 -0
- package/dist/react/index.d.ts +55 -6
- package/dist/react/index.js +16 -10
- package/dist/server/index.d.ts +8 -7
- package/dist/server/index.js +6 -4
- package/dist/template-Dlt5S54z.d.ts +196 -0
- package/dist/{types-CYJN1xCM.d.ts → types-BlqZkCWZ.d.ts} +11 -2
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-iER5StBm.d.ts → web-push-Dnyaha2z.d.ts} +21 -1
- package/dist/{wire-CmRrB3_s.d.ts → wire-BG1kuoXX.d.ts} +1 -1
- package/package.json +22 -2
- package/src/email/chrome.en-US.ts +8 -0
- package/src/email/chrome.pt-BR.ts +21 -0
- package/src/email/index.ts +43 -0
- package/src/email/locales.ts +29 -0
- package/src/email/previews/catalog.ts +208 -0
- package/src/email/previews/hono.ts +69 -0
- package/src/email/previews/index.ts +26 -0
- package/src/email/previews/react/copy.en-US.ts +24 -0
- package/src/email/previews/react/copy.pt-BR.ts +24 -0
- package/src/email/previews/react/copy.ts +42 -0
- package/src/email/previews/react/index.tsx +14 -0
- package/src/email/previews/react/loadable.tsx +83 -0
- package/src/email/previews/react/message-list.tsx +133 -0
- package/src/email/previews/react/message-view.tsx +167 -0
- package/src/email/previews/react/preview-screen.tsx +342 -0
- package/src/email/previews/react/transport.ts +60 -0
- package/src/email/previews/routes.ts +111 -0
- package/src/email/template.ts +365 -0
- package/src/email/text.ts +59 -0
- package/src/email/theme.ts +77 -0
- package/src/index.ts +10 -0
- package/src/live.ts +179 -0
- package/src/manifest/index.ts +37 -0
- package/src/manifest/server.ts +23 -0
- package/src/manifest/web.ts +13 -0
- package/src/react/create-web-notifications.tsx +15 -1
- package/src/react/index.ts +29 -0
- package/src/react/live-card.tsx +279 -0
- package/src/react/live-config.ts +79 -0
- package/src/react/live-section.tsx +141 -0
- package/src/react/panel-lazy.tsx +3 -0
- package/src/react/panel.tsx +57 -10
- package/src/react/relative-time.ts +14 -2
- package/src/server/transports/email.ts +62 -0
- package/src/server/transports/web-push.ts +22 -0
- package/src/types.ts +10 -1
- package/dist/chunk-7234ROOP.js.map +0 -1
- package/dist/chunk-BGI4BQ66.js.map +0 -1
- package/dist/chunk-M5XTROYM.js.map +0 -1
- package/dist/chunk-O5BVUXPO.js +0 -22
- package/dist/chunk-O5BVUXPO.js.map +0 -1
- package/dist/panel-UFXNO4AF.js.map +0 -1
- /package/dist/{chunk-GGAZOLNN.js.map → chunk-SWOWHIFE.js.map} +0 -0
- /package/dist/{chunk-KU4FVOFA.js.map → chunk-ZIR3ILFH.js.map} +0 -0
- /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
|
+
}
|