@bygghammar/cms-platform 0.1.0

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 (36) hide show
  1. package/README.md +36 -0
  2. package/package.json +39 -0
  3. package/src/backoffice/inspectors/blocks.tsx +2318 -0
  4. package/src/backoffice/inspectors/fields.tsx +436 -0
  5. package/src/blocks/block-props-context.tsx +36 -0
  6. package/src/blocks/components/CabinTabsBlock.tsx +167 -0
  7. package/src/blocks/components/ChecklistBlock.tsx +68 -0
  8. package/src/blocks/components/CtaBannerBlock.tsx +65 -0
  9. package/src/blocks/components/FaqBlock.tsx +62 -0
  10. package/src/blocks/components/HeroFullBlock.tsx +201 -0
  11. package/src/blocks/components/IconGridBlock.tsx +125 -0
  12. package/src/blocks/components/ImageMarqueeBlock.tsx +209 -0
  13. package/src/blocks/components/InstagramBlock.tsx +160 -0
  14. package/src/blocks/components/LinkTilesBlock.tsx +182 -0
  15. package/src/blocks/components/MediaSplitBlock.tsx +427 -0
  16. package/src/blocks/components/PriceTableBlock.tsx +166 -0
  17. package/src/blocks/components/ProseBlock.tsx +77 -0
  18. package/src/blocks/components/RichTextBlock.tsx +79 -0
  19. package/src/blocks/icons.ts +63 -0
  20. package/src/blocks/index.ts +13 -0
  21. package/src/blocks/registry.tsx +322 -0
  22. package/src/blocks/types.ts +470 -0
  23. package/src/host/index.tsx +134 -0
  24. package/src/html/sanitize-html.ts +175 -0
  25. package/src/i18n/context.tsx +44 -0
  26. package/src/i18n/index.ts +6 -0
  27. package/src/i18n/locales.ts +24 -0
  28. package/src/i18n/resolve.ts +82 -0
  29. package/src/i18n/translations-registry.ts +26 -0
  30. package/src/i18n/types.ts +22 -0
  31. package/src/i18n/video.ts +36 -0
  32. package/src/index.ts +5 -0
  33. package/src/theme.css +53 -0
  34. package/src/ui/Lightbox.tsx +69 -0
  35. package/src/ui/Reveal.tsx +47 -0
  36. package/src/util/scroll-lock.ts +32 -0
