@websline/cms-view-utils 1.10.2 → 1.14.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/package.json +6 -5
- package/src/ads/createAdRunner.js +15 -1
- package/src/client/fetchFormChallenge.js +21 -0
- package/src/client/submitForm.js +32 -0
- package/src/cms/fetchPage.js +5 -1
- package/src/cms/proxyToCms.js +35 -2
- package/src/cms/urlResolver.js +16 -3
- 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 +6 -0
- package/src/routes/cmsProxy.js +9 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@websline/cms-view-utils",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -18,21 +18,22 @@
|
|
|
18
18
|
"./dist/*": "./dist/*"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
+
"altcha-lib": "2.6.0",
|
|
21
22
|
"jsonwebtoken": "^9.0.3",
|
|
22
23
|
"tailwind-merge": "^3.7.0"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
26
|
"@eslint/compat": "^2.1.1",
|
|
26
27
|
"@eslint/js": "^10.0.1",
|
|
27
|
-
"eslint": "^10.
|
|
28
|
+
"eslint": "^10.12.0",
|
|
28
29
|
"eslint-config-prettier": "^10.1.8",
|
|
29
30
|
"eslint-plugin-svelte": "^3.23.0",
|
|
30
|
-
"globals": "^17.
|
|
31
|
-
"prettier": "^3.9.
|
|
31
|
+
"globals": "^17.13.0",
|
|
32
|
+
"prettier": "^3.9.9",
|
|
32
33
|
"prettier-plugin-svelte": "^4.1.1",
|
|
33
34
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
|
34
35
|
"tsup": "^8.5.1",
|
|
35
|
-
"vitest": "^5.0.
|
|
36
|
+
"vitest": "^5.0.3"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
|
38
39
|
"astro": ">=5",
|
|
@@ -108,6 +108,7 @@ const createAdRunner = ({
|
|
|
108
108
|
const occupied = new Map();
|
|
109
109
|
const teardowns = [];
|
|
110
110
|
const counted = new Set();
|
|
111
|
+
const sent = new Set();
|
|
111
112
|
const shown = [];
|
|
112
113
|
|
|
113
114
|
/**
|
|
@@ -218,11 +219,24 @@ const createAdRunner = ({
|
|
|
218
219
|
if (!ad.persistent) release(ad);
|
|
219
220
|
},
|
|
220
221
|
|
|
222
|
+
/**
|
|
223
|
+
* The ad's form was sent: its click, suppressed like one, but it stays on
|
|
224
|
+
* screen with the thanks until the visitor closes it.
|
|
225
|
+
*/
|
|
226
|
+
sent(ad) {
|
|
227
|
+
if (!ad || sent.has(ad.uuid)) return;
|
|
228
|
+
|
|
229
|
+
sent.add(ad.uuid);
|
|
230
|
+
track(ad.uuid, "click");
|
|
231
|
+
dismissals.record(ad.uuid, Date.now());
|
|
232
|
+
},
|
|
233
|
+
|
|
221
234
|
/** An ad that is not closable has no close button, so nothing to dismiss. */
|
|
222
235
|
dismiss(ad) {
|
|
223
236
|
if (!ad || ad.closable === false) return;
|
|
224
237
|
|
|
225
|
-
|
|
238
|
+
// Closing the thanks of a sent form turns nothing down.
|
|
239
|
+
if (!sent.has(ad.uuid)) track(ad.uuid, "close");
|
|
226
240
|
release(ad);
|
|
227
241
|
},
|
|
228
242
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch a fresh ALTCHA spam challenge for one form; needed while the form's `spam.captcha` is `true`.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} options
|
|
5
|
+
* @param {string} options.uuid - UUID of the form.
|
|
6
|
+
* @returns {Promise<object>} The challenge, to solve and send as `altcha` with the submit. It expires after ten minutes, names its form and stores one entry only.
|
|
7
|
+
*/
|
|
8
|
+
const fetchFormChallenge = async ({ uuid }) => {
|
|
9
|
+
const response = await fetch(`/api/cms/api/public/forms/${uuid}/challenge`, {
|
|
10
|
+
method: "GET",
|
|
11
|
+
cache: "no-store",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
throw new Error(`Challenge failed with ${response.status}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return response.json();
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export { fetchFormChallenge };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Submit a form's values; the CMS checks them against the live form and stores the entry.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} options
|
|
5
|
+
* @param {string} options.uuid - UUID of the form.
|
|
6
|
+
* @param {string} options.locale - Language the visitor filled the form in; decides the texts of the answer and of the confirmation mail.
|
|
7
|
+
* @param {Record<string, unknown>} options.values - Keyed by field `name`: text, textarea, email, phone, select → string; checkbox → array of option values, or boolean without options; date → `YYYY-MM-DD`; daterange → `{ from, to }`; persons → `{ adults, children: [age] }`; room, package → the option `value`.
|
|
8
|
+
* @param {string} [options.honeypot] - Content of the hidden honeypot input; empty for a real visitor.
|
|
9
|
+
* @param {boolean} [options.consent] - `true` once the guest agreed to the privacy policy; required while the form has `consent`.
|
|
10
|
+
* @param {{ challenge: object, solution: object }} [options.altcha] - A solved challenge from `fetchFormChallenge`; required while the form's `spam.captcha` is `true`. Missing, wrong, expired or already used, the answer is `422` with the error field `altcha`.
|
|
11
|
+
* @param {{ path?: string, adUuid?: string }} [options.source] - Where the form was sent from: the page path without leading slash and language segment, and the ad that opened the form.
|
|
12
|
+
* @returns {Promise<{ status: number, data: object }>} `201` with `{ mode: "redirect", url }` or `{ mode: "message", message }`; `422` with `errors: [{ field, code }]`; `429` once the visitor sent too many; `404` for a form that is gone or inactive.
|
|
13
|
+
*/
|
|
14
|
+
const submitForm = async ({
|
|
15
|
+
uuid,
|
|
16
|
+
locale,
|
|
17
|
+
values,
|
|
18
|
+
honeypot,
|
|
19
|
+
consent,
|
|
20
|
+
altcha,
|
|
21
|
+
source,
|
|
22
|
+
}) => {
|
|
23
|
+
const response = await fetch(`/api/cms/api/public/forms/${uuid}/submissions`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "content-type": "application/json" },
|
|
26
|
+
body: JSON.stringify({ locale, values, honeypot, consent, altcha, source }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return { status: response.status, data: await response.json().catch(() => ({})) };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export { submitForm };
|
package/src/cms/fetchPage.js
CHANGED
|
@@ -19,7 +19,11 @@ const fetchPage = async (context) => {
|
|
|
19
19
|
const draftUuid = resolveDraftUuidFromToken(editorToken);
|
|
20
20
|
const isCMSEditRoute = editorToken && draftUuid;
|
|
21
21
|
|
|
22
|
-
const cmsUrl = buildCmsPageUrl({
|
|
22
|
+
const cmsUrl = buildCmsPageUrl({
|
|
23
|
+
draftUuid,
|
|
24
|
+
path,
|
|
25
|
+
searchParams: url.searchParams,
|
|
26
|
+
});
|
|
23
27
|
|
|
24
28
|
const response = await fetch(cmsUrl, {
|
|
25
29
|
method: "GET",
|
package/src/cms/proxyToCms.js
CHANGED
|
@@ -4,6 +4,30 @@ import { HttpError } from "../shared/errors.js";
|
|
|
4
4
|
/** The events endpoint answers this, and a Response with a body on it throws. */
|
|
5
5
|
const NO_CONTENT = 204;
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The visitor's IP, behind nginx or behind CloudFront. nginx always overwrites
|
|
9
|
+
* `X-Real-IP`, so it wins; CloudFront names the viewer as `ip:port` and must not
|
|
10
|
+
* pass a viewer-sent `X-Real-IP` through.
|
|
11
|
+
*/
|
|
12
|
+
const visitorIpOf = (headers) => {
|
|
13
|
+
const realIp = headers.get("x-real-ip");
|
|
14
|
+
if (realIp) return realIp;
|
|
15
|
+
|
|
16
|
+
const viewer = headers.get("cloudfront-viewer-address") ?? "";
|
|
17
|
+
const portAt = viewer.lastIndexOf(":");
|
|
18
|
+
|
|
19
|
+
return portAt > 0 ? viewer.slice(0, portAt) : viewer;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A path the allowlist can judge as it will be requested. `fetch` turns `\` into
|
|
24
|
+
* `/` and resolves dot segments, so `forms/..\..\x/submissions` would match the
|
|
25
|
+
* list and still reach another route.
|
|
26
|
+
*/
|
|
27
|
+
const isPlainPath = (path) =>
|
|
28
|
+
!/[\\%]/.test(path) &&
|
|
29
|
+
!path.split("/").some((segment) => segment === "." || segment === "..");
|
|
30
|
+
|
|
7
31
|
const proxyToCms = async (context, { allow } = {}) => {
|
|
8
32
|
const cmsBase = import.meta.env.CMS_URL;
|
|
9
33
|
|
|
@@ -17,7 +41,7 @@ const proxyToCms = async (context, { allow } = {}) => {
|
|
|
17
41
|
// A write forwards the site's CMS token, and that token may carry scopes far
|
|
18
42
|
// beyond page delivery. Writes therefore reach only the paths named by the
|
|
19
43
|
// route that opened them, and everything else looks like it is not there.
|
|
20
|
-
if (allow && !allow(path)) {
|
|
44
|
+
if (allow && (!isPlainPath(path) || !allow(path))) {
|
|
21
45
|
throw new HttpError(404, "Not Found");
|
|
22
46
|
}
|
|
23
47
|
const url = new URL(request.url);
|
|
@@ -33,12 +57,17 @@ const proxyToCms = async (context, { allow } = {}) => {
|
|
|
33
57
|
? {
|
|
34
58
|
...buildCmsHeaders(request),
|
|
35
59
|
"content-type": request.headers.get("content-type") ?? "application/json",
|
|
60
|
+
"cms-visitor-ip": visitorIpOf(request.headers),
|
|
36
61
|
}
|
|
37
62
|
: buildCmsHeaders(request),
|
|
38
63
|
...(hasBody ? { body: await request.text() } : {}),
|
|
39
64
|
});
|
|
40
65
|
|
|
41
|
-
|
|
66
|
+
// A rejected write answers the visitor directly — a form shows the field errors
|
|
67
|
+
// of a 422 — so its body has to reach the browser.
|
|
68
|
+
const isRejectedWrite = hasBody && response.status >= 400 && response.status < 500;
|
|
69
|
+
|
|
70
|
+
if (!response.ok && !isRejectedWrite) {
|
|
42
71
|
throw new HttpError(response.status, "CMS Error");
|
|
43
72
|
}
|
|
44
73
|
|
|
@@ -47,11 +76,15 @@ const proxyToCms = async (context, { allow } = {}) => {
|
|
|
47
76
|
}
|
|
48
77
|
|
|
49
78
|
const data = await response.text();
|
|
79
|
+
// nginx caches every GET for minutes unless told not to; a spam challenge,
|
|
80
|
+
// for one, must reach each visitor fresh.
|
|
81
|
+
const noStore = /no-store/i.test(response.headers.get("cache-control") ?? "");
|
|
50
82
|
|
|
51
83
|
return new Response(data, {
|
|
52
84
|
status: response.status,
|
|
53
85
|
headers: {
|
|
54
86
|
"content-type": response.headers.get("content-type") ?? "application/json",
|
|
87
|
+
...(noStore ? { "cache-control": "no-store" } : {}),
|
|
55
88
|
},
|
|
56
89
|
});
|
|
57
90
|
};
|
package/src/cms/urlResolver.js
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
const CMS_BASE_URL = import.meta.env.CMS_URL;
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// The editor's own parameters say nothing about the page.
|
|
4
|
+
const EDITOR_PARAMS = ["editorToken", "cmsLocale"];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The page's own query string goes along, so forms on the page come pre-filled
|
|
8
|
+
* from the link, e.g. the quick bar's `?arrival=…&departure=…`.
|
|
9
|
+
*
|
|
10
|
+
* @param {{ draftUuid?: string, path: string, searchParams?: URLSearchParams }} options
|
|
11
|
+
*/
|
|
12
|
+
const buildCmsPageUrl = ({ draftUuid, path, searchParams }) => {
|
|
13
|
+
const query = new URLSearchParams(searchParams);
|
|
14
|
+
EDITOR_PARAMS.forEach((name) => query.delete(name));
|
|
15
|
+
const search = query.size ? `?${query}` : "";
|
|
16
|
+
|
|
4
17
|
if (draftUuid) {
|
|
5
|
-
return `${CMS_BASE_URL}/api/public/pages/preview/${draftUuid}`;
|
|
18
|
+
return `${CMS_BASE_URL}/api/public/pages/preview/${draftUuid}${search}`;
|
|
6
19
|
}
|
|
7
20
|
|
|
8
21
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
9
22
|
|
|
10
|
-
return `${CMS_BASE_URL}/api/public/pages${normalizedPath}`;
|
|
23
|
+
return `${CMS_BASE_URL}/api/public/pages${normalizedPath}${search}`;
|
|
11
24
|
};
|
|
12
25
|
|
|
13
26
|
const buildCmsAdsUrl = ({ locale, path }) => {
|
|
@@ -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 };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words a form brings along itself — buttons, captions, error messages —
|
|
3
|
+
* as opposed to the labels the editor wrote, which arrive with the form. Every
|
|
4
|
+
* language here works out of the box; a project replaces single texts through
|
|
5
|
+
* `overrides` without copying the rest.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const TEXTS = {
|
|
9
|
+
de: {
|
|
10
|
+
adults: "Erwachsene",
|
|
11
|
+
arrival: "Anreise",
|
|
12
|
+
back: "Zurück",
|
|
13
|
+
childAge: "Alter Kind {number}",
|
|
14
|
+
children: "Kinder",
|
|
15
|
+
choose: "Bitte wählen",
|
|
16
|
+
departure: "Abreise",
|
|
17
|
+
errorInvalid: "Bitte prüfen Sie diese Angabe.",
|
|
18
|
+
errorPastDate: "Dieses Datum liegt in der Vergangenheit.",
|
|
19
|
+
errorRequired: "Bitte füllen Sie dieses Feld aus.",
|
|
20
|
+
errorTooLong: "Dieser Text ist zu lang.",
|
|
21
|
+
next: "Weiter",
|
|
22
|
+
privacy: "Datenschutzerklärung",
|
|
23
|
+
required: "Pflichtfeld",
|
|
24
|
+
sendFailed:
|
|
25
|
+
"Das Formular konnte nicht gesendet werden. Bitte versuchen Sie es erneut.",
|
|
26
|
+
sending: "Wird gesendet …",
|
|
27
|
+
step: "Schritt {current} von {total}",
|
|
28
|
+
submit: "Absenden",
|
|
29
|
+
thanks: "Vielen Dank! Ihre Nachricht ist bei uns eingegangen.",
|
|
30
|
+
tooManyRequests:
|
|
31
|
+
"Zu viele Anfragen. Bitte versuchen Sie es in einer Minute erneut.",
|
|
32
|
+
years: "{count} Jahre",
|
|
33
|
+
},
|
|
34
|
+
en: {
|
|
35
|
+
adults: "Adults",
|
|
36
|
+
arrival: "Arrival",
|
|
37
|
+
back: "Back",
|
|
38
|
+
childAge: "Age of child {number}",
|
|
39
|
+
children: "Children",
|
|
40
|
+
choose: "Please choose",
|
|
41
|
+
departure: "Departure",
|
|
42
|
+
errorInvalid: "Please check this entry.",
|
|
43
|
+
errorPastDate: "This date is in the past.",
|
|
44
|
+
errorRequired: "Please fill in this field.",
|
|
45
|
+
errorTooLong: "This text is too long.",
|
|
46
|
+
next: "Next",
|
|
47
|
+
privacy: "Privacy policy",
|
|
48
|
+
required: "Required",
|
|
49
|
+
sendFailed: "The form could not be sent. Please try again.",
|
|
50
|
+
sending: "Sending …",
|
|
51
|
+
step: "Step {current} of {total}",
|
|
52
|
+
submit: "Send",
|
|
53
|
+
thanks: "Thank you! We have received your message.",
|
|
54
|
+
tooManyRequests: "Too many requests. Please try again in a minute.",
|
|
55
|
+
years: "{count} years",
|
|
56
|
+
},
|
|
57
|
+
it: {
|
|
58
|
+
adults: "Adulti",
|
|
59
|
+
arrival: "Arrivo",
|
|
60
|
+
back: "Indietro",
|
|
61
|
+
childAge: "Età bambino {number}",
|
|
62
|
+
children: "Bambini",
|
|
63
|
+
choose: "Si prega di scegliere",
|
|
64
|
+
departure: "Partenza",
|
|
65
|
+
errorInvalid: "Si prega di verificare questo dato.",
|
|
66
|
+
errorPastDate: "Questa data è nel passato.",
|
|
67
|
+
errorRequired: "Si prega di compilare questo campo.",
|
|
68
|
+
errorTooLong: "Questo testo è troppo lungo.",
|
|
69
|
+
next: "Avanti",
|
|
70
|
+
privacy: "Informativa sulla privacy",
|
|
71
|
+
required: "Campo obbligatorio",
|
|
72
|
+
sendFailed: "Non è stato possibile inviare il modulo. Si prega di riprovare.",
|
|
73
|
+
sending: "Invio in corso …",
|
|
74
|
+
step: "Passo {current} di {total}",
|
|
75
|
+
submit: "Invia",
|
|
76
|
+
thanks: "Grazie! Abbiamo ricevuto il suo messaggio.",
|
|
77
|
+
tooManyRequests: "Troppe richieste. Si prega di riprovare tra un minuto.",
|
|
78
|
+
years: "{count} anni",
|
|
79
|
+
},
|
|
80
|
+
fr: {
|
|
81
|
+
adults: "Adultes",
|
|
82
|
+
arrival: "Arrivée",
|
|
83
|
+
back: "Retour",
|
|
84
|
+
childAge: "Âge de l'enfant {number}",
|
|
85
|
+
children: "Enfants",
|
|
86
|
+
choose: "Veuillez choisir",
|
|
87
|
+
departure: "Départ",
|
|
88
|
+
errorInvalid: "Veuillez vérifier cette donnée.",
|
|
89
|
+
errorPastDate: "Cette date est dans le passé.",
|
|
90
|
+
errorRequired: "Veuillez remplir ce champ.",
|
|
91
|
+
errorTooLong: "Ce texte est trop long.",
|
|
92
|
+
next: "Suivant",
|
|
93
|
+
privacy: "Politique de confidentialité",
|
|
94
|
+
required: "Champ obligatoire",
|
|
95
|
+
sendFailed: "Le formulaire n'a pas pu être envoyé. Veuillez réessayer.",
|
|
96
|
+
sending: "Envoi en cours …",
|
|
97
|
+
step: "Étape {current} sur {total}",
|
|
98
|
+
submit: "Envoyer",
|
|
99
|
+
thanks: "Merci ! Nous avons bien reçu votre message.",
|
|
100
|
+
tooManyRequests: "Trop de demandes. Veuillez réessayer dans une minute.",
|
|
101
|
+
years: "{count} ans",
|
|
102
|
+
},
|
|
103
|
+
es: {
|
|
104
|
+
adults: "Adultos",
|
|
105
|
+
arrival: "Llegada",
|
|
106
|
+
back: "Atrás",
|
|
107
|
+
childAge: "Edad del niño {number}",
|
|
108
|
+
children: "Niños",
|
|
109
|
+
choose: "Por favor, elija",
|
|
110
|
+
departure: "Salida",
|
|
111
|
+
errorInvalid: "Por favor, compruebe este dato.",
|
|
112
|
+
errorPastDate: "Esta fecha está en el pasado.",
|
|
113
|
+
errorRequired: "Por favor, rellene este campo.",
|
|
114
|
+
errorTooLong: "Este texto es demasiado largo.",
|
|
115
|
+
next: "Siguiente",
|
|
116
|
+
privacy: "Política de privacidad",
|
|
117
|
+
required: "Campo obligatorio",
|
|
118
|
+
sendFailed:
|
|
119
|
+
"No se ha podido enviar el formulario. Por favor, inténtelo de nuevo.",
|
|
120
|
+
sending: "Enviando …",
|
|
121
|
+
step: "Paso {current} de {total}",
|
|
122
|
+
submit: "Enviar",
|
|
123
|
+
thanks: "¡Gracias! Hemos recibido su mensaje.",
|
|
124
|
+
tooManyRequests:
|
|
125
|
+
"Demasiadas solicitudes. Por favor, inténtelo de nuevo en un minuto.",
|
|
126
|
+
years: "{count} años",
|
|
127
|
+
},
|
|
128
|
+
nl: {
|
|
129
|
+
adults: "Volwassenen",
|
|
130
|
+
arrival: "Aankomst",
|
|
131
|
+
back: "Terug",
|
|
132
|
+
childAge: "Leeftijd kind {number}",
|
|
133
|
+
children: "Kinderen",
|
|
134
|
+
choose: "Maak een keuze",
|
|
135
|
+
departure: "Vertrek",
|
|
136
|
+
errorInvalid: "Controleer dit gegeven.",
|
|
137
|
+
errorPastDate: "Deze datum ligt in het verleden.",
|
|
138
|
+
errorRequired: "Vul dit veld in.",
|
|
139
|
+
errorTooLong: "Deze tekst is te lang.",
|
|
140
|
+
next: "Volgende",
|
|
141
|
+
privacy: "Privacybeleid",
|
|
142
|
+
required: "Verplicht veld",
|
|
143
|
+
sendFailed: "Het formulier kon niet worden verzonden. Probeer het opnieuw.",
|
|
144
|
+
sending: "Wordt verzonden …",
|
|
145
|
+
step: "Stap {current} van {total}",
|
|
146
|
+
submit: "Verzenden",
|
|
147
|
+
thanks: "Dank u! Wij hebben uw bericht ontvangen.",
|
|
148
|
+
tooManyRequests: "Te veel aanvragen. Probeer het over een minuut opnieuw.",
|
|
149
|
+
years: "{count} jaar",
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const FALLBACK_LOCALE = "en";
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The form's texts in one language. A text is looked up in the project's
|
|
157
|
+
* overrides for that language, then in the built-in one, then in English.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} locale - Language of the form, e.g. `"de"`.
|
|
160
|
+
* @param {Record<string, Record<string, string>>} [overrides] - Texts the project words differently, per language, e.g. `{ de: { next: "Fortfahren" } }`. Keep the `{placeholders}` of the original.
|
|
161
|
+
* @returns {(key: string, params?: Record<string, string | number>) => string} Unknown keys come back as the key itself.
|
|
162
|
+
*/
|
|
163
|
+
const createFormTexts = (locale, overrides = {}) => {
|
|
164
|
+
const sources = [overrides[locale], TEXTS[locale], TEXTS[FALLBACK_LOCALE]];
|
|
165
|
+
|
|
166
|
+
return (key, params = {}) => {
|
|
167
|
+
const template = sources.find((texts) => texts?.[key] != null)?.[key] ?? key;
|
|
168
|
+
|
|
169
|
+
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? ""));
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export { createFormTexts, TEXTS as FORM_TEXTS };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a form's values look like in the browser and what of them is sent. The
|
|
3
|
+
* browser checks only type and `required` — the same patterns the CMS uses, so
|
|
4
|
+
* a value that passes here is not turned down for its format there. Whether a
|
|
5
|
+
* date has passed, an option exists or a text is too long stays with the CMS.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
9
|
+
const PHONE_PATTERN = /^\+?[0-9 ()/.-]{3,40}$/;
|
|
10
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
11
|
+
|
|
12
|
+
const DEFAULT_ADULTS = 2;
|
|
13
|
+
|
|
14
|
+
const isValidDate = (value) => {
|
|
15
|
+
if (typeof value !== "string" || !DATE_PATTERN.test(value)) return false;
|
|
16
|
+
const date = new Date(`${value}T00:00:00Z`);
|
|
17
|
+
return !Number.isNaN(date.getTime()) && date.toISOString().startsWith(value);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The fields of some layouts, top to bottom, without empty columns. */
|
|
21
|
+
const fieldsOf = (layouts) =>
|
|
22
|
+
layouts.flatMap((layout) => layout.columns ?? []).filter(Boolean);
|
|
23
|
+
|
|
24
|
+
const emptyValueOf = (field) => {
|
|
25
|
+
if (field.type === "checkbox") return field.options?.length ? [] : false;
|
|
26
|
+
if (field.type === "daterange") return { from: "", to: "" };
|
|
27
|
+
if (field.type === "persons") return { adults: DEFAULT_ADULTS, children: [] };
|
|
28
|
+
return "";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Every field's starting value: what the URL pre-filled, else an empty one of its type. */
|
|
32
|
+
const initialValues = (form) =>
|
|
33
|
+
Object.fromEntries(
|
|
34
|
+
form.pages
|
|
35
|
+
.flatMap((page) => fieldsOf(page.layouts))
|
|
36
|
+
.map((field) => [field.name, field.value ?? emptyValueOf(field)]),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const isEmpty = (field, value) => {
|
|
40
|
+
if (value == null || value === false || value === "") return true;
|
|
41
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
42
|
+
if (field.type === "daterange") return !value.from && !value.to;
|
|
43
|
+
return typeof value === "string" && value.trim() === "";
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const CHECKERS = {
|
|
47
|
+
email: (value) => EMAIL_PATTERN.test(value.trim()),
|
|
48
|
+
phone: (value) => PHONE_PATTERN.test(value.trim()),
|
|
49
|
+
date: isValidDate,
|
|
50
|
+
daterange: ({ from, to }) => isValidDate(from) && isValidDate(to) && to >= from,
|
|
51
|
+
persons: ({ adults, children }) =>
|
|
52
|
+
adults >= 1 && children.every((age) => Number.isInteger(age)),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The errors of some fields, as the CMS would name them.
|
|
57
|
+
*
|
|
58
|
+
* @param {object[]} fields
|
|
59
|
+
* @param {Record<string, unknown>} values - Keyed by field name.
|
|
60
|
+
* @returns {Record<string, "REQUIRED" | "INVALID">} Keyed by field name; empty when all is well.
|
|
61
|
+
*/
|
|
62
|
+
const validateValues = (fields, values) => {
|
|
63
|
+
const errors = {};
|
|
64
|
+
|
|
65
|
+
for (const field of fields) {
|
|
66
|
+
const value = values[field.name];
|
|
67
|
+
|
|
68
|
+
if (isEmpty(field, value)) {
|
|
69
|
+
if (field.required) errors[field.name] = "REQUIRED";
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (CHECKERS[field.type] && !CHECKERS[field.type](value)) {
|
|
74
|
+
errors[field.name] = "INVALID";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return errors;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The values to send: empty ones left out, as the CMS does not store them anyway. */
|
|
82
|
+
const submissionValues = (fields, values) =>
|
|
83
|
+
Object.fromEntries(
|
|
84
|
+
fields
|
|
85
|
+
.filter((field) => !isEmpty(field, values[field.name]))
|
|
86
|
+
.map((field) => [field.name, values[field.name]]),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A persons value with `count` children. Ages already chosen stay; a new child
|
|
91
|
+
* starts without one, so the guest has to pick it.
|
|
92
|
+
*/
|
|
93
|
+
const withChildCount = (value, count) => ({
|
|
94
|
+
...value,
|
|
95
|
+
children: Array.from(
|
|
96
|
+
{ length: count },
|
|
97
|
+
(_, index) => value.children[index] ?? null,
|
|
98
|
+
),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export { fieldsOf, initialValues, submissionValues, validateValues, withChildCount };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { solveChallenge } from "altcha-lib";
|
|
2
|
+
import { deriveKey } from "altcha-lib/algorithms/web/pbkdf2";
|
|
3
|
+
import { fetchFormChallenge } from "../client/fetchFormChallenge.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The form's ALTCHA proof of work, solved in the background. WebCrypto derives
|
|
7
|
+
* the keys off the main thread and the solver yields regularly, so the page
|
|
8
|
+
* stays responsive without a worker a website would have to bundle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A challenge this close to its end is replaced before it is sent. */
|
|
12
|
+
const EXPIRY_MARGIN_S = 30;
|
|
13
|
+
const solve = async (uuid) => {
|
|
14
|
+
const challenge = await fetchFormChallenge({ uuid });
|
|
15
|
+
const solution = await solveChallenge({ challenge, deriveKey });
|
|
16
|
+
|
|
17
|
+
if (!solution) throw new Error("Challenge not solved");
|
|
18
|
+
|
|
19
|
+
return { challenge, solution };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} uuid - The form's UUID.
|
|
24
|
+
* @param {() => number} [now] - Milliseconds; for tests.
|
|
25
|
+
*/
|
|
26
|
+
const createSpamChallenge = (uuid, now = () => Date.now()) => {
|
|
27
|
+
let pending = null;
|
|
28
|
+
|
|
29
|
+
const isFresh = (payload) =>
|
|
30
|
+
payload.challenge.parameters.expiresAt - EXPIRY_MARGIN_S > now() / 1_000;
|
|
31
|
+
|
|
32
|
+
const restart = () => {
|
|
33
|
+
const attempt = solve(uuid);
|
|
34
|
+
pending = attempt;
|
|
35
|
+
// A failed attempt is retried at the next call instead of failing forever.
|
|
36
|
+
attempt.catch(() => {
|
|
37
|
+
if (pending === attempt) pending = null;
|
|
38
|
+
});
|
|
39
|
+
return attempt;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
/** Starts solving, once; later calls change nothing. */
|
|
44
|
+
start() {
|
|
45
|
+
if (!pending) restart();
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
/** A solved, unexpired payload, solving or replacing one as needed. */
|
|
49
|
+
async payload() {
|
|
50
|
+
const payload = await (pending ?? restart());
|
|
51
|
+
return isFresh(payload) ? payload : restart();
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/** Throws the current one away, after the CMS refused it. */
|
|
55
|
+
renew() {
|
|
56
|
+
return restart();
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export { createSpamChallenge };
|
package/src/index.js
CHANGED
|
@@ -10,11 +10,17 @@ export { fetchRatings } from "./client/fetchRatings.js";
|
|
|
10
10
|
export { fetchAds } from "./client/fetchAds.js";
|
|
11
11
|
|
|
12
12
|
export { recordAdEvent } from "./client/recordAdEvent.js";
|
|
13
|
+
export { submitForm } from "./client/submitForm.js";
|
|
14
|
+
export { fetchFormChallenge } from "./client/fetchFormChallenge.js";
|
|
13
15
|
|
|
14
16
|
export { createAdRunner } from "./ads/createAdRunner.js";
|
|
15
17
|
export { slotOf } from "./ads/slots.js";
|
|
16
18
|
export { toAdPath } from "./ads/adPath.js";
|
|
17
19
|
|
|
20
|
+
export { createFormController } from "./forms/createFormController.js";
|
|
21
|
+
export { createFormTexts } from "./forms/formTexts.js";
|
|
22
|
+
export { FORM_CONTROLS } from "./forms/formControls.js";
|
|
23
|
+
|
|
18
24
|
export { default as AddBlockPlaceholder } from "./components/AddBlockPlaceholder.svelte";
|
|
19
25
|
export { default as BlockWrapper } from "./components/BlockWrapper.svelte";
|
|
20
26
|
export { default as EditableBlock } from "./components/EditableBlock.svelte";
|
package/src/routes/cmsProxy.js
CHANGED
|
@@ -2,14 +2,17 @@ import { createCmsProxyHandler } from "../cms/proxyToCms.js";
|
|
|
2
2
|
|
|
3
3
|
export const prerender = false;
|
|
4
4
|
|
|
5
|
-
/** The only
|
|
6
|
-
const
|
|
5
|
+
/** The only writes a browser has any business making through this proxy. */
|
|
6
|
+
const WRITE_PATHS = [
|
|
7
|
+
/^api\/public\/ads\/[^/]+\/events$/,
|
|
8
|
+
/^api\/public\/forms\/[^/]+\/submissions$/,
|
|
9
|
+
];
|
|
7
10
|
|
|
8
11
|
export const GET = createCmsProxyHandler();
|
|
9
12
|
|
|
10
|
-
// Ad events
|
|
11
|
-
// this proxy. Narrowed to
|
|
12
|
-
// management scopes, and an open POST would hand them to every visitor.
|
|
13
|
+
// Ad events and form entries come from the browser, which can only reach the CMS
|
|
14
|
+
// through this proxy. Narrowed to those paths on purpose: the forwarded token may
|
|
15
|
+
// hold management scopes, and an open POST would hand them to every visitor.
|
|
13
16
|
export const POST = createCmsProxyHandler({
|
|
14
|
-
allow: (path) =>
|
|
17
|
+
allow: (path) => WRITE_PATHS.some((pattern) => pattern.test(path)),
|
|
15
18
|
});
|