@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
@@ -57,6 +57,7 @@ import type {
57
57
  PriceGroup,
58
58
  IntroImageAspect,
59
59
  CountdownProps,
60
+ LayoutProps,
60
61
  } from "../../blocks/types";
61
62
  import { INTRO_IMAGE_ASPECTS } from "../../blocks/types";
62
63
  import { BLOCK_ICON_NAMES, resolveBlockIcon } from "../../blocks/icons";
@@ -67,6 +68,8 @@ import {
67
68
  TextField,
68
69
  MediaField,
69
70
  MediaMultiField,
71
+ SelectField,
72
+ NumberField,
70
73
  } from "./fields";
71
74
  import { RichTextEditor } from "../../host";
72
75
  import { useEditLockBlocked } from "../EditLock";
@@ -74,6 +77,13 @@ import { Plus, Trash2, ArrowUp, ArrowDown } from "lucide-react";
74
77
  import type { LocalizedString, Locale } from "../../i18n";
75
78
  import { SUPPORTED_LOCALES } from "../../i18n";
76
79
  import { normalizeRevealMode, type RevealMode } from "../../ui/Reveal";
80
+ import {
81
+ clampAutoplayMs,
82
+ DEFAULT_AUTOPLAY_MS,
83
+ MIN_AUTOPLAY_MS,
84
+ MAX_AUTOPLAY_MS,
85
+ } from "../../ui/MobileCarousel";
86
+ import { LAYOUT_MAX_COLUMNS, LAYOUT_MIN_COLUMNS } from "../../blocks/block-tree";
77
87
 
