@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.2

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.
@@ -8,22 +8,23 @@
8
8
  import type { LucideProps } from "lucide-react";
9
9
  import {
10
10
  Blocks,
11
- Calendar,
12
- ClipboardList,
13
11
  FileText,
14
- Globe,
15
- GraduationCap,
16
12
  ImageIcon,
17
13
  Layers,
18
14
  LayoutDashboard,
19
15
  LayoutList,
20
- MessageSquareQuote,
21
16
  ShieldCheck,
22
17
  } from "lucide-react";
23
18
 
24
19
  import type { SiteConfig } from "../../config/site-config";
20
+ import { CMS_MODULE_LIST, type CmsModuleKey } from "../../config/modules";
25
21
  import type { BackofficeNavigationItem } from "../../navigation";
26
22
 
23
+ /**
24
+ * Sidebar keys: the always-present screens plus every module that declares a
25
+ * nav entry. Module keys are no longer repeated here — a module contributing a
26
+ * sidebar item is a fact about the module, kept in one place.
27
+ */
27
28
  export type BackofficeNavKey =
28
29
  | "dashboard"
29
30
  | "pages"
@@ -32,12 +33,8 @@ export type BackofficeNavKey =
32
33
  | "sections"
33
34
  | "media"
34
35
  | "seo"
35
- | "domains"
36
- | "trips"
37
- | "courses"
38
- | "forms"
39
- | "reviews"
40
- | "admin";
36
+ | "admin"
37
+ | CmsModuleKey;
41
38
 
42
39
  export type BackofficeNavItem = BackofficeNavigationItem<LucideProps>;
43
40
 
@@ -51,7 +48,8 @@ type Definition = {
51
48
  feature?: keyof SiteConfig["features"];
52
49
  };
53
50
 
