@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.cjs CHANGED
@@ -46,8 +46,16 @@ function TemplateEditorScreen({
46
46
  }) {
47
47
  const t = (0, import_react.useMemo)(() => (0, import_client.createAdminT)(uiLocale), [uiLocale]);
48
48
  const { template, isLoading, error, refresh } = (0, import_react_courier_admin.useTemplate)(client, type, locale);
49
- const { templates, saveTemplate, removeTemplate } = (0, import_react_courier_admin.useTemplates)(client);
50
- const siblings = (0, import_client.groupTemplatesByType)(templates).find((g) => g.type === type)?.entries ?? [];
49
+ const notSaved = error?.status === 404;
50
+ const { builtIn, isLoading: builtInLoading } = (0, import_react_courier_admin.useBuiltInTemplate)(client, type, locale);
51
+ const { catalog, refresh: refreshCatalog } = (0, import_react_courier_admin.useTemplateCatalog)(client);
52
+ const defaultLocale = catalog?.defaultLocale ?? "en-US";
53
+ const email = catalog?.emails.find((e) => e.type === type);
54
+ const languages = email ? (0, import_client.templateLanguages)(email, defaultLocale) : [];
55
+ const source = template ?? (notSaved ? builtIn : null);
56
+ const { resolution, resolve } = (0, import_react_courier_admin.useTemplateResolution)(client);
57
+ const [probe, setProbe] = (0, import_react.useState)("");
58
+ const { saveTemplate, removeTemplate } = (0, import_react_courier_admin.useTemplates)(client);
51
59
  const [isDeleting, setIsDeleting] = (0, import_react.useState)(false);
52
60
  const { revisions, rollback } = (0, import_react_courier_admin.useTemplateRevisions)(client, type, locale);
53
61
  const [isSaving, setIsSaving] = (0, import_react.useState)(false);
@@ -60,12 +68,15 @@ function TemplateEditorScreen({
60
68
  const [sampleJson, setSampleJson] = (0, import_react.useState)("{}");
61
69
  const [sampleError, setSampleError] = (0, import_react.useState)(null);
62
70
  (0, import_react.useEffect)(() => {
63
- if (!template) return;
64
- setSubject(template.subject ?? "");
65
- setHtml(template.html ?? "");
66
- setText(template.text);
67
- setActive(template.active);
68
- }, [template]);
71
+ if (!source) return;
72
+ setSubject(source.subject ?? "");
73
+ setHtml(source.html ?? "");
74
+ setText(source.text);
75
+ setActive(template ? template.active : true);
76
+ }, [source, template]);
77
+ (0, import_react.useEffect)(() => {
78
+ void resolve(type, locale ?? defaultLocale);
79
+ }, [resolve, type, locale, defaultLocale]);
69
80
  const run = (0, import_react.useCallback)(async () => {
70
81
  let data = {};
71
82
  try {
@@ -93,13 +104,15 @@ function TemplateEditorScreen({
93
104
  }, [renderPreview, type, text, subject, html, locale, sampleJson, t]);
94
105
  const runRef = (0, import_react.useRef)(run);
95
106
  runRef.current = run;
96
- const loadedType = template ? `${type}:${locale ?? ""}` : null;
107
+ const loadedType = source ? `${type}:${locale ?? ""}` : null;
97
108
  (0, import_react.useEffect)(() => {
98
109
  if (!loadedType) return;
99
110
  const timer = setTimeout(() => void runRef.current(), PREVIEW_DEBOUNCE_MS);
100
111
  return () => clearTimeout(timer);
101
112
  }, [loadedType, subject, html, text, sampleJson]);
102
- const dirty = !!template && (subject !== (template.subject ?? "") || html !== (template.html ?? "") || text !== template.text || active !== template.active);
113
+ const dirty = notSaved && !!builtIn || !!template && (subject !== (template.subject ?? "") || html !== (template.html ?? "") || text !== template.text || active !== template.active);
114
+ const protectedRow = !locale && (email?.system ?? system);
115
+ const shipsHere = locale ? languages.some((l) => l.locale === locale && l.builtIn) : !!email?.builtIn.default;
103
116
  const handleSubmit = async (event) => {
104
117
  event.preventDefault();
105
118
  setIsSaving(true);
@@ -110,7 +123,7 @@ function TemplateEditorScreen({
110
123
  if (html) input.html = html;
111
124
  if (template?.version !== void 0) input.ifVersion = template.version;
112
125
  await saveTemplate(type, input, locale);
113
- await refresh();
126
+ await Promise.all([refresh(), refreshCatalog()]);
114
127
  onSaved?.();
115
128
  } catch (err) {
116
129
  setSaveError(err);
@@ -118,9 +131,32 @@ function TemplateEditorScreen({
118
131
  setIsSaving(false);
119
132
  }
120
133
  };
121
- const switchTo = (entry) => {
122
- if (dirty && !window.confirm(t("templates.editor.discardChanges"))) return;
123
- onSelectLocale?.(entry);
134
+ const switchTo = (next) => {
135
+ if (dirty && !(notSaved && !!builtIn) && !window.confirm(t("templates.editor.discardChanges")))
136
+ return;
137
+ onSelectLocale?.(next);
138
+ };
139
+ const handleReset = async () => {
140
+ if (!builtIn || !window.confirm(t("templates.editor.confirmReset", { locale: locale ?? defaultLocale })))
141
+ return;
142
+ setIsDeleting(true);
143
+ setSaveError(null);
144
+ try {
145
+ if (locale) {
146
+ await removeTemplate(type, locale);
147
+ } else {
148
+ const input = { text: builtIn.text, active };
149
+ if (builtIn.subject) input.subject = builtIn.subject;
150
+ if (builtIn.html) input.html = builtIn.html;
151
+ if (template?.version !== void 0) input.ifVersion = template.version;
152
+ await saveTemplate(type, input, null);
153
+ }
154
+ await Promise.all([refresh(), refreshCatalog()]);
155
+ } catch (err) {
156
+ setSaveError(err);
157
+ } finally {
158
+ setIsDeleting(false);
159
+ }
124
160
  };
125
161
  const handleDelete = async () => {
126
162
  const message = locale ? t("templates.editor.confirmDeleteLocale", { type, locale }) : t("templates.editor.confirmDelete", { type });
@@ -136,8 +172,9 @@ function TemplateEditorScreen({
136
172
  setIsDeleting(false);
137
173
  }
138
174
  };
139
- if (isLoading) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.status, children: t("templates.editor.loading") });
140
- if (error)
175
+ if (isLoading || notSaved && builtInLoading)
176
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.status, children: t("templates.editor.loading") });
177
+ if (error && !(notSaved && builtIn))
141
178
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.error, role: "alert", children: error.explanation });
142
179
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.container, children: [
143
180
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h1", { style: styles.title, children: type }),
@@ -145,19 +182,23 @@ function TemplateEditorScreen({
145
182
  onSelectLocale ? (
146
183
  // Each tab opens another stored row, so this is navigation, not an
147
184
  // ARIA tablist: plain buttons, the open one marked aria-current.
148
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("nav", { "aria-label": t("templates.editor.locales"), style: styles.tabs, children: siblings.map((entry) => {
149
- const selected = entry.locale === (locale ?? null);
185
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("nav", { "aria-label": t("templates.editor.locales"), style: styles.tabs, children: languages.map((l) => {
186
+ const selected = l.locale === (locale ?? null);
150
187
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
151
188
  "button",
152
189
  {
153
190
  type: "button",
154
191
  "aria-current": selected ? "true" : void 0,
155
- onClick: () => selected ? void 0 : switchTo(entry),
156
- style: selected ? styles.tabSelected : entry.active ? styles.tab : styles.tabInactive,
157
- title: entry.active ? void 0 : t("common.status.inactive"),
158
- children: entry.locale ?? t("templates.defaultChip")
192
+ onClick: () => selected ? void 0 : switchTo({ type, locale: l.locale, system: !!email?.system }),
193
+ style: {
194
+ ...selected ? styles.tabSelected : styles.tab,
195
+ ...l.saved ? {} : styles.tabBuiltIn,
196
+ ...l.active ? {} : styles.tabInactive
197
+ },
198
+ title: l.active ? void 0 : t("common.status.inactive"),
199
+ children: l.label
159
200
  },
160
- entry.locale ?? ""
201
+ l.label
161
202
  );
162
203
  }) })
163
204
  ) : null,
@@ -168,17 +209,21 @@ function TemplateEditorScreen({
168
209
  style: { ...styles.rollbackButton, marginLeft: "auto" },
169
210
  onClick: () => {
170
211
  if (dirty && !window.confirm(t("templates.editor.discardChanges"))) return;
171
- onAddLocale(type, { locales: (0, import_client.missingTemplateLocales)(templates, type) });
212
+ onAddLocale(type, {
213
+ locales: catalog ? (0, import_client.suggestTemplateLocales)(catalog, type) : [],
214
+ defaultLocale
215
+ });
172
216
  },
173
217
  children: t("templates.editor.addLocale")
174
218
  }
175
219
  ) : null
176
220
  ] }) : null,
177
221
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.meta, children: [
178
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: locale ?? t("templates.defaultLocale") }),
179
- " \xB7 v",
180
- template?.version ?? 1,
181
- system ? ` \xB7 ${t("templates.editor.builtInNote")}` : ""
222
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: locale ?? defaultLocale }),
223
+ template ? ` \xB7 v${template.version}` : "",
224
+ template && protectedRow ? ` \xB7 ${t("templates.editor.builtInNote")}` : "",
225
+ !template && builtIn ? ` \xB7 ${t("templates.editor.builtInCopy")}` : "",
226
+ resolution && resolution.chain.length > 1 && resolution.requested === (locale ?? defaultLocale) ? ` \xB7 ${t("templates.editor.chain", { chain: [...resolution.chain, defaultLocale].join(" \u2192 ") })}` : ""
182
227
  ] }),
