@bygghammar/cms-platform 0.53.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 CHANGED
@@ -1,5 +1,25 @@
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
+
11
+ ## 0.54.0
12
+
13
+ - `forms/FormRenderer`: the whole public managed-form runtime (validation,
14
+ conditional fields, honeypot, partial drafts, thank-you/redirect) now lives in
15
+ the package, together with `ManagedForm` which resolves a form by key.
16
+ - `blocks/components/FormBlock`: the form section block is package-owned and is
17
+ registered in `SHARED_BLOCK_DEFS` as `form`, so a fresh site gets it for free.
18
+ - `host`: new site-adapter contract (`registerSiteAdapters`,
19
+ `usePublicFormFns`, `useTripSource`) so a site hands its own server functions
20
+ and central trip source to package-owned public components.
21
+ - `blocks/types`: added `FormProps`.
22
+
3
23
  ## 0.53.0
4
24
 
5
25
  - `admin/describe-log`: one place that turns action codes and JSON meta into
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.53.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": [
@@ -48,6 +48,7 @@
48
48
  "./trips": "./src/trips/index.ts",
49
49
  "./trips/TripSectionEditor": "./src/trips/TripSectionEditor.tsx",
50
50
  "./navigation": "./src/navigation/index.ts",
51
+ "./forms/FormRenderer": "./src/forms/FormRenderer.tsx",
51
52
  "./forms/conditions": "./src/forms/conditions.ts",
52
53
  "./forms/field-sets": "./src/forms/field-sets.ts",
53
54
  "./forms/form-audit": "./src/forms/form-audit.ts",
@@ -200,7 +201,9 @@
200
201
  "./server/media-usage-host": "./src/server/media-usage-host.ts",
201
202
  "./runtime/server-entry": "./src/runtime/server-entry.ts",
202
203
  "./site/indexing": "./src/site/indexing.ts",
203
- "./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"
204
207
  },
205
208
  "dependencies": {
206
209
  "fflate": "^0.8.3"
@@ -0,0 +1,51 @@
1
+ import { useT } from "../../i18n";
2
+ import type { LocalizedString } from "../../i18n";
3
+ import { ManagedForm } from "../../forms/FormRenderer";
4
+ import type { FormProps } from "../types";
5
+
6
+ const TONES: Record<string, string> = {
7
+ white: "bg-white text-ink",
8
+ paper: "bg-paper text-ink",
9
+ ink: "bg-ink text-paper",
10
+ };
11
+
12
+ /**
13
+ * Section wrapper around a managed form: optional eyebrow/title/lead plus the
14
+ * form itself, resolved by key from the Forms module.
15
+ */
16
+ export function FormBlock({ props }: { props: FormProps }) {
17
+ const { t } = useT();
18
+ const tone = props.tone ?? "paper";
19
+ const dark = tone === "ink";
20
+ const formKey = props.formKey?.trim();
21
+
22
+ return (
23
+ <section id={props.anchor || undefined} className={`${TONES[tone]} py-20 md:py-28`}>
24
+ <div className="mx-auto w-full max-w-3xl px-6">
25
+ {props.eyebrow ? (
26
+ <div className="text-[0.7rem] uppercase tracking-[0.28em] opacity-60">
27
+ {t(props.eyebrow as LocalizedString)}
28
+ </div>
29
+ ) : null}
30
+ {props.title ? (
31
+ <h2 className="mt-4 font-serif text-3xl md:text-4xl whitespace-pre-line">
32
+ {t(props.title as LocalizedString)}
33
+ </h2>
34
+ ) : null}
35
+ {props.lead ? (
36
+ <p className="mt-4 opacity-75 whitespace-pre-line">{t(props.lead as LocalizedString)}</p>
37
+ ) : null}
38
+ <div className="mt-10">
39
+ {formKey ? (
40
+ <ManagedForm formKey={formKey} dark={dark} />
41
+ ) : (
42
+ <p className="text-sm opacity-60 whitespace-pre-line">No form selected.</p>
43
+ )}
44
+ </div>
45
+ </div>
46
+ </section>
47
+ );
48
+ }
49
+
50
+ export type { FormProps };
51
+ export { FormRenderer, ManagedForm } from "../../forms/FormRenderer";
@@ -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;
@@ -12,6 +12,8 @@ import { ImageMarqueeBlock } from "./components/ImageMarqueeBlock";
12
12
  import { LinkTilesBlock } from "./components/LinkTilesBlock";
13
13
  import { CabinTabsBlock } from "./components/CabinTabsBlock";
14
14
  import { PriceTableBlock } from "./components/PriceTableBlock";
15
+ import { FormBlock } from "./components/FormBlock";
16
+ import { TripListBlock } from "./components/TripListBlock";
15
17
  import type {
16
18
  BlockProps,
17
19
  FaqProps,
@@ -27,6 +29,8 @@ import type {
27
29
  LinkTilesProps,
28
30
  CabinTabsProps,
29
31
  PriceTableProps,
32
+ FormProps,
33
+ TripListProps,
30
34
  } from "./types";
31
35
  import { BlockPropsProvider } from "./block-props-context";
32
36
 
@@ -309,6 +313,52 @@ export const SHARED_BLOCK_DEFS: Record<string, BlockDef> = {
309
313
  } satisfies PriceTableProps as unknown as BlockProps,
310
314
  hasInlineContent: true,
311
315
  },
316
+ form: {
317
+ type: "form",
318
+ label: "Form",
319
+ description:
320
+ "Renders a managed form from Backoffice \u2192 Forms. All forms share the same settings, notifications and submissions inbox.",
321
+ category: "content",
322
+ component: ({ props }) => <FormBlock props={props as FormProps} />,
323
+ defaultProps: {
324
+ formKey: "",
325
+ tone: "paper",
326
+ } satisfies FormProps as unknown as BlockProps,
327
+ hasInlineContent: true,
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
+ },
312
362
  };
