@bygghammar/cms-platform 0.55.0 → 0.56.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.56.0
4
+
5
+ - Public site chrome is now package-owned: `site/SiteHeader` and
6
+ `site/SiteFooter`. Structure, dropdown/keyboard behaviour, mobile panel,
7
+ address block and Organization JSON-LD live in the package; sites pass
8
+ navigation, brand text, logo artwork, CTA target and company details.
9
+
3
10
  ## 0.55.0
4
11
 
5
12
  - Public departures list is now package-owned: `blocks/components/TripListBlock`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -203,7 +203,9 @@
203
203
  "./site/indexing": "./src/site/indexing.ts",
204
204
  "./backoffice/IndexingStatusCard": "./src/backoffice/IndexingStatusCard.tsx",
205
205
  "./util/use-hash-scroll": "./src/util/use-hash-scroll.ts",
206
- "./site/site-url": "./src/site/site-url.ts"
206
+ "./site/site-url": "./src/site/site-url.ts",
207
+ "./site/SiteHeader": "./src/site/SiteHeader.tsx",
208
+ "./site/SiteFooter": "./src/site/SiteFooter.tsx"
207
209
  },
208
210
  "dependencies": {
209
211
  "fflate": "^0.8.3"
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Shared public site footer (site-agnostic).
3
+ *
4
+ * Layout, address block and Organization JSON-LD live here; brand text,
5
+ * columns and company details are supplied by the site.
6
+ */
7
+ import { Link } from "@tanstack/react-router";
8
+
9
+ import { useT } from "../i18n";
10
+ import type { LocalizedString } from "../i18n/types";
11
+
12
+ export type SiteFooterCompany = {
13
+ legalName: string;
14
+ locationName: string;
15
+ orgNumber: string;
16
+ street: string;
17
+ postCode: string;
18
+ city: string;
19
+ country: string;
20
+ email: string;
21
+ phone: string;
22
+ };
23
+
24
+ export type SiteFooterColumn = {
25
+ title: LocalizedString;
26
+ links: Array<{ label: LocalizedString; href: string }>;
27
+ };
28
+
29
+ export type SiteFooterProps = {
30
+ brandShort: LocalizedString;
31
+ brandName: LocalizedString;
32
+ tagline: LocalizedString;
33
+ legal: LocalizedString;
34
+ columns: readonly SiteFooterColumn[];
35
+ company: SiteFooterCompany;
36
+ /** Label prefixing the organisation number in the address block. */
37
+ orgNumberLabel?: string;
38
+ };
39
+
40
+ export function SiteFooter({
41
+ brandShort,
42
+ brandName,
43
+ tagline,
44
+ legal,
45
+ columns,
46
+ company,
47
+ orgNumberLabel = "Org.nr",
48
+ }: SiteFooterProps) {
49
+ const { t } = useT();
50
+
51
+ const jsonLd = {
52
+ "@context": "https://schema.org",
53
+ "@type": "Organization",
54
+ name: company.locationName,
55
+ legalName: company.legalName,
56
+ email: company.email,
57
+ telephone: company.phone,
58
+ identifier: company.orgNumber,
59
+ address: {
60
+ "@type": "PostalAddress",
61
+ streetAddress: company.street,
62
+ postalCode: company.postCode,
63
+ addressLocality: company.city,
64
+ addressCountry: company.country,
65
+ },
66
+ };
67
+
68
+ return (
69
+ <footer className="bg-ink text-paper">
70
+ <div className="container-editorial py-20 md:py-28">
71
+ <div className="grid gap-12 md:grid-cols-12">
72
+ <div className="md:col-span-4">
73
+ <div className="font-serif text-2xl tracking-[0.24em]">{t(brandShort)}</div>
74
+ <p className="mt-4 max-w-xs text-sm leading-relaxed text-paper/85 whitespace-pre-line">
75
+ {t(brandName)} — {t(tagline)}
76
+ </p>
77
+ </div>
78
+ <div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-8">
79
+ {columns.map((col, i) => (
80
+ <div key={i} className="min-w-0">
81
+ <div className="eyebrow text-paper/80">{t(col.title)}</div>
82
+ <ul className="mt-4 space-y-2">
83
+ {col.links.map((l, j) => (
84
+ <li key={j}>
85
+ {l.href.startsWith("/") && !l.href.startsWith("/#") ? (
86
+ <Link
87
+ to={l.href}
88
+ className="text-sm text-paper/85 hover:text-paper transition-colors"
89
+ >
90
+ {t(l.label)}
91
+ </Link>
92
+ ) : (
93
+ <a
94
+ href={l.href}
95
+ className="text-sm text-paper/85 hover:text-paper transition-colors"
96
+ >
97
+ {t(l.label)}
98
+ </a>
99
+ )}
100
+ </li>
101
+ ))}
102
+ </ul>
103
+ </div>
104
+ ))}
105
+ </div>
106
+ </div>
107
+ <div className="mt-16 border-t border-paper/15 pt-8 text-center text-xs text-paper/80">
108
+ <address className="not-italic leading-relaxed">
109
+ <div>
110
+ {company.locationName} · {company.street} · {company.postCode} {company.city}
111
+ </div>
112
+ <div className="mt-1">
113
+ {company.legalName} · {orgNumberLabel} {company.orgNumber}
114
+ </div>
115
+ <div className="mt-1">
116
+ <a href={`mailto:${company.email}`} className="hover:text-paper">
117
+ {company.email}
118
+ </a>
119
+ {" · "}
120
+ <a href={`tel:${company.phone.replace(/[^\d+]/g, "")}`} className="hover:text-paper">
121
+ {company.phone}
122
+ </a>
123
+ </div>
124
+ </address>
125
+ <div className="mt-4">{t(legal)}</div>
126
+ </div>
127
+ <script
128
+ type="application/ld+json"
129
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
130
+ />
131
+ </div>
132
+ </footer>
133
+ );
134
+ }
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Shared public site header (site-agnostic).
3
+ *
4
+ * Structure, motion, dropdown/keyboard behaviour and the mobile panel live
5
+ * here. Everything brand-specific — logo artwork, navigation data, CTA target
6
+ * and labels — is passed in by the site. All strings are localized maps
7
+ * resolved through the package `useT()`.
8
+ */
9
+ import { Link, useRouterState } from "@tanstack/react-router";
10
+ import { useEffect, useRef, useState } from "react";
11
+ import { ChevronDown, Menu, X } from "lucide-react";
12
+
13
+ import { useT } from "../i18n";
14
+ import type { LocalizedString } from "../i18n/types";
15
+ import { navigationPanelId, type NavigationItem } from "../navigation";
16
+
17
+ export type SiteHeaderProps = {
18
+ /** Top-level navigation, optionally with one level of children. */
19
+ nav: readonly NavigationItem[];
20
+ /** Brand name, used as the logo link's accessible label. */
21
+ brandName: LocalizedString;
22
+ /** Logo/wordmark artwork. Light artwork is inverted on light surfaces. */
23
+ logoSrc: string;
24
+ /** Set false when the logo artwork is already dark. */
25
+ invertLogoOnLightSurface?: boolean;
26
+ /** Primary call to action shown on desktop and at the end of the mobile panel. */
27
+ primaryCta: { href: string; label: LocalizedString };
28
+ /** Mobile menu toggle labels. */
29
+ menuLabels: { open: LocalizedString; close: LocalizedString };
30
+ /** Optional backoffice shortcut, rendered only when `signedIn` is true. */
31
+ backoffice?: { href: string; label: string; signedIn: boolean };
32
+ /**
33
+ * Routes that render a dark full-bleed hero behind the header. On those the
34
+ * un-scrolled header uses light text. Defaults to the home route.
35
+ */
36
+ lightTextPaths?: readonly string[];
37
+ };
38
+
39
+ function NavEntry({ item, lightText }: { item: NavigationItem; lightText: boolean }) {
40
+ const { t } = useT();
41
+ const children = item.children ?? [];
42
+ const [open, setOpen] = useState(false);
43
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
44
+ const wrapRef = useRef<HTMLDivElement | null>(null);
45
+ const pathname = useRouterState({ select: (r) => r.location.pathname });
46
+
47
+ useEffect(() => setOpen(false), [pathname]);
48
+ useEffect(
49
+ () => () => {
50
+ if (closeTimer.current) clearTimeout(closeTimer.current);
51
+ },
52
+ [],
53
+ );
54
+
55
+ const linkClass = [
56
+ "inline-flex items-center leading-none text-xs uppercase tracking-[0.18em] transition-colors",
57
+ !lightText ? "text-foreground/80 hover:text-foreground" : "text-paper/85 hover:text-paper",
58
+ ].join(" ");
59
+
60
+ if (children.length === 0) {
61
+ return (
62
+ <Link to={item.href} className={linkClass}>
63
+ {t(item.label)}
64
+ </Link>
65
+ );
66
+ }
67
+
68
+ const cancelClose = () => {
69
+ if (closeTimer.current) clearTimeout(closeTimer.current);
70
+ closeTimer.current = null;
71
+ };
72
+ const scheduleClose = () => {
73
+ cancelClose();
74
+ closeTimer.current = setTimeout(() => setOpen(false), 140);
75
+ };
76
+
77
+ const panelId = navigationPanelId(item.href);
78
+
79
+ return (
80
+ <div
81
+ ref={wrapRef}
82
+ className="relative flex items-center"
83
+ onMouseEnter={() => {
84
+ cancelClose();
85
+ setOpen(true);
86
+ }}
87
+ onMouseLeave={scheduleClose}
88
+ onFocus={() => {
89
+ cancelClose();
90
+ setOpen(true);
91
+ }}
92
+ onBlur={(e) => {
93
+ if (!wrapRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
94
+ }}
95
+ onKeyDown={(e) => {
96
+ if (e.key === "Escape") {
97
+ setOpen(false);
98
+ return;
99
+ }
100
+ if (e.key === "ArrowDown") {
101
+ e.preventDefault();
102
+ setOpen(true);
103
+ const links = wrapRef.current?.querySelectorAll<HTMLElement>("[data-sub-link]");
104
+ links?.[0]?.focus();
105
+ }
106
+ }}
107
+ >
108
+ <Link
109
+ to={item.href}
110
+ aria-haspopup="true"
111
+ aria-expanded={open}
112
+ aria-controls={panelId}
113
+ className={`${linkClass} gap-1.5`}
114
+ >
115
+ {t(item.label)}
116
+ <ChevronDown
117
+ className={`size-3 transition-transform duration-300 ${open ? "rotate-180" : ""}`}
118
+ aria-hidden
119
+ />
120
+ </Link>
121
+
122
+ <div
123
+ id={panelId}
124
+ className={[
125
+ "absolute left-1/2 top-full z-50 -translate-x-1/2 pt-4 transition-all duration-200",
126
+ open
127
+ ? "pointer-events-auto opacity-100 translate-y-0"
128
+ : "pointer-events-none opacity-0 translate-y-1",
129
+ ].join(" ")}
130
+ >
131
+ <ul className="min-w-[15rem] border border-border/60 bg-background/90 backdrop-blur-xl py-2 shadow-[0_20px_60px_-30px_rgba(0,0,0,0.45)]">
132
+ {children.map((c, i) => (
133
+ <li key={c.href + i}>
134
+ <Link
135
+ to={c.href}
136
+ data-sub-link
137
+ onClick={() => setOpen(false)}
138
+ onKeyDown={(e) => {
139
+ const links = Array.from(
140
+ wrapRef.current?.querySelectorAll<HTMLElement>("[data-sub-link]") ?? [],
141
+ );
142
+ if (e.key === "ArrowDown") {
143
+ e.preventDefault();
144
+ links[Math.min(i + 1, links.length - 1)]?.focus();
145
+ }
146
+ if (e.key === "ArrowUp") {
147
+ e.preventDefault();
148
+ links[Math.max(i - 1, 0)]?.focus();
149
+ }
150
+ }}
151
+ className="block px-5 py-3 text-[0.7rem] uppercase tracking-[0.2em] text-foreground/70 hover:bg-foreground/5 hover:text-foreground transition-colors"
152
+ >
153
+ {t(c.label)}
154
+ </Link>
155
+ </li>
156
+ ))}
157
+ </ul>
158
+ </div>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ export function SiteHeader({
164
+ nav,
165
+ brandName,
166
+ logoSrc,
167
+ invertLogoOnLightSurface = true,
168
+ primaryCta,
169
+ menuLabels,
170
+ backoffice,
171
+ lightTextPaths = ["/"],
172
+ }: SiteHeaderProps) {
173
+ const { t } = useT();
174
+ const pathname = useRouterState({ select: (r) => r.location.pathname });
175
+
176
+ const [scrolled, setScrolled] = useState(false);
177
+ const [open, setOpen] = useState(false);
178
+ const [mobileGroup, setMobileGroup] = useState<string | null>(null);
179
+ const panelRef = useRef<HTMLDivElement | null>(null);
180
+ const toggleRef = useRef<HTMLButtonElement | null>(null);
181
+
182
+ useEffect(() => {
183
+ const on = () => setScrolled(window.scrollY > 24);
184
+ on();
185
+ window.addEventListener("scroll", on, { passive: true });
186
+ return () => window.removeEventListener("scroll", on);
187
+ }, []);
188
+
189
+ useEffect(() => {
190
+ if (!open) return;
191
+ const onDown = (e: PointerEvent) => {
192
+ const target = e.target as Node | null;
193
+ if (!target) return;
194
+ if (panelRef.current?.contains(target)) return;
195
+ if (toggleRef.current?.contains(target)) return;
196
+ setOpen(false);
197
+ };
198
+ document.addEventListener("pointerdown", onDown);
199
+ return () => document.removeEventListener("pointerdown", onDown);
200
+ }, [open]);
201
+
202
+ const lightText = lightTextPaths.includes(pathname) && !scrolled;
203
+ const name = t(brandName);
204
+ const showBackoffice = Boolean(backoffice?.signedIn);
205
+
206
+ return (
207
+ <header
208
+ className={[
209
+ "fixed inset-x-0 top-0 z-50 transition-all duration-500",
210
+ scrolled ? "backdrop-blur-md bg-background/70 border-b border-border/50" : "bg-transparent",
211
+ ].join(" ")}
212
+ >
213
+ <div className="container-editorial flex h-16 items-center justify-between gap-4 md:h-20">
214
+ <Link to="/" className="shrink-0 inline-flex items-center" aria-label={name}>
215
+ <img
216
+ src={logoSrc}
217
+ alt={name}
218
+ className={[
219
+ "h-10 w-auto md:h-12 transition-[filter] duration-500",
220
+ !lightText && invertLogoOnLightSurface ? "brightness-0" : "",
221
+ ].join(" ")}
222
+ />
223
+ </Link>
224
+
225
+ <nav className="hidden md:flex items-center gap-10">
226
+ {nav.map((n) => (
227
+ <NavEntry key={n.href} item={n} lightText={lightText} />
228
+ ))}
229
+ </nav>
230
+
231
+ <div className="hidden md:flex items-center gap-5">
232
+ {showBackoffice && backoffice && (
233
+ <Link
234
+ to={backoffice.href}
235
+ className={[
236
+ "text-xs uppercase tracking-[0.2em] transition-colors",
237
+ !lightText
238
+ ? "text-foreground/70 hover:text-foreground"
239
+ : "text-paper/80 hover:text-paper",
240
+ ].join(" ")}
241
+ >
242
+ {backoffice.label}
243
+ </Link>
244
+ )}
245
+ <Link
246
+ to={primaryCta.href}
247
+ className={[
248
+ "inline-flex items-center border px-5 py-2.5 text-xs uppercase tracking-[0.2em] transition-colors",
249
+ !lightText
250
+ ? "border-foreground text-foreground hover:bg-foreground hover:text-background"
251
+ : "border-paper text-paper hover:bg-paper hover:text-ink",
252
+ ].join(" ")}
253
+ >
254
+ {t(primaryCta.label)}
255
+ </Link>
256
+ </div>
257
+
258
+ <button
259
+ ref={toggleRef}
260
+ type="button"
261
+ className={[
262
+ "md:hidden inline-flex h-11 w-11 shrink-0 items-center justify-center",
263
+ !lightText ? "text-foreground" : "text-paper",
264
+ ].join(" ")}
265
+ aria-label={open ? t(menuLabels.close) : t(menuLabels.open)}
266
+ aria-expanded={open}
267
+ onClick={() => setOpen((v) => !v)}
268
+ >
269
+ {open ? <X className="size-5" /> : <Menu className="size-5" />}
270
+ </button>
271
+ </div>
272
+
273
+ {open && (
274
+ <div ref={panelRef} className="md:hidden bg-background border-t border-border">
275
+ <nav className="container-editorial flex flex-col py-6">
276
+ {nav.map((n) => {
277
+ const kids = n.children ?? [];
278
+ if (kids.length === 0) {
279
+ return (
280
+ <Link
281
+ key={n.href}
282
+ to={n.href}
283
+ className="py-3 text-sm uppercase tracking-[0.2em] text-foreground/80 hover:text-foreground"
284
+ onClick={() => setOpen(false)}
285
+ >
286
+ {t(n.label)}
287
+ </Link>
288
+ );
289
+ }
290
+ const expanded = mobileGroup === n.href;
291
+ return (
292
+ <div key={n.href} className="border-b border-border/40 last:border-b-0">
293
+ <div className="flex items-center">
294
+ <Link
295
+ to={n.href}
296
+ className="flex-1 py-3 text-sm uppercase tracking-[0.2em] text-foreground/80 hover:text-foreground"
297
+ onClick={() => setOpen(false)}
298
+ >
299
+ {t(n.label)}
300
+ </Link>
301
+ <button
302
+ type="button"
303
+ aria-label={t(n.label)}
304
+ aria-expanded={expanded}
305
+ aria-controls={`m-sub-${n.href}`}
306
+ onClick={() => setMobileGroup(expanded ? null : n.href)}
307
+ className="inline-flex h-11 w-11 items-center justify-center text-foreground/60 hover:text-foreground"
308
+ >
309
+ <ChevronDown
310
+ className={`size-4 transition-transform duration-300 ${
311
+ expanded ? "rotate-180" : ""
312
+ }`}
313
+ />
314
+ </button>
315
+ </div>
316
+ {expanded && (
317
+ <ul id={`m-sub-${n.href}`} className="pb-3 pl-4">
318
+ {kids.map((c) => (
319
+ <li key={c.href}>
320
+ <Link
321
+ to={c.href}
322
+ className="block py-2.5 text-xs uppercase tracking-[0.2em] text-foreground/65 hover:text-foreground"
323
+ onClick={() => setOpen(false)}
324
+ >
325
+ {t(c.label)}
326
+ </Link>
327
+ </li>
328
+ ))}
329
+ </ul>
330
+ )}
331
+ </div>
332
+ );
333
+ })}
334
+ {showBackoffice && backoffice && (
335
+ <Link
336
+ to={backoffice.href}
337
+ className="py-3 text-sm uppercase tracking-[0.2em] text-foreground/60 hover:text-foreground"
338
+ onClick={() => setOpen(false)}
339
+ >
340
+ {backoffice.label}
341
+ </Link>
342
+ )}
343
+ <Link
344
+ to={primaryCta.href}
345
+ className="mt-4 inline-flex items-center justify-center border border-foreground px-5 py-3 text-xs uppercase tracking-[0.2em]"
346
+ onClick={() => setOpen(false)}
347
+ >
348
+ {t(primaryCta.label)}
349
+ </Link>
350
+ </nav>
351
+ </div>
352
+ )}
353
+ </header>
354
+ );
355
+ }