183
228
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.split, children: [
184
229
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.column, children: [
@@ -254,7 +299,16 @@ function TemplateEditorScreen({
254
299
  }
255
300
  ),
256
301
  !dirty && !isSaving && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: styles.meta, children: t("templates.editor.noChanges") }),
257
- !system ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
302
+ template && shipsHere && builtIn ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
303
+ "button",
304
+ {
305
+ type: "button",
306
+ disabled: isDeleting,
307
+ onClick: () => void handleReset(),
308
+ style: { ...styles.rollbackButton, height: 36, marginLeft: "auto" },
309
+ children: t("templates.editor.resetToBuiltIn")
310
+ }
311
+ ) : template && !protectedRow ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
258
312
  "button",
259
313
  {
260
314
  type: "button",
@@ -309,6 +363,43 @@ function TemplateEditorScreen({
309
363
  ) : preview ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("pre", { style: styles.previewText, children: preview.text }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.meta, children: t("templates.editor.nothingRendered") })
310
364
  ] })
311
365
  ] }),
366
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.revisions, children: [
367
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", { style: styles.subtitle, children: t("templates.editor.whoReceives") }),
368
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
369
+ "form",
370
+ {
371
+ style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" },
372
+ onSubmit: (event) => {
373
+ event.preventDefault();
374
+ if (probe.trim()) void resolve(type, probe.trim());
375
+ },
376
+ children: [
377
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
378
+ "input",
379
+ {
380
+ "aria-label": t("templates.editor.whoReceives"),
381
+ style: { ...styles.input, width: 200 },
382
+ value: probe,
383
+ placeholder: t("templates.editor.whoPlaceholder"),
384
+ spellCheck: false,
385
+ onChange: (event) => setProbe(event.target.value)
386
+ }
387
+ ),
388
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "submit", style: { ...styles.rollbackButton, height: 36 }, children: t("templates.editor.check") }),
389
+ resolution && probe.trim() ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { ...styles.meta, marginBottom: 0 }, role: "status", children: [
390
+ t("templates.editor.receives", {
391
+ requested: resolution.requested,
392
+ sent: resolution.sent
393
+ }),
394
+ " ",
395
+ resolution.source === "saved" ? t("templates.editor.sourceSaved") : t("templates.editor.sourceBuiltIn"),
396
+ " ",
397
+ resolution.chain.length > 0 ? `(${[...resolution.chain, resolution.defaultLocale].join(" \u2192 ")})` : ""
398
+ ] }) : null
399
+ ]
400
+ }
401
+ )
402
+ ] }),
312
403
  revisions.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.revisions, children: [
313
404
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", { style: styles.subtitle, children: t("templates.editor.history") }),
314
405
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { style: styles.list, children: revisions.map((rev) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { style: styles.row, children: [
@@ -365,7 +456,9 @@ var styles = {
365
456
  borderBottomColor: "var(--fonderie-text,#171717)",
366
457
  cursor: "default"
367
458
  },
368
- tabInactive: { ...tab, opacity: 0.55, textDecoration: "line-through" },
459
+ tabInactive: { opacity: 0.55, textDecoration: "line-through" },
460
+ // Fonderie's built-in copy, not saved by the app — the list's dashed chip.
461
+ tabBuiltIn: { fontStyle: "italic" },
369
462
  // Two columns, always: the editor on the left, the rendered result on the
370
463
  // right. Stacking the preview under a long form hid it below the fold.
371
464
  split: {
@@ -543,8 +636,14 @@ function TemplateListScreen({
543
636
  locale
544
637
  }) {
545
638
  const t = (0, import_client2.createAdminT)(locale);
546
- const { templates, isLoading, error } = (0, import_react_courier_admin2.useTemplates)(client);
547
- const groups = (0, import_client2.groupTemplatesByType)(templates);
639
+ const { catalog, isLoading, error } = (0, import_react_courier_admin2.useTemplateCatalog)(client);
640
+ const emails = catalog?.emails ?? [];
641
+ const defaultLocale = catalog?.defaultLocale ?? "en-US";
642
+ const chipStyle = (l) => ({
643
+ ...chip,
644
+ ...l.saved ? {} : chipBuiltIn,
645
+ ...l.active ? {} : chipOff
646
+ });
548
647
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: styles2.container, children: [
549
648
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
550
649
  "div",
@@ -558,8 +657,12 @@ function TemplateListScreen({
558
657
  type: "button",
559
658
  onClick: () => onCreateTemplate({
560
659
  locales: [
561
- ...new Set(templates.map((row) => row.locale).filter((l) => !!l))
562
- ]
660
+ ...new Set(
661
+ emails.flatMap(
662
+ (e) => (0, import_client2.templateLanguages)(e, defaultLocale).filter((l) => l.locale !== null).map((l) => l.label)
663
+ )
664
+ )
665
+ ].sort()
563
666
  }),
564
667
  style: styles2.newButton,
565
668
  children: t("templates.list.newTemplate")
@@ -569,39 +672,47 @@ function TemplateListScreen({
569
672
  }
570
673
  ),
571
674
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: styles2.hint, children: t("templates.list.hint") }),
572
- isLoading ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: styles2.status, children: t("templates.list.loading") }) : error ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: styles2.error, role: "alert", children: error.explanation }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("ul", { style: styles2.list, children: groups.map((group) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("li", { style: styles2.row, children: [
573
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
574
- "button",
575
- {
576
- type: "button",
577
- onClick: () => onSelectTemplate?.(group.primary),
578
- style: styles2.rowButton,
579
- children: [
580
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles2.type, children: group.type }),
581
- group.system ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles2.builtIn, children: t("templates.builtInBadge") }) : null,
582
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: group.primary.active ? styles2.active : styles2.inactive, children: [
675
+ isLoading ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: styles2.status, children: t("templates.list.loading") }) : error ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: styles2.error, role: "alert", children: error.explanation }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
676
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("ul", { style: styles2.list, children: emails.map((email) => {
677
+ const languages = (0, import_client2.templateLanguages)(email, defaultLocale);
678
+ const primary = languages.find((l) => l.locale === null) ?? languages[0];
679
+ const open = (l) => onSelectTemplate?.({
680
+ type: email.type,
681
+ locale: l?.locale ?? null,
682
+ system: email.system
683
+ });
684
+ return (
685
+ // Columns, not a flex row: the language chips line up down the
686
+ // list whatever the email name or badge.
687
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("li", { style: styles2.row, children: [
688
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("button", { type: "button", onClick: () => open(primary), style: styles2.rowButton, children: [
689
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles2.type, children: email.type }),
690
+ email.system ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles2.builtIn, children: t("templates.builtInBadge") }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", {})
691
+ ] }),
692
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles2.chips, children: languages.map((l) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
693
+ "button",
694
+ {
695
+ type: "button",
696
+ onClick: () => open(l),
697
+ style: chipStyle(l),
698
+ "aria-label": t("templates.list.openLocale", {
699
+ type: email.type,
700
+ locale: l.label
701
+ }),
702
+ title: l.active ? void 0 : t("common.status.inactive"),
703
+ children: l.label
704
+ },
705
+ l.label
706
+ )) }),
707
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: primary?.active === false ? styles2.inactive : styles2.active, children: [
583
708
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles2.dot }),
584
- group.primary.active ? t("common.status.active") : t("common.status.inactive")
709
+ primary?.active === false ? t("common.status.inactive") : t("common.status.active")
585
710
  ] })
586
- ]
587
- }
588
- ),
589
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles2.chips, children: group.entries.map((entry) => {
590
- const name = entry.locale ?? t("templates.defaultChip");
591
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
592
- "button",
593
- {
594
- type: "button",
595
- onClick: () => onSelectTemplate?.(entry),
596
- style: entry.active ? styles2.chip : styles2.chipInactive,
597
- "aria-label": t("templates.list.openLocale", { type: group.type, locale: name }),
598
- title: entry.active ? void 0 : t("common.status.inactive"),
599
- children: name
600
- },
601
- entry.locale ?? ""
711
+ ] }, email.type)
602
712
  );
