@voila.dev/ui-email-block-editor 1.1.9

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 (39) hide show
  1. package/README.md +23 -0
  2. package/package.json +64 -0
  3. package/src/blocks/article-block.tsx +122 -0
  4. package/src/blocks/block-definitions.tsx +94 -0
  5. package/src/blocks/block-text-input.tsx +44 -0
  6. package/src/blocks/button-block.tsx +119 -0
  7. package/src/blocks/divider-block.tsx +19 -0
  8. package/src/blocks/email-card-shell.tsx +80 -0
  9. package/src/blocks/grid-block.tsx +95 -0
  10. package/src/blocks/heading-block.tsx +86 -0
  11. package/src/blocks/image-block.tsx +264 -0
  12. package/src/blocks/list-block.tsx +202 -0
  13. package/src/blocks/offer-block.tsx +267 -0
  14. package/src/blocks/paragraph-block.tsx +47 -0
  15. package/src/blocks/product-block.tsx +155 -0
  16. package/src/blocks/rating-block.tsx +121 -0
  17. package/src/blocks/rich-text-editable.tsx +88 -0
  18. package/src/blocks/stat-block.tsx +113 -0
  19. package/src/blocks/table-block.tsx +257 -0
  20. package/src/dnd/sortable-block-list.tsx +263 -0
  21. package/src/document/reducer.ts +345 -0
  22. package/src/document/rich-text.ts +168 -0
  23. package/src/document/types.ts +388 -0
  24. package/src/email-block-editor.tsx +163 -0
  25. package/src/lib/use-media-query.ts +38 -0
  26. package/src/sections/add-block-menu.tsx +56 -0
  27. package/src/sections/block-options/alignment-option.tsx +53 -0
  28. package/src/sections/block-options/block-option-row.tsx +79 -0
  29. package/src/sections/block-options/money-option.tsx +69 -0
  30. package/src/sections/block-options/segmented-option.tsx +62 -0
  31. package/src/sections/block-options/select-option.tsx +76 -0
  32. package/src/sections/block-options/text-option.tsx +91 -0
  33. package/src/sections/block-toolbar.tsx +379 -0
  34. package/src/sections/editor-canvas.tsx +403 -0
  35. package/src/sections/editor-sidebar.tsx +95 -0
  36. package/src/sections/link-popover.tsx +89 -0
  37. package/src/sections/preview-toggle.tsx +45 -0
  38. package/src/styles.css +10 -0
  39. package/src/theme.ts +26 -0
