@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
|
@@ -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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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(
|
|
184
|
-
return <ImageField value={
|
|
273
|
+
if (isLocalizedImage(resolved)) {
|
|
274
|
+
return <ImageField value={resolved} onChange={onChange} label={fieldLabel} />;
|
|
185
275
|
}
|
|
186
|
-
if (Array.isArray(
|
|
276
|
+
if (Array.isArray(resolved)) {
|
|
187
277
|
return (
|
|
188
|
-
<ArrayField
|
|
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(
|
|
288
|
+
if (isPlainObject(resolved)) {
|
|
192
289
|
return (
|
|
193
|
-
<ObjectField
|
|
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
|
-
|
|
301
|
+
const value_ = resolved;
|
|
302
|
+
if (path.endsWith("formKey") && (typeof value_ === "string" || value_ == null)) {
|
|
197
303
|
return (
|
|
198
304
|
<FormKeyField
|
|
199
|
-
value={(
|
|
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={
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
859
|
+
{keys.map((k) => (
|
|
710
860
|
<Node
|
|
711
861
|
key={k}
|
|
712
|
-
value={
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
{
|
|
279
|
-
<label key={
|
|
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[
|
|
283
|
-
onChange={(e) => setFeatures({ ...features, [
|
|
276
|
+
checked={features[module.key]}
|
|
277
|
+
onChange={(e) => setFeatures({ ...features, [module.key]: e.target.checked })}
|
|
284
278
|
/>
|
|
285
|
-
|
|
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>
|