78
88
  export type BlockPatchProps = {
79
89
  block: BlockInstance;
@@ -1881,6 +1891,43 @@ function IconGridInspector({ block, patch }: BlockPatchProps) {
1881
1891
  </label>
1882
1892
  <TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
1883
1893
  </InspectorSection>
1894
+ <InspectorSection title="Mobile presentation">
1895
+ <SelectField
1896
+ label="On phones"
1897
+ value={p.mobileLayout ?? "stack"}
1898
+ options={[
1899
+ { value: "stack", label: "Stack (one card per row)" },
1900
+ { value: "slider", label: "Slider (swipe sideways)" },
1901
+ ]}
1902
+ onChange={(v) => update({ ...p, mobileLayout: v as IconGridProps["mobileLayout"] })}
1903
+ hint="Tablet and desktop keep the column grid above."
1904
+ />
1905
+ {p.mobileLayout === "slider" ? (
1906
+ <>
1907
+ <SelectField
1908
+ label="Autoplay"
1909
+ value={p.mobileAutoplay ? "on" : "off"}
1910
+ options={[
1911
+ { value: "off", label: "Off" },
1912
+ { value: "on", label: "On" },
1913
+ ]}
1914
+ onChange={(v) => update({ ...p, mobileAutoplay: v === "on" })}
1915
+ hint="Readers who ask for reduced motion never see it move on its own."
1916
+ />
1917
+ {p.mobileAutoplay ? (
1918
+ <NumberField
1919
+ label="Autoplay interval"
1920
+ value={Math.round(clampAutoplayMs(p.mobileAutoplayMs) / 1000)}
1921
+ min={MIN_AUTOPLAY_MS / 1000}
1922
+ max={MAX_AUTOPLAY_MS / 1000}
1923
+ suffix="seconds"
1924
+ onChange={(v) => update({ ...p, mobileAutoplayMs: clampAutoplayMs(v * 1000) })}
1925
+ hint={`Between ${MIN_AUTOPLAY_MS / 1000} and ${MAX_AUTOPLAY_MS / 1000} seconds. Default ${DEFAULT_AUTOPLAY_MS / 1000}.`}
1926
+ />
1927
+ ) : null}
1928
+ </>
1929
+ ) : null}
1930
+ </InspectorSection>
1884
1931
  <InspectorSection title={`Cards (${items.length})`}>
1885
1932
  <div className="space-y-4">
1886
1933
  {items.map((item, i) => {
@@ -2412,7 +2459,109 @@ function MediaSplitInspector({ block, patch }: BlockPatchProps) {
2412
2459
  }
2413
2460
 
2414
2461
 
2462
+ /* ---------------- Layout (columns container) ---------------- */
2463
+ function LayoutInspector({ block, patch }: BlockPatchProps) {
2464
+ const p = (block.props ?? {}) as LayoutProps;
2465
+ const update = (next: LayoutProps) => patch(next as unknown as BlockProps);
2466
+ const desktop = p.columnsDesktop ?? 2;
2467
+ const desktopOptions = Array.from(
2468
+ { length: LAYOUT_MAX_COLUMNS - LAYOUT_MIN_COLUMNS + 1 },
2469
+ (_, i) => LAYOUT_MIN_COLUMNS + i,
2470
+ );
2471
+
2472
+ return (
2473
+ <>
2474
+ <InspectorSection title="Columns">
2475
+ <SelectField
2476
+ label="Desktop columns"
2477
+ value={String(desktop)}
2478
+ options={desktopOptions.map((n) => ({ value: String(n), label: `${n} columns` }))}
2479
+ onChange={(v) =>
2480
+ update({ ...p, columnsDesktop: Number(v) as LayoutProps["columnsDesktop"] })
2481
+ }
2482
+ />
2483
+ <SelectField
2484
+ label="Tablet columns"
2485
+ value={String(p.columnsTablet ?? Math.min(2, desktop))}
2486
+ options={[1, 2, 3, 4].map((n) => ({
2487
+ value: String(n),
2488
+ label: n === 1 ? "1 column (stacked)" : `${n} columns`,
2489
+ }))}
2490
+ onChange={(v) =>
2491
+ update({ ...p, columnsTablet: Number(v) as LayoutProps["columnsTablet"] })
2492
+ }
2493
+ />
2494
+ <p className="text-[0.7rem] text-ink/50">
2495
+ Blocks are added to a column from the page outline: drag any block onto the column you
2496
+ want it in. Reducing the column count never deletes anything — leftover blocks move into
2497
+ the last column.
2498
+ </p>
2499
+ </InspectorSection>
2500
+ <InspectorSection title="Appearance">
2501
+ <SelectField
2502
+ label="Gap between columns"
2503
+ value={p.gap ?? "md"}
2504
+ options={[
2505
+ { value: "none", label: "None" },
2506
+ { value: "sm", label: "Small" },
2507
+ { value: "md", label: "Medium" },
2508
+ { value: "lg", label: "Large" },
2509
+ ]}
2510
+ onChange={(v) => update({ ...p, gap: v as LayoutProps["gap"] })}
2511
+ />
2512
+ <SelectField
2513
+ label="Vertical alignment"
2514
+ value={p.align ?? "stretch"}
2515
+ options={[
2516
+ { value: "stretch", label: "Equal height" },
2517
+ { value: "start", label: "Top" },
2518
+ { value: "center", label: "Centre" },
2519
+ ]}
2520
+ onChange={(v) => update({ ...p, align: v as LayoutProps["align"] })}
2521
+ />
2522
+ <SelectField
2523
+ label="Background"
2524
+ value={p.tone ?? "transparent"}
2525
+ options={[
2526
+ { value: "transparent", label: "Transparent" },
2527
+ { value: "white", label: "White" },
2528
+ { value: "paper", label: "Paper" },
2529
+ { value: "ice", label: "Ice" },
2530
+ ]}
2531
+ onChange={(v) => update({ ...p, tone: v as LayoutProps["tone"] })}
2532
+ />
2533
+ <SelectField
2534
+ label="Vertical padding"
2535
+ value={p.padding ?? "none"}
2536
+ options={[
2537
+ { value: "none", label: "None" },
2538
+ { value: "compact", label: "Compact" },
2539
+ { value: "normal", label: "Normal" },
2540
+ { value: "roomy", label: "Roomy" },
2541
+ ]}
2542
+ onChange={(v) => update({ ...p, padding: v as LayoutProps["padding"] })}
2543
+ />
2544
+ <SelectField
2545
+ label="Width"
2546
+ value={p.contained === false ? "full" : "contained"}
2547
+ options={[
2548
+ { value: "contained", label: "Inside the page container" },
2549
+ { value: "full", label: "Full width" },
2550
+ ]}
2551
+ onChange={(v) => update({ ...p, contained: v !== "full" })}
2552
+ />
2553
+ <TextField
2554
+ label="Anchor id"
2555
+ value={p.anchor ?? ""}
2556
+ onChange={(v) => update({ ...p, anchor: v })}
2557
+ />
2558
+ </InspectorSection>
2559
+ </>
2560
+ );
2561
+ }
2562
+
2415
2563
  export {
2564
+ LayoutInspector,
2416
2565
  HeroInspector,
2417
2566
  IntroInspector,
2418
2567
  FaqInspector,
@@ -112,6 +112,87 @@ export function TextField({
112
112
  );
113
113
  }
114
114
 
115
+ const CONTROL_CLASS =
116
+ "w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea";
117
+
118
+ /** Labelled `<select>` in the inspector's house style. */
119
+ export function SelectField<T extends string>({
120
+ label,
121
+ value,
122
+ options,
123
+ onChange,
124
+ hint,
125
+ }: {
126
+ label: string;
127
+ value: T;
128
+ options: readonly { value: T; label: string }[];
129
+ onChange: (v: T) => void;
130
+ hint?: string;
131
+ }) {
132
+ return (
133
+ <label className="block space-y-1.5">
134
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
135
+ <select
136
+ value={value}
137
+ onChange={(e) => onChange(e.target.value as T)}
138
+ className={CONTROL_CLASS}
139
+ >
140
+ {options.map((o) => (
141
+ <option key={o.value} value={o.value}>
142
+ {o.label}
143
+ </option>
144
+ ))}
145
+ </select>
146
+ {hint ? <span className="block text-[0.7rem] text-ink/50">{hint}</span> : null}
147
+ </label>
148
+ );
149
+ }
150
+
151
+ /** Labelled number input with a unit suffix and hard bounds. */
152
+ export function NumberField({
153
+ label,
154
+ value,
155
+ onChange,
156
+ min,
157
+ max,
158
+ step = 1,
159
+ suffix,
160
+ hint,
161
+ }: {
162
+ label: string;
163
+ value: number;
164
+ onChange: (v: number) => void;
165
+ min?: number;
166
+ max?: number;
167
+ step?: number;
168
+ suffix?: string;
169
+ hint?: string;
170
+ }) {
171
+ return (
172
+ <label className="block space-y-1.5">
173
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
174
+ <span className="flex items-center gap-2">
175
+ <input
176
+ type="number"
177
+ value={Number.isFinite(value) ? value : ""}
178
+ min={min}
179
+ max={max}
180
+ step={step}
181
+ onChange={(e) => {
182
+ const n = Number(e.target.value);
183
+ if (!Number.isFinite(n)) return;
184
+ const clamped = Math.min(max ?? n, Math.max(min ?? n, n));
185
+ onChange(clamped);
186
+ }}
187
+ className={CONTROL_CLASS}
188
+ />
189
+ {suffix ? <span className="shrink-0 text-xs text-ink/60">{suffix}</span> : null}
190
+ </span>
191
+ {hint ? <span className="block text-[0.7rem] text-ink/50">{hint}</span> : null}
192
+ </label>
193
+ );
194
+ }
195
+
115
196
  export function InspectorSection({
116
197
  title,
117
198
  children,
@@ -42,6 +42,7 @@ import {
42
42
  CtaBannerInspector,
43
43
  CountdownInspector,
44
44
  MediaSplitInspector,
45
+ LayoutInspector,
45
46
  type BlockPatchProps,
46
47
  } from "./blocks";
47
48
 
@@ -722,6 +723,8 @@ export function BlockInspector({
722
723
  globalSections: { key: string; name: string }[];
723
724
  }) {
724
725
  switch (block.type) {
726
+ case "layout":
727
+ return <LayoutInspector block={block} patch={patch} />;
725
728
  case "hero":
726
729
  return <HeroInspector block={block} patch={patch} />;
727
730
  case "intro":
@@ -1,8 +1,13 @@
1
+ import { useEffect, useId, useRef, type ReactNode } from "react";
1
2
  import { Loader2 } from "lucide-react";
2
3
 
3
4
  /**
4
- * Small backoffice confirmation modal (English UI) used before destructive or
5
- * bulk actions in the Media Library.
5
+ * Small backoffice confirmation modal (English UI) used before destructive,
6
+ * bulk or publication-changing actions.
7
+ *
8
+ * It is a real dialog for assistive technology: labelled by its heading,
9
+ * described by its body, focus moved to the confirm button on open and
10
+ * restored on close, Escape cancels and Tab is trapped inside.
6
11
  */
7
12
  export function ConfirmDialog({
8
13
  title,
@@ -12,6 +17,7 @@ export function ConfirmDialog({
12
17
  cancelLabel = "Cancel",
13
18
  danger = false,
14
19
  busy = false,
20
+ children,
15
21
  onConfirm,
16
22
  onCancel,
17
23
  }: {
@@ -22,23 +28,76 @@ export function ConfirmDialog({
22
28
  cancelLabel?: string;
23
29
  danger?: boolean;
24
30
  busy?: boolean;
31
+ /** Extra controls, e.g. the name field a duplicate is created with. */
32
+ children?: ReactNode;
25
33
  onConfirm: () => void;
26
34
  onCancel: () => void;
27
35
  }) {
36
+ const id = useId();
37
+ const panel = useRef<HTMLDivElement>(null);
38
+ const confirmButton = useRef<HTMLButtonElement>(null);
39
+
40
+ useEffect(() => {
41
+ const opener = document.activeElement as HTMLElement | null;
42
+ // Custom content owns the focus (a text field wants it); otherwise the
43
+ // safe default is the confirm button the dialog was opened to offer.
44
+ if (!children) confirmButton.current?.focus();
45
+ const onKeyDown = (event: KeyboardEvent) => {
46
+ if (event.key === "Escape") {
47
+ event.stopPropagation();
48
+ onCancel();
49
+ return;
50
+ }
51
+ if (event.key !== "Tab" || !panel.current) return;
52
+ const focusable = panel.current.querySelectorAll<HTMLElement>(
53
+ 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])',
54
+ );
55
+ const first = focusable[0];
56
+ const last = focusable[focusable.length - 1];
57
+ if (!first || !last) return;
58
+ if (event.shiftKey && document.activeElement === first) {
59
+ event.preventDefault();
60
+ last.focus();
61
+ } else if (!event.shiftKey && document.activeElement === last) {
62
+ event.preventDefault();
63
+ first.focus();
64
+ }
65
+ };
66
+ document.addEventListener("keydown", onKeyDown, true);
67
+ return () => {
68
+ document.removeEventListener("keydown", onKeyDown, true);
69
+ opener?.focus?.();
70
+ };
71
+ // Mount-only: re-running would steal focus back mid-interaction.
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, []);
74
+
28
75
  return (
29
76
  <div
30
77
  className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
31
78
  onClick={() => !busy && onCancel()}
32
79
  >
33
80
  <div
81
+ ref={panel}
82
+ role="dialog"
83
+ aria-modal="true"
84
+ aria-labelledby={`${id}-title`}
85
+ aria-describedby={description ? `${id}-description` : undefined}
34
86
  className="w-full max-w-md border border-ink/15 bg-white"
35
87
  onClick={(e) => e.stopPropagation()}
36
88
  >
37
89
  <div className="border-b border-ink/10 px-4 py-3">
38
- <h2 className="text-sm font-medium text-ink">{title}</h2>
90
+ <h2 id={`${id}-title`} className="text-sm font-medium text-ink">
91
+ {title}
92
+ </h2>
39
93
  </div>
40
94
  <div className="space-y-3 px-4 py-4">
41
- {description ? <p className="text-sm text-ink/70">{description}</p> : null}
95
+ {description ? (
96
+ <p id={`${id}-description`} className="text-sm text-ink/70">
97
+ {description}
98
+ </p>
99
+ ) : null}
100
+ {children}
42
101
  {details?.length ? (
43
102
  <ul className="max-h-40 space-y-1 overflow-auto border border-ink/10 bg-ice/30 p-2 text-[0.72rem] text-ink/70">
44
103
  {details.map((d) => (
@@ -59,6 +118,7 @@ export function ConfirmDialog({
59
118
  {cancelLabel}
60
119
  </button>
61
120
  <button
121
+ ref={confirmButton}
62
122
  type="button"
63
123
  disabled={busy}
64
124
  onClick={onConfirm}
@@ -81,6 +81,19 @@ export type BackofficeHost = {
81
81
  // locale domains
82
82
  saveLocaleDomain: HostServerFn;
83
83
  deleteLocaleDomain: HostServerFn;
84
+ /*
85
+ * Which hostname actually serves the backoffice, and why.
86
+ *
87
+ * `SITE_URL` is server information: it cannot be read from the browser,
88
+ * and a value baked into this registry would be a guess presented to an
89
+ * administrator as fact about their own install. So the answer is fetched
90
+ * from the server instead, and returns the package's `EffectiveAdminHost`.
91
+ *
92
+ * Optional: a host that has not registered it simply gets no statement
93
+ * about the backoffice hostname on the domains screen. The editor works
94
+ * either way — this only ever adds an explanation, never a permission.
95
+ */
96
+ getEffectiveAdminHost?: HostServerFn;
84
97
  /*
85
98
  * Popup opt-in destinations, for the popup editor's picker.
86
99
  *
@@ -179,6 +192,8 @@ export type BackofficeHost = {
179
192
  saveForm: HostServerFn;
180
193
  syncSubmissionsToSheet: HostServerFn;
181
194
  testWebhook: HostServerFn;
195
+ /** Optional: verifies the Google Sheets credentials, sheet and tab. */
196
+ testSheetConnection?: HostServerFn;
182
197
  listSubmissions: HostServerFn;
183
198
  setSubmissionStatus: HostServerFn;
184
199
  pushSubmissionsToSheet: HostServerFn;
@@ -211,6 +226,13 @@ export type BackofficeHost = {
211
226
  listPagePublications: HostServerFn;
212
227
  cancelPagePublication: HostServerFn;
213
228
  renamePage: HostServerFn;
229
+ /**
230
+ * Optional publish/unpublish and duplicate, both confirmed in the UI.
231
+ * Optional so a host that has not registered them keeps working: the
232
+ * Pages list simply hides the two actions.
233
+ */
234
+ setPageStatus?: HostServerFn;
235
+ duplicatePage?: HostServerFn;
214
236
  /**
215
237
  * The site's pages, listed.
216
238
  *
@@ -226,6 +248,14 @@ export type BackofficeHost = {
226
248
  /**
227
249
  * React hook returning a resolver for a page's public URL. Implemented as a
228
250
  * hook so a site can read its slug overrides and locale domains.
251
+ *
252
+ * `path` is site-relative. `host` is the public hostname for that locale,
253
+ * or `""` when the locale has no public hostname — since m7.10 the
254
+ * locale-to-domain table is explicit, so a locale missing from it has no
255
+ * public site at all. An empty `host` therefore means NO PUBLIC URL
256
+ * EXISTS; it is not a signal to use `path` on its own, and a host must
257
+ * not answer it by borrowing the current hostname or another locale's
258
+ * domain. Screens are expected to offer no public link in that case.
229
259
  */
230
260
  usePageUrl: () => (
231
261
  pageKey: string,
@@ -317,6 +347,7 @@ export function backofficePaths(host: BackofficeHost = backofficeHost()) {
317
347
  email: `${base}/admin/email`,
318
348
  contact: `${base}/admin/contact`,
319
349
  tracking: `${base}/admin/tracking`,
350
+ domains: `${base}/admin/domains`,
320
351
  redirects: `${base}/admin/redirects`,
321
352
  sitemap: `${base}/admin/sitemap`,
322
353
  navigation: `${base}/admin/navigation`,