@fonderie/react-courier-admin-screens 0.9.0 → 0.11.0

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/dist/index.js CHANGED
@@ -2,12 +2,15 @@
2
2
  import {
3
3
  createAdminT,
4
4
  formatAdminDate,
5
- groupTemplatesByType,
6
- missingTemplateLocales
5
+ suggestTemplateLocales,
6
+ templateLanguages
7
7
  } from "@fonderie/client";
8
8
  import {
9
+ useBuiltInTemplate,
9
10
  useTemplate,
11
+ useTemplateCatalog,
10
12
  useTemplatePreview,
13
+ useTemplateResolution,
11
14
  useTemplateRevisions,
12
15
  useTemplates
13
16
  } from "@fonderie/react-courier-admin";
@@ -28,8 +31,16 @@ function TemplateEditorScreen({
28
31
  }) {
29
32
  const t = useMemo(() => createAdminT(uiLocale), [uiLocale]);
30
33
  const { template, isLoading, error, refresh } = useTemplate(client, type, locale);
31
- const { templates, saveTemplate, removeTemplate } = useTemplates(client);
32
- const siblings = groupTemplatesByType(templates).find((g) => g.type === type)?.entries ?? [];
34
+ const notSaved = error?.status === 404;
35
+ const { builtIn, isLoading: builtInLoading } = useBuiltInTemplate(client, type, locale);
36
+ const { catalog, refresh: refreshCatalog } = useTemplateCatalog(client);
37
+ const defaultLocale = catalog?.defaultLocale ?? "en-US";
38
+ const email = catalog?.emails.find((e) => e.type === type);
39
+ const languages = email ? templateLanguages(email, defaultLocale) : [];
40
+ const source = template ?? (notSaved ? builtIn : null);
41
+ const { resolution, resolve } = useTemplateResolution(client);
42
+ const [probe, setProbe] = useState("");
43
+ const { saveTemplate, removeTemplate } = useTemplates(client);
33
44
  const [isDeleting, setIsDeleting] = useState(false);
34
45
  const { revisions, rollback } = useTemplateRevisions(client, type, locale);
35
46
  const [isSaving, setIsSaving] = useState(false);
@@ -42,12 +53,15 @@ function TemplateEditorScreen({
42
53
  const [sampleJson, setSampleJson] = useState("{}");
43
54
  const [sampleError, setSampleError] = useState(null);
44
55
  useEffect(() => {
45
- if (!template) return;
46
- setSubject(template.subject ?? "");
47
- setHtml(template.html ?? "");
48
- setText(template.text);
49
- setActive(template.active);
50
- }, [template]);
56
+ if (!source) return;
57
+ setSubject(source.subject ?? "");
58
+ setHtml(source.html ?? "");
59
+ setText(source.text);
60
+ setActive(template ? template.active : true);
61
+ }, [source, template]);
62
+ useEffect(() => {
63
+ void resolve(type, locale ?? defaultLocale);
64
+ }, [resolve, type, locale, defaultLocale]);
51
65
  const run = useCallback(async () => {
52
66
  let data = {};
53
67
  try {
@@ -75,13 +89,15 @@ function TemplateEditorScreen({
75
89
  }, [renderPreview, type, text, subject, html, locale, sampleJson, t]);
76
90
  const runRef = useRef(run);
77
91
  runRef.current = run;
78
- const loadedType = template ? `${type}:${locale ?? ""}` : null;
92
+ const loadedType = source ? `${type}:${locale ?? ""}` : null;
79
93
  useEffect(() => {
80
94
  if (!loadedType) return;
81
95
  const timer = setTimeout(() => void runRef.current(), PREVIEW_DEBOUNCE_MS);
82
96
  return () => clearTimeout(timer);
83
97
  }, [loadedType, subject, html, text, sampleJson]);
84
- const dirty = !!template && (subject !== (template.subject ?? "") || html !== (template.html ?? "") || text !== template.text || active !== template.active);
98
+ const dirty = notSaved && !!builtIn || !!template && (subject !== (template.subject ?? "") || html !== (template.html ?? "") || text !== template.text || active !== template.active);
99
+ const protectedRow = !locale && (email?.system ?? system);
100
+ const shipsHere = locale ? languages.some((l) => l.locale === locale && l.builtIn) : !!email?.builtIn.default;
85
101
  const handleSubmit = async (event) => {
86
102
  event.preventDefault();
87
103
  setIsSaving(true);
@@ -92,7 +108,7 @@ function TemplateEditorScreen({
92
108
  if (html) input.html = html;
93
109
  if (template?.version !== void 0) input.ifVersion = template.version;
94
110
  await saveTemplate(type, input, locale);
95
- await refresh();
111
+ await Promise.all([refresh(), refreshCatalog()]);
96
112
  onSaved?.();
97
113
  } catch (err) {
98
114
  setSaveError(err);
@@ -100,9 +116,32 @@ function TemplateEditorScreen({
100
116
  setIsSaving(false);
101
117
  }
102
118
  };
103
- const switchTo = (entry) => {
104
- if (dirty && !window.confirm(t("templates.editor.discardChanges"))) return;
105
- onSelectLocale?.(entry);
119
+ const switchTo = (next) => {
120
+ if (dirty && !(notSaved && !!builtIn) && !window.confirm(t("templates.editor.discardChanges")))
121
+ return;
122
+ onSelectLocale?.(next);
123
+ };
124
+ const handleReset = async () => {
125
+ if (!builtIn || !window.confirm(t("templates.editor.confirmReset", { locale: locale ?? defaultLocale })))
126
+ return;
127
+ setIsDeleting(true);
128
+ setSaveError(null);
129
+ try {
130
+ if (locale) {
131
+ await removeTemplate(type, locale);
132
+ } else {
133
+ const input = { text: builtIn.text, active };
134
+ if (builtIn.subject) input.subject = builtIn.subject;
135
+ if (builtIn.html) input.html = builtIn.html;
136
+ if (template?.version !== void 0) input.ifVersion = template.version;
137
+ await saveTemplate(type, input, null);
138
+ }
139
+ await Promise.all([refresh(), refreshCatalog()]);
140
+ } catch (err) {
141
+ setSaveError(err);
142
+ } finally {
143
+ setIsDeleting(false);
144
+ }
106
145
  };
107
146
  const handleDelete = async () => {
108
147
  const message = locale ? t("templates.editor.confirmDeleteLocale", { type, locale }) : t("templates.editor.confirmDelete", { type });
@@ -118,8 +157,9 @@ function TemplateEditorScreen({
118
157
  setIsDeleting(false);
119
158
  }
120
159
  };
121
- if (isLoading) return /* @__PURE__ */ jsx("p", { style: styles.status, children: t("templates.editor.loading") });
122
- if (error)
160
+ if (isLoading || notSaved && builtInLoading)
161
+ return /* @__PURE__ */ jsx("p", { style: styles.status, children: t("templates.editor.loading") });
162
+ if (error && !(notSaved && builtIn))
123
163
  return /* @__PURE__ */ jsx("p", { style: styles.error, role: "alert", children: error.explanation });
124
164
  return /* @__PURE__ */ jsxs("div", { style: styles.container, children: [
125
165
  /* @__PURE__ */ jsx("h1", { style: styles.title, children: type }),
@@ -127,19 +167,23 @@ function TemplateEditorScreen({
127
167
  onSelectLocale ? (
128
168
  // Each tab opens another stored row, so this is navigation, not an
129
169
  // ARIA tablist: plain buttons, the open one marked aria-current.
130
- /* @__PURE__ */ jsx("nav", { "aria-label": t("templates.editor.locales"), style: styles.tabs, children: siblings.map((entry) => {
131
- const selected = entry.locale === (locale ?? null);
170
+ /* @__PURE__ */ jsx("nav", { "aria-label": t("templates.editor.locales"), style: styles.tabs, children: languages.map((l) => {
171
+ const selected = l.locale === (locale ?? null);
132
172
  return /* @__PURE__ */ jsx(
133
173
  "button",
134
174
  {
135
175
  type: "button",
136
176
  "aria-current": selected ? "true" : void 0,
137
- onClick: () => selected ? void 0 : switchTo(entry),
138
- style: selected ? styles.tabSelected : entry.active ? styles.tab : styles.tabInactive,
139
- title: entry.active ? void 0 : t("common.status.inactive"),
140
- children: entry.locale ?? t("templates.defaultChip")
177
+ onClick: () => selected ? void 0 : switchTo({ type, locale: l.locale, system: !!email?.system }),
178
+ style: {
179
+ ...selected ? styles.tabSelected : styles.tab,
180
+ ...l.saved ? {} : styles.tabBuiltIn,
181
+ ...l.active ? {} : styles.tabInactive
182
+ },
183
+ title: l.active ? void 0 : t("common.status.inactive"),
184
+ children: l.label
141
185
  },
142
- entry.locale ?? ""
186
+ l.label
143
187
  );
144
188
  }) })
145
189
  ) : null,
@@ -150,17 +194,21 @@ function TemplateEditorScreen({
150
194
  style: { ...styles.rollbackButton, marginLeft: "auto" },
151
195
  onClick: () => {
152
196
  if (dirty && !window.confirm(t("templates.editor.discardChanges"))) return;
153
- onAddLocale(type, { locales: missingTemplateLocales(templates, type) });
197
+ onAddLocale(type, {
198
+ locales: catalog ? suggestTemplateLocales(catalog, type) : [],
199
+ defaultLocale
200
+ });
154
201
  },
155
202
  children: t("templates.editor.addLocale")
156
203
  }
157
204
  ) : null
158
205
  ] }) : null,
159
206
  /* @__PURE__ */ jsxs("p", { style: styles.meta, children: [
160
- /* @__PURE__ */ jsx("strong", { children: locale ?? t("templates.defaultLocale") }),
161
- " \xB7 v",
162
- template?.version ?? 1,
163
- system ? ` \xB7 ${t("templates.editor.builtInNote")}` : ""
207
+ /* @__PURE__ */ jsx("strong", { children: locale ?? defaultLocale }),
208
+ template ? ` \xB7 v${template.version}` : "",
209
+ template && protectedRow ? ` \xB7 ${t("templates.editor.builtInNote")}` : "",
210
+ !template && builtIn ? ` \xB7 ${t("templates.editor.builtInCopy")}` : "",
211
+ resolution && resolution.chain.length > 1 && resolution.requested === (locale ?? defaultLocale) ? ` \xB7 ${t("templates.editor.chain", { chain: [...resolution.chain, defaultLocale].join(" \u2192 ") })}` : ""
164
212
  ] }),
165
213
  /* @__PURE__ */ jsxs("div", { style: styles.split, children: [
166
214
  /* @__PURE__ */ jsxs("div", { style: styles.column, children: [
@@ -236,7 +284,16 @@ function TemplateEditorScreen({
236
284
  }
237
285
  ),
238
286
  !dirty && !isSaving && /* @__PURE__ */ jsx("span", { style: styles.meta, children: t("templates.editor.noChanges") }),
239
- !system ? /* @__PURE__ */ jsx(
287
+ template && shipsHere && builtIn ? /* @__PURE__ */ jsx(
288
+ "button",
289
+ {
290
+ type: "button",
291
+ disabled: isDeleting,
292
+ onClick: () => void handleReset(),
293
+ style: { ...styles.rollbackButton, height: 36, marginLeft: "auto" },
294
+ children: t("templates.editor.resetToBuiltIn")
295
+ }
296
+ ) : template && !protectedRow ? /* @__PURE__ */ jsx(
240
297
  "button",
241
298
  {
242
299
  type: "button",
@@ -291,6 +348,43 @@ function TemplateEditorScreen({
291
348
  ) : preview ? /* @__PURE__ */ jsx("pre", { style: styles.previewText, children: preview.text }) : /* @__PURE__ */ jsx("p", { style: styles.meta, children: t("templates.editor.nothingRendered") })
292
349
  ] })
293
350
  ] }),
351
+ /* @__PURE__ */ jsxs("div", { style: styles.revisions, children: [
352
+ /* @__PURE__ */ jsx("h2", { style: styles.subtitle, children: t("templates.editor.whoReceives") }),
353
+ /* @__PURE__ */ jsxs(
354
+ "form",
355
+ {
356
+ style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" },
357
+ onSubmit: (event) => {
358
+ event.preventDefault();
359
+ if (probe.trim()) void resolve(type, probe.trim());
360
+ },
361
+ children: [
362
+ /* @__PURE__ */ jsx(
363
+ "input",
364
+ {
365
+ "aria-label": t("templates.editor.whoReceives"),
366
+ style: { ...styles.input, width: 200 },
367
+ value: probe,
368
+ placeholder: t("templates.editor.whoPlaceholder"),
369
+ spellCheck: false,
370
+ onChange: (event) => setProbe(event.target.value)
371
+ }
372
+ ),
373
+ /* @__PURE__ */ jsx("button", { type: "submit", style: { ...styles.rollbackButton, height: 36 }, children: t("templates.editor.check") }),
374
+ resolution && probe.trim() ? /* @__PURE__ */ jsxs("span", { style: { ...styles.meta, marginBottom: 0 }, role: "status", children: [
375
+ t("templates.editor.receives", {
376
+ requested: resolution.requested,
377
+ sent: resolution.sent
378
+ }),
379
+ " ",
380
+ resolution.source === "saved" ? t("templates.editor.sourceSaved") : t("templates.editor.sourceBuiltIn"),
381
+ " ",
382
+ resolution.chain.length > 0 ? `(${[...resolution.chain, resolution.defaultLocale].join(" \u2192 ")})` : ""
383
+ ] }) : null
384
+ ]
385
+ }
386
+ )
387
+ ] }),
294
388
  revisions.length > 0 && /* @__PURE__ */ jsxs("div", { style: styles.revisions, children: [
295
389
  /* @__PURE__ */ jsx("h2", { style: styles.subtitle, children: t("templates.editor.history") }),
296
390
  /* @__PURE__ */ jsx("ul", { style: styles.list, children: revisions.map((rev) => /* @__PURE__ */ jsxs("li", { style: styles.row, children: [
@@ -347,7 +441,9 @@ var styles = {
347
441
  borderBottomColor: "var(--fonderie-text,#171717)",
348
442
  cursor: "default"
349
443
  },
350
- tabInactive: { ...tab, opacity: 0.55, textDecoration: "line-through" },
444
+ tabInactive: { opacity: 0.55, textDecoration: "line-through" },
445
+ // Fonderie's built-in copy, not saved by the app — the list's dashed chip.
446
+ tabBuiltIn: { fontStyle: "italic" },
351
447
  // Two columns, always: the editor on the left, the rendered result on the
352
448
  // right. Stacking the preview under a long form hid it below the fold.
353
449
  split: {
@@ -517,10 +613,10 @@ var styles = {
517
613
  // src/screens/TemplateListScreen.tsx
518
614
  import {
519
615
  createAdminT as createAdminT2,
520
- groupTemplatesByType as groupTemplatesByType2
616
+ templateLanguages as templateLanguages2
521
617
  } from "@fonderie/client";
522
- import { useTemplates as useTemplates2 } from "@fonderie/react-courier-admin";
523
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
618
+ import { useTemplateCatalog as useTemplateCatalog2 } from "@fonderie/react-courier-admin";
619
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
524
620
  function TemplateListScreen({
525
621
  client,
526
622
  onSelectTemplate,
@@ -528,8 +624,14 @@ function TemplateListScreen({
528
624
  locale
529
625
  }) {
530
626
  const t = createAdminT2(locale);
531
- const { templates, isLoading, error } = useTemplates2(client);
532
- const groups = groupTemplatesByType2(templates);
627
+ const { catalog, isLoading, error } = useTemplateCatalog2(client);
628
+ const emails = catalog?.emails ?? [];
629
+ const defaultLocale = catalog?.defaultLocale ?? "en-US";
630
+ const chipStyle = (l) => ({
631
+ ...chip,
632
+ ...l.saved ? {} : chipBuiltIn,
633
+ ...l.active ? {} : chipOff
634
+ });
533
635
  return /* @__PURE__ */ jsxs2("div", { style: styles2.container, children: [
534
636
  /* @__PURE__ */ jsxs2(
535
637
  "div",
@@ -543,8 +645,12 @@ function TemplateListScreen({
543
645
  type: "button",
544
646
  onClick: () => onCreateTemplate({
545
647
  locales: [
546
- ...new Set(templates.map((row) => row.locale).filter((l) => !!l))
547
- ]
648
+ ...new Set(
649
+ emails.flatMap(
650
+ (e) => templateLanguages2(e, defaultLocale).filter((l) => l.locale !== null).map((l) => l.label)
651
+ )
652
+ )
653
+ ].sort()
548
654
  }),
549
655
  style: styles2.newButton,
550
656
  children: t("templates.list.newTemplate")
@@ -554,39 +660,47 @@ function TemplateListScreen({
554
660
  }
555
661
  ),
556
662
  /* @__PURE__ */ jsx2("p", { style: styles2.hint, children: t("templates.list.hint") }),
557
- isLoading ? /* @__PURE__ */ jsx2("p", { style: styles2.status, children: t("templates.list.loading") }) : error ? /* @__PURE__ */ jsx2("p", { style: styles2.error, role: "alert", children: error.explanation }) : /* @__PURE__ */ jsx2("ul", { style: styles2.list, children: groups.map((group) => /* @__PURE__ */ jsxs2("li", { style: styles2.row, children: [
558
- /* @__PURE__ */ jsxs2(
559
- "button",
560
- {
561
- type: "button",
562
- onClick: () => onSelectTemplate?.(group.primary),
563
- style: styles2.rowButton,
564
- children: [
565
- /* @__PURE__ */ jsx2("span", { style: styles2.type, children: group.type }),
566
- group.system ? /* @__PURE__ */ jsx2("span", { style: styles2.builtIn, children: t("templates.builtInBadge") }) : null,
567
- /* @__PURE__ */ jsxs2("span", { style: group.primary.active ? styles2.active : styles2.inactive, children: [
663
+ isLoading ? /* @__PURE__ */ jsx2("p", { style: styles2.status, children: t("templates.list.loading") }) : error ? /* @__PURE__ */ jsx2("p", { style: styles2.error, role: "alert", children: error.explanation }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
664
+ /* @__PURE__ */ jsx2("ul", { style: styles2.list, children: emails.map((email) => {
665
+ const languages = templateLanguages2(email, defaultLocale);
666
+ const primary = languages.find((l) => l.locale === null) ?? languages[0];
667
+ const open = (l) => onSelectTemplate?.({
668
+ type: email.type,
669
+ locale: l?.locale ?? null,
670
+ system: email.system
671
+ });
672
+ return (
673
+ // Columns, not a flex row: the language chips line up down the
674
+ // list whatever the email name or badge.
675
+ /* @__PURE__ */ jsxs2("li", { style: styles2.row, children: [
676
+ /* @__PURE__ */ jsxs2("button", { type: "button", onClick: () => open(primary), style: styles2.rowButton, children: [
677
+ /* @__PURE__ */ jsx2("span", { style: styles2.type, children: email.type }),
678
+ email.system ? /* @__PURE__ */ jsx2("span", { style: styles2.builtIn, children: t("templates.builtInBadge") }) : /* @__PURE__ */ jsx2("span", {})
679
+ ] }),
680
+ /* @__PURE__ */ jsx2("span", { style: styles2.chips, children: languages.map((l) => /* @__PURE__ */ jsx2(
681
+ "button",
682
+ {
683
+ type: "button",
684
+ onClick: () => open(l),
685
+ style: chipStyle(l),
686
+ "aria-label": t("templates.list.openLocale", {
687
+ type: email.type,
688
+ locale: l.label
689
+ }),
690
+ title: l.active ? void 0 : t("common.status.inactive"),
691
+ children: l.label
692
+ },
693
+ l.label
694
+ )) }),
695
+ /* @__PURE__ */ jsxs2("span", { style: primary?.active === false ? styles2.inactive : styles2.active, children: [
568
696
  /* @__PURE__ */ jsx2("span", { style: styles2.dot }),
569
- group.primary.active ? t("common.status.active") : t("common.status.inactive")
697
+ primary?.active === false ? t("common.status.inactive") : t("common.status.active")
570
698
  ] })
571
- ]
572
- }
573
- ),
574
- /* @__PURE__ */ jsx2("span", { style: styles2.chips, children: group.entries.map((entry) => {
575
- const name = entry.locale ?? t("templates.defaultChip");
576
- return /* @__PURE__ */ jsx2(
577
- "button",
578
- {
579
- type: "button",
580
- onClick: () => onSelectTemplate?.(entry),
581
- style: entry.active ? styles2.chip : styles2.chipInactive,
582
- "aria-label": t("templates.list.openLocale", { type: group.type, locale: name }),
583
- title: entry.active ? void 0 : t("common.status.inactive"),
584
- children: name
585
- },
586
- entry.locale ?? ""
699
+ ] }, email.type)
587
700
  );
588
- }) })
589
- ] }, group.type)) })
701
+ }) }),
702
+ /* @__PURE__ */ jsx2("p", { style: styles2.legend, children: t("templates.list.legend") })
703
+ ] })
590
704
  ] });
591
705
  }
592
706
  var pill = {
@@ -600,6 +714,7 @@ var pill = {
600
714
  lineHeight: 1.5,
601
715
  minWidth: 64
602
716
  };
717
+ var MONO = 'var(--fonderie-mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace)';
603
718
  var chip = {
604
719
  height: 24,
605
720
  boxSizing: "border-box",
@@ -609,9 +724,15 @@ var chip = {
609
724
  background: "var(--fonderie-surface,#fff)",
610
725
  color: "var(--fonderie-text,#171717)",
611
726
  fontSize: 12,
612
- fontFamily: 'var(--fonderie-mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace)',
727
+ fontFamily: MONO,
613
728
  cursor: "pointer"
614
729
  };
730
+ var chipBuiltIn = {
731
+ borderStyle: "dashed",
732
+ background: "transparent",
733
+ color: "var(--fonderie-text-muted,#5c5c5c)"
734
+ };
735
+ var chipOff = { opacity: 0.5, textDecoration: "line-through" };
615
736
  var styles2 = {
616
737
  container: { padding: "32px 40px 64px", maxWidth: 1160, boxSizing: "border-box" },
617
738
  title: {
@@ -622,6 +743,7 @@ var styles2 = {
622
743
  lineHeight: 1.25
623
744
  },
624
745
  hint: { fontSize: 13.5, color: "var(--fonderie-text-muted,#5c5c5c)", margin: "4px 0 24px" },
746
+ legend: { fontSize: 12.5, color: "var(--fonderie-text-muted,#5c5c5c)", margin: "10px 2px 0" },
625
747
  status: { padding: "24px 0", color: "var(--fonderie-text-muted,#5c5c5c)", fontSize: 13.5 },
626
748
  error: { color: "var(--fonderie-danger,#e00)", marginBottom: 12, fontSize: 14 },
627
749
  list: {
@@ -634,20 +756,20 @@ var styles2 = {
634
756
  overflow: "hidden",
635
757
  boxShadow: "var(--fonderie-shadow-card,0 2px 3px 0 rgba(0,0,0,.05))"
636
758
  },
759
+ // name + badge | languages | status — fixed tracks so every column aligns.
637
760
  row: {
638
- display: "flex",
761
+ display: "grid",
762
+ gridTemplateColumns: "minmax(0, 1fr) minmax(180px, 280px) 88px",
639
763
  alignItems: "center",
640
- gap: 8,
764
+ gap: 16,
641
765
  paddingRight: 16,
642
766
  borderBottom: "1px solid var(--fonderie-border-light,#f5f5f5)"
643
767
  },
644
768
  rowButton: {
645
- flex: 1,
646
769
  minWidth: 0,
647
770
  display: "flex",
648
- justifyContent: "space-between",
649
771
  alignItems: "center",
650
- gap: 16,
772
+ gap: 12,
651
773
  padding: "13px 16px",
652
774
  background: "none",
653
775
  border: "none",
@@ -659,12 +781,12 @@ var styles2 = {
659
781
  type: {
660
782
  fontSize: 13,
661
783
  fontWeight: 600,
662
- flex: 1,
663
- fontFamily: 'var(--fonderie-mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace)'
784
+ overflow: "hidden",
785
+ textOverflow: "ellipsis",
786
+ whiteSpace: "nowrap",
787
+ fontFamily: MONO
664
788
  },
665
- chips: { display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 6 },
666
- chip,
667
- chipInactive: { ...chip, opacity: 0.5, textDecoration: "line-through" },
789
+ chips: { display: "flex", flexWrap: "wrap", gap: 6 },
668
790
  dot: { width: 6, height: 6, borderRadius: 999, background: "currentColor" },
669
791
  builtIn: {
670
792
  fontSize: 11,
@@ -672,7 +794,8 @@ var styles2 = {
672
794
  padding: "1px 7px",
673
795
  borderRadius: 999,
674
796
  border: "1px solid var(--fonderie-border,#e0e0e0)",
675
- color: "var(--fonderie-text-muted,#5c5c5c)"
797
+ color: "var(--fonderie-text-muted,#5c5c5c)",
798
+ flexShrink: 0
676
799
  },
677
800
  newButton: {
678
801
  display: "inline-flex",
@@ -706,14 +829,18 @@ import {
706
829
  FonderieApiError,
707
830
  createAdminT as createAdminT3
708
831
  } from "@fonderie/client";
709
- import { useEffect as useEffect2, useState as useState2 } from "react";
710
- import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
832
+ import { useTemplatePreview as useTemplatePreview2 } from "@fonderie/react-courier-admin";
833
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
834
+ import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
711
835
  var TYPE_RE = /^[a-z0-9][a-z0-9_-]{0,80}$/;
712
836
  var LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
837
+ var IMPLICIT2 = /* @__PURE__ */ new Set(["subject", "preheader", "brandName"]);
838
+ var PREVIEW_DEBOUNCE_MS2 = 500;
713
839
  function TemplateCreateScreen({
714
840
  client,
715
841
  type,
716
842
  locales = [],
843
+ defaultLocale,
717
844
  onCreated,
718
845
  locale
719
846
  }) {
@@ -726,14 +853,48 @@ function TemplateCreateScreen({
726
853
  const [text, setText] = useState2("");
727
854
  const [error, setError] = useState2(null);
728
855
  const [busy, setBusy] = useState2(false);
856
+ const [source, setSource] = useState2(
857
+ null
858
+ );
859
+ const { preview, isPreviewing, error: previewError, renderPreview } = useTemplatePreview2(client);
860
+ const variables = useRef2([]);
729
861
  useEffect2(() => {
730
862
  if (!type) return;
731
- client.getTemplate(type, null).then(({ result }) => {
863
+ client.getTemplate(type, null).catch(() => client.getBuiltInTemplate(type, null)).then(({ result }) => {
732
864
  setSubject(result.subject ?? "");
733
865
  setHtml(result.html ?? "");
734
866
  setText(result.text);
867
+ setSource({ subject: result.subject ?? "", html: result.html ?? "", text: result.text });
735
868
  }).catch(() => void 0);
736
869
  }, [client, type]);
870
+ const previewType = addingLocale ? type : newType.trim();
871
+ const previewLocale = LOCALE_RE.test(targetLocale.trim()) ? targetLocale.trim() : null;
872
+ useEffect2(() => {
873
+ if (!TYPE_RE.test(previewType) || !text.trim()) return;
874
+ const timer = setTimeout(async () => {
875
+ const input = { text, ...subject ? { subject } : {}, ...html ? { html } : {} };
876
+ const render = () => renderPreview(
877
+ previewType,
878
+ { ...input, data: Object.fromEntries(variables.current.map((v) => [v, v])) },
879
+ previewLocale
880
+ );
881
+ try {
882
+ const result = await render();
883
+ const used = result.variables.filter((v) => !IMPLICIT2.has(v));
884
+ if (used.some((v) => !variables.current.includes(v))) {
885
+ variables.current = used;
886
+ await render();
887
+ }
888
+ } catch {
889
+ }
890
+ }, PREVIEW_DEBOUNCE_MS2);
891
+ return () => clearTimeout(timer);
892
+ }, [renderPreview, previewType, previewLocale, subject, html, text]);
893
+ const untranslated = source ? [
894
+ subject && subject === source.subject ? tr("templates.subject") : null,
895
+ html && html === source.html ? tr("templates.htmlBody") : null,
896
+ text === source.text ? tr("templates.textBody") : null
897
+ ].filter((f) => f !== null) : [];
737
898
  const submit = async (e) => {
738
899
  e.preventDefault();
739
900
  setError(null);
@@ -742,6 +903,9 @@ function TemplateCreateScreen({
742
903
  if (!TYPE_RE.test(t)) return setError(tr("templates.create.errorType"));
743
904
  if (addingLocale && !l) return setError(tr("templates.create.errorLocaleRequired"));
744
905
  if (l && !LOCALE_RE.test(l)) return setError(tr("templates.create.errorLocale"));
906
+ if (l && defaultLocale && l.toLowerCase() === defaultLocale.toLowerCase()) {
907
+ return setError(tr("templates.create.errorDefaultLocale", { locale: defaultLocale }));
908
+ }
745
909
  if (!text.trim()) return setError(tr("templates.create.errorText"));
746
910
  setBusy(true);
747
911
  try {
@@ -769,75 +933,140 @@ function TemplateCreateScreen({
769
933
  return /* @__PURE__ */ jsxs3("div", { style: styles3.container, children: [
770
934
  /* @__PURE__ */ jsx3("h1", { style: styles3.title, children: addingLocale ? tr("templates.create.titleLocale", { type }) : tr("templates.create.titleNew") }),
771
935
  /* @__PURE__ */ jsx3("p", { style: styles3.meta, children: addingLocale ? tr("templates.create.hintLocale") : tr("templates.create.hintNew") }),
772
- /* @__PURE__ */ jsxs3("form", { style: styles3.form, onSubmit: (e) => void submit(e), children: [
773
- !addingLocale ? /* @__PURE__ */ jsxs3(Fragment, { children: [
774
- /* @__PURE__ */ jsx3("label", { style: styles3.label, htmlFor: "new-template-type", children: tr("templates.create.type") }),
936
+ /* @__PURE__ */ jsxs3("div", { style: styles3.split, children: [
937
+ /* @__PURE__ */ jsxs3("form", { style: styles3.form, onSubmit: (e) => void submit(e), children: [
938
+ !addingLocale ? /* @__PURE__ */ jsxs3(Fragment2, { children: [
939
+ /* @__PURE__ */ jsx3("label", { style: styles3.label, htmlFor: "new-template-type", children: tr("templates.create.type") }),
940
+ /* @__PURE__ */ jsx3(
941
+ "input",
942
+ {
943
+ id: "new-template-type",
944
+ style: styles3.input,
945
+ value: newType,
946
+ placeholder: "weekly-digest",
947
+ autoComplete: "off",
948
+ spellCheck: false,
949
+ onChange: (e) => setNewType(e.target.value)
950
+ }
951
+ )
952
+ ] }) : null,
953
+ /* @__PURE__ */ jsx3("label", { style: styles3.label, htmlFor: "new-template-locale", children: addingLocale ? tr("templates.create.locale") : tr("templates.create.localeOptional") }),
775
954
  /* @__PURE__ */ jsx3(
776
955
  "input",
777
956
  {
778
- id: "new-template-type",
957
+ id: "new-template-locale",
779
958
  style: styles3.input,
780
- value: newType,
781
- placeholder: "weekly-digest",
959
+ value: targetLocale,
960
+ placeholder: "fr",
961
+ list: "new-template-locales",
782
962
  autoComplete: "off",
783
963
  spellCheck: false,
784
- onChange: (e) => setNewType(e.target.value)
964
+ onChange: (e) => setTargetLocale(e.target.value)
785
965
  }
786
- )
787
- ] }) : null,
788
- /* @__PURE__ */ jsx3("label", { style: styles3.label, htmlFor: "new-template-locale", children: addingLocale ? tr("templates.create.locale") : tr("templates.create.localeOptional") }),
789
- /* @__PURE__ */ jsx3(
790
- "input",
791
- {
792
- id: "new-template-locale",
793
- style: styles3.input,
794
- value: targetLocale,
795
- placeholder: "fr",
796
- list: "new-template-locales",
797
- autoComplete: "off",
798
- spellCheck: false,
799
- onChange: (e) => setTargetLocale(e.target.value)
800
- }
801
- ),
802
- /* @__PURE__ */ jsx3("datalist", { id: "new-template-locales", children: [...new Set(locales)].map((l) => /* @__PURE__ */ jsx3("option", { value: l }, l)) }),
803
- /* @__PURE__ */ jsx3("label", { style: styles3.label, htmlFor: "new-template-subject", children: tr("templates.subject") }),
804
- /* @__PURE__ */ jsx3(
805
- "input",
806
- {
807
- id: "new-template-subject",
808
- style: styles3.input,
809
- value: subject,
810
- onChange: (e) => setSubject(e.target.value)
811
- }
812
- ),
813
- /* @__PURE__ */ jsx3("label", { style: styles3.label, htmlFor: "new-template-html", children: tr("templates.htmlBody") }),
814
- /* @__PURE__ */ jsx3(
815
- "textarea",
816
- {
817
- id: "new-template-html",
818
- style: { ...styles3.textarea, minHeight: 160 },
819
- value: html,
820
- onChange: (e) => setHtml(e.target.value)
821
- }
822
- ),
823
- /* @__PURE__ */ jsx3("label", { style: styles3.label, htmlFor: "new-template-text", children: tr("templates.textBody") }),
824
- /* @__PURE__ */ jsx3(
825
- "textarea",
826
- {
827
- id: "new-template-text",
828
- style: { ...styles3.textarea, minHeight: 100 },
829
- value: text,
830
- onChange: (e) => setText(e.target.value)
831
- }
832
- ),
833
- error ? /* @__PURE__ */ jsx3("p", { style: styles3.error, role: "alert", children: error }) : null,
834
- /* @__PURE__ */ jsx3("button", { type: "submit", disabled: busy, style: styles3.button, children: busy ? tr("templates.create.creating") : addingLocale ? tr("templates.create.submitLocale") : tr("templates.create.submitNew") })
966
+ ),
967
+ /* @__PURE__ */ jsx3("datalist", { id: "new-template-locales", children: [...new Set(locales)].map((l) => /* @__PURE__ */ jsx3("option", { value: l }, l)) }),
968
+ /* @__PURE__ */ jsx3("label", { style: styles3.label, htmlFor: "new-template-subject", children: tr("templates.subject") }),
969
+ /* @__PURE__ */ jsx3(
970
+ "input",
971
+ {
972
+ id: "new-template-subject",
973
+ style: styles3.input,
974
+ value: subject,
975
+ onChange: (e) => setSubject(e.target.value)
976
+ }
977
+ ),
978
+ /* @__PURE__ */ jsx3("label", { style: styles3.label, htmlFor: "new-template-html", children: tr("templates.htmlBody") }),
979
+ /* @__PURE__ */ jsx3(
980
+ "textarea",
981
+ {
982
+ id: "new-template-html",
983
+ style: { ...styles3.textarea, minHeight: 160 },
984
+ value: html,
985
+ onChange: (e) => setHtml(e.target.value)
986
+ }
987
+ ),
988
+ /* @__PURE__ */ jsx3("label", { style: styles3.label, htmlFor: "new-template-text", children: tr("templates.textBody") }),
989
+ /* @__PURE__ */ jsx3(
990
+ "textarea",
991
+ {
992
+ id: "new-template-text",
993
+ style: { ...styles3.textarea, minHeight: 100 },
994
+ value: text,
995
+ onChange: (e) => setText(e.target.value)
996
+ }
997
+ ),
998
+ untranslated.length > 0 ? /* @__PURE__ */ jsx3("p", { style: styles3.notice, children: tr("templates.create.untranslated", { fields: untranslated.join(", ") }) }) : null,
999
+ error ? /* @__PURE__ */ jsx3("p", { style: styles3.error, role: "alert", children: error }) : null,
1000
+ /* @__PURE__ */ jsx3("button", { type: "submit", disabled: busy, style: styles3.button, children: busy ? tr("templates.create.creating") : addingLocale ? tr("templates.create.submitLocale") : tr("templates.create.submitNew") })
1001
+ ] }),
1002
+ /* @__PURE__ */ jsxs3("div", { style: styles3.previewColumn, children: [
1003
+ /* @__PURE__ */ jsxs3("div", { style: styles3.previewHeader, children: [
1004
+ /* @__PURE__ */ jsx3("span", { style: styles3.label, children: tr("templates.editor.preview") }),
1005
+ /* @__PURE__ */ jsx3("span", { style: styles3.meta, children: isPreviewing ? tr("templates.editor.rendering") : tr("templates.editor.live") })
1006
+ ] }),
1007
+ previewError ? /* @__PURE__ */ jsx3("p", { style: styles3.error, role: "alert", children: previewError.explanation }) : null,
1008
+ preview?.subject ? /* @__PURE__ */ jsx3("p", { style: styles3.previewSubject, children: preview.subject }) : null,
1009
+ preview?.html ? (
1010
+ // sandbox="" — no scripts, no same-origin: operator-authored HTML must
1011
+ // never execute in the console's origin.
1012
+ /* @__PURE__ */ jsx3(
1013
+ "iframe",
1014
+ {
1015
+ title: tr("templates.editor.previewTitle"),
1016
+ style: styles3.previewFrame,
1017
+ sandbox: "",
1018
+ srcDoc: preview.html
1019
+ }
1020
+ )
1021
+ ) : preview ? /* @__PURE__ */ jsx3("pre", { style: styles3.previewText, children: preview.text }) : /* @__PURE__ */ jsx3("p", { style: styles3.meta, children: tr("templates.editor.nothingRendered") })
1022
+ ] })
835
1023
  ] })
836
1024
  ] });
837
1025
  }
838
- var MONO = 'var(--fonderie-mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace)';
1026
+ var MONO2 = 'var(--fonderie-mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace)';
839
1027
  var styles3 = {
840
- container: { padding: "8px 40px 64px", maxWidth: 760, boxSizing: "border-box" },
1028
+ container: { padding: "8px 40px 64px", maxWidth: 1400, boxSizing: "border-box" },
1029
+ // The editor's layout: the form on the left, the render pinned on the right.
1030
+ split: {
1031
+ display: "grid",
1032
+ gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)",
1033
+ gap: 24,
1034
+ alignItems: "start"
1035
+ },
1036
+ previewColumn: {
1037
+ minWidth: 0,
1038
+ display: "flex",
1039
+ flexDirection: "column",
1040
+ position: "sticky",
1041
+ top: 16
1042
+ },
1043
+ previewHeader: { display: "flex", justifyContent: "space-between", alignItems: "center" },
1044
+ previewSubject: { fontSize: 14, fontWeight: 600, margin: "8px 0" },
1045
+ previewFrame: {
1046
+ boxShadow: "var(--fonderie-shadow-card,0 2px 3px 0 rgba(0,0,0,.05))",
1047
+ width: "100%",
1048
+ height: 520,
1049
+ border: "1px solid var(--fonderie-border,#e0e0e0)",
1050
+ borderRadius: 8,
1051
+ background: "var(--fonderie-surface,#fff)"
1052
+ },
1053
+ previewText: {
1054
+ whiteSpace: "pre-wrap",
1055
+ border: "1px solid var(--fonderie-border,#e0e0e0)",
1056
+ borderRadius: 8,
1057
+ padding: 12,
1058
+ fontSize: 13,
1059
+ fontFamily: MONO2
1060
+ },
1061
+ // The console's warning token (the shell's amber), readable in both themes.
1062
+ notice: {
1063
+ fontSize: 13,
1064
+ color: "var(--fonderie-text,#171717)",
1065
+ background: "color-mix(in srgb, var(--fonderie-warning,#f5a623) 16%, transparent)",
1066
+ borderRadius: 6,
1067
+ padding: "8px 12px",
1068
+ margin: "12px 0 0"
1069
+ },
841
1070
  title: {
842
1071
  fontSize: 22,
843
1072
  fontWeight: 600,
@@ -880,7 +1109,7 @@ var styles3 = {
880
1109
  lineHeight: 1.55,
881
1110
  background: "var(--fonderie-surface,#fff)",
882
1111
  color: "var(--fonderie-text,#171717)",
883
- fontFamily: MONO
1112
+ fontFamily: MONO2
884
1113
  },
885
1114
  error: {
886
1115
  color: "var(--fonderie-danger,#e00)",