@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,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Field,
|
|
3
|
+
FieldContent,
|
|
4
|
+
FieldDescription,
|
|
5
|
+
FieldLabel,
|
|
6
|
+
} from "@voila.dev/ui/components/field";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One labelled option in « Réglages du bloc ». Every block setting goes
|
|
11
|
+
* through this row rather than hand-rolling `useId()` + `Label` + control, so
|
|
12
|
+
* spacing, label typography and description placement cannot drift from block
|
|
13
|
+
* to block.
|
|
14
|
+
*
|
|
15
|
+
* `vertical` stacks the label above a full-width control (text, select,
|
|
16
|
+
* segmented control); `horizontal` puts the control beside the label, which is
|
|
17
|
+
* what a `Switch` wants.
|
|
18
|
+
*/
|
|
19
|
+
export function BlockOptionRow({
|
|
20
|
+
label,
|
|
21
|
+
htmlFor,
|
|
22
|
+
description,
|
|
23
|
+
orientation = "vertical",
|
|
24
|
+
children,
|
|
25
|
+
}: {
|
|
26
|
+
label: string;
|
|
27
|
+
/** The id of the control this row labels; omit for controls that label
|
|
28
|
+
* themselves (a radio group carries its own `aria-label`). */
|
|
29
|
+
htmlFor?: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
orientation?: "vertical" | "horizontal";
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
}) {
|
|
34
|
+
if (orientation === "horizontal") {
|
|
35
|
+
return (
|
|
36
|
+
<Field orientation="horizontal">
|
|
37
|
+
<FieldContent>
|
|
38
|
+
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
|
39
|
+
{description === undefined ? null : (
|
|
40
|
+
<FieldDescription>{description}</FieldDescription>
|
|
41
|
+
)}
|
|
42
|
+
</FieldContent>
|
|
43
|
+
{children}
|
|
44
|
+
</Field>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Field>
|
|
50
|
+
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
|
51
|
+
{children}
|
|
52
|
+
{description === undefined ? null : (
|
|
53
|
+
<FieldDescription>{description}</FieldDescription>
|
|
54
|
+
)}
|
|
55
|
+
</Field>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A titled group of rows. §1.4 of the editor plan: once a block carries more
|
|
61
|
+
* than a handful of options they are split into « Contenu », « Apparence » and
|
|
62
|
+
* « Lien », always in that order.
|
|
63
|
+
*/
|
|
64
|
+
export function BlockOptionSection({
|
|
65
|
+
title,
|
|
66
|
+
children,
|
|
67
|
+
}: {
|
|
68
|
+
title: string;
|
|
69
|
+
children: ReactNode;
|
|
70
|
+
}) {
|
|
71
|
+
return (
|
|
72
|
+
<section className="flex flex-col gap-3">
|
|
73
|
+
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
|
74
|
+
{title}
|
|
75
|
+
</h4>
|
|
76
|
+
{children}
|
|
77
|
+
</section>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { MoneyInput } from "@voila.dev/ui/components/money-input";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import type { EmailEditorMoney } from "#/document/types.ts";
|
|
4
|
+
import { BlockOptionRow } from "#/sections/block-options/block-option-row.tsx";
|
|
5
|
+
import { EMAIL_PREVIEW_LOCALE } from "#/theme.ts";
|
|
6
|
+
|
|
7
|
+
/** EUR has two decimals, and it is the only currency the platform transacts
|
|
8
|
+
* in; widening this means mirroring the domain's `currencyDecimals`. */
|
|
9
|
+
const MINOR_UNITS_PER_UNIT = 100;
|
|
10
|
+
|
|
11
|
+
/** `2550` → `"25.50"`, and back. The field holds major units because that is
|
|
12
|
+
* what an author types; the document only ever stores the integer. */
|
|
13
|
+
const moneyToInputValue = (money: EmailEditorMoney): string =>
|
|
14
|
+
money.amountInMinorUnits === 0
|
|
15
|
+
? ""
|
|
16
|
+
: (money.amountInMinorUnits / MINOR_UNITS_PER_UNIT).toString();
|
|
17
|
+
|
|
18
|
+
const inputValueToMinorUnits = (value: string): number => {
|
|
19
|
+
const parsed = Number.parseFloat(value.replace(",", "."));
|
|
20
|
+
return Number.isFinite(parsed)
|
|
21
|
+
? Math.round(parsed * MINOR_UNITS_PER_UNIT)
|
|
22
|
+
: 0;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How a price reads on the canvas. The sent email formats it per recipient
|
|
27
|
+
* (`renderMarketingEmailDocument`); the canvas shows the default locale, so an
|
|
28
|
+
* author never sees an amount their reader will not get.
|
|
29
|
+
*/
|
|
30
|
+
export const formatPreviewPrice = (money: EmailEditorMoney): string =>
|
|
31
|
+
new Intl.NumberFormat(EMAIL_PREVIEW_LOCALE, {
|
|
32
|
+
style: "currency",
|
|
33
|
+
currency: money.currency,
|
|
34
|
+
}).format(money.amountInMinorUnits / MINOR_UNITS_PER_UNIT);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The one price control. The document stores integer minor units and a
|
|
38
|
+
* currency (never a formatted string), so one campaign can be sent in several
|
|
39
|
+
* locales and formatted per recipient at render time.
|
|
40
|
+
*/
|
|
41
|
+
export function MoneyOption({
|
|
42
|
+
label,
|
|
43
|
+
value,
|
|
44
|
+
onChange,
|
|
45
|
+
description,
|
|
46
|
+
}: {
|
|
47
|
+
label: string;
|
|
48
|
+
value: EmailEditorMoney;
|
|
49
|
+
onChange: (money: EmailEditorMoney) => void;
|
|
50
|
+
description?: string;
|
|
51
|
+
}) {
|
|
52
|
+
const id = useId();
|
|
53
|
+
return (
|
|
54
|
+
<BlockOptionRow label={label} htmlFor={id} description={description}>
|
|
55
|
+
<MoneyInput
|
|
56
|
+
id={id}
|
|
57
|
+
value={moneyToInputValue(value)}
|
|
58
|
+
onValueChange={(next) =>
|
|
59
|
+
onChange({
|
|
60
|
+
...value,
|
|
61
|
+
amountInMinorUnits: inputValueToMinorUnits(next),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
currency={value.currency}
|
|
65
|
+
currencyLabel="Devise"
|
|
66
|
+
/>
|
|
67
|
+
</BlockOptionRow>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SegmentedControl,
|
|
3
|
+
SegmentedControlItem,
|
|
4
|
+
} from "@voila.dev/ui/components/segmented-control";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import { BlockOptionRow } from "#/sections/block-options/block-option-row.tsx";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A closed choice small enough to show every option at once: alignment, a
|
|
10
|
+
* column count. Values may be numbers; they are matched back by their string
|
|
11
|
+
* form, so the callback still receives the typed value.
|
|
12
|
+
*
|
|
13
|
+
* An option carrying an `icon` renders icon-only and keeps its `label` as the
|
|
14
|
+
* accessible name.
|
|
15
|
+
*/
|
|
16
|
+
export function SegmentedOption<Value extends string | number>({
|
|
17
|
+
label,
|
|
18
|
+
value,
|
|
19
|
+
options,
|
|
20
|
+
onChange,
|
|
21
|
+
description,
|
|
22
|
+
}: {
|
|
23
|
+
label: string;
|
|
24
|
+
value: Value;
|
|
25
|
+
options: ReadonlyArray<{
|
|
26
|
+
readonly value: Value;
|
|
27
|
+
readonly label: string;
|
|
28
|
+
readonly icon?: ReactNode;
|
|
29
|
+
}>;
|
|
30
|
+
onChange: (value: Value) => void;
|
|
31
|
+
description?: string;
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<BlockOptionRow label={label} description={description}>
|
|
35
|
+
<SegmentedControl
|
|
36
|
+
size="sm"
|
|
37
|
+
aria-label={label}
|
|
38
|
+
value={String(value)}
|
|
39
|
+
onValueChange={(next) => {
|
|
40
|
+
const picked = options.find(
|
|
41
|
+
(option) => String(option.value) === String(next),
|
|
42
|
+
);
|
|
43
|
+
if (picked !== undefined) {
|
|
44
|
+
onChange(picked.value);
|
|
45
|
+
}
|
|
46
|
+
}}
|
|
47
|
+
className="w-full"
|
|
48
|
+
>
|
|
49
|
+
{options.map((option) => (
|
|
50
|
+
<SegmentedControlItem
|
|
51
|
+
key={String(option.value)}
|
|
52
|
+
value={String(option.value)}
|
|
53
|
+
aria-label={option.icon === undefined ? undefined : option.label}
|
|
54
|
+
className="flex-1"
|
|
55
|
+
>
|
|
56
|
+
{option.icon ?? option.label}
|
|
57
|
+
</SegmentedControlItem>
|
|
58
|
+
))}
|
|
59
|
+
</SegmentedControl>
|
|
60
|
+
</BlockOptionRow>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NativeSelect,
|
|
3
|
+
NativeSelectOption,
|
|
4
|
+
} from "@voila.dev/ui/components/native-select";
|
|
5
|
+
import { Switch } from "@voila.dev/ui/components/switch";
|
|
6
|
+
import { useId } from "react";
|
|
7
|
+
import { BlockOptionRow } from "#/sections/block-options/block-option-row.tsx";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The one control for a small closed choice — the admin-form default in this
|
|
11
|
+
* repo. Option values may be numbers (a heading level); they are matched back
|
|
12
|
+
* by their string form, so the callback still receives the typed value.
|
|
13
|
+
*/
|
|
14
|
+
export function SelectOption<Value extends string | number>({
|
|
15
|
+
label,
|
|
16
|
+
value,
|
|
17
|
+
options,
|
|
18
|
+
onChange,
|
|
19
|
+
description,
|
|
20
|
+
}: {
|
|
21
|
+
label: string;
|
|
22
|
+
value: Value;
|
|
23
|
+
options: ReadonlyArray<{ readonly value: Value; readonly label: string }>;
|
|
24
|
+
onChange: (value: Value) => void;
|
|
25
|
+
description?: string;
|
|
26
|
+
}) {
|
|
27
|
+
const id = useId();
|
|
28
|
+
return (
|
|
29
|
+
<BlockOptionRow label={label} htmlFor={id} description={description}>
|
|
30
|
+
<NativeSelect
|
|
31
|
+
id={id}
|
|
32
|
+
size="sm"
|
|
33
|
+
value={String(value)}
|
|
34
|
+
onChange={(event) => {
|
|
35
|
+
const picked = options.find(
|
|
36
|
+
(option) => String(option.value) === event.target.value,
|
|
37
|
+
);
|
|
38
|
+
if (picked !== undefined) {
|
|
39
|
+
onChange(picked.value);
|
|
40
|
+
}
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{options.map((option) => (
|
|
44
|
+
<NativeSelectOption key={String(option.value)} value={option.value}>
|
|
45
|
+
{option.label}
|
|
46
|
+
</NativeSelectOption>
|
|
47
|
+
))}
|
|
48
|
+
</NativeSelect>
|
|
49
|
+
</BlockOptionRow>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The one boolean control: a switch beside its label, description below. */
|
|
54
|
+
export function ToggleOption({
|
|
55
|
+
label,
|
|
56
|
+
checked,
|
|
57
|
+
onChange,
|
|
58
|
+
description,
|
|
59
|
+
}: {
|
|
60
|
+
label: string;
|
|
61
|
+
checked: boolean;
|
|
62
|
+
onChange: (checked: boolean) => void;
|
|
63
|
+
description?: string;
|
|
64
|
+
}) {
|
|
65
|
+
const id = useId();
|
|
66
|
+
return (
|
|
67
|
+
<BlockOptionRow
|
|
68
|
+
label={label}
|
|
69
|
+
htmlFor={id}
|
|
70
|
+
description={description}
|
|
71
|
+
orientation="horizontal"
|
|
72
|
+
>
|
|
73
|
+
<Switch id={id} checked={checked} onCheckedChange={onChange} />
|
|
74
|
+
</BlockOptionRow>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Input } from "@voila.dev/ui/components/input";
|
|
2
|
+
import { Textarea } from "@voila.dev/ui/components/textarea";
|
|
3
|
+
import { useId } from "react";
|
|
4
|
+
import { BlockOptionRow } from "#/sections/block-options/block-option-row.tsx";
|
|
5
|
+
|
|
6
|
+
/** A single-line text option (a label, a title, a price caption). */
|
|
7
|
+
export function TextOption({
|
|
8
|
+
label,
|
|
9
|
+
value,
|
|
10
|
+
onChange,
|
|
11
|
+
placeholder,
|
|
12
|
+
description,
|
|
13
|
+
}: {
|
|
14
|
+
label: string;
|
|
15
|
+
value: string;
|
|
16
|
+
onChange: (value: string) => void;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
}) {
|
|
20
|
+
const id = useId();
|
|
21
|
+
return (
|
|
22
|
+
<BlockOptionRow label={label} htmlFor={id} description={description}>
|
|
23
|
+
<Input
|
|
24
|
+
id={id}
|
|
25
|
+
value={value}
|
|
26
|
+
placeholder={placeholder}
|
|
27
|
+
onChange={(event) => onChange(event.target.value)}
|
|
28
|
+
/>
|
|
29
|
+
</BlockOptionRow>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A multi-line text option (a card description, a rating question). */
|
|
34
|
+
export function TextAreaOption({
|
|
35
|
+
label,
|
|
36
|
+
value,
|
|
37
|
+
onChange,
|
|
38
|
+
placeholder,
|
|
39
|
+
description,
|
|
40
|
+
rows = 3,
|
|
41
|
+
}: {
|
|
42
|
+
label: string;
|
|
43
|
+
value: string;
|
|
44
|
+
onChange: (value: string) => void;
|
|
45
|
+
placeholder?: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
rows?: number;
|
|
48
|
+
}) {
|
|
49
|
+
const id = useId();
|
|
50
|
+
return (
|
|
51
|
+
<BlockOptionRow label={label} htmlFor={id} description={description}>
|
|
52
|
+
<Textarea
|
|
53
|
+
id={id}
|
|
54
|
+
rows={rows}
|
|
55
|
+
value={value}
|
|
56
|
+
placeholder={placeholder}
|
|
57
|
+
onChange={(event) => onChange(event.target.value)}
|
|
58
|
+
/>
|
|
59
|
+
</BlockOptionRow>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The one destination-URL control. Always « Lien (URL) », always
|
|
65
|
+
* `type="url"` with the `https://` placeholder, so a link field is
|
|
66
|
+
* recognisable at a glance in any block (§1.2 of the editor plan).
|
|
67
|
+
*/
|
|
68
|
+
export function LinkOption({
|
|
69
|
+
label = "Lien (URL)",
|
|
70
|
+
value,
|
|
71
|
+
onChange,
|
|
72
|
+
description,
|
|
73
|
+
}: {
|
|
74
|
+
label?: string;
|
|
75
|
+
value: string;
|
|
76
|
+
onChange: (href: string) => void;
|
|
77
|
+
description?: string;
|
|
78
|
+
}) {
|
|
79
|
+
const id = useId();
|
|
80
|
+
return (
|
|
81
|
+
<BlockOptionRow label={label} htmlFor={id} description={description}>
|
|
82
|
+
<Input
|
|
83
|
+
id={id}
|
|
84
|
+
type="url"
|
|
85
|
+
placeholder="https://"
|
|
86
|
+
value={value}
|
|
87
|
+
onChange={(event) => onChange(event.target.value)}
|
|
88
|
+
/>
|
|
89
|
+
</BlockOptionRow>
|
|
90
|
+
);
|
|
91
|
+
}
|