@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.
- package/README.md +23 -0
- package/package.json +64 -0
- package/src/blocks/article-block.tsx +122 -0
- package/src/blocks/block-definitions.tsx +94 -0
- package/src/blocks/block-text-input.tsx +44 -0
- package/src/blocks/button-block.tsx +119 -0
- package/src/blocks/divider-block.tsx +19 -0
- package/src/blocks/email-card-shell.tsx +80 -0
- package/src/blocks/grid-block.tsx +95 -0
- package/src/blocks/heading-block.tsx +86 -0
- package/src/blocks/image-block.tsx +264 -0
- package/src/blocks/list-block.tsx +202 -0
- package/src/blocks/offer-block.tsx +267 -0
- package/src/blocks/paragraph-block.tsx +47 -0
- package/src/blocks/product-block.tsx +155 -0
- package/src/blocks/rating-block.tsx +121 -0
- package/src/blocks/rich-text-editable.tsx +88 -0
- package/src/blocks/stat-block.tsx +113 -0
- package/src/blocks/table-block.tsx +257 -0
- package/src/dnd/sortable-block-list.tsx +263 -0
- package/src/document/reducer.ts +345 -0
- package/src/document/rich-text.ts +168 -0
- package/src/document/types.ts +388 -0
- package/src/email-block-editor.tsx +163 -0
- package/src/lib/use-media-query.ts +38 -0
- package/src/sections/add-block-menu.tsx +56 -0
- package/src/sections/block-options/alignment-option.tsx +53 -0
- package/src/sections/block-options/block-option-row.tsx +79 -0
- package/src/sections/block-options/money-option.tsx +69 -0
- package/src/sections/block-options/segmented-option.tsx +62 -0
- package/src/sections/block-options/select-option.tsx +76 -0
- package/src/sections/block-options/text-option.tsx +91 -0
- package/src/sections/block-toolbar.tsx +379 -0
- package/src/sections/editor-canvas.tsx +403 -0
- package/src/sections/editor-sidebar.tsx +95 -0
- package/src/sections/link-popover.tsx +89 -0
- package/src/sections/preview-toggle.tsx +45 -0
- package/src/styles.css +10 -0
- package/src/theme.ts +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @voila.dev/ui-email-block-editor
|
|
2
|
+
|
|
3
|
+
WYSIWYG block editor for marketing emails. The canvas mirrors the transactional
|
|
4
|
+
email chrome (600px card, email theme) so what the admin composes looks like the
|
|
5
|
+
email that will be sent — the server-rendered preview stays the source of truth.
|
|
6
|
+
|
|
7
|
+
- `document/types.ts` — the editor document model (`EmailEditorDocument`,
|
|
8
|
+
blocks: heading, paragraph, button, image, divider). Pure TypeScript; the
|
|
9
|
+
domain's `MarketingEmailDocument` schema validates exactly this shape.
|
|
10
|
+
- `document/reducer.ts` — pure reducer (add/update/remove/move/duplicate/
|
|
11
|
+
select/replace) with an injected block-id factory.
|
|
12
|
+
- `blocks/*` — one WYSIWYG component per block, edited in place.
|
|
13
|
+
- `sections/*` — canvas chrome, block toolbar, add-block menu, settings sidebar.
|
|
14
|
+
- `dnd/sortable-block-list.tsx` — keyboard-accessible dnd-kit reordering.
|
|
15
|
+
- `email-block-editor.tsx` — the composed `<EmailBlockEditor />`.
|
|
16
|
+
|
|
17
|
+
Image uploads are delegated: the host passes `onUploadImage(file) → Promise<url>`.
|
|
18
|
+
|
|
19
|
+
Consumers must add this package to their Tailwind sources:
|
|
20
|
+
|
|
21
|
+
```css
|
|
22
|
+
@source "../../../../packages/ui-email-block-editor/src";
|
|
23
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voila.dev/ui-email-block-editor",
|
|
3
|
+
"version": "1.1.9",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "The email template editor that lives in your app, not someone else's SaaS.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://ui.voila.dev/components/email-block-editor",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/voila-voila-dev/ui.git",
|
|
11
|
+
"directory": "packages/ui-email-block-editor"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"react",
|
|
15
|
+
"email",
|
|
16
|
+
"editor",
|
|
17
|
+
"block-editor",
|
|
18
|
+
"dnd-kit"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"!src/**/*.test.ts",
|
|
26
|
+
"!src/**/*.test.tsx",
|
|
27
|
+
"!src/**/*.stories.ts",
|
|
28
|
+
"!src/**/*.stories.tsx"
|
|
29
|
+
],
|
|
30
|
+
"exports": {
|
|
31
|
+
"./styles.css": "./src/styles.css",
|
|
32
|
+
"./email-block-editor": "./src/email-block-editor.tsx",
|
|
33
|
+
"./theme": "./src/theme.ts",
|
|
34
|
+
"./document/*": "./src/document/*.ts",
|
|
35
|
+
"./blocks/*": "./src/blocks/*.tsx",
|
|
36
|
+
"./sections/*": "./src/sections/*.tsx",
|
|
37
|
+
"./dnd/*": "./src/dnd/*.tsx"
|
|
38
|
+
},
|
|
39
|
+
"imports": {
|
|
40
|
+
"#/*": "./src/*"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"check": "biome check",
|
|
44
|
+
"check-types": "tsc --noEmit",
|
|
45
|
+
"format": "biome format",
|
|
46
|
+
"lint": "biome lint",
|
|
47
|
+
"test": "vitest run"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@dnd-kit/core": "^6.3.1",
|
|
51
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
52
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
53
|
+
"@phosphor-icons/react": "2.1.10",
|
|
54
|
+
"@voila.dev/ui": "0.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"react": "^19.0.0",
|
|
58
|
+
"react-dom": "^19.0.0",
|
|
59
|
+
"tailwindcss": "^4.0.0"
|
|
60
|
+
},
|
|
61
|
+
"sideEffects": [
|
|
62
|
+
"**/*.css"
|
|
63
|
+
]
|
|
64
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { NewspaperIcon } from "@phosphor-icons/react";
|
|
2
|
+
import type {
|
|
3
|
+
EmailBlockComponentProps,
|
|
4
|
+
EmailBlockDefinition,
|
|
5
|
+
} from "#/blocks/block-definitions.tsx";
|
|
6
|
+
import { BlockTextInput } from "#/blocks/block-text-input.tsx";
|
|
7
|
+
import { EmailCardMeta, EmailCardShell } from "#/blocks/email-card-shell.tsx";
|
|
8
|
+
import type { EmailEditorArticleBlock } from "#/document/types.ts";
|
|
9
|
+
import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
|
|
10
|
+
import {
|
|
11
|
+
LinkOption,
|
|
12
|
+
TextAreaOption,
|
|
13
|
+
TextOption,
|
|
14
|
+
} from "#/sections/block-options/text-option.tsx";
|
|
15
|
+
import { EMAIL_COLOR } from "#/theme.ts";
|
|
16
|
+
|
|
17
|
+
/** The author/date line, with a separator only when both are present. */
|
|
18
|
+
const metaLine = (block: EmailEditorArticleBlock): string =>
|
|
19
|
+
[block.author, block.publishDate].filter((part) => part !== "").join(" · ");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A blog post or an external resource, on the shared card shell. Field names
|
|
23
|
+
* follow the real blog model, so a digest built from published posts maps
|
|
24
|
+
* straight across. The title and description are edited in place; the image,
|
|
25
|
+
* the byline and the link live in the settings.
|
|
26
|
+
*/
|
|
27
|
+
function ArticleBlockView({
|
|
28
|
+
block,
|
|
29
|
+
onChange,
|
|
30
|
+
}: EmailBlockComponentProps<EmailEditorArticleBlock>) {
|
|
31
|
+
const meta = metaLine(block);
|
|
32
|
+
return (
|
|
33
|
+
<EmailCardShell image={block.image}>
|
|
34
|
+
<BlockTextInput
|
|
35
|
+
ariaLabel="Titre de l'article"
|
|
36
|
+
value={block.title}
|
|
37
|
+
placeholder="Titre de l'article"
|
|
38
|
+
onChange={(title) => onChange({ ...block, title })}
|
|
39
|
+
className="font-bold text-[17px] leading-[1.3]"
|
|
40
|
+
style={{ color: EMAIL_COLOR.brand }}
|
|
41
|
+
/>
|
|
42
|
+
{meta === "" ? null : <EmailCardMeta>{meta}</EmailCardMeta>}
|
|
43
|
+
<textarea
|
|
44
|
+
aria-label="Résumé de l'article"
|
|
45
|
+
value={block.description}
|
|
46
|
+
placeholder="Le résumé de l'article."
|
|
47
|
+
rows={2}
|
|
48
|
+
onChange={(event) =>
|
|
49
|
+
onChange({ ...block, description: event.target.value })
|
|
50
|
+
}
|
|
51
|
+
className="w-full resize-none border-none bg-transparent p-0 text-[15px] leading-[1.5] outline-none [field-sizing:content] placeholder:opacity-40"
|
|
52
|
+
style={{ color: EMAIL_COLOR.ink }}
|
|
53
|
+
/>
|
|
54
|
+
</EmailCardShell>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ArticleBlockSettings({
|
|
59
|
+
block,
|
|
60
|
+
onChange,
|
|
61
|
+
}: EmailBlockComponentProps<EmailEditorArticleBlock>) {
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
<BlockOptionSection title="Contenu">
|
|
65
|
+
<TextOption
|
|
66
|
+
label="Titre"
|
|
67
|
+
value={block.title}
|
|
68
|
+
onChange={(title) => onChange({ ...block, title })}
|
|
69
|
+
/>
|
|
70
|
+
<TextAreaOption
|
|
71
|
+
label="Résumé"
|
|
72
|
+
value={block.description}
|
|
73
|
+
onChange={(description) => onChange({ ...block, description })}
|
|
74
|
+
/>
|
|
75
|
+
<TextOption
|
|
76
|
+
label="Auteur"
|
|
77
|
+
value={block.author}
|
|
78
|
+
onChange={(author) => onChange({ ...block, author })}
|
|
79
|
+
/>
|
|
80
|
+
<TextOption
|
|
81
|
+
label="Date de publication"
|
|
82
|
+
value={block.publishDate}
|
|
83
|
+
onChange={(publishDate) => onChange({ ...block, publishDate })}
|
|
84
|
+
placeholder="2026-07-20"
|
|
85
|
+
description="Format AAAA-MM-JJ ; la date est écrite dans la langue du destinataire à l'envoi."
|
|
86
|
+
/>
|
|
87
|
+
</BlockOptionSection>
|
|
88
|
+
<BlockOptionSection title="Apparence">
|
|
89
|
+
<TextOption
|
|
90
|
+
label="Adresse de l'image"
|
|
91
|
+
value={block.image.src}
|
|
92
|
+
onChange={(src) =>
|
|
93
|
+
onChange({ ...block, image: { ...block.image, src } })
|
|
94
|
+
}
|
|
95
|
+
placeholder="https://"
|
|
96
|
+
/>
|
|
97
|
+
<TextOption
|
|
98
|
+
label="Texte alternatif"
|
|
99
|
+
value={block.image.alt}
|
|
100
|
+
onChange={(alt) =>
|
|
101
|
+
onChange({ ...block, image: { ...block.image, alt } })
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
</BlockOptionSection>
|
|
105
|
+
<BlockOptionSection title="Lien">
|
|
106
|
+
<LinkOption
|
|
107
|
+
value={block.href}
|
|
108
|
+
onChange={(href) => onChange({ ...block, href })}
|
|
109
|
+
description="La carte entière renvoie vers cette adresse."
|
|
110
|
+
/>
|
|
111
|
+
</BlockOptionSection>
|
|
112
|
+
</>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const articleBlockDefinition: EmailBlockDefinition<EmailEditorArticleBlock> =
|
|
117
|
+
{
|
|
118
|
+
label: "Article",
|
|
119
|
+
icon: NewspaperIcon,
|
|
120
|
+
View: ArticleBlockView,
|
|
121
|
+
Settings: ArticleBlockSettings,
|
|
122
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Icon } from "@phosphor-icons/react";
|
|
2
|
+
import type { ComponentType, ReactNode } from "react";
|
|
3
|
+
import { articleBlockDefinition } from "#/blocks/article-block.tsx";
|
|
4
|
+
import { buttonBlockDefinition } from "#/blocks/button-block.tsx";
|
|
5
|
+
import { dividerBlockDefinition } from "#/blocks/divider-block.tsx";
|
|
6
|
+
import { gridBlockDefinition } from "#/blocks/grid-block.tsx";
|
|
7
|
+
import { headingBlockDefinition } from "#/blocks/heading-block.tsx";
|
|
8
|
+
import { imageBlockDefinition } from "#/blocks/image-block.tsx";
|
|
9
|
+
import { listBlockDefinition } from "#/blocks/list-block.tsx";
|
|
10
|
+
import { offerBlockDefinition } from "#/blocks/offer-block.tsx";
|
|
11
|
+
import { paragraphBlockDefinition } from "#/blocks/paragraph-block.tsx";
|
|
12
|
+
import { productBlockDefinition } from "#/blocks/product-block.tsx";
|
|
13
|
+
import { ratingBlockDefinition } from "#/blocks/rating-block.tsx";
|
|
14
|
+
import { statBlockDefinition } from "#/blocks/stat-block.tsx";
|
|
15
|
+
import { tableBlockDefinition } from "#/blocks/table-block.tsx";
|
|
16
|
+
import type {
|
|
17
|
+
EmailEditorBlock,
|
|
18
|
+
EmailEditorBlockType,
|
|
19
|
+
EmailEditorLeafBlockType,
|
|
20
|
+
EmailEditorPreview,
|
|
21
|
+
} from "#/document/types.ts";
|
|
22
|
+
|
|
23
|
+
/** What every block component receives, in the canvas and in the sidebar. */
|
|
24
|
+
export interface EmailBlockComponentProps<
|
|
25
|
+
B extends EmailEditorBlock = EmailEditorBlock,
|
|
26
|
+
> {
|
|
27
|
+
block: B;
|
|
28
|
+
selected: boolean;
|
|
29
|
+
onChange: (block: B) => void;
|
|
30
|
+
onUploadImage?: (file: File) => Promise<string>;
|
|
31
|
+
/** Which rendering the canvas is showing; a block whose layout differs
|
|
32
|
+
* between a phone and a desktop client reads it. Defaults to `desktop` in
|
|
33
|
+
* the settings panel, where it is irrelevant. */
|
|
34
|
+
preview?: EmailEditorPreview;
|
|
35
|
+
/** Container blocks only (the grid): the child rows the canvas has already
|
|
36
|
+
* wired for selection, drag-and-drop and their own toolbars. */
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Everything the editor needs to know about one block type. Adding a block =
|
|
42
|
+
* add its interface to `document/types.ts` (type union + `createEmailEditorBlock`
|
|
43
|
+
* case), write one `blocks/<type>-block.tsx` exporting a definition, and
|
|
44
|
+
* register it below — the mapped type makes a missing entry a compile error,
|
|
45
|
+
* and the menu, canvas and sidebar all pick it up from here.
|
|
46
|
+
*/
|
|
47
|
+
export interface EmailBlockDefinition<
|
|
48
|
+
B extends EmailEditorBlock = EmailEditorBlock,
|
|
49
|
+
> {
|
|
50
|
+
/** French label shown in the add-block menu. */
|
|
51
|
+
readonly label: string;
|
|
52
|
+
readonly icon: Icon;
|
|
53
|
+
/** The WYSIWYG rendering, edited in place on the canvas. */
|
|
54
|
+
readonly View: ComponentType<EmailBlockComponentProps<B>>;
|
|
55
|
+
/** The per-block settings panel; null when the block has none. */
|
|
56
|
+
readonly Settings: ComponentType<EmailBlockComponentProps<B>> | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const EMAIL_BLOCK_DEFINITIONS: {
|
|
60
|
+
readonly [T in EmailEditorBlockType]: EmailBlockDefinition<
|
|
61
|
+
Extract<EmailEditorBlock, { type: T }>
|
|
62
|
+
>;
|
|
63
|
+
} = {
|
|
64
|
+
heading: headingBlockDefinition,
|
|
65
|
+
paragraph: paragraphBlockDefinition,
|
|
66
|
+
button: buttonBlockDefinition,
|
|
67
|
+
image: imageBlockDefinition,
|
|
68
|
+
divider: dividerBlockDefinition,
|
|
69
|
+
list: listBlockDefinition,
|
|
70
|
+
stat: statBlockDefinition,
|
|
71
|
+
table: tableBlockDefinition,
|
|
72
|
+
article: articleBlockDefinition,
|
|
73
|
+
product: productBlockDefinition,
|
|
74
|
+
offer: offerBlockDefinition,
|
|
75
|
+
rating: ratingBlockDefinition,
|
|
76
|
+
grid: gridBlockDefinition,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const EMAIL_BLOCK_TYPES = Object.keys(
|
|
80
|
+
EMAIL_BLOCK_DEFINITIONS,
|
|
81
|
+
) as ReadonlyArray<EmailEditorBlockType>;
|
|
82
|
+
|
|
83
|
+
/** The types offered inside a grid cell: everything but the grid, which cannot
|
|
84
|
+
* nest. Derived from the registry so a new leaf block appears automatically. */
|
|
85
|
+
export const EMAIL_LEAF_BLOCK_TYPES = EMAIL_BLOCK_TYPES.filter(
|
|
86
|
+
(type): type is EmailEditorLeafBlockType => type !== "grid",
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
/** Look up a block's definition with the union narrowed away — the registry
|
|
90
|
+
* guarantees the definition matches the block's own type. */
|
|
91
|
+
export const emailBlockDefinition = (
|
|
92
|
+
block: EmailEditorBlock,
|
|
93
|
+
): EmailBlockDefinition =>
|
|
94
|
+
EMAIL_BLOCK_DEFINITIONS[block.type] as EmailBlockDefinition;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single-line field that *wraps* instead of clipping. An `<input>` scrolls
|
|
5
|
+
* its overflow out of sight, which is wrong on a canvas that has to mirror the
|
|
6
|
+
* email: inside a 178px grid cell a heading or a stat label has to run onto a
|
|
7
|
+
* second line exactly as the reader will see it.
|
|
8
|
+
*
|
|
9
|
+
* So it is a one-row `<textarea>` sized to its content, with Enter suppressed
|
|
10
|
+
* — the block model holds a plain string, and a line break in it would mean
|
|
11
|
+
* nothing to the renderer.
|
|
12
|
+
*/
|
|
13
|
+
export function BlockTextInput({
|
|
14
|
+
value,
|
|
15
|
+
onChange,
|
|
16
|
+
ariaLabel,
|
|
17
|
+
placeholder,
|
|
18
|
+
className,
|
|
19
|
+
style,
|
|
20
|
+
}: {
|
|
21
|
+
value: string;
|
|
22
|
+
onChange: (value: string) => void;
|
|
23
|
+
ariaLabel: string;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
style?: CSSProperties;
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<textarea
|
|
30
|
+
aria-label={ariaLabel}
|
|
31
|
+
rows={1}
|
|
32
|
+
value={value}
|
|
33
|
+
placeholder={placeholder}
|
|
34
|
+
onChange={(event) => onChange(event.target.value)}
|
|
35
|
+
onKeyDown={(event) => {
|
|
36
|
+
if (event.key === "Enter") {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
}
|
|
39
|
+
}}
|
|
40
|
+
className={`w-full resize-none overflow-hidden border-none bg-transparent p-0 outline-none [field-sizing:content] placeholder:opacity-40 ${className ?? ""}`}
|
|
41
|
+
style={style}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { CursorClickIcon } from "@phosphor-icons/react";
|
|
2
|
+
import type {
|
|
3
|
+
EmailBlockComponentProps,
|
|
4
|
+
EmailBlockDefinition,
|
|
5
|
+
} from "#/blocks/block-definitions.tsx";
|
|
6
|
+
import type {
|
|
7
|
+
EmailEditorAlignment,
|
|
8
|
+
EmailEditorButtonBlock,
|
|
9
|
+
EmailEditorButtonVariant,
|
|
10
|
+
} from "#/document/types.ts";
|
|
11
|
+
import { AlignmentOption } from "#/sections/block-options/alignment-option.tsx";
|
|
12
|
+
import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
|
|
13
|
+
import { SelectOption } from "#/sections/block-options/select-option.tsx";
|
|
14
|
+
import {
|
|
15
|
+
LinkOption,
|
|
16
|
+
TextOption,
|
|
17
|
+
} from "#/sections/block-options/text-option.tsx";
|
|
18
|
+
import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
|
|
19
|
+
|
|
20
|
+
const VARIANT_OPTIONS: ReadonlyArray<{
|
|
21
|
+
readonly value: EmailEditorButtonVariant;
|
|
22
|
+
readonly label: string;
|
|
23
|
+
}> = [
|
|
24
|
+
{ value: "primary", label: "Plein (couleur de marque)" },
|
|
25
|
+
{ value: "secondary", label: "Contour" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** Flexbox equivalents of the email's `align` attribute. */
|
|
29
|
+
const JUSTIFY: { readonly [A in EmailEditorAlignment]: string } = {
|
|
30
|
+
left: "flex-start",
|
|
31
|
+
center: "center",
|
|
32
|
+
right: "flex-end",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The call-to-action button. The label is edited in place; the target URL, the
|
|
37
|
+
* alignment and the variant live in the settings sidebar (no inline chip — the
|
|
38
|
+
* button carries one link for the whole block, unlike a paragraph's
|
|
39
|
+
* per-selection links). Mirrors the domain `emailButton` component.
|
|
40
|
+
*/
|
|
41
|
+
function ButtonBlockView({
|
|
42
|
+
block,
|
|
43
|
+
onChange,
|
|
44
|
+
}: EmailBlockComponentProps<EmailEditorButtonBlock>) {
|
|
45
|
+
const filled = block.variant === "primary";
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex" style={{ justifyContent: JUSTIFY[block.align] }}>
|
|
48
|
+
<span
|
|
49
|
+
className="inline-block rounded-lg px-[30px] py-[13px]"
|
|
50
|
+
style={{
|
|
51
|
+
backgroundColor: filled ? EMAIL_COLOR.brand : "transparent",
|
|
52
|
+
border: `1px solid ${EMAIL_COLOR.brand}`,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<input
|
|
56
|
+
aria-label="Libellé du bouton"
|
|
57
|
+
value={block.label}
|
|
58
|
+
onChange={(event) =>
|
|
59
|
+
onChange({ ...block, label: event.target.value })
|
|
60
|
+
}
|
|
61
|
+
placeholder="Votre bouton"
|
|
62
|
+
className="min-w-16 max-w-full border-none bg-transparent p-0 text-center font-semibold text-[15px] leading-none outline-none [field-sizing:content] placeholder:opacity-50"
|
|
63
|
+
style={{
|
|
64
|
+
fontFamily: EMAIL_FONT,
|
|
65
|
+
color: filled ? "#ffffff" : EMAIL_COLOR.brand,
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
</span>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ButtonBlockSettings({
|
|
74
|
+
block,
|
|
75
|
+
onChange,
|
|
76
|
+
}: EmailBlockComponentProps<EmailEditorButtonBlock>) {
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
<BlockOptionSection title="Contenu">
|
|
80
|
+
<TextOption
|
|
81
|
+
label="Libellé"
|
|
82
|
+
value={block.label}
|
|
83
|
+
onChange={(label) => onChange({ ...block, label })}
|
|
84
|
+
/>
|
|
85
|
+
</BlockOptionSection>
|
|
86
|
+
<BlockOptionSection title="Apparence">
|
|
87
|
+
<AlignmentOption
|
|
88
|
+
value={block.align}
|
|
89
|
+
onChange={(align) => onChange({ ...block, align })}
|
|
90
|
+
/>
|
|
91
|
+
<SelectOption
|
|
92
|
+
label="Style"
|
|
93
|
+
value={block.variant}
|
|
94
|
+
options={VARIANT_OPTIONS}
|
|
95
|
+
onChange={(variant) => onChange({ ...block, variant })}
|
|
96
|
+
description={
|
|
97
|
+
block.variant === "secondary"
|
|
98
|
+
? "Outlook (moteur Word) ignore les coins arrondis : le contour y sera à angles droits."
|
|
99
|
+
: undefined
|
|
100
|
+
}
|
|
101
|
+
/>
|
|
102
|
+
</BlockOptionSection>
|
|
103
|
+
<BlockOptionSection title="Lien">
|
|
104
|
+
<LinkOption
|
|
105
|
+
value={block.href}
|
|
106
|
+
onChange={(href) => onChange({ ...block, href })}
|
|
107
|
+
/>
|
|
108
|
+
</BlockOptionSection>
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const buttonBlockDefinition: EmailBlockDefinition<EmailEditorButtonBlock> =
|
|
114
|
+
{
|
|
115
|
+
label: "Bouton",
|
|
116
|
+
icon: CursorClickIcon,
|
|
117
|
+
View: ButtonBlockView,
|
|
118
|
+
Settings: ButtonBlockSettings,
|
|
119
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { MinusIcon } from "@phosphor-icons/react";
|
|
2
|
+
import type { EmailBlockDefinition } from "#/blocks/block-definitions.tsx";
|
|
3
|
+
import type { EmailEditorDividerBlock } from "#/document/types.ts";
|
|
4
|
+
import { EMAIL_COLOR } from "#/theme.ts";
|
|
5
|
+
|
|
6
|
+
/** A horizontal rule. Mirrors the domain `emailDivider` component. */
|
|
7
|
+
function DividerBlockView() {
|
|
8
|
+
return (
|
|
9
|
+
<hr className="my-2 border-t" style={{ borderColor: EMAIL_COLOR.border }} />
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const dividerBlockDefinition: EmailBlockDefinition<EmailEditorDividerBlock> =
|
|
14
|
+
{
|
|
15
|
+
label: "Séparateur",
|
|
16
|
+
icon: MinusIcon,
|
|
17
|
+
View: DividerBlockView,
|
|
18
|
+
Settings: null,
|
|
19
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ImageIcon } from "@phosphor-icons/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { EmailEditorCardImage } from "#/document/types.ts";
|
|
4
|
+
import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one card shape. The article, product and offer blocks are all
|
|
8
|
+
* card-shaped, so they share this shell (and its `emailCard` counterpart in
|
|
9
|
+
* the renderer) rather than each growing its own border, radius and padding —
|
|
10
|
+
* which is exactly how three cards drift into three looks.
|
|
11
|
+
*/
|
|
12
|
+
export function EmailCardShell({
|
|
13
|
+
image,
|
|
14
|
+
highlighted = false,
|
|
15
|
+
children,
|
|
16
|
+
}: {
|
|
17
|
+
/** Omit for a card with no visual; an empty `src` renders the placeholder. */
|
|
18
|
+
image?: EmailEditorCardImage;
|
|
19
|
+
highlighted?: boolean;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
className="overflow-hidden rounded-[14px]"
|
|
25
|
+
style={{
|
|
26
|
+
border: `${highlighted ? 2 : 1}px solid ${highlighted ? EMAIL_COLOR.brand : EMAIL_COLOR.border}`,
|
|
27
|
+
backgroundColor: EMAIL_COLOR.card,
|
|
28
|
+
fontFamily: EMAIL_FONT,
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
{image === undefined ? null : <EmailCardImage image={image} />}
|
|
32
|
+
<div className="flex flex-col gap-2 px-[18px] py-4">{children}</div>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function EmailCardImage({ image }: { image: EmailEditorCardImage }) {
|
|
38
|
+
if (image.src === "") {
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
className="flex h-28 items-center justify-center"
|
|
42
|
+
style={{
|
|
43
|
+
backgroundColor: EMAIL_COLOR.canvas,
|
|
44
|
+
color: EMAIL_COLOR.muted,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<ImageIcon size={24} aria-hidden />
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return <img src={image.src} alt={image.alt} className="block w-full" />;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The muted meta line a card puts under its title (author, date, period). */
|
|
55
|
+
export function EmailCardMeta({ children }: { children: ReactNode }) {
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className="text-[13px] leading-[1.4]"
|
|
59
|
+
style={{ color: EMAIL_COLOR.muted }}
|
|
60
|
+
>
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A card's call to action. The target lives in the settings, so the canvas
|
|
67
|
+
* only shows the pill; an empty label means the card has no button. */
|
|
68
|
+
export function EmailCardButton({ label }: { label: string }) {
|
|
69
|
+
if (label === "") {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return (
|
|
73
|
+
<span
|
|
74
|
+
className="mt-1 inline-block self-start rounded-lg px-[18px] py-[10px] font-semibold text-[14px] text-white"
|
|
75
|
+
style={{ backgroundColor: EMAIL_COLOR.brand }}
|
|
76
|
+
>
|
|
77
|
+
{label}
|
|
78
|
+
</span>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { ColumnsIcon } from "@phosphor-icons/react";
|
|
2
|
+
import type {
|
|
3
|
+
EmailBlockComponentProps,
|
|
4
|
+
EmailBlockDefinition,
|
|
5
|
+
} from "#/blocks/block-definitions.tsx";
|
|
6
|
+
import type {
|
|
7
|
+
EmailEditorGridBlock,
|
|
8
|
+
EmailEditorGridColumns,
|
|
9
|
+
EmailEditorGridMobileColumns,
|
|
10
|
+
} from "#/document/types.ts";
|
|
11
|
+
import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
|
|
12
|
+
import { SegmentedOption } from "#/sections/block-options/segmented-option.tsx";
|
|
13
|
+
|
|
14
|
+
/** The gutter between two cells, mirrored by the renderer's cell padding. */
|
|
15
|
+
export const EMAIL_GRID_GAP_PX = 16;
|
|
16
|
+
|
|
17
|
+
const DESKTOP_COLUMN_OPTIONS: ReadonlyArray<{
|
|
18
|
+
readonly value: EmailEditorGridColumns;
|
|
19
|
+
readonly label: string;
|
|
20
|
+
}> = [
|
|
21
|
+
{ value: 1, label: "1" },
|
|
22
|
+
{ value: 2, label: "2" },
|
|
23
|
+
{ value: 3, label: "3" },
|
|
24
|
+
{ value: 4, label: "4" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const MOBILE_COLUMN_OPTIONS: ReadonlyArray<{
|
|
28
|
+
readonly value: EmailEditorGridMobileColumns;
|
|
29
|
+
readonly label: string;
|
|
30
|
+
}> = [
|
|
31
|
+
{ value: 1, label: "1" },
|
|
32
|
+
{ value: 2, label: "2" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The layout shell of a multi-column row. The cells themselves — the child
|
|
37
|
+
* block rows and the « ajouter » slot — are composed by the canvas and slotted
|
|
38
|
+
* in as `children`, so the grid owns the layout and nothing else.
|
|
39
|
+
*
|
|
40
|
+
* The cells mirror the count the reader will actually get in the previewed
|
|
41
|
+
* client, which is what makes the desktop/mobile switch meaningful.
|
|
42
|
+
*/
|
|
43
|
+
function GridBlockView({
|
|
44
|
+
block,
|
|
45
|
+
preview,
|
|
46
|
+
children,
|
|
47
|
+
}: EmailBlockComponentProps<EmailEditorGridBlock>) {
|
|
48
|
+
const columns =
|
|
49
|
+
preview === "mobile" ? block.mobileColumns : block.desktopColumns;
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className="grid items-start"
|
|
53
|
+
style={{
|
|
54
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
55
|
+
gap: `${EMAIL_GRID_GAP_PX}px`,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function GridBlockSettings({
|
|
64
|
+
block,
|
|
65
|
+
onChange,
|
|
66
|
+
}: EmailBlockComponentProps<EmailEditorGridBlock>) {
|
|
67
|
+
return (
|
|
68
|
+
<BlockOptionSection title="Apparence">
|
|
69
|
+
<SegmentedOption
|
|
70
|
+
label="Colonnes (ordinateur)"
|
|
71
|
+
value={block.desktopColumns}
|
|
72
|
+
options={DESKTOP_COLUMN_OPTIONS}
|
|
73
|
+
onChange={(desktopColumns) => onChange({ ...block, desktopColumns })}
|
|
74
|
+
/>
|
|
75
|
+
<SegmentedOption
|
|
76
|
+
label="Colonnes (mobile)"
|
|
77
|
+
value={block.mobileColumns}
|
|
78
|
+
options={MOBILE_COLUMN_OPTIONS}
|
|
79
|
+
onChange={(mobileColumns) => onChange({ ...block, mobileColumns })}
|
|
80
|
+
description={
|
|
81
|
+
block.mobileColumns === block.desktopColumns
|
|
82
|
+
? undefined
|
|
83
|
+
: "Un nombre de colonnes différent sur mobile repose sur une media query : l'application Gmail sur compte tiers l'ignore et repasse à une colonne."
|
|
84
|
+
}
|
|
85
|
+
/>
|
|
86
|
+
</BlockOptionSection>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const gridBlockDefinition: EmailBlockDefinition<EmailEditorGridBlock> = {
|
|
91
|
+
label: "Colonnes",
|
|
92
|
+
icon: ColumnsIcon,
|
|
93
|
+
View: GridBlockView,
|
|
94
|
+
Settings: GridBlockSettings,
|
|
95
|
+
};
|