@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.
- package/ADOPTING.md +145 -0
- package/README.md +65 -0
- package/dist/catalog-BOVZ2b-b.d.ts +132 -0
- package/dist/{chunk-XE7HZVMH.js → chunk-BGI4BQ66.js} +1 -1
- package/dist/chunk-BGI4BQ66.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-HHMRCMQU.js → chunk-GGAZOLNN.js} +2 -2
- package/dist/{chunk-CUZW62JS.js → chunk-KU4FVOFA.js} +2 -2
- package/dist/{chunk-WHBMPHQE.js → chunk-KZG7QGFA.js} +28 -6
- package/dist/chunk-KZG7QGFA.js.map +1 -0
- package/dist/{chunk-MMLV4EZT.js → chunk-M5XTROYM.js} +2 -2
- package/dist/chunk-ZFIYBNZ7.js +502 -0
- package/dist/chunk-ZFIYBNZ7.js.map +1 -0
- package/dist/{create-api-notifications-B3u6Kx3x.d.ts → create-api-notifications-B-VMezVB.d.ts} +43 -4
- package/dist/{create-web-notifications-BHCzaU2y.d.ts → create-web-notifications-B-xQ6R0B.d.ts} +2 -2
- 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-B9xt3sRh.d.ts → generators-BsuXe8Mp.d.ts} +1 -1
- package/dist/hono/index.d.ts +6 -5
- package/dist/hono/index.js +4 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +5 -5
- package/dist/{jobs-BDZ7aGHV.d.ts → jobs-D64B_bg7.d.ts} +1 -1
- 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 +15 -4
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +18 -4
- package/dist/manifest/web.js +9 -1
- package/dist/manifest/web.js.map +1 -1
- package/dist/{preferences-screen-IOW6Y2H2.js → preferences-screen-V3ZBWP7A.js} +2 -2
- package/dist/preview-screen-DYJRAnAY.d.ts +83 -0
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +10 -10
- package/dist/server/index.d.ts +8 -7
- package/dist/server/index.js +5 -4
- package/dist/template-Dlt5S54z.d.ts +196 -0
- package/dist/{types-CXLAG3UU.d.ts → types-BFmAY0P5.d.ts} +39 -2
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-Cs14Wp9u.d.ts → web-push-D-ysQd5h.d.ts} +1 -1
- package/dist/{wire-6dzyfDE7.d.ts → wire-_coV3tJk.d.ts} +1 -1
- package/package.json +23 -3
- 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 +1 -0
- package/src/manifest/index.ts +37 -0
- package/src/manifest/server.ts +23 -0
- package/src/manifest/web.ts +13 -0
- package/src/server/db.ts +12 -1
- package/src/server/dispatch.ts +4 -0
- package/src/server/router.ts +17 -1
- package/src/server/transports/email.ts +62 -0
- package/src/types.ts +39 -1
- package/dist/chunk-WHBMPHQE.js.map +0 -1
- package/dist/chunk-XE7HZVMH.js.map +0 -1
- /package/dist/{chunk-HHMRCMQU.js.map → chunk-GGAZOLNN.js.map} +0 -0
- /package/dist/{chunk-CUZW62JS.js.map → chunk-KU4FVOFA.js.map} +0 -0
- /package/dist/{chunk-MMLV4EZT.js.map → chunk-M5XTROYM.js.map} +0 -0
- /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
|
+
}
|