@bygghammar/cms-platform 0.54.0 → 0.55.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 +8 -0
- package/package.json +4 -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/site-url.ts +35 -0
- package/src/util/use-hash-scroll.ts +42 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.55.0
|
|
4
|
+
|
|
5
|
+
- Public departures list is now package-owned: `blocks/components/TripListBlock`,
|
|
6
|
+
registered as the shared `tripList` block (with the legacy `courseList` alias).
|
|
7
|
+
- New optional site adapter `useSiteActions` supplies shared call-to-action
|
|
8
|
+
labels (booking button) with brand-neutral defaults.
|
|
9
|
+
- New shared utilities: `util/use-hash-scroll` and `site/site-url`.
|
|
10
|
+
|
|
3
11
|
## 0.54.0
|
|
4
12
|
|
|
5
13
|
- `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.55.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,9 @@
|
|
|
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"
|
|
205
207
|
},
|
|
206
208
|
"dependencies": {
|
|
207
209
|
"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,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
|
+
}
|