@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 +12 -0
- package/package.json +2 -1
- package/src/blocks/components/FormBlock.tsx +51 -0
- package/src/blocks/registry.tsx +15 -0
- package/src/blocks/types.ts +10 -0
- package/src/forms/FormRenderer.tsx +438 -0
- package/src/host/index.tsx +75 -0
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.
|
|
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";
|
package/src/blocks/registry.tsx
CHANGED
|
@@ -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
|
/**
|
package/src/blocks/types.ts
CHANGED
|
@@ -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 };
|
package/src/host/index.tsx
CHANGED
|
@@ -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
|
+
}
|