@@ -0,0 +1,403 @@
1
+ import { cn } from "@voila.dev/ui/lib/utils";
2
+ import { type ReactElement, type ReactNode, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import {
5
+ EMAIL_LEAF_BLOCK_TYPES,
6
+ type EmailBlockComponentProps,
7
+ emailBlockDefinition,
8
+ } from "#/blocks/block-definitions.tsx";
9
+ import { EMAIL_GRID_GAP_PX } from "#/blocks/grid-block.tsx";
10
+ import {
11
+ EmailEditorDndContext,
12
+ SortableBlockContainer,
13
+ type SortableBlockHandle,
14
+ SortableBlockItem,
15
+ } from "#/dnd/sortable-block-list.tsx";
16
+ import type {
17
+ EmailEditorAction,
18
+ EmailEditorContainerId,
19
+ EmailEditorState,
20
+ } from "#/document/reducer.ts";
21
+ import {
22
+ EMAIL_PREVIEW_WIDTH,
23
+ type EmailEditorBlock,
24
+ type EmailEditorGridBlock,
25
+ type EmailEditorPreview,
26
+ isEmailEditorGridBlock,
27
+ } from "#/document/types.ts";
28
+ import { useCoarsePointer } from "#/lib/use-media-query.ts";
29
+ import { AddBlockMenu } from "#/sections/add-block-menu.tsx";
30
+ import { BlockToolbar } from "#/sections/block-toolbar.tsx";
31
+ import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
32
+
33
+ /** Render `node` into `slot`, or in place when there is no slot. */
34
+ const renderIn = (slot: HTMLElement | null, node: ReactElement): ReactNode =>
35
+ slot === null ? node : createPortal(node, slot);
36
+
37
+ /** Blocks whose content is edited through the span model, and therefore want
38
+ * the toolbar's bold/italic/underline/link group. */
39
+ const RICH_TEXT_BLOCK_TYPES: ReadonlySet<string> = new Set([
40
+ "paragraph",
41
+ "list",
42
+ "rating",
43
+ ]);
44
+
45
+ /** Everything the block rows need from the editor, threaded down one level of
46
+ * nesting without turning each row into a ten-prop component. */
47
+ interface CanvasContext {
48
+ readonly state: EmailEditorState;
49
+ readonly dispatch: (action: EmailEditorAction) => void;
50
+ readonly coarsePointer: boolean;
51
+ readonly preview: EmailEditorPreview;
52
+ readonly onUploadImage?: (file: File) => Promise<string>;
53
+ readonly onOpenSettings?: () => void;
54
+ }
55
+
56
+ /** The dashed « ajouter » slot of a grid cell. */
57
+ function GridAddCell({
58
+ gridId,
59
+ context,
60
+ }: {
61
+ gridId: string;
62
+ context: CanvasContext;
63
+ }) {
64
+ return (
65
+ <div className="flex items-center justify-center rounded-lg border border-dashed px-2 py-6">
66
+ <AddBlockMenu
67
+ types={EMAIL_LEAF_BLOCK_TYPES}
68
+ onAdd={(type) =>
69
+ context.dispatch({
70
+ type: "add",
71
+ blockType: type,
72
+ containerId: gridId,
73
+ })
74
+ }
75
+ />
76
+ </div>
77
+ );
78
+ }
79
+
80
+ function GridBlockCells({
81
+ block,
82
+ context,
83
+ }: {
84
+ block: EmailEditorGridBlock;
85
+ context: CanvasContext;
86
+ }) {
87
+ const definition = emailBlockDefinition(block);
88
+ const selected = context.state.selectedBlockId === block.id;
89
+ const showAddCell = selected || block.children.length === 0;
90
+ // Under a touch pointer a cell is far too narrow for a row of 44px targets,
91
+ // so a selected child's toolbar is portalled up here and gets the whole
92
+ // grid's width. Under a mouse the toolbar floats and needs no help.
93
+ const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
94
+ return (
95
+ <SortableBlockContainer
96
+ containerId={block.id}
97
+ blockIds={block.children.map((child) => child.id)}
98
+ layout="grid"
99
+ >
100
+ <div ref={setToolbarSlot} className="mb-2 empty:mb-0" />
101
+ <definition.View
102
+ block={block}
103
+ selected={selected}
104
+ preview={context.preview}
105
+ onChange={(updated) =>
106
+ context.dispatch({ type: "update", block: updated })
107
+ }
108
+ onUploadImage={context.onUploadImage}
109
+ >
110
+ {block.children.map((child, index) => (
111
+ <CanvasBlockRow
112
+ key={child.id}
113
+ block={child}
114
+ index={index}
115
+ containerId={block.id}
116
+ context={context}
117
+ toolbarSlot={context.coarsePointer ? toolbarSlot : null}
118
+ />
119
+ ))}
120
+ {showAddCell ? (
121
+ <GridAddCell gridId={block.id} context={context} />
122
+ ) : null}
123
+ </definition.View>
124
+ </SortableBlockContainer>
125
+ );
126
+ }
127
+
128
+ /**
129
+ * The controls of the selected row. It stays mounted while dragging — dnd-kit
130
+ * holds a reference to the drag handle it contains — but is hidden, so it
131
+ * stops floating over the neighbouring blocks' text.
132
+ *
133
+ * Under a touch pointer the 44px bar is too tall to float without covering the
134
+ * previous block, so it sits in the flow above its own block; a row inside a
135
+ * grid cell hands it to the grid instead, which has the width for it. The
136
+ * portal keeps it in this row's React tree, so dnd-kit and the selection
137
+ * handlers are unaffected.
138
+ */
139
+ // fallow-ignore-next-line complexity -- prop wiring: one dispatch per toolbar action, plus the nested/root split.
140
+ function CanvasBlockRowToolbar({
141
+ block,
142
+ index,
143
+ containerId,
144
+ context,
145
+ handle,
146
+ toolbarSlot,
147
+ }: {
148
+ block: EmailEditorBlock;
149
+ index: number;
150
+ containerId: EmailEditorContainerId;
151
+ context: CanvasContext;
152
+ handle: SortableBlockHandle;
153
+ toolbarSlot: HTMLElement | null;
154
+ }) {
155
+ const { dispatch, coarsePointer } = context;
156
+ const nested = containerId !== null;
157
+ return renderIn(
158
+ toolbarSlot,
159
+ <div
160
+ className={cn(
161
+ toolbarSlot !== null || coarsePointer
162
+ ? "mb-2"
163
+ : "-top-9 absolute right-0 z-10",
164
+ handle.isDragging && "pointer-events-none opacity-0",
165
+ )}
166
+ >
167
+ <BlockToolbar
168
+ handle={handle}
169
+ richText={RICH_TEXT_BLOCK_TYPES.has(block.type)}
170
+ coarsePointer={coarsePointer}
171
+ addableTypes={nested ? EMAIL_LEAF_BLOCK_TYPES : undefined}
172
+ onAddBelow={(type) =>
173
+ dispatch({
174
+ type: "add",
175
+ blockType: type,
176
+ containerId,
177
+ index: index + 1,
178
+ })
179
+ }
180
+ onDuplicate={() => dispatch({ type: "duplicate", blockId: block.id })}
181
+ onRemove={() => dispatch({ type: "remove", blockId: block.id })}
182
+ onOpenSettings={context.onOpenSettings}
183
+ onSelectContainer={
184
+ nested
185
+ ? () => dispatch({ type: "select", blockId: containerId })
186
+ : undefined
187
+ }
188
+ />
189
+ </div>,
190
+ );
191
+ }
192
+
193
+ function CanvasBlockRow({
194
+ block,
195
+ index,
196
+ containerId,
197
+ context,
198
+ toolbarSlot = null,
199
+ }: {
200
+ block: EmailEditorBlock;
201
+ index: number;
202
+ containerId: EmailEditorContainerId;
203
+ context: CanvasContext;
204
+ /** Where to render the toolbar when the row's own column is too narrow for
205
+ * it; see {@link GridBlockCells}. */
206
+ toolbarSlot?: HTMLElement | null;
207
+ }) {
208
+ const { dispatch } = context;
209
+ const selected = context.state.selectedBlockId === block.id;
210
+ const definition = emailBlockDefinition(block);
211
+ const grid = isEmailEditorGridBlock(block) ? block : null;
212
+ const viewProps: EmailBlockComponentProps = {
213
+ block,
214
+ selected,
215
+ preview: context.preview,
216
+ onChange: (updated) => dispatch({ type: "update", block: updated }),
217
+ onUploadImage: context.onUploadImage,
218
+ };
219
+ // Selecting the innermost block: the child's handler runs first and stops
220
+ // the event, so clicking inside a grid cell never selects the grid.
221
+ const select = (event: { stopPropagation: () => void }) => {
222
+ event.stopPropagation();
223
+ dispatch({ type: "select", blockId: block.id });
224
+ };
225
+
226
+ return (
227
+ <SortableBlockItem
228
+ blockId={block.id}
229
+ className={cn(
230
+ "group relative rounded-lg py-3",
231
+ // Every row's *content* starts at its container's edge, root or
232
+ // cell — that alignment is what makes the canvas read as one email.
233
+ // The padding that gives the selection ring its breathing room is
234
+ // cancelled by an equal negative margin, so it never shifts content.
235
+ // A cell gets half the 16px gutter on each side, so two neighbouring
236
+ // rings meet exactly rather than overlapping.
237
+ containerId === null ? "-mx-3 px-3" : "-mx-2 px-2",
238
+ selected && "ring-2 ring-ring/50",
239
+ )}
240
+ >
241
+ {(handle) => (
242
+ <>
243
+ {selected ? (
244
+ <CanvasBlockRowToolbar
245
+ block={block}
246
+ index={index}
247
+ containerId={containerId}
248
+ context={context}
249
+ handle={handle}
250
+ toolbarSlot={toolbarSlot}
251
+ />
252
+ ) : null}
253
+ {/* Selection follows focus (most blocks host a focusable control)
254
+ plus plain clicks for non-editable blocks like the divider. */}
255
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: selection sugar; the real controls inside stay keyboard-accessible. */}
256
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: same as above. */}
257
+ <div onClick={select} onFocus={select}>
258
+ {grid === null ? (
259
+ <definition.View {...viewProps} />
260
+ ) : (
261
+ <GridBlockCells block={grid} context={context} />
262
+ )}
263
+ </div>
264
+ </>
265
+ )}
266
+ </SortableBlockItem>
267
+ );
268
+ }
269
+
270
+ /**
271
+ * Neutral stand-in for the branded header the server prepends. Pass
272
+ * `headerSlot` to render your own logo instead.
273
+ */
274
+ function CardHeaderPlaceholder() {
275
+ return (
276
+ <div
277
+ className="flex justify-center pt-8 pb-2 text-[13px]"
278
+ style={{ color: EMAIL_COLOR.muted, fontFamily: EMAIL_FONT }}
279
+ >
280
+ <div className="rounded-md border border-dashed px-4 py-3">
281
+ Your header
282
+ </div>
283
+ </div>
284
+ );
285
+ }
286
+
287
+ /**
288
+ * Neutral stand-in for the branded footer the server appends. Pass
289
+ * `footerSlot` to render your own.
290
+ */
291
+ function CardFooterPlaceholder() {
292
+ return (
293
+ <div
294
+ className="px-8 pt-6 pb-2 text-center text-[13px] leading-[1.6]"
295
+ style={{ color: EMAIL_COLOR.muted, fontFamily: EMAIL_FONT }}
296
+ >
297
+ <div>
298
+ The full footer (contact details, social links, unsubscribe) is added
299
+ when the email is sent.
300
+ </div>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ /**
306
+ * The editing surface, dressed as the email it produces: the grey canvas, the
307
+ * 600px white card, the blocks, and header/footer placeholders.
308
+ * The real chrome is rendered server-side — the canvas only mirrors it.
309
+ * Blocks are added from the selected block's toolbar (+); an empty document
310
+ * shows a single centered add button instead.
311
+ */
312
+ export function EditorCanvas({
313
+ state,
314
+ dispatch,
315
+ preview,
316
+ onUploadImage,
317
+ onOpenSettings,
318
+ headerSlot,
319
+ footerSlot,
320
+ }: {
321
+ state: EmailEditorState;
322
+ dispatch: (action: EmailEditorAction) => void;
323
+ /** Which rendering to mirror: the 600px card, or a phone-width one where
324
+ * grids collapse to their mobile column count. */
325
+ preview: EmailEditorPreview;
326
+ onUploadImage?: (file: File) => Promise<string>;
327
+ /** Present when the settings live in a sheet rather than in the sidebar;
328
+ * each selected block's toolbar then offers a « Réglages » button. */
329
+ onOpenSettings?: () => void;
330
+ headerSlot?: ReactNode;
331
+ footerSlot?: ReactNode;
332
+ }) {
333
+ const coarsePointer = useCoarsePointer();
334
+ const blocks = state.document.blocks;
335
+ const context: CanvasContext = {
336
+ state,
337
+ dispatch,
338
+ coarsePointer,
339
+ preview,
340
+ onUploadImage,
341
+ onOpenSettings,
342
+ };
343
+ return (
344
+ <div
345
+ className="flex justify-center rounded-lg px-4 py-8"
346
+ style={{ backgroundColor: EMAIL_COLOR.canvas }}
347
+ >
348
+ <div
349
+ className="w-full"
350
+ style={{ maxWidth: `${EMAIL_PREVIEW_WIDTH[preview]}px` }}
351
+ >
352
+ <div
353
+ className="rounded-[14px] border"
354
+ style={{
355
+ backgroundColor: EMAIL_COLOR.card,
356
+ borderColor: EMAIL_COLOR.border,
357
+ }}
358
+ >
359
+ {headerSlot ?? <CardHeaderPlaceholder />}
360
+ <div className="px-8 pt-2 pb-8">
361
+ <EmailEditorDndContext
362
+ blocks={blocks}
363
+ onMove={(blockId, toContainerId, toIndex) =>
364
+ dispatch({ type: "move", blockId, toContainerId, toIndex })
365
+ }
366
+ >
367
+ <SortableBlockContainer
368
+ containerId={null}
369
+ blockIds={blocks.map((block) => block.id)}
370
+ layout="list"
371
+ style={{ minHeight: `${EMAIL_GRID_GAP_PX}px` }}
372
+ >
373
+ {blocks.map((block, index) => (
374
+ <CanvasBlockRow
375
+ key={block.id}
376
+ block={block}
377
+ index={index}
378
+ containerId={null}
379
+ context={context}
380
+ />
381
+ ))}
382
+ </SortableBlockContainer>
383
+ </EmailEditorDndContext>
384
+ {blocks.length === 0 ? (
385
+ <div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-10">
386
+ <p
387
+ className="text-[14px]"
388
+ style={{ color: EMAIL_COLOR.muted, fontFamily: EMAIL_FONT }}
389
+ >
390
+ Votre email est vide.
391
+ </p>
392
+ <AddBlockMenu
393
+ onAdd={(type) => dispatch({ type: "add", blockType: type })}
394
+ />
395
+ </div>
396
+ ) : null}
397
+ </div>
398
+ </div>
399
+ {footerSlot ?? <CardFooterPlaceholder />}
400
+ </div>
401
+ </div>
402
+ );
403
+ }
@@ -0,0 +1,95 @@
1
+ import {
2
+ type EmailBlockComponentProps,
3
+ emailBlockDefinition,
4
+ } from "#/blocks/block-definitions.tsx";
5
+ import {
6
+ allEmailEditorBlocks,
7
+ type EmailEditorAction,
8
+ type EmailEditorState,
9
+ } from "#/document/reducer.ts";
10
+ import type { EmailEditorBlock } from "#/document/types.ts";
11
+
12
+ function SelectedBlockSettings({
13
+ block,
14
+ dispatch,
15
+ onUploadImage,
16
+ }: {
17
+ block: EmailEditorBlock;
18
+ dispatch: (action: EmailEditorAction) => void;
19
+ onUploadImage?: (file: File) => Promise<string>;
20
+ }) {
21
+ const definition = emailBlockDefinition(block);
22
+ if (definition.Settings === null) {
23
+ return (
24
+ <p className="text-muted-foreground text-sm">
25
+ Aucun réglage pour ce bloc.
26
+ </p>
27
+ );
28
+ }
29
+ const settingsProps: EmailBlockComponentProps = {
30
+ block,
31
+ selected: true,
32
+ onChange: (updated) => dispatch({ type: "update", block: updated }),
33
+ onUploadImage,
34
+ };
35
+ return <definition.Settings key={block.id} {...settingsProps} />;
36
+ }
37
+
38
+ /**
39
+ * The settings of the selected block, driven by the block registry. Rendered
40
+ * bare so it can live either in the desktop sidebar card or in the mobile
41
+ * bottom sheet without either owning the other's chrome.
42
+ */
43
+ export function BlockSettingsPanel({
44
+ state,
45
+ dispatch,
46
+ onUploadImage,
47
+ }: {
48
+ state: EmailEditorState;
49
+ dispatch: (action: EmailEditorAction) => void;
50
+ onUploadImage?: (file: File) => Promise<string>;
51
+ }) {
52
+ // Grid children are selectable too, so the lookup walks the whole tree.
53
+ const selectedBlock =
54
+ allEmailEditorBlocks(state.document.blocks).find(
55
+ (block) => block.id === state.selectedBlockId,
56
+ ) ?? null;
57
+
58
+ if (selectedBlock === null) {
59
+ return (
60
+ <p className="text-muted-foreground text-sm">
61
+ Sélectionnez un bloc pour modifier ses réglages.
62
+ </p>
63
+ );
64
+ }
65
+
66
+ return (
67
+ <SelectedBlockSettings
68
+ block={selectedBlock}
69
+ dispatch={dispatch}
70
+ onUploadImage={onUploadImage}
71
+ />
72
+ );
73
+ }
74
+
75
+ /** The desktop settings column: the panel above, in its own titled card. */
76
+ export function EditorSidebar({
77
+ state,
78
+ dispatch,
79
+ onUploadImage,
80
+ }: {
81
+ state: EmailEditorState;
82
+ dispatch: (action: EmailEditorAction) => void;
83
+ onUploadImage?: (file: File) => Promise<string>;
84
+ }) {
85
+ return (
86
+ <div className="flex flex-col gap-4 rounded-lg border bg-background p-4">
87
+ <h3 className="font-medium text-sm">Réglages du bloc</h3>
88
+ <BlockSettingsPanel
89
+ state={state}
90
+ dispatch={dispatch}
91
+ onUploadImage={onUploadImage}
92
+ />
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,89 @@
1
+ import { Button } from "@voila.dev/ui/components/button";
2
+ import { Input } from "@voila.dev/ui/components/input";
3
+ import {
4
+ Popover,
5
+ PopoverContent,
6
+ PopoverTrigger,
7
+ } from "@voila.dev/ui/components/popover";
8
+ import { type ReactElement, useState } from "react";
9
+
10
+ /**
11
+ * The shared « lien » editing surface: a small popover with a URL field,
12
+ * apply, and an optional remove action. The paragraph toolbar drives it with
13
+ * text-selection callbacks; the button block edits its target through it.
14
+ */
15
+ export function LinkPopover({
16
+ trigger,
17
+ initialHref,
18
+ onOpen,
19
+ onApply,
20
+ onRemove,
21
+ }: {
22
+ trigger: ReactElement;
23
+ /** Resolved each time the popover opens (e.g. the link under the caret). */
24
+ initialHref: () => string;
25
+ /** Runs as the popover opens (the toolbar saves the text selection here). */
26
+ onOpen?: () => void;
27
+ onApply: (href: string) => void;
28
+ /** « Retirer le lien » — omitted when removing makes no sense (a button). */
29
+ onRemove?: () => void;
30
+ }) {
31
+ const [open, setOpen] = useState(false);
32
+ const [href, setHref] = useState("");
33
+
34
+ return (
35
+ <Popover
36
+ open={open}
37
+ onOpenChange={(next) => {
38
+ if (next) {
39
+ onOpen?.();
40
+ setHref(initialHref());
41
+ }
42
+ setOpen(next);
43
+ }}
44
+ >
45
+ <PopoverTrigger render={trigger} />
46
+ <PopoverContent
47
+ side="bottom"
48
+ align="start"
49
+ className="w-[min(18rem,calc(100vw-2rem))] p-3"
50
+ >
51
+ <form
52
+ className="flex flex-col gap-2"
53
+ onSubmit={(event) => {
54
+ event.preventDefault();
55
+ onApply(href.trim());
56
+ setOpen(false);
57
+ }}
58
+ >
59
+ <Input
60
+ aria-label="Adresse du lien"
61
+ type="url"
62
+ placeholder="https://"
63
+ value={href}
64
+ onChange={(event) => setHref(event.target.value)}
65
+ autoFocus
66
+ />
67
+ <div className="flex items-center justify-between gap-2">
68
+ <Button type="submit" size="sm" disabled={href.trim() === ""}>
69
+ Appliquer
70
+ </Button>
71
+ {onRemove ? (
72
+ <Button
73
+ type="button"
74
+ variant="ghost"
75
+ size="sm"
76
+ onClick={() => {
77
+ onRemove();
78
+ setOpen(false);
79
+ }}
80
+ >
81
+ Retirer le lien
82
+ </Button>
83
+ ) : null}
84
+ </div>
85
+ </form>
86
+ </PopoverContent>
87
+ </Popover>
88
+ );
89
+ }
@@ -0,0 +1,45 @@
1
+ import { DesktopIcon, DeviceMobileIcon } from "@phosphor-icons/react";
2
+ import {
3
+ SegmentedControl,
4
+ SegmentedControlItem,
5
+ } from "@voila.dev/ui/components/segmented-control";
6
+ import type { EmailEditorPreview } from "#/document/types.ts";
7
+
8
+ const PREVIEWS: ReadonlyArray<{
9
+ readonly value: EmailEditorPreview;
10
+ readonly label: string;
11
+ readonly Icon: typeof DesktopIcon;
12
+ }> = [
13
+ { value: "desktop", label: "Ordinateur", Icon: DesktopIcon },
14
+ { value: "mobile", label: "Mobile", Icon: DeviceMobileIcon },
15
+ ];
16
+
17
+ /**
18
+ * Switches the canvas between the two renderings an email actually gets: the
19
+ * full 600px card, and a phone-width one where grids collapse to their mobile
20
+ * column count. Editor chrome, so it uses app styling and sits above the
21
+ * canvas rather than inside the card.
22
+ */
23
+ export function PreviewToggle({
24
+ value,
25
+ onChange,
26
+ }: {
27
+ value: EmailEditorPreview;
28
+ onChange: (preview: EmailEditorPreview) => void;
29
+ }) {
30
+ return (
31
+ <SegmentedControl
32
+ size="sm"
33
+ aria-label="Aperçu"
34
+ value={value}
35
+ onValueChange={(next) => onChange(next as EmailEditorPreview)}
36
+ >
37
+ {PREVIEWS.map((preview) => (
38
+ <SegmentedControlItem key={preview.value} value={preview.value}>
39
+ <preview.Icon aria-hidden />
40
+ {preview.label}
41
+ </SegmentedControlItem>
42
+ ))}
43
+ </SegmentedControl>
44
+ );
45
+ }
package/src/styles.css ADDED
@@ -0,0 +1,10 @@
1
+ /*
2
+ * Tailwind must scan this package or none of its classes are generated — the
3
+ * components ship as .tsx source, not as compiled CSS.
4
+ *
5
+ * `@source` resolves relative to the file that declares it, so this works
6
+ * wherever the package ends up: node_modules in an app, a symlink in a
7
+ * workspace. Consumers import this file and add nothing of their own.
8
+ */
9
+ @source "./";
10
+ @source not "./**/*.test.*";
package/src/theme.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Email look mirrored into the editor canvas, so blocks render on screen the
3
+ * way the sent email will. Override these to match your own email theme: your
4
+ * server-side renderer stays the source of truth, and these values only need to
5
+ * agree with it closely enough that the canvas is an honest preview.
6
+ */
7
+ export const EMAIL_COLOR = {
8
+ brand: "#151b77",
9
+ ink: "#2a2a33",
10
+ muted: "#9095a3",
11
+ border: "#ececf1",
12
+ card: "#ffffff",
13
+ canvas: "#f4f4f7",
14
+ } as const;
15
+
16
+ export const EMAIL_FONT =
17
+ "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
18
+
19
+ /**
20
+ * The locale the canvas previews prices and dates in, mirroring
21
+ * `renderMarketingEmailDocument`'s default. Deliberately not the browser's
22
+ * locale: the canvas shows what the email will look like, and a French author
23
+ * on an English-configured machine would otherwise see a price the recipient
24
+ * never gets. The sent email formats per recipient.
25
+ */
26
+ export const EMAIL_PREVIEW_LOCALE = "fr-FR";