@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
@@ -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 = (props.columns ?? 3) === 2 ? "sm:grid-cols-2" : "sm:grid-cols-2 lg:grid-cols-3";
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 id={props.anchor || undefined} className={`${tone} py-16 md:py-24`}>
45
+ <section
46
+ id={props.anchor || undefined}
47
+ className={`${tone} py-16 md:py-24`}
48
+ >
34
49
  <div className="container-editorial">
35
- {props.eyebrow || props.title || props.lead ? (
50
+ {eyebrow || title || lead ? (
36
51
  <Reveal>
37
52
  <div className={headBox}>
38
- {props.eyebrow ? (
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
- {t(props.eyebrow)}
59
+ {eyebrow}
45
60
  </span>
46
61
  ) : null}
47
- {props.title ? (
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
- {t(props.title)}
68
+ {title}
54
69
  </h2>
55
70
  ) : null}
56
- {props.lead ? (
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
- {t(props.lead)}
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
- <div
73
- key={g.id ?? gi}
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
- className={`flex items-baseline justify-between gap-3 px-5 py-4 ${
84
- g.highlight ? "bg-sea text-white" : dark ? "bg-paper/10" : "bg-ink/[0.04]"
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
- <span
88
- className={`font-serif text-lg md:text-xl ${
89
- g.highlight ? "text-white" : dark ? "text-paper" : "text-ink"
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-[0.6rem] uppercase tracking-[0.2em] ${
97
- g.highlight ? "text-white/80" : dark ? "text-paper/60" : "text-ink/50"
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(props.unitLabel)}
117
+ {t(g.label)}
101
118
  </span>
102
- ) : null}
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={`font-serif text-xl tabular-nums ${
120
- dark ? "text-paper" : "text-ink"
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
- {t(r.price)}
129
+ {unitLabel}
124
130
  </span>
125
- </li>
126
- ))}
127
- </ul>
131
+ ) : null}
132
+ </div>
128
133
 
129
- {g.caption ? (
130
- <p
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
- {t(g.caption)}
136
- </p>
137
- ) : null}
138
- </div>
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
- <div className={`mt-8 flex flex-wrap items-center gap-5 ${footBox}`}>
144
- {props.ctaLabel && props.ctaHref ? (
145
- <a
146
- href={props.ctaHref}
147
- style={buttonColorStyle(props.ctaColors)}
148
- className={`inline-flex items-center px-6 py-3 text-[0.7rem] uppercase tracking-[0.22em] ${BUTTON_COLOR_CLASS}`}
149
- >
150
- {t(props.ctaLabel)}
151
- </a>
152
- ) : null}
153
- {props.note ? (
154
- <p
155
- className={`max-w-2xl text-sm leading-relaxed whitespace-pre-line ${
156
- centered ? "mx-auto" : ""
157
- } ${
158
- dark ? "text-paper/70" : "text-ink/65"
159
- }`}
160
- >
161
- {t(props.note)}
162
- </p>
163
- ) : null}
164
- </div>
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
- * The stored HTML is sanitized on every render (never trusted as-is).
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" : "",
@@ -41,3 +41,11 @@ export {
41
41
  } from "./countdown-replacement";
42
42
 
43
43
  export * from "./countdown-typography";
44
+ export {
45
+ hasAnyText,
46
+ hasText,
47
+ resolveCta,
48
+ resolvedHref,
49
+ resolvedText,
50
+ type ResolvedCta,
51
+ } from "./optional-content";
@@ -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
+ }
@@ -134,6 +134,7 @@ export const SHARED_BLOCK_DEFS: Record<string, BlockDef> = {
134
134
  padding: "normal",
135
135
  columns: 1,
136
136
  dropCap: false,
137
+ reveal: "fade",
137
138
  } satisfies RichTextProps as unknown as BlockProps,
138
139
  hasInlineContent: true,
139
140
  },
@@ -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