@bygghammar/cms-platform 0.53.0 → 0.54.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.54.0
4
+
5
+ - `forms/FormRenderer`: the whole public managed-form runtime (validation,
6
+ conditional fields, honeypot, partial drafts, thank-you/redirect) now lives in
7
+ the package, together with `ManagedForm` which resolves a form by key.
8
+ - `blocks/components/FormBlock`: the form section block is package-owned and is
9
+ registered in `SHARED_BLOCK_DEFS` as `form`, so a fresh site gets it for free.
10
+ - `host`: new site-adapter contract (`registerSiteAdapters`,
11
+ `usePublicFormFns`, `useTripSource`) so a site hands its own server functions
12
+ and central trip source to package-owned public components.
13
+ - `blocks/types`: added `FormProps`.
14
+
3
15
  ## 0.53.0
4
16
 
5
17
  - `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.54.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",
@@ -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";
@@ -12,6 +12,7 @@ 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";
15
16
  import type {
16
17
  BlockProps,
17
18
  FaqProps,
@@ -27,6 +28,7 @@ import type {
27
28
  LinkTilesProps,
28
29
  CabinTabsProps,
29
30
  PriceTableProps,
31
+ FormProps,
30
32
  } from "./types";
31
33
  import { BlockPropsProvider } from "./block-props-context";
32
34
 
@@ -309,6 +311,19 @@ export const SHARED_BLOCK_DEFS: Record<string, BlockDef> = {
309
311
  } satisfies PriceTableProps as unknown as BlockProps,
310
312
  hasInlineContent: true,
311
313
  },
314
+ form: {
315
+ type: "form",
316
+ label: "Form",
317
+ description:
318
+ "Renders a managed form from Backoffice \u2192 Forms. All forms share the same settings, notifications and submissions inbox.",
319
+ category: "content",
320
+ component: ({ props }) => <FormBlock props={props as FormProps} />,
321
+ defaultProps: {
322
+ formKey: "",
323
+ tone: "paper",
324
+ } satisfies FormProps as unknown as BlockProps,
325
+ hasInlineContent: true,
326
+ },
312
327
  };
313
328
 
314
329
  /**
@@ -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 };
@@ -148,3 +148,78 @@ export function useHostMediaUpload(existing: HostMediaItem[] = []): HostMediaUpl
148
148
  if (!hook) missing("useMediaUpload");
149
149
  return hook(existing);
150
150
  }
151
+
152
+ /* --------------------------- public form runtime --------------------------- */
153
+
154
+ /** Answers keyed by field key, exactly as the visitor typed them. */
155
+ export type HostFormValues = Record<string, string>;
156
+
157
+ export type HostSubmitFormInput = {
158
+ key: string;
159
+ locale: string;
160
+ pagePath?: string;
161
+ consent?: boolean;
162
+ elapsedMs?: number;
163
+ hp?: string;
164
+ clientToken?: string;
165
+ values: HostFormValues;
166
+ };
167
+
168
+ export type HostSavePartialInput = {
169
+ key: string;
170
+ clientToken: string;
171
+ locale: string;
172
+ pagePath?: string;
173
+ values: HostFormValues;
174
+ };
175
+
176
+ /**
177
+ * Server functions behind the public form runtime. Each site wraps its own
178
+ * `createServerFn` declarations (they must live in the site bundle) and hands
179
+ * plain callables to the package.
180
+ */
181
+ export type HostPublicFormFns = {
182
+ getPublicForm: (key: string) => Promise<unknown>;
183
+ submitForm: (input: HostSubmitFormInput) => Promise<unknown>;
184
+ savePartialSubmission: (input: HostSavePartialInput) => Promise<unknown>;
185
+ };
186
+
187
+ export type HostSiteAdapters = {
188
+ /** Hook returning the site's public form server functions. */
189
+ usePublicFormFns: () => HostPublicFormFns;
190
+ /**
191
+ * Hook returning the site's central trip/course source, used to fill
192
+ * `trip`/`course` dropdowns from a single source of truth.
193
+ */
194
+ useTripSource: () => unknown[];
195
+ };
196
+
197
+ let siteAdapters: Partial<HostSiteAdapters> = {};
198
+
199
+ export function registerSiteAdapters(adapters: Partial<HostSiteAdapters>): void {
200
+ siteAdapters = { ...siteAdapters, ...adapters };
201
+ }
202
+
203
+ /** Register public-site adapters while constructing a value the site exports. */
204
+ export function initializeSiteAdapters<T>(
205
+ adapters: Partial<HostSiteAdapters>,
206
+ initialize: () => T,
207
+ ): T {
208
+ registerSiteAdapters(adapters);
209
+ return initialize();
210
+ }
211
+
212
+ export function useHostPublicFormFns(): HostPublicFormFns {
213
+ const hook = siteAdapters.usePublicFormFns;
214
+ if (!hook) {
215
+ throw new Error(
216
+ '@bygghammar/cms-platform: site adapter "usePublicFormFns" is not registered. Call registerSiteAdapters() during site startup.',
217
+ );
218
+ }
219
+ return hook();
220
+ }
221
+
222
+ export function useHostTripSource(): unknown[] {
223
+ const hook = siteAdapters.useTripSource;
224
+ return hook ? hook() : [];
225
+ }