@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.10

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 (83) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +16 -2
  3. package/docs/EDIT-LOCKS.md +213 -0
  4. package/docs/POPUPS.md +102 -0
  5. package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
  6. package/package.json +31 -3
  7. package/scripts/create-cms-site.mjs +5 -4
  8. package/scripts/sync-migrations.mjs +27 -6
  9. package/src/backoffice/ContactDetailsView.tsx +19 -5
  10. package/src/backoffice/DomainsView.tsx +152 -25
  11. package/src/backoffice/EditLock.tsx +513 -0
  12. package/src/backoffice/EmailSettingsView.tsx +99 -0
  13. package/src/backoffice/FormEditorView.tsx +531 -512
  14. package/src/backoffice/FormSettingsView.tsx +16 -4
  15. package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
  16. package/src/backoffice/NavigationView.tsx +35 -11
  17. package/src/backoffice/PageBuilderView.tsx +51 -16
  18. package/src/backoffice/RichTextEditor.tsx +50 -14
  19. package/src/backoffice/SectionEditorView.tsx +18 -6
  20. package/src/backoffice/SectionFormView.tsx +184 -31
  21. package/src/backoffice/SiteSettingsView.tsx +14 -15
  22. package/src/backoffice/TrackingView.tsx +247 -208
  23. package/src/backoffice/forms/VersionHistory.tsx +4 -2
  24. package/src/backoffice/inspectors/blocks.tsx +22 -1
  25. package/src/backoffice/inspectors/fields.tsx +21 -0
  26. package/src/backoffice/routes/host.ts +58 -7
  27. package/src/backoffice/routes/index.tsx +117 -15
  28. package/src/backoffice/routes/pages.tsx +10 -6
  29. package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
  30. package/src/backoffice/shell/domain-guard.ts +27 -14
  31. package/src/backoffice/shell/index.ts +5 -0
  32. package/src/backoffice/shell/nav-model.ts +32 -23
  33. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  34. package/src/backoffice/tracking-drafts.ts +150 -0
  35. package/src/blocks/components/CtaBannerBlock.tsx +18 -12
  36. package/src/blocks/components/HeroFullBlock.tsx +25 -12
  37. package/src/blocks/components/MediaSplitBlock.tsx +91 -41
  38. package/src/blocks/components/PriceTableBlock.tsx +123 -89
  39. package/src/blocks/components/RichTextBlock.tsx +26 -4
  40. package/src/blocks/index.ts +8 -0
  41. package/src/blocks/optional-content.ts +82 -0
  42. package/src/blocks/registry.tsx +1 -0
  43. package/src/blocks/types.ts +6 -0
  44. package/src/cms/edit-lock.ts +491 -0
  45. package/src/config/modules.ts +208 -0
  46. package/src/config/site-config.ts +9 -31
  47. package/src/forms/FormRenderer.tsx +62 -3
  48. package/src/forms/submission-result.ts +35 -0
  49. package/src/forms/success-listeners.ts +46 -0
  50. package/src/host/index.tsx +27 -1
  51. package/src/modules/index.ts +28 -0
  52. package/src/modules/public-runtime.tsx +159 -0
  53. package/src/navigation/index.ts +29 -0
  54. package/src/popups/admin.tsx +123 -0
  55. package/src/popups/cms-bridges.ts +240 -0
  56. package/src/popups/destinations.tsx +147 -0
  57. package/src/popups/host.ts +91 -0
  58. package/src/popups/media-bridge.tsx +84 -0
  59. package/src/popups/register.ts +23 -0
  60. package/src/popups/runtime.tsx +128 -0
  61. package/src/runtime/server-entry.ts +8 -2
  62. package/src/server/content-sections-core.ts +22 -5
  63. package/src/server/domains-core.ts +124 -107
  64. package/src/server/edit-lock-adapter.ts +91 -0
  65. package/src/server/edit-lock-supabase-adapter.ts +135 -0
  66. package/src/server/edit-locks-core.ts +286 -0
  67. package/src/server/forms-core.ts +37 -2
  68. package/src/server/injections-core.ts +230 -49
  69. package/src/server/page-seo-save-core.ts +27 -0
  70. package/src/server/pages-core.ts +108 -11
  71. package/src/server/popup-optin-trigger.ts +111 -0
  72. package/src/server/popup-optin-webhook-core.ts +363 -0
  73. package/src/server/provider-secrets.ts +100 -4
  74. package/src/server/schemas.ts +74 -9
  75. package/src/server/webhook-url-guard.ts +199 -0
  76. package/src/site/SiteHeader.tsx +11 -5
  77. package/src/site/host.ts +172 -0
  78. package/src/site/locale-domains.ts +500 -0
  79. package/src/site/routes.ts +206 -38
  80. package/src/site/site-url.ts +80 -17
  81. package/src/tracking/injections.ts +52 -9
  82. package/src/types/css-modules.d.ts +6 -0
  83. package/src/ui/Reveal.tsx +74 -9
