@bygghammar/cms-platform 0.55.0 → 0.57.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.57.0
4
+
5
+ - Server-function inputs are now platform contracts: `server/schemas` ships the
6
+ shared zod validators for the page builder, administration, media library,
7
+ redirects and the page SEO editor. Sites keep declaring their own server
8
+ functions (the build step requires it) but stop re-writing the shapes.
9
+ - New `server/page-seo-save-core`: slug-conflict rejection, manual-edit
10
+ stamping, automatic 301 redirects and the page SEO upsert now live in the
11
+ package. Sites supply their locales, registry slugs and client only.
12
+ - `zod` is declared as a peer dependency.
13
+
14
+ ## 0.56.0
15
+
16
+ - Public site chrome is now package-owned: `site/SiteHeader` and
17
+ `site/SiteFooter`. Structure, dropdown/keyboard behaviour, mobile panel,
18
+ address block and Organization JSON-LD live in the package; sites pass
19
+ navigation, brand text, logo artwork, CTA target and company details.
20
+
3
21
  ## 0.55.0
4
22
 
5
23
  - 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.57.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,11 @@
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",
209
+ "./server/schemas": "./src/server/schemas.ts",
210
+ "./server/page-seo-save-core": "./src/server/page-seo-save-core.ts"
207
211
  },
208
212
  "dependencies": {
209
213
  "fflate": "^0.8.3"
@@ -230,7 +234,8 @@
230
234
  "@tiptap/extension-table": ">=3",
231
235
  "@dnd-kit/core": ">=6",
232
236
  "@dnd-kit/sortable": ">=3",
233
- "@dnd-kit/utilities": ">=3"
237
+ "@dnd-kit/utilities": ">=3",
238
+ "zod": ">=3"
234
239
  },
