@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,68 @@
|
|
|
1
|
+
import { Check } from "lucide-react";
|
|
2
|
+
import { useT } from "../../i18n";
|
|
3
|
+
import { Reveal } from "../../ui/Reveal";
|
|
4
|
+
import type { ChecklistProps } from "../types";
|
|
5
|
+
|
|
6
|
+
/** Column(s) of localized checkmark lists — e.g. "What's included" / "Extra perks". */
|
|
7
|
+
export function ChecklistBlock({ props }: { props: ChecklistProps }) {
|
|
8
|
+
const { t } = useT();
|
|
9
|
+
const columns = props.columns ?? [];
|
|
10
|
+
const tone = props.tone === "paper" ? "bg-paper" : "bg-white";
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<section className={`${tone} py-20 md:py-28`}>
|
|
14
|
+
<div className="container-editorial">
|
|
15
|
+
{(props.eyebrow || props.title) && (
|
|
16
|
+
<Reveal>
|
|
17
|
+
<div className="max-w-3xl">
|
|
18
|
+
{props.eyebrow ? (
|
|
19
|
+
<span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/70">
|
|
20
|
+
{t(props.eyebrow)}
|
|
21
|
+
</span>
|
|
22
|
+
) : null}
|
|
23
|
+
{props.title ? (
|
|
24
|
+
<h2 className="mt-4 font-serif text-[clamp(1.9rem,4vw,3.25rem)] leading-[1.05] tracking-[-0.02em] text-ink whitespace-pre-line">
|
|
25
|
+
{t(props.title)}
|
|
26
|
+
</h2>
|
|
27
|
+
) : null}
|
|
28
|
+
</div>
|
|
29
|
+
</Reveal>
|
|
30
|
+
)}
|
|
31
|
+
|
|
32
|
+
<div
|
|
33
|
+
className={`mt-12 md:mt-16 grid gap-8 md:gap-10 ${
|
|
34
|
+
columns.length > 1 ? "md:grid-cols-2" : "max-w-2xl"
|
|
35
|
+
}`}
|
|
36
|
+
>
|
|
37
|
+
{columns.map((col, ci) => (
|
|
38
|
+
<Reveal key={col.id ?? ci} delay={ci * 90}>
|
|
39
|
+
<div className="h-full border border-ink/10 bg-paper/60 p-7 md:p-9">
|
|
40
|
+
{col.title ? (
|
|
41
|
+
<h3 className="font-serif text-xl md:text-2xl text-ink whitespace-pre-line">
|
|
42
|
+
{t(col.title)}
|
|
43
|
+
</h3>
|
|
44
|
+
) : null}
|
|
45
|
+
<ul className="mt-5 space-y-3">
|
|
46
|
+
{(col.entries ?? []).map((e, i) => (
|
|
47
|
+
<li key={e.id ?? i} className="flex items-start gap-3">
|
|
48
|
+
<Check className="mt-1 size-4 shrink-0 text-sea" aria-hidden />
|
|
49
|
+
<span className="leading-relaxed text-ink/85">{t(e.text)}</span>
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
</div>
|
|
54
|
+
</Reveal>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{props.note ? (
|
|
59
|
+
<Reveal>
|
|
60
|
+
<p className="mt-8 max-w-3xl text-sm leading-relaxed text-ink/70 whitespace-pre-line">
|
|
61
|
+
{t(props.note)}
|
|
62
|
+
</p>
|
|
63
|
+
</Reveal>
|
|
64
|
+
) : null}
|
|
65
|
+
</div>
|
|
66
|
+
</section>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ArrowRight } from "lucide-react";
|
|
2
|
+
import { useT } from "../../i18n";
|
|
3
|
+
import { Reveal } from "../../ui/Reveal";
|
|
4
|
+
import type { CtaBannerProps } from "../types";
|
|
5
|
+
|
|
6
|
+
/** Localized call-to-action banner with up to two links. */
|
|
7
|
+
export function CtaBannerBlock({ props }: { props: CtaBannerProps }) {
|
|
8
|
+
const { t } = useT();
|
|
9
|
+
const tone = props.tone ?? "ink";
|
|
10
|
+
const dark = tone === "ink";
|
|
11
|
+
const bg = dark ? "bg-ink text-paper" : tone === "sea" ? "bg-ice text-ink" : "bg-paper text-ink";
|
|
12
|
+
const muted = dark ? "text-paper/80" : "text-ink/75";
|
|
13
|
+
const eyebrowTone = dark ? "text-paper/75" : "text-ink/70";
|
|
14
|
+
const primary = dark
|
|
15
|
+
? "bg-paper text-ink hover:bg-sea"
|
|
16
|
+
: "bg-ink text-paper hover:bg-ink/85";
|
|
17
|
+
const secondary = dark
|
|
18
|
+
? "border border-paper/60 text-paper hover:bg-paper hover:text-ink"
|
|
19
|
+
: "border border-ink/30 text-ink hover:bg-ink hover:text-paper";
|
|
20
|
+
|
|
21
|
+
const links = [
|
|
22
|
+
{ cta: props.cta, className: primary },
|
|
23
|
+
{ cta: props.secondaryCta, className: secondary },
|
|
24
|
+
].filter((l) => l.cta?.href || l.cta?.label);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<section className={`${bg} py-20 md:py-28`}>
|
|
28
|
+
<div className="container-editorial">
|
|
29
|
+
<Reveal>
|
|
30
|
+
<div className="max-w-3xl">
|
|
31
|
+
{props.eyebrow ? (
|
|
32
|
+
<span className={`text-[0.7rem] uppercase tracking-[0.22em] ${eyebrowTone}`}>
|
|
33
|
+
{t(props.eyebrow)}
|
|
34
|
+
</span>
|
|
35
|
+
) : null}
|
|
36
|
+
{props.title ? (
|
|
37
|
+
<h2 className="mt-4 font-serif text-[clamp(1.9rem,4vw,3.25rem)] leading-[1.05] tracking-[-0.02em] whitespace-pre-line">
|
|
38
|
+
{t(props.title)}
|
|
39
|
+
</h2>
|
|
40
|
+
) : null}
|
|
41
|
+
{props.body ? (
|
|
42
|
+
<p className={`mt-5 text-base md:text-lg leading-relaxed whitespace-pre-line ${muted}`}>
|
|
43
|
+
{t(props.body)}
|
|
44
|
+
</p>
|
|
45
|
+
) : null}
|
|
46
|
+
{links.length > 0 && (
|
|
47
|
+
<div className="mt-9 flex flex-wrap items-center gap-3">
|
|
48
|
+
{links.map((l, i) => (
|
|
49
|
+
<a
|
|
50
|
+
key={i}
|
|
51
|
+
href={l.cta?.href || "#"}
|
|
52
|
+
className={`inline-flex items-center gap-2 px-7 py-4 text-xs uppercase tracking-[0.22em] transition-colors ${l.className}`}
|
|
53
|
+
>
|
|
54
|
+
{t(l.cta?.label)}
|
|
55
|
+
<ArrowRight className="size-4" />
|
|
56
|
+
</a>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
</Reveal>
|
|
62
|
+
</div>
|
|
63
|
+
</section>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Plus, Minus } from "lucide-react";
|
|
3
|
+
import { useT } from "../../i18n";
|
|
4
|
+
import type { FaqProps } from "../types";
|
|
5
|
+
|
|
6
|
+
const COLS: Record<number, string> = {
|
|
7
|
+
1: "",
|
|
8
|
+
2: "md:grid md:grid-cols-2 md:gap-x-10",
|
|
9
|
+
3: "md:grid md:grid-cols-2 lg:grid-cols-3 md:gap-x-10",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function FaqBlock({ props }: { props: FaqProps }) {
|
|
13
|
+
const { t } = useT();
|
|
14
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
15
|
+
const items = props.items ?? [];
|
|
16
|
+
const columns = props.columns ?? 1;
|
|
17
|
+
const wide = columns > 1;
|
|
18
|
+
return (
|
|
19
|
+
<section className="bg-white py-24 md:py-32">
|
|
20
|
+
<div className={wide ? "container-editorial" : "mx-auto max-w-3xl px-6"}>
|
|
21
|
+
{props.title ? (
|
|
22
|
+
<h2 className="font-serif text-3xl md:text-5xl text-ink mb-10 md:mb-14 whitespace-pre-line">
|
|
23
|
+
{t(props.title)}
|
|
24
|
+
</h2>
|
|
25
|
+
) : null}
|
|
26
|
+
<ul className={`border-t border-ink/10 ${COLS[columns] ?? ""}`}>
|
|
27
|
+
{items.map((item) => {
|
|
28
|
+
const isOpen = openId === item.id;
|
|
29
|
+
return (
|
|
30
|
+
<li key={item.id} className="border-b border-ink/10">
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
className="w-full flex items-center justify-between gap-6 py-6 text-left"
|
|
34
|
+
onClick={() => setOpenId(isOpen ? null : item.id)}
|
|
35
|
+
aria-expanded={isOpen}
|
|
36
|
+
>
|
|
37
|
+
<span className="font-serif text-lg md:text-xl text-ink">
|
|
38
|
+
{t(item.q)}
|
|
39
|
+
</span>
|
|
40
|
+
<span className="shrink-0 flex size-9 items-center justify-center border border-ink/20 text-ink transition-colors group-hover:border-sea">
|
|
41
|
+
{isOpen ? <Minus className="size-4" /> : <Plus className="size-4" />}
|
|
42
|
+
</span>
|
|
43
|
+
</button>
|
|
44
|
+
<div
|
|
45
|
+
className={`grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out ${
|
|
46
|
+
isOpen ? "grid-rows-[1fr] pb-6" : "grid-rows-[0fr]"
|
|
47
|
+
}`}
|
|
48
|
+
>
|
|
49
|
+
<div className="min-h-0">
|
|
50
|
+
<p className="text-ink/70 leading-relaxed whitespace-pre-line">
|
|
51
|
+
{t(item.a)}
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</li>
|
|
56
|
+
);
|
|
57
|
+
})}
|
|
58
|
+
</ul>
|
|
59
|
+
</div>
|
|
60
|
+
</section>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { ArrowRight, ChevronDown } from "lucide-react";
|
|
2
|
+
import { useT } from "../../i18n";
|
|
3
|
+
import { FocalImage } from "../../host";
|
|
4
|
+
import { LazyVideo } from "../../host";
|
|
5
|
+
import type { HeroFullProps } from "../types";
|
|
6
|
+
|
|
7
|
+
const HEIGHTS: Record<NonNullable<HeroFullProps["height"]>, string> = {
|
|
8
|
+
full: "min-h-[100svh]",
|
|
9
|
+
tall: "min-h-[85svh]",
|
|
10
|
+
medium: "min-h-[65svh]",
|
|
11
|
+
compact: "min-h-[50svh]",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const TITLE_SIZES: Record<NonNullable<HeroFullProps["titleSize"]>, string> = {
|
|
15
|
+
sm: "text-[clamp(1.6rem,5vw,2.5rem)]",
|
|
16
|
+
md: "text-[clamp(2rem,6vw,3.5rem)]",
|
|
17
|
+
lg: "text-[clamp(2.4rem,7.5vw,4.75rem)]",
|
|
18
|
+
xl: "text-[clamp(2.8rem,9vw,6.5rem)]",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const WIDTHS: Record<NonNullable<HeroFullProps["contentWidth"]>, string> = {
|
|
22
|
+
narrow: "max-w-xl",
|
|
23
|
+
normal: "max-w-3xl",
|
|
24
|
+
wide: "max-w-5xl",
|
|
25
|
+
full: "max-w-none",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const OVERLAYS: Record<NonNullable<HeroFullProps["overlay"]>, string> = {
|
|
29
|
+
none: "",
|
|
30
|
+
light: "bg-black/20",
|
|
31
|
+
medium: "bg-black/40",
|
|
32
|
+
strong: "bg-black/60",
|
|
33
|
+
bottom: "bg-gradient-to-t from-black/80 via-black/25 to-transparent",
|
|
34
|
+
top: "bg-gradient-to-b from-black/75 via-black/20 to-transparent",
|
|
35
|
+
side: "bg-gradient-to-r from-black/75 via-black/25 to-transparent",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fullscreen hero with an image or video background and highly configurable
|
|
40
|
+
* overlay text. Mobile-first: separate mobile media, fluid type, safe padding.
|
|
41
|
+
*/
|
|
42
|
+
export function HeroFullBlock({ props: p }: { props: HeroFullProps }) {
|
|
43
|
+
const { t } = useT();
|
|
44
|
+
|
|
45
|
+
const align = p.textAlign ?? "left";
|
|
46
|
+
const vertical = p.verticalAlign ?? "center";
|
|
47
|
+
const dark = (p.textTone ?? "light") === "light";
|
|
48
|
+
const mediaType = p.mediaType ?? "image";
|
|
49
|
+
|
|
50
|
+
const image = p.image;
|
|
51
|
+
const imageMobile = p.imageMobile || image;
|
|
52
|
+
const videoSrc = p.videoSrc;
|
|
53
|
+
const videoSrcMobile = p.videoSrcMobile || videoSrc;
|
|
54
|
+
const videoSrcWebm = p.videoSrcWebm;
|
|
55
|
+
const videoSrcWebmMobile = p.videoSrcWebmMobile || videoSrcWebm;
|
|
56
|
+
const poster = p.posterSrc;
|
|
57
|
+
const posterMobile = p.posterSrcMobile || poster;
|
|
58
|
+
|
|
59
|
+
const alignClass =
|
|
60
|
+
align === "center" ? "items-center text-center" : align === "right" ? "items-end text-right" : "items-start text-left";
|
|
61
|
+
const rowClass =
|
|
62
|
+
align === "center" ? "justify-center" : align === "right" ? "justify-end" : "justify-start";
|
|
63
|
+
const colClass =
|
|
64
|
+
vertical === "top" ? "justify-start pt-28 md:pt-36" : vertical === "bottom" ? "justify-end pb-20 md:pb-28" : "justify-center";
|
|
65
|
+
|
|
66
|
+
const textColor = dark ? "text-white" : "text-ink";
|
|
67
|
+
const mutedColor = dark ? "text-white/80" : "text-ink/70";
|
|
68
|
+
const eyebrowColor = dark ? "text-white/75" : "text-ink/65";
|
|
69
|
+
|
|
70
|
+
const primaryBtn = dark
|
|
71
|
+
? "bg-white text-ink hover:bg-sea hover:text-white"
|
|
72
|
+
: "bg-ink text-paper hover:bg-ink/85";
|
|
73
|
+
const secondaryBtn = dark
|
|
74
|
+
? "border border-white/65 text-white hover:bg-white hover:text-ink"
|
|
75
|
+
: "border border-ink/30 text-ink hover:bg-ink hover:text-paper";
|
|
76
|
+
|
|
77
|
+
const ctas = [
|
|
78
|
+
{ cta: p.primaryCta, className: primaryBtn },
|
|
79
|
+
{ cta: p.secondaryCta, className: secondaryBtn },
|
|
80
|
+
].filter((c) => c.cta?.href || (c.cta?.label && Object.keys(c.cta.label).length));
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<section
|
|
84
|
+
id={p.anchor || undefined}
|
|
85
|
+
className={`relative isolate w-full overflow-hidden bg-ink ${HEIGHTS[p.height ?? "full"]} flex`}
|
|
86
|
+
>
|
|
87
|
+
{/* Background media */}
|
|
88
|
+
<div className="absolute inset-0 -z-10">
|
|
89
|
+
{mediaType === "video" && videoSrc ? (
|
|
90
|
+
<>
|
|
91
|
+
{/* Only the element that is actually displayed intersects the
|
|
92
|
+
viewport, so exactly one video is ever fetched. */}
|
|
93
|
+
<LazyVideo
|
|
94
|
+
key={`desktop:${videoSrc}`}
|
|
95
|
+
className="hidden size-full object-cover md:block"
|
|
96
|
+
src={videoSrc}
|
|
97
|
+
sources={
|
|
98
|
+
videoSrcWebm
|
|
99
|
+
? [
|
|
100
|
+
{ src: videoSrcWebm, type: "video/webm" },
|
|
101
|
+
{ src: videoSrc, type: "video/mp4" },
|
|
102
|
+
]
|
|
103
|
+
: undefined
|
|
104
|
+
}
|
|
105
|
+
poster={poster || undefined}
|
|
106
|
+
autoPlay={p.videoAutoplay !== false}
|
|
107
|
+
loop={p.videoLoop !== false}
|
|
108
|
+
muted={p.videoMuted !== false}
|
|
109
|
+
preload="metadata"
|
|
110
|
+
/>
|
|
111
|
+
<LazyVideo
|
|
112
|
+
key={`mobile:${videoSrcMobile}`}
|
|
113
|
+
className="size-full object-cover md:hidden"
|
|
114
|
+
src={videoSrcMobile}
|
|
115
|
+
sources={
|
|
116
|
+
videoSrcWebmMobile && videoSrcMobile
|
|
117
|
+
? [
|
|
118
|
+
{ src: videoSrcWebmMobile, type: "video/webm" },
|
|
119
|
+
{ src: videoSrcMobile, type: "video/mp4" },
|
|
120
|
+
]
|
|
121
|
+
: undefined
|
|
122
|
+
}
|
|
123
|
+
poster={posterMobile || undefined}
|
|
124
|
+
autoPlay={p.videoAutoplay !== false}
|
|
125
|
+
loop={p.videoLoop !== false}
|
|
126
|
+
muted={p.videoMuted !== false}
|
|
127
|
+
preload="metadata"
|
|
128
|
+
/>
|
|
129
|
+
</>
|
|
130
|
+
) : image || imageMobile ? (
|
|
131
|
+
<>
|
|
132
|
+
{image ? (
|
|
133
|
+
<FocalImage
|
|
134
|
+
src={image}
|
|
135
|
+
alt={t(p.imageAlt) || ""}
|
|
136
|
+
sizes="100vw"
|
|
137
|
+
className="hidden size-full object-cover md:block"
|
|
138
|
+
/>
|
|
139
|
+
) : null}
|
|
140
|
+
{imageMobile ? (
|
|
141
|
+
<FocalImage
|
|
142
|
+
src={imageMobile}
|
|
143
|
+
alt={t(p.imageAlt) || ""}
|
|
144
|
+
sizes="100vw"
|
|
145
|
+
className="size-full object-cover md:hidden"
|
|
146
|
+
/>
|
|
147
|
+
) : null}
|
|
148
|
+
</>
|
|
149
|
+
) : null}
|
|
150
|
+
<div className={`absolute inset-0 ${OVERLAYS[p.overlay ?? "medium"]}`} />
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{/* Content */}
|
|
154
|
+
<div className={`container-editorial flex w-full flex-col ${colClass}`}>
|
|
155
|
+
<div className={`flex w-full ${rowClass}`}>
|
|
156
|
+
<div className={`flex w-full flex-col ${alignClass} ${WIDTHS[p.contentWidth ?? "normal"]} ${textColor}`}>
|
|
157
|
+
{p.eyebrow ? (
|
|
158
|
+
<span className={`text-[0.65rem] uppercase tracking-[0.3em] md:text-[0.72rem] ${eyebrowColor}`}>
|
|
159
|
+
{t(p.eyebrow)}
|
|
160
|
+
</span>
|
|
161
|
+
) : null}
|
|
162
|
+
{p.title ? (
|
|
163
|
+
<h1
|
|
164
|
+
className={`mt-4 ${TITLE_SIZES[p.titleSize ?? "lg"]} leading-[1.02] tracking-[-0.02em] ${
|
|
165
|
+
(p.titleFont ?? "serif") === "serif" ? "font-serif" : "font-sans font-semibold"
|
|
166
|
+
} ${p.titleUppercase ? "uppercase tracking-[0.04em]" : ""} whitespace-pre-line`}
|
|
167
|
+
>
|
|
168
|
+
{t(p.title)}
|
|
169
|
+
</h1>
|
|
170
|
+
) : null}
|
|
171
|
+
{p.body ? (
|
|
172
|
+
<p className={`mt-5 whitespace-pre-line text-base leading-relaxed md:text-lg ${mutedColor} ${WIDTHS[p.contentWidth ?? "normal"] === "max-w-none" ? "" : ""}`}>
|
|
173
|
+
{t(p.body)}
|
|
174
|
+
</p>
|
|
175
|
+
) : null}
|
|
176
|
+
{ctas.length > 0 && (
|
|
177
|
+
<div className={`mt-8 flex flex-wrap gap-3 ${rowClass}`}>
|
|
178
|
+
{ctas.map((c, i) => (
|
|
179
|
+
<a
|
|
180
|
+
key={i}
|
|
181
|
+
href={c.cta?.href || "#"}
|
|
182
|
+
className={`inline-flex items-center gap-2 px-6 py-3.5 text-[0.7rem] uppercase tracking-[0.22em] transition-colors md:px-8 md:py-4 md:text-xs ${c.className}`}
|
|
183
|
+
>
|
|
184
|
+
{t(c.cta?.label)}
|
|
185
|
+
<ArrowRight className="size-4" />
|
|
186
|
+
</a>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{p.showScrollCue ? (
|
|
195
|
+
<div className={`pointer-events-none absolute inset-x-0 bottom-6 flex justify-center ${dark ? "text-white/70" : "text-ink/50"}`}>
|
|
196
|
+
<ChevronDown className="size-6 animate-bounce" />
|
|
197
|
+
</div>
|
|
198
|
+
) : null}
|
|
199
|
+
</section>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useT } from "../../i18n";
|
|
2
|
+
import { Reveal } from "../../ui/Reveal";
|
|
3
|
+
import { resolveBlockIcon } from "../icons";
|
|
4
|
+
import type { IconGridProps } from "../types";
|
|
5
|
+
|
|
6
|
+
const COLS: Record<number, string> = {
|
|
7
|
+
2: "sm:grid-cols-2",
|
|
8
|
+
3: "sm:grid-cols-2 lg:grid-cols-3",
|
|
9
|
+
4: "sm:grid-cols-2 lg:grid-cols-4",
|
|
10
|
+
5: "sm:grid-cols-2 lg:grid-cols-5",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const TONE: Record<string, string> = {
|
|
14
|
+
white: "bg-white",
|
|
15
|
+
paper: "bg-paper",
|
|
16
|
+
ice: "bg-ice",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Icon cards: icon + title + optional meta (e.g. price) and body text.
|
|
21
|
+
* One column on mobile, configurable 2–5 columns from `sm:`/`lg:` up.
|
|
22
|
+
*/
|
|
23
|
+
export function IconGridBlock({ props }: { props: IconGridProps }) {
|
|
24
|
+
const { t } = useT();
|
|
25
|
+
const items = props.items ?? [];
|
|
26
|
+
const tone = TONE[props.tone ?? "white"] ?? "bg-white";
|
|
27
|
+
const cols = COLS[props.columns ?? 3] ?? COLS[3];
|
|
28
|
+
const bare = props.cardStyle === "bare";
|
|
29
|
+
const plainIcon = props.iconStyle === "plain";
|
|
30
|
+
const iconAlign = props.iconAlign ?? "center";
|
|
31
|
+
const iconAlignClass =
|
|
32
|
+
iconAlign === "left" ? "self-start" : iconAlign === "right" ? "self-end" : "self-center";
|
|
33
|
+
const textAlignClass =
|
|
34
|
+
iconAlign === "left" ? "text-left" : iconAlign === "right" ? "text-right" : "text-center";
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<section id={props.anchor || undefined} className={`${tone} py-20 md:py-28`}>
|
|
38
|
+
<div className="container-editorial">
|
|
39
|
+
{(props.eyebrow || props.title || props.lead) && (
|
|
40
|
+
<Reveal>
|
|
41
|
+
<div className="max-w-3xl">
|
|
42
|
+
{props.eyebrow ? (
|
|
43
|
+
<span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/70">
|
|
44
|
+
{t(props.eyebrow)}
|
|
45
|
+
</span>
|
|
46
|
+
) : null}
|
|
47
|
+
{props.title ? (
|
|
48
|
+
<h2 className="mt-4 font-serif text-[clamp(1.9rem,4vw,3.25rem)] leading-[1.05] tracking-[-0.02em] text-ink whitespace-pre-line">
|
|
49
|
+
{t(props.title)}
|
|
50
|
+
</h2>
|
|
51
|
+
) : null}
|
|
52
|
+
{props.lead ? (
|
|
53
|
+
<p className="mt-5 max-w-2xl text-base md:text-lg leading-relaxed text-ink/75 whitespace-pre-line">
|
|
54
|
+
{t(props.lead)}
|
|
55
|
+
</p>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
</Reveal>
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
<div
|
|
62
|
+
className={`mt-12 md:mt-16 grid grid-cols-1 ${cols} ${
|
|
63
|
+
bare ? "gap-8 md:gap-10" : "gap-px bg-ink/10"
|
|
64
|
+
}`}
|
|
65
|
+
>
|
|
66
|
+
|
|
67
|
+
{items.map((item, i) => {
|
|
68
|
+
const Icon = resolveBlockIcon(item.icon);
|
|
69
|
+
const inner = (
|
|
70
|
+
<>
|
|
71
|
+
<span
|
|
72
|
+
className={`${iconAlignClass} ${
|
|
73
|
+
plainIcon
|
|
74
|
+
? "inline-flex text-sea"
|
|
75
|
+
: "inline-flex size-11 shrink-0 items-center justify-center rounded-full border border-sea/35 bg-sea/10 text-sea"
|
|
76
|
+
}`}
|
|
77
|
+
aria-hidden
|
|
78
|
+
>
|
|
79
|
+
<Icon className="size-5" strokeWidth={1.5} />
|
|
80
|
+
</span>
|
|
81
|
+
<div className="mt-5 min-w-0">
|
|
82
|
+
<h3 className="font-serif text-lg md:text-xl leading-snug text-ink whitespace-pre-line">
|
|
83
|
+
{item.title ? t(item.title) : null}
|
|
84
|
+
</h3>
|
|
85
|
+
{item.meta ? (
|
|
86
|
+
<div className="mt-1.5 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
87
|
+
{t(item.meta)}
|
|
88
|
+
</div>
|
|
89
|
+
) : null}
|
|
90
|
+
{item.body ? (
|
|
91
|
+
<p className="mt-3 text-sm leading-relaxed text-ink/70 whitespace-pre-line">
|
|
92
|
+
{t(item.body)}
|
|
93
|
+
</p>
|
|
94
|
+
) : null}
|
|
95
|
+
</div>
|
|
96
|
+
</>
|
|
97
|
+
);
|
|
98
|
+
const cls = bare
|
|
99
|
+
? `flex h-full flex-col ${textAlignClass}`
|
|
100
|
+
: `flex h-full flex-col p-7 md:p-8 ${tone} ${textAlignClass} transition-colors hover:bg-ice/40`;
|
|
101
|
+
return (
|
|
102
|
+
<Reveal key={item.id ?? i} delay={i * 50}>
|
|
103
|
+
{item.href ? (
|
|
104
|
+
<a href={item.href} className={`${cls} group`}>
|
|
105
|
+
{inner}
|
|
106
|
+
</a>
|
|
107
|
+
) : (
|
|
108
|
+
<div className={cls}>{inner}</div>
|
|
109
|
+
)}
|
|
110
|
+
</Reveal>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{props.note ? (
|
|
116
|
+
<Reveal>
|
|
117
|
+
<p className="mt-8 max-w-3xl text-sm leading-relaxed text-ink/70 whitespace-pre-line">
|
|
118
|
+
{t(props.note)}
|
|
119
|
+
</p>
|
|
120
|
+
</Reveal>
|
|
121
|
+
) : null}
|
|
122
|
+
</div>
|
|
123
|
+
</section>
|
|
124
|
+
);
|
|
125
|
+
}
|