@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 +7 -0
- package/package.json +4 -2
- package/src/site/SiteFooter.tsx +134 -0
- package/src/site/SiteHeader.tsx +355 -0
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.
|
|
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
|
+
}
|