235
240
  "peerDependenciesMeta": {
236
241
  "@supabase/supabase-js": {
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Host-agnostic page SEO write path: slug-conflict rejection, manual-edit
3
+ * stamping, automatic 301 redirects for changed slugs, and the upsert itself.
4
+ *
5
+ * The site owns only its page registry, its locales and its authenticated
6
+ * client; every rule below is shared by every site.
7
+ */
8
+ import { stampManualEdits } from "../i18n/translation-meta";
9
+ import { slugRedirectRows, type SlugConflict } from "./page-seo-core";
10
+
11
+ /* eslint-disable @typescript-eslint/no-explicit-any */
12
+ type Client = {
13
+ from: (table: string) => any;
14
+ };
15
+
16
+ export type PageSeoWriteContext = { supabase: Client; userId: string };
17
+
18
+ export type PageSeoSaveInput = {
19
+ pageKey: string;
20
+ slugs: Record<string, unknown>;
21
+ seo: Record<string, unknown>;
22
+ };
23
+
24
+ export type PageSeoSaveResult =
25
+ | { ok: true; pageKey: string }
26
+ | { ok: false; reason: "slug_conflict"; conflicts: SlugConflict[] };
27
+
28
+ export type PageSeoSaveDeps = {
29
+ context: PageSeoWriteContext;
30
+ locales: readonly string[];
31
+ /** Slug map declared in the site's route registry for this page, if any. */
32
+ registrySlugs?: Record<string, string> | undefined;
33
+ /** Site-side conflict lookup (it reads the stored overrides). */
34
+ detectConflicts: (input: PageSeoSaveInput) => Promise<SlugConflict[]>;
35
+ };
36
+
37
+ export async function savePageSeoCore(
38
+ deps: PageSeoSaveDeps,
39
+ data: PageSeoSaveInput,
40
+ ): Promise<PageSeoSaveResult> {
41
+ const { context } = deps;
42
+
43
+ const conflicts = await deps.detectConflicts(data);
44
+ if (conflicts.length > 0) return { ok: false, reason: "slug_conflict", conflicts };
45
+
46
+ const { data: prevRow } = await context.supabase
47
+ .from("page_seo")
48
+ .select("slugs, seo")
49
+ .eq("page_key", data.pageKey)
50
+ .maybeSingle();
51
+
52
+ const stampedSlugs = stampManualEdits(data.slugs, prevRow?.slugs ?? null);
53
+ const stampedSeo = stampManualEdits(data.seo, prevRow?.seo ?? null);
54
+
55
+ // Any previous public URL keeps working: create 301s to the new one.
56
+ const rows = slugRedirectRows({
57
+ locales: deps.locales,
58
+ previousSlugs: (prevRow?.slugs as Record<string, string> | null) ?? null,
59
+ registrySlugs: deps.registrySlugs,
60
+ nextSlugs: data.slugs as Record<string, string>,
61
+ userId: context.userId,
62
+ });
63
+
64
+ if (rows.length > 0) {
65
+ // Retarget stale chains that pointed at the old path, then insert.
66
+ for (const r of rows) {
67
+ await context.supabase
68
+ .from("redirects")
69
+ .update({ to_path: r.to_path })
70
+ .eq("to_path", r.from_path);
71
+ await context.supabase.from("redirects").delete().eq("from_path", r.from_path);
72
+ }
73
+ const { error: redirErr } = await context.supabase.from("redirects").insert(rows);
74
+ if (redirErr) console.error("[cms] redirect create", redirErr);
75
+ }
76
+
77
+ const { error } = await context.supabase.from("page_seo").upsert(
78
+ {
79
+ page_key: data.pageKey,
80
+ slugs: stampedSlugs,
81
+ seo: stampedSeo,
82
+ updated_by: context.userId,
83
+ },
84
+ { onConflict: "page_key" },
85
+ );
86
+ if (error) throw new Error(error.message);
87
+
88
+ return { ok: true, pageKey: data.pageKey };
89
+ }
90
+
91
+ /** Remove a page's stored override, falling back to the code registry. */
92
+ export async function deletePageSeoOverrideCore(
93
+ context: PageSeoWriteContext,
94
+ pageKey: string,
95
+ ): Promise<{ ok: true; pageKey: string }> {
96
+ const { error } = await context.supabase.from("page_seo").delete().eq("page_key", pageKey);
97
+ if (error) throw new Error(error.message);
98
+ return { ok: true, pageKey };
99
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Shared input validators for the platform's server functions.
3
+ *
4
+ * Every site declares its server functions locally (TanStack's build step must
5
+ * see `createServerFn` in the site's own source), but the *shapes* those
6
+ * functions accept are platform contracts. They live here so a validator is
7
+ * written, reviewed and fixed exactly once.
8
+ *
9
+ * These modules are pure zod schemas: no database access, no auth, no request
10
+ * state, so they are safe to import from either side of the boundary.
11
+ */
12
+ import { z } from "zod";
13
+
14
+ /* ------------------------------------------------------------------ shared */
15
+
16
+ export const uuid = z.string().uuid();
17
+ export const uuidInput = <K extends string>(key: K) => z.object({ [key]: uuid } as Record<K, typeof uuid>);
18
+
19
+ /** Optional email that accepts an empty string and stores it as null. */
20
+ export const nullableEmail = z
21
+ .string()
22
+ .email()
23
+ .nullable()
24
+ .optional()
25
+ .or(z.literal("").transform(() => null));
26
+
27
+ /** CMS key: lowercase, digits, dash and underscore only. */
28
+ export const cmsKey = z
29
+ .string()
30
+ .min(1)
31
+ .max(64)
32
+ .regex(/^[a-z0-9_-]+$/);
33
+
34
+ export const listLimit = z.object({ limit: z.number().min(1).max(500).default(200) });
35
+
36
+ /* --------------------------------------------------------------- page builder */
37
+
38
+ /** One block instance as it travels over the wire. */
39
+ export const blockInstanceSchema = z.object({
40
+ id: z.string().min(1),
41
+ type: z.string().min(1),
42
+ name: z.string().max(120).optional(),
43
+ props: z.record(z.string(), z.any()).optional(),
44
+ visible: z.boolean().optional(),
45
+ });
46
+
47
+ export const pageSchemas = {
48
+ key: z.object({ key: z.string() }),
49
+ pageId: z.object({ pageId: uuid }),
50
+ versionId: z.object({ versionId: uuid }),
51
+ sectionId: z.object({ sectionId: uuid }),
52
+ publicationId: z.object({ publicationId: uuid }),
53
+ createPage: z.object({ key: cmsKey, name: z.string().min(1).max(120) }),
54
+ renamePage: z.object({ pageId: uuid, name: z.string().min(1).max(120) }),
55
+ savePageVersion: z.object({
56
+ pageId: uuid,
57
+ blocks: z.array(blockInstanceSchema),
58
+ publish: z.boolean().optional(),
59
+ note: z.string().max(500).optional(),
60
+ }),
61
+ createGlobalSection: z.object({
62
+ key: cmsKey,
63
+ name: z.string().min(1).max(120),
64
+ blocks: z.array(blockInstanceSchema).optional(),
65
+ kind: z.enum(["block", "section"]).optional(),
66
+ }),
67
+ setGlobalSectionKind: z.object({ sectionId: uuid, kind: z.enum(["block", "section"]) }),
68
+ saveGlobalSectionVersion: z.object({
69
+ sectionId: uuid,
70
+ blocks: z.array(blockInstanceSchema),
71
+ note: z.string().max(500).optional(),
72
+ }),
73
+ renameGlobalSection: z.object({ sectionId: uuid, name: z.string().min(1).max(120) }),
74
+ schedulePageVersion: z.object({
75
+ versionId: uuid,
76
+ publishAt: z.string().datetime(),
77
+ unpublishAt: z.string().datetime().nullable().optional(),
78
+ note: z.string().max(500).optional(),
79
+ }),
80
+ } as const;
81
+
82
+ /* ------------------------------------------------------------- administration */
83
+
84
+ /**
85
+ * Administration inputs. Roles and provider kinds come from the platform's
86
+ * own enums, which the caller passes so a site cannot drift from them.
87
+ */
88
+ export function adminSchemas(input: {
89
+ roles: readonly [string, ...string[]];
90
+ providerKinds: readonly [string, ...string[]];
91
+ }) {
92
+ const role = z.enum(input.roles);
93
+ return {
94
+ role,
95
+ createUser: z.object({
96
+ email: z.string().email(),
97
+ password: z.string().min(8).max(128).optional(),
98
+ roles: z.array(role).default([]),
99
+ }),
100
+ userId: z.object({ userId: uuid }),
101
+ setUserPassword: z.object({ userId: uuid, password: z.string().min(8).max(128) }),
102
+ setUserRoles: z.object({ userId: uuid, roles: z.array(role) }),
103
+ email: z.object({ email: z.string().email() }),
104
+ listLimit,
105
+ emailSettings: z.object({
106
+ primary_from_name: z.string().max(200).nullable().optional(),
107
+ primary_from_email: nullableEmail,
108
+ backup_from_email: nullableEmail,
109
+ reply_to: nullableEmail,
110
+ admin_notify_to: z.string().max(500).nullable().optional(),
111
+ default_provider_id: uuid.nullable().optional(),
112
+ fallback_provider_id: uuid.nullable().optional(),
113
+ }),
114
+ emailProvider: z.object({
115
+ id: uuid.optional(),
116
+ name: z.string().min(1).max(120),
117
+ kind: z.enum(input.providerKinds),
118
+ is_active: z.boolean().default(true),
119
+ sort_order: z.number().int().min(0).max(9999).default(100),
120
+ from_name: z.string().max(200).nullable().optional(),
121
+ from_email: nullableEmail,
122
+ reply_to: nullableEmail,
123
+ config: z.record(z.string(), z.unknown()).default({}),
124
+ secret_ref_name: z.string().max(200).nullable().optional(),
125
+ notes: z.string().max(2000).nullable().optional(),
126
+ }),
127
+ id: z.object({ id: uuid }),
128
+ sendTestEmail: z.object({ provider_id: uuid, to: z.string().email().max(320) }),
129
+ } as const;
130
+ }
131
+
132
+ /* --------------------------------------------------------------------- media */
133
+
134
+ export const mediaSchemas = {
135
+ path: z.object({ path: z.string().min(1) }),
136
+ id: z.object({ id: uuid }),
137
+ uploadBundle: z.object({
138
+ files: z
139
+ .array(
140
+ z.object({
141
+ name: z.string().min(1),
142
+ contentType: z.string().min(1),
143
+ bytes: z.array(z.number().int().min(0).max(255)),
144
+ }),
145
+ )
146
+ .min(1)
147
+ .max(50),
148
+ upsert: z.boolean().optional(),
149
+ }),
150
+ reviewImage: z.object({
151
+ slug: z.string().trim().min(1).max(80),
152
+ variants: z
153
+ .array(z.object({ width: z.number().int(), data: z.string().min(16).max(4_000_000) }))
154
+ .length(4),
155
+ }),
156
+ prepareOverwrite: z.object({ bases: z.array(z.string().min(1)).max(500) }),
157
+ logOverwrite: z.object({
158
+ paths: z.array(z.string()).max(3000),
159
+ skipped: z.array(z.string()).max(500),
160
+ failed: z.array(z.string()).max(500),
161
+ }),
162
+ } as const;
163
+
164
+ /* ------------------------------------------------------------------ redirects */
165
+
166
+ export const redirectSchemas = {
167
+ path: z.object({ path: z.string().max(512) }),
168
+ id: z.object({ id: uuid }),
169
+ save: z.object({
170
+ id: uuid.optional(),
171
+ from_path: z.string().min(1).max(512),
172
+ to_path: z.string().min(1).max(512),
173
+ status: z
174
+ .number()
175
+ .int()
176
+ .refine((n) => n === 301 || n === 302, "301 or 302"),
177
+ }),
178
+ } as const;
179
+
180
+ /* --------------------------------------------------------------------- page SEO */
181
+
182
+ const localeMetaMap = z
183
+ .record(
184
+ z.string(),
185
+ z
186
+ .object({
187
+ origin: z.enum(["auto", "manual"]),
188
+ svHash: z.string().optional(),
189
+ updatedAt: z.string().optional(),
190
+ })
191
+ .passthrough(),
192
+ )
193
+ .optional();
194
+
195
+ const localizedString = z.object({ _meta: localeMetaMap }).catchall(z.string());
196
+
197
+ const localizedImage = z
198
+ .object({
199
+ src: z.string().url().or(z.string().startsWith("/")),
200
+ alt: localizedString,
201
+ srcByLocale: z.object({ _meta: localeMetaMap }).catchall(z.string()).optional(),
202
+ width: z.number().int().positive().optional(),
203
+ height: z.number().int().positive().optional(),
204
+ })
205
+ .optional();
206
+
207
+ /**
208
+ * Page SEO editor inputs. The site supplies its own page keys and locales;
209
+ * everything else — the localized string/image shape, the allowed Open Graph
210
+ * and Twitter values, the slug map — is a platform contract.
211
+ */
212
+ export function pageSeoSchemas(input: {
213
+ pageKeys: readonly [string, ...string[]];
214
+ locales: readonly [string, ...string[]];
215
+ }) {
216
+ const seo = z
217
+ .object({
218
+ title: localizedString.optional(),
219
+ description: localizedString.optional(),
220
+ ogTitle: localizedString.optional(),
221
+ ogDescription: localizedString.optional(),
222
+ ogImage: localizedImage,
223
+ ogType: z.enum(["website", "article", "product"]).optional(),
224
+ twitterCard: z.enum(["summary", "summary_large_image"]).optional(),
225
+ keywords: localizedString.optional(),
226
+ robots: z.string().max(120).optional(),
227
+ jsonLd: z.record(z.enum(input.locales), z.record(z.string(), z.unknown())).optional(),
228
+ })
229
+ .strict();
230
+
231
+ const slugs = z.object({ _meta: localeMetaMap }).catchall(z.string().max(120));
232
+
233
+ return {
234
+ seo,
235
+ slugs,
236
+ save: z.object({ pageKey: z.enum(input.pageKeys), slugs, seo }),
237
+ delete: z.object({ pageKey: z.enum(input.pageKeys) }),
238
+ } as const;
239
+ }
240
+
241
+ export type PageSeoSaveInput = {
242
+ pageKey: string;
243
+ slugs: Record<string, unknown>;
244
+ seo: Record<string, unknown>;
245
+ };
@@ -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
+ }