@bygghammar/cms-platform 0.42.0 → 0.43.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.42.0",
3
+ "version": "0.43.1",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -157,7 +157,8 @@
157
157
  "./server/backup-core": "./src/server/backup-core.ts",
158
158
  "./backoffice/shell": "./src/backoffice/shell/index.ts",
159
159
  "./backoffice/routes": "./src/backoffice/routes/index.tsx",
160
- "./backoffice/routes/host": "./src/backoffice/routes/host.ts"
160
+ "./backoffice/routes/host": "./src/backoffice/routes/host.ts",
161
+ "./backoffice/inspectors": "./src/backoffice/inspectors/index.tsx"
161
162
  },
162
163
  "dependencies": {
163
164
  "fflate": "^0.8.3"
@@ -0,0 +1,789 @@
1
+ /**
2
+ * Site-agnostic block inspector router.
3
+ *
4
+ * The per-block inspectors live in ./blocks. This module owns the inspectors
5
+ * that need host plumbing (shared content sections, trips/cruises, forms) plus
6
+ * the router that maps a block type to its inspector. Everything site-specific
7
+ * — server functions, section defaults, editor components, wording — comes from
8
+ * the backoffice host registry.
9
+ */
10
+ import { useEffect, useMemo, useState } from "react";
11
+ import { Link } from "@tanstack/react-router";
12
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
13
+ import { useServerFn } from "@tanstack/react-start";
14
+ import { toast } from "sonner";
15
+ import { Plus, Trash2, ArrowUp, ArrowDown, Save, RotateCcw, Crosshair } from "lucide-react";
16
+
17
+ import type { BlockInstance, BlockProps, TripListProps, TripItem } from "../../blocks/types";
18
+ import { tripNumber } from "../../trips";
19
+ import { FocalImage, FocusPicker } from "../../host";
20
+ import {
21
+ getSharedDrafts,
22
+ setSharedDraft,
23
+ clearSharedDraft,
24
+ subscribeSharedDrafts,
25
+ } from "../../cms/shared-draft-store";
26
+ import { backofficeHost, backofficePaths } from "../routes/host";
27
+ import { InspectorSection, LocalizedField, TextField } from "./fields";
28
+ import {
29
+ HeroInspector,
30
+ IntroInspector,
31
+ FaqInspector,
32
+ InstagramInspector,
33
+ ProseInspector,
34
+ RichTextInspector,
35
+ HeroFullInspector,
36
+ ImageMarqueeInspector,
37
+ LinkTilesInspector,
38
+ CabinTabsInspector,
39
+ PriceTableInspector,
40
+ IconGridInspector,
41
+ ChecklistInspector,
42
+ CtaBannerInspector,
43
+ MediaSplitInspector,
44
+ type BlockPatchProps,
45
+ } from "./blocks";
46
+
47
+ /** Link target for a shared-section editor; paths are site-configured strings. */
48
+ function sectionEditorPath(key: string) {
49
+ return `${backofficePaths().sections}/${key}` as never;
50
+ }
51
+
52
+ /** Localized string map as the form block stores its copy. */
53
+ type FormBlockCopy = {
54
+ formKey?: string;
55
+ eyebrow?: Record<string, string>;
56
+ title?: Record<string, string>;
57
+ lead?: Record<string, string>;
58
+ tone?: string;
59
+ anchor?: string;
60
+ };
61
+
62
+ function uid(prefix = "f") {
63
+ return `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
64
+ }
65
+
66
+ /* ---------------- GlobalRef ---------------- */
67
+ function GlobalRefInspector({
68
+ block,
69
+ patch,
70
+ globalSections,
71
+ }: BlockPatchProps & {
72
+ globalSections: { key: string; name: string }[];
73
+ }) {
74
+ const p = (block.props ?? {}) as { sectionKey?: string };
75
+ return (
76
+ <InspectorSection title="Global block">
77
+ <label className="block space-y-1.5">
78
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Section</span>
79
+ <select
80
+ value={p.sectionKey ?? ""}
81
+ onChange={(e) => patch({ sectionKey: e.target.value } as unknown as BlockProps)}
82
+ className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
83
+ >
84
+ <option value="">— pick one —</option>
85
+ {globalSections.map((g) => (
86
+ <option key={g.key} value={g.key}>
87
+ {g.name} ({g.key})
88
+ </option>
89
+ ))}
90
+ </select>
91
+ </label>
92
+ </InspectorSection>
93
+ );
94
+ }
95
+
96
+ /* ---------------- Shared section editor (inline) ---------------- */
97
+ function SharedSectionEditor({ sectionKey }: { sectionKey: string }) {
98
+ const host = backofficeHost();
99
+ const overrides = host.content.useOverrides();
100
+ const site = host.content.useSite();
101
+ const qc = useQueryClient();
102
+ const save = useServerFn(host.fns.saveContentSection);
103
+
104
+ const saved = useMemo(() => {
105
+ const o = overrides?.[sectionKey];
106
+ const base = (site as unknown as Record<string, unknown>)[sectionKey];
107
+ const def = host.content.defaultForSection(sectionKey) ?? base;
108
+ // Older overrides predate newer fields (e.g. contact.formTitle/formKey) —
109
+ // merge the defaults in so those inputs still render.
110
+ return o ? host.content.withSectionDefaults(o, def) : base;
111
+ // eslint-disable-next-line react-hooks/exhaustive-deps
112
+ }, [sectionKey, JSON.stringify(overrides?.[sectionKey] ?? null)]);
113
+
114
+ const current = saved;
115
+
116
+ const [draft, setDraft] = useState<unknown>(() => getSharedDrafts()[sectionKey] ?? current);
117
+ const [dirty, setDirty] = useState(() => getSharedDrafts()[sectionKey] !== undefined);
118
+ const [busy, setBusy] = useState(false);
119
+
120
+ // Re-seed only when the persisted value actually changes (identity-stable).
121
+ useEffect(() => {
122
+ const pending = getSharedDrafts()[sectionKey];
123
+ setDraft(pending ?? current);
124
+ setDirty(pending !== undefined);
125
+ }, [current, sectionKey]);
126
+
127
+ if (current === undefined) {
128
+ const known = host.content.editableSectionKeys.includes(sectionKey);
129
+ return (
130
+ <InspectorSection title="Shared content">
131
+ {known ? (
132
+ <Link
133
+ to={sectionEditorPath(sectionKey)}
134
+ className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm hover:border-sea"
135
+ >
136
+ Open “{sectionKey}” section
137
+ </Link>
138
+ ) : (
139
+ <p className="text-[0.7rem] text-ink/50">
140
+ This block has no shared content section. Use the overrides above.
141
+ </p>
142
+ )}
143
+ </InspectorSection>
144
+ );
145
+ }
146
+
147
+ const onSave = async () => {
148
+ setBusy(true);
149
+ try {
150
+ await save({ data: { key: sectionKey, data: draft } });
151
+ clearSharedDraft(sectionKey);
152
+ await qc.invalidateQueries({ queryKey: host.content.overridesQueryKey });
153
+ toast.success("Shared content saved");
154
+ setDirty(false);
155
+ } catch (e) {
156
+ toast.error(e instanceof Error ? e.message : "Save failed");
157
+ } finally {
158
+ setBusy(false);
159
+ }
160
+ };
161
+
162
+ const onReset = () => {
163
+ const def = host.content.defaultForSection(sectionKey);
164
+ setDraft(def);
165
+ setSharedDraft(sectionKey, def);
166
+ setDirty(true);
167
+ };
168
+
169
+ return (
170
+ <InspectorSection title={`Shared content — "${sectionKey}" (all pages)`}>
171
+ <p className="text-[0.7rem] text-ink/50">
172
+ Edits here affect every page that uses this block and show live in the canvas. They are
173
+ stored when you press Save shared, Save draft or Publish.
174
+ </p>
175
+ <host.components.SectionForm
176
+ value={draft}
177
+ onChange={(next) => {
178
+ setDraft(next);
179
+ setSharedDraft(sectionKey, next);
180
+ setDirty(true);
181
+ }}
182
+ />
183
+
184
+ <div className="flex flex-wrap items-center gap-2 pt-1">
185
+ <button
186
+ type="button"
187
+ onClick={onSave}
188
+ disabled={!dirty || busy}
189
+ className="inline-flex items-center gap-1.5 bg-ink text-paper px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:bg-ink/85 disabled:opacity-40"
190
+ >
191
+ <Save className="size-3.5" /> {busy ? "Saving…" : "Save shared"}
192
+ </button>
193
+ <button
194
+ type="button"
195
+ onClick={onReset}
196
+ className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-ink"
197
+ >
198
+ <RotateCcw className="size-3.5" /> Reset to default
199
+ </button>
200
+ <Link
201
+ to={sectionEditorPath(sectionKey)}
202
+ className="ml-auto text-[0.7rem] underline text-ink/60 hover:text-ink"
203
+ >
204
+ Open full editor
205
+ </Link>
206
+ </div>
207
+ </InspectorSection>
208
+ );
209
+ }
210
+
211
+ /* ---------------- Gallery responsive preview ---------------- */
212
+ function GalleryResponsivePreview() {
213
+ const host = backofficeHost();
214
+ const overrides = host.content.useOverrides();
215
+ const site = host.content.useSite();
216
+ const qc = useQueryClient();
217
+ const save = useServerFn(host.fns.saveContentSection);
218
+ const [pending, setPending] = useState<Record<string, unknown>>(() => getSharedDrafts());
219
+
220
+ useEffect(() => subscribeSharedDrafts(setPending), []);
221
+
222
+ const shared = useMemo(() => {
223
+ const p = pending["gallery"] as
224
+ { images?: Array<{ src: string; alt: Record<string, string> }> } | undefined;
225
+ if (p) return p;
226
+ const o =
227
+ (overrides?.gallery as { images?: Array<{ src: string; alt: Record<string, string> }> }) ??
228
+ undefined;
229
+ return (
230
+ o ??
231
+ (
232
+ site as unknown as {
233
+ gallery: { images: Array<{ src: string; alt: Record<string, string> }> };
234
+ }
235
+ ).gallery
236
+ );
237
+ }, [pending, overrides, site]);
238
+
239
+ const images = (shared?.images ?? []) as Array<{ src: string; alt: Record<string, string> }>;
240
+ const [device, setDevice] = useState<"desktop" | "tablet" | "mobile">("desktop");
241
+ const [busy, setBusy] = useState(false);
242
+ const [focusSrc, setFocusSrc] = useState<string | null>(null);
243
+
244
+ const persist = async (nextImages: typeof images) => {
245
+ const next = { ...(shared as object), images: nextImages };
246
+ setBusy(true);
247
+ try {
248
+ await save({ data: { key: "gallery", data: next } });
249
+ clearSharedDraft("gallery");
250
+ await qc.invalidateQueries({ queryKey: host.content.overridesQueryKey });
251
+ } catch (e) {
252
+ toast.error(e instanceof Error ? e.message : "Save failed");
253
+ } finally {
254
+ setBusy(false);
255
+ }
256
+ };
257
+
258
+ const move = (i: number, dir: -1 | 1) => {
259
+ const j = i + dir;
260
+ if (j < 0 || j >= images.length) return;
261
+ const next = images.slice();
262
+ [next[i], next[j]] = [next[j], next[i]];
263
+ void persist(next);
264
+ };
265
+
266
+ // Bento visual: desktop = 3 cols, first spans 2 rows; tablet = 2 cols; mobile = 1 col.
267
+ const gridClass =
268
+ device === "desktop"
269
+ ? "grid-cols-3 grid-rows-2 h-56"
270
+ : device === "tablet"
271
+ ? "grid-cols-2 auto-rows-[80px]"
272
+ : "grid-cols-1 auto-rows-[80px]";
273
+ const frameWidth = device === "desktop" ? "100%" : device === "tablet" ? 320 : 200;
274
+
275
+ return (
276
+ <InspectorSection title="Responsive preview & order">
277
+ <p className="text-[0.7rem] text-ink/50">
278
+ One ordering drives all breakpoints. Reorder here to update every device.
279
+ </p>
280
+ <div className="flex items-center gap-1 pb-2">
281
+ {(["desktop", "tablet", "mobile"] as const).map((d) => (
282
+ <button
283
+ key={d}
284
+ type="button"
285
+ onClick={() => setDevice(d)}
286
+ className={`px-2 py-1 text-[0.65rem] uppercase tracking-widest border ${
287
+ device === d
288
+ ? "border-sea bg-ice/50 text-ink"
289
+ : "border-ink/15 text-ink/55 hover:text-ink"
290
+ }`}
291
+ >
292
+ {d}
293
+ </button>
294
+ ))}
295
+ {busy ? <span className="text-[0.65rem] text-ink/50 ml-auto">Saving…</span> : null}
296
+ </div>
297
+ <div className="bg-ink/5 p-3 flex justify-center">
298
+ <div style={{ width: frameWidth }} className="max-w-full">
299
+ <div className={`grid gap-1.5 ${gridClass}`}>
300
+ {images.map((img, i) => (
301
+ <div
302
+ key={i}
303
+ className={`relative overflow-hidden border border-ink/10 bg-white ${
304
+ device === "desktop" && i === 0 ? "row-span-2" : ""
305
+ }`}
306
+ >
307
+ {img.src ? (
308
+ <button
309
+ type="button"
310
+ onClick={() => setFocusSrc(img.src)}
311
+ className="block h-full w-full"
312
+ title="Set focus point"
313
+ >
314
+ <FocalImage
315
+ src={img.src}
316
+ responsive={false}
317
+ alt=""
318
+ className="h-full w-full object-cover"
319
+ />
320
+ </button>
321
+ ) : (
322
+ <div className="h-full w-full grid place-items-center text-[0.6rem] text-ink/40">
323
+ empty
324
+ </div>
325
+ )}
326
+ <span className="absolute top-1 left-1 bg-ink/70 text-paper text-[0.6rem] px-1">
327
+ {i + 1}
328
+ </span>
329
+ </div>
330
+ ))}
331
+ </div>
332
+ </div>
333
+ </div>
334
+ <div className="pt-2 space-y-1">
335
+ {images.map((img, i) => (
336
+ <div key={i} className="flex items-center gap-2 border border-ink/10 px-2 py-1">
337
+ <span className="text-[0.65rem] uppercase tracking-widest text-ink/55 w-6">
338
+ {i + 1}
339
+ </span>
340
+ <div className="h-8 w-10 shrink-0 bg-ink/5 overflow-hidden">
341
+ {img.src ? (
342
+ <FocalImage
343
+ src={img.src}
344
+ responsive={false}
345
+ alt=""
346
+ className="h-full w-full object-cover"
347
+ />
348
+ ) : null}
349
+ </div>
350
+ <span className="flex-1 truncate text-[0.7rem] text-ink/70">
351
+ {img.alt?.sv || img.alt?.en || img.src.split("/").pop()}
352
+ </span>
353
+ <button
354
+ type="button"
355
+ onClick={() => setFocusSrc(img.src)}
356
+ disabled={!img.src}
357
+ className="p-1 text-ink/50 hover:text-ink disabled:opacity-30"
358
+ title="Set focus point"
359
+ >
360
+ <Crosshair className="size-3.5" />
361
+ </button>
362
+ <button
363
+ type="button"
364
+ onClick={() => move(i, -1)}
365
+ disabled={i === 0 || busy}
366
+ className="p-1 text-ink/50 hover:text-ink disabled:opacity-30"
367
+ title="Move up"
368
+ >
369
+ <ArrowUp className="size-3.5" />
370
+ </button>
371
+ <button
372
+ type="button"
373
+ onClick={() => move(i, 1)}
374
+ disabled={i === images.length - 1 || busy}
375
+ className="p-1 text-ink/50 hover:text-ink disabled:opacity-30"
376
+ title="Move down"
377
+ >
378
+ <ArrowDown className="size-3.5" />
379
+ </button>
380
+ </div>
381
+ ))}
382
+ </div>
383
+ {focusSrc ? (
384
+ <FocusPicker
385
+ src={focusSrc}
386
+ modal
387
+ title={focusSrc.split("/").pop()}
388
+ onClose={() => setFocusSrc(null)}
389
+ />
390
+ ) : null}
391
+ </InspectorSection>
392
+ );
393
+ }
394
+
395
+ function GlobalContentNotice({ blockType }: { blockType: string }) {
396
+ return (
397
+ <div className="space-y-3">
398
+ <p className="text-sm text-ink/70">
399
+ This block renders shared site content. Edit its text, images and links in the Sections
400
+ editor for consistent styling across every page.
401
+ </p>
402
+ <Link
403
+ to={sectionEditorPath(blockType)}
404
+ className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm hover:border-sea"
405
+ >
406
+ Open “{blockType}” section
407
+ </Link>
408
+ </div>
409
+ );
410
+ }
411
+
412
+ /* ---------------- Trips list ---------------- */
413
+ function TripListInspector({ block, patch }: BlockPatchProps) {
414
+ const labels = backofficeHost().labels?.trips;
415
+ const one = labels?.singular ?? "Trip";
416
+ const many = labels?.plural ?? "Trips";
417
+ const p = (block.props ?? {}) as TripListProps;
418
+ const items = p.items ?? [];
419
+ const update = (next: TripListProps) => patch(next as unknown as BlockProps);
420
+ const setItem = (i: number, item: TripItem) => {
421
+ const next = items.slice();
422
+ next[i] = item;
423
+ update({ ...p, items: next });
424
+ };
425
+ const move = (i: number, dir: -1 | 1) => {
426
+ const j = i + dir;
427
+ if (j < 0 || j >= items.length) return;
428
+ const next = items.slice();
429
+ [next[i], next[j]] = [next[j], next[i]];
430
+ update({ ...p, items: next });
431
+ };
432
+
433
+ return (
434
+ <>
435
+ <InspectorSection title="Heading">
436
+ <LocalizedField
437
+ label="Eyebrow"
438
+ value={p.eyebrow}
439
+ onChange={(v) => update({ ...p, eyebrow: v })}
440
+ />
441
+ <LocalizedField
442
+ label="Title"
443
+ value={p.title}
444
+ onChange={(v) => update({ ...p, title: v })}
445
+ />
446
+ <LocalizedField
447
+ label="Lead"
448
+ multiline
449
+ value={p.lead}
450
+ onChange={(v) => update({ ...p, lead: v })}
451
+ />
452
+ <LocalizedField
453
+ label="Note"
454
+ multiline
455
+ value={p.note}
456
+ onChange={(v) => update({ ...p, note: v })}
457
+ />
458
+ <TextField
459
+ label="Anchor id (optional)"
460
+ value={p.anchor ?? ""}
461
+ onChange={(v) => update({ ...p, anchor: v })}
462
+ />
463
+ <label className="block space-y-1.5">
464
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
465
+ Background
466
+ </span>
467
+ <select
468
+ value={p.tone ?? "white"}
469
+ onChange={(e) => update({ ...p, tone: e.target.value as TripListProps["tone"] })}
470
+ className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
471
+ >
472
+ <option value="white">White</option>
473
+ <option value="paper">Paper</option>
474
+ </select>
475
+ </label>
476
+ <label className="block space-y-1.5">
477
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
478
+ Columns
479
+ </span>
480
+ <select
481
+ value={String(p.columns ?? 3)}
482
+ onChange={(e) =>
483
+ update({ ...p, columns: Number(e.target.value) as TripListProps["columns"] })
484
+ }
485
+ className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
486
+ >
487
+ <option value="2">2</option>
488
+ <option value="3">3</option>
489
+ <option value="4">4</option>
490
+ </select>
491
+ </label>
492
+ <label className="block space-y-1.5">
493
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
494
+ {`${one} source`}
495
+ </span>
496
+ <select
497
+ value={p.source ?? "global"}
498
+ onChange={(e) => update({ ...p, source: e.target.value as TripListProps["source"] })}
499
+ className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
500
+ >
501
+ <option value="global">{`${many} (single source of truth)`}</option>
502
+ <option value="custom">Custom list (this block only)</option>
503
+ </select>
504
+ </label>
505
+ {(p.source ?? "global") === "global" ? (
506
+ <>
507
+ <TextField
508
+ label="Filter by year (empty = all)"
509
+ value={p.year ?? ""}
510
+ onChange={(v) => update({ ...p, year: v })}
511
+ />
512
+ <p className="text-xs text-ink/55">
513
+ {`${many} are managed centrally in Backoffice → ${many}. Edit them there and every page updates.`}
514
+ </p>
515
+ </>
516
+ ) : null}
517
+ </InspectorSection>
518
+ {(p.source ?? "global") === "global" ? null : (
519
+ <InspectorSection title={`${many} (${items.length})`}>
520
+ <div className="space-y-4">
521
+ {items.map((item, i) => (
522
+ <div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3">
523
+ <div className="flex items-center justify-between">
524
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/50">
525
+ #{i + 1}
526
+ </span>
527
+ <div className="flex items-center gap-1">
528
+ <button
529
+ onClick={() => move(i, -1)}
530
+ className="p-1 text-ink/50 hover:text-ink"
531
+ title="Move up"
532
+ >
533
+ <ArrowUp className="size-3.5" />
534
+ </button>
535
+ <button
536
+ onClick={() => move(i, 1)}
537
+ className="p-1 text-ink/50 hover:text-ink"
538
+ title="Move down"
539
+ >
540
+ <ArrowDown className="size-3.5" />
541
+ </button>
542
+ <button
543
+ onClick={() => {
544
+ const next = items.slice();
545
+ next.splice(i, 1);
546
+ update({ ...p, items: next });
547
+ }}
548
+ className="p-1 text-red-500 hover:text-red-700"
549
+ title="Delete"
550
+ >
551
+ <Trash2 className="size-3.5" />
552
+ </button>
553
+ </div>
554
+ </div>
555
+ <LocalizedField
556
+ label={`${one} no`}
557
+ value={tripNumber(item)}
558
+ onChange={(v) => setItem(i, { ...item, tripNo: v })}
559
+ />
560
+ <LocalizedField
561
+ label="Location"
562
+ value={item.location}
563
+ onChange={(v) => setItem(i, { ...item, location: v })}
564
+ />
565
+ <LocalizedField
566
+ label="Dates"
567
+ value={item.dates}
568
+ onChange={(v) => setItem(i, { ...item, dates: v })}
569
+ />
570
+ <LocalizedField
571
+ label="Accommodation"
572
+ value={item.accommodation}
573
+ onChange={(v) => setItem(i, { ...item, accommodation: v })}
574
+ />
575
+ <LocalizedField
576
+ label="Price"
577
+ value={item.price}
578
+ onChange={(v) => setItem(i, { ...item, price: v })}
579
+ />
580
+ <LocalizedField
581
+ label="Original price"
582
+ value={item.oldPrice}
583
+ onChange={(v) => setItem(i, { ...item, oldPrice: v })}
584
+ />
585
+ <LocalizedField
586
+ label="Badge"
587
+ value={item.badge}
588
+ onChange={(v) => setItem(i, { ...item, badge: v })}
589
+ />
590
+ <LocalizedField
591
+ label="Button label"
592
+ value={item.bookLabel}
593
+ onChange={(v) => setItem(i, { ...item, bookLabel: v })}
594
+ />
595
+ <TextField
596
+ label="Booking URL"
597
+ value={item.bookUrl ?? ""}
598
+ onChange={(v) => setItem(i, { ...item, bookUrl: v })}
599
+ />
600
+ <label className="flex items-center gap-2 text-sm text-ink">
601
+ <input
602
+ type="checkbox"
603
+ checked={!!item.featured}
604
+ onChange={(e) => setItem(i, { ...item, featured: e.target.checked })}
605
+ />
606
+ {`Highlight this ${one.toLowerCase()}`}
607
+ </label>
608
+ </div>
609
+ ))}
610
+ </div>
611
+ <button
612
+ onClick={() =>
613
+ update({
614
+ ...p,
615
+ items: [
616
+ ...items,
617
+ {
618
+ id: uid("t"),
619
+ tripNo: {},
620
+ location: {},
621
+ dates: {},
622
+ accommodation: {},
623
+ price: {},
624
+ },
625
+ ],
626
+ })
627
+ }
628
+ className="mt-1 inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
629
+ >
630
+ <Plus className="size-4" /> {`Add ${one.toLowerCase()}`}
631
+ </button>
632
+ </InspectorSection>
633
+ )}
634
+ </>
635
+ );
636
+ }
637
+
638
+ /* ---------------- Form ---------------- */
639
+ function FormInspector({ block, patch }: BlockPatchProps) {
640
+ const p = (block.props ?? {}) as FormBlockCopy;
641
+ const listAll = useServerFn(backofficeHost().fns.listForms);
642
+ const { data: forms } = useQuery<Array<{ key: string; name: string; is_active: boolean }>>({
643
+ queryKey: ["backoffice-forms"],
644
+ queryFn: () => listAll() as Promise<Array<{ key: string; name: string; is_active: boolean }>>,
645
+ staleTime: 30_000,
646
+ });
647
+
648
+ return (
649
+ <>
650
+ <InspectorSection title="Form">
651
+ <label className="block">
652
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Which form</span>
653
+ <select
654
+ value={p.formKey ?? ""}
655
+ onChange={(e) => patch({ ...p, formKey: e.target.value } as unknown as BlockProps)}
656
+ className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
657
+ >
658
+ <option value="">Select a form…</option>
659
+ {(forms ?? []).map((f) => (
660
+ <option key={f.key} value={f.key}>
661
+ {f.name} ({f.key}){f.is_active ? "" : " — inactive"}
662
+ </option>
663
+ ))}
664
+ </select>
665
+ </label>
666
+ <p className="text-xs text-ink/50">
667
+ Fields, notifications, spam protection and the submissions inbox are managed in{" "}
668
+ <Link to={backofficePaths().forms} className="underline">
669
+ Forms
670
+ </Link>
671
+ .
672
+ </p>
673
+ </InspectorSection>
674
+ <InspectorSection title="Content">
675
+ <LocalizedField
676
+ label="Eyebrow"
677
+ value={p.eyebrow}
678
+ onChange={(v) => patch({ ...p, eyebrow: v } as unknown as BlockProps)}
679
+ />
680
+ <LocalizedField
681
+ label="Title"
682
+ value={p.title}
683
+ onChange={(v) => patch({ ...p, title: v } as unknown as BlockProps)}
684
+ />
685
+ <LocalizedField
686
+ label="Lead"
687
+ value={p.lead}
688
+ multiline
689
+ onChange={(v) => patch({ ...p, lead: v } as unknown as BlockProps)}
690
+ />
691
+ </InspectorSection>
692
+ <InspectorSection title="Layout">
693
+ <label className="block">
694
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Tone</span>
695
+ <select
696
+ value={p.tone ?? "paper"}
697
+ onChange={(e) => patch({ ...p, tone: e.target.value } as unknown as BlockProps)}
698
+ className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
699
+ >
700
+ <option value="white">White</option>
701
+ <option value="paper">Paper</option>
702
+ <option value="ink">Ink (dark)</option>
703
+ </select>
704
+ </label>
705
+ <TextField
706
+ label="Anchor id"
707
+ value={p.anchor ?? ""}
708
+ onChange={(v) => patch({ ...p, anchor: v } as unknown as BlockProps)}
709
+ />
710
+ </InspectorSection>
711
+ </>
712
+ );
713
+ }
714
+
715
+ /* ---------------- Router ---------------- */
716
+ export function BlockInspector({
717
+ block,
718
+ patch,
719
+ globalSections,
720
+ }: BlockPatchProps & {
721
+ globalSections: { key: string; name: string }[];
722
+ }) {
723
+ switch (block.type) {
724
+ case "hero":
725
+ return <HeroInspector block={block} patch={patch} />;
726
+ case "intro":
727
+ return <IntroInspector block={block} patch={patch} />;
728
+ case "faq":
729
+ return <FaqInspector block={block} patch={patch} />;
730
+ case "instagram":
731
+ return <InstagramInspector block={block} patch={patch} />;
732
+ case "prose":
733
+ return <ProseInspector block={block} patch={patch} />;
734
+ case "richText":
735
+ return <RichTextInspector block={block} patch={patch} />;
736
+ case "checklist":
737
+ return <ChecklistInspector block={block} patch={patch} />;
738
+ case "iconGrid":
739
+ return <IconGridInspector block={block} patch={patch} />;
740
+ case "imageMarquee":
741
+ return <ImageMarqueeInspector block={block} patch={patch} />;
742
+ case "linkTiles":
743
+ return <LinkTilesInspector block={block} patch={patch} />;
744
+ case "cabinTabs":
745
+ return <CabinTabsInspector block={block} patch={patch} />;
746
+ case "priceTable":
747
+ return <PriceTableInspector block={block} patch={patch} />;
748
+ case "tripList":
749
+ case "courseList":
750
+ return <TripListInspector block={block} patch={patch} />;
751
+ case "ctaBanner":
752
+ return <CtaBannerInspector block={block} patch={patch} />;
753
+ case "mediaSplit":
754
+ return <MediaSplitInspector block={block} patch={patch} />;
755
+ case "heroFull":
756
+ return <HeroFullInspector block={block} patch={patch} />;
757
+ case "form":
758
+ return <FormInspector block={block} patch={patch} />;
759
+ case "globalRef":
760
+ return <GlobalRefInspector block={block} patch={patch} globalSections={globalSections} />;
761
+ case "gallery":
762
+ return (
763
+ <>
764
+ <SharedSectionEditor sectionKey="gallery" />
765
+ <GalleryResponsivePreview />
766
+ </>
767
+ );
768
+ case "contactCta":
769
+ // The contact CTA block renders content from BOTH shared sections:
770
+ // "cta" (title, body, CTA, form) and "contact" (eyebrow/form title,
771
+ // labels, contact copy) — expose both so every string is editable.
772
+ return (
773
+ <>
774
+ <SharedSectionEditor sectionKey="cta" />
775
+ <SharedSectionEditor sectionKey="contact" />
776
+ </>
777
+ );
778
+ case "stats":
779
+ case "features":
780
+ case "destinations":
781
+ case "testimonials":
782
+ return <SharedSectionEditor sectionKey={block.type} />;
783
+ case "trips":
784
+ case "packages":
785
+ return <SharedSectionEditor sectionKey="trips" />;
786
+ default:
787
+ return <GlobalContentNotice blockType={block.type} />;
788
+ }
789
+ }
@@ -64,6 +64,7 @@ export type BackofficeHost = {
64
64
  getContentOverrides: HostServerFn;
65
65
  saveContentSection: HostServerFn;
66
66
  // forms
67
+ listForms: HostServerFn;
67
68
  getFormDefaults: HostServerFn;
68
69
  saveFormDefaults: HostServerFn;
69
70
  purgeOldSubmissions: HostServerFn;
@@ -96,6 +97,10 @@ export type BackofficeHost = {
96
97
  defaults: Record<string, unknown>;
97
98
  /** Code default for a single shared section. */
98
99
  defaultForSection: (key: string) => unknown;
100
+ /** Merges code defaults into a stored override so newer fields still render. */
101
+ withSectionDefaults: (stored: unknown, defaults: unknown) => unknown;
102
+ /** Keys of the shared sections a site exposes in its sections editor. */
103
+ editableSectionKeys: string[];
99
104
  };
100
105
  /** Editor components a site wires with its own locales, media and forms. */
101
106
  components: {
@@ -106,6 +111,11 @@ export type BackofficeHost = {
106
111
  /** Review portrait uploader. */
107
112
  ReviewImageUpload: ReviewImageUploadComponent;
108
113
  };
114
+ /** Site wording for domain concepts the shared UI names. */
115
+ labels?: {
116
+ /** Singular/plural noun for the trips module (e.g. Cruise/Cruises). */
117
+ trips?: { singular: string; plural: string };
118
+ };
109
119
  /** Backup screen adapters — auth headers plus browser media archiving. */
110
120
  backup?: {
111
121
  authHeaders: () => Promise<Record<string, string>>;
@@ -152,5 +162,7 @@ export function backofficePaths(host: BackofficeHost = backofficeHost()) {
152
162
  navigation: `${base}/admin/navigation`,
153
163
  version: `${base}/admin/version`,
154
164
  backup: `${base}/admin/backup`,
165
+ sections: `${base}/sections`,
166
+ globalSections: `${base}/global-sections`,
155
167
  };
156
168
  }