@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
@@ -7,6 +7,9 @@
7
7
  */
8
8
 
9
9
  import { useEffect, useState, type ReactNode } from "react";
10
+
11
+ import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
12
+ import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
10
13
  import { ChevronLeft, Code2, FormInput, RotateCcw, Save } from "lucide-react";
11
14
 
12
15
  /** Metadata for one editable shared section. */
@@ -20,7 +23,8 @@ export type SectionEditorDeps = {
20
23
  value: unknown;
21
24
  /** Code default for this section, used by "Reset to default". */
22
25
  codeDefault: unknown;
23
- save: (value: unknown) => Promise<unknown>;
26
+ /** `editSessionId` identifies the tab holding the lock; the server checks it. */
27
+ save: (value: unknown, editSessionId?: string) => Promise<unknown>;
24
28
  goToList: () => void;
25
29
  renderForm: (args: { value: unknown; onChange: (next: unknown) => void }) => ReactNode;
26
30
  notify: {
@@ -40,6 +44,11 @@ export function SectionEditorView({ deps }: { deps: SectionEditorDeps }) {
40
44
  const [busy, setBusy] = useState(false);
41
45
  const [dirty, setDirty] = useState(false);
42
46
 
47
+ const lock = useEditLock({
48
+ resourceType: EDIT_LOCK_TYPES.contentSection,
49
+ resourceId: sectionKey,
50
+ });
51
+
43
52
  useEffect(() => {
44
53
  setDraft(value);
45
54
  setJsonText(JSON.stringify(value, null, 2));
@@ -85,7 +94,7 @@ export function SectionEditorView({ deps }: { deps: SectionEditorDeps }) {
85
94
  }
86
95
  setBusy(true);
87
96
  try {
88
- await deps.save(payload);
97
+ await deps.save(payload, lock.sessionId);
89
98
  notify.success("Saved");
90
99
  setDirty(false);
91
100
  } catch (err) {
@@ -144,7 +153,9 @@ export function SectionEditorView({ deps }: { deps: SectionEditorDeps }) {
144
153
  )}
145
154
  </div>
146
155
 
147
- <div className="mt-6">
156
+ <EditLockBanner lock={lock} className="mt-6" />
157
+
158
+ <EditLockFence lock={lock} className="mt-6 block">
148
159
  {mode === "form" ? (
149
160
  deps.renderForm({
150
161
  value: draft,
@@ -166,12 +177,12 @@ export function SectionEditorView({ deps }: { deps: SectionEditorDeps }) {
166
177
  />
167
178
  </div>
168
179
  )}
169
- </div>
180
+ </EditLockFence>
170
181
 
171
182
  <div className="mt-6 flex flex-wrap items-center gap-3">
172
183
  <button
173
184
  onClick={onSave}
174
- disabled={busy || !dirty}
185
+ disabled={busy || !dirty || lock.blocked}
175
186
  className="inline-flex items-center gap-2 bg-ink text-paper px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-ink/85 disabled:opacity-50"
176
187
  >
177
188
  <Save className="size-4" />
@@ -179,7 +190,8 @@ export function SectionEditorView({ deps }: { deps: SectionEditorDeps }) {
179
190
  </button>
180
191
  <button
181
192
  onClick={onReset}
182
- className="inline-flex items-center gap-2 border border-ink/20 px-6 py-3 text-xs uppercase tracking-[0.22em] hover:border-ink"
193
+ disabled={lock.blocked}
194
+ className="inline-flex items-center gap-2 border border-ink/20 px-6 py-3 text-xs uppercase tracking-[0.22em] hover:border-ink disabled:opacity-50"
183
195
  >
184
196
  <RotateCcw className="size-4" />
185
197
  Reset to default
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  createContext,
3
3
  useContext,
4
+ useRef,
4
5
  useState,
5
6
  type ComponentType,
6
7
  type CSSProperties,
@@ -48,6 +49,14 @@ import { MediaPickerDialog } from "./inspectors/fields";
48
49
  * - Plain object → recurse into fields.
49
50
  * - string/number/bool → primitive input.
50
51
  *
52
+ * Sibling items teach the form what a list holds. An array's entries are
53
+ * merged into one example — a "shape" — which is handed to each row and down
54
+ * through its fields. That example is what lets the form render a field an
55
+ * individual item happens to be missing, and lets "Add item" build a real
56
+ * entry for a list that is currently empty. Without it a nested menu is
57
+ * uneditable: an item with no `children` key offers no way to add one, and
58
+ * adding to an empty `children: []` appends a bare string.
59
+ *
51
60
  * Everything site-specific (locale list, image renderer, focus editor, the
52
61
  * managed-forms list) is injected by the host.
53
62
  * Backoffice copy is English by project rule.
@@ -116,6 +125,10 @@ function isLocalizedImage(
116
125
  return isPlainObject(v) && typeof v.src === "string" && isPlainObject(v.alt);
117
126
  }
118
127
 
128
+ function newRowId(): string {
129
+ return `row_${Math.random().toString(36).slice(2, 10)}`;
130
+ }
131
+
119
132
  function labelize(key: string): string {
120
133
  return key
121
134
  .replace(/([A-Z])/g, " $1")
@@ -130,6 +143,65 @@ function useIsLocalizedString() {
130
143
  return makeIsLocalizedString(locales);
131
144
  }
132
145
 
146
+ /**
147
+ * One example object describing everything the items of a list can hold.
148
+ *
149
+ * Menu data is ragged: the first item is `{ label, href }` and the third also
150
+ * has `children`. Merging the entries gives every row the same set of fields
151
+ * to offer, so a submenu can be added to an item that has none yet. The first
152
+ * non-empty value for a key wins, because that is the one that shows what the
153
+ * key actually contains.
154
+ */
155
+ function mergedShape(items: readonly unknown[]): Record<string, unknown> | undefined {
156
+ let out: Record<string, unknown> | undefined;
157
+ for (const item of items) {
158
+ if (!isPlainObject(item)) continue;
159
+ out ??= {};
160
+ for (const [k, v] of Object.entries(item)) {
161
+ const known = out[k];
162
+ const knownIsEmpty =
163
+ known === undefined ||
164
+ known === "" ||
165
+ (Array.isArray(known) && known.length === 0) ||
166
+ (isPlainObject(known) && Object.keys(known).length === 0);
167
+ if (knownIsEmpty) out[k] = v;
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /** The first entry of a list that can serve as an example of its items. */
174
+ function elementShape(...lists: (readonly unknown[] | undefined)[]): unknown {
175
+ for (const list of lists) {
176
+ if (!list?.length) continue;
177
+ const merged = mergedShape(list);
178
+ if (merged) return merged;
179
+ const first = list.find((v) => v !== undefined && v !== null);
180
+ if (first !== undefined) return first;
181
+ }
182
+ return undefined;
183
+ }
184
+
185
+ /** Keys under which a nested list holds items of the parent's own shape. */
186
+ const TREE_KEYS = ["children", "items", "links"] as const;
187
+
188
+ /**
189
+ * Whether an object is a menu entry rather than a content row.
190
+ *
191
+ * Used only to decide whether to offer a submenu on a list item whose siblings
192
+ * do not have one either — the cold start, where no data exists to learn from.
193
+ * A link has somewhere to go and something to call it; a gallery slide or a
194
+ * price row has neither, and is left alone.
195
+ */
196
+ function isLinkNode(value: Record<string, unknown>, deps: SectionFormDeps): boolean {
197
+ const isLocalizedString = makeIsLocalizedString(deps.locales);
198
+ const hasTarget = ["href", "url", "to", "path"].some((k) => typeof value[k] === "string");
199
+ const hasName = ["label", "title", "name", "text"].some(
200
+ (k) => isLocalizedString(value[k]) || typeof value[k] === "string",
201
+ );
202
+ return hasTarget && hasName;
203
+ }
204
+
133
205
  function blankLike(template: unknown, deps: SectionFormDeps): unknown {
134
206
  const isLocalizedString = makeIsLocalizedString(deps.locales);
135
207
  if (isLocalizedString(template)) return { [deps.defaultLocale]: "" };
@@ -169,34 +241,68 @@ function Node({
169
241
  path,
170
242
  depth,
171
243
  fieldLabel,
244
+ shape,
245
+ inList,
172
246
  }: {
173
247
  value: AnyVal;
174
248
  onChange: (v: AnyVal) => void;
175
249
  path: string;
176
250
  depth: number;
177
251
  fieldLabel?: string;
252
+ /** An example of this field taken from sibling items, when one exists. */
253
+ shape?: unknown;
254
+ /** True when this value is an entry in a list rather than a named field. */
255
+ inList?: boolean;
178
256
  }) {
257
+ const deps = useDeps();
179
258
  const isLocalizedString = useIsLocalizedString();
180
- if (isLocalizedString(value)) {
181
- return <LocalizedStringField value={value} onChange={onChange} label={fieldLabel} />;
259
+
260
+ // A field a particular item is missing, or that was saved as an empty
261
+ // object, still has to be editable — and has to be edited with the right
262
+ // control. The sibling example says which one, without writing anything:
263
+ // nothing reaches the data until the editor changes something.
264
+ const isBlank =
265
+ value === undefined ||
266
+ value === null ||
267
+ (isPlainObject(value) && Object.keys(value).length === 0 && !isPlainObject(shape));
268
+ const resolved = isBlank && shape !== undefined ? blankLike(shape, deps) : value;
269
+
270
+ if (isLocalizedString(resolved)) {
271
+ return <LocalizedStringField value={resolved} onChange={onChange} label={fieldLabel} />;
182
272
  }
183
- if (isLocalizedImage(value)) {
184
- return <ImageField value={value} onChange={onChange} label={fieldLabel} />;
273
+ if (isLocalizedImage(resolved)) {
274
+ return <ImageField value={resolved} onChange={onChange} label={fieldLabel} />;
185
275
  }
186
- if (Array.isArray(value)) {
276
+ if (Array.isArray(resolved)) {
187
277
  return (
188
- <ArrayField value={value} onChange={onChange} path={path} depth={depth} label={fieldLabel} />
278
+ <ArrayField
279
+ value={resolved}
280
+ onChange={onChange}
281
+ path={path}
282
+ depth={depth}
283
+ label={fieldLabel}
284
+ shape={shape}
285
+ />
189
286
  );
190
287
  }
191
- if (isPlainObject(value)) {
288
+ if (isPlainObject(resolved)) {
192
289
  return (
193
- <ObjectField value={value} onChange={onChange} path={path} depth={depth} label={fieldLabel} />
290
+ <ObjectField
291
+ value={resolved}
292
+ onChange={onChange}
293
+ path={path}
294
+ depth={depth}
295
+ label={fieldLabel}
296
+ shape={shape}
297
+ inList={inList}
298
+ />
194
299
  );
195
300
  }
196
- if (path.endsWith("formKey") && (typeof value === "string" || value == null)) {
301
+ const value_ = resolved;
302
+ if (path.endsWith("formKey") && (typeof value_ === "string" || value_ == null)) {
197
303
  return (
198
304
  <FormKeyField
199
- value={(value as string) ?? ""}
305
+ value={(value_ as string) ?? ""}
200
306
  onChange={onChange}
201
307
  label={fieldLabel ?? "Form"}
202
308
  />
@@ -204,7 +310,7 @@ function Node({
204
310
  }
205
311
  return (
206
312
  <PrimitiveField
207
- value={value}
313
+ value={value_}
208
314
  onChange={onChange}
209
315
  label={fieldLabel}
210
316
  hint={
@@ -452,23 +558,42 @@ function ArrayField({
452
558
  path,
453
559
  depth,
454
560
  label,
561
+ shape,
455
562
  }: {
456
563
  value: unknown[];
457
564
  onChange: (v: unknown[]) => void;
458
565
  path: string;
459
566
  depth: number;
460
567
  label?: string;
568
+ /** An example list from sibling items, used when this one is empty. */
569
+ shape?: unknown;
461
570
  }) {
462
571
  const deps = useDeps();
463
572
  const [openIdx, setOpenIdx] = useState<number | null>(value.length <= 3 ? 0 : null);
464
- // Stable per-position ids so dnd-kit can reorder items whose original data has no id.
465
- const [ids, setIds] = useState<string[]>(() =>
466
- value.map((_, i) => `row_${i}_${Math.random().toString(36).slice(2, 8)}`),
467
- );
468
- if (ids.length !== value.length) {
469
- // Rebuild when items are added/removed externally (add/remove below always keeps ids in sync).
470
- setIds(value.map((_, i) => `row_${i}_${Math.random().toString(36).slice(2, 8)}`));
573
+
574
+ // Stable row identity for dnd-kit and for React keys. Ids used to be minted
575
+ // for every position whenever the list's length changed, which remounted
576
+ // every row — collapsing open items and dropping the caret out of whatever
577
+ // field was being typed in. They are now minted only for positions that are
578
+ // genuinely new, and follow their item through a reorder.
579
+ const minted = useRef<string[]>([]);
580
+ if (minted.current.length !== value.length) {
581
+ const next = minted.current.slice(0, value.length);
582
+ while (next.length < value.length) next.push(newRowId());
583
+ minted.current = next;
471
584
  }
585
+ // An item carrying its own id keeps it, so it survives the whole value being
586
+ // replaced after a save. Duplicates would break dnd-kit, so they are ignored.
587
+ const own = value.map((item) =>
588
+ isPlainObject(item) && typeof item.id === "string" && item.id ? item.id : null,
589
+ );
590
+ const ownAreUsable =
591
+ own.every((id) => id !== null) && new Set(own as string[]).size === own.length;
592
+ const ids = ownAreUsable ? (own as string[]) : minted.current;
593
+
594
+ // What "Add item" should build. Sibling entries describe it exactly; an
595
+ // empty list borrows the example handed down from the items around it.
596
+ const template = elementShape(value, Array.isArray(shape) ? shape : undefined);
472
597
 
473
598
  const sensors = useSensors(
474
599
  useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
@@ -484,25 +609,24 @@ function ArrayField({
484
609
  const j = i + dir;
485
610
  if (j < 0 || j >= value.length) return;
486
611
  onChange(arrayMove(value.slice(), i, j));
487
- setIds((prev) => arrayMove(prev, i, j));
612
+ minted.current = arrayMove(minted.current, i, j);
488
613
  setOpenIdx(j);
489
614
  };
490
615
  const remove = (i: number) => {
491
616
  const copy = value.slice();
492
617
  copy.splice(i, 1);
493
618
  onChange(copy);
494
- setIds((prev) => {
495
- const nx = prev.slice();
496
- nx.splice(i, 1);
497
- return nx;
498
- });
619
+ const nx = minted.current.slice();
620
+ nx.splice(i, 1);
621
+ minted.current = nx;
499
622
  setOpenIdx(null);
500
623
  };
501
624
  const add = () => {
502
- const template = value[0] ?? "";
503
- const next = value.concat([blankLike(template, deps)]);
625
+ // `template` is undefined only for a list nothing has ever described — a
626
+ // list of plain strings, which is what an empty string produces.
627
+ const next = value.concat([blankLike(template ?? "", deps)]);
504
628
  onChange(next);
505
- setIds((prev) => [...prev, `row_${prev.length}_${Math.random().toString(36).slice(2, 8)}`]);
629
+ minted.current = [...minted.current, newRowId()];
506
630
  setOpenIdx(next.length - 1);
507
631
  };
508
632
 
@@ -513,7 +637,7 @@ function ArrayField({
513
637
  const ni = ids.indexOf(String(over.id));
514
638
  if (oi < 0 || ni < 0) return;
515
639
  onChange(arrayMove(value.slice(), oi, ni));
516
- setIds((prev) => arrayMove(prev, oi, ni));
640
+ minted.current = arrayMove(minted.current, oi, ni);
517
641
  if (openIdx === oi) setOpenIdx(ni);
518
642
  else if (openIdx === ni) setOpenIdx(oi);
519
643
  };
@@ -547,6 +671,8 @@ function ArrayField({
547
671
  onChange={(next) => update(i, next)}
548
672
  path={`${path}[${i}]`}
549
673
  depth={depth + 1}
674
+ shape={template}
675
+ inList
550
676
  />
551
677
  </div>
552
678
  )}
@@ -696,24 +822,51 @@ function ObjectField({
696
822
  path,
697
823
  depth,
698
824
  label,
825
+ shape,
826
+ inList,
699
827
  }: {
700
828
  value: Record<string, unknown>;
701
829
  onChange: (v: Record<string, unknown>) => void;
702
830
  path: string;
703
831
  depth: number;
704
832
  label?: string;
833
+ /** An example of this object taken from its sibling items in a list. */
834
+ shape?: unknown;
835
+ /** True when this object is an entry in a list rather than a named field. */
836
+ inList?: boolean;
705
837
  }) {
706
- const entries = Object.entries(value);
838
+ const deps = useDeps();
839
+ const example = isPlainObject(shape) ? shape : undefined;
840
+
841
+ // Fields this item is missing but its siblings have. A menu item saved
842
+ // without a `children` key is not an item without a submenu, it is an item
843
+ // whose submenu nobody has written yet — and it must be possible to write
844
+ // one. Nothing is added to the data by showing the field.
845
+ const keys = [...Object.keys(value)];
846
+ for (const k of Object.keys(example ?? {})) if (!keys.includes(k)) keys.push(k);
847
+
848
+ // The cold start: no sibling has a submenu either, so there is nothing to
849
+ // learn the shape from. Offer one on entries that are plainly links, and
850
+ // leave gallery slides, price rows and the like untouched.
851
+ // Only entries of a list can be menu items; a named `cta` field is a button,
852
+ // and giving it a submenu would be noise.
853
+ const isTreeKey = (k: string) => (TREE_KEYS as readonly string[]).includes(k);
854
+ const isLink = Boolean(inList) && isLinkNode(value, deps);
855
+ if (isLink && !keys.some(isTreeKey)) keys.push(TREE_KEYS[0]);
856
+
707
857
  const content = (
708
858
  <div className={depth === 0 ? "space-y-5" : "space-y-4"}>
709
- {entries.map(([k, v]) => (
859
+ {keys.map((k) => (
710
860
  <Node
711
861
  key={k}
712
- value={v}
862
+ value={value[k]}
713
863
  onChange={(nv) => onChange({ ...value, [k]: nv })}
714
864
  path={path ? `${path}.${k}` : k}
715
865
  depth={depth + 1}
716
866
  fieldLabel={labelize(k)}
867
+ // A submenu on a link holds items of the link's own shape, which is
868
+ // the only thing left to go on once the siblings have said nothing.
869
+ shape={example?.[k] ?? (isLink && isTreeKey(k) ? [value] : undefined)}
717
870
  />
718
871
  ))}
719
872
  </div>
@@ -11,6 +11,7 @@ import {
11
11
  type SiteFeatureFlags,
12
12
  type SiteSettingOverrides,
13
13
  } from "../config/site-config";
14
+ import { CMS_MODULE_LIST, CMS_MODULE_LABELS } from "../config/modules";
14
15
  import type { IndexingPolicy } from "../site/indexing";
15
16
  import { IndexingStatusCard } from "./IndexingStatusCard";
16
17
 
@@ -48,16 +49,9 @@ export type SiteSettingsDeps = {
48
49
  MediaField?: (props: any) => ReactNode;
49
50
  };
50
51
 
51
- const FEATURE_LABELS: Record<keyof SiteFeatureFlags, string> = {
52
- trips: "Trips & departures",
53
- courses: "Courses",
54
- reviews: "Reviews",
55
- email: "Transactional email",
56
- tracking: "Code & tracking",
57
- backup: "Backup & restore",
58
- domains: "Custom domains",
59
- forms: "Forms",
60
- };
52
+ // Labels and order come from the module table, so a new module appears here
53
+ // without a second list to keep in step.
54
+ const FEATURE_LABELS = CMS_MODULE_LABELS;
61
55
 
62
56
  const COMPANY_LABELS: Record<keyof typeof EMPTY_COMPANY, string> = {
63
57
  legalName: "Legal name",
@@ -275,14 +269,19 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
275
269
  busy={busy === "features"}
276
270
  />
277
271
  <div className="mt-4 grid gap-3 md:grid-cols-2">
278
- {(Object.keys(DEFAULT_FEATURE_FLAGS) as (keyof SiteFeatureFlags)[]).map((flag) => (
279
- <label key={flag} className="flex items-center gap-3 text-sm text-ink">
272
+ {CMS_MODULE_LIST.map((module) => (
273
+ <label key={module.key} className="flex items-center gap-3 text-sm text-ink">
280
274
  <input
281
275
  type="checkbox"
282
- checked={features[flag]}
283
- onChange={(e) => setFeatures({ ...features, [flag]: e.target.checked })}
276
+ checked={features[module.key]}
277
+ onChange={(e) => setFeatures({ ...features, [module.key]: e.target.checked })}
284
278
  />
285
- {FEATURE_LABELS[flag]}
279
+ <span>
280
+ {FEATURE_LABELS[module.key]}
281
+ {module.description ? (
282
+ <span className="block text-xs text-ink/60">{module.description}</span>
283
+ ) : null}
284
+ </span>
286
285
  </label>
287
286
  ))}
288
287
  </div>