@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
@@ -8,36 +8,12 @@
8
8
  * path or feature set.
9
9
  */
10
10
 
11
- /** Feature modules a site can switch on. Unknown flags are ignored. */
12
- export type SiteFeatureFlags = {
13
- /** Trips / departures module (`/kontoret/trips`). */
14
- trips: boolean;
15
- /** Courses module (`/kontoret/kurser`). */
16
- courses: boolean;
17
- /** Reviews module (`/kontoret/recensioner`). */
18
- reviews: boolean;
19
- /** Transactional email + provider settings screens. */
20
- email: boolean;
21
- /** Head/body code injection and analytics pixels. */
22
- tracking: boolean;
23
- /** Backup & restore tooling (superadmin only). */
24
- backup: boolean;
25
- /** Custom domain management screen. */
26
- domains: boolean;
27
- /** Public forms module. */
28
- forms: boolean;
29
- };
30
-
31
- export const DEFAULT_FEATURE_FLAGS: SiteFeatureFlags = {
32
- trips: false,
33
- courses: false,
34
- reviews: false,
35
- email: true,
36
- tracking: true,
37
- backup: true,
38
- domains: false,
39
- forms: true,
40
- };
11
+ // Modules — their keys, defaults and labels — are described once in
12
+ // `./modules`. Re-exported here so every existing import keeps working.
13
+ export type { SiteFeatureFlags } from "./modules";
14
+ export { DEFAULT_FEATURE_FLAGS } from "./modules";
15
+ import type { SiteFeatureFlags } from "./modules";
16
+ import { CMS_MODULE_KEYS, DEFAULT_FEATURE_FLAGS } from "./modules";
41
17
 
42
18
  /** Official company / contact details. Locale-neutral by design. */