603
- }) })
604
- ] }, group.type)) })
713
+ }) }),
714
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: styles2.legend, children: t("templates.list.legend") })
715
+ ] })
605
716
  ] });
606
717
  }
607
718
  var pill = {
@@ -615,6 +726,7 @@ var pill = {
615
726
  lineHeight: 1.5,
616
727
  minWidth: 64
617
728
  };
729
+ var MONO = 'var(--fonderie-mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace)';
618
730
  var chip = {
619
731
  height: 24,
620
732
  boxSizing: "border-box",
@@ -624,9 +736,15 @@ var chip = {
624
736
  background: "var(--fonderie-surface,#fff)",
625
737
  color: "var(--fonderie-text,#171717)",
626
738
  fontSize: 12,
627
- fontFamily: 'var(--fonderie-mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace)',
739
+ fontFamily: MONO,
628
740
  cursor: "pointer"
629
741
  };
742
+ var chipBuiltIn = {
743
+ borderStyle: "dashed",
744
+ background: "transparent",
745
+ color: "var(--fonderie-text-muted,#5c5c5c)"
746
+ };
747
+ var chipOff = { opacity: 0.5, textDecoration: "line-through" };
630
748
  var styles2 = {
631
749
  container: { padding: "32px 40px 64px", maxWidth: 1160, boxSizing: "border-box" },
632
750
  title: {
@@ -637,6 +755,7 @@ var styles2 = {
637
755
  lineHeight: 1.25
638
756
  },
639
757
  hint: { fontSize: 13.5, color: "var(--fonderie-text-muted,#5c5c5c)", margin: "4px 0 24px" },
758
+ legend: { fontSize: 12.5, color: "var(--fonderie-text-muted,#5c5c5c)", margin: "10px 2px 0" },
640
759
  status: { padding: "24px 0", color: "var(--fonderie-text-muted,#5c5c5c)", fontSize: 13.5 },
641
760
  error: { color: "var(--fonderie-danger,#e00)", marginBottom: 12, fontSize: 14 },
642
761
  list: {
@@ -649,20 +768,20 @@ var styles2 = {
649
768
  overflow: "hidden",
650
769
  boxShadow: "var(--fonderie-shadow-card,0 2px 3px 0 rgba(0,0,0,.05))"
651
770
  },
771
+ // name + badge | languages | status — fixed tracks so every column aligns.
652
772
  row: {
653
- display: "flex",
773
+ display: "grid",
774
+ gridTemplateColumns: "minmax(0, 1fr) minmax(180px, 280px) 88px",
654
775
  alignItems: "center",
655
- gap: 8,
776
+ gap: 16,
656
777
  paddingRight: 16,
657
778
  borderBottom: "1px solid var(--fonderie-border-light,#f5f5f5)"
658
779
  },
659
780
  rowButton: {
660
- flex: 1,
661
781
  minWidth: 0,
662
782
  display: "flex",
663
- justifyContent: "space-between",
664
783
  alignItems: "center",
665
- gap: 16,
784
+ gap: 12,
666
785
  padding: "13px 16px",
667
786
  background: "none",
668
787
  border: "none",
@@ -674,12 +793,12 @@ var styles2 = {
674
793
  type: {
675
794
  fontSize: 13,
676
795
  fontWeight: 600,
677
- flex: 1,
678
- fontFamily: 'var(--fonderie-mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace)'
796
+ overflow: "hidden",
797
+ textOverflow: "ellipsis",
798
+ whiteSpace: "nowrap",
799
+ fontFamily: MONO
679
800
  },
680
- chips: { display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 6 },
681
- chip,
682
- chipInactive: { ...chip, opacity: 0.5, textDecoration: "line-through" },
801
+ chips: { display: "flex", flexWrap: "wrap", gap: 6 },
683
802
  dot: { width: 6, height: 6, borderRadius: 999, background: "currentColor" },
684
803
  builtIn: {
685
804
  fontSize: 11,
@@ -687,7 +806,8 @@ var styles2 = {
687
806
  padding: "1px 7px",
688
807
  borderRadius: 999,
689
808
  border: "1px solid var(--fonderie-border,#e0e0e0)",
690
- color: "var(--fonderie-text-muted,#5c5c5c)"
809
+ color: "var(--fonderie-text-muted,#5c5c5c)",
810
+ flexShrink: 0
691
811
  },
692
812
  newButton: {
693
813
  display: "inline-flex",
@@ -718,14 +838,18 @@ var styles2 = {
718
838
 
719
839
  // src/screens/TemplateCreateScreen.tsx
720
840
  var import_client3 = require("@fonderie/client");
841
+ var import_react_courier_admin3 = require("@fonderie/react-courier-admin");
721
842
  var import_react2 = require("react");
722
843
  var import_jsx_runtime3 = require("react/jsx-runtime");
723
844
  var TYPE_RE = /^[a-z0-9][a-z0-9_-]{0,80}$/;
724
845
  var LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
846
+ var IMPLICIT2 = /* @__PURE__ */ new Set(["subject", "preheader", "brandName"]);
847
+ var PREVIEW_DEBOUNCE_MS2 = 500;
725
848
  function TemplateCreateScreen({
726
849
  client,
727
850
  type,
728
851
  locales = [],
852
+ defaultLocale,
729
853
  onCreated,
730
854
  locale
731
855
  }) {
@@ -738,14 +862,48 @@ function TemplateCreateScreen({
738
862
  const [text, setText] = (0, import_react2.useState)("");
739
863
  const [error, setError] = (0, import_react2.useState)(null);
740
864
  const [busy, setBusy] = (0, import_react2.useState)(false);
865
+ const [source, setSource] = (0, import_react2.useState)(
866
+ null
867
+ );
868
+ const { preview, isPreviewing, error: previewError, renderPreview } = (0, import_react_courier_admin3.useTemplatePreview)(client);
869
+ const variables = (0, import_react2.useRef)([]);
741
870
  (0, import_react2.useEffect)(() => {
742
871
  if (!type) return;
743
- client.getTemplate(type, null).then(({ result }) => {
872
+ client.getTemplate(type, null).catch(() => client.getBuiltInTemplate(type, null)).then(({ result }) => {
744
873
  setSubject(result.subject ?? "");
745
874
  setHtml(result.html ?? "");
746
875
  setText(result.text);
876
+ setSource({ subject: result.subject ?? "", html: result.html ?? "", text: result.text });
747
877
  }).catch(() => void 0);
748
878
  }, [client, type]);
879
+ const previewType = addingLocale ? type : newType.trim();
880
+ const previewLocale = LOCALE_RE.test(targetLocale.trim()) ? targetLocale.trim() : null;
881
+ (0, import_react2.useEffect)(() => {
882
+ if (!TYPE_RE.test(previewType) || !text.trim()) return;
883
+ const timer = setTimeout(async () => {
884
+ const input = { text, ...subject ? { subject } : {}, ...html ? { html } : {} };
885
+ const render = () => renderPreview(
886
+ previewType,
887
+ { ...input, data: Object.fromEntries(variables.current.map((v) => [v, v])) },
888
+ previewLocale
889
+ );
890
+ try {
891
+ const result = await render();
892
+ const used = result.variables.filter((v) => !IMPLICIT2.has(v));
893
+ if (used.some((v) => !variables.current.includes(v))) {
894
+ variables.current = used;
895
+ await render();
896
+ }
897
+ } catch {
898
+ }
899
+ }, PREVIEW_DEBOUNCE_MS2);
900
+ return () => clearTimeout(timer);
901
+ }, [renderPreview, previewType, previewLocale, subject, html, text]);
902
+ const untranslated = source ? [
903
+ subject && subject === source.subject ? tr("templates.subject") : null,
904
+ html && html === source.html ? tr("templates.htmlBody") : null,
905
+ text === source.text ? tr("templates.textBody") : null
906
+ ].filter((f) => f !== null) : [];
749
907
  const submit = async (e) => {
750
908
  e.preventDefault();
751
909
  setError(null);
@@ -754,6 +912,9 @@ function TemplateCreateScreen({
754
912
  if (!TYPE_RE.test(t)) return setError(tr("templates.create.errorType"));
755
913
  if (addingLocale && !l) return setError(tr("templates.create.errorLocaleRequired"));
756
914
  if (l && !LOCALE_RE.test(l)) return setError(tr("templates.create.errorLocale"));
915
+ if (l && defaultLocale && l.toLowerCase() === defaultLocale.toLowerCase()) {
916
+ return setError(tr("templates.create.errorDefaultLocale", { locale: defaultLocale }));
917
+ }
757
918
  if (!text.trim()) return setError(tr("templates.create.errorText"));
758
919
  setBusy(true);
759
920
  try {
@@ -781,75 +942,140 @@ function TemplateCreateScreen({
781
942
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: styles3.container, children: [
782
943
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h1", { style: styles3.title, children: addingLocale ? tr("templates.create.titleLocale", { type }) : tr("templates.create.titleNew") }),
783
944
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: styles3.meta, children: addingLocale ? tr("templates.create.hintLocale") : tr("templates.create.hintNew") }),
784
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("form", { style: styles3.form, onSubmit: (e) => void submit(e), children: [
785
- !addingLocale ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
786
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { style: styles3.label, htmlFor: "new-template-type", children: tr("templates.create.type") }),
945
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: styles3.split, children: [
946
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("form", { style: styles3.form, onSubmit: (e) => void submit(e), children: [
947
+ !addingLocale ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
948
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { style: styles3.label, htmlFor: "new-template-type", children: tr("templates.create.type") }),
949
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
950
+ "input",
951
+ {
952
+ id: "new-template-type",
953
+ style: styles3.input,
954
+ value: newType,
955
+ placeholder: "weekly-digest",
956
+ autoComplete: "off",
957
+ spellCheck: false,
958
+ onChange: (e) => setNewType(e.target.value)
959
+ }
960
+ )
961
+ ] }) : null,
962
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { style: styles3.label, htmlFor: "new-template-locale", children: addingLocale ? tr("templates.create.locale") : tr("templates.create.localeOptional") }),
787
963
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
788
964
  "input",
789
965
  {
790
- id: "new-template-type",
966
+ id: "new-template-locale",
791
967
  style: styles3.input,
792
- value: newType,
793
- placeholder: "weekly-digest",
968
+ value: targetLocale,
969
+ placeholder: "fr",
970
+ list: "new-template-locales",
794
971
  autoComplete: "off",
795
972
  spellCheck: false,
796
- onChange: (e) => setNewType(e.target.value)
973
+ onChange: (e) => setTargetLocale(e.target.value)
797
974
  }
798
- )
799
- ] }) : null,
800
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { style: styles3.label, htmlFor: "new-template-locale", children: addingLocale ? tr("templates.create.locale") : tr("templates.create.localeOptional") }),
801
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
802
- "input",
803
- {
804
- id: "new-template-locale",
805
- style: styles3.input,
806
- value: targetLocale,
807
- placeholder: "fr",
808
- list: "new-template-locales",
809
- autoComplete: "off",
810
- spellCheck: false,
811
- onChange: (e) => setTargetLocale(e.target.value)
812
- }
813
- ),
814
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("datalist", { id: "new-template-locales", children: [...new Set(locales)].map((l) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: l }, l)) }),
815
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { style: styles3.label, htmlFor: "new-template-subject", children: tr("templates.subject") }),
816
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
817
- "input",
818
- {
819
- id: "new-template-subject",
820
- style: styles3.input,
821
- value: subject,
822
- onChange: (e) => setSubject(e.target.value)
823
- }
824
- ),
825
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { style: styles3.label, htmlFor: "new-template-html", children: tr("templates.htmlBody") }),
826
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
827
- "textarea",
828
- {
829
- id: "new-template-html",
830
- style: { ...styles3.textarea, minHeight: 160 },
831
- value: html,
832
- onChange: (e) => setHtml(e.target.value)
833
- }
834
- ),
835
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { style: styles3.label, htmlFor: "new-template-text", children: tr("templates.textBody") }),
836
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
837
- "textarea",
838
- {
839
- id: "new-template-text",
840
- style: { ...styles3.textarea, minHeight: 100 },
841
- value: text,
842
- onChange: (e) => setText(e.target.value)
843
- }
844
- ),
845
- error ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: styles3.error, role: "alert", children: error }) : null,
846
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "submit", disabled: busy, style: styles3.button, children: busy ? tr("templates.create.creating") : addingLocale ? tr("templates.create.submitLocale") : tr("templates.create.submitNew") })
975
+ ),
976
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("datalist", { id: "new-template-locales", children: [...new Set(locales)].map((l) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: l }, l)) }),
977
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { style: styles3.label, htmlFor: "new-template-subject", children: tr("templates.subject") }),
978
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
979
+ "input",
980
+ {
981
+ id: "new-template-subject",
982
+ style: styles3.input,
983
+ value: subject,
984
+ onChange: (e) => setSubject(e.target.value)
985
+ }
986
+ ),
987
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { style: styles3.label, htmlFor: "new-template-html", children: tr("templates.htmlBody") }),
988
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
989
+ "textarea",
990
+ {
991
+ id: "new-template-html",
992
+ style: { ...styles3.textarea, minHeight: 160 },
993
+ value: html,
994
+ onChange: (e) => setHtml(e.target.value)
995
+ }
996
+ ),
997
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { style: styles3.label, htmlFor: "new-template-text", children: tr("templates.textBody") }),
998
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
999
+ "textarea",
1000
+ {
1001
+ id: "new-template-text",
1002
+ style: { ...styles3.textarea, minHeight: 100 },
1003
+ value: text,
1004
+ onChange: (e) => setText(e.target.value)
1005
+ }
1006
+ ),
1007
+ untranslated.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: styles3.notice, children: tr("templates.create.untranslated", { fields: untranslated.join(", ") }) }) : null,
1008
+ error ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: styles3.error, role: "alert", children: error }) : null,
1009
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "submit", disabled: busy, style: styles3.button, children: busy ? tr("templates.create.creating") : addingLocale ? tr("templates.create.submitLocale") : tr("templates.create.submitNew") })
1010
+ ] }),
1011
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: styles3.previewColumn, children: [
1012
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: styles3.previewHeader, children: [
1013
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: styles3.label, children: tr("templates.editor.preview") }),
1014
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: styles3.meta, children: isPreviewing ? tr("templates.editor.rendering") : tr("templates.editor.live") })
1015
+ ] }),
1016
+ previewError ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: styles3.error, role: "alert", children: previewError.explanation }) : null,
1017
+ preview?.subject ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: styles3.previewSubject, children: preview.subject }) : null,
1018
+ preview?.html ? (
1019
+ // sandbox="" — no scripts, no same-origin: operator-authored HTML must
1020
+ // never execute in the console's origin.
1021
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1022
+ "iframe",
1023
+ {
1024
+ title: tr("templates.editor.previewTitle"),
1025
+ style: styles3.previewFrame,
1026
+ sandbox: "",
1027
+ srcDoc: preview.html
1028
+ }
1029
+ )
1030
+ ) : preview ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("pre", { style: styles3.previewText, children: preview.text }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: styles3.meta, children: tr("templates.editor.nothingRendered") })
1031
+ ] })
847
1032
  ] })