@@ -0,0 +1,2318 @@
1
+ /**
2
+ * Shared block inspectors (backoffice). Portable across sites: they depend
3
+ * only on block types, shared field widgets, the i18n core and the host
4
+ * RichTextEditor. Site-specific inspectors (global sections, forms, courses)
5
+ * stay in each site's own inspector router.
6
+ */
7
+ import { useEffect, useMemo, useState } from "react";
8
+ import type {
9
+ BlockInstance,
10
+ BlockProps,
11
+ HeroProps,
12
+ IntroProps,
13
+ FaqProps,
14
+ FaqItem,
15
+ CtaProps,
16
+ InstagramProps,
17
+ InstagramItem,
18
+ ProseProps,
19
+ ProseItem,
20
+ RichTextProps,
21
+ ChecklistProps,
22
+ ChecklistColumn,
23
+ CtaBannerProps,
24
+ MediaSplitProps,
25
+ MediaSplitSlide,
26
+ MediaSplitBullet,
27
+ IconGridProps,
28
+ IconGridItem,
29
+ HeroFullProps,
30
+ ImageMarqueeProps,
31
+ ImageMarqueeItem,
32
+ LinkTilesProps,
33
+ LinkTilesItem,
34
+ CabinTabsProps,
35
+ CabinTabItem,
36
+ PriceTableProps,
37
+ PriceGroup,
38
+ IntroImageAspect,
39
+ } from "../../blocks/types";
40
+ import { INTRO_IMAGE_ASPECTS } from "../../blocks/types";
41
+ import { BLOCK_ICON_NAMES, resolveBlockIcon } from "../../blocks/icons";
42
+ import { InspectorSection, LocalizedField, TextField, MediaField, MediaMultiField } from "./fields";
43
+ import { RichTextEditor } from "../../host";
44
+ import { Plus, Trash2, ArrowUp, ArrowDown } from "lucide-react";
45
+ import type { LocalizedString, Locale } from "../../i18n";
46
+ import { SUPPORTED_LOCALES } from "../../i18n";
47
+
48
+ export type BlockPatchProps = {
49
+ block: BlockInstance;
50
+ patch: (props: BlockProps) => void;
51
+ };
52
+
53
+ function uid(prefix = "f") {
54
+ return `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
55
+ }
56
+ /* ---------------- Hero ---------------- */
57
+ function HeroInspector({ block, patch }: BlockPatchProps) {
58
+ const p = (block.props ?? {}) as HeroProps;
59
+ const setCta = (key: "primaryCta" | "secondaryCta", next: CtaProps) =>
60
+ patch({ ...p, [key]: next } as unknown as BlockProps);
61
+
62
+ return (
63
+ <>
64
+ <InspectorSection title="Content">
65
+ <LocalizedField
66
+ label="Eyebrow"
67
+ value={p.eyebrow}
68
+ onChange={(v) => patch({ ...p, eyebrow: v } as unknown as BlockProps)}
69
+ />
70
+ <LocalizedField
71
+ label="Title"
72
+ value={p.title}
73
+ multiline
74
+ onChange={(v) => patch({ ...p, title: v } as unknown as BlockProps)}
75
+ />
76
+ <LocalizedField
77
+ label="Body"
78
+ value={p.body}
79
+ multiline
80
+ onChange={(v) => patch({ ...p, body: v } as unknown as BlockProps)}
81
+ />
82
+ </InspectorSection>
83
+ <InspectorSection title="Primary CTA">
84
+ <LocalizedField
85
+ label="Label"
86
+ value={p.primaryCta?.label}
87
+ onChange={(v) => setCta("primaryCta", { ...(p.primaryCta ?? {}), label: v })}
88
+ />
89
+ <TextField
90
+ label="Link"
91
+ value={p.primaryCta?.href}
92
+ placeholder="/boka"
93
+ onChange={(v) => setCta("primaryCta", { ...(p.primaryCta ?? {}), href: v })}
94
+ />
95
+ </InspectorSection>
96
+ <InspectorSection title="Secondary CTA">
97
+ <LocalizedField
98
+ label="Label"
99
+ value={p.secondaryCta?.label}
100
+ onChange={(v) => setCta("secondaryCta", { ...(p.secondaryCta ?? {}), label: v })}
101
+ />
102
+ <TextField
103
+ label="Link"
104
+ value={p.secondaryCta?.href}
105
+ placeholder="/utbildningen"
106
+ onChange={(v) => setCta("secondaryCta", { ...(p.secondaryCta ?? {}), href: v })}
107
+ />
108
+ </InspectorSection>
109
+ <InspectorSection title="Background media — Desktop">
110
+ <MediaField
111
+ label="Video"
112
+ accept="video"
113
+ value={p.videoSrc}
114
+ onChange={(v) => patch({ ...p, videoSrc: v } as unknown as BlockProps)}
115
+ />
116
+ <MediaField
117
+ label="Poster image"
118
+ accept="image"
119
+ value={p.posterSrc}
120
+ onChange={(v) => patch({ ...p, posterSrc: v } as unknown as BlockProps)}
121
+ />
122
+ </InspectorSection>
123
+ <InspectorSection title="Background media — Tablet (≤1024px)">
124
+ <p className="text-[0.7rem] text-ink/50">Leave empty to reuse the desktop media.</p>
125
+ <MediaField
126
+ label="Video"
127
+ accept="video"
128
+ value={p.videoSrcTablet}
129
+ onChange={(v) => patch({ ...p, videoSrcTablet: v } as unknown as BlockProps)}
130
+ />
131
+ <MediaField
132
+ label="Poster image"
133
+ accept="image"
134
+ value={p.posterSrcTablet}
135
+ onChange={(v) => patch({ ...p, posterSrcTablet: v } as unknown as BlockProps)}
136
+ />
137
+ </InspectorSection>
138
+ <InspectorSection title="Background media — Mobile (≤640px)">
139
+ <p className="text-[0.7rem] text-ink/50">Leave empty to reuse tablet, then desktop.</p>
140
+ <MediaField
141
+ label="Video"
142
+ accept="video"
143
+ value={p.videoSrcMobile}
144
+ onChange={(v) => patch({ ...p, videoSrcMobile: v } as unknown as BlockProps)}
145
+ />
146
+ <MediaField
147
+ label="Poster image"
148
+ accept="image"
149
+ value={p.posterSrcMobile}
150
+ onChange={(v) => patch({ ...p, posterSrcMobile: v } as unknown as BlockProps)}
151
+ />
152
+ </InspectorSection>
153
+ <p className="text-[0.7rem] text-ink/50">
154
+ Leaving a field empty falls back to the shared site content.
155
+ </p>
156
+ </>
157
+ );
158
+ }
159
+
160
+ /* ---------------- Intro ---------------- */
161
+ function IntroInspector({ block, patch }: BlockPatchProps) {
162
+ const p = (block.props ?? {}) as IntroProps;
163
+ return (
164
+ <InspectorSection title="Content">
165
+ <LocalizedField
166
+ label="Eyebrow"
167
+ value={p.eyebrow}
168
+ onChange={(v) => patch({ ...p, eyebrow: v } as unknown as BlockProps)}
169
+ />
170
+ <LocalizedField
171
+ label="Title"
172
+ value={p.title}
173
+ multiline
174
+ onChange={(v) => patch({ ...p, title: v } as unknown as BlockProps)}
175
+ />
176
+ <LocalizedField
177
+ label="Body"
178
+ value={p.body}
179
+ multiline
180
+ onChange={(v) => patch({ ...p, body: v } as unknown as BlockProps)}
181
+ />
182
+ <MediaField
183
+ label="Image"
184
+ accept="image"
185
+ value={p.imageSrc}
186
+ onChange={(v) => patch({ ...p, imageSrc: v } as unknown as BlockProps)}
187
+ />
188
+ <LocalizedField
189
+ label="Image alt text"
190
+ value={p.imageAlt}
191
+ onChange={(v) => patch({ ...p, imageAlt: v } as unknown as BlockProps)}
192
+ />
193
+ <label className="block space-y-1">
194
+ <span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
195
+ Image shape (width / height)
196
+ </span>
197
+ <select
198
+ value={p.imageAspect ?? "4/5"}
199
+ onChange={(e) =>
200
+ patch({ ...p, imageAspect: e.target.value as IntroImageAspect } as unknown as BlockProps)
201
+ }
202
+ 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"
203
+ >
204
+ {INTRO_IMAGE_ASPECTS.map((o) => (
205
+ <option key={o} value={o}>
206
+ {o === "auto" ? "Original ratio" : o}
207
+ </option>
208
+ ))}
209
+ </select>
210
+ </label>
211
+ <label className="block space-y-1">
212
+ <span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
213
+ Image width ({Math.min(100, Math.max(30, p.imageWidth ?? 100))}%)
214
+ </span>
215
+ <input
216
+ type="range"
217
+ min={30}
218
+ max={100}
219
+ step={5}
220
+ value={Math.min(100, Math.max(30, p.imageWidth ?? 100))}
221
+ onChange={(e) =>
222
+ patch({ ...p, imageWidth: Number(e.target.value) } as unknown as BlockProps)
223
+ }
224
+ className="w-full accent-sea"
225
+ />
226
+ </label>
227
+ <p className="text-[0.7rem] text-ink/50">
228
+ Empty fields fall back to shared site content.
229
+ </p>
230
+
231
+ </InspectorSection>
232
+ );
233
+ }
234
+
235
+ /* ---------------- FAQ ---------------- */
236
+ function FaqInspector({ block, patch }: BlockPatchProps) {
237
+ const p = (block.props ?? { items: [] }) as FaqProps;
238
+ const items = p.items ?? [];
239
+ const update = (next: FaqProps) => patch(next as unknown as BlockProps);
240
+ const setItem = (i: number, item: FaqItem) => {
241
+ const next = items.slice();
242
+ next[i] = item;
243
+ update({ ...p, items: next });
244
+ };
245
+ const move = (i: number, dir: -1 | 1) => {
246
+ const j = i + dir;
247
+ if (j < 0 || j >= items.length) return;
248
+ const next = items.slice();
249
+ [next[i], next[j]] = [next[j], next[i]];
250
+ update({ ...p, items: next });
251
+ };
252
+ const add = () =>
253
+ update({
254
+ ...p,
255
+ items: [
256
+ ...items,
257
+ { id: uid("q"), q: { sv: "" } as LocalizedString, a: { sv: "" } as LocalizedString },
258
+ ],
259
+ });
260
+ const remove = (i: number) => {
261
+ const next = items.slice();
262
+ next.splice(i, 1);
263
+ update({ ...p, items: next });
264
+ };
265
+
266
+ return (
267
+ <>
268
+ <InspectorSection title="Heading">
269
+ <LocalizedField
270
+ label="Title"
271
+ value={p.title}
272
+ onChange={(v) => update({ ...p, title: v as LocalizedString })}
273
+ />
274
+ <label className="block space-y-1.5">
275
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
276
+ Columns (desktop)
277
+ </span>
278
+ <select
279
+ value={String(p.columns ?? 1)}
280
+ onChange={(e) =>
281
+ update({ ...p, columns: Number(e.target.value) as FaqProps["columns"] })
282
+ }
283
+ 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"
284
+ >
285
+ <option value="1">1 column</option>
286
+ <option value="2">2 columns</option>
287
+ <option value="3">3 columns</option>
288
+ </select>
289
+ </label>
290
+ </InspectorSection>
291
+ <InspectorSection title={`Items (${items.length})`}>
292
+ <div className="space-y-4">
293
+ {items.map((item, i) => (
294
+ <div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3">
295
+ <div className="flex items-center justify-between">
296
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/50">
297
+ #{i + 1}
298
+ </span>
299
+ <div className="flex items-center gap-1">
300
+ <button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
301
+ <ArrowUp className="size-3.5" />
302
+ </button>
303
+ <button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
304
+ <ArrowDown className="size-3.5" />
305
+ </button>
306
+ <button onClick={() => remove(i)} className="p-1 text-red-500 hover:text-red-700" title="Delete">
307
+ <Trash2 className="size-3.5" />
308
+ </button>
309
+ </div>
310
+ </div>
311
+ <LocalizedField
312
+ label="Question"
313
+ value={item.q}
314
+ onChange={(v) => setItem(i, { ...item, q: v as LocalizedString })}
315
+ />
316
+ <LocalizedField
317
+ label="Answer"
318
+ multiline
319
+ value={item.a}
320
+ onChange={(v) => setItem(i, { ...item, a: v as LocalizedString })}
321
+ />
322
+ </div>
323
+ ))}
324
+ </div>
325
+ <button
326
+ onClick={add}
327
+ 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"
328
+ >
329
+ <Plus className="size-4" /> Add question
330
+ </button>
331
+ </InspectorSection>
332
+ </>
333
+ );
334
+ }
335
+
336
+ /* ---------------- Instagram ---------------- */
337
+ function InstagramInspector({ block, patch }: BlockPatchProps) {
338
+ const p = (block.props ?? { items: [] }) as InstagramProps;
339
+ const items = p.items ?? [];
340
+ const update = (next: InstagramProps) => patch(next as unknown as BlockProps);
341
+ const setItem = (i: number, item: InstagramItem) => {
342
+ const next = items.slice();
343
+ next[i] = item;
344
+ update({ ...p, items: next });
345
+ };
346
+ const move = (i: number, dir: -1 | 1) => {
347
+ const j = i + dir;
348
+ if (j < 0 || j >= items.length) return;
349
+ const next = items.slice();
350
+ [next[i], next[j]] = [next[j], next[i]];
351
+ update({ ...p, items: next });
352
+ };
353
+ const add = () =>
354
+ update({
355
+ ...p,
356
+ items: [...items, { id: uid("ig"), image: "", alt: { sv: "" }, href: "" }],
357
+ });
358
+ const remove = (i: number) => {
359
+ const next = items.slice();
360
+ next.splice(i, 1);
361
+ update({ ...p, items: next });
362
+ };
363
+
364
+ const NumberField = ({
365
+ label,
366
+ value,
367
+ min,
368
+ max,
369
+ fallback,
370
+ onChange,
371
+ }: {
372
+ label: string;
373
+ value: number | undefined;
374
+ min: number;
375
+ max: number;
376
+ fallback: number;
377
+ onChange: (n: number) => void;
378
+ }) => (
379
+ <label className="block space-y-1">
380
+ <span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
381
+ <input
382
+ type="number"
383
+ min={min}
384
+ max={max}
385
+ value={value ?? fallback}
386
+ onChange={(e) => onChange(Number(e.target.value))}
387
+ 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"
388
+ />
389
+ </label>
390
+ );
391
+
392
+ const SelectField = <T extends string>({
393
+ label,
394
+ value,
395
+ options,
396
+ onChange,
397
+ }: {
398
+ label: string;
399
+ value: T | undefined;
400
+ options: readonly T[];
401
+ onChange: (v: T) => void;
402
+ }) => (
403
+ <label className="block space-y-1">
404
+ <span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
405
+ <select
406
+ value={value ?? options[0]}
407
+ onChange={(e) => onChange(e.target.value as T)}
408
+ 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"
409
+ >
410
+ {options.map((o) => (
411
+ <option key={o} value={o}>
412
+ {o}
413
+ </option>
414
+ ))}
415
+ </select>
416
+ </label>
417
+ );
418
+
419
+ return (
420
+ <>
421
+ <InspectorSection title="Header">
422
+ <LocalizedField
423
+ label="Title"
424
+ value={p.title}
425
+ onChange={(v) => update({ ...p, title: v })}
426
+ />
427
+ <TextField
428
+ label="Instagram handle"
429
+ value={p.handle}
430
+ placeholder="yourhandle"
431
+ onChange={(v) => update({ ...p, handle: v })}
432
+ />
433
+ <TextField
434
+ label="Profile link (optional)"
435
+ value={p.profileHref}
436
+ placeholder="https://instagram.com/..."
437
+ onChange={(v) => update({ ...p, profileHref: v })}
438
+ />
439
+ <div className="flex gap-4 pt-1">
440
+ <label className="flex items-center gap-2 text-sm text-ink/75">
441
+ <input
442
+ type="checkbox"
443
+ checked={p.showHeader ?? true}
444
+ onChange={(e) => update({ ...p, showHeader: e.target.checked })}
445
+ />
446
+ Show header
447
+ </label>
448
+ <label className="flex items-center gap-2 text-sm text-ink/75">
449
+ <input
450
+ type="checkbox"
451
+ checked={p.showOverlay ?? true}
452
+ onChange={(e) => update({ ...p, showOverlay: e.target.checked })}
453
+ />
454
+ Hover overlay
455
+ </label>
456
+ </div>
457
+ </InspectorSection>
458
+
459
+ <InspectorSection title="Layout">
460
+ <SelectField
461
+ label="Layout"
462
+ value={p.layout}
463
+ options={["grid", "carousel"] as const}
464
+ onChange={(v) => update({ ...p, layout: v })}
465
+ />
466
+ <div className="grid grid-cols-3 gap-2">
467
+ <NumberField
468
+ label="Cols mobile"
469
+ value={p.columnsMobile}
470
+ min={1}
471
+ max={4}
472
+ fallback={2}
473
+ onChange={(n) => update({ ...p, columnsMobile: n })}
474
+ />
475
+ <NumberField
476
+ label="Cols tablet"
477
+ value={p.columnsTablet}
478
+ min={2}
479
+ max={6}
480
+ fallback={3}
481
+ onChange={(n) => update({ ...p, columnsTablet: n })}
482
+ />
483
+ <NumberField
484
+ label="Cols desktop"
485
+ value={p.columnsDesktop}
486
+ min={2}
487
+ max={8}
488
+ fallback={4}
489
+ onChange={(n) => update({ ...p, columnsDesktop: n })}
490
+ />
491
+ </div>
492
+ <NumberField
493
+ label="Rows (grid only)"
494
+ value={p.rows}
495
+ min={1}
496
+ max={8}
497
+ fallback={2}
498
+ onChange={(n) => update({ ...p, rows: n })}
499
+ />
500
+ <div className="grid grid-cols-2 gap-2">
501
+ <SelectField
502
+ label="Gap"
503
+ value={p.gap}
504
+ options={["none", "sm", "md", "lg"] as const}
505
+ onChange={(v) => update({ ...p, gap: v })}
506
+ />
507
+ <SelectField
508
+ label="Aspect"
509
+ value={p.aspect}
510
+ options={["square", "portrait", "landscape"] as const}
511
+ onChange={(v) => update({ ...p, aspect: v })}
512
+ />
513
+ </div>
514
+ <p className="text-[0.7rem] text-ink/50">
515
+ Grid shows up to <strong>cols × rows</strong> images. Carousel shows all.
516
+ </p>
517
+ </InspectorSection>
518
+
519
+ <InspectorSection title={`Images (${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 onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
529
+ <ArrowUp className="size-3.5" />
530
+ </button>
531
+ <button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
532
+ <ArrowDown className="size-3.5" />
533
+ </button>
534
+ <button onClick={() => remove(i)} className="p-1 text-red-500 hover:text-red-700" title="Delete">
535
+ <Trash2 className="size-3.5" />
536
+ </button>
537
+ </div>
538
+ </div>
539
+ <MediaField
540
+ label="Image"
541
+ accept="image"
542
+ value={item.image}
543
+ onChange={(v) => setItem(i, { ...item, image: v })}
544
+ />
545
+ <LocalizedField
546
+ label="Alt text"
547
+ value={item.alt}
548
+ onChange={(v) => setItem(i, { ...item, alt: v })}
549
+ />
550
+ <TextField
551
+ label="Post URL (optional)"
552
+ value={item.href}
553
+ placeholder="https://instagram.com/p/..."
554
+ onChange={(v) => setItem(i, { ...item, href: v })}
555
+ />
556
+ </div>
557
+ ))}
558
+ </div>
559
+ <button
560
+ onClick={add}
561
+ className="mt-3 inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm hover:border-sea"
562
+ >
563
+ <Plus className="size-3.5" /> Add image
564
+ </button>
565
+ </InspectorSection>
566
+ </>
567
+ );
568
+ }
569
+
570
+ /* ---------------- Prose (text section) ---------------- */
571
+ function ProseInspector({ block, patch }: BlockPatchProps) {
572
+ const p = (block.props ?? {}) as ProseProps;
573
+ const items = p.items ?? [];
574
+ const update = (next: ProseProps) => patch(next as unknown as BlockProps);
575
+ const setItem = (i: number, item: ProseItem) => {
576
+ const next = items.slice();
577
+ next[i] = item;
578
+ update({ ...p, items: next });
579
+ };
580
+ const move = (i: number, dir: -1 | 1) => {
581
+ const j = i + dir;
582
+ if (j < 0 || j >= items.length) return;
583
+ const next = items.slice();
584
+ [next[i], next[j]] = [next[j], next[i]];
585
+ update({ ...p, items: next });
586
+ };
587
+
588
+ return (
589
+ <>
590
+ <InspectorSection title="Heading">
591
+ <LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
592
+ <LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
593
+ <LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
594
+ </InspectorSection>
595
+ <InspectorSection title="Layout">
596
+ <label className="block space-y-1.5">
597
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Columns</span>
598
+ <select
599
+ value={p.layout ?? "single"}
600
+ onChange={(e) => update({ ...p, layout: e.target.value as ProseProps["layout"] })}
601
+ 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"
602
+ >
603
+ <option value="single">Single column</option>
604
+ <option value="two-col">Two columns</option>
605
+ </select>
606
+ </label>
607
+ <label className="block space-y-1.5">
608
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Background</span>
609
+ <select
610
+ value={p.tone ?? "white"}
611
+ onChange={(e) => update({ ...p, tone: e.target.value as ProseProps["tone"] })}
612
+ 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"
613
+ >
614
+ <option value="white">White</option>
615
+ <option value="paper">Paper</option>
616
+ </select>
617
+ </label>
618
+ </InspectorSection>
619
+ <InspectorSection title={`Passages (${items.length})`}>
620
+ <div className="space-y-4">
621
+ {items.map((item, i) => (
622
+ <div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3">
623
+ <div className="flex items-center justify-between">
624
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
625
+ <div className="flex items-center gap-1">
626
+ <button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
627
+ <ArrowUp className="size-3.5" />
628
+ </button>
629
+ <button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
630
+ <ArrowDown className="size-3.5" />
631
+ </button>
632
+ <button
633
+ onClick={() => {
634
+ const next = items.slice();
635
+ next.splice(i, 1);
636
+ update({ ...p, items: next });
637
+ }}
638
+ className="p-1 text-red-500 hover:text-red-700"
639
+ title="Delete"
640
+ >
641
+ <Trash2 className="size-3.5" />
642
+ </button>
643
+ </div>
644
+ </div>
645
+ <LocalizedField label="Heading" value={item.heading} onChange={(v) => setItem(i, { ...item, heading: v })} />
646
+ <LocalizedField label="Body" multiline value={item.body} onChange={(v) => setItem(i, { ...item, body: v })} />
647
+ </div>
648
+ ))}
649
+ </div>
650
+ <button
651
+ onClick={() => update({ ...p, items: [...items, { id: uid("p"), heading: {}, body: {} }] })}
652
+ 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"
653
+ >
654
+ <Plus className="size-4" /> Add passage
655
+ </button>
656
+ </InspectorSection>
657
+ </>
658
+ );
659
+ }
660
+
661
+
662
+ /* ---------------- Rich text (WYSIWYG) ---------------- */
663
+ function RichTextInspector({ block, patch }: BlockPatchProps) {
664
+ const p = (block.props ?? {}) as RichTextProps;
665
+ const update = (next: RichTextProps) => patch(next as unknown as BlockProps);
666
+ const [loc, setLoc] = useState<Locale>(SUPPORTED_LOCALES[0]);
667
+ const html = ((p.html ?? {}) as Record<string, string>)[loc] ?? "";
668
+
669
+ const select = <T extends string>(
670
+ label: string,
671
+ value: T,
672
+ options: { value: T; label: string }[],
673
+ onChange: (v: T) => void,
674
+ ) => (
675
+ <label className="block space-y-1.5">
676
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
677
+ <select
678
+ value={value}
679
+ onChange={(e) => onChange(e.target.value as T)}
680
+ 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"
681
+ >
682
+ {options.map((o) => (
683
+ <option key={o.value} value={o.value}>
684
+ {o.label}
685
+ </option>
686
+ ))}
687
+ </select>
688
+ </label>
689
+ );
690
+
691
+ return (
692
+ <>
693
+ <InspectorSection title="Heading (optional)">
694
+ <LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
695
+ <LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
696
+ </InspectorSection>
697
+
698
+ <InspectorSection title="Text">
699
+ <div className="flex gap-1">
700
+ {SUPPORTED_LOCALES.map((l) => (
701
+ <button
702
+ key={l}
703
+ type="button"
704
+ onClick={() => setLoc(l)}
705
+ className={[
706
+ "border px-2.5 py-1 text-[0.65rem] uppercase tracking-widest",
707
+ l === loc ? "border-sea bg-sea/10 text-ink" : "border-ink/15 text-ink/60 hover:text-ink",
708
+ ].join(" ")}
709
+ >
710
+ {l}
711
+ </button>
712
+ ))}
713
+ </div>
714
+ <RichTextEditor
715
+ key={loc}
716
+ value={html}
717
+ onChange={(next) =>
718
+ update({
719
+ ...p,
720
+ html: { ...((p.html ?? {}) as Record<string, string>), [loc]: next } as RichTextProps["html"],
721
+ })
722
+ }
723
+ />
724
+ </InspectorSection>
725
+
726
+ <InspectorSection title="Layout">
727
+ {select("Background", p.tone ?? "white", [
728
+ { value: "white", label: "White" },
729
+ { value: "paper", label: "Paper" },
730
+ { value: "ice", label: "Ice" },
731
+ { value: "ink", label: "Dark" },
732
+ ] as { value: NonNullable<RichTextProps["tone"]>; label: string }[], (v) => update({ ...p, tone: v }))}
733
+ {select("Width", p.width ?? "normal", [
734
+ { value: "narrow", label: "Narrow" },
735
+ { value: "normal", label: "Normal" },
736
+ { value: "wide", label: "Wide" },
737
+ { value: "full", label: "Full width" },
738
+ ] as { value: NonNullable<RichTextProps["width"]>; label: string }[], (v) => update({ ...p, width: v }))}
739
+ {select("Alignment", p.align ?? "left", [
740
+ { value: "left", label: "Left" },
741
+ { value: "center", label: "Centered" },
742
+ ] as { value: NonNullable<RichTextProps["align"]>; label: string }[], (v) => update({ ...p, align: v }))}
743
+ {select("Spacing", p.padding ?? "normal", [
744
+ { value: "none", label: "None" },
745
+ { value: "compact", label: "Compact" },
746
+ { value: "normal", label: "Normal" },
747
+ { value: "spacious", label: "Spacious" },
748
+ ] as { value: NonNullable<RichTextProps["padding"]>; label: string }[], (v) => update({ ...p, padding: v }))}
749
+ {select("Columns", String(p.columns ?? 1), [
750
+ { value: "1", label: "One column" },
751
+ { value: "2", label: "Two columns (desktop)" },
752
+ ], (v) => update({ ...p, columns: Number(v) as 1 | 2 }))}
753
+ <label className="flex items-center gap-2 text-sm text-ink">
754
+ <input
755
+ type="checkbox"
756
+ checked={Boolean(p.dropCap)}
757
+ onChange={(e) => update({ ...p, dropCap: e.target.checked })}
758
+ className="size-4 accent-sea"
759
+ />
760
+ Drop cap on the first paragraph
761
+ </label>
762
+ <TextField
763
+ label="Anchor id"
764
+ value={p.anchor}
765
+ onChange={(v) => update({ ...p, anchor: v })}
766
+ placeholder="e.g. villkor"
767
+ />
768
+ </InspectorSection>
769
+ </>
770
+ );
771
+ }
772
+
773
+ /* ---------------- Hero Section Fullscreen ---------------- */
774
+ function HeroFullInspector({ block, patch }: BlockPatchProps) {
775
+ const p = (block.props ?? {}) as HeroFullProps;
776
+ const update = (next: HeroFullProps) => patch(next as unknown as BlockProps);
777
+ const sel = <K extends keyof HeroFullProps>(
778
+ label: string,
779
+ key: K,
780
+ current: string,
781
+ options: { value: string; label: string }[],
782
+ ) => (
783
+ <label className="block space-y-1.5">
784
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
785
+ <select
786
+ value={current}
787
+ onChange={(e) => update({ ...p, [key]: e.target.value } as HeroFullProps)}
788
+ 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"
789
+ >
790
+ {options.map((o) => (
791
+ <option key={o.value} value={o.value}>{o.label}</option>
792
+ ))}
793
+ </select>
794
+ </label>
795
+ );
796
+ const check = (label: string, key: keyof HeroFullProps, fallback = false) => (
797
+ <label className="flex items-center gap-2 text-sm text-ink">
798
+ <input
799
+ type="checkbox"
800
+ checked={p[key] === undefined ? fallback : Boolean(p[key])}
801
+ onChange={(e) => update({ ...p, [key]: e.target.checked } as HeroFullProps)}
802
+ className="size-4 accent-sea"
803
+ />
804
+ {label}
805
+ </label>
806
+ );
807
+
808
+ const mediaType = p.mediaType ?? "image";
809
+
810
+ return (
811
+ <>
812
+ <InspectorSection title="Text">
813
+ <LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
814
+ <LocalizedField label="Title" multiline value={p.title} onChange={(v) => update({ ...p, title: v })} />
815
+ <LocalizedField label="Body" multiline value={p.body} onChange={(v) => update({ ...p, body: v })} />
816
+ </InspectorSection>
817
+
818
+ <InspectorSection title="Buttons">
819
+ <LocalizedField
820
+ label="Primary label"
821
+ value={p.primaryCta?.label}
822
+ onChange={(v) => update({ ...p, primaryCta: { ...p.primaryCta, label: v } })}
823
+ />
824
+ <TextField
825
+ label="Primary link"
826
+ value={p.primaryCta?.href}
827
+ onChange={(v) => update({ ...p, primaryCta: { ...p.primaryCta, href: v } })}
828
+ placeholder="/boka"
829
+ />
830
+ <LocalizedField
831
+ label="Secondary label"
832
+ value={p.secondaryCta?.label}
833
+ onChange={(v) => update({ ...p, secondaryCta: { ...p.secondaryCta, label: v } })}
834
+ />
835
+ <TextField
836
+ label="Secondary link"
837
+ value={p.secondaryCta?.href}
838
+ onChange={(v) => update({ ...p, secondaryCta: { ...p.secondaryCta, href: v } })}
839
+ />
840
+ </InspectorSection>
841
+
842
+ <InspectorSection title="Background media">
843
+ {sel("Media type", "mediaType", mediaType, [
844
+ { value: "image", label: "Image" },
845
+ { value: "video", label: "Video" },
846
+ ])}
847
+ {mediaType === "image" ? (
848
+ <>
849
+ <MediaField
850
+ label="Image (desktop)"
851
+ accept="image"
852
+ value={p.image}
853
+ onChange={(v) => update({ ...p, image: v })}
854
+ />
855
+ <MediaField
856
+ label="Image (mobile, optional)"
857
+ accept="image"
858
+ value={p.imageMobile}
859
+ onChange={(v) => update({ ...p, imageMobile: v })}
860
+ />
861
+ <LocalizedField label="Image alt text" value={p.imageAlt} onChange={(v) => update({ ...p, imageAlt: v })} />
862
+ </>
863
+ ) : (
864
+ <>
865
+ <MediaField
866
+ label="Video (desktop)"
867
+ accept="video"
868
+ value={p.videoSrc}
869
+ onChange={(v) => update({ ...p, videoSrc: v })}
870
+ />
871
+ <MediaField
872
+ label="Poster (desktop)"
873
+ accept="image"
874
+ value={p.posterSrc}
875
+ onChange={(v) => update({ ...p, posterSrc: v })}
876
+ />
877
+ <MediaField
878
+ label="Video WebM (desktop, optional)"
879
+ accept="video"
880
+ value={p.videoSrcWebm}
881
+ onChange={(v) => update({ ...p, videoSrcWebm: v })}
882
+ />
883
+
884
+ <MediaField
885
+ label="Video (mobile, optional)"
886
+ accept="video"
887
+ value={p.videoSrcMobile}
888
+ onChange={(v) => update({ ...p, videoSrcMobile: v })}
889
+ />
890
+ <MediaField
891
+ label="Video WebM (mobile, optional)"
892
+ accept="video"
893
+ value={p.videoSrcWebmMobile}
894
+ onChange={(v) => update({ ...p, videoSrcWebmMobile: v })}
895
+ />
896
+
897
+ <MediaField
898
+ label="Poster (mobile, optional)"
899
+ accept="image"
900
+ value={p.posterSrcMobile}
901
+ onChange={(v) => update({ ...p, posterSrcMobile: v })}
902
+ />
903
+ {check("Autoplay", "videoAutoplay", true)}
904
+ {check("Loop", "videoLoop", true)}
905
+ {check("Muted", "videoMuted", true)}
906
+ </>
907
+ )}
908
+ </InspectorSection>
909
+
910
+ <InspectorSection title="Layout">
911
+ {sel("Height", "height", p.height ?? "full", [
912
+ { value: "full", label: "Fullscreen (100%)" },
913
+ { value: "tall", label: "Tall (85%)" },
914
+ { value: "medium", label: "Medium (65%)" },
915
+ { value: "compact", label: "Compact (50%)" },
916
+ ])}
917
+ {sel("Overlay", "overlay", p.overlay ?? "medium", [
918
+ { value: "none", label: "None" },
919
+ { value: "light", label: "Light" },
920
+ { value: "medium", label: "Medium" },
921
+ { value: "strong", label: "Strong" },
922
+ { value: "bottom", label: "Gradient from bottom" },
923
+ { value: "top", label: "Gradient from top" },
924
+ { value: "side", label: "Gradient from left" },
925
+ ])}
926
+ {sel("Horizontal alignment", "textAlign", p.textAlign ?? "left", [
927
+ { value: "left", label: "Left" },
928
+ { value: "center", label: "Centered" },
929
+ { value: "right", label: "Right" },
930
+ ])}
931
+ {sel("Vertical alignment", "verticalAlign", p.verticalAlign ?? "center", [
932
+ { value: "top", label: "Top" },
933
+ { value: "center", label: "Middle" },
934
+ { value: "bottom", label: "Bottom" },
935
+ ])}
936
+ {sel("Content width", "contentWidth", p.contentWidth ?? "normal", [
937
+ { value: "narrow", label: "Narrow" },
938
+ { value: "normal", label: "Normal" },
939
+ { value: "wide", label: "Wide" },
940
+ { value: "full", label: "Full width" },
941
+ ])}
942
+ {sel("Text colour", "textTone", p.textTone ?? "light", [
943
+ { value: "light", label: "Light text (dark media)" },
944
+ { value: "dark", label: "Dark text (light media)" },
945
+ ])}
946
+ {sel("Title size", "titleSize", p.titleSize ?? "lg", [
947
+ { value: "sm", label: "Small" },
948
+ { value: "md", label: "Medium" },
949
+ { value: "lg", label: "Large" },
950
+ { value: "xl", label: "Extra large" },
951
+ ])}
952
+ {sel("Title font", "titleFont", p.titleFont ?? "serif", [
953
+ { value: "serif", label: "Serif" },
954
+ { value: "sans", label: "Sans" },
955
+ ])}
956
+ {check("Uppercase title", "titleUppercase")}
957
+ {check("Show scroll cue", "showScrollCue", true)}
958
+ <TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
959
+ </InspectorSection>
960
+ </>
961
+ );
962
+ }
963
+
964
+ /* ---------------- Image marquee ---------------- */
965
+ function ImageMarqueeInspector({ block, patch }: BlockPatchProps) {
966
+ const p = (block.props ?? {}) as ImageMarqueeProps;
967
+ const items = p.items ?? [];
968
+ const texts = p.items2 ?? [];
969
+ const [addColumn, setAddColumn] = useState("");
970
+ const columnOptions = Array.from({ length: Math.min(3, Math.max(1, p.columns ?? 2)) }, (_, i) => ({
971
+ value: String(i + 1),
972
+ label: `Column ${i + 1}`,
973
+ }));
974
+ const update = (next: ImageMarqueeProps) => patch(next as unknown as BlockProps);
975
+ const setItem = (i: number, item: ImageMarqueeItem) => {
976
+ const next = items.slice();
977
+ next[i] = item;
978
+ update({ ...p, items: next });
979
+ };
980
+ const move = (i: number, dir: -1 | 1) => {
981
+ const j = i + dir;
982
+ if (j < 0 || j >= items.length) return;
983
+ const next = items.slice();
984
+ [next[i], next[j]] = [next[j], next[i]];
985
+ update({ ...p, items: next });
986
+ };
987
+ const sel = (
988
+ label: string,
989
+ value: string,
990
+ options: { value: string; label: string }[],
991
+ onChange: (v: string) => void,
992
+ ) => (
993
+ <label className="block space-y-1.5">
994
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
995
+ <select
996
+ value={value}
997
+ onChange={(e) => onChange(e.target.value)}
998
+ 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"
999
+ >
1000
+ {options.map((o) => (
1001
+ <option key={o.value} value={o.value}>{o.label}</option>
1002
+ ))}
1003
+ </select>
1004
+ </label>
1005
+ );
1006
+
1007
+ return (
1008
+ <>
1009
+ <InspectorSection title="Text">
1010
+ <LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
1011
+ <LocalizedField label="Title" multiline value={p.title} onChange={(v) => update({ ...p, title: v })} />
1012
+ <LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
1013
+ <div className="space-y-3">
1014
+ {texts.map((item, i) => (
1015
+ <div key={item.id ?? i} className="border border-ink/10 p-3 space-y-2">
1016
+ <div className="flex items-center justify-between">
1017
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/50">Passage #{i + 1}</span>
1018
+ <button
1019
+ onClick={() => {
1020
+ const next = texts.slice();
1021
+ next.splice(i, 1);
1022
+ update({ ...p, items2: next });
1023
+ }}
1024
+ className="p-1 text-red-500 hover:text-red-700"
1025
+ title="Delete"
1026
+ >
1027
+ <Trash2 className="size-3.5" />
1028
+ </button>
1029
+ </div>
1030
+ <LocalizedField
1031
+ label="Heading"
1032
+ value={item.heading}
1033
+ onChange={(v) => {
1034
+ const next = texts.slice();
1035
+ next[i] = { ...item, heading: v };
1036
+ update({ ...p, items2: next });
1037
+ }}
1038
+ />
1039
+ <LocalizedField
1040
+ label="Body"
1041
+ multiline
1042
+ value={item.body}
1043
+ onChange={(v) => {
1044
+ const next = texts.slice();
1045
+ next[i] = { ...item, body: v };
1046
+ update({ ...p, items2: next });
1047
+ }}
1048
+ />
1049
+ </div>
1050
+ ))}
1051
+ <button
1052
+ onClick={() => update({ ...p, items2: [...texts, { id: uid("mtxt") }] })}
1053
+ className="inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-sea"
1054
+ >
1055
+ <Plus className="size-3.5" /> Add passage
1056
+ </button>
1057
+ </div>
1058
+ </InspectorSection>
1059
+ <InspectorSection title="Layout">
1060
+ {sel("Text position", p.textPosition ?? "left", [
1061
+ { value: "left", label: "Text left, images right" },
1062
+ { value: "right", label: "Text right, images left" },
1063
+ { value: "none", label: "Images only (full width)" },
1064
+ ], (v) => update({ ...p, textPosition: v as ImageMarqueeProps["textPosition"] }))}
1065
+ {sel("Columns (desktop)", String(p.columns ?? 2), [
1066
+ { value: "1", label: "1 column" },
1067
+ { value: "2", label: "2 columns" },
1068
+ { value: "3", label: "3 columns" },
1069
+ ], (v) => update({ ...p, columns: Number(v) as ImageMarqueeProps["columns"] }))}
1070
+ {sel("First column direction", p.firstDirection ?? "up", [
1071
+ { value: "up", label: "Up (next column down)" },
1072
+ { value: "down", label: "Down (next column up)" },
1073
+ ], (v) => update({ ...p, firstDirection: v as ImageMarqueeProps["firstDirection"] }))}
1074
+ {sel("Speed", p.speed ?? "normal", [
1075
+ { value: "ultra-slow", label: "Ultra slow" },
1076
+ { value: "very-slow", label: "Very slow" },
1077
+ { value: "slow", label: "Slow" },
1078
+ { value: "normal", label: "Normal" },
1079
+ { value: "fast", label: "Fast" },
1080
+ ], (v) => update({ ...p, speed: v as ImageMarqueeProps["speed"] }))}
1081
+ {sel("Image shape", p.aspect ?? "portrait", [
1082
+ { value: "portrait", label: "Portrait" },
1083
+ { value: "square", label: "Square" },
1084
+ { value: "landscape", label: "Landscape" },
1085
+ { value: "mixed", label: "Mixed" },
1086
+ ], (v) => update({ ...p, aspect: v as ImageMarqueeProps["aspect"] }))}
1087
+ {sel("Gap", p.gap ?? "md", [
1088
+ { value: "sm", label: "Small" },
1089
+ { value: "md", label: "Medium" },
1090
+ { value: "lg", label: "Large" },
1091
+ ], (v) => update({ ...p, gap: v as ImageMarqueeProps["gap"] }))}
1092
+ {sel("Background", p.tone ?? "white", [
1093
+ { value: "white", label: "White" },
1094
+ { value: "paper", label: "Paper" },
1095
+ { value: "ice", label: "Ice" },
1096
+ ], (v) => update({ ...p, tone: v as ImageMarqueeProps["tone"] }))}
1097
+ <label className="flex items-center gap-2 text-sm text-ink">
1098
+ <input
1099
+ type="checkbox"
1100
+ checked={p.rounded ?? false}
1101
+ onChange={(e) => update({ ...p, rounded: e.target.checked })}
1102
+ />
1103
+ Rounded corners
1104
+ </label>
1105
+ <TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
1106
+ </InspectorSection>
1107
+ <InspectorSection title={`Images (${items.length})`}>
1108
+ <div className="space-y-4">
1109
+ {items.map((item, i) => (
1110
+ <div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3 min-w-0">
1111
+ <div className="flex items-center justify-between">
1112
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
1113
+ <div className="flex items-center gap-1">
1114
+ <button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
1115
+ <ArrowUp className="size-3.5" />
1116
+ </button>
1117
+ <button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
1118
+ <ArrowDown className="size-3.5" />
1119
+ </button>
1120
+ <button
1121
+ onClick={() => {
1122
+ const next = items.slice();
1123
+ next.splice(i, 1);
1124
+ update({ ...p, items: next });
1125
+ }}
1126
+ className="p-1 text-red-500 hover:text-red-700"
1127
+ title="Delete"
1128
+ >
1129
+ <Trash2 className="size-3.5" />
1130
+ </button>
1131
+ </div>
1132
+ </div>
1133
+ <MediaField
1134
+ label="Image"
1135
+ accept="image"
1136
+ value={item.image}
1137
+ onChange={(url) => setItem(i, { ...item, image: url })}
1138
+ />
1139
+ {sel(
1140
+ "Column",
1141
+ String(item.column ?? ""),
1142
+ [
1143
+ { value: "", label: "All columns (spread)" },
1144
+ ...columnOptions,
1145
+ ],
1146
+ (v) => setItem(i, { ...item, column: v ? Number(v) : undefined }),
1147
+ )}
1148
+ <LocalizedField label="Alt text" value={item.alt} onChange={(v) => setItem(i, { ...item, alt: v })} />
1149
+ </div>
1150
+ ))}
1151
+ </div>
1152
+ {sel(
1153
+ "Add new images to",
1154
+ String(addColumn),
1155
+ [{ value: "", label: "All columns (spread)" }, ...columnOptions],
1156
+ (v) => setAddColumn(v),
1157
+ )}
1158
+ <MediaMultiField
1159
+ label="Add several images at once"
1160
+ values={[]}
1161
+ onChange={(urls) => {
1162
+ update({
1163
+ ...p,
1164
+ items: [
1165
+ ...items,
1166
+ ...urls.map((url) => ({
1167
+ id: uid("img"),
1168
+ image: url,
1169
+ ...(addColumn ? { column: Number(addColumn) } : {}),
1170
+ })),
1171
+ ],
1172
+ });
1173
+ }}
1174
+ />
1175
+ <button
1176
+ onClick={() =>
1177
+ update({
1178
+ ...p,
1179
+ items: [
1180
+ ...items,
1181
+ { id: uid("img"), ...(addColumn ? { column: Number(addColumn) } : {}) },
1182
+ ],
1183
+ })
1184
+ }
1185
+ className="mt-3 inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-sea"
1186
+ >
1187
+ <Plus className="size-3.5" /> Add single image
1188
+ </button>
1189
+ </InspectorSection>
1190
+ </>
1191
+ );
1192
+ }
1193
+
1194
+
1195
+ /* ---------------- Link tiles ---------------- */
1196
+ function LinkTilesInspector({ block, patch }: BlockPatchProps) {
1197
+ const p = (block.props ?? {}) as LinkTilesProps;
1198
+ const items = p.items ?? [];
1199
+ const update = (next: LinkTilesProps) => patch(next as unknown as BlockProps);
1200
+ const setItem = (i: number, item: LinkTilesItem) => {
1201
+ const next = items.slice();
1202
+ next[i] = item;
1203
+ update({ ...p, items: next });
1204
+ };
1205
+ const move = (i: number, dir: -1 | 1) => {
1206
+ const j = i + dir;
1207
+ if (j < 0 || j >= items.length) return;
1208
+ const next = items.slice();
1209
+ [next[i], next[j]] = [next[j], next[i]];
1210
+ update({ ...p, items: next });
1211
+ };
1212
+ const sel = (
1213
+ label: string,
1214
+ value: string,
1215
+ options: { value: string; label: string }[],
1216
+ onChange: (v: string) => void,
1217
+ ) => (
1218
+ <label className="block space-y-1.5">
1219
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
1220
+ <select
1221
+ value={value}
1222
+ onChange={(e) => onChange(e.target.value)}
1223
+ 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"
1224
+ >
1225
+ {options.map((o) => (
1226
+ <option key={o.value} value={o.value}>{o.label}</option>
1227
+ ))}
1228
+ </select>
1229
+ </label>
1230
+ );
1231
+
1232
+ return (
1233
+ <>
1234
+ <InspectorSection title="Heading (optional)">
1235
+ <LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
1236
+ <LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
1237
+ <LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
1238
+ </InspectorSection>
1239
+ <InspectorSection title="Layout">
1240
+ {sel("Columns (desktop)", String(p.columns ?? 3), [
1241
+ { value: "1", label: "1 column" },
1242
+ { value: "2", label: "2 columns" },
1243
+ { value: "3", label: "3 columns" },
1244
+ { value: "4", label: "4 columns" },
1245
+ ], (v) => update({ ...p, columns: Number(v) as LinkTilesProps["columns"] }))}
1246
+ {sel("Tile shape", p.aspect ?? "portrait", [
1247
+ { value: "portrait", label: "Portrait" },
1248
+ { value: "tall", label: "Tall" },
1249
+ { value: "square", label: "Square" },
1250
+ { value: "landscape", label: "Landscape" },
1251
+ ], (v) => update({ ...p, aspect: v as LinkTilesProps["aspect"] }))}
1252
+ {sel("Gap", p.gap ?? "md", [
1253
+ { value: "sm", label: "Small" },
1254
+ { value: "md", label: "Medium" },
1255
+ { value: "lg", label: "Large" },
1256
+ ], (v) => update({ ...p, gap: v as LinkTilesProps["gap"] }))}
1257
+ {sel("Image overlay", p.overlay ?? "medium", [
1258
+ { value: "none", label: "None" },
1259
+ { value: "light", label: "Light" },
1260
+ { value: "medium", label: "Medium" },
1261
+ { value: "strong", label: "Strong" },
1262
+ { value: "brand", label: "Brand tint (pink)" },
1263
+ ], (v) => update({ ...p, overlay: v as LinkTilesProps["overlay"] }))}
1264
+ {sel("Label position", p.labelAlign ?? "left", [
1265
+ { value: "left", label: "Bottom left" },
1266
+ { value: "center", label: "Bottom centre" },
1267
+ ], (v) => update({ ...p, labelAlign: v as LinkTilesProps["labelAlign"] }))}
1268
+ {sel("Background", p.tone ?? "white", [
1269
+ { value: "white", label: "White" },
1270
+ { value: "paper", label: "Paper" },
1271
+ { value: "ice", label: "Ice" },
1272
+ { value: "ink", label: "Ink (dark)" },
1273
+ ], (v) => update({ ...p, tone: v as LinkTilesProps["tone"] }))}
1274
+ <label className="flex items-center gap-2 text-sm text-ink">
1275
+ <input
1276
+ type="checkbox"
1277
+ checked={p.rounded ?? false}
1278
+ onChange={(e) => update({ ...p, rounded: e.target.checked })}
1279
+ />
1280
+ Rounded corners
1281
+ </label>
1282
+ <TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
1283
+ </InspectorSection>
1284
+ <InspectorSection title={`Tiles (${items.length})`}>
1285
+ <div className="space-y-4">
1286
+ {items.map((item, i) => (
1287
+ <div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3 min-w-0">
1288
+ <div className="flex items-center justify-between">
1289
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
1290
+ <div className="flex items-center gap-1">
1291
+ <button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
1292
+ <ArrowUp className="size-3.5" />
1293
+ </button>
1294
+ <button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
1295
+ <ArrowDown className="size-3.5" />
1296
+ </button>
1297
+ <button
1298
+ onClick={() => {
1299
+ const next = items.slice();
1300
+ next.splice(i, 1);
1301
+ update({ ...p, items: next });
1302
+ }}
1303
+ className="p-1 text-red-500 hover:text-red-700"
1304
+ title="Delete"
1305
+ >
1306
+ <Trash2 className="size-3.5" />
1307
+ </button>
1308
+ </div>
1309
+ </div>
1310
+ <MediaField
1311
+ label="Image"
1312
+ accept="image"
1313
+ value={item.image}
1314
+ onChange={(url) => setItem(i, { ...item, image: url })}
1315
+ />
1316
+ <LocalizedField label="Label" value={item.label} onChange={(v) => setItem(i, { ...item, label: v })} />
1317
+ <LocalizedField label="Eyebrow" value={item.eyebrow} onChange={(v) => setItem(i, { ...item, eyebrow: v })} />
1318
+ <LocalizedField label="Text" multiline value={item.body} onChange={(v) => setItem(i, { ...item, body: v })} />
1319
+ <LocalizedField label="Alt text" value={item.alt} onChange={(v) => setItem(i, { ...item, alt: v })} />
1320
+ <TextField
1321
+ label="Link"
1322
+ value={item.href}
1323
+ placeholder="/boka"
1324
+ onChange={(v) => setItem(i, { ...item, href: v })}
1325
+ />
1326
+ </div>
1327
+ ))}
1328
+ </div>
1329
+ <button
1330
+ onClick={() => update({ ...p, items: [...items, { id: uid("tile") }] })}
1331
+ className="mt-3 inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-sea"
1332
+ >
1333
+ <Plus className="size-3.5" /> Add tile
1334
+ </button>
1335
+ </InspectorSection>
1336
+ </>
1337
+ );
1338
+ }
1339
+
1340
+
1341
+ /* ---------------- Cabin tabs ---------------- */
1342
+ function CabinTabsInspector({ block, patch }: BlockPatchProps) {
1343
+ const p = (block.props ?? {}) as CabinTabsProps;
1344
+ const items = p.items ?? [];
1345
+ const update = (next: CabinTabsProps) => patch(next as unknown as BlockProps);
1346
+ const setItem = (i: number, item: CabinTabItem) => {
1347
+ const next = items.slice();
1348
+ next[i] = item;
1349
+ update({ ...p, items: next });
1350
+ };
1351
+ const move = (i: number, dir: -1 | 1) => {
1352
+ const j = i + dir;
1353
+ if (j < 0 || j >= items.length) return;
1354
+ const next = items.slice();
1355
+ [next[i], next[j]] = [next[j], next[i]];
1356
+ update({ ...p, items: next });
1357
+ };
1358
+
1359
+ return (
1360
+ <>
1361
+ <InspectorSection title="Heading">
1362
+ <LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
1363
+ <LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
1364
+ <LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
1365
+ <LocalizedField label="Note" multiline value={p.note} onChange={(v) => update({ ...p, note: v })} />
1366
+ <label className="block space-y-1.5">
1367
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Background</span>
1368
+ <select
1369
+ value={p.tone ?? "white"}
1370
+ onChange={(e) => update({ ...p, tone: e.target.value as CabinTabsProps["tone"] })}
1371
+ 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"
1372
+ >
1373
+ <option value="white">White</option>
1374
+ <option value="paper">Paper</option>
1375
+ <option value="ice">Ice</option>
1376
+ </select>
1377
+ </label>
1378
+ <TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
1379
+ </InspectorSection>
1380
+ <InspectorSection title={`Cabins (${items.length})`}>
1381
+ <div className="space-y-4">
1382
+ {items.map((item, i) => {
1383
+ const bullets = item.bullets ?? [];
1384
+ const images = item.images ?? [];
1385
+ return (
1386
+ <div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3 min-w-0">
1387
+ <div className="flex items-center justify-between">
1388
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
1389
+ <div className="flex items-center gap-1">
1390
+ <button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
1391
+ <ArrowUp className="size-3.5" />
1392
+ </button>
1393
+ <button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
1394
+ <ArrowDown className="size-3.5" />
1395
+ </button>
1396
+ <button
1397
+ onClick={() => {
1398
+ const next = items.slice();
1399
+ next.splice(i, 1);
1400
+ update({ ...p, items: next });
1401
+ }}
1402
+ className="p-1 text-red-500 hover:text-red-700"
1403
+ title="Delete"
1404
+ >
1405
+ <Trash2 className="size-3.5" />
1406
+ </button>
1407
+ </div>
1408
+ </div>
1409
+ <LocalizedField label="Tab label" value={item.label} onChange={(v) => setItem(i, { ...item, label: v })} />
1410
+ <LocalizedField label="Heading" value={item.title} onChange={(v) => setItem(i, { ...item, title: v })} />
1411
+ <LocalizedField label="Description" multiline value={item.body} onChange={(v) => setItem(i, { ...item, body: v })} />
1412
+
1413
+ <div className="space-y-2">
1414
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
1415
+ Bullets ({bullets.length})
1416
+ </span>
1417
+ {bullets.map((b, bi) => (
1418
+ <div key={b.id ?? bi} className="flex items-start gap-2">
1419
+ <div className="min-w-0 flex-1">
1420
+ <LocalizedField
1421
+ label={`Bullet ${bi + 1}`}
1422
+ value={b.text}
1423
+ onChange={(v) => {
1424
+ const next = bullets.slice();
1425
+ next[bi] = { ...b, text: v };
1426
+ setItem(i, { ...item, bullets: next });
1427
+ }}
1428
+ />
1429
+ </div>
1430
+ <button
1431
+ onClick={() => setItem(i, { ...item, bullets: bullets.filter((_, x) => x !== bi) })}
1432
+ className="mt-6 p-1 text-red-500 hover:text-red-700"
1433
+ title="Delete bullet"
1434
+ >
1435
+ <Trash2 className="size-3.5" />
1436
+ </button>
1437
+ </div>
1438
+ ))}
1439
+ <button
1440
+ onClick={() => setItem(i, { ...item, bullets: [...bullets, { id: uid("b") }] })}
1441
+ className="inline-flex items-center gap-1.5 border border-ink/15 px-2.5 py-1 text-[0.65rem] uppercase tracking-[0.18em] text-ink hover:border-sea"
1442
+ >
1443
+ <Plus className="size-3" /> Add bullet
1444
+ </button>
1445
+ </div>
1446
+
1447
+ <div className="space-y-2">
1448
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
1449
+ Images ({images.length})
1450
+ </span>
1451
+ {images.map((im, ii) => (
1452
+ <div key={im.id ?? ii} className="border border-ink/10 p-2 space-y-2">
1453
+ <div className="flex items-center justify-between">
1454
+ <span className="text-[0.65rem] uppercase tracking-widest text-ink/45">Image {ii + 1}</span>
1455
+ <button
1456
+ onClick={() => setItem(i, { ...item, images: images.filter((_, x) => x !== ii) })}
1457
+ className="p-1 text-red-500 hover:text-red-700"
1458
+ title="Delete image"
1459
+ >
1460
+ <Trash2 className="size-3.5" />
1461
+ </button>
1462
+ </div>
1463
+ <MediaField
1464
+ label="Image"
1465
+ accept="image"
1466
+ value={im.src}
1467
+ onChange={(url) => {
1468
+ const next = images.slice();
1469
+ next[ii] = { ...im, src: url };
1470
+ setItem(i, { ...item, images: next });
1471
+ }}
1472
+ />
1473
+ <LocalizedField
1474
+ label="Alt text"
1475
+ value={im.alt}
1476
+ onChange={(v) => {
1477
+ const next = images.slice();
1478
+ next[ii] = { ...im, alt: v };
1479
+ setItem(i, { ...item, images: next });
1480
+ }}
1481
+ />
1482
+ </div>
1483
+ ))}
1484
+ <button
1485
+ onClick={() => setItem(i, { ...item, images: [...images, { id: uid("img") }] })}
1486
+ className="inline-flex items-center gap-1.5 border border-ink/15 px-2.5 py-1 text-[0.65rem] uppercase tracking-[0.18em] text-ink hover:border-sea"
1487
+ >
1488
+ <Plus className="size-3" /> Add image
1489
+ </button>
1490
+ </div>
1491
+ </div>
1492
+ );
1493
+ })}
1494
+ </div>
1495
+ <button
1496
+ onClick={() => update({ ...p, items: [...items, { id: uid("cabin") }] })}
1497
+ className="mt-3 inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-sea"
1498
+ >
1499
+ <Plus className="size-3.5" /> Add cabin
1500
+ </button>
1501
+ </InspectorSection>
1502
+ </>
1503
+ );
1504
+ }
1505
+
1506
+ /* ---------------- Price table ---------------- */
1507
+ function PriceTableInspector({ block, patch }: BlockPatchProps) {
1508
+ const p = (block.props ?? {}) as PriceTableProps;
1509
+ const groups = p.groups ?? [];
1510
+ const update = (next: PriceTableProps) => patch(next as unknown as BlockProps);
1511
+ const setGroup = (i: number, g: PriceGroup) => {
1512
+ const next = groups.slice();
1513
+ next[i] = g;
1514
+ update({ ...p, groups: next });
1515
+ };
1516
+ const move = (i: number, dir: -1 | 1) => {
1517
+ const j = i + dir;
1518
+ if (j < 0 || j >= groups.length) return;
1519
+ const next = groups.slice();
1520
+ [next[i], next[j]] = [next[j], next[i]];
1521
+ update({ ...p, groups: next });
1522
+ };
1523
+
1524
+ return (
1525
+ <>
1526
+ <InspectorSection title="Heading">
1527
+ <LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
1528
+ <LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
1529
+ <LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
1530
+ <LocalizedField label="Unit label" value={p.unitLabel} onChange={(v) => update({ ...p, unitLabel: v })} />
1531
+ <LocalizedField label="Note" multiline value={p.note} onChange={(v) => update({ ...p, note: v })} />
1532
+ <LocalizedField label="Button label" value={p.ctaLabel} onChange={(v) => update({ ...p, ctaLabel: v })} />
1533
+ <TextField label="Button link" value={p.ctaHref} placeholder="/boka" onChange={(v) => update({ ...p, ctaHref: v })} />
1534
+ </InspectorSection>
1535
+ <InspectorSection title="Layout">
1536
+ <label className="block space-y-1.5">
1537
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Columns (desktop)</span>
1538
+ <select
1539
+ value={String(p.columns ?? 3)}
1540
+ onChange={(e) => update({ ...p, columns: Number(e.target.value) as PriceTableProps["columns"] })}
1541
+ 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"
1542
+ >
1543
+ <option value="2">2 columns</option>
1544
+ <option value="3">3 columns</option>
1545
+ </select>
1546
+ </label>
1547
+ <label className="block space-y-1.5">
1548
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Background</span>
1549
+ <select
1550
+ value={p.tone ?? "paper"}
1551
+ onChange={(e) => update({ ...p, tone: e.target.value as PriceTableProps["tone"] })}
1552
+ 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"
1553
+ >
1554
+ <option value="white">White</option>
1555
+ <option value="paper">Paper</option>
1556
+ <option value="ice">Ice</option>
1557
+ <option value="ink">Ink (dark)</option>
1558
+ </select>
1559
+ </label>
1560
+ <label className="block space-y-1.5">
1561
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Alignment</span>
1562
+ <select
1563
+ value={p.align ?? "center"}
1564
+ onChange={(e) => update({ ...p, align: e.target.value as PriceTableProps["align"] })}
1565
+ 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"
1566
+ >
1567
+ <option value="center">Centered</option>
1568
+ <option value="left">Left aligned</option>
1569
+ </select>
1570
+ </label>
1571
+ <TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
1572
+
1573
+ </InspectorSection>
1574
+ <InspectorSection title={`Price cards (${groups.length})`}>
1575
+ <div className="space-y-4">
1576
+ {groups.map((g, i) => {
1577
+ const rows = g.rows ?? [];
1578
+ return (
1579
+ <div key={g.id ?? i} className="border border-ink/10 p-3 space-y-3 min-w-0">
1580
+ <div className="flex items-center justify-between">
1581
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
1582
+ <div className="flex items-center gap-1">
1583
+ <button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
1584
+ <ArrowUp className="size-3.5" />
1585
+ </button>
1586
+ <button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
1587
+ <ArrowDown className="size-3.5" />
1588
+ </button>
1589
+ <button
1590
+ onClick={() => update({ ...p, groups: groups.filter((_, x) => x !== i) })}
1591
+ className="p-1 text-red-500 hover:text-red-700"
1592
+ title="Delete"
1593
+ >
1594
+ <Trash2 className="size-3.5" />
1595
+ </button>
1596
+ </div>
1597
+ </div>
1598
+ <LocalizedField label="Cabin" value={g.label} onChange={(v) => setGroup(i, { ...g, label: v })} />
1599
+ <LocalizedField label="Caption" multiline value={g.caption} onChange={(v) => setGroup(i, { ...g, caption: v })} />
1600
+ <label className="flex items-center gap-2 text-sm text-ink">
1601
+ <input
1602
+ type="checkbox"
1603
+ checked={g.highlight ?? false}
1604
+ onChange={(e) => setGroup(i, { ...g, highlight: e.target.checked })}
1605
+ />
1606
+ Highlight card
1607
+ </label>
1608
+ <div className="space-y-2">
1609
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
1610
+ Rows ({rows.length})
1611
+ </span>
1612
+ {rows.map((r, ri) => (
1613
+ <div key={r.id ?? ri} className="border border-ink/10 p-2 space-y-2">
1614
+ <div className="flex items-center justify-between">
1615
+ <span className="text-[0.65rem] uppercase tracking-widest text-ink/45">
1616
+ Row {ri + 1}
1617
+ {r.hidden ? " · hidden" : ""}
1618
+ </span>
1619
+ <button
1620
+ onClick={() => setGroup(i, { ...g, rows: rows.filter((_, x) => x !== ri) })}
1621
+ className="p-1 text-red-500 hover:text-red-700"
1622
+ title="Delete row"
1623
+ >
1624
+ <Trash2 className="size-3.5" />
1625
+ </button>
1626
+ </div>
1627
+ <label className="flex items-center gap-2 text-xs text-ink">
1628
+ <input
1629
+ type="checkbox"
1630
+ checked={r.hidden !== true}
1631
+ onChange={(e) => {
1632
+ const next = rows.slice();
1633
+ next[ri] = { ...r, hidden: !e.target.checked };
1634
+ setGroup(i, { ...g, rows: next });
1635
+ }}
1636
+ />
1637
+ Show this row on the site
1638
+ </label>
1639
+ <LocalizedField
1640
+ label="Guests"
1641
+ value={r.label}
1642
+ onChange={(v) => {
1643
+ const next = rows.slice();
1644
+ next[ri] = { ...r, label: v };
1645
+ setGroup(i, { ...g, rows: next });
1646
+ }}
1647
+ />
1648
+ <LocalizedField
1649
+ label="Price"
1650
+ value={r.price}
1651
+ onChange={(v) => {
1652
+ const next = rows.slice();
1653
+ next[ri] = { ...r, price: v };
1654
+ setGroup(i, { ...g, rows: next });
1655
+ }}
1656
+ />
1657
+ </div>
1658
+ ))}
1659
+ <button
1660
+ onClick={() => setGroup(i, { ...g, rows: [...rows, { id: uid("row") }] })}
1661
+ className="inline-flex items-center gap-1.5 border border-ink/15 px-2.5 py-1 text-[0.65rem] uppercase tracking-[0.18em] text-ink hover:border-sea"
1662
+ >
1663
+ <Plus className="size-3" /> Add row
1664
+ </button>
1665
+ </div>
1666
+ </div>
1667
+ );
1668
+ })}
1669
+ </div>
1670
+ <button
1671
+ onClick={() => update({ ...p, groups: [...groups, { id: uid("grp") }] })}
1672
+ className="mt-3 inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-sea"
1673
+ >
1674
+ <Plus className="size-3.5" /> Add price card
1675
+ </button>
1676
+ </InspectorSection>
1677
+ </>
1678
+ );
1679
+ }
1680
+
1681
+ /* ---------------- Checklist ---------------- */
1682
+ function IconGridInspector({ block, patch }: BlockPatchProps) {
1683
+ const p = (block.props ?? {}) as IconGridProps;
1684
+ const items = p.items ?? [];
1685
+ const update = (next: IconGridProps) => patch(next as unknown as BlockProps);
1686
+ const setItem = (i: number, item: IconGridItem) => {
1687
+ const next = items.slice();
1688
+ next[i] = item;
1689
+ update({ ...p, items: next });
1690
+ };
1691
+ const move = (i: number, dir: -1 | 1) => {
1692
+ const j = i + dir;
1693
+ if (j < 0 || j >= items.length) return;
1694
+ const next = items.slice();
1695
+ [next[i], next[j]] = [next[j], next[i]];
1696
+ update({ ...p, items: next });
1697
+ };
1698
+
1699
+ return (
1700
+ <>
1701
+ <InspectorSection title="Heading">
1702
+ <LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
1703
+ <LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
1704
+ <LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
1705
+ <LocalizedField label="Note" multiline value={p.note} onChange={(v) => update({ ...p, note: v })} />
1706
+ </InspectorSection>
1707
+ <InspectorSection title="Layout">
1708
+ <label className="block space-y-1.5">
1709
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Columns (desktop)</span>
1710
+ <select
1711
+ value={String(p.columns ?? 3)}
1712
+ onChange={(e) => update({ ...p, columns: Number(e.target.value) as IconGridProps["columns"] })}
1713
+ 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"
1714
+ >
1715
+ {[2, 3, 4, 5].map((n) => (
1716
+ <option key={n} value={n}>{n} columns</option>
1717
+ ))}
1718
+ </select>
1719
+ </label>
1720
+ <label className="block space-y-1.5">
1721
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Background</span>
1722
+ <select
1723
+ value={p.tone ?? "white"}
1724
+ onChange={(e) => update({ ...p, tone: e.target.value as IconGridProps["tone"] })}
1725
+ 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"
1726
+ >
1727
+ <option value="white">White</option>
1728
+ <option value="paper">Paper</option>
1729
+ <option value="ice">Ice</option>
1730
+ </select>
1731
+ </label>
1732
+ <label className="block space-y-1.5">
1733
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Icon style</span>
1734
+ <select
1735
+ value={p.iconStyle ?? "circle"}
1736
+ onChange={(e) => update({ ...p, iconStyle: e.target.value as IconGridProps["iconStyle"] })}
1737
+ 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"
1738
+ >
1739
+ <option value="circle">Outline circle</option>
1740
+ <option value="plain">Plain icon</option>
1741
+ </select>
1742
+ </label>
1743
+ <label className="block space-y-1.5">
1744
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Icon position</span>
1745
+ <select
1746
+ value={p.iconAlign ?? "center"}
1747
+ onChange={(e) => update({ ...p, iconAlign: e.target.value as IconGridProps["iconAlign"] })}
1748
+ 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"
1749
+ >
1750
+ <option value="left">Top left</option>
1751
+ <option value="center">Top centre</option>
1752
+ <option value="right">Top right</option>
1753
+ </select>
1754
+ </label>
1755
+ <label className="block space-y-1.5">
1756
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Card style</span>
1757
+ <select
1758
+ value={p.cardStyle ?? "bordered"}
1759
+ onChange={(e) => update({ ...p, cardStyle: e.target.value as IconGridProps["cardStyle"] })}
1760
+ 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"
1761
+ >
1762
+ <option value="bordered">Bordered cards</option>
1763
+ <option value="bare">Bare (no cards)</option>
1764
+ </select>
1765
+ </label>
1766
+ <TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
1767
+ </InspectorSection>
1768
+ <InspectorSection title={`Cards (${items.length})`}>
1769
+ <div className="space-y-4">
1770
+ {items.map((item, i) => {
1771
+ const Icon = resolveBlockIcon(item.icon);
1772
+ return (
1773
+ <div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3">
1774
+ <div className="flex items-center justify-between">
1775
+ <span className="flex items-center gap-2 text-[0.7rem] uppercase tracking-widest text-ink/50">
1776
+ <Icon className="size-4 text-sea" />#{i + 1}
1777
+ </span>
1778
+ <div className="flex items-center gap-1">
1779
+ <button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
1780
+ <ArrowUp className="size-3.5" />
1781
+ </button>
1782
+ <button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
1783
+ <ArrowDown className="size-3.5" />
1784
+ </button>
1785
+ <button
1786
+ onClick={() => {
1787
+ const next = items.slice();
1788
+ next.splice(i, 1);
1789
+ update({ ...p, items: next });
1790
+ }}
1791
+ className="p-1 text-red-500 hover:text-red-700"
1792
+ title="Delete"
1793
+ >
1794
+ <Trash2 className="size-3.5" />
1795
+ </button>
1796
+ </div>
1797
+ </div>
1798
+ <label className="block space-y-1.5">
1799
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Icon</span>
1800
+ <select
1801
+ value={item.icon ?? "sparkles"}
1802
+ onChange={(e) => setItem(i, { ...item, icon: e.target.value })}
1803
+ 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"
1804
+ >
1805
+ {BLOCK_ICON_NAMES.map((name) => (
1806
+ <option key={name} value={name}>{name}</option>
1807
+ ))}
1808
+ </select>
1809
+ </label>
1810
+ <LocalizedField label="Title" value={item.title} onChange={(v) => setItem(i, { ...item, title: v })} />
1811
+ <LocalizedField label="Meta (e.g. price)" value={item.meta} onChange={(v) => setItem(i, { ...item, meta: v })} />
1812
+ <LocalizedField label="Text" multiline value={item.body} onChange={(v) => setItem(i, { ...item, body: v })} />
1813
+ <TextField label="Link (optional)" value={item.href} onChange={(v) => setItem(i, { ...item, href: v })} />
1814
+ </div>
1815
+ );
1816
+ })}
1817
+ </div>
1818
+ <button
1819
+ onClick={() => update({ ...p, items: [...items, { id: uid("icon"), icon: "sparkles" }] })}
1820
+ className="mt-3 inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-sea"
1821
+ >
1822
+ <Plus className="size-3.5" /> Add card
1823
+ </button>
1824
+ </InspectorSection>
1825
+ </>
1826
+ );
1827
+ }
1828
+
1829
+ function ChecklistInspector({ block, patch }: BlockPatchProps) {
1830
+ const p = (block.props ?? {}) as ChecklistProps;
1831
+ const columns = p.columns ?? [];
1832
+ const update = (next: ChecklistProps) => patch(next as unknown as BlockProps);
1833
+ const setCol = (i: number, col: ChecklistColumn) => {
1834
+ const next = columns.slice();
1835
+ next[i] = col;
1836
+ update({ ...p, columns: next });
1837
+ };
1838
+
1839
+ return (
1840
+ <>
1841
+ <InspectorSection title="Heading">
1842
+ <LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
1843
+ <LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
1844
+ <LocalizedField label="Note" multiline value={p.note} onChange={(v) => update({ ...p, note: v })} />
1845
+ <label className="block space-y-1.5">
1846
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Background</span>
1847
+ <select
1848
+ value={p.tone ?? "white"}
1849
+ onChange={(e) => update({ ...p, tone: e.target.value as ChecklistProps["tone"] })}
1850
+ 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"
1851
+ >
1852
+ <option value="white">White</option>
1853
+ <option value="paper">Paper</option>
1854
+ </select>
1855
+ </label>
1856
+ </InspectorSection>
1857
+ {columns.map((col, ci) => {
1858
+ const entries = col.entries ?? [];
1859
+ return (
1860
+ <InspectorSection key={col.id ?? ci} title={`Column ${ci + 1} (${entries.length} items)`}>
1861
+ <LocalizedField label="Column title" value={col.title} onChange={(v) => setCol(ci, { ...col, title: v })} />
1862
+ <div className="space-y-2">
1863
+ {entries.map((e, i) => (
1864
+ <div key={e.id ?? i} className="border border-ink/10 p-3 space-y-2">
1865
+ <div className="flex items-center justify-between">
1866
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
1867
+ <button
1868
+ onClick={() => {
1869
+ const next = entries.slice();
1870
+ next.splice(i, 1);
1871
+ setCol(ci, { ...col, entries: next });
1872
+ }}
1873
+ className="p-1 text-red-500 hover:text-red-700"
1874
+ title="Delete"
1875
+ >
1876
+ <Trash2 className="size-3.5" />
1877
+ </button>
1878
+ </div>
1879
+ <LocalizedField
1880
+ label="Item"
1881
+ value={e.text}
1882
+ onChange={(v) => {
1883
+ const next = entries.slice();
1884
+ next[i] = { ...e, text: v };
1885
+ setCol(ci, { ...col, entries: next });
1886
+ }}
1887
+ />
1888
+ </div>
1889
+ ))}
1890
+ </div>
1891
+ <div className="flex items-center gap-2">
1892
+ <button
1893
+ onClick={() => setCol(ci, { ...col, entries: [...entries, { id: uid("i"), text: {} }] })}
1894
+ className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
1895
+ >
1896
+ <Plus className="size-4" /> Add item
1897
+ </button>
1898
+ <button
1899
+ onClick={() => {
1900
+ const next = columns.slice();
1901
+ next.splice(ci, 1);
1902
+ update({ ...p, columns: next });
1903
+ }}
1904
+ className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-red-600 hover:border-red-400"
1905
+ >
1906
+ <Trash2 className="size-3.5" /> Remove column
1907
+ </button>
1908
+ </div>
1909
+ </InspectorSection>
1910
+ );
1911
+ })}
1912
+ <button
1913
+ onClick={() => update({ ...p, columns: [...columns, { id: uid("c"), title: {}, entries: [] }] })}
1914
+ className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
1915
+ >
1916
+ <Plus className="size-4" /> Add column
1917
+ </button>
1918
+ </>
1919
+ );
1920
+ }
1921
+
1922
+ /* ---------------- CTA banner ---------------- */
1923
+ function CtaBannerInspector({ block, patch }: BlockPatchProps) {
1924
+ const p = (block.props ?? {}) as CtaBannerProps;
1925
+ const update = (next: CtaBannerProps) => patch(next as unknown as BlockProps);
1926
+
1927
+ return (
1928
+ <>
1929
+ <InspectorSection title="Content">
1930
+ <LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
1931
+ <LocalizedField label="Title" multiline value={p.title} onChange={(v) => update({ ...p, title: v })} />
1932
+ <LocalizedField label="Body" multiline value={p.body} onChange={(v) => update({ ...p, body: v })} />
1933
+ <label className="block space-y-1.5">
1934
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Background</span>
1935
+ <select
1936
+ value={p.tone ?? "ink"}
1937
+ onChange={(e) => update({ ...p, tone: e.target.value as CtaBannerProps["tone"] })}
1938
+ 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"
1939
+ >
1940
+ <option value="ink">Dark</option>
1941
+ <option value="sea">Ice</option>
1942
+ <option value="paper">Paper</option>
1943
+ </select>
1944
+ </label>
1945
+ </InspectorSection>
1946
+ <InspectorSection title="Primary button">
1947
+ <LocalizedField label="Label" value={p.cta?.label} onChange={(v) => update({ ...p, cta: { ...p.cta, label: v } })} />
1948
+ <TextField label="URL" value={p.cta?.href ?? ""} onChange={(v) => update({ ...p, cta: { ...p.cta, href: v } })} />
1949
+ </InspectorSection>
1950
+ <InspectorSection title="Secondary button">
1951
+ <LocalizedField label="Label" value={p.secondaryCta?.label} onChange={(v) => update({ ...p, secondaryCta: { ...p.secondaryCta, label: v } })} />
1952
+ <TextField label="URL" value={p.secondaryCta?.href ?? ""} onChange={(v) => update({ ...p, secondaryCta: { ...p.secondaryCta, href: v } })} />
1953
+ </InspectorSection>
1954
+ </>
1955
+ );
1956
+ }
1957
+
1958
+
1959
+ /* ---------------- Image or video — Left/Right ---------------- */
1960
+ const MEDIA_SPLIT_STYLES: Record<
1961
+ NonNullable<MediaSplitProps["style"]>,
1962
+ { label: string; preset: Partial<MediaSplitProps> }
1963
+ > = {
1964
+ editorial: {
1965
+ label: "Editorial (default)",
1966
+ preset: { tone: "white", padding: "normal", mediaRatio: "50", aspect: "landscape", rounded: false, fullBleed: false, align: "center" },
1967
+ },
1968
+ compact: {
1969
+ label: "Compact",
1970
+ preset: { tone: "paper", padding: "compact", mediaRatio: "40", aspect: "square", rounded: false, fullBleed: false, align: "center" },
1971
+ },
1972
+ fullBleed: {
1973
+ label: "Full bleed",
1974
+ preset: { tone: "white", padding: "compact", mediaRatio: "60", aspect: "wide", rounded: false, fullBleed: true, align: "center" },
1975
+ },
1976
+ dark: {
1977
+ label: "Dark",
1978
+ preset: { tone: "ink", padding: "spacious", mediaRatio: "50", aspect: "portrait", rounded: true, fullBleed: false, align: "center" },
1979
+ },
1980
+ };
1981
+
1982
+ function Select<T extends string>({
1983
+ label,
1984
+ value,
1985
+ options,
1986
+ onChange,
1987
+ }: {
1988
+ label: string;
1989
+ value: T;
1990
+ options: { value: T; label: string }[];
1991
+ onChange: (v: T) => void;
1992
+ }) {
1993
+ return (
1994
+ <label className="block space-y-1.5">
1995
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
1996
+ <select
1997
+ value={value}
1998
+ onChange={(e) => onChange(e.target.value as T)}
1999
+ 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"
2000
+ >
2001
+ {options.map((o) => (
2002
+ <option key={o.value} value={o.value}>
2003
+ {o.label}
2004
+ </option>
2005
+ ))}
2006
+ </select>
2007
+ </label>
2008
+ );
2009
+ }
2010
+
2011
+ function Toggle({
2012
+ label,
2013
+ checked,
2014
+ onChange,
2015
+ }: {
2016
+ label: string;
2017
+ checked: boolean;
2018
+ onChange: (v: boolean) => void;
2019
+ }) {
2020
+ return (
2021
+ <label className="flex items-center gap-2 text-sm text-ink">
2022
+ <input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
2023
+ {label}
2024
+ </label>
2025
+ );
2026
+ }
2027
+
2028
+ function MediaSplitInspector({ block, patch }: BlockPatchProps) {
2029
+ const p = (block.props ?? {}) as MediaSplitProps;
2030
+ const update = (next: MediaSplitProps) => patch(next as unknown as BlockProps);
2031
+ const slides = p.slides ?? [];
2032
+ const bullets = p.bullets ?? [];
2033
+ const setSlide = (i: number, s: MediaSplitSlide) => {
2034
+ const next = slides.slice();
2035
+ next[i] = s;
2036
+ update({ ...p, slides: next });
2037
+ };
2038
+ const moveSlide = (i: number, dir: -1 | 1) => {
2039
+ const j = i + dir;
2040
+ if (j < 0 || j >= slides.length) return;
2041
+ const next = slides.slice();
2042
+ [next[i], next[j]] = [next[j], next[i]];
2043
+ update({ ...p, slides: next });
2044
+ };
2045
+ const setBullet = (i: number, b: MediaSplitBullet) => {
2046
+ const next = bullets.slice();
2047
+ next[i] = b;
2048
+ update({ ...p, bullets: next });
2049
+ };
2050
+ const mediaType = p.mediaType ?? "image";
2051
+
2052
+ return (
2053
+ <>
2054
+ <InspectorSection title="Style">
2055
+ <Select
2056
+ label="Preset"
2057
+ value={p.style ?? "editorial"}
2058
+ options={(Object.keys(MEDIA_SPLIT_STYLES) as NonNullable<MediaSplitProps["style"]>[]).map((k) => ({
2059
+ value: k,
2060
+ label: MEDIA_SPLIT_STYLES[k].label,
2061
+ }))}
2062
+ onChange={(v) => update({ ...p, style: v, ...MEDIA_SPLIT_STYLES[v].preset })}
2063
+ />
2064
+ <p className="text-xs text-ink/50">
2065
+ Picking a preset applies its layout defaults — you can still change every setting below.
2066
+ </p>
2067
+ </InspectorSection>
2068
+
2069
+ <InspectorSection title="Content">
2070
+ <LocalizedField label="Eyebrow / subtitle" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
2071
+ <LocalizedField label="Title" multiline value={p.title} onChange={(v) => update({ ...p, title: v })} />
2072
+ <LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
2073
+ <LocalizedField label="Body" multiline value={p.body} onChange={(v) => update({ ...p, body: v })} />
2074
+ </InspectorSection>
2075
+
2076
+ <InspectorSection title={`Bullets (${bullets.length})`}>
2077
+ {bullets.map((b, i) => (
2078
+ <div key={b.id ?? i} className="border border-ink/10 p-3 space-y-2">
2079
+ <div className="flex items-center justify-between">
2080
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
2081
+ <button
2082
+ onClick={() => {
2083
+ const next = bullets.slice();
2084
+ next.splice(i, 1);
2085
+ update({ ...p, bullets: next });
2086
+ }}
2087
+ className="p-1 text-red-500 hover:text-red-700"
2088
+ title="Delete"
2089
+ >
2090
+ <Trash2 className="size-3.5" />
2091
+ </button>
2092
+ </div>
2093
+ <LocalizedField label="Text" value={b.text} onChange={(v) => setBullet(i, { ...b, text: v })} />
2094
+ </div>
2095
+ ))}
2096
+ <button
2097
+ onClick={() => update({ ...p, bullets: [...bullets, { id: uid("b"), text: {} }] })}
2098
+ className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
2099
+ >
2100
+ <Plus className="size-4" /> Add bullet
2101
+ </button>
2102
+ </InspectorSection>
2103
+
2104
+ <InspectorSection title="Buttons">
2105
+ <LocalizedField
2106
+ label="Primary label"
2107
+ value={p.cta?.label}
2108
+ onChange={(v) => update({ ...p, cta: { ...(p.cta ?? {}), label: v } })}
2109
+ />
2110
+ <TextField
2111
+ label="Primary link"
2112
+ value={p.cta?.href ?? ""}
2113
+ onChange={(v) => update({ ...p, cta: { ...(p.cta ?? {}), href: v } })}
2114
+ />
2115
+ <LocalizedField
2116
+ label="Secondary label"
2117
+ value={p.secondaryCta?.label}
2118
+ onChange={(v) => update({ ...p, secondaryCta: { ...(p.secondaryCta ?? {}), label: v } })}
2119
+ />
2120
+ <TextField
2121
+ label="Secondary link"
2122
+ value={p.secondaryCta?.href ?? ""}
2123
+ onChange={(v) => update({ ...p, secondaryCta: { ...(p.secondaryCta ?? {}), href: v } })}
2124
+ />
2125
+ </InspectorSection>
2126
+
2127
+ <InspectorSection title="Layout">
2128
+ <Select
2129
+ label="Media side (desktop)"
2130
+ value={p.mediaSide ?? "left"}
2131
+ options={[
2132
+ { value: "left", label: "Media left / text right" },
2133
+ { value: "right", label: "Text left / media right" },
2134
+ ]}
2135
+ onChange={(v) => update({ ...p, mediaSide: v })}
2136
+ />
2137
+ <Select
2138
+ label="Media width"
2139
+ value={p.mediaRatio ?? "50"}
2140
+ options={[
2141
+ { value: "40", label: "40%" },
2142
+ { value: "50", label: "50%" },
2143
+ { value: "60", label: "60%" },
2144
+ ]}
2145
+ onChange={(v) => update({ ...p, mediaRatio: v })}
2146
+ />
2147
+ <Select
2148
+ label="Vertical alignment"
2149
+ value={p.align ?? "center"}
2150
+ options={[
2151
+ { value: "center", label: "Center" },
2152
+ { value: "top", label: "Top" },
2153
+ ]}
2154
+ onChange={(v) => update({ ...p, align: v })}
2155
+ />
2156
+ <Select
2157
+ label="Background"
2158
+ value={p.tone ?? "white"}
2159
+ options={[
2160
+ { value: "white", label: "White" },
2161
+ { value: "paper", label: "Paper" },
2162
+ { value: "sea", label: "Ice" },
2163
+ { value: "ink", label: "Ink (dark)" },
2164
+ ]}
2165
+ onChange={(v) => update({ ...p, tone: v })}
2166
+ />
2167
+ <Select
2168
+ label="Spacing"
2169
+ value={p.padding ?? "normal"}
2170
+ options={[
2171
+ { value: "compact", label: "Compact" },
2172
+ { value: "normal", label: "Normal" },
2173
+ { value: "spacious", label: "Spacious" },
2174
+ ]}
2175
+ onChange={(v) => update({ ...p, padding: v })}
2176
+ />
2177
+ <Select
2178
+ label="Media aspect"
2179
+ value={p.aspect ?? "landscape"}
2180
+ options={[
2181
+ { value: "landscape", label: "Landscape 4:3" },
2182
+ { value: "wide", label: "Wide 16:9" },
2183
+ { value: "square", label: "Square" },
2184
+ { value: "portrait", label: "Portrait 4:5" },
2185
+ { value: "auto", label: "Auto (natural)" },
2186
+ ]}
2187
+ onChange={(v) => update({ ...p, aspect: v })}
2188
+ />
2189
+ <Select
2190
+ label="Media backdrop"
2191
+ value={p.mediaBg ?? "tint"}
2192
+ options={[
2193
+ { value: "tint", label: "Light tint" },
2194
+ { value: "transparent", label: "Transparent (blend with section)" },
2195
+ ]}
2196
+ onChange={(v) => update({ ...p, mediaBg: v })}
2197
+ />
2198
+ <Select
2199
+ label="Media fit"
2200
+ value={p.mediaFit ?? "cover"}
2201
+ options={[
2202
+ { value: "cover", label: "Fill frame (crop)" },
2203
+ { value: "contain", label: "Fit inside (logos)" },
2204
+ ]}
2205
+ onChange={(v) => update({ ...p, mediaFit: v })}
2206
+ />
2207
+ <Toggle label="Full-bleed width" checked={Boolean(p.fullBleed)} onChange={(v) => update({ ...p, fullBleed: v })} />
2208
+ <Toggle label="Rounded media corners" checked={Boolean(p.rounded)} onChange={(v) => update({ ...p, rounded: v })} />
2209
+ <TextField label="Anchor id (optional)" value={p.anchor ?? ""} onChange={(v) => update({ ...p, anchor: v })} />
2210
+ <p className="text-xs text-ink/50">
2211
+ On mobile the order is always: subtitle and title, then media, then the remaining text.
2212
+ </p>
2213
+ </InspectorSection>
2214
+
2215
+ <InspectorSection title="Media">
2216
+ <Select
2217
+ label="Media type"
2218
+ value={mediaType}
2219
+ options={[
2220
+ { value: "image", label: "Single image" },
2221
+ { value: "slideshow", label: "Image slideshow" },
2222
+ { value: "video", label: "Video" },
2223
+ ]}
2224
+ onChange={(v) => update({ ...p, mediaType: v })}
2225
+ />
2226
+ {mediaType === "image" ? (
2227
+ <>
2228
+ <MediaField label="Image" accept="image" value={p.image} onChange={(url) => update({ ...p, image: url })} />
2229
+ <LocalizedField label="Image alt text" value={p.imageAlt} onChange={(v) => update({ ...p, imageAlt: v })} />
2230
+ </>
2231
+ ) : null}
2232
+ {mediaType === "video" ? (
2233
+ <>
2234
+ <MediaField label="Video" accept="video" value={p.videoSrc} onChange={(url) => update({ ...p, videoSrc: url })} />
2235
+ <MediaField label="Poster image" accept="image" value={p.posterSrc} onChange={(url) => update({ ...p, posterSrc: url })} />
2236
+ <Toggle label="Autoplay" checked={p.videoAutoplay !== false} onChange={(v) => update({ ...p, videoAutoplay: v })} />
2237
+ <Toggle label="Loop" checked={p.videoLoop !== false} onChange={(v) => update({ ...p, videoLoop: v })} />
2238
+ <Toggle label="Muted" checked={p.videoMuted !== false} onChange={(v) => update({ ...p, videoMuted: v })} />
2239
+ <Toggle label="Show controls" checked={Boolean(p.videoControls)} onChange={(v) => update({ ...p, videoControls: v })} />
2240
+ </>
2241
+ ) : null}
2242
+ </InspectorSection>
2243
+
2244
+ {mediaType === "slideshow" ? (
2245
+ <InspectorSection title={`Slides (${slides.length})`}>
2246
+ <Toggle label="Autoplay" checked={Boolean(p.autoplay)} onChange={(v) => update({ ...p, autoplay: v })} />
2247
+ <TextField
2248
+ label="Autoplay interval (ms)"
2249
+ value={String(p.intervalMs ?? 5000)}
2250
+ onChange={(v) => update({ ...p, intervalMs: Number(v) || 5000 })}
2251
+ />
2252
+ <Toggle label="Show pagination dots" checked={p.showDots !== false} onChange={(v) => update({ ...p, showDots: v })} />
2253
+ <Toggle label="Show arrows" checked={p.showArrows !== false} onChange={(v) => update({ ...p, showArrows: v })} />
2254
+ <Toggle label="Loop" checked={p.loop !== false} onChange={(v) => update({ ...p, loop: v })} />
2255
+ <Toggle label="Show captions" checked={Boolean(p.showCaptions)} onChange={(v) => update({ ...p, showCaptions: v })} />
2256
+ {slides.map((s, i) => (
2257
+ <div key={s.id ?? i} className="border border-ink/10 p-3 space-y-2">
2258
+ <div className="flex items-center justify-between">
2259
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
2260
+ <div className="flex items-center gap-1">
2261
+ <button onClick={() => moveSlide(i, -1)} className="p-1 text-ink/60 hover:text-ink" title="Move up">
2262
+ <ArrowUp className="size-3.5" />
2263
+ </button>
2264
+ <button onClick={() => moveSlide(i, 1)} className="p-1 text-ink/60 hover:text-ink" title="Move down">
2265
+ <ArrowDown className="size-3.5" />
2266
+ </button>
2267
+ <button
2268
+ onClick={() => {
2269
+ const next = slides.slice();
2270
+ next.splice(i, 1);
2271
+ update({ ...p, slides: next });
2272
+ }}
2273
+ className="p-1 text-red-500 hover:text-red-700"
2274
+ title="Delete"
2275
+ >
2276
+ <Trash2 className="size-3.5" />
2277
+ </button>
2278
+ </div>
2279
+ </div>
2280
+ <MediaField label="Image" accept="image" value={s.image} onChange={(url) => setSlide(i, { ...s, image: url })} />
2281
+ <LocalizedField label="Alt text" value={s.alt} onChange={(v) => setSlide(i, { ...s, alt: v })} />
2282
+ <LocalizedField label="Caption" value={s.caption} onChange={(v) => setSlide(i, { ...s, caption: v })} />
2283
+ <TextField label="Link (optional)" value={s.href ?? ""} onChange={(v) => setSlide(i, { ...s, href: v })} />
2284
+ </div>
2285
+ ))}
2286
+ <button
2287
+ onClick={() => update({ ...p, slides: [...slides, { id: uid("s"), image: "", alt: {} }] })}
2288
+ className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
2289
+ >
2290
+ <Plus className="size-4" /> Add slide
2291
+ </button>
2292
+ </InspectorSection>
2293
+ ) : null}
2294
+ </>
2295
+ );
2296
+ }
2297
+
2298
+
2299
+ export {
2300
+ HeroInspector,
2301
+ IntroInspector,
2302
+ FaqInspector,
2303
+ InstagramInspector,
2304
+ ProseInspector,
2305
+ RichTextInspector,
2306
+ HeroFullInspector,
2307
+ ImageMarqueeInspector,
2308
+ LinkTilesInspector,
2309
+ CabinTabsInspector,
2310
+ PriceTableInspector,
2311
+ IconGridInspector,
2312
+ ChecklistInspector,
2313
+ CtaBannerInspector,
2314
+ MediaSplitInspector,
2315
+ Select as InspectorSelect,
2316
+ Toggle as InspectorToggle,
2317
+ uid as inspectorUid,
2318
+ };