@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
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CheckIcon,
|
|
3
|
+
PlusIcon,
|
|
4
|
+
SealPercentIcon,
|
|
5
|
+
XIcon,
|
|
6
|
+
} from "@phosphor-icons/react";
|
|
7
|
+
import { Button } from "@voila.dev/ui/components/button";
|
|
8
|
+
import type {
|
|
9
|
+
EmailBlockComponentProps,
|
|
10
|
+
EmailBlockDefinition,
|
|
11
|
+
} from "#/blocks/block-definitions.tsx";
|
|
12
|
+
import { BlockTextInput } from "#/blocks/block-text-input.tsx";
|
|
13
|
+
import {
|
|
14
|
+
EmailCardButton,
|
|
15
|
+
EmailCardMeta,
|
|
16
|
+
EmailCardShell,
|
|
17
|
+
} from "#/blocks/email-card-shell.tsx";
|
|
18
|
+
import type { EmailEditorOfferBlock } from "#/document/types.ts";
|
|
19
|
+
import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
|
|
20
|
+
import {
|
|
21
|
+
formatPreviewPrice,
|
|
22
|
+
MoneyOption,
|
|
23
|
+
} from "#/sections/block-options/money-option.tsx";
|
|
24
|
+
import { ToggleOption } from "#/sections/block-options/select-option.tsx";
|
|
25
|
+
import {
|
|
26
|
+
LinkOption,
|
|
27
|
+
TextAreaOption,
|
|
28
|
+
TextOption,
|
|
29
|
+
} from "#/sections/block-options/text-option.tsx";
|
|
30
|
+
import { EMAIL_COLOR } from "#/theme.ts";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A pricing plan on the shared card shell: an eyebrow, a name, a price with an
|
|
34
|
+
* optional period, a feature list and a call to action. `highlighted` draws
|
|
35
|
+
* the recommended plan of a row in the brand colour.
|
|
36
|
+
*/
|
|
37
|
+
function OfferBlockView({
|
|
38
|
+
block,
|
|
39
|
+
onChange,
|
|
40
|
+
}: EmailBlockComponentProps<EmailEditorOfferBlock>) {
|
|
41
|
+
return (
|
|
42
|
+
<EmailCardShell
|
|
43
|
+
image={block.image.src === "" ? undefined : block.image}
|
|
44
|
+
highlighted={block.highlighted}
|
|
45
|
+
>
|
|
46
|
+
<OfferHeader block={block} onChange={onChange} />
|
|
47
|
+
{block.description === "" ? null : (
|
|
48
|
+
<p
|
|
49
|
+
className="text-[15px] leading-[1.5]"
|
|
50
|
+
style={{ color: EMAIL_COLOR.ink }}
|
|
51
|
+
>
|
|
52
|
+
{block.description}
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
<OfferFeatureList features={block.features} />
|
|
56
|
+
<EmailCardButton label={block.buttonLabel} />
|
|
57
|
+
</EmailCardShell>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The plan's identity: its optional surtitle, its name, and the price with
|
|
62
|
+
* its optional recurrence. */
|
|
63
|
+
function OfferHeader({
|
|
64
|
+
block,
|
|
65
|
+
onChange,
|
|
66
|
+
}: {
|
|
67
|
+
block: EmailEditorOfferBlock;
|
|
68
|
+
onChange: (block: EmailEditorOfferBlock) => void;
|
|
69
|
+
}) {
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
{block.eyebrow === "" ? null : (
|
|
73
|
+
<span
|
|
74
|
+
className="font-semibold text-[11px] uppercase tracking-[0.06em]"
|
|
75
|
+
style={{ color: EMAIL_COLOR.brand }}
|
|
76
|
+
>
|
|
77
|
+
{block.eyebrow}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
<BlockTextInput
|
|
81
|
+
ariaLabel="Nom de l'offre"
|
|
82
|
+
value={block.name}
|
|
83
|
+
placeholder="Nom de l'offre"
|
|
84
|
+
onChange={(name) => onChange({ ...block, name })}
|
|
85
|
+
className="font-bold text-[17px] leading-[1.3]"
|
|
86
|
+
style={{ color: EMAIL_COLOR.brand }}
|
|
87
|
+
/>
|
|
88
|
+
<div className="flex items-baseline gap-1.5">
|
|
89
|
+
<span
|
|
90
|
+
className="font-bold text-[26px] leading-[1.1]"
|
|
91
|
+
style={{ color: EMAIL_COLOR.ink }}
|
|
92
|
+
>
|
|
93
|
+
{formatPreviewPrice(block.price)}
|
|
94
|
+
</span>
|
|
95
|
+
{block.period === "" ? null : (
|
|
96
|
+
<EmailCardMeta>{block.period}</EmailCardMeta>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
</>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The ticked « points inclus », mirroring the bulleted list the renderer
|
|
104
|
+
* emits for them. */
|
|
105
|
+
function OfferFeatureList({ features }: { features: ReadonlyArray<string> }) {
|
|
106
|
+
if (features.length === 0) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return (
|
|
110
|
+
<ul className="flex list-none flex-col gap-1 p-0">
|
|
111
|
+
{features.map((feature, index) => (
|
|
112
|
+
<li
|
|
113
|
+
key={index}
|
|
114
|
+
className="flex items-start gap-2 text-[14px] leading-[1.5]"
|
|
115
|
+
style={{ color: EMAIL_COLOR.ink }}
|
|
116
|
+
>
|
|
117
|
+
<CheckIcon
|
|
118
|
+
size={16}
|
|
119
|
+
aria-hidden
|
|
120
|
+
style={{ color: EMAIL_COLOR.brand, marginTop: 3 }}
|
|
121
|
+
/>
|
|
122
|
+
{feature}
|
|
123
|
+
</li>
|
|
124
|
+
))}
|
|
125
|
+
</ul>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function OfferFeatureSettings({
|
|
130
|
+
block,
|
|
131
|
+
onChange,
|
|
132
|
+
}: {
|
|
133
|
+
block: EmailEditorOfferBlock;
|
|
134
|
+
onChange: (block: EmailEditorOfferBlock) => void;
|
|
135
|
+
}) {
|
|
136
|
+
return (
|
|
137
|
+
<div className="flex flex-col gap-2">
|
|
138
|
+
<span className="font-medium text-sm">Points inclus</span>
|
|
139
|
+
{block.features.map((feature, index) => (
|
|
140
|
+
<div key={index} className="flex items-center gap-2">
|
|
141
|
+
<input
|
|
142
|
+
aria-label={`Point inclus ${index + 1}`}
|
|
143
|
+
value={feature}
|
|
144
|
+
onChange={(event) =>
|
|
145
|
+
onChange({
|
|
146
|
+
...block,
|
|
147
|
+
features: block.features.map((current, at) =>
|
|
148
|
+
at === index ? event.target.value : current,
|
|
149
|
+
),
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
className="h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
153
|
+
/>
|
|
154
|
+
<Button
|
|
155
|
+
variant="ghost"
|
|
156
|
+
size="icon-sm"
|
|
157
|
+
aria-label={`Supprimer le point ${index + 1}`}
|
|
158
|
+
onClick={() =>
|
|
159
|
+
onChange({
|
|
160
|
+
...block,
|
|
161
|
+
features: block.features.filter((_, at) => at !== index),
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
>
|
|
165
|
+
<XIcon aria-hidden />
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
))}
|
|
169
|
+
<Button
|
|
170
|
+
variant="outline"
|
|
171
|
+
size="sm"
|
|
172
|
+
onClick={() =>
|
|
173
|
+
onChange({ ...block, features: [...block.features, ""] })
|
|
174
|
+
}
|
|
175
|
+
>
|
|
176
|
+
<PlusIcon aria-hidden />
|
|
177
|
+
Ajouter un point
|
|
178
|
+
</Button>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function OfferBlockSettings({
|
|
184
|
+
block,
|
|
185
|
+
onChange,
|
|
186
|
+
}: EmailBlockComponentProps<EmailEditorOfferBlock>) {
|
|
187
|
+
return (
|
|
188
|
+
<>
|
|
189
|
+
<BlockOptionSection title="Contenu">
|
|
190
|
+
<TextOption
|
|
191
|
+
label="Surtitre"
|
|
192
|
+
value={block.eyebrow}
|
|
193
|
+
onChange={(eyebrow) => onChange({ ...block, eyebrow })}
|
|
194
|
+
placeholder="Le plus choisi"
|
|
195
|
+
/>
|
|
196
|
+
<TextOption
|
|
197
|
+
label="Nom"
|
|
198
|
+
value={block.name}
|
|
199
|
+
onChange={(name) => onChange({ ...block, name })}
|
|
200
|
+
/>
|
|
201
|
+
<MoneyOption
|
|
202
|
+
label="Prix"
|
|
203
|
+
value={block.price}
|
|
204
|
+
onChange={(price) => onChange({ ...block, price })}
|
|
205
|
+
/>
|
|
206
|
+
<TextOption
|
|
207
|
+
label="Périodicité"
|
|
208
|
+
value={block.period}
|
|
209
|
+
onChange={(period) => onChange({ ...block, period })}
|
|
210
|
+
placeholder="par mois"
|
|
211
|
+
description="Laissez vide pour un tarif unique."
|
|
212
|
+
/>
|
|
213
|
+
<TextAreaOption
|
|
214
|
+
label="Description"
|
|
215
|
+
value={block.description}
|
|
216
|
+
onChange={(description) => onChange({ ...block, description })}
|
|
217
|
+
/>
|
|
218
|
+
<OfferFeatureSettings block={block} onChange={onChange} />
|
|
219
|
+
</BlockOptionSection>
|
|
220
|
+
<BlockOptionSection title="Apparence">
|
|
221
|
+
<ToggleOption
|
|
222
|
+
label="Mettre en avant"
|
|
223
|
+
checked={block.highlighted}
|
|
224
|
+
onChange={(highlighted) => onChange({ ...block, highlighted })}
|
|
225
|
+
description="Encadre la carte dans la couleur de marque. Outlook (moteur Word) affiche des angles droits."
|
|
226
|
+
/>
|
|
227
|
+
<TextOption
|
|
228
|
+
label="Adresse de l'image"
|
|
229
|
+
value={block.image.src}
|
|
230
|
+
onChange={(src) =>
|
|
231
|
+
onChange({ ...block, image: { ...block.image, src } })
|
|
232
|
+
}
|
|
233
|
+
placeholder="https://"
|
|
234
|
+
description="Laissez vide pour une offre sans visuel."
|
|
235
|
+
/>
|
|
236
|
+
<TextOption
|
|
237
|
+
label="Texte alternatif"
|
|
238
|
+
value={block.image.alt}
|
|
239
|
+
onChange={(alt) =>
|
|
240
|
+
onChange({ ...block, image: { ...block.image, alt } })
|
|
241
|
+
}
|
|
242
|
+
/>
|
|
243
|
+
</BlockOptionSection>
|
|
244
|
+
<BlockOptionSection title="Lien">
|
|
245
|
+
<TextOption
|
|
246
|
+
label="Libellé du bouton"
|
|
247
|
+
value={block.buttonLabel}
|
|
248
|
+
onChange={(buttonLabel) => onChange({ ...block, buttonLabel })}
|
|
249
|
+
placeholder="Choisir cette offre"
|
|
250
|
+
description="Laissez vide pour une carte sans bouton."
|
|
251
|
+
/>
|
|
252
|
+
<LinkOption
|
|
253
|
+
value={block.buttonHref}
|
|
254
|
+
onChange={(buttonHref) => onChange({ ...block, buttonHref })}
|
|
255
|
+
/>
|
|
256
|
+
</BlockOptionSection>
|
|
257
|
+
</>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export const offerBlockDefinition: EmailBlockDefinition<EmailEditorOfferBlock> =
|
|
262
|
+
{
|
|
263
|
+
label: "Offre",
|
|
264
|
+
icon: SealPercentIcon,
|
|
265
|
+
View: OfferBlockView,
|
|
266
|
+
Settings: OfferBlockSettings,
|
|
267
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { TextAlignLeftIcon } from "@phosphor-icons/react";
|
|
2
|
+
import type {
|
|
3
|
+
EmailBlockComponentProps,
|
|
4
|
+
EmailBlockDefinition,
|
|
5
|
+
} from "#/blocks/block-definitions.tsx";
|
|
6
|
+
import { RichTextEditable } from "#/blocks/rich-text-editable.tsx";
|
|
7
|
+
import type { EmailEditorParagraphBlock } from "#/document/types.ts";
|
|
8
|
+
import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A rich body paragraph, edited in place (bold, italic, underline and links
|
|
12
|
+
* via the block toolbar). Mirrors the domain `emailParagraph` component
|
|
13
|
+
* (16px ink).
|
|
14
|
+
*/
|
|
15
|
+
function ParagraphBlockView({
|
|
16
|
+
block,
|
|
17
|
+
onChange,
|
|
18
|
+
}: EmailBlockComponentProps<EmailEditorParagraphBlock>) {
|
|
19
|
+
return (
|
|
20
|
+
<RichTextEditable
|
|
21
|
+
spans={block.spans}
|
|
22
|
+
onChange={(spans) => onChange({ ...block, spans })}
|
|
23
|
+
ariaLabel="Paragraphe"
|
|
24
|
+
placeholder="Votre texte. Utilisez {{firstName}} pour personnaliser."
|
|
25
|
+
className="text-[16px] leading-[1.6]"
|
|
26
|
+
style={{ fontFamily: EMAIL_FONT, color: EMAIL_COLOR.ink }}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ParagraphBlockSettings() {
|
|
32
|
+
return (
|
|
33
|
+
<p className="text-muted-foreground text-xs">
|
|
34
|
+
{
|
|
35
|
+
"Mettez en forme le texte (gras, italique, souligné, lien) depuis la barre d'outils du bloc. Personnalisez avec {{firstName}}, {{lastName}} ou {{email}} ; la valeur du contact est substituée à l'envoi."
|
|
36
|
+
}
|
|
37
|
+
</p>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const paragraphBlockDefinition: EmailBlockDefinition<EmailEditorParagraphBlock> =
|
|
42
|
+
{
|
|
43
|
+
label: "Paragraphe",
|
|
44
|
+
icon: TextAlignLeftIcon,
|
|
45
|
+
View: ParagraphBlockView,
|
|
46
|
+
Settings: ParagraphBlockSettings,
|
|
47
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { TagIcon } 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 { EmailCardButton, EmailCardShell } from "#/blocks/email-card-shell.tsx";
|
|
8
|
+
import type { EmailEditorProductBlock } from "#/document/types.ts";
|
|
9
|
+
import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
|
|
10
|
+
import {
|
|
11
|
+
formatPreviewPrice,
|
|
12
|
+
MoneyOption,
|
|
13
|
+
} from "#/sections/block-options/money-option.tsx";
|
|
14
|
+
import { ToggleOption } from "#/sections/block-options/select-option.tsx";
|
|
15
|
+
import {
|
|
16
|
+
LinkOption,
|
|
17
|
+
TextAreaOption,
|
|
18
|
+
TextOption,
|
|
19
|
+
} from "#/sections/block-options/text-option.tsx";
|
|
20
|
+
import { EMAIL_COLOR } from "#/theme.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A catalogue item on the shared card shell: visual, name, description, price
|
|
24
|
+
* with an optional struck-through « tarif de base », and a call to action.
|
|
25
|
+
*/
|
|
26
|
+
function ProductBlockView({
|
|
27
|
+
block,
|
|
28
|
+
onChange,
|
|
29
|
+
}: EmailBlockComponentProps<EmailEditorProductBlock>) {
|
|
30
|
+
return (
|
|
31
|
+
<EmailCardShell image={block.image}>
|
|
32
|
+
<BlockTextInput
|
|
33
|
+
ariaLabel="Nom du produit"
|
|
34
|
+
value={block.name}
|
|
35
|
+
placeholder="Nom du produit"
|
|
36
|
+
onChange={(name) => onChange({ ...block, name })}
|
|
37
|
+
className="font-bold text-[17px] leading-[1.3]"
|
|
38
|
+
style={{ color: EMAIL_COLOR.brand }}
|
|
39
|
+
/>
|
|
40
|
+
<textarea
|
|
41
|
+
aria-label="Description du produit"
|
|
42
|
+
value={block.description}
|
|
43
|
+
placeholder="La description du produit."
|
|
44
|
+
rows={2}
|
|
45
|
+
onChange={(event) =>
|
|
46
|
+
onChange({ ...block, description: event.target.value })
|
|
47
|
+
}
|
|
48
|
+
className="w-full resize-none border-none bg-transparent p-0 text-[15px] leading-[1.5] outline-none [field-sizing:content] placeholder:opacity-40"
|
|
49
|
+
style={{ color: EMAIL_COLOR.ink }}
|
|
50
|
+
/>
|
|
51
|
+
<div className="flex items-baseline gap-2">
|
|
52
|
+
<span
|
|
53
|
+
className="font-bold text-[18px]"
|
|
54
|
+
style={{ color: EMAIL_COLOR.ink }}
|
|
55
|
+
>
|
|
56
|
+
{formatPreviewPrice(block.price)}
|
|
57
|
+
</span>
|
|
58
|
+
{block.compareAtPrice === null ? null : (
|
|
59
|
+
<span
|
|
60
|
+
className="text-[14px] line-through"
|
|
61
|
+
style={{ color: EMAIL_COLOR.muted }}
|
|
62
|
+
>
|
|
63
|
+
{formatPreviewPrice(block.compareAtPrice)}
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
<EmailCardButton label={block.buttonLabel} />
|
|
68
|
+
</EmailCardShell>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ProductBlockSettings({
|
|
73
|
+
block,
|
|
74
|
+
onChange,
|
|
75
|
+
}: EmailBlockComponentProps<EmailEditorProductBlock>) {
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
<BlockOptionSection title="Contenu">
|
|
79
|
+
<TextOption
|
|
80
|
+
label="Nom"
|
|
81
|
+
value={block.name}
|
|
82
|
+
onChange={(name) => onChange({ ...block, name })}
|
|
83
|
+
/>
|
|
84
|
+
<TextAreaOption
|
|
85
|
+
label="Description"
|
|
86
|
+
value={block.description}
|
|
87
|
+
onChange={(description) => onChange({ ...block, description })}
|
|
88
|
+
/>
|
|
89
|
+
<MoneyOption
|
|
90
|
+
label="Prix"
|
|
91
|
+
value={block.price}
|
|
92
|
+
onChange={(price) => onChange({ ...block, price })}
|
|
93
|
+
/>
|
|
94
|
+
<ToggleOption
|
|
95
|
+
label="Tarif de base barré"
|
|
96
|
+
checked={block.compareAtPrice !== null}
|
|
97
|
+
onChange={(enabled) =>
|
|
98
|
+
onChange({
|
|
99
|
+
...block,
|
|
100
|
+
compareAtPrice: enabled ? { ...block.price } : null,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
description="Affiche un prix barré à côté du prix courant."
|
|
104
|
+
/>
|
|
105
|
+
{block.compareAtPrice === null ? null : (
|
|
106
|
+
<MoneyOption
|
|
107
|
+
label="Tarif de base"
|
|
108
|
+
value={block.compareAtPrice}
|
|
109
|
+
onChange={(compareAtPrice) =>
|
|
110
|
+
onChange({ ...block, compareAtPrice })
|
|
111
|
+
}
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
</BlockOptionSection>
|
|
115
|
+
<BlockOptionSection title="Apparence">
|
|
116
|
+
<TextOption
|
|
117
|
+
label="Adresse de l'image"
|
|
118
|
+
value={block.image.src}
|
|
119
|
+
onChange={(src) =>
|
|
120
|
+
onChange({ ...block, image: { ...block.image, src } })
|
|
121
|
+
}
|
|
122
|
+
placeholder="https://"
|
|
123
|
+
/>
|
|
124
|
+
<TextOption
|
|
125
|
+
label="Texte alternatif"
|
|
126
|
+
value={block.image.alt}
|
|
127
|
+
onChange={(alt) =>
|
|
128
|
+
onChange({ ...block, image: { ...block.image, alt } })
|
|
129
|
+
}
|
|
130
|
+
/>
|
|
131
|
+
</BlockOptionSection>
|
|
132
|
+
<BlockOptionSection title="Lien">
|
|
133
|
+
<TextOption
|
|
134
|
+
label="Libellé du bouton"
|
|
135
|
+
value={block.buttonLabel}
|
|
136
|
+
onChange={(buttonLabel) => onChange({ ...block, buttonLabel })}
|
|
137
|
+
placeholder="Commander"
|
|
138
|
+
description="Laissez vide pour une carte sans bouton."
|
|
139
|
+
/>
|
|
140
|
+
<LinkOption
|
|
141
|
+
value={block.href}
|
|
142
|
+
onChange={(href) => onChange({ ...block, href })}
|
|
143
|
+
/>
|
|
144
|
+
</BlockOptionSection>
|
|
145
|
+
</>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const productBlockDefinition: EmailBlockDefinition<EmailEditorProductBlock> =
|
|
150
|
+
{
|
|
151
|
+
label: "Produit",
|
|
152
|
+
icon: TagIcon,
|
|
153
|
+
View: ProductBlockView,
|
|
154
|
+
Settings: ProductBlockSettings,
|
|
155
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { StarIcon } from "@phosphor-icons/react";
|
|
2
|
+
import type {
|
|
3
|
+
EmailBlockComponentProps,
|
|
4
|
+
EmailBlockDefinition,
|
|
5
|
+
} from "#/blocks/block-definitions.tsx";
|
|
6
|
+
import { RichTextEditable } from "#/blocks/rich-text-editable.tsx";
|
|
7
|
+
import type {
|
|
8
|
+
EmailEditorRatingBlock,
|
|
9
|
+
EmailEditorRatingStyle,
|
|
10
|
+
} from "#/document/types.ts";
|
|
11
|
+
import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
|
|
12
|
+
import { SelectOption } from "#/sections/block-options/select-option.tsx";
|
|
13
|
+
import {
|
|
14
|
+
LinkOption,
|
|
15
|
+
TextOption,
|
|
16
|
+
} from "#/sections/block-options/text-option.tsx";
|
|
17
|
+
import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
|
|
18
|
+
|
|
19
|
+
/** The scale, mirroring the domain's `EMAIL_RATING_SCALE`. */
|
|
20
|
+
const RATING_SCALE = [1, 2, 3, 4, 5] as const;
|
|
21
|
+
|
|
22
|
+
const STYLE_OPTIONS: ReadonlyArray<{
|
|
23
|
+
readonly value: EmailEditorRatingStyle;
|
|
24
|
+
readonly label: string;
|
|
25
|
+
}> = [
|
|
26
|
+
{ value: "filled", label: "Étoiles pleines" },
|
|
27
|
+
{ value: "outline", label: "Étoiles contour" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A one-to-five satisfaction question. In the sent email each star is its own
|
|
32
|
+
* link carrying `?rating=N`, which makes the five steps five separately
|
|
33
|
+
* countable tracked links; on the canvas they are inert.
|
|
34
|
+
*/
|
|
35
|
+
function RatingBlockView({
|
|
36
|
+
block,
|
|
37
|
+
onChange,
|
|
38
|
+
}: EmailBlockComponentProps<EmailEditorRatingBlock>) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex flex-col items-center gap-3 text-center">
|
|
41
|
+
<RichTextEditable
|
|
42
|
+
spans={block.question}
|
|
43
|
+
onChange={(question) => onChange({ ...block, question })}
|
|
44
|
+
ariaLabel="Question"
|
|
45
|
+
placeholder="Que pensez-vous de votre dernière mission ?"
|
|
46
|
+
className="text-center text-[16px] leading-[1.6]"
|
|
47
|
+
style={{ fontFamily: EMAIL_FONT, color: EMAIL_COLOR.ink }}
|
|
48
|
+
/>
|
|
49
|
+
<div className="flex items-center gap-2" aria-hidden>
|
|
50
|
+
{RATING_SCALE.map((rating) => (
|
|
51
|
+
<StarIcon
|
|
52
|
+
key={rating}
|
|
53
|
+
size={30}
|
|
54
|
+
weight={block.style === "filled" ? "fill" : "regular"}
|
|
55
|
+
color={EMAIL_COLOR.brand}
|
|
56
|
+
/>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
{block.lowLabel === "" && block.highLabel === "" ? null : (
|
|
60
|
+
<div
|
|
61
|
+
className="flex w-full max-w-[220px] justify-between text-[12px]"
|
|
62
|
+
style={{ color: EMAIL_COLOR.muted, fontFamily: EMAIL_FONT }}
|
|
63
|
+
>
|
|
64
|
+
<span>{block.lowLabel}</span>
|
|
65
|
+
<span>{block.highLabel}</span>
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function RatingBlockSettings({
|
|
73
|
+
block,
|
|
74
|
+
onChange,
|
|
75
|
+
}: EmailBlockComponentProps<EmailEditorRatingBlock>) {
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
<BlockOptionSection title="Contenu">
|
|
79
|
+
<p className="text-muted-foreground text-xs">
|
|
80
|
+
La question se saisit et se met en forme directement sur le bloc,
|
|
81
|
+
comme un paragraphe.
|
|
82
|
+
</p>
|
|
83
|
+
<TextOption
|
|
84
|
+
label="Libellé bas de l'échelle"
|
|
85
|
+
value={block.lowLabel}
|
|
86
|
+
onChange={(lowLabel) => onChange({ ...block, lowLabel })}
|
|
87
|
+
placeholder="Pas du tout"
|
|
88
|
+
/>
|
|
89
|
+
<TextOption
|
|
90
|
+
label="Libellé haut de l'échelle"
|
|
91
|
+
value={block.highLabel}
|
|
92
|
+
onChange={(highLabel) => onChange({ ...block, highLabel })}
|
|
93
|
+
placeholder="Tout à fait"
|
|
94
|
+
/>
|
|
95
|
+
</BlockOptionSection>
|
|
96
|
+
<BlockOptionSection title="Apparence">
|
|
97
|
+
<SelectOption
|
|
98
|
+
label="Style"
|
|
99
|
+
value={block.style}
|
|
100
|
+
options={STYLE_OPTIONS}
|
|
101
|
+
onChange={(style) => onChange({ ...block, style })}
|
|
102
|
+
/>
|
|
103
|
+
</BlockOptionSection>
|
|
104
|
+
<BlockOptionSection title="Lien">
|
|
105
|
+
<LinkOption
|
|
106
|
+
value={block.href}
|
|
107
|
+
onChange={(href) => onChange({ ...block, href })}
|
|
108
|
+
description="Chaque étoile pointe vers cette adresse avec « rating=1 » à « rating=5 » ajouté : les cinq notes sont donc comptées séparément dans les statistiques de clic."
|
|
109
|
+
/>
|
|
110
|
+
</BlockOptionSection>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const ratingBlockDefinition: EmailBlockDefinition<EmailEditorRatingBlock> =
|
|
116
|
+
{
|
|
117
|
+
label: "Notation",
|
|
118
|
+
icon: StarIcon,
|
|
119
|
+
View: RatingBlockView,
|
|
120
|
+
Settings: RatingBlockSettings,
|
|
121
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
editorElementToTextSpans,
|
|
4
|
+
textSpansToEditorHtml,
|
|
5
|
+
textSpansToPlainText,
|
|
6
|
+
} from "#/document/rich-text.ts";
|
|
7
|
+
import type { EmailEditorTextSpan } from "#/document/types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The in-place rich-text surface shared by every block that holds spans (the
|
|
11
|
+
* paragraph, a list item). Bold, italic, underline and links are applied from
|
|
12
|
+
* the block toolbar, which acts on the live selection — so this component only
|
|
13
|
+
* has to keep the DOM and the span model in sync.
|
|
14
|
+
*
|
|
15
|
+
* The DOM is rewritten only when the document changes from *outside* the
|
|
16
|
+
* editable, which is what keeps the caret from jumping while typing.
|
|
17
|
+
*/
|
|
18
|
+
export function RichTextEditable({
|
|
19
|
+
spans,
|
|
20
|
+
onChange,
|
|
21
|
+
ariaLabel,
|
|
22
|
+
placeholder,
|
|
23
|
+
className,
|
|
24
|
+
style,
|
|
25
|
+
}: {
|
|
26
|
+
spans: ReadonlyArray<EmailEditorTextSpan>;
|
|
27
|
+
onChange: (spans: ReadonlyArray<EmailEditorTextSpan>) => void;
|
|
28
|
+
ariaLabel: string;
|
|
29
|
+
placeholder: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
style?: React.CSSProperties;
|
|
32
|
+
}) {
|
|
33
|
+
const editableRef = useRef<HTMLDivElement>(null);
|
|
34
|
+
const renderedHtmlRef = useRef<string | null>(null);
|
|
35
|
+
const html = textSpansToEditorHtml(spans);
|
|
36
|
+
|
|
37
|
+
useLayoutEffect(() => {
|
|
38
|
+
const editable = editableRef.current;
|
|
39
|
+
if (editable && html !== renderedHtmlRef.current) {
|
|
40
|
+
editable.innerHTML = html;
|
|
41
|
+
renderedHtmlRef.current = html;
|
|
42
|
+
}
|
|
43
|
+
}, [html]);
|
|
44
|
+
|
|
45
|
+
const handleInput = () => {
|
|
46
|
+
const editable = editableRef.current;
|
|
47
|
+
if (!editable) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const next = editorElementToTextSpans(editable);
|
|
51
|
+
// Remember the normalized serialization: when the parent echoes the
|
|
52
|
+
// change back, the effect sees the same html and leaves the DOM (and
|
|
53
|
+
// the caret) alone.
|
|
54
|
+
renderedHtmlRef.current = textSpansToEditorHtml(next);
|
|
55
|
+
onChange(next);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const empty = textSpansToPlainText(spans).trim() === "";
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
// biome-ignore lint/a11y/useSemanticElements: rich text needs contentEditable; the div IS the text box.
|
|
62
|
+
// biome-ignore lint/a11y/useFocusableInteractive: contentEditable elements are natively focusable.
|
|
63
|
+
<div
|
|
64
|
+
ref={editableRef}
|
|
65
|
+
role="textbox"
|
|
66
|
+
aria-multiline="true"
|
|
67
|
+
aria-label={ariaLabel}
|
|
68
|
+
contentEditable
|
|
69
|
+
suppressContentEditableWarning
|
|
70
|
+
data-placeholder={placeholder}
|
|
71
|
+
onInput={handleInput}
|
|
72
|
+
className={
|
|
73
|
+
// `min-h-[1lh]` keeps one line of height while the box is empty: a
|
|
74
|
+
// collapsed contentEditable would let the absolutely-positioned
|
|
75
|
+
// placeholder spill over whatever follows the block.
|
|
76
|
+
`relative min-h-[1lh] w-full whitespace-pre-wrap break-words outline-none [&_a]:text-[#151b77] [&_a]:underline ${className ?? ""} ` +
|
|
77
|
+
(empty
|
|
78
|
+
? // `inset-x-0` matters: without horizontal offsets the pseudo-element
|
|
79
|
+
// takes its static position, which under a centred block starts at
|
|
80
|
+
// the middle of the line and wraps. Spanning the full width lets it
|
|
81
|
+
// inherit the block's own text alignment instead.
|
|
82
|
+
"before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:opacity-40 before:content-[attr(data-placeholder)]"
|
|
83
|
+
: "")
|
|
84
|
+
}
|
|
85
|
+
style={style}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
}
|