@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useT } from "../../i18n";
|
|
2
|
+
import { resolveCta, resolvedText } from "../optional-content";
|
|
2
3
|
import { BUTTON_COLOR_CLASS, buttonColorStyle } from "../button-colors";
|
|
3
4
|
import { Reveal } from "../../ui/Reveal";
|
|
4
5
|
import type { PriceTableProps } from "../types";
|
|
@@ -15,51 +16,65 @@ const TONE: Record<string, string> = {
|
|
|
15
16
|
* Replaces the old price image so prices stay editable and readable.
|
|
16
17
|
*/
|
|
17
18
|
export function PriceTableBlock({ props }: { props: PriceTableProps }) {
|
|
18
|
-
const { t } = useT();
|
|
19
|
+
const { t, locale } = useT();
|
|
20
|
+
const eyebrow = resolvedText(props.eyebrow, locale);
|
|
21
|
+
const title = resolvedText(props.title, locale);
|
|
22
|
+
const lead = resolvedText(props.lead, locale);
|
|
23
|
+
const note = resolvedText(props.note, locale);
|
|
24
|
+
const unitLabel = resolvedText(props.unitLabel, locale);
|
|
25
|
+
// An empty label or link renders nothing rather than a bare button.
|
|
26
|
+
const cta = resolveCta(
|
|
27
|
+
{ label: props.ctaLabel, href: props.ctaHref },
|
|
28
|
+
locale,
|
|
29
|
+
);
|
|
19
30
|
const groups = props.groups ?? [];
|
|
20
31
|
const dark = (props.tone ?? "paper") === "ink";
|
|
21
32
|
const tone = TONE[props.tone ?? "paper"] ?? "bg-paper";
|
|
22
|
-
const cols =
|
|
33
|
+
const cols =
|
|
34
|
+
(props.columns ?? 3) === 2
|
|
35
|
+
? "sm:grid-cols-2"
|
|
36
|
+
: "sm:grid-cols-2 lg:grid-cols-3";
|
|
23
37
|
// Centered by default — applies on every breakpoint.
|
|
24
38
|
const centered = (props.align ?? "center") === "center";
|
|
25
39
|
const headBox = centered ? "mx-auto max-w-2xl text-center" : "max-w-2xl";
|
|
26
|
-
const footBox = centered
|
|
27
|
-
? "justify-center text-center"
|
|
28
|
-
: "";
|
|
40
|
+
const footBox = centered ? "justify-center text-center" : "";
|
|
29
41
|
|
|
30
42
|
if (groups.length === 0) return null;
|
|
31
43
|
|
|
32
44
|
return (
|
|
33
|
-
<section
|
|
45
|
+
<section
|
|
46
|
+
id={props.anchor || undefined}
|
|
47
|
+
className={`${tone} py-16 md:py-24`}
|
|
48
|
+
>
|
|
34
49
|
<div className="container-editorial">
|
|
35
|
-
{
|
|
50
|
+
{eyebrow || title || lead ? (
|
|
36
51
|
<Reveal>
|
|
37
52
|
<div className={headBox}>
|
|
38
|
-
{
|
|
53
|
+
{eyebrow ? (
|
|
39
54
|
<span
|
|
40
55
|
className={`text-[0.7rem] uppercase tracking-[0.22em] ${
|
|
41
56
|
dark ? "text-paper/70" : "text-ink/60"
|
|
42
57
|
}`}
|
|
43
58
|
>
|
|
44
|
-
{
|
|
59
|
+
{eyebrow}
|
|
45
60
|
</span>
|
|
46
61
|
) : null}
|
|
47
|
-
{
|
|
62
|
+
{title ? (
|
|
48
63
|
<h2
|
|
49
64
|
className={`mt-3 font-serif text-[clamp(1.8rem,3.6vw,2.9rem)] leading-[1.06] ${
|
|
50
65
|
dark ? "text-paper" : "text-ink"
|
|
51
66
|
}`}
|
|
52
67
|
>
|
|
53
|
-
{
|
|
68
|
+
{title}
|
|
54
69
|
</h2>
|
|
55
70
|
) : null}
|
|
56
|
-
{
|
|
71
|
+
{lead ? (
|
|
57
72
|
<p
|
|
58
73
|
className={`mt-4 text-base leading-relaxed whitespace-pre-line ${
|
|
59
74
|
dark ? "text-paper/80" : "text-ink/70"
|
|
60
75
|
}`}
|
|
61
76
|
>
|
|
62
|
-
{
|
|
77
|
+
{lead}
|
|
63
78
|
</p>
|
|
64
79
|
) : null}
|
|
65
80
|
</div>
|
|
@@ -68,100 +83,119 @@ export function PriceTableBlock({ props }: { props: PriceTableProps }) {
|
|
|
68
83
|
|
|
69
84
|
<Reveal>
|
|
70
85
|
<div className={`mt-10 grid grid-cols-1 ${cols} gap-5`}>
|
|
71
|
-
{groups.map((g, gi) =>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
className={`flex h-full flex-col border ${
|
|
75
|
-
g.highlight
|
|
76
|
-
? "border-sea/60 bg-white shadow-[0_18px_40px_-30px_rgb(0_0_0/0.45)]"
|
|
77
|
-
: dark
|
|
78
|
-
? "border-paper/15 bg-paper/5"
|
|
79
|
-
: "border-ink/10 bg-white"
|
|
80
|
-
}`}
|
|
81
|
-
>
|
|
86
|
+
{groups.map((g, gi) => {
|
|
87
|
+
const caption = resolvedText(g.caption, locale);
|
|
88
|
+
return (
|
|
82
89
|
<div
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
key={g.id ?? gi}
|
|
91
|
+
className={`flex h-full flex-col border ${
|
|
92
|
+
g.highlight
|
|
93
|
+
? "border-sea/60 bg-white shadow-[0_18px_40px_-30px_rgb(0_0_0/0.45)]"
|
|
94
|
+
: dark
|
|
95
|
+
? "border-paper/15 bg-paper/5"
|
|
96
|
+
: "border-ink/10 bg-white"
|
|
85
97
|
}`}
|
|
86
98
|
>
|
|
87
|
-
<
|
|
88
|
-
className={`
|
|
89
|
-
g.highlight
|
|
99
|
+
<div
|
|
100
|
+
className={`flex items-baseline justify-between gap-3 px-5 py-4 ${
|
|
101
|
+
g.highlight
|
|
102
|
+
? "bg-sea text-white"
|
|
103
|
+
: dark
|
|
104
|
+
? "bg-paper/10"
|
|
105
|
+
: "bg-ink/[0.04]"
|
|
90
106
|
}`}
|
|
91
107
|
>
|
|
92
|
-
{t(g.label)}
|
|
93
|
-
</span>
|
|
94
|
-
{props.unitLabel ? (
|
|
95
108
|
<span
|
|
96
|
-
className={`text-
|
|
97
|
-
g.highlight
|
|
109
|
+
className={`font-serif text-lg md:text-xl ${
|
|
110
|
+
g.highlight
|
|
111
|
+
? "text-white"
|
|
112
|
+
: dark
|
|
113
|
+
? "text-paper"
|
|
114
|
+
: "text-ink"
|
|
98
115
|
}`}
|
|
99
116
|
>
|
|
100
|
-
{t(
|
|
117
|
+
{t(g.label)}
|
|
101
118
|
</span>
|
|
102
|
-
|
|
103
|
-
</div>
|
|
104
|
-
|
|
105
|
-
<ul className={`flex-1 divide-y ${dark ? "divide-paper/12" : "divide-ink/8"}`}>
|
|
106
|
-
{(g.rows ?? []).filter((r) => !r.hidden).map((r, ri) => (
|
|
107
|
-
<li
|
|
108
|
-
key={r.id ?? ri}
|
|
109
|
-
className="flex items-baseline justify-between gap-4 px-5 py-3.5"
|
|
110
|
-
>
|
|
111
|
-
<span
|
|
112
|
-
className={`text-[0.7rem] uppercase tracking-[0.18em] ${
|
|
113
|
-
dark ? "text-paper/65" : "text-ink/55"
|
|
114
|
-
}`}
|
|
115
|
-
>
|
|
116
|
-
{t(r.label)}
|
|
117
|
-
</span>
|
|
119
|
+
{unitLabel ? (
|
|
118
120
|
<span
|
|
119
|
-
className={`
|
|
120
|
-
|
|
121
|
+
className={`text-[0.6rem] uppercase tracking-[0.2em] ${
|
|
122
|
+
g.highlight
|
|
123
|
+
? "text-white/80"
|
|
124
|
+
: dark
|
|
125
|
+
? "text-paper/60"
|
|
126
|
+
: "text-ink/50"
|
|
121
127
|
}`}
|
|
122
128
|
>
|
|
123
|
-
{
|
|
129
|
+
{unitLabel}
|
|
124
130
|
</span>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
</ul>
|
|
131
|
+
) : null}
|
|
132
|
+
</div>
|
|
128
133
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
className={`px-5 pb-5 pt-3 text-xs leading-relaxed ${
|
|
132
|
-
dark ? "text-paper/60" : "text-ink/55"
|
|
133
|
-
}`}
|
|
134
|
+
<ul
|
|
135
|
+
className={`flex-1 divide-y ${dark ? "divide-paper/12" : "divide-ink/8"}`}
|
|
134
136
|
>
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
{(g.rows ?? [])
|
|
138
|
+
.filter((r) => !r.hidden)
|
|
139
|
+
.map((r, ri) => (
|
|
140
|
+
<li
|
|
141
|
+
key={r.id ?? ri}
|
|
142
|
+
className="flex items-baseline justify-between gap-4 px-5 py-3.5"
|
|
143
|
+
>
|
|
144
|
+
<span
|
|
145
|
+
className={`text-[0.7rem] uppercase tracking-[0.18em] ${
|
|
146
|
+
dark ? "text-paper/65" : "text-ink/55"
|
|
147
|
+
}`}
|
|
148
|
+
>
|
|
149
|
+
{t(r.label)}
|
|
150
|
+
</span>
|
|
151
|
+
<span
|
|
152
|
+
className={`font-serif text-xl tabular-nums ${
|
|
153
|
+
dark ? "text-paper" : "text-ink"
|
|
154
|
+
}`}
|
|
155
|
+
>
|
|
156
|
+
{t(r.price)}
|
|
157
|
+
</span>
|
|
158
|
+
</li>
|
|
159
|
+
))}
|
|
160
|
+
</ul>
|
|
161
|
+
|
|
162
|
+
{caption ? (
|
|
163
|
+
<p
|
|
164
|
+
className={`px-5 pb-5 pt-3 text-xs leading-relaxed ${
|
|
165
|
+
dark ? "text-paper/60" : "text-ink/55"
|
|
166
|
+
}`}
|
|
167
|
+
>
|
|
168
|
+
{caption}
|
|
169
|
+
</p>
|
|
170
|
+
) : null}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
140
174
|
</div>
|
|
141
175
|
</Reveal>
|
|
142
176
|
|
|
143
|
-
|
|
144
|
-
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
dark ? "text-paper/70" : "text-ink/65"
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
177
|
+
{cta || note ? (
|
|
178
|
+
<div className={`mt-8 flex flex-wrap items-center gap-5 ${footBox}`}>
|
|
179
|
+
{cta ? (
|
|
180
|
+
<a
|
|
181
|
+
href={cta.href}
|
|
182
|
+
style={buttonColorStyle(props.ctaColors)}
|
|
183
|
+
className={`inline-flex items-center px-6 py-3 text-[0.7rem] uppercase tracking-[0.22em] ${BUTTON_COLOR_CLASS}`}
|
|
184
|
+
>
|
|
185
|
+
{cta.label}
|
|
186
|
+
</a>
|
|
187
|
+
) : null}
|
|
188
|
+
{note ? (
|
|
189
|
+
<p
|
|
190
|
+
className={`max-w-2xl text-sm leading-relaxed whitespace-pre-line ${
|
|
191
|
+
centered ? "mx-auto" : ""
|
|
192
|
+
} ${dark ? "text-paper/70" : "text-ink/65"}`}
|
|
193
|
+
>
|
|
194
|
+
{note}
|
|
195
|
+
</p>
|
|
196
|
+
) : null}
|
|
197
|
+
</div>
|
|
198
|
+
) : null}
|
|
165
199
|
</div>
|
|
166
200
|
</section>
|
|
167
201
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useT } from "../../i18n";
|
|
2
2
|
import type { LocalizedString } from "../../i18n";
|
|
3
|
-
import { Reveal } from "../../ui/Reveal";
|
|
3
|
+
import { Reveal, normalizeRevealMode } from "../../ui/Reveal";
|
|
4
4
|
import type { RichTextProps } from "../types";
|
|
5
5
|
import { sanitizeHtml } from "../../html/sanitize-html";
|
|
6
6
|
|
|
@@ -25,14 +25,35 @@ const PADDING: Record<string, string> = {
|
|
|
25
25
|
spacious: "py-28 md:py-40",
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Keeps authored markup inside the column, whatever gets pasted in.
|
|
30
|
+
*
|
|
31
|
+
* The host site owns `rte-content` and its typography; these are the few rules
|
|
32
|
+
* that are not a matter of taste. A pasted URL with no spaces in it, a wide
|
|
33
|
+
* table or an oversized image would otherwise push the whole page sideways —
|
|
34
|
+
* on a phone, every section of it, not just this one.
|
|
35
|
+
*/
|
|
36
|
+
const CONTAINMENT = [
|
|
37
|
+
"break-words",
|
|
38
|
+
"[&_img]:max-w-full [&_img]:h-auto",
|
|
39
|
+
"[&_pre]:max-w-full [&_pre]:overflow-x-auto",
|
|
40
|
+
// A wide table scrolls on its own rather than widening the page.
|
|
41
|
+
"[&_table]:block [&_table]:max-w-full [&_table]:overflow-x-auto",
|
|
42
|
+
].join(" ");
|
|
43
|
+
|
|
28
44
|
/**
|
|
29
45
|
* Free-form rich text authored in the backoffice WYSIWYG editor.
|
|
30
|
-
*
|
|
46
|
+
*
|
|
47
|
+
* The stored HTML is sanitized on every render (never trusted as-is), and
|
|
48
|
+
* there is no limit on how much of it there can be — long documents are
|
|
49
|
+
* expected. The Page Builder preview renders through this same component, so
|
|
50
|
+
* what an editor sees is what gets published.
|
|
31
51
|
*/
|
|
32
52
|
export function RichTextBlock({ props }: { props: RichTextProps }) {
|
|
33
53
|
const { t } = useT();
|
|
34
54
|
const tone = props.tone ?? "white";
|
|
35
55
|
const html = sanitizeHtml(t(props.html as LocalizedString));
|
|
56
|
+
const reveal = normalizeRevealMode(props.reveal);
|
|
36
57
|
|
|
37
58
|
return (
|
|
38
59
|
<section
|
|
@@ -47,7 +68,7 @@ export function RichTextBlock({ props }: { props: RichTextProps }) {
|
|
|
47
68
|
].join(" ")}
|
|
48
69
|
>
|
|
49
70
|
{props.eyebrow || props.title ? (
|
|
50
|
-
<Reveal>
|
|
71
|
+
<Reveal mode={reveal}>
|
|
51
72
|
{props.eyebrow ? (
|
|
52
73
|
<span className="text-[0.7rem] uppercase tracking-[0.22em] opacity-70">
|
|
53
74
|
{t(props.eyebrow as LocalizedString)}
|
|
@@ -61,10 +82,11 @@ export function RichTextBlock({ props }: { props: RichTextProps }) {
|
|
|
61
82
|
</Reveal>
|
|
62
83
|
) : null}
|
|
63
84
|
|
|
64
|
-
<Reveal>
|
|
85
|
+
<Reveal mode={reveal}>
|
|
65
86
|
<div
|
|
66
87
|
className={[
|
|
67
88
|
"rte-content",
|
|
89
|
+
CONTAINMENT,
|
|
68
90
|
tone === "ink" ? "rte-invert" : "",
|
|
69
91
|
props.columns === 2 ? "md:columns-2 md:gap-12" : "",
|
|
70
92
|
props.dropCap ? "rte-dropcap" : "",
|
package/src/blocks/index.ts
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which optional block content is real enough to render.
|
|
3
|
+
*
|
|
4
|
+
* CMS fields are rarely *absent*. They are present and empty: a localized
|
|
5
|
+
* string nobody filled in is `{ sv: "", en: "" }`, which is a perfectly truthy
|
|
6
|
+
* object. So `props.title && <h1>{t(props.title)}</h1>` renders an empty
|
|
7
|
+
* heading, and a call to action guarded with `label || href` renders a button
|
|
8
|
+
* containing nothing but its arrow — a bordered rectangle the visitor can click
|
|
9
|
+
* to go nowhere.
|
|
10
|
+
*
|
|
11
|
+
* Every optional element therefore has to be decided on the *resolved, trimmed*
|
|
12
|
+
* value for the locale actually being rendered, not on the shape of the stored
|
|
13
|
+
* object. These helpers are that decision, in one place, so block components
|
|
14
|
+
* and the Page Builder preview cannot disagree about it.
|
|
15
|
+
*/
|
|
16
|
+
import { t, type Locale, type LocalizedString } from "../i18n";
|
|
17
|
+
|
|
18
|
+
/** Resolved, trimmed text for a locale. `""` when there is nothing to show. */
|
|
19
|
+
export function resolvedText(
|
|
20
|
+
value: Partial<LocalizedString> | string | undefined | null,
|
|
21
|
+
locale: Locale,
|
|
22
|
+
): string {
|
|
23
|
+
return t(value as LocalizedString | string | undefined | null, locale).trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True when a localized field has visible text in this locale.
|
|
28
|
+
*
|
|
29
|
+
* Locale is part of the question: the resolver falls back to the default locale
|
|
30
|
+
* and the generated translation map, so a field filled in only in the source
|
|
31
|
+
* language still has text in every locale — while one filled in nowhere has
|
|
32
|
+
* text in none.
|
|
33
|
+
*/
|
|
34
|
+
export function hasText(
|
|
35
|
+
value: Partial<LocalizedString> | string | undefined | null,
|
|
36
|
+
locale: Locale,
|
|
37
|
+
): boolean {
|
|
38
|
+
return resolvedText(value, locale) !== "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True when a localized field is filled in for *any* locale.
|
|
43
|
+
*
|
|
44
|
+
* This is the editor's question rather than the visitor's: the inspector edits
|
|
45
|
+
* every locale at once, so it wants to know whether the field has been used at
|
|
46
|
+
* all, not whether one particular locale resolves.
|
|
47
|
+
*/
|
|
48
|
+
export function hasAnyText(
|
|
49
|
+
value: Partial<LocalizedString> | string | undefined | null,
|
|
50
|
+
): boolean {
|
|
51
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
52
|
+
if (!value || typeof value !== "object") return false;
|
|
53
|
+
return Object.values(value).some((v) => typeof v === "string" && v.trim() !== "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A link is usable when something survives trimming. `""` when it is not. */
|
|
57
|
+
export function resolvedHref(href: string | undefined | null): string {
|
|
58
|
+
return typeof href === "string" ? href.trim() : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A call to action with both halves present, ready to render. */
|
|
62
|
+
export type ResolvedCta = { label: string; href: string };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a call to action, or `null` when it must not be rendered.
|
|
66
|
+
*
|
|
67
|
+
* Both halves are required. A label with no link is not a button, it is text
|
|
68
|
+
* pretending to be one; a link with no label is an empty target. Either way the
|
|
69
|
+
* whole element is dropped — not emptied, dropped — so no border, no arrow, no
|
|
70
|
+
* anchor and no gap is left behind.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveCta(
|
|
73
|
+
cta: { label?: Partial<LocalizedString>; href?: string } | undefined | null,
|
|
74
|
+
locale: Locale,
|
|
75
|
+
): ResolvedCta | null {
|
|
76
|
+
if (!cta) return null;
|
|
77
|
+
const label = resolvedText(cta.label, locale);
|
|
78
|
+
if (!label) return null;
|
|
79
|
+
const href = resolvedHref(cta.href);
|
|
80
|
+
if (!href) return null;
|
|
81
|
+
return { label, href };
|
|
82
|
+
}
|
package/src/blocks/registry.tsx
CHANGED
package/src/blocks/types.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ButtonColors } from "./button-colors";
|
|
|
2
2
|
import type { CountdownColors, CountdownRounding } from "./countdown-colors";
|
|
3
3
|
import type { CountdownTextStyle } from "./countdown-typography";
|
|
4
4
|
import type { LocalizedString } from "../i18n";
|
|
5
|
+
import type { RevealMode } from "../ui/Reveal";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* JSON-compatible value. Declared here so the package never depends on a
|
|
@@ -340,6 +341,11 @@ export type RichTextProps = {
|
|
|
340
341
|
padding?: "none" | "compact" | "normal" | "spacious";
|
|
341
342
|
columns?: 1 | 2;
|
|
342
343
|
dropCap?: boolean;
|
|
344
|
+
/**
|
|
345
|
+
* How the block appears on screen. Absent means `fade`, which is what every
|
|
346
|
+
* block did before the setting existed.
|
|
347
|
+
*/
|
|
348
|
+
reveal?: RevealMode;
|
|
343
349
|
anchor?: string;
|
|
344
350
|
};
|
|
345
351
|
|