@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,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
+ }