313
363
 
314
364
  /**
@@ -423,3 +423,13 @@ export type PriceTableProps = {
423
423
 
424
424
  groups?: PriceGroup[];
425
425
  };
426
+
427
+ /** Props for the shared managed-form section block. */
428
+ export type FormProps = {
429
+ formKey?: string;
430
+ eyebrow?: Partial<LocalizedString>;
431
+ title?: Partial<LocalizedString>;
432
+ lead?: Partial<LocalizedString>;
433
+ tone?: "white" | "paper" | "ink";
434
+ anchor?: string;
435
+ };
@@ -0,0 +1,438 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { ArrowRight } from "lucide-react";
4
+ import { useT, useLocale } from "../i18n";
5
+ import type { LocalizedString } from "../i18n";
6
+ import { tripFieldOptions, resolveAutocomplete } from "./field-sets";
7
+ import type { FormFieldDef, PublicForm, TripSource } from "./field-sets";
8
+ import {
9
+ isFieldRequired,
10
+ isFieldVisible,
11
+ normalizeCheckbox,
12
+ validateAll,
13
+ validateField,
14
+ } from "./conditions";
15
+ import { renderLinkedText } from "./rich-text";
16
+ import { useHostPublicFormFns, useHostTripSource } from "../host";
17
+
18
+ const TOKEN_PREFIX = "form-draft-token:";
19
+
20
+ /** Stable per-visitor, per-form token so a draft updates instead of piling up. */
21
+ function draftToken(formKey: string): string {
22
+ if (typeof window === "undefined") return "";
23
+ const storageKey = TOKEN_PREFIX + formKey;
24
+ try {
25
+ const existing = window.localStorage.getItem(storageKey);
26
+ if (existing) return existing;
27
+ const token =
28
+ typeof crypto !== "undefined" && crypto.randomUUID
29
+ ? crypto.randomUUID()
30
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
31
+ window.localStorage.setItem(storageKey, token);
32
+ return token;
33
+ } catch {
34
+ return "";
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Renders a managed form from an already-resolved definition.
40
+ * `preview` runs every rule and validation but never contacts the server,
41
+ * which is what the backoffice preview uses.
42
+ */
43
+ export function FormRenderer({
44
+ form,
45
+ dark = false,
46
+ className,
47
+ preview = false,
48
+ }: {
49
+ form: PublicForm;
50
+ dark?: boolean;
51
+ className?: string;
52
+ preview?: boolean;
53
+ }) {
54
+ const { t } = useT();
55
+ const locale = useLocale();
56
+ const { submitForm: send, savePartialSubmission: saveDraft } = useHostPublicFormFns();
57
+
58
+ const mountedAt = useRef(Date.now());
59
+ const [values, setValues] = useState<Record<string, string>>({});
60
+ const [consent, setConsent] = useState(false);
61
+ const [hp, setHp] = useState("");
62
+ const [busy, setBusy] = useState(false);
63
+ const [error, setError] = useState<string | null>(null);
64
+ const [done, setDone] = useState(false);
65
+ const [errors, setErrors] = useState<Record<string, string>>({});
66
+ const [touched, setTouched] = useState<Record<string, boolean>>({});
67
+
68
+ useEffect(() => {
69
+ mountedAt.current = Date.now();
70
+ }, [form.key]);
71
+
72
+ const tripItems = useHostTripSource() as TripSource[] | undefined;
73
+
74
+ // "trip"/"course" fields are dropdowns whose choices come from the central
75
+ // trips source (single source of truth), resolved at render time.
76
+ const resolved = useMemo(() => {
77
+ const trips = (tripItems ?? []) as TripSource[];
78
+ return (form.fields ?? []).map((f) =>
79
+ f.type === "trip" || f.type === "course"
80
+ ? {
81
+ ...f,
82
+ type: "select" as const,
83
+ options: tripFieldOptions(trips, f.tripYear ?? f.courseYear),
84
+ }
85
+ : f,
86
+ );
87
+ }, [form, tripItems]);
88
+
89
+ const enabled = useMemo(() => resolved.filter((f) => f.enabled), [resolved]);
90
+ /** Fields whose show-conditions currently match. */
91
+ const fields = useMemo(() => enabled.filter((f) => isFieldVisible(f, values)), [enabled, values]);
92
+
93
+ // Save what the visitor typed even if they never submit (debounced).
94
+ const dirty = useRef(false);
95
+ useEffect(() => {
96
+ if (preview || !form.savePartials || done || !dirty.current) return;
97
+ const token = draftToken(form.key);
98
+ if (!token) return;
99
+ const snapshot = Object.fromEntries(fields.map((f) => [f.key, values[f.key] ?? ""]));
100
+ if (!Object.values(snapshot).some((v) => v.trim())) return;
101
+ const id = window.setTimeout(() => {
102
+ void Promise.resolve(
103
+ saveDraft({
104
+ key: form.key,
105
+ clientToken: token,
106
+ locale,
107
+ pagePath: window.location.pathname,
108
+ values: snapshot,
109
+ }),
110
+ ).catch(() => {});
111
+ }, 1500);
112
+ return () => window.clearTimeout(id);
113
+ }, [values, fields, form.key, form.savePartials, locale, done, preview, saveDraft]);
114
+
115
+ const setValue = (f: FormFieldDef, value: string) => {
116
+ dirty.current = true;
117
+ setValues((v) => {
118
+ const next = { ...v, [f.key]: value };
119
+ // Answers to fields that just became hidden must not be submitted.
120
+ for (const other of enabled) {
121
+ if (other.key !== f.key && !isFieldVisible(other, next)) delete next[other.key];
122
+ }
123
+ return next;
124
+ });
125
+ if (touched[f.key] || errors[f.key]) {
126
+ setErrors((e) => {
127
+ const next = { ...e };
128
+ const msg = validateField(f, { ...values, [f.key]: value }, locale);
129
+ if (msg) next[f.key] = msg;
130
+ else delete next[f.key];
131
+ return next;
132
+ });
133
+ }
134
+ };
135
+
136
+ const blurField = (f: FormFieldDef) => {
137
+ setTouched((t) => ({ ...t, [f.key]: true }));
138
+ const msg = validateField(f, values, locale);
139
+ setErrors((e) => {
140
+ const next = { ...e };
141
+ if (msg) next[f.key] = msg;
142
+ else delete next[f.key];
143
+ return next;
144
+ });
145
+ };
146
+
147
+ if (done) {
148
+ return (
149
+ <div
150
+ className={[
151
+ className ?? "",
152
+ dark
153
+ ? "bg-paper/5 backdrop-blur border border-paper/15"
154
+ : "border border-ink/10 bg-white",
155
+ "p-8 text-center",
156
+ ].join(" ")}
157
+ >
158
+ <p className="font-serif text-2xl whitespace-pre-line">
159
+ {t(form.thankyouMessage as LocalizedString)}
160
+ </p>
161
+ {preview ? (
162
+ <button
163
+ type="button"
164
+ onClick={() => {
165
+ setDone(false);
166
+ setValues({});
167
+ setConsent(false);
168
+ setErrors({});
169
+ setTouched({});
170
+ }}
171
+ className="mt-6 border border-current/20 px-4 py-2 text-[0.65rem] uppercase tracking-[0.2em]"
172
+ >
173
+ Reset preview
174
+ </button>
175
+ ) : null}
176
+ </div>
177
+ );
178
+ }
179
+
180
+ const inputCls = dark
181
+ ? "w-full bg-paper/10 border border-paper/25 px-4 pt-5 pb-2 text-paper placeholder-transparent focus:border-sea focus:bg-paper/15 focus:outline-none transition-colors"
182
+ : "w-full bg-white border border-ink/20 px-4 pt-5 pb-2 text-ink placeholder-transparent focus:border-sea focus:outline-none transition-colors";
183
+ const labelCls = dark
184
+ ? "pointer-events-none absolute left-4 top-1.5 text-[0.65rem] uppercase tracking-[0.2em] text-paper/75"
185
+ : "pointer-events-none absolute left-4 top-1.5 text-[0.65rem] uppercase tracking-[0.2em] text-ink/55";
186
+
187
+ const onSubmit = async (e: React.FormEvent) => {
188
+ e.preventDefault();
189
+ setError(null);
190
+
191
+ // Validate everything that is currently visible before touching the server.
192
+ const found = validateAll(enabled, values, locale);
193
+ if (form.requireConsent && !consent) {
194
+ setError(
195
+ locale === "sv" ? "Du behöver godkänna villkoren." : "Please accept the terms to continue.",
196
+ );
197
+ if (!Object.keys(found).length) return;
198
+ }
199
+ if (Object.keys(found).length) {
200
+ setErrors(found);
201
+ setTouched(Object.fromEntries(Object.keys(found).map((k) => [k, true])));
202
+ const first = document.getElementById(`${form.key}-${Object.keys(found)[0]}`);
203
+ first?.focus();
204
+ first?.scrollIntoView({ behavior: "smooth", block: "center" });
205
+ return;
206
+ }
207
+
208
+ // Preview mode stops here: rules and validation ran, nothing is stored.
209
+ if (preview) {
210
+ setDone(true);
211
+ return;
212
+ }
213
+
214
+ setBusy(true);
215
+ setError(null);
216
+ try {
217
+ // Send only the answers to fields the visitor actually saw.
218
+ const visible = Object.fromEntries(fields.map((f) => [f.key, values[f.key] ?? ""]));
219
+ await send({
220
+ key: form.key,
221
+ locale,
222
+ pagePath: typeof window !== "undefined" ? window.location.pathname : undefined,
223
+ consent,
224
+ elapsedMs: Date.now() - mountedAt.current,
225
+ hp,
226
+ clientToken: form.savePartials ? draftToken(form.key) : undefined,
227
+ values: visible,
228
+ });
229
+ if (form.thankyouMode === "redirect" && form.thankyouRedirect) {
230
+ window.location.href = form.thankyouRedirect;
231
+ return;
232
+ }
233
+ setDone(true);
234
+ } catch (err) {
235
+ setError(err instanceof Error ? err.message : "Something went wrong");
236
+ } finally {
237
+ setBusy(false);
238
+ }
239
+ };
240
+
241
+ const renderField = (f: FormFieldDef) => {
242
+ const label = t(f.label as LocalizedString);
243
+ const help = f.help ? t(f.help as LocalizedString) : "";
244
+ const placeholder = f.placeholder ? t(f.placeholder as LocalizedString) : label;
245
+ const id = `${form.key}-${f.key}`;
246
+ const required = isFieldRequired(f, values);
247
+ const err = errors[f.key];
248
+ const errId = `${id}-error`;
249
+ const invalidCls = err ? " border-red-500" : "";
250
+
251
+ const Message = err ? (
252
+ <p id={errId} role="alert" className="mt-1 text-xs text-red-500 whitespace-pre-line">
253
+ {err}
254
+ </p>
255
+ ) : help ? (
256
+ <p className="mt-1 text-xs opacity-60 whitespace-pre-line">{help}</p>
257
+ ) : null;
258
+
259
+ if (f.type === "checkbox") {
260
+ return (
261
+ <div key={f.key} className="sm:col-span-2">
262
+ <label className="flex items-start gap-3 text-sm">
263
+ <input
264
+ id={id}
265
+ name={f.key}
266
+ type="checkbox"
267
+ autoComplete="off"
268
+ checked={normalizeCheckbox(values[f.key] ?? "") === "true"}
269
+ aria-invalid={err ? true : undefined}
270
+ aria-describedby={err ? errId : undefined}
271
+ onChange={(e) => setValue(f, e.target.checked ? "true" : "false")}
272
+ onBlur={() => blurField(f)}
273
+ className="mt-0.5 size-4 shrink-0 accent-sea"
274
+ />
275
+ <span>
276
+ {label}
277
+ {required ? " *" : ""}
278
+ </span>
279
+ </label>
280
+ {Message}
281
+ </div>
282
+ );
283
+ }
284
+
285
+ const auto = resolveAutocomplete(f);
286
+ const inputMode =
287
+ f.type === "tel"
288
+ ? ("tel" as const)
289
+ : f.type === "email"
290
+ ? ("email" as const)
291
+ : f.type === "number"
292
+ ? ("numeric" as const)
293
+ : undefined;
294
+
295
+ const common = {
296
+ id,
297
+ name: f.key,
298
+ autoComplete: auto,
299
+ placeholder,
300
+ maxLength: f.maxLength,
301
+ value: values[f.key] ?? "",
302
+ "aria-invalid": err ? true : undefined,
303
+ "aria-describedby": err ? errId : undefined,
304
+ onChange: (
305
+ e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
306
+ ) => setValue(f, e.target.value),
307
+ onBlur: () => blurField(f),
308
+ className: inputCls + invalidCls,
309
+ };
310
+ return (
311
+ <div key={f.key} className={["relative", f.wide ? "sm:col-span-2" : ""].join(" ")}>
312
+ {f.type === "textarea" ? (
313
+ <textarea {...common} rows={4} />
314
+ ) : f.type === "select" ? (
315
+ <select {...common}>
316
+ <option value="">—</option>
317
+ {(f.options ?? []).map((o) => (
318
+ <option key={o.value} value={o.value}>
319
+ {t(o.label as LocalizedString)}
320
+ </option>
321
+ ))}
322
+ </select>
323
+ ) : (
324
+ <input
325
+ {...common}
326
+ inputMode={inputMode}
327
+ type={f.type}
328
+ min={f.type === "date" ? f.minDate : f.min}
329
+ max={f.type === "date" ? f.maxDate : f.max}
330
+ />
331
+ )}
332
+ <label htmlFor={id} className={labelCls}>
333
+ {label}
334
+ {required ? " *" : ""}
335
+ </label>
336
+ {Message}
337
+ </div>
338
+ );
339
+ };
340
+
341
+ return (
342
+ <form
343
+ id={`form-${form.key}`}
344
+ onSubmit={onSubmit}
345
+ noValidate
346
+ className={[
347
+ className ?? "",
348
+ dark ? "bg-paper/5 backdrop-blur border border-paper/15" : "border border-ink/10 bg-white",
349
+ "p-6 md:p-8 space-y-5",
350
+ ].join(" ")}
351
+ >
352
+ <div className="grid gap-4 sm:grid-cols-2">{fields.map(renderField)}</div>
353
+
354
+ {form.honeypot ? (
355
+ <div className="hidden" aria-hidden="true">
356
+ <label>
357
+ Company
358
+ <input
359
+ tabIndex={-1}
360
+ autoComplete="off"
361
+ value={hp}
362
+ onChange={(e) => setHp(e.target.value)}
363
+ />
364
+ </label>
365
+ </div>
366
+ ) : null}
367
+
368
+ {form.requireConsent ? (
369
+ <label
370
+ className={[
371
+ "flex items-start gap-3 text-xs",
372
+ dark ? "text-paper/85" : "text-ink/70",
373
+ ].join(" ")}
374
+ >
375
+ <input
376
+ type="checkbox"
377
+ required
378
+ checked={consent}
379
+ onChange={(e) => setConsent(e.target.checked)}
380
+ className={[
381
+ "mt-0.5 size-4 shrink-0 accent-sea bg-transparent border",
382
+ dark ? "border-paper/40" : "border-ink/30",
383
+ ].join(" ")}
384
+ />
385
+ <span>
386
+ {renderLinkedText(t(form.consentText as LocalizedString), {
387
+ privacyUrl: form.privacyPolicyUrl,
388
+ })}
389
+ </span>
390
+ </label>
391
+ ) : null}
392
+
393
+ {error ? <p className="text-xs text-red-500 whitespace-pre-line">{error}</p> : null}
394
+
395
+ <button
396
+ type="submit"
397
+ disabled={busy}
398
+ className={[
399
+ "group inline-flex w-full items-center justify-center gap-2 px-6 py-4 text-xs uppercase tracking-[0.22em] transition-all disabled:opacity-60",
400
+ dark ? "bg-paper text-ink hover:bg-sea" : "bg-ink text-paper hover:bg-sea hover:text-ink",
401
+ ].join(" ")}
402
+ >
403
+ {t(form.submitLabel as LocalizedString)}
404
+ <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
405
+ </button>
406
+ </form>
407
+ );
408
+ }
409
+
410
+ /** Fetches a published form by key and renders it. */
411
+ export function ManagedForm({
412
+ formKey,
413
+ dark = false,
414
+ className,
415
+ }: {
416
+ formKey: string;
417
+ dark?: boolean;
418
+ className?: string;
419
+ }) {
420
+ const { getPublicForm } = useHostPublicFormFns();
421
+ const { data: form, isLoading } = useQuery({
422
+ queryKey: ["public-form", formKey],
423
+ queryFn: () => getPublicForm(formKey) as Promise<PublicForm | null>,
424
+ staleTime: 60_000,
425
+ });
426
+
427
+ if (isLoading) {
428
+ return (
429
+ <div className={className}>
430
+ <div className="h-64 animate-pulse border border-current/15 bg-current/5" />
431
+ </div>
432
+ );
433
+ }
434
+ if (!form) return null;
435
+ return <FormRenderer form={form} dark={dark} className={className} />;
436
+ }
437
+
438
+ export type { PublicForm };
@@ -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.
@@ -148,3 +149,97 @@ export function useHostMediaUpload(existing: HostMediaItem[] = []): HostMediaUpl
148
149
  if (!hook) missing("useMediaUpload");
149
150
  return hook(existing);
150
151
  }
152
+
153
+ /* --------------------------- public form runtime --------------------------- */
154
+
155
+ /** Answers keyed by field key, exactly as the visitor typed them. */
156
+ export type HostFormValues = Record<string, string>;
157
+
158
+ export type HostSubmitFormInput = {
159
+ key: string;
160
+ locale: string;
161
+ pagePath?: string;
162
+ consent?: boolean;
163
+ elapsedMs?: number;
164
+ hp?: string;
165
+ clientToken?: string;
166
+ values: HostFormValues;
167
+ };
168
+
169
+ export type HostSavePartialInput = {
170
+ key: string;
171
+ clientToken: string;
172
+ locale: string;
173
+ pagePath?: string;
174
+ values: HostFormValues;
175
+ };
176
+
177
+ /**
178
+ * Server functions behind the public form runtime. Each site wraps its own
179
+ * `createServerFn` declarations (they must live in the site bundle) and hands
180
+ * plain callables to the package.
181
+ */
182
+ export type HostPublicFormFns = {
183
+ getPublicForm: (key: string) => Promise<unknown>;
184
+ submitForm: (input: HostSubmitFormInput) => Promise<unknown>;
185
+ savePartialSubmission: (input: HostSavePartialInput) => Promise<unknown>;
186
+ };
187
+
188
+ export type HostSiteAdapters = {
189
+ /** Hook returning the site's public form server functions. */
190
+ usePublicFormFns: () => HostPublicFormFns;
191
+ /**
192
+ * Hook returning the site's central trip/course source, used to fill
193
+ * `trip`/`course` dropdowns from a single source of truth.
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>;
206
+ };
207
+
208
+ const DEFAULT_SITE_ACTIONS: HostSiteActions = { book: { sv: "Boka", en: "Book" } };
209
+
210
+ let siteAdapters: Partial<HostSiteAdapters> = {};
211
+
212
+ export function registerSiteAdapters(adapters: Partial<HostSiteAdapters>): void {
213
+ siteAdapters = { ...siteAdapters, ...adapters };
214
+ }
215
+
216
+ /** Register public-site adapters while constructing a value the site exports. */
217
+ export function initializeSiteAdapters<T>(
218
+ adapters: Partial<HostSiteAdapters>,
219
+ initialize: () => T,
220
+ ): T {
221
+ registerSiteAdapters(adapters);
222
+ return initialize();
223
+ }
224
+
225
+ export function useHostPublicFormFns(): HostPublicFormFns {
226
+ const hook = siteAdapters.usePublicFormFns;
227
+ if (!hook) {
228
+ throw new Error(
229
+ '@bygghammar/cms-platform: site adapter "usePublicFormFns" is not registered. Call registerSiteAdapters() during site startup.',
230
+ );
231
+ }
232
+ return hook();
233
+ }
234
+
235
+ export function useHostTripSource(): unknown[] {
236
+ const hook = siteAdapters.useTripSource;
237
+ return hook ? hook() : [];
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
+ }