@websline/cms-view-utils 1.10.2 → 1.15.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/dist/previewBridge.global.js +1 -1
- package/package.json +6 -5
- package/src/ads/createAdRunner.js +15 -1
- package/src/blocks/registry.js +34 -0
- package/src/client/fetchFormChallenge.js +21 -0
- package/src/client/submitForm.js +32 -0
- package/src/cms/fetchPage.js +7 -1
- package/src/cms/proxyToCms.js +35 -2
- package/src/cms/urlResolver.js +16 -3
- package/src/components/BlockEditList.svelte +50 -0
- package/src/components/BlockEditSlot.svelte +47 -0
- package/src/components/BlockSlot.astro +17 -0
- package/src/components/Picture.svelte +4 -2
- package/src/editor/editorContent.svelte.js +168 -0
- package/src/editor/editorProps.js +14 -0
- package/src/editor/notify.js +5 -0
- package/src/forms/createFormController.js +326 -0
- package/src/forms/fieldStates.js +175 -0
- package/src/forms/formControls.js +58 -0
- package/src/forms/formTexts.js +173 -0
- package/src/forms/formValues.js +101 -0
- package/src/forms/spamChallenge.js +61 -0
- package/src/index.js +12 -0
- package/src/routes/cmsProxy.js +9 -6
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { toAdPath } from "../ads/adPath.js";
|
|
2
|
+
import { submitForm } from "../client/submitForm.js";
|
|
3
|
+
import { createFieldStates } from "./fieldStates.js";
|
|
4
|
+
import { createFormTexts } from "./formTexts.js";
|
|
5
|
+
import { createSpamChallenge } from "./spamChallenge.js";
|
|
6
|
+
import {
|
|
7
|
+
fieldsOf,
|
|
8
|
+
initialValues,
|
|
9
|
+
submissionValues,
|
|
10
|
+
validateValues,
|
|
11
|
+
} from "./formValues.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runs one form on the website: values, the check per page, the honeypot,
|
|
15
|
+
* sending, and what follows. Rendering stays with the template, which reads the
|
|
16
|
+
* state through the store contract (`$form` in Svelte) and calls the actions.
|
|
17
|
+
*
|
|
18
|
+
* Everything the guest or the CMS wrote — labels, pre-filled values, the
|
|
19
|
+
* thank-you message — is plain text. Print it escaped, never as HTML.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CONSENT_FIELD = "consent";
|
|
23
|
+
const CHALLENGE_FIELD = "altcha";
|
|
24
|
+
|
|
25
|
+
/** Only these are followed after a submit; anything else could run script here. */
|
|
26
|
+
const SAFE_REDIRECT = /^(https?:\/\/|\/(?!\/))/i;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The honeypot input's attributes. Kept off-screen rather than hidden, which
|
|
30
|
+
* bots recognise; out of the tab order, so people never land in it.
|
|
31
|
+
*/
|
|
32
|
+
const HONEYPOT_ATTRIBUTES = {
|
|
33
|
+
type: "text",
|
|
34
|
+
name: "wl_hp",
|
|
35
|
+
autocomplete: "off",
|
|
36
|
+
tabindex: -1,
|
|
37
|
+
"aria-hidden": "true",
|
|
38
|
+
style: "position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const ERROR_TEXT_KEYS = {
|
|
42
|
+
INVALID: "errorInvalid",
|
|
43
|
+
PAST_DATE: "errorPastDate",
|
|
44
|
+
REQUIRED: "errorRequired",
|
|
45
|
+
TOO_LONG: "errorTooLong",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} FormState
|
|
50
|
+
* @property {number} step - 0-based index of the page shown.
|
|
51
|
+
* @property {Record<string, unknown>} values - Keyed by field name.
|
|
52
|
+
* @property {boolean} consent
|
|
53
|
+
* @property {string} honeypot - Bind to a hidden input people leave empty.
|
|
54
|
+
* @property {Record<string, string>} errors - Error code per field name; `consent` for the consent checkbox.
|
|
55
|
+
* @property {"idle" | "sending" | "sent"} status
|
|
56
|
+
* @property {"tooManyRequests" | "sendFailed" | null} notice - A problem that belongs to no single field; a text key.
|
|
57
|
+
* @property {string | null} message - The thank-you message, once sent; `null` while a redirect is under way.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a template draws from the state, computed on every change.
|
|
62
|
+
*
|
|
63
|
+
* @typedef {object} FormViewParts
|
|
64
|
+
* @property {object} page - The page shown, from `form.pages`.
|
|
65
|
+
* @property {boolean} isFirst
|
|
66
|
+
* @property {boolean} isLast
|
|
67
|
+
* @property {{ number: number, label: string | null, current: boolean, done: boolean }[]} steps - For a progress display; one entry per page.
|
|
68
|
+
* @property {string} submitLabel - The main button's text: next page, sending, or send.
|
|
69
|
+
* @property {Record<string, object>} fields - Per field name: `control` (see `FORM_CONTROLS`), `field`, `id`, `name`, `value`, `set`, `error`, `errorMessage`, `invalid`, `helpId`, `errorId`, `describedBy`, and what the control needs, see `fieldStates.js`.
|
|
70
|
+
* @property {object | null} consentField - The consent checkbox: `id`, `text`, `privacyUrl`, `checked`, `set` and the error parts of a field; `null` without one.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/** What the store hands out. @typedef {FormState & FormViewParts} FormView */
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {object} options
|
|
77
|
+
* @param {object} options.form - The form as the page delivers it, e.g. a form block's `form`.
|
|
78
|
+
* @param {string} [options.adUuid] - The ad showing the form: sent along as the entry's source, and part of every element id.
|
|
79
|
+
* @param {() => void} [options.onSent] - Called once the CMS stored the entry, before the thanks or the redirect; an ad reports its `click` here.
|
|
80
|
+
* @param {Record<string, Record<string, string>>} [options.texts] - Texts the project words differently, see `createFormTexts`.
|
|
81
|
+
* @param {Pick<Location, "pathname" | "assign">} [options.location] - Where the form sits and where a redirect goes; the browser's by default.
|
|
82
|
+
* @param {() => Date} [options.now] - For tests.
|
|
83
|
+
*/
|
|
84
|
+
const createFormController = ({
|
|
85
|
+
form,
|
|
86
|
+
adUuid,
|
|
87
|
+
onSent,
|
|
88
|
+
texts,
|
|
89
|
+
location = globalThis.location,
|
|
90
|
+
now = () => new Date(),
|
|
91
|
+
}) => {
|
|
92
|
+
const text = createFormTexts(form.locale, texts);
|
|
93
|
+
const lastStep = form.pages.length - 1;
|
|
94
|
+
const pageFields = form.pages.map((page) => fieldsOf(page.layouts));
|
|
95
|
+
const fieldNames = new Set([
|
|
96
|
+
CONSENT_FIELD,
|
|
97
|
+
...pageFields.flat().map((field) => field.name),
|
|
98
|
+
]);
|
|
99
|
+
// Solving starts with the guest's first input, so it is done before they send.
|
|
100
|
+
let spam = form.spam?.captcha ? createSpamChallenge(form.uuid) : null;
|
|
101
|
+
|
|
102
|
+
/** @type {FormState} */
|
|
103
|
+
let state = {
|
|
104
|
+
step: 0,
|
|
105
|
+
values: initialValues(form),
|
|
106
|
+
consent: false,
|
|
107
|
+
honeypot: "",
|
|
108
|
+
errors: {},
|
|
109
|
+
status: "idle",
|
|
110
|
+
notice: null,
|
|
111
|
+
message: null,
|
|
112
|
+
};
|
|
113
|
+
const listeners = new Set();
|
|
114
|
+
|
|
115
|
+
const errorText = (code) => text(ERROR_TEXT_KEYS[code] ?? ERROR_TEXT_KEYS.INVALID);
|
|
116
|
+
|
|
117
|
+
const withoutError = (name) => {
|
|
118
|
+
const errors = { ...state.errors };
|
|
119
|
+
delete errors[name];
|
|
120
|
+
return errors;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const setValue = (name, value) => {
|
|
124
|
+
spam?.start();
|
|
125
|
+
update({
|
|
126
|
+
values: { ...state.values, [name]: value },
|
|
127
|
+
errors: withoutError(name),
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const setConsent = (consent) => {
|
|
132
|
+
spam?.start();
|
|
133
|
+
update({ consent, errors: withoutError(CONSENT_FIELD) });
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const fieldStatesOf = createFieldStates({
|
|
137
|
+
form,
|
|
138
|
+
adUuid,
|
|
139
|
+
fields: pageFields.flat(),
|
|
140
|
+
text,
|
|
141
|
+
errorText,
|
|
142
|
+
valueOf: (name) => state.values[name],
|
|
143
|
+
setValue,
|
|
144
|
+
setConsent,
|
|
145
|
+
now,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const submitLabelOf = ({ step, status }) => {
|
|
149
|
+
if (step < lastStep) return text("next");
|
|
150
|
+
return text(status === "sending" ? "sending" : "submit");
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** @returns {FormView} */
|
|
154
|
+
const viewOf = (base) => ({
|
|
155
|
+
...base,
|
|
156
|
+
page: form.pages[base.step],
|
|
157
|
+
isFirst: base.step === 0,
|
|
158
|
+
isLast: base.step === lastStep,
|
|
159
|
+
steps: form.pages.map((page, index) => ({
|
|
160
|
+
number: index + 1,
|
|
161
|
+
label: page.label ?? null,
|
|
162
|
+
current: index === base.step,
|
|
163
|
+
done: index < base.step,
|
|
164
|
+
})),
|
|
165
|
+
submitLabel: submitLabelOf(base),
|
|
166
|
+
...fieldStatesOf(base),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
let view = viewOf(state);
|
|
170
|
+
|
|
171
|
+
const update = (changes) => {
|
|
172
|
+
state = { ...state, ...changes };
|
|
173
|
+
view = viewOf(state);
|
|
174
|
+
for (const listener of listeners) listener(view);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const consentErrors = () =>
|
|
178
|
+
form.consent && !state.consent ? { [CONSENT_FIELD]: "REQUIRED" } : {};
|
|
179
|
+
|
|
180
|
+
const errorsOfStep = (step) => ({
|
|
181
|
+
...validateValues(pageFields[step], state.values),
|
|
182
|
+
...(step === lastStep ? consentErrors() : {}),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// The consent checkbox sits on the last page, so its error points there too.
|
|
186
|
+
const stepOfField = (name) => {
|
|
187
|
+
if (name === CONSENT_FIELD) return lastStep;
|
|
188
|
+
const step = pageFields.findIndex((fields) =>
|
|
189
|
+
fields.some((field) => field.name === name),
|
|
190
|
+
);
|
|
191
|
+
return step < 0 ? state.step : step;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const next = () => {
|
|
195
|
+
const errors = errorsOfStep(state.step);
|
|
196
|
+
update(
|
|
197
|
+
Object.keys(errors).length
|
|
198
|
+
? { errors }
|
|
199
|
+
: { errors: {}, step: Math.min(state.step + 1, lastStep) },
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const isRefusedChallenge = ({ status, data }) =>
|
|
204
|
+
status === 422 && data.errors?.some(({ field }) => field === CHALLENGE_FIELD);
|
|
205
|
+
|
|
206
|
+
const send = (altcha) =>
|
|
207
|
+
submitForm({
|
|
208
|
+
uuid: form.uuid,
|
|
209
|
+
locale: form.locale,
|
|
210
|
+
values: submissionValues(pageFields.flat(), state.values),
|
|
211
|
+
honeypot: state.honeypot || undefined,
|
|
212
|
+
consent: form.consent ? state.consent : undefined,
|
|
213
|
+
altcha,
|
|
214
|
+
source: {
|
|
215
|
+
path: toAdPath(location.pathname, form.locale),
|
|
216
|
+
...(adUuid ? { adUuid } : {}),
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const showAnswer = ({ status, data }) => {
|
|
221
|
+
if (status === 201) {
|
|
222
|
+
// The entry is stored: a failing callback must not tell the guest to send again.
|
|
223
|
+
try {
|
|
224
|
+
onSent?.();
|
|
225
|
+
} catch {
|
|
226
|
+
// The website's own problem; the guest still gets the thanks.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (status === 201 && data.mode === "redirect" && SAFE_REDIRECT.test(data.url)) {
|
|
231
|
+
location.assign(data.url);
|
|
232
|
+
update({ status: "sent" });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// No message, e.g. a thank-you page missing in this language: never an empty box.
|
|
237
|
+
if (status === 201) {
|
|
238
|
+
update({ status: "sent", message: data.message || text("thanks") });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// An error without a field here, like the challenge, has nowhere to show.
|
|
243
|
+
const fieldErrors = (data.errors ?? []).filter(({ field }) =>
|
|
244
|
+
fieldNames.has(field),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (status === 422 && fieldErrors.length) {
|
|
248
|
+
const errors = Object.fromEntries(
|
|
249
|
+
fieldErrors.map(({ field, code }) => [field, code]),
|
|
250
|
+
);
|
|
251
|
+
const step = Math.min(...fieldErrors.map(({ field }) => stepOfField(field)));
|
|
252
|
+
update({ status: "idle", errors, step });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
update({
|
|
257
|
+
status: "idle",
|
|
258
|
+
notice: status === 429 ? "tooManyRequests" : "sendFailed",
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
/** Svelte store contract: called now and on every change. */
|
|
264
|
+
subscribe(listener) {
|
|
265
|
+
listeners.add(listener);
|
|
266
|
+
listener(view);
|
|
267
|
+
return () => listeners.delete(listener);
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
/** @param {string} key @param {Record<string, string | number>} [params] */
|
|
271
|
+
text,
|
|
272
|
+
|
|
273
|
+
/** The message for an error code, e.g. from `errors[name]`. */
|
|
274
|
+
errorText,
|
|
275
|
+
|
|
276
|
+
/** Spread onto the honeypot input; send its content through `setHoneypot`. */
|
|
277
|
+
honeypotAttributes: HONEYPOT_ATTRIBUTES,
|
|
278
|
+
|
|
279
|
+
setValue,
|
|
280
|
+
|
|
281
|
+
setConsent,
|
|
282
|
+
|
|
283
|
+
setHoneypot(honeypot) {
|
|
284
|
+
update({ honeypot });
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
/** On to the next page, once this one is filled in correctly. */
|
|
288
|
+
next,
|
|
289
|
+
|
|
290
|
+
back() {
|
|
291
|
+
update({ errors: {}, step: Math.max(state.step - 1, 0) });
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Checks the last page and sends the values of all pages at once. Before the
|
|
296
|
+
* last page — Enter in a field — it turns the page instead.
|
|
297
|
+
*/
|
|
298
|
+
async submit() {
|
|
299
|
+
if (state.status !== "idle") return;
|
|
300
|
+
if (state.step < lastStep) return next();
|
|
301
|
+
|
|
302
|
+
const errors = errorsOfStep(state.step);
|
|
303
|
+
if (Object.keys(errors).length) {
|
|
304
|
+
update({ errors });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
update({ errors: {}, notice: null, status: "sending" });
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
let answer = await send(await spam?.payload());
|
|
312
|
+
// Expired or spent meanwhile, or the captcha was switched on after this
|
|
313
|
+
// page was cached: one attempt with a fresh challenge.
|
|
314
|
+
if (isRefusedChallenge(answer)) {
|
|
315
|
+
spam ??= createSpamChallenge(form.uuid);
|
|
316
|
+
answer = await send(await spam.renew());
|
|
317
|
+
}
|
|
318
|
+
showAnswer(answer);
|
|
319
|
+
} catch {
|
|
320
|
+
update({ status: "idle", notice: "sendFailed" });
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
export { createFormController };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { controlOf } from "./formControls.js";
|
|
2
|
+
import { withChildCount } from "./formValues.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Everything a website needs to draw one field, besides its look: the ids that
|
|
6
|
+
* tie label, help and error together, the current value and error, the options
|
|
7
|
+
* to offer and the setters. Built once per field; only what changes with the
|
|
8
|
+
* values is recomputed on every update.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The CMS's oldest child age. */
|
|
12
|
+
const MAX_CHILD_AGE = 17;
|
|
13
|
+
/** For a CMS that does not send `maxAdults` yet; its own default. */
|
|
14
|
+
const DEFAULT_MAX_ADULTS = 10;
|
|
15
|
+
|
|
16
|
+
const numberOptions = (from, to, label = String) =>
|
|
17
|
+
Array.from({ length: to - from + 1 }, (_, index) => ({
|
|
18
|
+
value: from + index,
|
|
19
|
+
label: label(from + index),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const pad = (number) => String(number).padStart(2, "0");
|
|
23
|
+
|
|
24
|
+
/** The guest's today, as a date input reads it. */
|
|
25
|
+
const localDay = (date) =>
|
|
26
|
+
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
27
|
+
|
|
28
|
+
/** What one control adds on top of every field's state; `current` reads the field's value now. */
|
|
29
|
+
const CONTROL_PARTS = {
|
|
30
|
+
dropdown: ({ field, text }) => ({
|
|
31
|
+
options: field.options ?? [],
|
|
32
|
+
placeholder: field.placeholder || text("choose"),
|
|
33
|
+
}),
|
|
34
|
+
radio: ({ field }) => ({ options: field.options }),
|
|
35
|
+
buttons: ({ field }) => ({ options: field.options }),
|
|
36
|
+
checkboxes: ({ field, set, current }) => ({
|
|
37
|
+
options: field.options,
|
|
38
|
+
toggle: (option, checked) =>
|
|
39
|
+
set(
|
|
40
|
+
checked
|
|
41
|
+
? [...current(), option]
|
|
42
|
+
: current().filter((entry) => entry !== option),
|
|
43
|
+
),
|
|
44
|
+
}),
|
|
45
|
+
daterange: ({ set, current }) => ({
|
|
46
|
+
setFrom: (from) => set({ ...current(), from }),
|
|
47
|
+
setTo: (to) => set({ ...current(), to }),
|
|
48
|
+
}),
|
|
49
|
+
persons: ({ field, text, set, current }) => ({
|
|
50
|
+
adultOptions: numberOptions(1, field.maxAdults ?? DEFAULT_MAX_ADULTS),
|
|
51
|
+
childOptions: field.children ? numberOptions(0, field.maxChildren) : null,
|
|
52
|
+
ageOptions: numberOptions(0, MAX_CHILD_AGE, (count) => text("years", { count })),
|
|
53
|
+
setAdults: (adults) => set({ ...current(), adults: Number(adults) }),
|
|
54
|
+
setChildren: (count) => set(withChildCount(current(), Number(count))),
|
|
55
|
+
setChildAge: (index, age) =>
|
|
56
|
+
set({
|
|
57
|
+
...current(),
|
|
58
|
+
children: current().children.map((entry, i) =>
|
|
59
|
+
i === index ? (age === "" || age == null ? null : Number(age)) : entry,
|
|
60
|
+
),
|
|
61
|
+
}),
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** What changes with value and error, per control. */
|
|
66
|
+
const LIVE_PARTS = {
|
|
67
|
+
date: ({ field, today }) => ({ min: field.allowPast ? undefined : today }),
|
|
68
|
+
daterange: ({ field, today, value }) => {
|
|
69
|
+
const min = field.allowPast ? undefined : today;
|
|
70
|
+
return { min, minTo: value.from || min };
|
|
71
|
+
},
|
|
72
|
+
// Only an age still missing is marked, not the whole field.
|
|
73
|
+
persons: ({ invalid, value }) => ({
|
|
74
|
+
ageInvalid: value.children.map((age) => invalid && age == null),
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const messagesOf = ({ id, help, error, errorText }) => {
|
|
79
|
+
const helpId = `${id}-help`;
|
|
80
|
+
const errorId = `${id}-error`;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
error,
|
|
84
|
+
errorMessage: error ? errorText(error) : null,
|
|
85
|
+
invalid: Boolean(error),
|
|
86
|
+
helpId,
|
|
87
|
+
errorId,
|
|
88
|
+
describedBy:
|
|
89
|
+
[help && helpId, error && errorId].filter(Boolean).join(" ") || undefined,
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {object} options
|
|
95
|
+
* @param {object} options.form
|
|
96
|
+
* @param {string} [options.adUuid] - The ad showing the form; part of every id, since the same form may also sit on the page.
|
|
97
|
+
* @param {object[]} options.fields - All fields of the form.
|
|
98
|
+
* @param {(key: string, params?: object) => string} options.text
|
|
99
|
+
* @param {(code: string) => string} options.errorText
|
|
100
|
+
* @param {(name: string) => unknown} options.valueOf
|
|
101
|
+
* @param {(name: string, value: unknown) => void} options.setValue
|
|
102
|
+
* @param {(consent: boolean) => void} options.setConsent
|
|
103
|
+
* @param {() => Date} options.now
|
|
104
|
+
*/
|
|
105
|
+
const createFieldStates = ({
|
|
106
|
+
form,
|
|
107
|
+
adUuid,
|
|
108
|
+
fields,
|
|
109
|
+
text,
|
|
110
|
+
errorText,
|
|
111
|
+
valueOf,
|
|
112
|
+
setValue,
|
|
113
|
+
setConsent,
|
|
114
|
+
now,
|
|
115
|
+
}) => {
|
|
116
|
+
const scope = adUuid ? `${form.uuid}-ad-${adUuid}` : form.uuid;
|
|
117
|
+
const idOf = (key) => `form-${scope}-${key}`;
|
|
118
|
+
|
|
119
|
+
const fixed = fields.map((field) => {
|
|
120
|
+
const control = controlOf(field);
|
|
121
|
+
const set = (value) => setValue(field.name, value);
|
|
122
|
+
const current = () => valueOf(field.name);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
field,
|
|
126
|
+
control,
|
|
127
|
+
id: idOf(field.id),
|
|
128
|
+
name: field.name,
|
|
129
|
+
set,
|
|
130
|
+
...CONTROL_PARTS[control]?.({ field, text, set, current }),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const consentId = idOf("consent");
|
|
135
|
+
|
|
136
|
+
/** Keyed by field name, plus the consent checkbox or `null` without one. */
|
|
137
|
+
return (state) => {
|
|
138
|
+
const today = localDay(now());
|
|
139
|
+
|
|
140
|
+
const fieldStates = Object.fromEntries(
|
|
141
|
+
fixed.map((entry) => {
|
|
142
|
+
const value = state.values[entry.name];
|
|
143
|
+
const messages = messagesOf({
|
|
144
|
+
id: entry.id,
|
|
145
|
+
help: entry.field.help,
|
|
146
|
+
error: state.errors[entry.name],
|
|
147
|
+
errorText,
|
|
148
|
+
});
|
|
149
|
+
const live = LIVE_PARTS[entry.control]?.({
|
|
150
|
+
field: entry.field,
|
|
151
|
+
today,
|
|
152
|
+
value,
|
|
153
|
+
invalid: messages.invalid,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return [entry.name, { ...entry, ...messages, ...live, value }];
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const consentField = form.consent
|
|
161
|
+
? {
|
|
162
|
+
id: consentId,
|
|
163
|
+
text: form.consent.text,
|
|
164
|
+
privacyUrl: form.privacyUrl ?? null,
|
|
165
|
+
checked: state.consent,
|
|
166
|
+
set: setConsent,
|
|
167
|
+
...messagesOf({ id: consentId, error: state.errors.consent, errorText }),
|
|
168
|
+
}
|
|
169
|
+
: null;
|
|
170
|
+
|
|
171
|
+
return { fields: fieldStates, consentField };
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export { createFieldStates };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The controls a website draws for a form's fields. The field types are the
|
|
3
|
+
* CMS's; which control each one gets is decided here, so a website only draws
|
|
4
|
+
* one component per control. A new field type in the CMS lands here, and every
|
|
5
|
+
* website then needs a component for any control it adds.
|
|
6
|
+
*
|
|
7
|
+
* - `text`, `email`, `tel` — a single-line input; the name is its `type`.
|
|
8
|
+
* - `textarea` — multi-line text.
|
|
9
|
+
* - `dropdown` — one choice from `options`, with a `placeholder` first.
|
|
10
|
+
* - `radio` — one choice from `options`, as a list.
|
|
11
|
+
* - `buttons` — one choice from `options`, as a row of buttons.
|
|
12
|
+
* - `checkbox` — a single checkbox; its label is the sentence the guest agrees to.
|
|
13
|
+
* - `checkboxes` — any number of `options`, ticked through `toggle`.
|
|
14
|
+
* - `date` — a day, no earlier than `min`.
|
|
15
|
+
* - `daterange` — arrival and departure through `setFrom` and `setTo`.
|
|
16
|
+
* - `persons` — adults, and children with an age each.
|
|
17
|
+
*/
|
|
18
|
+
const FORM_CONTROLS = [
|
|
19
|
+
"text",
|
|
20
|
+
"email",
|
|
21
|
+
"tel",
|
|
22
|
+
"textarea",
|
|
23
|
+
"dropdown",
|
|
24
|
+
"radio",
|
|
25
|
+
"buttons",
|
|
26
|
+
"checkbox",
|
|
27
|
+
"checkboxes",
|
|
28
|
+
"date",
|
|
29
|
+
"daterange",
|
|
30
|
+
"persons",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const CONTROL_BY_TYPE = {
|
|
34
|
+
date: "date",
|
|
35
|
+
daterange: "daterange",
|
|
36
|
+
email: "email",
|
|
37
|
+
package: "dropdown",
|
|
38
|
+
persons: "persons",
|
|
39
|
+
phone: "tel",
|
|
40
|
+
room: "dropdown",
|
|
41
|
+
text: "text",
|
|
42
|
+
textarea: "textarea",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The control for a field, or `null` for a type this package does not know yet. */
|
|
46
|
+
const controlOf = (field) => {
|
|
47
|
+
if (field.type === "select") {
|
|
48
|
+
return field.display === "radio" || field.display === "buttons"
|
|
49
|
+
? field.display
|
|
50
|
+
: "dropdown";
|
|
51
|
+
}
|
|
52
|
+
if (field.type === "checkbox") {
|
|
53
|
+
return field.options?.length ? "checkboxes" : "checkbox";
|
|
54
|
+
}
|
|
55
|
+
return CONTROL_BY_TYPE[field.type] ?? null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export { controlOf, FORM_CONTROLS };
|