@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.
- package/README.md +36 -0
- package/package.json +39 -0
- package/src/backoffice/inspectors/blocks.tsx +2318 -0
- package/src/backoffice/inspectors/fields.tsx +436 -0
- package/src/blocks/block-props-context.tsx +36 -0
- package/src/blocks/components/CabinTabsBlock.tsx +167 -0
- package/src/blocks/components/ChecklistBlock.tsx +68 -0
- package/src/blocks/components/CtaBannerBlock.tsx +65 -0
- package/src/blocks/components/FaqBlock.tsx +62 -0
- package/src/blocks/components/HeroFullBlock.tsx +201 -0
- package/src/blocks/components/IconGridBlock.tsx +125 -0
- package/src/blocks/components/ImageMarqueeBlock.tsx +209 -0
- package/src/blocks/components/InstagramBlock.tsx +160 -0
- package/src/blocks/components/LinkTilesBlock.tsx +182 -0
- package/src/blocks/components/MediaSplitBlock.tsx +427 -0
- package/src/blocks/components/PriceTableBlock.tsx +166 -0
- package/src/blocks/components/ProseBlock.tsx +77 -0
- package/src/blocks/components/RichTextBlock.tsx +79 -0
- package/src/blocks/icons.ts +63 -0
- package/src/blocks/index.ts +13 -0
- package/src/blocks/registry.tsx +322 -0
- package/src/blocks/types.ts +470 -0
- package/src/host/index.tsx +134 -0
- package/src/html/sanitize-html.ts +175 -0
- package/src/i18n/context.tsx +44 -0
- package/src/i18n/index.ts +6 -0
- package/src/i18n/locales.ts +24 -0
- package/src/i18n/resolve.ts +82 -0
- package/src/i18n/translations-registry.ts +26 -0
- package/src/i18n/types.ts +22 -0
- package/src/i18n/video.ts +36 -0
- package/src/index.ts +5 -0
- package/src/theme.css +53 -0
- package/src/ui/Lightbox.tsx +69 -0
- package/src/ui/Reveal.tsx +47 -0
- 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
|
+
}
|