54
- const DEFINITIONS: readonly Definition[] = [
51
+ /** Screens every site has, regardless of which modules are on. */
52
+ const CORE: readonly Definition[] = [
55
53
  { key: "dashboard", path: "", label: "Dashboard", icon: LayoutDashboard, exact: true },
56
54
  { key: "pages", path: "/pages-builder", label: "Page builder", icon: Blocks },
57
55
  { key: "global-sections", path: "/global-sections", label: "Global Sections", icon: Layers },
@@ -59,17 +57,28 @@ const DEFINITIONS: readonly Definition[] = [
59
57
  { key: "sections", path: "/sections", label: "Shared content", icon: LayoutList },
60
58
  { key: "media", path: "/media", label: "Media", icon: ImageIcon },
61
59
  { key: "seo", path: "/seo", label: "SEO", icon: FileText },
62
- { key: "domains", path: "/domains", label: "Domains", icon: Globe, feature: "domains" },
63
- { key: "trips", path: "/trips", label: "Trips", icon: Calendar, feature: "trips" },
64
- { key: "courses", path: "/kurser", label: "Courses", icon: GraduationCap, feature: "courses" },
65
- { key: "forms", path: "/forms", label: "Forms", icon: ClipboardList, feature: "forms" },
66
- {
67
- key: "reviews",
68
- path: "/recensioner",
69
- label: "Reviews",
70
- icon: MessageSquareQuote,
71
- feature: "reviews",
72
- },
60
+ ];
61
+
62
+ /** Entries contributed by modules, each gated on its own flag. */
63
+ const MODULE_ITEMS: readonly Definition[] = CMS_MODULE_LIST.flatMap((module) =>
64
+ module.nav === undefined
65
+ ? []
66
+ : [
67
+ {
68
+ key: module.key,
69
+ path: module.nav.path,
70
+ label: module.nav.label,
71
+ icon: module.nav.icon,
72
+ ...(module.nav.exact === undefined ? {} : { exact: module.nav.exact }),
73
+ feature: module.key,
74
+ },
75
+ ],
76
+ );
77
+
78
+ // Admin stays last: it is the catch-all for everything a module did not claim.
79
+ const DEFINITIONS: readonly Definition[] = [
80
+ ...CORE,
81
+ ...MODULE_ITEMS,
73
82
  { key: "admin", path: "/admin", label: "Admin", icon: ShieldCheck },
74
83
  ];
75
84
 
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The CMS module table.
3
+ *
4
+ * "Modules" were previously described in four separate places that had to agree
5
+ * by hand: the `SiteFeatureFlags` type, the defaults object, the label map on
6
+ * the Modules screen, and the sidebar definitions. Adding one meant editing all
7
+ * four and hoping none was missed.
8
+ *
9
+ * This is the single description each of those now derives from. It is
10
+ * deliberately NOT a plugin registry: the key space stays a closed, typed union
11
+ * so an unknown value in `site_settings` is still rejected rather than trusted.
12
+ * What it buys is that a new first-party module is one entry here plus one line
13
+ * in the flag type, instead of five edits across the package.
14
+ */
15
+ import type { ComponentType } from "react";
16
+ import type { LucideProps } from "lucide-react";
17
+ import {
18
+ Blocks,
19
+ Calendar,
20
+ ClipboardList,
21
+ Globe,
22
+ GraduationCap,
23
+ MessageSquare,
24
+ MessageSquareQuote,
25
+ } from "lucide-react";
26
+
27
+ /**
28
+ * Every module a site can switch on.
29
+ *
30
+ * Adding a key here is the one type-level edit a new module needs; the compiler
31
+ * then requires the matching table entry below.
32
+ */
33
+ export type SiteFeatureFlags = {
34
+ /** Trips / departures module (`/kontoret/trips`). */
35
+ trips: boolean;
36
+ /** Courses module (`/kontoret/kurser`). */
37
+ courses: boolean;
38
+ /** Reviews module (`/kontoret/recensioner`). */
39
+ reviews: boolean;
40
+ /** Transactional email + provider settings screens. */
41
+ email: boolean;
42
+ /** Head/body code injection and analytics pixels. */
43
+ tracking: boolean;
44
+ /** Backup & restore tooling (superadmin only). */
45
+ backup: boolean;
46
+ /** Custom domain management screen. */
47
+ domains: boolean;
48
+ /** Public forms module. */
49
+ forms: boolean;
50
+ /** Popups module: public popup runtime + backoffice administration. */
51
+ popups: boolean;
52
+ };
53
+
54
+ export type CmsModuleKey = keyof SiteFeatureFlags;
55
+
56
+ /** A sidebar entry a module contributes when it is enabled. */
57
+ export type CmsModuleNav = {
58
+ /** Appended to the site's configured backoffice path. Never hardcode `/kontoret`. */
59
+ readonly path: string;
60
+ readonly label: string;
61
+ readonly icon: ComponentType<LucideProps>;
62
+ readonly exact?: boolean;
63
+ };
64
+
65
+ export type CmsModuleDefinition = {
66
+ readonly key: CmsModuleKey;
67
+ /** Shown on the Modules screen. */
68
+ readonly label: string;
69
+ /** Whether a site that says nothing gets this module. */
70
+ readonly defaultEnabled: boolean;
71
+ /** One line of help on the Modules screen. */
72
+ readonly description?: string;
73
+ /**
74
+ * Sidebar contribution. Absent for modules with no screen of their own —
75
+ * `email`, `tracking` and `backup` live under Admin.
76
+ */
77
+ readonly nav?: CmsModuleNav;
78
+ /**
79
+ * Minimum role for the module's own screens. `superadmin` is used by modules
80
+ * that can destroy or exfiltrate data.
81
+ */
82
+ readonly requiresRole?: "staff" | "superadmin";
83
+ /**
84
+ * True when the module contributes a component to the public site. The public
85
+ * runtime host renders only the modules that both declare this and are
86
+ * enabled, so a disabled module mounts nothing.
87
+ */
88
+ readonly hasPublicRuntime?: boolean;
89
+ };
90
+
91
+ /**
92
+ * Every module, in sidebar order.
93
+ *
94
+ * `satisfies` pins the array to the flag union, so a key added to
95
+ * `SiteFeatureFlags` without an entry here — or an entry whose key is not a
96
+ * flag — is a compile error rather than a module that silently never appears.
97
+ */
98
+ export const CMS_MODULES = [
99
+ {
100
+ key: "trips",
101
+ label: "Trips & departures",
102
+ defaultEnabled: false,
103
+ nav: { path: "/trips", label: "Trips", icon: Calendar },
104
+ requiresRole: "staff",
105
+ },
106
+ {
107
+ key: "courses",
108
+ label: "Courses",
109
+ defaultEnabled: false,
110
+ nav: { path: "/kurser", label: "Courses", icon: GraduationCap },
111
+ requiresRole: "staff",
112
+ },
113
+ {
114
+ key: "reviews",
115
+ label: "Reviews",
116
+ defaultEnabled: false,
117
+ nav: { path: "/recensioner", label: "Reviews", icon: MessageSquareQuote },
118
+ requiresRole: "staff",
119
+ },
120
+ { key: "email", label: "Transactional email", defaultEnabled: true, requiresRole: "staff" },
121
+ { key: "tracking", label: "Code & tracking", defaultEnabled: true, requiresRole: "staff" },
122
+ { key: "backup", label: "Backup & restore", defaultEnabled: true, requiresRole: "superadmin" },
123
+ {
124
+ key: "domains",
125
+ label: "Custom domains",
126
+ defaultEnabled: false,
127
+ nav: { path: "/domains", label: "Domains", icon: Globe },
128
+ requiresRole: "staff",
129
+ },
130
+ {
131
+ key: "forms",
132
+ label: "Forms",
133
+ defaultEnabled: true,
134
+ nav: { path: "/forms", label: "Forms", icon: ClipboardList },
135
+ requiresRole: "staff",
136
+ },
137
+ {
138
+ key: "popups",
139
+ label: "Popups",
140
+ defaultEnabled: false,
141
+ description: "Timed popups on the public site, with their own statistics.",
142
+ nav: { path: "/popups", label: "Popups", icon: MessageSquare },
143
+ requiresRole: "staff",
144
+ hasPublicRuntime: true,
145
+ },
146
+ ] as const satisfies readonly CmsModuleDefinition[];
147
+
148
+ /** Compile-time proof that every flag has exactly one module entry. */
149
+ type DeclaredKeys = (typeof CMS_MODULES)[number]["key"];
150
+ type MissingModule = Exclude<CmsModuleKey, DeclaredKeys>;
151
+ type AssertNever<T extends never> = T;
152
+ type _EveryFlagIsDeclared = AssertNever<MissingModule>;
153
+
154
+ /**
155
+ * The table widened to the declaration type.
156
+ *
157
+ * `CMS_MODULES` is `as const` so the exhaustiveness check above can see the
158
+ * literal keys; consumers want the declared shape, where every optional field
159
+ * exists whether or not a particular entry uses it.
160
+ */
161
+ export const CMS_MODULE_LIST: readonly CmsModuleDefinition[] = CMS_MODULES;
162
+
163
+ export const CMS_MODULE_KEYS: readonly CmsModuleKey[] = CMS_MODULES.map((m) => m.key);
164
+
165
+ export function findCmsModule(key: CmsModuleKey): CmsModuleDefinition | undefined {
166
+ return CMS_MODULES.find((module) => module.key === key);
167
+ }
168
+
169
+ /** The defaults a site starts from, derived so the two can never disagree. */
170
+ export const DEFAULT_FEATURE_FLAGS: SiteFeatureFlags = Object.fromEntries(
171
+ CMS_MODULES.map((module) => [module.key, module.defaultEnabled]),
172
+ ) as SiteFeatureFlags;
173
+
174
+ /** Labels for the Modules screen, derived from the same table. */
175
+ export const CMS_MODULE_LABELS: Readonly<Record<CmsModuleKey, string>> = Object.fromEntries(
176
+ CMS_MODULES.map((module) => [module.key, module.label]),
177
+ ) as Record<CmsModuleKey, string>;
178
+
179
+ /**
180
+ * Blocks that unnecessary icon imports are not the reason a public bundle grows:
181
+ * this module table is imported by the public runtime host as well, so keep it
182
+ * free of anything heavier than the icon components already used by the sidebar.
183
+ */
184
+ export type { LucideProps };
@@ -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,18 @@ export function FormRenderer({
45
48
  dark = false,
46
49
  className,
47
50
  preview = false,
51
+ onSuccess,
48
52
  }: {
49
53
  form: PublicForm;
50
54
  dark?: boolean;
51
55
  className?: string;
52
56
  preview?: boolean;
57
+ /**
58
+ * Called only for a submission the host actually stored. A honeypot
59
+ * rejection — `{ ok: true, skipped: true }` — never reaches it, and neither
60
+ * does preview mode, which contacts no server at all.
61
+ */
62
+ onSuccess?: (event: FormSubmissionSuccess) => void;
53
63
  }) {
54
64
  const { t } = useT();
55
65
  const locale = useLocale();
@@ -216,16 +226,32 @@ export function FormRenderer({
216
226
  try {
217
227
  // Send only the answers to fields the visitor actually saw.
218
228
  const visible = Object.fromEntries(fields.map((f) => [f.key, values[f.key] ?? ""]));
219
- await send({
229
+ const pagePath = typeof window !== "undefined" ? window.location.pathname : undefined;
230
+ const result = await send({
220
231
  key: form.key,
221
232
  locale,
222
- pagePath: typeof window !== "undefined" ? window.location.pathname : undefined,
233
+ pagePath,
223
234
  consent,
224
235
  elapsedMs: Date.now() - mountedAt.current,
225
236
  hp,
226
237
  clientToken: form.savePartials ? draftToken(form.key) : undefined,
227
238
  values: visible,
228
239
  });
240
+
241
+ // Announced before the redirect below, not after: assigning
242
+ // window.location.href tears the page down, and a listener that has not
243
+ // run by then never will. Only a stored submission counts — a honeypot
244
+ // rejection answers ok:true and must not look like a conversion.
245
+ if (isStoredSubmission(result)) {
246
+ const event: FormSubmissionSuccess = {
247
+ formKey: form.key,
248
+ locale,
249
+ ...(pagePath === undefined ? {} : { pagePath }),
250
+ };
251
+ emitFormSubmissionSuccess(event);
252
+ onSuccess?.(event);
253
+ }
254
+
229
255
  if (form.thankyouMode === "redirect" && form.thankyouRedirect) {
230
256
  window.location.href = form.thankyouRedirect;
231
257
  return;
@@ -412,10 +438,13 @@ export function ManagedForm({
412
438
  formKey,
413
439
  dark = false,
414
440
  className,
441
+ onSuccess,
415
442
  }: {
416
443
  formKey: string;
417
444
  dark?: boolean;
418
445
  className?: string;
446
+ /** Forwarded to `FormRenderer`; fires only for a stored submission. */
447
+ onSuccess?: (event: FormSubmissionSuccess) => void;
419
448
  }) {
420
449
  const { getPublicForm } = useHostPublicFormFns();
421
450
  const { data: form, isLoading } = useQuery({
@@ -432,7 +461,15 @@ export function ManagedForm({
432
461
  );
433
462
  }
434
463
  if (!form) return null;
435
- return <FormRenderer form={form} dark={dark} className={className} />;
464
+ return (
465
+ <FormRenderer
466
+ form={form}
467
+ dark={dark}
468
+ className={className}
469
+ {...(onSuccess === undefined ? {} : { onSuccess })}
470
+ />
471
+ );
436
472
  }
437
473
 
438
474
  export type { PublicForm };
475
+ 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.
@@ -191,7 +192,15 @@ export type HostSavePartialInput = {
191
192
  */
192
193
  export type HostPublicFormFns = {
193
194
  getPublicForm: (key: string) => Promise<unknown>;
194
- submitForm: (input: HostSubmitFormInput) => Promise<unknown>;
195
+ /**
196
+ * Stores a submission.
197
+ *
198
+ * The result is read, not discarded: `skipped: true` means the request was
199
+ * accepted and deliberately not stored (a honeypot hit), which must never be
200
+ * reported as a completed submission. A host that answers with something
201
+ * else is treated as unconfirmed rather than successful.
202
+ */
203
+ submitForm: (input: HostSubmitFormInput) => Promise<FormSubmitResult | unknown>;
195
204
  savePartialSubmission: (input: HostSavePartialInput) => Promise<unknown>;
196
205
  };
197
206
 
@@ -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";