@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,427 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { flushSync } from "react-dom";
3
+ import { ArrowLeft, ArrowRight, Check, Image as ImageIcon, Play } from "lucide-react";
4
+
5
+ import { useT } from "../../i18n";
6
+ import { Reveal } from "../../ui/Reveal";
7
+ import { Lightbox } from "../../ui/Lightbox";
8
+ import { FocalImage } from "../../host";
9
+ import { LazyVideo } from "../../host";
10
+ import type { MediaSplitProps, MediaSplitSlide } from "../types";
11
+
12
+
13
+ const PADDING: Record<NonNullable<MediaSplitProps["padding"]>, string> = {
14
+ compact: "py-12 md:py-16",
15
+ normal: "py-20 md:py-28",
16
+ spacious: "py-28 md:py-40",
17
+ };
18
+
19
+ const ASPECT: Record<NonNullable<MediaSplitProps["aspect"]>, string> = {
20
+ square: "aspect-square",
21
+ portrait: "aspect-[4/5]",
22
+ landscape: "aspect-[4/3]",
23
+ wide: "aspect-video",
24
+ auto: "",
25
+ };
26
+
27
+ /** Column template depends on both the media ratio and which side it sits on. */
28
+ function columnsClass(
29
+ ratio: NonNullable<MediaSplitProps["mediaRatio"]>,
30
+ side: NonNullable<MediaSplitProps["mediaSide"]>,
31
+ ) {
32
+ if (ratio === "50") return "md:grid-cols-2";
33
+ const mediaBigger = ratio === "60";
34
+ const mediaFirst = side === "left";
35
+ if (mediaBigger === mediaFirst) return "md:grid-cols-[3fr_2fr]";
36
+ return "md:grid-cols-[2fr_3fr]";
37
+ }
38
+
39
+ function Slideshow({
40
+ slides,
41
+ props,
42
+ aspectClass,
43
+ radius,
44
+ }: {
45
+ slides: MediaSplitSlide[];
46
+ props: MediaSplitProps;
47
+ aspectClass: string;
48
+ radius: string;
49
+ }) {
50
+ const { t } = useT();
51
+ const trackRef = useRef<HTMLDivElement>(null);
52
+ const [index, setIndex] = useState(0);
53
+ const indexRef = useRef(0);
54
+ const [inView, setInView] = useState(false);
55
+ const count = slides.length;
56
+
57
+ const goTo = useCallback((i: number) => {
58
+ const el = trackRef.current;
59
+ if (!el) return;
60
+ const child = el.children[i] as HTMLElement | undefined;
61
+ if (!child) return;
62
+ el.scrollTo({ left: child.offsetLeft - el.offsetLeft, behavior: "smooth" });
63
+ indexRef.current = i;
64
+ setIndex(i);
65
+ }, []);
66
+
67
+ const step = useCallback(
68
+ (dir: -1 | 1) => {
69
+ if (count === 0) return;
70
+ let next = indexRef.current + dir;
71
+ const loop = props.loop !== false;
72
+ if (next < 0) next = loop ? count - 1 : 0;
73
+ if (next > count - 1) next = loop ? 0 : count - 1;
74
+ goTo(next);
75
+ },
76
+ [count, goTo, props.loop],
77
+ );
78
+
79
+ useEffect(() => {
80
+ const el = trackRef.current;
81
+ if (!el) return;
82
+ const io = new IntersectionObserver(
83
+ ([entry]) => setInView(entry.isIntersecting),
84
+ { threshold: 0.25 },
85
+ );
86
+ io.observe(el);
87
+ return () => io.disconnect();
88
+ }, []);
89
+
90
+ useEffect(() => {
91
+ if (!props.autoplay || !inView || count < 2) return;
92
+ const ms = Math.max(1500, props.intervalMs ?? 5000);
93
+ const id = window.setInterval(() => step(1), ms);
94
+ return () => window.clearInterval(id);
95
+ }, [props.autoplay, props.intervalMs, inView, count, step]);
96
+
97
+ const onScroll = () => {
98
+ const el = trackRef.current;
99
+ if (!el) return;
100
+ const children = Array.from(el.children) as HTMLElement[];
101
+ const mid = el.scrollLeft + el.clientWidth / 2;
102
+ let nearest = 0;
103
+ let best = Infinity;
104
+ children.forEach((c, i) => {
105
+ const center = c.offsetLeft - el.offsetLeft + c.clientWidth / 2;
106
+ const d = Math.abs(center - mid);
107
+ if (d < best) {
108
+ best = d;
109
+ nearest = i;
110
+ }
111
+ });
112
+ indexRef.current = nearest;
113
+ setIndex(nearest);
114
+ };
115
+
116
+ return (
117
+ <div className="relative">
118
+ <div
119
+ ref={trackRef}
120
+ onScroll={onScroll}
121
+ className={`flex snap-x snap-mandatory overflow-x-auto scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${radius}`}
122
+ >
123
+ {slides.map((s, i) => {
124
+ const alt = s.alt ? t(s.alt as Parameters<typeof t>[0]) : "";
125
+ const media = (
126
+ <div className={`relative w-full ${props.mediaBg === "transparent" ? "" : "bg-ink/5"} overflow-hidden ${aspectClass}`}>
127
+ {s.image ? (
128
+ <FocalImage
129
+ src={s.image}
130
+ sizes="(min-width: 768px) 50vw, 100vw"
131
+ alt={alt}
132
+ loading={i === 0 ? "eager" : "lazy"}
133
+ className={`absolute inset-0 h-full w-full ${props.mediaFit === "contain" ? "object-contain" : "object-cover"}`}
134
+ />
135
+ ) : (
136
+ <div className="absolute inset-0 grid place-items-center text-ink/25">
137
+ <ImageIcon className="size-8" />
138
+ </div>
139
+ )}
140
+ </div>
141
+ );
142
+ return (
143
+ <div key={s.id ?? i} className="w-full shrink-0 snap-center">
144
+ {s.href ? (
145
+ <a href={s.href} target="_blank" rel="noreferrer noopener" className="block">
146
+ {media}
147
+ </a>
148
+ ) : (
149
+ media
150
+ )}
151
+ {props.showCaptions && s.caption ? (
152
+ <p className="mt-3 text-sm text-ink/65 whitespace-pre-line">{t(s.caption as Parameters<typeof t>[0])}</p>
153
+ ) : null}
154
+ </div>
155
+ );
156
+ })}
157
+ </div>
158
+
159
+ {props.showArrows !== false && count > 1 ? (
160
+ <div className="pointer-events-none absolute inset-y-0 left-0 right-0 flex items-center justify-between px-3">
161
+ <button
162
+ type="button"
163
+ aria-label="Previous"
164
+ onClick={() => step(-1)}
165
+ className="pointer-events-auto grid size-10 place-items-center bg-paper/85 text-ink hover:bg-paper transition-colors"
166
+ >
167
+ <ArrowLeft className="size-4" />
168
+ </button>
169
+ <button
170
+ type="button"
171
+ aria-label="Next"
172
+ onClick={() => step(1)}
173
+ className="pointer-events-auto grid size-10 place-items-center bg-paper/85 text-ink hover:bg-paper transition-colors"
174
+ >
175
+ <ArrowRight className="size-4" />
176
+ </button>
177
+ </div>
178
+ ) : null}
179
+
180
+ {props.showDots !== false && count > 1 ? (
181
+ <div className="mt-4 flex justify-center gap-2">
182
+ {slides.map((s, i) => (
183
+ <button
184
+ key={s.id ?? i}
185
+ type="button"
186
+ aria-label={`Go to slide ${i + 1}`}
187
+ onClick={() => goTo(i)}
188
+ className={`h-1.5 transition-all ${
189
+ i === index ? "w-8 bg-sea" : "w-3 bg-ink/25 hover:bg-ink/45"
190
+ }`}
191
+ />
192
+ ))}
193
+ </div>
194
+ ) : null}
195
+ </div>
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Split section: image / slideshow / video on one side, editorial text on the
201
+ * other. Mirrorable. On mobile the order is always eyebrow + title, media,
202
+ * then the rest of the text.
203
+ */
204
+ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
205
+ const { t } = useT();
206
+ const side = props.mediaSide ?? "left";
207
+ const tone = props.tone ?? "white";
208
+ const dark = tone === "ink";
209
+ const bg =
210
+ tone === "ink"
211
+ ? "bg-ink text-paper"
212
+ : tone === "sea"
213
+ ? "bg-ice text-ink"
214
+ : tone === "paper"
215
+ ? "bg-paper text-ink"
216
+ : "bg-white text-ink";
217
+ const muted = dark ? "text-paper/80" : "text-ink/80";
218
+ const eyebrowTone = dark ? "text-paper/70" : "text-ink/70";
219
+ const radius = props.rounded ? "rounded-2xl overflow-hidden" : "";
220
+ const aspectClass = ASPECT[props.aspect ?? "landscape"];
221
+ const mediaBgClass = props.mediaBg === "transparent" ? "" : "bg-ink/5";
222
+ const fitClass = props.mediaFit === "contain" ? "object-contain" : "object-cover";
223
+ const items = (props.slides ?? []).filter((s) => s.image || s.caption);
224
+ const bullets = props.bullets ?? [];
225
+ const [videoOpen, setVideoOpen] = useState(false);
226
+ const lightboxVideoRef = useRef<HTMLVideoElement>(null);
227
+
228
+ const openVideoWithSound = () => {
229
+ // Mount and start the lightbox player inside the initiating click. This
230
+ // preserves the browser's user-activation window for unmuted playback.
231
+ flushSync(() => setVideoOpen(true));
232
+ const video = lightboxVideoRef.current;
233
+ if (!video) return;
234
+ video.muted = false;
235
+ video.defaultMuted = false;
236
+ video.volume = 1;
237
+ video.play().catch(() => {
238
+ // Keep the player unmuted: native controls let the user start playback
239
+ // if this browser still requires a second explicit media interaction.
240
+ });
241
+ };
242
+
243
+
244
+
245
+ const primaryBtn = dark
246
+ ? "bg-paper text-ink hover:bg-sea"
247
+ : "bg-ink text-paper hover:bg-ink/85";
248
+ const secondaryBtn = dark
249
+ ? "border border-paper/60 text-paper hover:bg-paper hover:text-ink"
250
+ : "border border-ink/30 text-ink hover:bg-ink hover:text-paper";
251
+
252
+ const media = (() => {
253
+ if (props.mediaType === "video") {
254
+ if (!props.videoSrc && !props.posterSrc) return null;
255
+ return (
256
+ <div className={`relative w-full ${mediaBgClass} overflow-hidden ${aspectClass} ${radius}`}>
257
+ {props.videoSrc ? (
258
+ <>
259
+ <LazyVideo
260
+ src={props.videoSrc}
261
+ poster={props.posterSrc || undefined}
262
+ autoPlay={props.videoAutoplay !== false}
263
+ loop={props.videoLoop !== false}
264
+ muted={props.videoMuted !== false}
265
+ controls={props.videoControls ?? false}
266
+ preload="metadata"
267
+ className={`absolute inset-0 h-full w-full ${fitClass}`}
268
+ />
269
+ <button
270
+ type="button"
271
+ onClick={openVideoWithSound}
272
+ aria-label="Play video"
273
+ className="group absolute inset-0 flex items-end justify-end p-4 sm:p-5 bg-ink/0 transition-colors hover:bg-ink/25"
274
+ >
275
+ <span className="grid size-14 sm:size-16 place-items-center rounded-full bg-paper/85 text-ink shadow-lg transition-transform group-hover:scale-110">
276
+ <Play className="size-5 sm:size-6 translate-x-[2px]" />
277
+ </span>
278
+
279
+ </button>
280
+ </>
281
+ ) : (
282
+ <FocalImage
283
+ src={props.posterSrc}
284
+ alt=""
285
+ className={`absolute inset-0 h-full w-full ${fitClass}`}
286
+ />
287
+ )}
288
+ </div>
289
+ );
290
+ }
291
+
292
+ if (props.mediaType === "slideshow") {
293
+ if (items.length === 0) return null;
294
+ return <Slideshow slides={items} props={props} aspectClass={aspectClass} radius={radius} />;
295
+ }
296
+ if (!props.image) return null;
297
+ return (
298
+ <div className={`relative w-full ${mediaBgClass} overflow-hidden ${aspectClass} ${radius}`}>
299
+ <FocalImage
300
+ src={props.image}
301
+ sizes="(min-width: 768px) 50vw, 100vw"
302
+ alt={props.imageAlt ? t(props.imageAlt as Parameters<typeof t>[0]) : ""}
303
+ className={`absolute inset-0 h-full w-full ${fitClass}`}
304
+ />
305
+ </div>
306
+ );
307
+ })();
308
+
309
+ const hasHead = Boolean(props.eyebrow || props.title);
310
+ const links = [
311
+ { cta: props.cta, className: primaryBtn },
312
+ { cta: props.secondaryCta, className: secondaryBtn },
313
+ ].filter((l) => l.cta?.href || l.cta?.label);
314
+
315
+ const inner = (
316
+ <div
317
+ className={`grid gap-8 md:gap-14 ${columnsClass(props.mediaRatio ?? "50", side)} ${
318
+ props.align === "top" ? "items-start" : "items-center"
319
+ }`}
320
+ >
321
+ {/* Text wrapper: on mobile it dissolves so head/body can be ordered
322
+ around the media; on desktop it becomes one column. */}
323
+ <div
324
+ className={`contents md:block md:space-y-6 ${
325
+ side === "left" ? "md:order-2" : "md:order-1"
326
+ }`}
327
+ >
328
+ {hasHead ? (
329
+ <div className="order-1">
330
+ {props.eyebrow ? (
331
+ <span className={`text-[0.7rem] uppercase tracking-[0.22em] ${eyebrowTone}`}>
332
+ {t(props.eyebrow as Parameters<typeof t>[0])}
333
+ </span>
334
+ ) : null}
335
+ {props.title ? (
336
+ <h2 className="mt-4 font-serif text-[clamp(1.9rem,4vw,3.25rem)] leading-[1.05] tracking-[-0.02em] whitespace-pre-line">
337
+ {t(props.title as Parameters<typeof t>[0])}
338
+ </h2>
339
+ ) : null}
340
+ </div>
341
+ ) : null}
342
+
343
+ <div className="order-3 space-y-6">
344
+ {props.lead ? (
345
+ <p className={`text-lg md:text-xl leading-relaxed whitespace-pre-line ${muted}`}>
346
+ {t(props.lead as Parameters<typeof t>[0])}
347
+ </p>
348
+ ) : null}
349
+ {props.body ? (
350
+ <p className={`leading-relaxed whitespace-pre-line ${muted}`}>
351
+ {t(props.body as Parameters<typeof t>[0])}
352
+ </p>
353
+ ) : null}
354
+ {bullets.length > 0 ? (
355
+ <ul className="space-y-3">
356
+ {bullets.map((b, i) => (
357
+ <li key={b.id ?? i} className="flex gap-3">
358
+ <Check className={`mt-0.5 size-4 shrink-0 ${dark ? "text-sea" : "text-sea"}`} />
359
+ <span className={muted}>{t(b.text as Parameters<typeof t>[0])}</span>
360
+ </li>
361
+ ))}
362
+ </ul>
363
+ ) : null}
364
+ {links.length > 0 ? (
365
+ <div className="flex flex-wrap gap-3 pt-2">
366
+ {links.map((l, i) => (
367
+ <a
368
+ key={i}
369
+ href={l.cta?.href || "#"}
370
+ className={`inline-flex items-center gap-2 px-6 py-3 text-xs uppercase tracking-[0.22em] transition-colors ${l.className}`}
371
+ >
372
+ {l.cta?.label ? t(l.cta.label as Parameters<typeof t>[0]) : ""}
373
+ <ArrowRight className="size-4" />
374
+ </a>
375
+ ))}
376
+ </div>
377
+ ) : null}
378
+ </div>
379
+ </div>
380
+
381
+ {media ? (
382
+ <div className={`order-2 ${side === "left" ? "md:order-1" : "md:order-2"}`}>{media}</div>
383
+ ) : null}
384
+ </div>
385
+ );
386
+
387
+ return (
388
+ <section
389
+ id={props.anchor || undefined}
390
+ className={`${bg} ${PADDING[props.padding ?? "normal"]}`}
391
+ >
392
+ <div className={props.fullBleed ? "px-4 md:px-8" : "container-editorial"}>
393
+ <Reveal>{inner}</Reveal>
394
+ </div>
395
+
396
+ {props.mediaType === "video" && props.videoSrc ? (
397
+ <Lightbox
398
+ open={videoOpen}
399
+ onClose={() => setVideoOpen(false)}
400
+ label={props.title ? t(props.title as Parameters<typeof t>[0]) : "Video"}
401
+ contentClassName="w-full max-w-5xl"
402
+ >
403
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
404
+ <video
405
+ key={videoOpen ? "open" : "closed"}
406
+ ref={lightboxVideoRef}
407
+ src={props.videoSrc}
408
+ poster={props.posterSrc || undefined}
409
+ autoPlay
410
+ muted={false}
411
+ controls
412
+ playsInline
413
+ onLoadedData={(event) => {
414
+ event.currentTarget.muted = false;
415
+ event.currentTarget.defaultMuted = false;
416
+ event.currentTarget.volume = 1;
417
+ }}
418
+ onClick={(e) => e.stopPropagation()}
419
+ className="h-auto max-h-[85vh] w-full bg-ink object-contain"
420
+ />
421
+
422
+ </Lightbox>
423
+ ) : null}
424
+ </section>
425
+ );
426
+ }
427
+
@@ -0,0 +1,166 @@
1
+ import { useT } from "../../i18n";
2
+ import { Reveal } from "../../ui/Reveal";
3
+ import type { PriceTableProps } from "../types";
4
+
5
+ const TONE: Record<string, string> = {
6
+ white: "bg-white",
7
+ paper: "bg-paper",
8
+ ice: "bg-ice",
9
+ ink: "bg-ink",
10
+ };
11
+
12
+ /**
13
+ * Price cards — one card per cabin category with a row per occupancy.
14
+ * Replaces the old price image so prices stay editable and readable.
15
+ */
16
+ export function PriceTableBlock({ props }: { props: PriceTableProps }) {
17
+ const { t } = useT();
18
+ const groups = props.groups ?? [];
19
+ const dark = (props.tone ?? "paper") === "ink";
20
+ const tone = TONE[props.tone ?? "paper"] ?? "bg-paper";
21
+ const cols = (props.columns ?? 3) === 2 ? "sm:grid-cols-2" : "sm:grid-cols-2 lg:grid-cols-3";
22
+ // Centered by default — applies on every breakpoint.
23
+ const centered = (props.align ?? "center") === "center";
24
+ const headBox = centered ? "mx-auto max-w-2xl text-center" : "max-w-2xl";
25
+ const footBox = centered
26
+ ? "justify-center text-center"
27
+ : "";
28
+
29
+ if (groups.length === 0) return null;
30
+
31
+ return (
32
+ <section id={props.anchor || undefined} className={`${tone} py-16 md:py-24`}>
33
+ <div className="container-editorial">
34
+ {props.eyebrow || props.title || props.lead ? (
35
+ <Reveal>
36
+ <div className={headBox}>
37
+ {props.eyebrow ? (
38
+ <span
39
+ className={`text-[0.7rem] uppercase tracking-[0.22em] ${
40
+ dark ? "text-paper/70" : "text-ink/60"
41
+ }`}
42
+ >
43
+ {t(props.eyebrow)}
44
+ </span>
45
+ ) : null}
46
+ {props.title ? (
47
+ <h2
48
+ className={`mt-3 font-serif text-[clamp(1.8rem,3.6vw,2.9rem)] leading-[1.06] ${
49
+ dark ? "text-paper" : "text-ink"
50
+ }`}
51
+ >
52
+ {t(props.title)}
53
+ </h2>
54
+ ) : null}
55
+ {props.lead ? (
56
+ <p
57
+ className={`mt-4 text-base leading-relaxed whitespace-pre-line ${
58
+ dark ? "text-paper/80" : "text-ink/70"
59
+ }`}
60
+ >
61
+ {t(props.lead)}
62
+ </p>
63
+ ) : null}
64
+ </div>
65
+ </Reveal>
66
+ ) : null}
67
+
68
+ <Reveal>
69
+ <div className={`mt-10 grid grid-cols-1 ${cols} gap-5`}>
70
+ {groups.map((g, gi) => (
71
+ <div
72
+ key={g.id ?? gi}
73
+ className={`flex h-full flex-col border ${
74
+ g.highlight
75
+ ? "border-sea/60 bg-white shadow-[0_18px_40px_-30px_rgb(0_0_0/0.45)]"
76
+ : dark
77
+ ? "border-paper/15 bg-paper/5"
78
+ : "border-ink/10 bg-white"
79
+ }`}
80
+ >
81
+ <div
82
+ className={`flex items-baseline justify-between gap-3 px-5 py-4 ${
83
+ g.highlight ? "bg-sea text-white" : dark ? "bg-paper/10" : "bg-ink/[0.04]"
84
+ }`}
85
+ >
86
+ <span
87
+ className={`font-serif text-lg md:text-xl ${
88
+ g.highlight ? "text-white" : dark ? "text-paper" : "text-ink"
89
+ }`}
90
+ >
91
+ {t(g.label)}
92
+ </span>
93
+ {props.unitLabel ? (
94
+ <span
95
+ className={`text-[0.6rem] uppercase tracking-[0.2em] ${
96
+ g.highlight ? "text-white/80" : dark ? "text-paper/60" : "text-ink/50"
97
+ }`}
98
+ >
99
+ {t(props.unitLabel)}
100
+ </span>
101
+ ) : null}
102
+ </div>
103
+
104
+ <ul className={`flex-1 divide-y ${dark ? "divide-paper/12" : "divide-ink/8"}`}>
105
+ {(g.rows ?? []).filter((r) => !r.hidden).map((r, ri) => (
106
+ <li
107
+ key={r.id ?? ri}
108
+ className="flex items-baseline justify-between gap-4 px-5 py-3.5"
109
+ >
110
+ <span
111
+ className={`text-[0.7rem] uppercase tracking-[0.18em] ${
112
+ dark ? "text-paper/65" : "text-ink/55"
113
+ }`}
114
+ >
115
+ {t(r.label)}
116
+ </span>
117
+ <span
118
+ className={`font-serif text-xl tabular-nums ${
119
+ dark ? "text-paper" : "text-ink"
120
+ }`}
121
+ >
122
+ {t(r.price)}
123
+ </span>
124
+ </li>
125
+ ))}
126
+ </ul>
127
+
128
+ {g.caption ? (
129
+ <p
130
+ className={`px-5 pb-5 pt-3 text-xs leading-relaxed ${
131
+ dark ? "text-paper/60" : "text-ink/55"
132
+ }`}
133
+ >
134
+ {t(g.caption)}
135
+ </p>
136
+ ) : null}
137
+ </div>
138
+ ))}
139
+ </div>
140
+ </Reveal>
141
+
142
+ <div className={`mt-8 flex flex-wrap items-center gap-5 ${footBox}`}>
143
+ {props.ctaLabel && props.ctaHref ? (
144
+ <a
145
+ href={props.ctaHref}
146
+ className="inline-flex items-center bg-sea px-6 py-3 text-[0.7rem] uppercase tracking-[0.22em] text-white hover:bg-brass"
147
+ >
148
+ {t(props.ctaLabel)}
149
+ </a>
150
+ ) : null}
151
+ {props.note ? (
152
+ <p
153
+ className={`max-w-2xl text-sm leading-relaxed whitespace-pre-line ${
154
+ centered ? "mx-auto" : ""
155
+ } ${
156
+ dark ? "text-paper/70" : "text-ink/65"
157
+ }`}
158
+ >
159
+ {t(props.note)}
160
+ </p>
161
+ ) : null}
162
+ </div>
163
+ </div>
164
+ </section>
165
+ );
166
+ }
@@ -0,0 +1,77 @@
1
+ import { useT } from "../../i18n";
2
+ import { Reveal } from "../../ui/Reveal";
3
+ import type { ProseProps } from "../types";
4
+
5
+ /**
6
+ * Editorial long-form text block: optional eyebrow/title/lead plus a list of
7
+ * heading + body passages. Every string is localized (see i18n rules).
8
+ */
9
+ export function ProseBlock({ props }: { props: ProseProps }) {
10
+ const { t } = useT();
11
+ const items = props.items ?? [];
12
+ const twoCol = props.layout === "two-col";
13
+ const tone = props.tone === "paper" ? "bg-paper" : "bg-white";
14
+ const hasHeader = Boolean(props.eyebrow || props.title || props.lead);
15
+ // Without body items (or without a header) the block is just a heading —
16
+ // keep it tight so stacking it above another block doesn't leave a huge gap.
17
+ const padY = [
18
+ hasHeader ? "pt-20 md:pt-28" : "pt-0",
19
+ items.length > 0 ? "pb-20 md:pb-28" : "pb-8 md:pb-10",
20
+ ].join(" ");
21
+
22
+ return (
23
+ <section className={`${tone} ${padY}`}>
24
+
25
+ <div className="container-editorial">
26
+ {(props.eyebrow || props.title || props.lead) && (
27
+ <Reveal>
28
+ <div className="max-w-3xl">
29
+ {props.eyebrow ? (
30
+ <span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/70">
31
+ {t(props.eyebrow)}
32
+ </span>
33
+ ) : null}
34
+ {props.title ? (
35
+ <h2 className="mt-4 font-serif text-[clamp(1.9rem,4vw,3.25rem)] leading-[1.05] tracking-[-0.02em] text-ink whitespace-pre-line">
36
+ {t(props.title)}
37
+ </h2>
38
+ ) : null}
39
+ {props.lead ? (
40
+ <p className="mt-6 text-lg md:text-xl leading-relaxed text-ink/85 whitespace-pre-line">
41
+ {t(props.lead)}
42
+ </p>
43
+ ) : null}
44
+ </div>
45
+ </Reveal>
46
+ )}
47
+
48
+ {items.length > 0 && (
49
+ <div
50
+ className={`mt-12 md:mt-16 ${
51
+ twoCol
52
+ ? "grid gap-10 md:gap-14 md:grid-cols-2"
53
+ : "max-w-3xl space-y-10"
54
+ }`}
55
+ >
56
+ {items.map((item, i) => (
57
+ <Reveal key={item.id ?? i} delay={i * 70}>
58
+ <article className="border-t border-ink/10 pt-6">
59
+ {item.heading ? (
60
+ <h3 className="font-serif text-xl md:text-2xl text-ink whitespace-pre-line">
61
+ {t(item.heading)}
62
+ </h3>
63
+ ) : null}
64
+ {item.body ? (
65
+ <p className="mt-3 leading-relaxed text-ink/80 whitespace-pre-line">
66
+ {t(item.body)}
67
+ </p>
68
+ ) : null}
69
+ </article>
70
+ </Reveal>
71
+ ))}
72
+ </div>
73
+ )}
74
+ </div>
75
+ </section>
76
+ );
77
+ }