43
19
  export type SiteCompany = {
@@ -254,7 +230,9 @@ export function parseSiteSettingOverrides(raw: unknown): SiteSettingOverrides {
254
230
  }
255
231
  if (isRecord(raw["features"])) {
256
232
  const features: Partial<SiteFeatureFlags> = {};
257
- for (const key of Object.keys(DEFAULT_FEATURE_FLAGS) as (keyof SiteFeatureFlags)[]) {
233
+ // Still a closed allowlist: an unknown key in the database is dropped on
234
+ // read and on write. Modules made the list derived, not arbitrary.
235
+ for (const key of CMS_MODULE_KEYS) {
258
236
  const value = (raw["features"] as Record<string, unknown>)[key];
259
237
  if (typeof value === "boolean") features[key] = value;
260
238
  }
@@ -14,6 +14,9 @@ import {
14
14
  } from "./conditions";
15
15
  import { renderLinkedText } from "./rich-text";
16
16
  import { useHostPublicFormFns, useHostTripSource } from "../host";
17
+ import { emitFormSubmissionSuccess } from "./success-listeners";
18
+ import { isStoredSubmission } from "./submission-result";
19
+ import type { FormSubmissionSuccess } from "./submission-result";
17
20
 
18
21
  const TOKEN_PREFIX = "form-draft-token:";
19
22
 
@@ -45,11 +48,31 @@ export function FormRenderer({
45
48
  dark = false,
46
49
  className,
47
50
  preview = false,
51
+ context,
52
+ onSuccess,
48
53
  }: {
49
54
  form: PublicForm;
50
55
  dark?: boolean;
51
56
  className?: string;
52
57
  preview?: boolean;
58
+ /**
59
+ * Where this form is being rendered, for whoever is rendering it.
60
+ *
61
+ * Merged into the submission itself, so the server decides what a submission
62
+ * was from what it stored — not from anything the browser says afterwards.
63
+ * A feature that needs the server to treat its submissions differently sends
64
+ * that here, and names itself: this file deliberately does not know which
65
+ * features exist, and a test holds it to that.
66
+ *
67
+ * Absent for an ordinary page form, and that absence is the signal.
68
+ */
69
+ context?: Readonly<Record<string, string>>;
70
+ /**
71
+ * Called only for a submission the host actually stored. A honeypot
72
+ * rejection — `{ ok: true, skipped: true }` — never reaches it, and neither
73
+ * does preview mode, which contacts no server at all.
74
+ */
75
+ onSuccess?: (event: FormSubmissionSuccess) => void;
53
76
  }) {
54
77
  const { t } = useT();
55
78
  const locale = useLocale();
@@ -216,16 +239,37 @@ export function FormRenderer({
216
239
  try {
217
240
  // Send only the answers to fields the visitor actually saw.
218
241
  const visible = Object.fromEntries(fields.map((f) => [f.key, values[f.key] ?? ""]));
219
- await send({
242
+ const pagePath = typeof window !== "undefined" ? window.location.pathname : undefined;
243
+ const result = await send({
220
244
  key: form.key,
221
245
  locale,
222
- pagePath: typeof window !== "undefined" ? window.location.pathname : undefined,
246
+ pagePath,
223
247
  consent,
224
248
  elapsedMs: Date.now() - mountedAt.current,
225
249
  hp,
226
250
  clientToken: form.savePartials ? draftToken(form.key) : undefined,
251
+ // Spread rather than nested, and omitted entirely when there is none:
252
+ // the host reads a key's presence as the signal, so an always-present
253
+ // field holding an empty value is a different contract wearing the
254
+ // same name.
255
+ ...(context ?? {}),
227
256
  values: visible,
228
257
  });
258
+
259
+ // Announced before the redirect below, not after: assigning
260
+ // window.location.href tears the page down, and a listener that has not
261
+ // run by then never will. Only a stored submission counts — a honeypot
262
+ // rejection answers ok:true and must not look like a conversion.
263
+ if (isStoredSubmission(result)) {
264
+ const event: FormSubmissionSuccess = {
265
+ formKey: form.key,
266
+ locale,
267
+ ...(pagePath === undefined ? {} : { pagePath }),
268
+ };
269
+ emitFormSubmissionSuccess(event);
270
+ onSuccess?.(event);
271
+ }
272
+
229
273
  if (form.thankyouMode === "redirect" && form.thankyouRedirect) {
230
274
  window.location.href = form.thankyouRedirect;
231
275
  return;
@@ -412,10 +456,16 @@ export function ManagedForm({
412
456
  formKey,
413
457
  dark = false,
414
458
  className,
459
+ context,
460
+ onSuccess,
415
461
  }: {
416
462
  formKey: string;
417
463
  dark?: boolean;
418
464
  className?: string;
465
+ /** Forwarded to `FormRenderer`; see its `context`. */
466
+ context?: Readonly<Record<string, string>>;
467
+ /** Forwarded to `FormRenderer`; fires only for a stored submission. */
468
+ onSuccess?: (event: FormSubmissionSuccess) => void;
419
469
  }) {
420
470
  const { getPublicForm } = useHostPublicFormFns();
421
471
  const { data: form, isLoading } = useQuery({
@@ -432,7 +482,16 @@ export function ManagedForm({
432
482
  );
433
483
  }
434
484
  if (!form) return null;
435
- return <FormRenderer form={form} dark={dark} className={className} />;
485
+ return (
486
+ <FormRenderer
487
+ form={form}
488
+ dark={dark}
489
+ className={className}
490
+ {...(context === undefined ? {} : { context })}
491
+ {...(onSuccess === undefined ? {} : { onSuccess })}
492
+ />
493
+ );
436
494
  }
437
495
 
438
496
  export type { PublicForm };
497
+ export type { FormSubmissionSuccess };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * What the host's submit endpoint answered.
3
+ *
4
+ * The distinction that matters is `skipped`. A site's spam protection may
5
+ * accept a request and deliberately store nothing — a honeypot hit answers
6
+ * `{ ok: true, skipped: true }`. Nothing was saved, no notification was sent,
7
+ * and no person is behind it. Treating that as a completed submission is how a
8
+ * bot inflates every conversion figure a site reports.
9
+ */
10
+ export type FormSubmitResult = {
11
+ readonly ok: boolean;
12
+ /** True when the request was accepted but deliberately not stored. */
13
+ readonly skipped?: boolean;
14
+ };
15
+
16
+ /** Details handed to a success listener. Never carries what the visitor typed. */
17
+ export type FormSubmissionSuccess = {
18
+ readonly formKey: string;
19
+ readonly locale: string;
20
+ /** The page the form was submitted from, when the host knows it. */
21
+ readonly pagePath?: string;
22
+ };
23
+
24
+ /**
25
+ * True only for a submission the host actually stored.
26
+ *
27
+ * Deliberately conservative: an endpoint that answers with something this
28
+ * cannot read is treated as *not* a confirmed success, because over-counting a
29
+ * conversion is worse than under-counting one.
30
+ */
31
+ export function isStoredSubmission(result: unknown): boolean {
32
+ if (typeof result !== "object" || result === null) return false;
33
+ const { ok, skipped } = result as FormSubmitResult;
34
+ return ok === true && skipped !== true;
35
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A generic notification seam for genuinely successful form submissions.
3
+ *
4
+ * The form system knows nothing about who is listening. Any CMS module — popups
5
+ * today, something else later — subscribes here, and the form runtime keeps
6
+ * working with no listener at all. That is the whole point: `FormRenderer` must
7
+ * never import a module in order to tell it something happened.
8
+ */
9
+ import type { FormSubmissionSuccess } from "./submission-result";
10
+
11
+ type Listener = (event: FormSubmissionSuccess) => void;
12
+
13
+ const listeners = new Set<Listener>();
14
+
15
+ /**
16
+ * Subscribe to stored submissions. Returns an unsubscribe function.
17
+ *
18
+ * Only genuine submissions arrive here — a honeypot rejection never does.
19
+ */
20
+ export function onFormSubmissionSuccess(listener: Listener): () => void {
21
+ listeners.add(listener);
22
+ return () => {
23
+ listeners.delete(listener);
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Notify every listener.
29
+ *
30
+ * Each is isolated: a listener that throws must not break the visitor's
31
+ * submission, which has already succeeded by the time this runs.
32
+ */
33
+ export function emitFormSubmissionSuccess(event: FormSubmissionSuccess): void {
34
+ for (const listener of [...listeners]) {
35
+ try {
36
+ listener(event);
37
+ } catch {
38
+ /* A subscriber must never break a form. */
39
+ }
40
+ }
41
+ }
42
+
43
+ /** Test helper: drops every subscription. */
44
+ export function clearFormSubmissionListeners(): void {
45
+ listeners.clear();
46
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ComponentType, ImgHTMLAttributes } from "react";
2
2
  import type { UseQueryOptions } from "@tanstack/react-query";
3
3
  import type { LocalizedString } from "../i18n/types";
4
+ import type { FormSubmitResult } from "../forms/submission-result";
4
5
 
5
6
  /**
6
7
  * Host component contract.
@@ -67,6 +68,12 @@ export type HostRichTextEditorProps = {
67
68
  value: string;
68
69
  onChange: (html: string) => void;
69
70
  placeholder?: string;
71
+ /**
72
+ * Show the text but refuse edits. Set while another administrator holds the
73
+ * editing lock — a disabled `fieldset` cannot stop a `contenteditable`, so
74
+ * an editor that ignores this stays typable behind the lock.
75
+ */
76
+ readOnly?: boolean;
70
77
  };
71
78
 
72
79
  export type HostMediaUpload = {
@@ -173,6 +180,17 @@ export type HostSubmitFormInput = {
173
180
  elapsedMs?: number;
174
181
  hp?: string;
175
182
  clientToken?: string;
183
+ /**
184
+ * The popup this form was submitted from, when it was submitted from one.
185
+ *
186
+ * Absent for every ordinary page form, and that absence is meaningful: a
187
+ * host decides whether a submission is a campaign opt-in by whether this is
188
+ * here. It is the only popup-shaped value the browser sends — the
189
+ * destination, the endpoint, the signing secret and which fields may leave
190
+ * are all read on the server from the popup's stored configuration, because
191
+ * anything the browser could name it could also change.
192
+ */
193
+ popupKey?: string;
176
194
  values: HostFormValues;
177
195
  };
178
196
 
@@ -191,7 +209,15 @@ export type HostSavePartialInput = {
191
209
  */
192
210
  export type HostPublicFormFns = {
193
211
  getPublicForm: (key: string) => Promise<unknown>;
194
- submitForm: (input: HostSubmitFormInput) => Promise<unknown>;
212
+ /**
213
+ * Stores a submission.
214
+ *
215
+ * The result is read, not discarded: `skipped: true` means the request was
216
+ * accepted and deliberately not stored (a honeypot hit), which must never be
217
+ * reported as a completed submission. A host that answers with something
218
+ * else is treated as unconfirmed rather than successful.
219
+ */
220
+ submitForm: (input: HostSubmitFormInput) => Promise<FormSubmitResult | unknown>;
195
221
  savePartialSubmission: (input: HostSavePartialInput) => Promise<unknown>;
196
222
  };
197
223
 
@@ -0,0 +1,28 @@
1
+ export {
2
+ CmsModuleAdmin,
3
+ CmsModuleRuntimes,
4
+ clearModuleRuntimes,
5
+ enabledModuleRuntimes,
6
+ moduleAdminLoader,
7
+ registerModuleAdmin,
8
+ registerModuleRuntime,
9
+ } from "./public-runtime";
10
+ export type {
11
+ CmsModuleAdminProps,
12
+ CmsModuleRuntimesProps,
13
+ ModuleComponentLoader,
14
+ } from "./public-runtime";
15
+ export {
16
+ CMS_MODULES,
17
+ CMS_MODULE_KEYS,
18
+ CMS_MODULE_LABELS,
19
+ CMS_MODULE_LIST,
20
+ DEFAULT_FEATURE_FLAGS,
21
+ findCmsModule,
22
+ } from "../config/modules";
23
+ export type {
24
+ CmsModuleDefinition,
25
+ CmsModuleKey,
26
+ CmsModuleNav,
27
+ SiteFeatureFlags,
28
+ } from "../config/modules";
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Lazy public-site contributions from enabled modules.
3
+ *
4
+ * A module registers a *loader*, never a component. Two consequences that are
5
+ * the whole point:
6
+ *
7
+ * 1. Defining a module costs nothing. Registration does not import the module's
8
+ * implementation, so a site that has popups switched off never evaluates the
9
+ * popup runtime and never asks the browser for its chunk.
10
+ * 2. Loading happens after hydration, on the client only. The server renders
11
+ * nothing here and so does the first client render, so there is nothing for
12
+ * hydration to disagree about.
13
+ *
14
+ * The loaders are ordinary local `import()` calls that the host's bundler turns
15
+ * into chunks. Nothing is ever fetched from a remote origin.
16
+ */
17
+ import { useEffect, useState } from "react";
18
+ import type { ComponentType, ReactNode } from "react";
19
+ import type { CmsModuleKey, SiteFeatureFlags } from "../config/modules";
20
+ import { CMS_MODULE_LIST } from "../config/modules";
21
+ import { siteConfig } from "../config/site-config";
22
+
23
+ /** What a loader must resolve to: a module with a default-exported component. */
24
+ export type ModuleComponentLoader = () => Promise<{ default: ComponentType }>;
25
+
26
+ const publicLoaders = new Map<CmsModuleKey, ModuleComponentLoader>();
27
+ const adminLoaders = new Map<CmsModuleKey, ModuleComponentLoader>();
28
+
29
+ /**
30
+ * Register a module's public component loader.
31
+ *
32
+ * Idempotent by key: bundlers evaluate a module once, but a site may call this
33
+ * from more than one entry, and mounting the same runtime twice would double
34
+ * every impression it records.
35
+ */
36
+ export function registerModuleRuntime(key: CmsModuleKey, load: ModuleComponentLoader): void {
37
+ const declared = CMS_MODULE_LIST.find((module) => module.key === key);
38
+ if (declared?.hasPublicRuntime !== true) {
39
+ throw new Error(
40
+ `@bygghammar/cms-platform: module "${key}" did not declare hasPublicRuntime, so it cannot contribute to the public site.`,
41
+ );
42
+ }
43
+ publicLoaders.set(key, load);
44
+ }
45
+
46
+ /** Register a module's backoffice screen loader. */
47
+ export function registerModuleAdmin(key: CmsModuleKey, load: ModuleComponentLoader): void {
48
+ adminLoaders.set(key, load);
49
+ }
50
+
51
+ /** Test helper. Not part of the site-facing contract. */
52
+ export function clearModuleRuntimes(): void {
53
+ publicLoaders.clear();
54
+ adminLoaders.clear();
55
+ }
56
+
57
+ /** Which registered public runtimes the given feature set enables, in table order. */
58
+ export function enabledModuleRuntimes(
59
+ features: SiteFeatureFlags,
60
+ ): readonly { key: CmsModuleKey; load: ModuleComponentLoader }[] {
61
+ return CMS_MODULE_LIST.flatMap((module) => {
62
+ if (features[module.key] !== true) return [];
63
+ const load = publicLoaders.get(module.key);
64
+ return load === undefined ? [] : [{ key: module.key, load }];
65
+ });
66
+ }
67
+
68
+ export function moduleAdminLoader(key: CmsModuleKey): ModuleComponentLoader | undefined {
69
+ return adminLoaders.get(key);
70
+ }
71
+
72
+ /**
73
+ * Loads one module's component after hydration and renders it.
74
+ *
75
+ * The loader runs in an effect, never during render, so it cannot execute on
76
+ * the server. `cancelled` guards a module that unmounts before its chunk
77
+ * arrives, which is ordinary on a fast navigation.
78
+ */
79
+ function LazyModule({ load }: { load: ModuleComponentLoader }): ReactNode {
80
+ // Null until the chunk arrives. That is what makes this safe under SSR and
81
+ // hydration alike: the server renders nothing, the first client pass renders
82
+ // nothing, and the component only appears once an effect has resolved it. No
83
+ // separate "hydrated" flag is needed — the effect is the gate.
84
+ const [Component, setComponent] = useState<ComponentType | null>(null);
85
+
86
+ useEffect(() => {
87
+ let cancelled = false;
88
+ load().then(
89
+ (module) => {
90
+ if (!cancelled) setComponent(() => module.default);
91
+ },
92
+ () => {
93
+ // A module that fails to load must not take the site down with it.
94
+ },
95
+ );
96
+ return () => {
97
+ cancelled = true;
98
+ };
99
+ }, [load]);
100
+
101
+ return Component === null ? null : <Component />;
102
+ }
103
+
104
+ export type CmsModuleRuntimesProps = {
105
+ /**
106
+ * Resolved feature flags. Defaults to the registered site config, which is
107
+ * the code-level answer — deployable and reviewable. A host that already has
108
+ * database overrides in hand may pass them instead; the public root
109
+ * deliberately does not fetch them itself, because a blocking read there
110
+ * would sit on the server-render path of every page.
111
+ */
112
+ readonly features?: SiteFeatureFlags;
113
+ };
114
+
115
+ export function CmsModuleRuntimes({ features }: CmsModuleRuntimesProps): ReactNode {
116
+ let resolved: SiteFeatureFlags | null = null;
117
+ try {
118
+ resolved = features ?? siteConfig().features;
119
+ } catch {
120
+ // No site config registered yet: render nothing rather than throwing from
121
+ // a site's root layout.
122
+ resolved = null;
123
+ }
124
+
125
+ if (resolved === null) return null;
126
+
127
+ return (
128
+ <>
129
+ {enabledModuleRuntimes(resolved).map(({ key, load }) => (
130
+ <LazyModule key={key} load={load} />
131
+ ))}
132
+ </>
133
+ );
134
+ }
135
+
136
+ export type CmsModuleAdminProps = {
137
+ readonly moduleKey: CmsModuleKey;
138
+ /** Rendered while the chunk is in flight. */
139
+ readonly fallback?: ReactNode;
140
+ };
141
+
142
+ /**
143
+ * Mounts a module's backoffice screen, loading its chunk on first visit.
144
+ *
145
+ * Kept separate from the public runtime registry so the two are independently
146
+ * lazy: a public page never asks for the admin chunk, and an administrator
147
+ * opening the screen never pulls in anything the public site uses.
148
+ */
149
+ export function CmsModuleAdmin({ moduleKey, fallback = null }: CmsModuleAdminProps): ReactNode {
150
+ const load = moduleAdminLoader(moduleKey);
151
+ if (load === undefined) {
152
+ return (
153
+ <div className="p-8 text-sm text-ink/50">
154
+ This module has no backoffice screen registered.
155
+ </div>
156
+ );
157
+ }
158
+ return <LazyModule load={load} />;
159
+ }
@@ -1,5 +1,7 @@
1
1
  import type { ComponentType } from "react";
2
2
 
3
+ import { hasText, resolvedHref } from "../blocks/optional-content";
4
+ import type { Locale } from "../i18n/locales";
3
5
  import type { LocalizedString } from "../i18n/types";
4
6
 
5
7
  export type NavigationItem = {
@@ -39,3 +41,30 @@ export function isBackofficeNavigationActive(
39
41
  export function navigationPanelId(href: string): string {
40
42
  return `nav-sub-${href.replace(/\W+/g, "-")}`;
41
43
  }
44
+
45
+ /**
46
+ * Navigation entries a visitor can actually use, in the order given.
47
+ *
48
+ * A menu entry is a label pointing somewhere. One missing either half is not a
49
+ * menu entry — it is a half-finished row in the editor, and rendering it gives
50
+ * a visitor an empty dropdown line or a link to nowhere. Entries are dropped
51
+ * per locale, on the same terms as any other optional content: a label written
52
+ * only in Swedish still shows in English through the usual fallback, while one
53
+ * deliberately blanked for English is absent there.
54
+ *
55
+ * Order is preserved exactly, so what the editor arranged is what renders.
56
+ */
57
+ export function renderableNavigation(
58
+ items: readonly NavigationItem[] | undefined,
59
+ locale: Locale,
60
+ ): NavigationItem[] {
61
+ return (items ?? [])
62
+ .filter((item) => hasText(item.label, locale) && resolvedHref(item.href) !== "")
63
+ .map((item) => {
64
+ const href = resolvedHref(item.href);
65
+ const children = renderableNavigation(item.children, locale);
66
+ return children.length > 0
67
+ ? { ...item, href, children }
68
+ : { ...item, href, children: undefined };
69
+ });
70
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The Popups backoffice screen.
3
+ *
4
+ * Its own module with its own subpath export, not part of `backoffice/routes`:
5
+ * that barrel eagerly imports every admin view, so listing popups there would
6
+ * pull the builder and the statistics screen into any chunk that touches the
7
+ * backoffice. The module host loads this lazily on first visit, and a public
8
+ * bundle never reaches it.
9
+ *
10
+ * The screen itself belongs to the popup package. What this file adds is the
11
+ * CMS half: the site's forms and pages, discovered through the platform's own
12
+ * host contract rather than reimplemented per site.
13
+ */
14
+ import { useCallback, useMemo } from "react";
15
+ import type { ReactNode } from "react";
16
+ import { PopupAdminApp } from "@bygghammar/lightweight-popup/admin";
17
+ // The backoffice styling ships with the popup package but must be imported by
18
+ // whoever mounts the screen. Importing it HERE — inside the lazily loaded
19
+ // admin module — makes the consumer's bundler attach it to the admin chunk, so
20
+ // a public bundle never carries a byte of it.
21
+ import "@bygghammar/lightweight-popup/admin.css";
22
+ import type { AdminAdapters } from "@bygghammar/lightweight-popup/adapters";
23
+
24
+ import { backofficeHost } from "../backoffice/routes/host";
25
+ import type { BackofficeHost } from "../backoffice/routes/host";
26
+ import { siteConfig } from "../config/site-config";
27
+ import { useFeatureGate } from "../backoffice/shell/use-resolved-config";
28
+ import { cmsPopupNavigation } from "./cms-bridges";
29
+ import { cmsMediaPicker } from "./media-bridge";
30
+ import { ManagedForm } from "../forms/FormRenderer";
31
+ import {
32
+ cmsFormDirectory,
33
+ cmsOptInDestinations,
34
+ cmsPageDirectory,
35
+ } from "./cms-bridges";
36
+ import { popupAdminHost } from "./host";
37
+
38
+ /** The page group, or nothing — a site can run popups without the page builder. */
39
+ type PagesHost = NonNullable<BackofficeHost["pages"]>;
40
+
41
+ /**
42
+ * The screen, once it knows how to turn a page key into an address.
43
+ *
44
+ * Split out for one reason: `usePageUrl` is a hook and the page group is
45
+ * optional, so it cannot be called behind a condition. A separate component
46
+ * makes the call unconditional in the only place it happens.
47
+ */
48
+ function PopupsScreen({
49
+ resolvePath,
50
+ }: {
51
+ resolvePath?: (pageKey: string) => string;
52
+ }): ReactNode {
53
+ const host = popupAdminHost();
54
+
55
+ const adapters = useMemo<AdminAdapters>(
56
+ () => ({
57
+ popups: host.popups,
58
+ statistics: host.statistics,
59
+ // The CMS knows its own forms and pages; a host only overrides these when
60
+ // its content model differs from the platform's.
61
+ formDirectory: host.formDirectory ?? cmsFormDirectory(),
62
+ pages: host.pages ?? cmsPageDirectory(resolvePath),
63
+ // Only offered when the site actually has destinations: the popup editor
64
+ // shows no picker at all otherwise, rather than an empty control.
65
+ optInDestinations: cmsOptInDestinations(),
66
+ // THE line that makes "Choose from media library" appear. Without it the
67
+ // popup editor falls back to typing an address, which is what an
68
+ // administrator was left with before.
69
+ media: cmsMediaPicker(),
70
+ navigation: cmsPopupNavigation("popups"),
71
+ forms: {
72
+ // The builder preview renders the site's real form, so what an editor
73
+ // checks is what a visitor will see.
74
+ //
75
+ // Without `popupKey`, deliberately. An editor filling the preview to
76
+ // see how it looks would otherwise store an opt-in and fire the
77
+ // popup's delivery — a real webhook, from somebody checking a layout.
78
+ renderForm: ({ formKey }) => <ManagedForm formKey={formKey} />,
79
+ },
80
+ }),
81
+ [host, resolvePath],
82
+ );
83
+
84
+ return <PopupAdminApp adapters={adapters} />;
85
+ }
86
+
87
+ /**
88
+ * The same screen, with the site's routing engine behind the page addresses.
89
+ *
90
+ * The default locale's path, because a popup's targeting is one list of paths
91
+ * for the whole site rather than one per language. On a site that gives each
92
+ * locale its own address, the other locales are a rule away in Advanced.
93
+ */
94
+ function PopupsScreenWithPageUrls({ pages }: { pages: PagesHost }): ReactNode {
95
+ const pageUrl = pages.usePageUrl();
96
+ const locale = siteConfig().defaultLocale;
97
+ const resolvePath = useCallback(
98
+ (pageKey: string) => pageUrl(pageKey, locale).path,
99
+ [pageUrl, locale],
100
+ );
101
+ return <PopupsScreen resolvePath={resolvePath} />;
102
+ }
103
+
104
+ function PopupsRouteInner(): ReactNode {
105
+ const pages = backofficeHost().pages;
106
+ // Branching on which component renders, not on which hooks run: each of the
107
+ // two calls its own hooks unconditionally, which is the rule.
108
+ return pages === undefined ? (
109
+ <PopupsScreen />
110
+ ) : (
111
+ <PopupsScreenWithPageUrls pages={pages} />
112
+ );
113
+ }
114
+
115
+ /** PopupsRoute guarded by the "popups" module switch. */
116
+ export function PopupsRoute(): ReactNode {
117
+ const gate = useFeatureGate("popups", "Popups");
118
+ if (gate) return <>{gate}</>;
119
+ return <PopupsRouteInner />;
120
+ }
121
+
122
+ // Default export so the module host can load this screen lazily.
123
+ export default PopupsRoute;