@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,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
+ }