848
1033
  ] });
849
1034
  }
850
- var MONO = 'var(--fonderie-mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace)';
1035
+ var MONO2 = 'var(--fonderie-mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace)';
851
1036
  var styles3 = {
852
- container: { padding: "8px 40px 64px", maxWidth: 760, boxSizing: "border-box" },
1037
+ container: { padding: "8px 40px 64px", maxWidth: 1400, boxSizing: "border-box" },
1038
+ // The editor's layout: the form on the left, the render pinned on the right.
1039
+ split: {
1040
+ display: "grid",
1041
+ gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)",
1042
+ gap: 24,
1043
+ alignItems: "start"
1044
+ },
1045
+ previewColumn: {
1046
+ minWidth: 0,
1047
+ display: "flex",
1048
+ flexDirection: "column",
1049
+ position: "sticky",
1050
+ top: 16
1051
+ },
1052
+ previewHeader: { display: "flex", justifyContent: "space-between", alignItems: "center" },
1053
+ previewSubject: { fontSize: 14, fontWeight: 600, margin: "8px 0" },
1054
+ previewFrame: {
1055
+ boxShadow: "var(--fonderie-shadow-card,0 2px 3px 0 rgba(0,0,0,.05))",
1056
+ width: "100%",
1057
+ height: 520,
1058
+ border: "1px solid var(--fonderie-border,#e0e0e0)",
1059
+ borderRadius: 8,
1060
+ background: "var(--fonderie-surface,#fff)"
1061
+ },
1062
+ previewText: {
1063
+ whiteSpace: "pre-wrap",
1064
+ border: "1px solid var(--fonderie-border,#e0e0e0)",
1065
+ borderRadius: 8,
1066
+ padding: 12,
1067
+ fontSize: 13,
1068
+ fontFamily: MONO2
1069
+ },
1070
+ // The console's warning token (the shell's amber), readable in both themes.
1071
+ notice: {
1072
+ fontSize: 13,
1073
+ color: "var(--fonderie-text,#171717)",
1074
+ background: "color-mix(in srgb, var(--fonderie-warning,#f5a623) 16%, transparent)",
1075
+ borderRadius: 6,
1076
+ padding: "8px 12px",
1077
+ margin: "12px 0 0"
1078
+ },
853
1079
  title: {
854
1080
  fontSize: 22,
855
1081
  fontWeight: 600,
@@ -892,7 +1118,7 @@ var styles3 = {
892
1118
  lineHeight: 1.55,
893
1119
  background: "var(--fonderie-surface,#fff)",
894
1120
  color: "var(--fonderie-text,#171717)",
895
- fontFamily: MONO
1121
+ fontFamily: MONO2
896
1122
  },
897
1123
  error: {
898
1124
  color: "var(--fonderie-danger,#e00)",