@bygghammar/cms-platform 0.54.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 +15 -0
- package/package.json +6 -2
- package/src/blocks/components/TripListBlock.tsx +198 -0
- package/src/blocks/registry.tsx +35 -0
- package/src/host/index.tsx +20 -0
- package/src/site/SiteFooter.tsx +134 -0
- package/src/site/SiteHeader.tsx +355 -0
- package/src/site/site-url.ts +35 -0
- package/src/util/use-hash-scroll.ts +42 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
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
|
+
|
|
10
|
+
## 0.55.0
|
|
11
|
+
|
|
12
|
+
- Public departures list is now package-owned: `blocks/components/TripListBlock`,
|
|
13
|
+
registered as the shared `tripList` block (with the legacy `courseList` alias).
|
|
14
|
+
- New optional site adapter `useSiteActions` supplies shared call-to-action
|
|
15
|
+
labels (booking button) with brand-neutral defaults.
|
|
16
|
+
- New shared utilities: `util/use-hash-scroll` and `site/site-url`.
|
|
17
|
+
|
|
3
18
|
## 0.54.0
|
|
4
19
|
|
|
5
20
|
- `forms/FormRenderer`: the whole public managed-form runtime (validation,
|
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": [
|
|
@@ -201,7 +201,11 @@
|
|
|
201
201
|
"./server/media-usage-host": "./src/server/media-usage-host.ts",
|
|
202
202
|
"./runtime/server-entry": "./src/runtime/server-entry.ts",
|
|
203
203
|
"./site/indexing": "./src/site/indexing.ts",
|
|
204
|
-
"./backoffice/IndexingStatusCard": "./src/backoffice/IndexingStatusCard.tsx"
|
|
204
|
+
"./backoffice/IndexingStatusCard": "./src/backoffice/IndexingStatusCard.tsx",
|
|
205
|
+
"./util/use-hash-scroll": "./src/util/use-hash-scroll.ts",
|
|
206
|
+
"./site/site-url": "./src/site/site-url.ts",
|
|
207
|
+
"./site/SiteHeader": "./src/site/SiteHeader.tsx",
|
|
208
|
+
"./site/SiteFooter": "./src/site/SiteFooter.tsx"
|
|
205
209
|
},
|
|
206
210
|
"dependencies": {
|
|
207
211
|
"fflate": "^0.8.3"
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { ArrowRight, Sparkles } from "lucide-react";
|
|
3
|
+
import { useT } from "../../i18n";
|
|
4
|
+
import { Reveal } from "../../ui/Reveal";
|
|
5
|
+
import { useHostSiteActions, useHostTripSource } from "../../host";
|
|
6
|
+
import { tripNumber, type TripItem, type TripListProps } from "../../trips";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Grid of bookable trips/departures.
|
|
10
|
+
*
|
|
11
|
+
* Items come either from the block's own props (`source: "custom"`) or from the
|
|
12
|
+
* site's single central source registered as the `useTripSource` adapter, so a
|
|
13
|
+
* page can show its own selection or a filtered year of the global list.
|
|
14
|
+
*
|
|
15
|
+
* Structure only: every colour comes from the shared design tokens, so each
|
|
16
|
+
* site keeps its own look without forking this component.
|
|
17
|
+
*/
|
|
18
|
+
export function TripListBlock({ props }: { props: TripListProps }) {
|
|
19
|
+
const { t } = useT();
|
|
20
|
+
const actions = useHostSiteActions();
|
|
21
|
+
const source = useHostTripSource() as TripItem[];
|
|
22
|
+
const globalItems = (source ?? []).map((c, i) => ({ ...c, id: c.id ?? `global-${i}` }));
|
|
23
|
+
const items: TripItem[] =
|
|
24
|
+
props.source === "custom"
|
|
25
|
+
? (props.items ?? [])
|
|
26
|
+
: props.year
|
|
27
|
+
? globalItems.filter((c) => String(c.year ?? "") === String(props.year))
|
|
28
|
+
: globalItems;
|
|
29
|
+
const tone = props.tone === "paper" ? "bg-paper" : "bg-white";
|
|
30
|
+
const cols =
|
|
31
|
+
props.columns === 2
|
|
32
|
+
? "sm:grid-cols-2"
|
|
33
|
+
: props.columns === 4
|
|
34
|
+
? "sm:grid-cols-2 lg:grid-cols-4"
|
|
35
|
+
: "sm:grid-cols-2 lg:grid-cols-3";
|
|
36
|
+
|
|
37
|
+
// The accent "focus" treatment is a mobile-only affordance: on stacked phone
|
|
38
|
+
// layouts the card nearest the viewport centre lights up while scrolling.
|
|
39
|
+
// From `sm:` up every card stays neutral.
|
|
40
|
+
const cardRefs = useRef<Array<HTMLElement | null>>([]);
|
|
41
|
+
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
45
|
+
const mq = window.matchMedia("(max-width: 639px)");
|
|
46
|
+
let raf = 0;
|
|
47
|
+
|
|
48
|
+
const measure = () => {
|
|
49
|
+
raf = 0;
|
|
50
|
+
const mid = window.innerHeight / 2;
|
|
51
|
+
let best: number | null = null;
|
|
52
|
+
let bestDist = Infinity;
|
|
53
|
+
cardRefs.current.forEach((el, i) => {
|
|
54
|
+
if (!el) return;
|
|
55
|
+
const r = el.getBoundingClientRect();
|
|
56
|
+
if (r.bottom <= 0 || r.top >= window.innerHeight) return;
|
|
57
|
+
const dist = Math.abs(r.top + r.height / 2 - mid);
|
|
58
|
+
if (dist < bestDist) {
|
|
59
|
+
bestDist = dist;
|
|
60
|
+
best = i;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
setActiveIndex(best);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const onScroll = () => {
|
|
67
|
+
if (!raf) raf = window.requestAnimationFrame(measure);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const attach = () => {
|
|
71
|
+
if (mq.matches) {
|
|
72
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
73
|
+
window.addEventListener("resize", onScroll);
|
|
74
|
+
measure();
|
|
75
|
+
} else {
|
|
76
|
+
window.removeEventListener("scroll", onScroll);
|
|
77
|
+
window.removeEventListener("resize", onScroll);
|
|
78
|
+
setActiveIndex(null);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
attach();
|
|
83
|
+
mq.addEventListener("change", attach);
|
|
84
|
+
return () => {
|
|
85
|
+
mq.removeEventListener("change", attach);
|
|
86
|
+
window.removeEventListener("scroll", onScroll);
|
|
87
|
+
window.removeEventListener("resize", onScroll);
|
|
88
|
+
if (raf) window.cancelAnimationFrame(raf);
|
|
89
|
+
};
|
|
90
|
+
}, [items.length]);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<section id={props.anchor || undefined} className={`${tone} py-20 md:py-28`}>
|
|
94
|
+
<div className="container-editorial">
|
|
95
|
+
{(props.eyebrow || props.title || props.lead) && (
|
|
96
|
+
<Reveal>
|
|
97
|
+
<div className="max-w-3xl">
|
|
98
|
+
{props.eyebrow ? (
|
|
99
|
+
<span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/70">
|
|
100
|
+
{t(props.eyebrow)}
|
|
101
|
+
</span>
|
|
102
|
+
) : null}
|
|
103
|
+
{props.title ? (
|
|
104
|
+
<h2 className="mt-4 font-serif text-[clamp(1.9rem,4vw,3.25rem)] leading-[1.05] tracking-[-0.02em] text-ink whitespace-pre-line">
|
|
105
|
+
{t(props.title)}
|
|
106
|
+
</h2>
|
|
107
|
+
) : null}
|
|
108
|
+
{props.lead ? (
|
|
109
|
+
<p className="mt-5 max-w-2xl text-base md:text-lg leading-relaxed text-ink/75 whitespace-pre-line">
|
|
110
|
+
{t(props.lead)}
|
|
111
|
+
</p>
|
|
112
|
+
) : null}
|
|
113
|
+
</div>
|
|
114
|
+
</Reveal>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
<div className={`mt-12 md:mt-16 grid gap-5 ${cols}`}>
|
|
118
|
+
{items.map((c, i) => (
|
|
119
|
+
<Reveal key={c.id ?? i} delay={i * 60}>
|
|
120
|
+
<article
|
|
121
|
+
ref={(el) => {
|
|
122
|
+
cardRefs.current[i] = el;
|
|
123
|
+
}}
|
|
124
|
+
className={[
|
|
125
|
+
"group relative flex h-full flex-col justify-between bg-card p-7 md:p-8 text-ink border transition-all",
|
|
126
|
+
activeIndex === i
|
|
127
|
+
? "border-sea shadow-[0_20px_60px_-30px_rgba(42,196,234,0.55)]"
|
|
128
|
+
: "border-ink/10 hover:border-ink/25",
|
|
129
|
+
].join(" ")}
|
|
130
|
+
>
|
|
131
|
+
{activeIndex === i && (
|
|
132
|
+
<span
|
|
133
|
+
aria-hidden
|
|
134
|
+
className="absolute inset-x-0 top-0 h-[3px]"
|
|
135
|
+
style={{ backgroundColor: "var(--sea)" }}
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
138
|
+
<div>
|
|
139
|
+
<div className="flex items-center justify-between gap-3">
|
|
140
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/70">
|
|
141
|
+
{t(tripNumber(c))}
|
|
142
|
+
</div>
|
|
143
|
+
{c.badge ? (
|
|
144
|
+
<span
|
|
145
|
+
className="inline-flex items-center gap-1 px-2 py-0.5 text-[0.6rem] uppercase tracking-[0.2em] text-ink"
|
|
146
|
+
style={{ backgroundColor: "var(--sea)" }}
|
|
147
|
+
>
|
|
148
|
+
<Sparkles className="size-3" />
|
|
149
|
+
{t(c.badge)}
|
|
150
|
+
</span>
|
|
151
|
+
) : null}
|
|
152
|
+
</div>
|
|
153
|
+
<h3 className="mt-2 font-serif text-2xl md:text-[1.85rem] leading-tight tracking-[-0.01em] whitespace-pre-line">
|
|
154
|
+
{t(c.location)}
|
|
155
|
+
</h3>
|
|
156
|
+
<div className="mt-4 space-y-1.5 border-t border-ink/10 pt-4 text-sm text-ink/70">
|
|
157
|
+
<div>{t(c.dates)}</div>
|
|
158
|
+
<div>{t(c.accommodation)}</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="mt-8">
|
|
162
|
+
<div className="flex flex-wrap items-baseline gap-2">
|
|
163
|
+
<span className="font-serif text-3xl tracking-[-0.01em]">{t(c.price)}</span>
|
|
164
|
+
{c.oldPrice ? (
|
|
165
|
+
<span className="text-sm text-ink/65 line-through">{t(c.oldPrice)}</span>
|
|
166
|
+
) : null}
|
|
167
|
+
</div>
|
|
168
|
+
{c.bookUrl ? (
|
|
169
|
+
<a
|
|
170
|
+
href={c.bookUrl}
|
|
171
|
+
target="_blank"
|
|
172
|
+
rel="noopener noreferrer"
|
|
173
|
+
className="mt-6 inline-flex w-full items-center justify-center gap-2 bg-ink px-5 py-3 text-xs uppercase tracking-[0.22em] text-paper transition-colors hover:bg-ink/85"
|
|
174
|
+
>
|
|
175
|
+
{c.bookLabel ? t(c.bookLabel) : t(actions.book)}
|
|
176
|
+
<ArrowRight className="size-4" />
|
|
177
|
+
</a>
|
|
178
|
+
) : null}
|
|
179
|
+
</div>
|
|
180
|
+
</article>
|
|
181
|
+
</Reveal>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{props.note ? (
|
|
186
|
+
<Reveal>
|
|
187
|
+
<p className="mt-8 max-w-3xl text-sm leading-relaxed text-ink/70 whitespace-pre-line">
|
|
188
|
+
{t(props.note)}
|
|
189
|
+
</p>
|
|
190
|
+
</Reveal>
|
|
191
|
+
) : null}
|
|
192
|
+
</div>
|
|
193
|
+
</section>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @deprecated Legacy alias; `courseList` blocks render the trips list. */
|
|
198
|
+
export const CourseListBlock = TripListBlock;
|
package/src/blocks/registry.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { LinkTilesBlock } from "./components/LinkTilesBlock";
|
|
|
13
13
|
import { CabinTabsBlock } from "./components/CabinTabsBlock";
|
|
14
14
|
import { PriceTableBlock } from "./components/PriceTableBlock";
|
|
15
15
|
import { FormBlock } from "./components/FormBlock";
|
|
16
|
+
import { TripListBlock } from "./components/TripListBlock";
|
|
16
17
|
import type {
|
|
17
18
|
BlockProps,
|
|
18
19
|
FaqProps,
|
|
@@ -29,6 +30,7 @@ import type {
|
|
|
29
30
|
CabinTabsProps,
|
|
30
31
|
PriceTableProps,
|
|
31
32
|
FormProps,
|
|
33
|
+
TripListProps,
|
|
32
34
|
} from "./types";
|
|
33
35
|
import { BlockPropsProvider } from "./block-props-context";
|
|
34
36
|
|
|
@@ -324,6 +326,39 @@ export const SHARED_BLOCK_DEFS: Record<string, BlockDef> = {
|
|
|
324
326
|
} satisfies FormProps as unknown as BlockProps,
|
|
325
327
|
hasInlineContent: true,
|
|
326
328
|
},
|
|
329
|
+
tripList: {
|
|
330
|
+
type: "tripList",
|
|
331
|
+
label: "Departures",
|
|
332
|
+
description:
|
|
333
|
+
"Grid of bookable departures pulled from the central Trips source in the backoffice (optionally filtered by year).",
|
|
334
|
+
category: "content",
|
|
335
|
+
component: ({ props }) => <TripListBlock props={props as TripListProps} />,
|
|
336
|
+
defaultProps: {
|
|
337
|
+
title: { sv: "Avg\u00e5ngar", en: "Departures" },
|
|
338
|
+
tone: "white",
|
|
339
|
+
columns: 3,
|
|
340
|
+
source: "global",
|
|
341
|
+
year: "",
|
|
342
|
+
items: [],
|
|
343
|
+
} satisfies TripListProps as unknown as BlockProps,
|
|
344
|
+
hasInlineContent: true,
|
|
345
|
+
},
|
|
346
|
+
courseList: {
|
|
347
|
+
type: "courseList",
|
|
348
|
+
label: "Departures (legacy)",
|
|
349
|
+
description: "Legacy alias for the shared departures list.",
|
|
350
|
+
category: "content",
|
|
351
|
+
component: ({ props }) => <TripListBlock props={props as TripListProps} />,
|
|
352
|
+
defaultProps: {
|
|
353
|
+
title: { sv: "Avg\u00e5ngar", en: "Departures" },
|
|
354
|
+
tone: "white",
|
|
355
|
+
columns: 3,
|
|
356
|
+
source: "global",
|
|
357
|
+
year: "",
|
|
358
|
+
items: [],
|
|
359
|
+
} satisfies TripListProps as unknown as BlockProps,
|
|
360
|
+
hasInlineContent: true,
|
|
361
|
+
},
|
|
327
362
|
};
|
|
328
363
|
|
|
329
364
|
/**
|
package/src/host/index.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentType, ImgHTMLAttributes } from "react";
|
|
2
2
|
import type { UseQueryOptions } from "@tanstack/react-query";
|
|
3
|
+
import type { LocalizedString } from "../i18n/types";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Host component contract.
|
|
@@ -192,8 +193,20 @@ export type HostSiteAdapters = {
|
|
|
192
193
|
* `trip`/`course` dropdowns from a single source of truth.
|
|
193
194
|
*/
|
|
194
195
|
useTripSource: () => unknown[];
|
|
196
|
+
/**
|
|
197
|
+
* Hook returning the site's shared action labels (e.g. the booking button).
|
|
198
|
+
* Optional: the package falls back to neutral defaults.
|
|
199
|
+
*/
|
|
200
|
+
useSiteActions: () => HostSiteActions;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** Shared call-to-action labels used by public blocks. */
|
|
204
|
+
export type HostSiteActions = {
|
|
205
|
+
book: Partial<LocalizedString>;
|
|
195
206
|
};
|
|
196
207
|
|
|
208
|
+
const DEFAULT_SITE_ACTIONS: HostSiteActions = { book: { sv: "Boka", en: "Book" } };
|
|
209
|
+
|
|
197
210
|
let siteAdapters: Partial<HostSiteAdapters> = {};
|
|
198
211
|
|
|
199
212
|
export function registerSiteAdapters(adapters: Partial<HostSiteAdapters>): void {
|
|
@@ -223,3 +236,10 @@ export function useHostTripSource(): unknown[] {
|
|
|
223
236
|
const hook = siteAdapters.useTripSource;
|
|
224
237
|
return hook ? hook() : [];
|
|
225
238
|
}
|
|
239
|
+
|
|
240
|
+
/** Shared action labels, or brand-neutral defaults when no site registered any. */
|
|
241
|
+
export function useHostSiteActions(): HostSiteActions {
|
|
242
|
+
const hook = siteAdapters.useSiteActions;
|
|
243
|
+
const value = hook ? hook() : undefined;
|
|
244
|
+
return { ...DEFAULT_SITE_ACTIONS, ...(value ?? {}) };
|
|
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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical site origin.
|
|
3
|
+
*
|
|
4
|
+
* `SITE_URL` is the authoritative origin for the backoffice, authentication
|
|
5
|
+
* redirects and password links. When it is unset the request host is used.
|
|
6
|
+
*
|
|
7
|
+
* Pure module — no database, no filesystem, no network. Safe to import from
|
|
8
|
+
* tests, scripts and server code.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Strip scheme, path, trailing dots/slashes and a leading `www.`. */
|
|
12
|
+
export function normalizeHost(value: string): string {
|
|
13
|
+
return (value ?? "")
|
|
14
|
+
.trim()
|
|
15
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")
|
|
16
|
+
.replace(/\/.*$/, "")
|
|
17
|
+
.replace(/\.+$/, "")
|
|
18
|
+
.replace(/^www\./i, "")
|
|
19
|
+
.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The configured site origin (`https://host`), or null when unset. */
|
|
23
|
+
export function siteOrigin(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
24
|
+
const raw = (env["SITE_URL"] ?? "").trim().replace(/\/+$/, "");
|
|
25
|
+
if (!raw) return null;
|
|
26
|
+
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The hostname of SITE_URL, normalized, or null when SITE_URL is unset. */
|
|
30
|
+
export function siteHost(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
31
|
+
const origin = siteOrigin(env);
|
|
32
|
+
if (!origin) return null;
|
|
33
|
+
const host = normalizeHost(origin);
|
|
34
|
+
return host || null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useRouterState } from "@tanstack/react-router";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Scrolls to `#hash` targets across route transitions and initial loads.
|
|
6
|
+
* Retries briefly so sections that mount after data loads still resolve.
|
|
7
|
+
*/
|
|
8
|
+
export function useHashScroll(options?: { desktopOffset?: number; mobileOffset?: number }) {
|
|
9
|
+
const hash = useRouterState({ select: (s) => s.location.hash });
|
|
10
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
11
|
+
const desktopOffset = options?.desktopOffset ?? 80;
|
|
12
|
+
const mobileOffset = options?.mobileOffset ?? 64;
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (typeof window === "undefined") return;
|
|
16
|
+
const raw = hash?.replace(/^#/, "");
|
|
17
|
+
if (!raw) return;
|
|
18
|
+
|
|
19
|
+
let cancelled = false;
|
|
20
|
+
const start = performance.now();
|
|
21
|
+
const headerOffset = window.matchMedia("(min-width: 768px)").matches
|
|
22
|
+
? desktopOffset
|
|
23
|
+
: mobileOffset;
|
|
24
|
+
|
|
25
|
+
const tick = () => {
|
|
26
|
+
if (cancelled) return;
|
|
27
|
+
const el = document.getElementById(raw);
|
|
28
|
+
if (el) {
|
|
29
|
+
const top = el.getBoundingClientRect().top + window.scrollY - headerOffset;
|
|
30
|
+
window.scrollTo({ top, behavior: "smooth" });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (performance.now() - start < 1500) window.requestAnimationFrame(tick);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const raf = window.requestAnimationFrame(tick);
|
|
37
|
+
return () => {
|
|
38
|
+
cancelled = true;
|
|
39
|
+
window.cancelAnimationFrame(raf);
|
|
40
|
+
};
|
|
41
|
+
}, [hash, pathname, desktopOffset, mobileOffset]);
|
|
42
|
+
}
|