@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,436 @@
1
+ import { useRef, useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { toast } from "sonner";
4
+ import { X, Image as ImageIcon, Upload, Loader2 } from "lucide-react";
5
+
6
+ import type { LocalizedString } from "../../i18n";
7
+ import { SUPPORTED_LOCALES, LOCALE_LABELS, type Locale } from "../../i18n";
8
+
9
+
10
+ import {
11
+ FocalImage,
12
+ FocusPicker,
13
+ useHostMediaLibraryOptions,
14
+ useHostMediaUpload,
15
+ type HostMediaItem as MediaItem,
16
+ } from "../../host";
17
+
18
+ type LocValue = Partial<LocalizedString> | undefined;
19
+
20
+ export function LocalizedField({
21
+ label,
22
+ value,
23
+ onChange,
24
+ multiline,
25
+ placeholder,
26
+ }: {
27
+ label: string;
28
+ value: LocValue;
29
+ onChange: (next: Partial<LocalizedString>) => void;
30
+ multiline?: boolean;
31
+ placeholder?: Partial<Record<Locale, string>>;
32
+ }) {
33
+ const v = (value ?? {}) as Record<string, string>;
34
+ return (
35
+ <div className="space-y-1.5">
36
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
37
+ {label}
38
+ </div>
39
+ <div className="space-y-2">
40
+ {SUPPORTED_LOCALES.map((loc) => {
41
+ const commonProps = {
42
+ value: v[loc] ?? "",
43
+ placeholder: placeholder?.[loc] ?? "",
44
+ onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
45
+ onChange({ ...v, [loc]: e.target.value } as Partial<LocalizedString>),
46
+ className:
47
+ "w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea",
48
+ };
49
+ return (
50
+ <div key={loc} className="flex gap-2 items-start">
51
+ <span className="mt-1.5 shrink-0 text-[0.65rem] uppercase tracking-widest text-ink/45 w-8">
52
+ {LOCALE_LABELS[loc] ?? loc}
53
+ </span>
54
+ {multiline ? (
55
+ <textarea rows={3} {...commonProps} />
56
+ ) : (
57
+ <input type="text" {...commonProps} />
58
+ )}
59
+ </div>
60
+ );
61
+ })}
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ export function TextField({
68
+ label,
69
+ value,
70
+ onChange,
71
+ placeholder,
72
+ }: {
73
+ label: string;
74
+ value: string | undefined;
75
+ onChange: (v: string) => void;
76
+ placeholder?: string;
77
+ }) {
78
+ return (
79
+ <label className="block space-y-1.5">
80
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
81
+ {label}
82
+ </span>
83
+ <input
84
+ type="text"
85
+ value={value ?? ""}
86
+ placeholder={placeholder}
87
+ onChange={(e) => onChange(e.target.value)}
88
+ 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"
89
+ />
90
+ </label>
91
+ );
92
+ }
93
+
94
+ export function InspectorSection({
95
+ title,
96
+ children,
97
+ }: {
98
+ title: string;
99
+ children: React.ReactNode;
100
+ }) {
101
+ return (
102
+ <section className="space-y-3 border-b border-ink/10 pb-5 last:border-b-0 last:pb-0">
103
+ <div className="text-xs font-medium uppercase tracking-[0.18em] text-ink">
104
+ {title}
105
+ </div>
106
+ <div className="space-y-4">{children}</div>
107
+ </section>
108
+ );
109
+ }
110
+
111
+ /* ---------------- Media picker ---------------- */
112
+
113
+ export function MediaPickerDialog({
114
+ accept,
115
+ onPick,
116
+ onPickMany,
117
+ onClose,
118
+ multiple,
119
+ }: {
120
+ accept?: "image" | "video" | "any";
121
+ onPick: (item: MediaItem) => void;
122
+ onPickMany?: (items: MediaItem[]) => void;
123
+ onClose: () => void;
124
+ multiple?: boolean;
125
+ }) {
126
+ const mediaLibraryOptions = useHostMediaLibraryOptions();
127
+ const { data = [], isLoading } = useQuery(mediaLibraryOptions);
128
+ const [q, setQ] = useState("");
129
+ const [selected, setSelected] = useState<MediaItem[]>([]);
130
+ const fileInput = useRef<HTMLInputElement>(null);
131
+ const { upload, uploading, progress } = useHostMediaUpload(data);
132
+ const acceptAttr = accept === "image" ? "image/*" : accept === "video" ? "video/*" : undefined;
133
+
134
+ const handleFiles = async (files: FileList | null) => {
135
+ if (!files?.length) return;
136
+ try {
137
+ const urls = await upload(files);
138
+ if (fileInput.current) fileInput.current.value = "";
139
+ const items = urls.map((last) => {
140
+ const name = decodeURIComponent(last.split("/").pop() ?? "");
141
+ return {
142
+ name,
143
+ path: name,
144
+ size: 0,
145
+ contentType: /\.(mp4|webm|mov)$/i.test(name) ? "video/mp4" : "image/webp",
146
+ updatedAt: null,
147
+ url: last,
148
+ } as MediaItem;
149
+ });
150
+ if (!items.length) return;
151
+ toast.success(`Uploaded ${items.length} file${items.length > 1 ? "s" : ""}`);
152
+ if (multiple) {
153
+ setSelected((prev) => {
154
+ const seen = new Set(prev.map((m) => m.path));
155
+ return [...prev, ...items.filter((m) => !seen.has(m.path))];
156
+ });
157
+ } else {
158
+ onPick(items[items.length - 1]);
159
+ onClose();
160
+ }
161
+ } catch (e: any) {
162
+ toast.error(e?.message ?? "Upload failed");
163
+ }
164
+ };
165
+
166
+ const toggle = (m: MediaItem) => {
167
+ if (!multiple) {
168
+ onPick(m);
169
+ onClose();
170
+ return;
171
+ }
172
+ setSelected((prev) =>
173
+ prev.some((x) => x.path === m.path)
174
+ ? prev.filter((x) => x.path !== m.path)
175
+ : [...prev, m],
176
+ );
177
+ };
178
+
179
+ const confirm = () => {
180
+ if (onPickMany) onPickMany(selected);
181
+ else selected.forEach(onPick);
182
+ onClose();
183
+ };
184
+
185
+
186
+ const filtered = data.filter((m) => {
187
+ if (accept === "image" && !(m.contentType ?? "").startsWith("image/")) return false;
188
+ if (accept === "video" && !(m.contentType ?? "").startsWith("video/")) return false;
189
+ if (q && !m.name.toLowerCase().includes(q.toLowerCase())) return false;
190
+ return true;
191
+ });
192
+ return (
193
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
194
+ <div
195
+ className="w-full max-w-4xl max-h-[85vh] bg-white border border-ink/15 flex flex-col"
196
+ onClick={(e) => e.stopPropagation()}
197
+ >
198
+ <div className="flex items-center gap-2 border-b border-ink/10 p-3">
199
+ <ImageIcon className="size-4 text-ink/60" />
200
+ <span className="text-sm font-medium text-ink">Media library</span>
201
+ <input
202
+ value={q}
203
+ onChange={(e) => setQ(e.target.value)}
204
+ placeholder="Search…"
205
+ className="ml-3 flex-1 border border-ink/15 px-2 py-1 text-sm focus:outline-none focus:border-sea"
206
+ />
207
+ <input
208
+ ref={fileInput}
209
+ type="file"
210
+ multiple
211
+ accept={acceptAttr}
212
+ className="hidden"
213
+ onChange={(e) => handleFiles(e.target.files)}
214
+ />
215
+ <button
216
+ type="button"
217
+ disabled={uploading}
218
+ onClick={() => fileInput.current?.click()}
219
+ className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1 text-xs uppercase tracking-widest text-ink hover:border-sea disabled:opacity-50"
220
+ >
221
+ {uploading ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
222
+ {uploading ? "Uploading…" : "Upload"}
223
+ </button>
224
+ <button onClick={onClose} className="text-ink/50 hover:text-ink p-1"><X className="size-4" /></button>
225
+ </div>
226
+ {progress ? (
227
+ <div className="border-b border-ink/10 px-3 py-2">
228
+ <div className="mb-1 flex items-center justify-between text-[0.7rem] text-ink/60">
229
+ <span className="truncate">{progress.label}</span>
230
+ <span>{progress.percent}%</span>
231
+ </div>
232
+ <div className="h-1.5 w-full bg-ice/60 overflow-hidden">
233
+ <div
234
+ className="h-full bg-sea transition-all duration-200"
235
+ style={{ width: `${progress.percent}%` }}
236
+ />
237
+ </div>
238
+ </div>
239
+ ) : null}
240
+ <div className="flex-1 overflow-auto p-3">
241
+ {isLoading ? (
242
+ <div className="text-sm text-ink/60">Loading…</div>
243
+ ) : filtered.length === 0 ? (
244
+ <div className="text-sm text-ink/60">No media yet — use Upload above to add files.</div>
245
+
246
+ ) : (
247
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
248
+ {filtered.map((m) => {
249
+ const isImg = (m.contentType ?? "").startsWith("image/");
250
+ const isVid = (m.contentType ?? "").startsWith("video/");
251
+ const isSel = multiple && selected.some((x) => x.path === m.path);
252
+ return (
253
+ <button
254
+ key={m.path}
255
+ onClick={() => toggle(m)}
256
+ className={`group border text-left relative ${
257
+ isSel ? "border-sea ring-2 ring-sea/40" : "border-ink/10 hover:border-sea"
258
+ }`}
259
+ >
260
+ {isSel && (
261
+ <span className="absolute top-2 right-2 z-10 flex size-5 items-center justify-center bg-sea text-ink text-xs font-bold">✓</span>
262
+ )}
263
+ <div className="aspect-video bg-ice/40 flex items-center justify-center overflow-hidden">
264
+ {isImg ? (
265
+ <img src={m.url} alt="" className="h-full w-full object-cover" />
266
+ ) : isVid ? (
267
+ <video src={m.url} muted className="h-full w-full object-cover" />
268
+ ) : (
269
+ <span className="text-xs text-ink/50">{m.contentType ?? "file"}</span>
270
+ )}
271
+ </div>
272
+ <div className="px-2 py-1.5 text-[0.7rem] text-ink truncate">{m.name}</div>
273
+ </button>
274
+ );
275
+ })}
276
+ </div>
277
+ )}
278
+ </div>
279
+ {multiple && (
280
+ <div className="flex items-center justify-end gap-2 border-t border-ink/10 p-3">
281
+ <span className="text-xs text-ink/60">{selected.length} selected</span>
282
+ <button
283
+ type="button"
284
+ onClick={confirm}
285
+ disabled={selected.length === 0}
286
+ className="inline-flex items-center gap-1.5 bg-ink px-4 py-1.5 text-xs uppercase tracking-widest text-white hover:bg-sea hover:text-ink disabled:opacity-40"
287
+ >
288
+ Add selected
289
+ </button>
290
+ </div>
291
+ )}
292
+ </div>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ export function MediaField({
298
+ label,
299
+ value,
300
+ onChange,
301
+ accept = "any",
302
+ placeholder,
303
+ }: {
304
+ label: string;
305
+ value: string | undefined;
306
+ onChange: (url: string) => void;
307
+ accept?: "image" | "video" | "any";
308
+ placeholder?: string;
309
+ }) {
310
+ const [open, setOpen] = useState(false);
311
+ const [focusOpen, setFocusOpen] = useState(false);
312
+ const isImg = value && /\.(png|jpe?g|gif|webp|avif|svg)(\?|$)/i.test(value);
313
+ const isVid = value && /\.(mp4|webm|mov)(\?|$)/i.test(value);
314
+ return (
315
+ <div className="space-y-1.5">
316
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</div>
317
+ {value ? (
318
+ <div className="border border-ink/10 bg-ice/30 aspect-video overflow-hidden flex items-center justify-center">
319
+ {isImg ? (
320
+ <FocalImage src={value} responsive={false} alt="" className="h-full w-full object-cover" />
321
+ ) : isVid ? (
322
+ <video src={value} muted className="h-full w-full object-cover" />
323
+ ) : (
324
+ <span className="text-xs text-ink/50 p-2 break-all">{value}</span>
325
+ )}
326
+ </div>
327
+ ) : null}
328
+ <div className="flex flex-wrap items-center gap-2">
329
+ <input
330
+ type="text"
331
+ value={value ?? ""}
332
+ placeholder={placeholder ?? "https://…"}
333
+ onChange={(e) => onChange(e.target.value)}
334
+ className="min-w-0 flex-1 basis-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
335
+ />
336
+
337
+ <button
338
+ type="button"
339
+ onClick={() => setOpen(true)}
340
+ className="border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-widest text-ink hover:border-sea"
341
+ >
342
+ Library
343
+ </button>
344
+ {isImg ? (
345
+ <button
346
+ type="button"
347
+ onClick={() => setFocusOpen(true)}
348
+ className="border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-widest text-ink hover:border-sea"
349
+ title="Choose which part of the image stays visible when cropped"
350
+ >
351
+ Focus
352
+ </button>
353
+ ) : null}
354
+ {value ? (
355
+ <button
356
+ type="button"
357
+ onClick={() => onChange("")}
358
+ className="border border-ink/20 px-2 py-1.5 text-ink/50 hover:text-red-600"
359
+ title="Clear"
360
+ ><X className="size-4" /></button>
361
+ ) : null}
362
+ </div>
363
+ {open && (
364
+ <MediaPickerDialog
365
+ accept={accept}
366
+ onPick={(m) => onChange(m.url)}
367
+ onClose={() => setOpen(false)}
368
+ />
369
+ )}
370
+ {focusOpen && value ? (
371
+ <FocusPicker
372
+ src={value}
373
+ modal
374
+ title={value.split("/").pop()}
375
+ onClose={() => setFocusOpen(false)}
376
+ />
377
+ ) : null}
378
+
379
+ </div>
380
+ );
381
+ }
382
+
383
+ export function MediaMultiField({
384
+ label,
385
+ values,
386
+ onChange,
387
+ accept = "image",
388
+ }: {
389
+ label: string;
390
+ values: string[];
391
+ onChange: (next: string[]) => void;
392
+ accept?: "image" | "video" | "any";
393
+ }) {
394
+ const [open, setOpen] = useState(false);
395
+ const add = (items: MediaItem[]) => {
396
+ const seen = new Set(values);
397
+ const fresh = items.filter((m) => m.url && !seen.has(m.url)).map((m) => m.url);
398
+ if (fresh.length) onChange([...values, ...fresh]);
399
+ };
400
+ return (
401
+ <div className="space-y-2">
402
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</div>
403
+ <div className="flex flex-wrap gap-2">
404
+ {values.map((url, i) => (
405
+ <div key={`${url}-${i}`} className="relative group border border-ink/10">
406
+ <img src={url} alt="" className="size-16 object-cover" />
407
+ <button
408
+ type="button"
409
+ onClick={() => onChange(values.filter((_, j) => j !== i))}
410
+ className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center bg-ink text-white hover:bg-red-600"
411
+ title="Remove"
412
+ ><X className="size-3" /></button>
413
+ </div>
414
+ ))}
415
+ <button
416
+ type="button"
417
+ onClick={() => setOpen(true)}
418
+ className="flex size-16 items-center justify-center border border-dashed border-ink/30 text-ink/50 hover:border-sea hover:text-ink"
419
+ title="Add images"
420
+ >
421
+ <ImageIcon className="size-5" />
422
+ </button>
423
+ </div>
424
+ {open && (
425
+ <MediaPickerDialog
426
+ accept={accept}
427
+ multiple
428
+ onPick={(m) => add([m])}
429
+ onPickMany={(items) => add(items)}
430
+ onClose={() => setOpen(false)}
431
+ />
432
+ )}
433
+
434
+ </div>
435
+ );
436
+ }
@@ -0,0 +1,36 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import type { BlockProps } from "./types";
3
+ import type { LocalizedString } from "../i18n";
4
+
5
+ const Ctx = createContext<BlockProps>({});
6
+
7
+ export function BlockPropsProvider({
8
+ value,
9
+ children,
10
+ }: {
11
+ value: BlockProps | undefined;
12
+ children: ReactNode;
13
+ }) {
14
+ return <Ctx.Provider value={value ?? {}}>{children}</Ctx.Provider>;
15
+ }
16
+
17
+ export function useBlockProps<T = BlockProps>(): T {
18
+ return useContext(Ctx) as unknown as T;
19
+ }
20
+
21
+ /**
22
+ * Merge an override localized string over a base one. Only non-empty
23
+ * string values in the override are applied; missing/empty locales fall
24
+ * back to the base value so admins can translate only what they need.
25
+ */
26
+ export function mergeLocalized(
27
+ override: Partial<LocalizedString> | undefined | null,
28
+ base: LocalizedString,
29
+ ): LocalizedString {
30
+ if (!override) return base;
31
+ const out: Record<string, string> = { ...(base as unknown as Record<string, string>) };
32
+ for (const [k, v] of Object.entries(override)) {
33
+ if (typeof v === "string" && v.trim() !== "") out[k] = v;
34
+ }
35
+ return out as unknown as LocalizedString;
36
+ }
@@ -0,0 +1,167 @@
1
+ import { useState } from "react";
2
+ import { Check, ChevronLeft, ChevronRight } from "lucide-react";
3
+ import { useT } from "../../i18n";
4
+ import { Reveal } from "../../ui/Reveal";
5
+ import { FocalImage } from "../../host";
6
+ import type { CabinTabsProps } from "../types";
7
+
8
+ const TONE: Record<string, string> = {
9
+ white: "bg-white",
10
+ paper: "bg-paper",
11
+ ice: "bg-ice",
12
+ };
13
+
14
+ /**
15
+ * Cabin categories as clickable tabs — each tab keeps its own description,
16
+ * bullet list and image gallery. Everything is editable in the page builder.
17
+ */
18
+ export function CabinTabsBlock({ props }: { props: CabinTabsProps }) {
19
+ const { t } = useT();
20
+ const items = props.items ?? [];
21
+ const [active, setActive] = useState(0);
22
+ const [slide, setSlide] = useState(0);
23
+
24
+ if (items.length === 0) return null;
25
+
26
+ const item = items[Math.min(active, items.length - 1)];
27
+ const images = (item.images ?? []).filter((i) => i.src);
28
+ const image = images.length > 0 ? images[slide % images.length] : undefined;
29
+ const tone = TONE[props.tone ?? "white"] ?? "bg-white";
30
+
31
+ const select = (i: number) => {
32
+ setActive(i);
33
+ setSlide(0);
34
+ };
35
+
36
+ return (
37
+ <section id={props.anchor || undefined} className={`${tone} py-16 md:py-24`}>
38
+ <div className="container-editorial">
39
+ {props.eyebrow || props.title || props.lead ? (
40
+ <Reveal>
41
+ <div className="max-w-2xl">
42
+ {props.eyebrow ? (
43
+ <span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
44
+ {t(props.eyebrow)}
45
+ </span>
46
+ ) : null}
47
+ {props.title ? (
48
+ <h2 className="mt-3 font-serif text-[clamp(1.8rem,3.6vw,2.9rem)] leading-[1.06] text-ink">
49
+ {t(props.title)}
50
+ </h2>
51
+ ) : null}
52
+ {props.lead ? (
53
+ <p className="mt-4 text-base leading-relaxed text-ink/70 whitespace-pre-line">
54
+ {t(props.lead)}
55
+ </p>
56
+ ) : null}
57
+ </div>
58
+ </Reveal>
59
+ ) : null}
60
+
61
+ <Reveal>
62
+ <div className="mt-10 grid gap-8 lg:grid-cols-[minmax(0,15rem)_minmax(0,1fr)_minmax(0,1.05fr)] lg:gap-10">
63
+ {/* Tabs */}
64
+ <div
65
+ role="tablist"
66
+ aria-label={t(props.title) || "Categories"}
67
+ className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
68
+ >
69
+ {items.map((it, i) => {
70
+ const on = i === Math.min(active, items.length - 1);
71
+ return (
72
+ <button
73
+ key={it.id ?? i}
74
+ role="tab"
75
+ aria-selected={on}
76
+ onClick={() => select(i)}
77
+ className={`shrink-0 border px-4 py-3 text-left text-[0.7rem] uppercase tracking-[0.2em] transition-colors lg:shrink ${
78
+ on
79
+ ? "border-sea bg-sea text-white"
80
+ : "border-ink/12 bg-white text-ink/70 hover:border-sea hover:text-ink"
81
+ }`}
82
+ >
83
+ {t(it.label)}
84
+ </button>
85
+ );
86
+ })}
87
+ </div>
88
+
89
+ {/* Copy */}
90
+ <div className="min-w-0">
91
+ <h3 className="font-serif text-2xl md:text-3xl text-sea">
92
+ {t(item.title) || t(item.label)}
93
+ </h3>
94
+ {item.body ? (
95
+ <p className="mt-4 text-base leading-relaxed text-ink/75 whitespace-pre-line">
96
+ {t(item.body)}
97
+ </p>
98
+ ) : null}
99
+ {(item.bullets ?? []).length > 0 ? (
100
+ <ul className="mt-6 space-y-2.5">
101
+ {(item.bullets ?? []).map((b, i) => (
102
+ <li key={b.id ?? i} className="flex items-start gap-3">
103
+ <Check className="mt-1 size-4 shrink-0 text-sea" aria-hidden />
104
+ <span className="leading-relaxed text-ink/85">{t(b.text)}</span>
105
+ </li>
106
+ ))}
107
+ </ul>
108
+ ) : null}
109
+ </div>
110
+
111
+ {/* Gallery */}
112
+ <div className="min-w-0">
113
+ <div className="relative aspect-[4/3] w-full overflow-hidden bg-ink/10">
114
+ {image?.src ? (
115
+ <FocalImage
116
+ key={`${item.id}-${slide}`}
117
+ src={image.src}
118
+ alt={t(image.alt) || t(item.label) || ""}
119
+ loading="lazy"
120
+ sizes="(max-width: 1024px) 92vw, 40vw"
121
+ className="absolute inset-0 h-full w-full object-cover"
122
+ />
123
+ ) : null}
124
+ {images.length > 1 ? (
125
+ <>
126
+ <button
127
+ onClick={() => setSlide((s) => (s - 1 + images.length) % images.length)}
128
+ aria-label="Föregående bild"
129
+ className="absolute left-0 top-1/2 -translate-y-1/2 bg-ink/45 p-3 text-white hover:bg-ink/70"
130
+ >
131
+ <ChevronLeft className="size-5" />
132
+ </button>
133
+ <button
134
+ onClick={() => setSlide((s) => (s + 1) % images.length)}
135
+ aria-label="Nästa bild"
136
+ className="absolute right-0 top-1/2 -translate-y-1/2 bg-ink/45 p-3 text-white hover:bg-ink/70"
137
+ >
138
+ <ChevronRight className="size-5" />
139
+ </button>
140
+ <div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-2">
141
+ {images.map((im, i) => (
142
+ <button
143
+ key={im.id ?? i}
144
+ onClick={() => setSlide(i)}
145
+ aria-label={`Bild ${i + 1}`}
146
+ className={`size-2 rounded-full ${
147
+ i === slide % images.length ? "bg-white" : "bg-white/50"
148
+ }`}
149
+ />
150
+ ))}
151
+ </div>
152
+ </>
153
+ ) : null}
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </Reveal>
158
+
159
+ {props.note ? (
160
+ <p className="mt-8 max-w-3xl text-sm leading-relaxed text-ink/65 whitespace-pre-line">
161
+ {t(props.note)}
162
+ </p>
163
+ ) : null}
164
+ </div>
165
+ </section>
166
+ );
167
+ }