@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.
- package/CHANGELOG.md +489 -0
- package/README.md +16 -2
- package/docs/EDIT-LOCKS.md +213 -0
- package/docs/POPUPS.md +102 -0
- package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
- package/package.json +31 -3
- package/scripts/create-cms-site.mjs +5 -4
- package/scripts/sync-migrations.mjs +27 -6
- package/src/backoffice/ContactDetailsView.tsx +19 -5
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/EditLock.tsx +513 -0
- package/src/backoffice/EmailSettingsView.tsx +99 -0
- package/src/backoffice/FormEditorView.tsx +531 -512
- package/src/backoffice/FormSettingsView.tsx +16 -4
- package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
- package/src/backoffice/NavigationView.tsx +35 -11
- package/src/backoffice/PageBuilderView.tsx +51 -16
- package/src/backoffice/RichTextEditor.tsx +50 -14
- package/src/backoffice/SectionEditorView.tsx +18 -6
- package/src/backoffice/SectionFormView.tsx +184 -31
- package/src/backoffice/SiteSettingsView.tsx +14 -15
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/forms/VersionHistory.tsx +4 -2
- package/src/backoffice/inspectors/blocks.tsx +22 -1
- package/src/backoffice/inspectors/fields.tsx +21 -0
- package/src/backoffice/routes/host.ts +58 -7
- package/src/backoffice/routes/index.tsx +117 -15
- package/src/backoffice/routes/pages.tsx +10 -6
- package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/nav-model.ts +32 -23
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/components/CtaBannerBlock.tsx +18 -12
- package/src/blocks/components/HeroFullBlock.tsx +25 -12
- package/src/blocks/components/MediaSplitBlock.tsx +91 -41
- package/src/blocks/components/PriceTableBlock.tsx +123 -89
- package/src/blocks/components/RichTextBlock.tsx +26 -4
- package/src/blocks/index.ts +8 -0
- package/src/blocks/optional-content.ts +82 -0
- package/src/blocks/registry.tsx +1 -0
- package/src/blocks/types.ts +6 -0
- package/src/cms/edit-lock.ts +491 -0
- package/src/config/modules.ts +208 -0
- package/src/config/site-config.ts +9 -31
- package/src/forms/FormRenderer.tsx +62 -3
- package/src/forms/submission-result.ts +35 -0
- package/src/forms/success-listeners.ts +46 -0
- package/src/host/index.tsx +27 -1
- package/src/modules/index.ts +28 -0
- package/src/modules/public-runtime.tsx +159 -0
- package/src/navigation/index.ts +29 -0
- package/src/popups/admin.tsx +123 -0
- package/src/popups/cms-bridges.ts +240 -0
- package/src/popups/destinations.tsx +147 -0
- package/src/popups/host.ts +91 -0
- package/src/popups/media-bridge.tsx +84 -0
- package/src/popups/register.ts +23 -0
- package/src/popups/runtime.tsx +128 -0
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +22 -5
- package/src/server/domains-core.ts +124 -107
- package/src/server/edit-lock-adapter.ts +91 -0
- package/src/server/edit-lock-supabase-adapter.ts +135 -0
- package/src/server/edit-locks-core.ts +286 -0
- package/src/server/forms-core.ts +37 -2
- package/src/server/injections-core.ts +230 -49
- package/src/server/page-seo-save-core.ts +27 -0
- package/src/server/pages-core.ts +108 -11
- package/src/server/popup-optin-trigger.ts +111 -0
- package/src/server/popup-optin-webhook-core.ts +363 -0
- package/src/server/provider-secrets.ts +100 -4
- package/src/server/schemas.ts +74 -9
- package/src/server/webhook-url-guard.ts +199 -0
- package/src/site/SiteHeader.tsx +11 -5
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/tracking/injections.ts +52 -9
- package/src/types/css-modules.d.ts +6 -0
- 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 {
|
|
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)
|
|
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
|
-
{
|
|
37
|
+
{eyebrow ? (
|
|
32
38
|
<span className={`text-[0.7rem] uppercase tracking-[0.22em] ${eyebrowTone}`}>
|
|
33
|
-
{
|
|
39
|
+
{eyebrow}
|
|
34
40
|
</span>
|
|
35
41
|
) : null}
|
|
36
|
-
{
|
|
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
|
-
{
|
|
44
|
+
{title}
|
|
39
45
|
</h2>
|
|
40
46
|
) : null}
|
|
41
|
-
{
|
|
47
|
+
{body ? (
|
|
42
48
|
<p className={`mt-5 text-base md:text-lg leading-relaxed whitespace-pre-line ${muted}`}>
|
|
43
|
-
{
|
|
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
|
|
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
|
-
{
|
|
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)
|
|
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
|
-
{
|
|
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
|
-
{
|
|
202
|
+
{eyebrow}
|
|
190
203
|
</span>
|
|
191
204
|
) : null}
|
|
192
|
-
{
|
|
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
|
-
{
|
|
212
|
+
{title}
|
|
200
213
|
</h1>
|
|
201
214
|
) : null}
|
|
202
|
-
{
|
|
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
|
-
{
|
|
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
|
|
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
|
-
{
|
|
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 {
|
|
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
|
|
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
|
|
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 &&
|
|
156
|
-
<p className="mt-3 text-sm text-ink/65 whitespace-pre-line">
|
|
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"
|
|
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 =
|
|
232
|
-
|
|
233
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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={
|
|
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
|
|
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(
|
|
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
|
-
{
|
|
341
|
-
<span
|
|
342
|
-
{
|
|
382
|
+
{eyebrow ? (
|
|
383
|
+
<span
|
|
384
|
+
className={`text-[0.7rem] uppercase tracking-[0.22em] ${eyebrowTone}`}
|
|
385
|
+
>
|
|
386
|
+
{eyebrow}
|
|
343
387
|
</span>
|
|
344
388
|
) : null}
|
|
345
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
355
|
-
<p
|
|
356
|
-
{
|
|
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
|
-
{
|
|
405
|
+
{body ? (
|
|
360
406
|
<p className={`leading-relaxed whitespace-pre-line ${muted}`}>
|
|
361
|
-
{
|
|
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
|
|
369
|
-
|
|
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
|
|
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
|
|
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
|
|
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={
|
|
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
|
-
|