@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
|
@@ -8,36 +8,12 @@
|
|
|
8
8
|
* path or feature set.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/host/index.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/navigation/index.ts
CHANGED
|
@@ -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;
|