@@ -12,6 +12,7 @@ import type { ReactNode } from "react";
12
12
  import { useQuery } from "@tanstack/react-query";
13
13
  import { useServerFn } from "@tanstack/react-start";
14
14
 
15
+ import { findCmsModule, moduleRoleSatisfied } from "../../config/modules";
15
16
  import type { SiteConfig, SiteFeatureFlags } from "../../config/site-config";
16
17
  import { resolveSiteConfig } from "../../config/site-config";
17
18
  import { backofficeHost } from "../routes/host";
@@ -82,3 +83,69 @@ export function useFeatureGate(flag: keyof SiteFeatureFlags, label?: string): Re
82
83
  if (enabled) return null;
83
84
  return <FeatureDisabledNotice label={label} />;
84
85
  }
86
+
87
+ /** Query key shared by every screen that asks what the signed-in user holds. */
88
+ export const MY_ROLES_QUERY_KEY = ["admin", "my-roles"] as const;
89
+
90
+ /**
91
+ * Platform roles of the signed-in viewer.
92
+ *
93
+ * Non-string entries are dropped rather than trusted: this list decides what a
94
+ * screen offers, and a host that answers with something other than an array of
95
+ * role names should grant nothing, not everything.
96
+ */
97
+ export function useMyRoles(): { roles: string[]; isLoading: boolean } {
98
+ const host = backofficeHost();
99
+ const load = useServerFn(host.fns.getMyRoles);
100
+ const q = useQuery({ queryKey: MY_ROLES_QUERY_KEY, queryFn: () => load() as Promise<unknown> });
101
+ const roles = Array.isArray(q.data)
102
+ ? q.data.filter((role): role is string => typeof role === "string")
103
+ : [];
104
+ return { roles, isLoading: q.isLoading };
105
+ }
106
+
107
+ /**
108
+ * Whether the viewer's roles reach a module's screens. `undefined` while they
109
+ * are still being read, so a caller can wait rather than deny access to
110
+ * somebody who in fact holds the role.
111
+ */
112
+ export function useModuleRoleAllowed(flag: keyof SiteFeatureFlags): boolean | undefined {
113
+ const { roles, isLoading } = useMyRoles();
114
+ if (isLoading) return undefined;
115
+ return moduleRoleSatisfied(findCmsModule(flag)?.requiresRole, roles);
116
+ }
117
+
118
+ /** Notice shown instead of a screen the viewer's roles do not reach. */
119
+ export function ModuleRoleNotice({ label, role }: { label?: string; role: string }) {
120
+ return (
121
+ <div className="p-8 md:p-12">
122
+ <h1 className="font-serif text-2xl text-ink">
123
+ {label ? `${label} needs the ${role} role` : `This screen needs the ${role} role`}
124
+ </h1>
125
+ <p className="mt-3 max-w-md text-sm text-ink/65">
126
+ Your account does not hold it, so this screen is read-only to you — every save here would be
127
+ refused by the server. A superadministrator can grant the role under Admin → User roles.
128
+ </p>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Guard for a module screen that also has a role requirement.
135
+ *
136
+ * Both gates apply, and the module switch is answered first: a module that is
137
+ * turned off is off for an administrator too, and saying "you need a role" for
138
+ * a screen nobody can reach would be a lie about why it is unavailable.
139
+ */
140
+ export function useModuleGate(flag: keyof SiteFeatureFlags, label?: string): ReactNode | null {
141
+ const featureGate = useFeatureGate(flag, label);
142
+ const allowed = useModuleRoleAllowed(flag);
143
+ if (featureGate) return featureGate;
144
+ // Worded differently from the settings wait above on purpose: the two are
145
+ // waiting on different answers, and only this one is a wait that must never
146
+ // be mistaken for a refusal.
147
+ if (allowed === undefined)
148
+ return <div className="p-8 text-sm text-ink/50">Checking your access…</div>;
149
+ if (allowed) return null;
150
+ return <ModuleRoleNotice label={label} role={findCmsModule(flag)?.requiresRole ?? "admin"} />;
151
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Draft bookkeeping for the "Code & tracking" screen.
3
+ *
4
+ * The screen edits rows locally and reloads them from the server, and those two
5
+ * facts fight: reloading used to replace the whole editing state, so anything
6
+ * not yet saved — a new profile, a duplicate, edits to one profile while
7
+ * another was being saved — was thrown away the moment a refetch landed.
8
+ *
9
+ * So local rows are reconciled against the server list rather than replaced.
10
+ * A row the user has changed keeps its local value; an untouched row takes the
11
+ * server's; a row the server no longer has goes away; and a row that has never
12
+ * been saved is never dropped by a reload, because the server cannot possibly
13
+ * know about it yet.
14
+ *
15
+ * Pure module — no React, so the rules are testable on their own.
16
+ */
17
+
18
+ /** The fields a draft row carries. Structural, to match the host's own type. */
19
+ export type DraftProfile = {
20
+ id: string;
21
+ name: string;
22
+ scope_type: string;
23
+ scope_value: string;
24
+ enabled: boolean;
25
+ head_html: string;
26
+ body_start_html: string;
27
+ body_end_html: string;
28
+ gtm_id: string;
29
+ ga4_id: string;
30
+ meta_pixel_id: string;
31
+ tiktok_pixel_id: string;
32
+ extra_meta: { name: string; content: string }[];
33
+ sort_order: number;
34
+ };
35
+
36
+ export type DraftRow<T extends DraftProfile = DraftProfile> = {
37
+ /**
38
+ * Identity of the row in the editor, stable across saves and reloads.
39
+ *
40
+ * Not the server id: a row exists in the editor before it has one, and
41
+ * keying off `id` is what made an unsaved row and its saved counterpart look
42
+ * like the same row to React and like different rows to everything else.
43
+ */
44
+ key: string;
45
+ /** Current edited value. */
46
+ value: T;
47
+ /**
48
+ * The server row this draft was last in sync with, or null for a row that
49
+ * has never been saved. Local edits are exactly `value` differing from this.
50
+ */
51
+ base: T | null;
52
+ };
53
+
54
+ let counter = 0;
55
+
56
+ /** A fresh editor key. */
57
+ export function draftKey(): string {
58
+ counter += 1;
59
+ return `draft-${counter}`;
60
+ }
61
+
62
+ const COMPARED = [
63
+ "name",
64
+ "scope_type",
65
+ "scope_value",
66
+ "enabled",
67
+ "head_html",
68
+ "body_start_html",
69
+ "body_end_html",
70
+ "gtm_id",
71
+ "ga4_id",
72
+ "meta_pixel_id",
73
+ "tiktok_pixel_id",
74
+ "sort_order",
75
+ ] as const;
76
+
77
+ /** True when two rows hold the same content, ignoring server-only fields. */
78
+ export function sameProfile(a: DraftProfile, b: DraftProfile): boolean {
79
+ for (const field of COMPARED) {
80
+ if (a[field] !== b[field]) return false;
81
+ }
82
+ return JSON.stringify(a.extra_meta ?? []) === JSON.stringify(b.extra_meta ?? []);
83
+ }
84
+
85
+ /** True when the row holds edits the server has not seen. */
86
+ export function isDirty(row: DraftRow): boolean {
87
+ return row.base === null || !sameProfile(row.value, row.base);
88
+ }
89
+
90
+ /** Wrap a freshly loaded server row. */
91
+ export function serverRow<T extends DraftProfile>(value: T): DraftRow<T> {
92
+ return { key: draftKey(), value, base: value };
93
+ }
94
+
95
+ /** Wrap a row that exists only in the editor so far. */
96
+ export function newRow<T extends DraftProfile>(value: T): DraftRow<T> {
97
+ return { key: draftKey(), value, base: null };
98
+ }
99
+
100
+ /**
101
+ * Fold a server list into the current rows.
102
+ *
103
+ * Saved rows keep the server's order; rows that have never been saved keep
104
+ * their own order after them, which is where they were added.
105
+ */
106
+ export function mergeServerProfiles<T extends DraftProfile>(
107
+ rows: DraftRow<T>[],
108
+ profiles: T[],
109
+ ): DraftRow<T>[] {
110
+ const saved: DraftRow<T>[] = [];
111
+ for (const p of profiles) {
112
+ const row = rows.find((r) => r.base !== null && r.value.id === p.id);
113
+ if (!row) {
114
+ saved.push(serverRow(p));
115
+ continue;
116
+ }
117
+ // A row the user has edited keeps its edits and simply learns what the
118
+ // server now holds; an untouched row takes the server's version, so a real
119
+ // update still lands.
120
+ saved.push(isDirty(row) ? { ...row, base: p } : { ...row, value: p, base: p });
121
+ }
122
+
123
+ const unsaved = rows.filter((r) => r.base === null);
124
+ return [...saved, ...unsaved];
125
+ }
126
+
127
+ /**
128
+ * Record a successful save.
129
+ *
130
+ * `sent` is the value that actually went to the server, not whatever the row
131
+ * holds now — anything typed while the request was in flight is still unsaved
132
+ * and has to stay that way. `id` adopts the identity a newly created row was
133
+ * given, so the next reload reconciles with it instead of listing it twice.
134
+ */
135
+ export function markSaved<T extends DraftProfile>(
136
+ rows: DraftRow<T>[],
137
+ key: string,
138
+ sent: T,
139
+ id?: string,
140
+ ): DraftRow<T>[] {
141
+ return rows.map((row) => {
142
+ if (row.key !== key) return row;
143
+ const savedId = id || row.value.id;
144
+ return {
145
+ ...row,
146
+ value: { ...row.value, id: savedId },
147
+ base: { ...sent, id: savedId },
148
+ };
149
+ });
150
+ }
@@ -1,11 +1,12 @@
1
1
  import { ArrowRight } from "lucide-react";
2
2
  import { useT } from "../../i18n";
3
+ import { resolveCta, resolvedText } from "../optional-content";
3
4
  import { Reveal } from "../../ui/Reveal";
4
5
  import type { CtaBannerProps } from "../types";
5
6
 
6
7
  /** Localized call-to-action banner with up to two links. */
7
8
  export function CtaBannerBlock({ props }: { props: CtaBannerProps }) {
8
- const { t } = useT();
9
+ const { locale } = useT();
9
10
  const tone = props.tone ?? "ink";
10
11
  const dark = tone === "ink";
11
12
  const bg = dark ? "bg-ink text-paper" : tone === "sea" ? "bg-ice text-ink" : "bg-paper text-ink";
@@ -18,29 +19,34 @@ export function CtaBannerBlock({ props }: { props: CtaBannerProps }) {
18
19
  ? "border border-paper/60 text-paper hover:bg-paper hover:text-ink"
19
20
  : "border border-ink/30 text-ink hover:bg-ink hover:text-paper";
20
21
 
22
+ const eyebrow = resolvedText(props.eyebrow, locale);
23
+ const title = resolvedText(props.title, locale);
24
+ const body = resolvedText(props.body, locale);
25
+
26
+ // Both a visible label and a usable link, or the whole element goes.
21
27
  const links = [
22
- { cta: props.cta, className: primary },
23
- { cta: props.secondaryCta, className: secondary },
24
- ].filter((l) => l.cta?.href || l.cta?.label);
28
+ { cta: resolveCta(props.cta, locale), className: primary },
29
+ { cta: resolveCta(props.secondaryCta, locale), className: secondary },
30
+ ].filter((l): l is { cta: NonNullable<typeof l.cta>; className: string } => l.cta !== null);
25
31
 
26
32
  return (
27
33
  <section className={`${bg} py-20 md:py-28`}>
28
34
  <div className="container-editorial">
29
35
  <Reveal>
30
36
  <div className="max-w-3xl">
31
- {props.eyebrow ? (
37
+ {eyebrow ? (
32
38
  <span className={`text-[0.7rem] uppercase tracking-[0.22em] ${eyebrowTone}`}>
33
- {t(props.eyebrow)}
39
+ {eyebrow}
34
40
  </span>
35
41
  ) : null}
36
- {props.title ? (
42
+ {title ? (
37
43
  <h2 className="mt-4 font-serif text-[clamp(1.9rem,4vw,3.25rem)] leading-[1.05] tracking-[-0.02em] whitespace-pre-line">
38
- {t(props.title)}
44
+ {title}
39
45
  </h2>
40
46
  ) : null}
41
- {props.body ? (
47
+ {body ? (
42
48
  <p className={`mt-5 text-base md:text-lg leading-relaxed whitespace-pre-line ${muted}`}>
43
- {t(props.body)}
49
+ {body}
44
50
  </p>
45
51
  ) : null}
46
52
  {links.length > 0 && (
@@ -48,10 +54,10 @@ export function CtaBannerBlock({ props }: { props: CtaBannerProps }) {
48
54
  {links.map((l, i) => (
49
55
  <a
50
56
  key={i}
51
- href={l.cta?.href || "#"}
57
+ href={l.cta.href}
52
58
  className={`inline-flex items-center gap-2 px-7 py-4 text-xs uppercase tracking-[0.22em] transition-colors ${l.className}`}
53
59
  >
54
- {t(l.cta?.label)}
60
+ {l.cta.label}
55
61
  <ArrowRight className="size-4" />
56
62
  </a>
57
63
  ))}
@@ -1,5 +1,6 @@
1
1
  import { ArrowRight, ChevronDown } from "lucide-react";
2
2
  import { useT } from "../../i18n";
3
+ import { resolveCta, resolvedText } from "../optional-content";
3
4
  import { FocalImage } from "../../host";
4
5
  import { LazyVideo } from "../../host";
5
6
  import { useClaimPriority } from "../../media/priority";
@@ -41,7 +42,7 @@ const OVERLAYS: Record<NonNullable<HeroFullProps["overlay"]>, string> = {
41
42
  * overlay text. Mobile-first: separate mobile media, fluid type, safe padding.
42
43
  */
43
44
  export function HeroFullBlock({ props: p }: { props: HeroFullProps }) {
44
- const { t } = useT();
45
+ const { t, locale } = useT();
45
46
 
46
47
  const align = p.textAlign ?? "left";
47
48
  const vertical = p.verticalAlign ?? "center";
@@ -100,10 +101,22 @@ export function HeroFullBlock({ props: p }: { props: HeroFullProps }) {
100
101
  ? "border border-white/65 text-white hover:bg-white hover:text-ink"
101
102
  : "border border-ink/30 text-ink hover:bg-ink hover:text-paper";
102
103
 
104
+ // Optional text is decided on the resolved, trimmed value for this locale.
105
+ // The stored objects are almost never absent — an untouched field is
106
+ // `{ sv: "", en: "" }` — so testing the prop itself would render an empty
107
+ // heading, and testing a call to action with `label || href` would render a
108
+ // bordered button containing nothing but its arrow.
109
+ const eyebrow = resolvedText(p.eyebrow, locale);
110
+ const title = resolvedText(p.title, locale);
111
+ const body = resolvedText(p.body, locale);
112
+
113
+ // Both halves or nothing: a label with no link is not a button, and a link
114
+ // with no label is an empty target. Dropped entirely, so no border, arrow,
115
+ // anchor or gap is left where the call to action would have been.
103
116
  const ctas = [
104
- { cta: p.primaryCta, className: primaryBtn },
105
- { cta: p.secondaryCta, className: secondaryBtn },
106
- ].filter((c) => c.cta?.href || (c.cta?.label && Object.keys(c.cta.label).length));
117
+ { cta: resolveCta(p.primaryCta, locale), className: primaryBtn },
118
+ { cta: resolveCta(p.secondaryCta, locale), className: secondaryBtn },
119
+ ].filter((c): c is { cta: NonNullable<typeof c.cta>; className: string } => c.cta !== null);
107
120
 
108
121
  return (
109
122
  <section
@@ -181,27 +194,27 @@ export function HeroFullBlock({ props: p }: { props: HeroFullProps }) {
181
194
  <div className={`container-editorial flex w-full flex-col ${colClass}`}>
182
195
  <div className={`flex w-full ${rowClass}`}>
183
196
  <div className={`flex w-full flex-col ${alignClass} ${WIDTHS[p.contentWidth ?? "normal"]} ${textColor}`}>
184
- {p.eyebrow ? (
197
+ {eyebrow ? (
185
198
  <span
186
199
  style={legibility}
187
200
  className={`text-[0.65rem] uppercase tracking-[0.3em] md:text-[0.72rem] ${eyebrowColor}`}
188
201
  >
189
- {t(p.eyebrow)}
202
+ {eyebrow}
190
203
  </span>
191
204
  ) : null}
192
- {p.title ? (
205
+ {title ? (
193
206
  <h1
194
207
  style={legibility}
195
208
  className={`mt-4 ${TITLE_SIZES[p.titleSize ?? "lg"]} leading-[1.02] tracking-[-0.02em] ${
196
209
  (p.titleFont ?? "serif") === "serif" ? "font-serif" : "font-sans font-semibold"
197
210
  } ${p.titleUppercase ? "uppercase tracking-[0.04em]" : ""} whitespace-pre-line`}
198
211
  >
199
- {t(p.title)}
212
+ {title}
200
213
  </h1>
201
214
  ) : null}
202
- {p.body ? (
215
+ {body ? (
203
216
  <p style={legibility} className={`mt-5 whitespace-pre-line text-base leading-relaxed md:text-lg ${mutedColor} ${WIDTHS[p.contentWidth ?? "normal"] === "max-w-none" ? "" : ""}`}>
204
- {t(p.body)}
217
+ {body}
205
218
  </p>
206
219
  ) : null}
207
220
  {ctas.length > 0 && (
@@ -209,10 +222,10 @@ export function HeroFullBlock({ props: p }: { props: HeroFullProps }) {
209
222
  {ctas.map((c, i) => (
210
223
  <a
211
224
  key={i}
212
- href={c.cta?.href || "#"}
225
+ href={c.cta.href}
213
226
  className={`inline-flex items-center gap-2 px-6 py-3.5 text-[0.7rem] uppercase tracking-[0.22em] transition-colors md:px-8 md:py-4 md:text-xs ${c.className}`}
214
227
  >
215
- {t(c.cta?.label)}
228
+ {c.cta.label}
216
229
  <ArrowRight className="size-4" />
217
230
  </a>
218
231
  ))}
@@ -1,8 +1,15 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { flushSync } from "react-dom";
3
- import { ArrowLeft, ArrowRight, Check, Image as ImageIcon, Play } from "lucide-react";
3
+ import {
4
+ ArrowLeft,
5
+ ArrowRight,
6
+ Check,
7
+ Image as ImageIcon,
8
+ Play,
9
+ } from "lucide-react";
4
10
 
5
11
  import { useT } from "../../i18n";
12
+ import { hasText, resolveCta, resolvedText } from "../optional-content";
6
13
  import { Reveal } from "../../ui/Reveal";
7
14
  import { Lightbox } from "../../ui/Lightbox";
8
15
  import { FocalImage } from "../../host";
@@ -10,7 +17,6 @@ import { LazyVideo } from "../../host";
10
17
  import { useClaimPriority } from "../../media/priority";
11
18
  import type { MediaSplitProps, MediaSplitSlide } from "../types";
12
19
 
13
-
14
20
  const PADDING: Record<NonNullable<MediaSplitProps["padding"]>, string> = {
15
21
  compact: "py-12 md:py-16",
16
22
  normal: "py-20 md:py-28",
@@ -48,7 +54,7 @@ function Slideshow({
48
54
  aspectClass: string;
49
55
  radius: string;
50
56
  }) {
51
- const { t } = useT();
57
+ const { t, locale } = useT();
52
58
  // The first slide is the only slide visible on load, so it is this block's
53
59
  // priority candidate — but only if no earlier block already claimed it.
54
60
  const slidePriority = useClaimPriority(slides[0]?.image ?? null);
@@ -126,8 +132,11 @@ function Slideshow({
126
132
  >
127
133
  {slides.map((s, i) => {
128
134
  const alt = s.alt ? t(s.alt as Parameters<typeof t>[0]) : "";
135
+ const caption = resolvedText(s.caption, locale);
129
136
  const media = (
130
- <div className={`relative w-full ${props.mediaBg === "transparent" ? "" : "bg-ink/5"} overflow-hidden ${aspectClass}`}>
137
+ <div
138
+ className={`relative w-full ${props.mediaBg === "transparent" ? "" : "bg-ink/5"} overflow-hidden ${aspectClass}`}
139
+ >
131
140
  {s.image ? (
132
141
  <FocalImage
133
142
  src={s.image}
@@ -146,14 +155,21 @@ function Slideshow({
146
155
  return (
147
156
  <div key={s.id ?? i} className="w-full shrink-0 snap-center">
148
157
  {s.href ? (
149
- <a href={s.href} target="_blank" rel="noreferrer noopener" className="block">
158
+ <a
159
+ href={s.href}
160
+ target="_blank"
161
+ rel="noreferrer noopener"
162
+ className="block"
163
+ >
150
164
  {media}
151
165
  </a>
152
166
  ) : (
153
167
  media
154
168
  )}
155
- {props.showCaptions && s.caption ? (
156
- <p className="mt-3 text-sm text-ink/65 whitespace-pre-line">{t(s.caption as Parameters<typeof t>[0])}</p>
169
+ {props.showCaptions && caption ? (
170
+ <p className="mt-3 text-sm text-ink/65 whitespace-pre-line">
171
+ {caption}
172
+ </p>
157
173
  ) : null}
158
174
  </div>
159
175
  );
@@ -209,9 +225,11 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
209
225
  // Claimed before rendering the media so a media-split placed first on a page
210
226
  // gets the priority image when there is no hero above it.
211
227
  const imagePriority = useClaimPriority(
212
- props.mediaType === "slideshow" || props.mediaType === "video" ? null : props.image ?? null,
228
+ props.mediaType === "slideshow" || props.mediaType === "video"
229
+ ? null
230
+ : (props.image ?? null),
213
231
  );
214
- const { t } = useT();
232
+ const { t, locale } = useT();
215
233
  const side = props.mediaSide ?? "left";
216
234
  const tone = props.tone ?? "white";
217
235
  const dark = tone === "ink";
@@ -228,9 +246,14 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
228
246
  const radius = props.rounded ? "rounded-2xl overflow-hidden" : "";
229
247
  const aspectClass = ASPECT[props.aspect ?? "landscape"];
230
248
  const mediaBgClass = props.mediaBg === "transparent" ? "" : "bg-ink/5";
231
- const fitClass = props.mediaFit === "contain" ? "object-contain" : "object-cover";
232
- const items = (props.slides ?? []).filter((s) => s.image || s.caption);
233
- const bullets = props.bullets ?? [];
249
+ const fitClass =
250
+ props.mediaFit === "contain" ? "object-contain" : "object-cover";
251
+ const items = (props.slides ?? []).filter(
252
+ (s) =>
253
+ s.image || (props.showCaptions === true && hasText(s.caption, locale)),
254
+ );
255
+ // A bullet with no text in this locale would render as a lone check mark.
256
+ const bullets = (props.bullets ?? []).filter((b) => hasText(b.text, locale));
234
257
  const [videoOpen, setVideoOpen] = useState(false);
235
258
  const lightboxVideoRef = useRef<HTMLVideoElement>(null);
236
259
 
@@ -249,8 +272,6 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
249
272
  });
250
273
  };
251
274
 
252
-
253
-
254
275
  const primaryBtn = dark
255
276
  ? "bg-paper text-ink hover:bg-sea"
256
277
  : "bg-ink text-paper hover:bg-ink/85";
@@ -262,7 +283,9 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
262
283
  if (props.mediaType === "video") {
263
284
  if (!props.videoSrc && !props.posterSrc) return null;
264
285
  return (
265
- <div className={`relative w-full ${mediaBgClass} overflow-hidden ${aspectClass} ${radius}`}>
286
+ <div
287
+ className={`relative w-full ${mediaBgClass} overflow-hidden ${aspectClass} ${radius}`}
288
+ >
266
289
  {props.videoSrc ? (
267
290
  <>
268
291
  <LazyVideo
@@ -284,7 +307,6 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
284
307
  <span className="grid size-14 sm:size-16 place-items-center rounded-full bg-paper/85 text-ink shadow-lg transition-transform group-hover:scale-110">
285
308
  <Play className="size-5 sm:size-6 translate-x-[2px]" />
286
309
  </span>
287
-
288
310
  </button>
289
311
  </>
290
312
  ) : (
@@ -300,15 +322,26 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
300
322
 
301
323
  if (props.mediaType === "slideshow") {
302
324
  if (items.length === 0) return null;
303
- return <Slideshow slides={items} props={props} aspectClass={aspectClass} radius={radius} />;
325
+ return (
326
+ <Slideshow
327
+ slides={items}
328
+ props={props}
329
+ aspectClass={aspectClass}
330
+ radius={radius}
331
+ />
332
+ );
304
333
  }
305
334
  if (!props.image) return null;
306
335
  return (
307
- <div className={`relative w-full ${mediaBgClass} overflow-hidden ${aspectClass} ${radius}`}>
336
+ <div
337
+ className={`relative w-full ${mediaBgClass} overflow-hidden ${aspectClass} ${radius}`}
338
+ >
308
339
  <FocalImage
309
340
  src={props.image}
310
341
  sizes="(min-width: 768px) 50vw, 100vw"
311
- alt={props.imageAlt ? t(props.imageAlt as Parameters<typeof t>[0]) : ""}
342
+ alt={
343
+ props.imageAlt ? t(props.imageAlt as Parameters<typeof t>[0]) : ""
344
+ }
312
345
  priority={imagePriority}
313
346
  className={`absolute inset-0 h-full w-full ${fitClass}`}
314
347
  />
@@ -316,11 +349,20 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
316
349
  );
317
350
  })();
318
351
 
319
- const hasHead = Boolean(props.eyebrow || props.title);
352
+ const eyebrow = resolvedText(props.eyebrow, locale);
353
+ const title = resolvedText(props.title, locale);
354
+ const lead = resolvedText(props.lead, locale);
355
+ const body = resolvedText(props.body, locale);
356
+ const hasHead = Boolean(eyebrow || title);
357
+
358
+ // Both a visible label and a usable link, or the whole element goes.
320
359
  const links = [
321
- { cta: props.cta, className: primaryBtn },
322
- { cta: props.secondaryCta, className: secondaryBtn },
323
- ].filter((l) => l.cta?.href || l.cta?.label);
360
+ { cta: resolveCta(props.cta, locale), className: primaryBtn },
361
+ { cta: resolveCta(props.secondaryCta, locale), className: secondaryBtn },
362
+ ].filter(
363
+ (l): l is { cta: NonNullable<typeof l.cta>; className: string } =>
364
+ l.cta !== null,
365
+ );
324
366
 
325
367
  const inner = (
326
368
  <div
@@ -337,36 +379,42 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
337
379
  >
338
380
  {hasHead ? (
339
381
  <div className="order-1">
340
- {props.eyebrow ? (
341
- <span className={`text-[0.7rem] uppercase tracking-[0.22em] ${eyebrowTone}`}>
342
- {t(props.eyebrow as Parameters<typeof t>[0])}
382
+ {eyebrow ? (
383
+ <span
384
+ className={`text-[0.7rem] uppercase tracking-[0.22em] ${eyebrowTone}`}
385
+ >
386
+ {eyebrow}
343
387
  </span>
344
388
  ) : null}
345
- {props.title ? (
389
+ {title ? (
346
390
  <h2 className="mt-4 font-serif text-[clamp(1.9rem,4vw,3.25rem)] leading-[1.05] tracking-[-0.02em] whitespace-pre-line">
347
- {t(props.title as Parameters<typeof t>[0])}
391
+ {title}
348
392
  </h2>
349
393
  ) : null}
350
394
  </div>
351
395
  ) : null}
352
396
 
353
397
  <div className="order-3 space-y-6">
354
- {props.lead ? (
355
- <p className={`text-lg md:text-xl leading-relaxed whitespace-pre-line ${muted}`}>
356
- {t(props.lead as Parameters<typeof t>[0])}
398
+ {lead ? (
399
+ <p
400
+ className={`text-lg md:text-xl leading-relaxed whitespace-pre-line ${muted}`}
401
+ >
402
+ {lead}
357
403
  </p>
358
404
  ) : null}
359
- {props.body ? (
405
+ {body ? (
360
406
  <p className={`leading-relaxed whitespace-pre-line ${muted}`}>
361
- {t(props.body as Parameters<typeof t>[0])}
407
+ {body}
362
408
  </p>
363
409
  ) : null}
364
410
  {bullets.length > 0 ? (
365
411
  <ul className="space-y-3">
366
412
  {bullets.map((b, i) => (
367
413
  <li key={b.id ?? i} className="flex gap-3">
368
- <Check className={`mt-0.5 size-4 shrink-0 ${dark ? "text-sea" : "text-sea"}`} />
369
- <span className={muted}>{t(b.text as Parameters<typeof t>[0])}</span>
414
+ <Check
415
+ className={`mt-0.5 size-4 shrink-0 ${dark ? "text-sea" : "text-sea"}`}
416
+ />
417
+ <span className={muted}>{resolvedText(b.text, locale)}</span>
370
418
  </li>
371
419
  ))}
372
420
  </ul>
@@ -376,10 +424,10 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
376
424
  {links.map((l, i) => (
377
425
  <a
378
426
  key={i}
379
- href={l.cta?.href || "#"}
427
+ href={l.cta.href}
380
428
  className={`inline-flex items-center gap-2 px-6 py-3 text-xs uppercase tracking-[0.22em] transition-colors ${l.className}`}
381
429
  >
382
- {l.cta?.label ? t(l.cta.label as Parameters<typeof t>[0]) : ""}
430
+ {l.cta.label}
383
431
  <ArrowRight className="size-4" />
384
432
  </a>
385
433
  ))}
@@ -389,7 +437,11 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
389
437
  </div>
390
438
 
391
439
  {media ? (
392
- <div className={`order-2 ${side === "left" ? "md:order-1" : "md:order-2"}`}>{media}</div>
440
+ <div
441
+ className={`order-2 ${side === "left" ? "md:order-1" : "md:order-2"}`}
442
+ >
443
+ {media}
444
+ </div>
393
445
  ) : null}
394
446
  </div>
395
447
  );
@@ -407,7 +459,7 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
407
459
  <Lightbox
408
460
  open={videoOpen}
409
461
  onClose={() => setVideoOpen(false)}
410
- label={props.title ? t(props.title as Parameters<typeof t>[0]) : "Video"}
462
+ label={title || "Video"}
411
463
  contentClassName="w-full max-w-5xl"
412
464
  >
413
465
  {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
@@ -428,10 +480,8 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
428
480
  onClick={(e) => e.stopPropagation()}
429
481
  className="h-auto max-h-[85vh] w-full bg-ink object-contain"
430
482
  />
431
-
432
483
  </Lightbox>
433
484
  ) : null}
434
485
  </section>
435
486
  );
436
487
  }
437
-