@bettercms-ai/astro 0.22.1 → 0.24.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/README.md +227 -227
- package/components/BcmsBlocks.astro +640 -620
- package/components/BcmsButton.astro +44 -44
- package/components/BcmsField.astro +30 -30
- package/components/BcmsForm.astro +360 -279
- package/components/BcmsImage.astro +112 -112
- package/components/BcmsLive.astro +66 -66
- package/components/BcmsLiveBlocks.astro +72 -72
- package/components/BcmsSearch.astro +23 -23
- package/components/BcmsVisualEditing.astro +26 -26
- package/dist/bindings.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware-entry.js.map +1 -1
- package/dist/routes/draft-disable.js.map +1 -1
- package/dist/routes/draft-enable.js.map +1 -1
- package/dist/runtime.js.map +1 -1
- package/env.d.ts +35 -35
- package/package.json +86 -86
|
@@ -1,279 +1,360 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* <BcmsForm> — renders a BetterCMS form with conditional fields, honeypot and
|
|
4
|
-
* optional Turnstile. Submits JSON to the public forms endpoint with
|
|
5
|
-
* progressive enhancement (falls back to a normal POST without JS).
|
|
6
|
-
*
|
|
7
|
-
* Markup is class-driven and unstyled — the host owns CSS (matches the
|
|
8
|
-
* server-side renderer's class names: bcms-form, bcms-field, bcms-check, …).
|
|
9
|
-
*/
|
|
10
|
-
import config from "bettercms:config";
|
|
11
|
-
import type { DeliveryForm, DeliveryFormField } from "@bettercms-ai/sdk";
|
|
12
|
-
import { getCountries, getCountryCallingCode, isSupportedCountry, getExampleNumber } from "libphonenumber-js/min";
|
|
13
|
-
import phoneExamples from "libphonenumber-js/mobile/examples";
|
|
14
|
-
|
|
15
|
-
interface Props {
|
|
16
|
-
form: DeliveryForm;
|
|
17
|
-
turnstileSiteKey?: string | null;
|
|
18
|
-
class?: string;
|
|
19
|
-
/** Anything else lands on the <form> itself. <BcmsBlocks> uses this to put a form block's
|
|
20
|
-
* `data-bcms-block` marker on its own root — without it the attribute is passed as a prop
|
|
21
|
-
* nothing reads, the marker silently vanishes, and a top-level form becomes a section the
|
|
22
|
-
* Visual Editor cannot see. Caught by the marker canary in blocks.test.ts. */
|
|
23
|
-
[key: `data-${string}`]: unknown;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const { form, turnstileSiteKey, class: className, ...rest } = Astro.props;
|
|
27
|
-
const action = `${config.apiUrl}/api/v1/forms/public/${encodeURIComponent(form.id)}/submissions`;
|
|
28
|
-
|
|
29
|
-
const INPUT_TYPE: Partial<Record<DeliveryFormField["type"], string>> = {
|
|
30
|
-
email: "email",
|
|
31
|
-
number: "number",
|
|
32
|
-
phone: "tel",
|
|
33
|
-
date: "date",
|
|
34
|
-
url: "url",
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Calling-code options for a phone picker. Frontmatter only — this runs at build/SSR time and
|
|
39
|
-
* ships ZERO bytes of libphonenumber to the visitor; the inline script below never sees it.
|
|
40
|
-
*
|
|
41
|
-
* Every libphonenumber helper is wrapped in an arrow rather than passed as a bare callback:
|
|
42
|
-
* `.filter`/`.map` hand it the array INDEX as its `metadata` argument and it throws.
|
|
43
|
-
*/
|
|
44
|
-
const REGION_NAMES =
|
|
45
|
-
typeof Intl !== "undefined" && typeof Intl.DisplayNames === "function"
|
|
46
|
-
? new Intl.DisplayNames(["en"], { type: "region" })
|
|
47
|
-
: null;
|
|
48
|
-
|
|
49
|
-
let countryCache: { iso: string; name: string; code: string; trunk: boolean }[] | null = null;
|
|
50
|
-
const countryList = () => {
|
|
51
|
-
if (countryCache) return countryCache;
|
|
52
|
-
countryCache = getCountries()
|
|
53
|
-
.filter((iso) => isSupportedCountry(iso))
|
|
54
|
-
.map((iso) => {
|
|
55
|
-
let name = iso as string;
|
|
56
|
-
try {
|
|
57
|
-
name = REGION_NAMES?.of(iso) ?? iso;
|
|
58
|
-
} catch {
|
|
59
|
-
name = iso;
|
|
60
|
-
}
|
|
61
|
-
let trunk = false;
|
|
62
|
-
try {
|
|
63
|
-
const ex = getExampleNumber(iso, phoneExamples);
|
|
64
|
-
if (ex) {
|
|
65
|
-
const national = ex.formatNational().replace(/\D/g, "");
|
|
66
|
-
trunk = national.length > String(ex.nationalNumber).length && national.startsWith("0");
|
|
67
|
-
}
|
|
68
|
-
} catch {
|
|
69
|
-
trunk = false;
|
|
70
|
-
}
|
|
71
|
-
return { iso: iso as string, name, code: getCountryCallingCode(iso), trunk };
|
|
72
|
-
})
|
|
73
|
-
.sort((a, b) => a.name.localeCompare(b.name));
|
|
74
|
-
return countryCache;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <BcmsForm> — renders a BetterCMS form with conditional fields, honeypot and
|
|
4
|
+
* optional Turnstile. Submits JSON to the public forms endpoint with
|
|
5
|
+
* progressive enhancement (falls back to a normal POST without JS).
|
|
6
|
+
*
|
|
7
|
+
* Markup is class-driven and unstyled — the host owns CSS (matches the
|
|
8
|
+
* server-side renderer's class names: bcms-form, bcms-field, bcms-check, …).
|
|
9
|
+
*/
|
|
10
|
+
import config from "bettercms:config";
|
|
11
|
+
import type { DeliveryForm, DeliveryFormField } from "@bettercms-ai/sdk";
|
|
12
|
+
import { getCountries, getCountryCallingCode, isSupportedCountry, getExampleNumber } from "libphonenumber-js/min";
|
|
13
|
+
import phoneExamples from "libphonenumber-js/mobile/examples";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
form: DeliveryForm;
|
|
17
|
+
turnstileSiteKey?: string | null;
|
|
18
|
+
class?: string;
|
|
19
|
+
/** Anything else lands on the <form> itself. <BcmsBlocks> uses this to put a form block's
|
|
20
|
+
* `data-bcms-block` marker on its own root — without it the attribute is passed as a prop
|
|
21
|
+
* nothing reads, the marker silently vanishes, and a top-level form becomes a section the
|
|
22
|
+
* Visual Editor cannot see. Caught by the marker canary in blocks.test.ts. */
|
|
23
|
+
[key: `data-${string}`]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { form, turnstileSiteKey, class: className, ...rest } = Astro.props;
|
|
27
|
+
const action = `${config.apiUrl}/api/v1/forms/public/${encodeURIComponent(form.id)}/submissions`;
|
|
28
|
+
|
|
29
|
+
const INPUT_TYPE: Partial<Record<DeliveryFormField["type"], string>> = {
|
|
30
|
+
email: "email",
|
|
31
|
+
number: "number",
|
|
32
|
+
phone: "tel",
|
|
33
|
+
date: "date",
|
|
34
|
+
url: "url",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Calling-code options for a phone picker. Frontmatter only — this runs at build/SSR time and
|
|
39
|
+
* ships ZERO bytes of libphonenumber to the visitor; the inline script below never sees it.
|
|
40
|
+
*
|
|
41
|
+
* Every libphonenumber helper is wrapped in an arrow rather than passed as a bare callback:
|
|
42
|
+
* `.filter`/`.map` hand it the array INDEX as its `metadata` argument and it throws.
|
|
43
|
+
*/
|
|
44
|
+
const REGION_NAMES =
|
|
45
|
+
typeof Intl !== "undefined" && typeof Intl.DisplayNames === "function"
|
|
46
|
+
? new Intl.DisplayNames(["en"], { type: "region" })
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
let countryCache: { iso: string; name: string; code: string; trunk: boolean }[] | null = null;
|
|
50
|
+
const countryList = () => {
|
|
51
|
+
if (countryCache) return countryCache;
|
|
52
|
+
countryCache = getCountries()
|
|
53
|
+
.filter((iso) => isSupportedCountry(iso))
|
|
54
|
+
.map((iso) => {
|
|
55
|
+
let name = iso as string;
|
|
56
|
+
try {
|
|
57
|
+
name = REGION_NAMES?.of(iso) ?? iso;
|
|
58
|
+
} catch {
|
|
59
|
+
name = iso;
|
|
60
|
+
}
|
|
61
|
+
let trunk = false;
|
|
62
|
+
try {
|
|
63
|
+
const ex = getExampleNumber(iso, phoneExamples);
|
|
64
|
+
if (ex) {
|
|
65
|
+
const national = ex.formatNational().replace(/\D/g, "");
|
|
66
|
+
trunk = national.length > String(ex.nationalNumber).length && national.startsWith("0");
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
trunk = false;
|
|
70
|
+
}
|
|
71
|
+
return { iso: iso as string, name, code: getCountryCallingCode(iso), trunk };
|
|
72
|
+
})
|
|
73
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
74
|
+
return countryCache;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Multi-step forms. A `step` field is a divider: the fields before the first one are page 1 and
|
|
81
|
+
* each divider opens the next. Every page is a <fieldset data-bcms-step>, so with no JavaScript
|
|
82
|
+
* the form is simply one long form; the inline script below shows one at a time. A form with no
|
|
83
|
+
* divider takes the single un-wrapped group, so its markup is exactly what it always was.
|
|
84
|
+
* The same rule lives in the hosted renderer and BcmsForm (next); each package publishes on its
|
|
85
|
+
* own and cannot count on a newer shared helper arriving with it.
|
|
86
|
+
*/
|
|
87
|
+
type Page = { label: string | null; key: string | null; fields: DeliveryFormField[] };
|
|
88
|
+
const all = form.fields ?? [];
|
|
89
|
+
let pages: Page[] | null = null;
|
|
90
|
+
if (all.some((f) => f.type === "step")) {
|
|
91
|
+
pages = [];
|
|
92
|
+
let cur: Page = { label: null, key: null, fields: [] };
|
|
93
|
+
for (const f of all) {
|
|
94
|
+
if (f.type !== "step") {
|
|
95
|
+
cur.fields.push(f);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (cur.fields.length) pages.push(cur);
|
|
99
|
+
cur = { label: f.label, key: f.key, fields: [] };
|
|
100
|
+
}
|
|
101
|
+
if (cur.fields.length) pages.push(cur);
|
|
102
|
+
}
|
|
103
|
+
const groups: Page[] = pages ?? [{ label: null, key: null, fields: all }];
|
|
104
|
+
const Wrap = pages ? "fieldset" : Fragment;
|
|
105
|
+
// Copy of FORM_STEPS_CSS in the hosted renderer: a <fieldset> otherwise draws its own border.
|
|
106
|
+
// Canonical copy is FORM_STEPS_JS in the backend's src/lib/sites/render-page.ts, pinned byte for
|
|
107
|
+
// byte by src/__tests__/content/form-script-pin.test.ts. Emitted only for a stepped form, ahead of
|
|
108
|
+
// the submit script, and idempotent per form, so several <BcmsForm>s on a page are fine.
|
|
109
|
+
const STEPS_JS = `document.querySelectorAll('form[data-bcms-steps]').forEach(function(f){
|
|
110
|
+
if(f.getAttribute('data-bcms-steps-on'))return;
|
|
111
|
+
f.setAttribute('data-bcms-steps-on','1');
|
|
112
|
+
var steps=[].slice.call(f.querySelectorAll('fieldset[data-bcms-step]'));
|
|
113
|
+
if(steps.length<2)return;
|
|
114
|
+
var last=steps.length-1,at=0,submit=f.querySelector('button[type="submit"]');
|
|
115
|
+
f.noValidate=true;
|
|
116
|
+
function bad(s){var c=s.querySelectorAll('input,select,textarea');for(var k=0;k<c.length;k++){if(!c[k].checkValidity())return c[k];}return null;}
|
|
117
|
+
function show(n,focus){
|
|
118
|
+
at=n;
|
|
119
|
+
steps.forEach(function(s,k){s.hidden=k!==n;});
|
|
120
|
+
if(submit)submit.hidden=n!==last;
|
|
121
|
+
var h=steps[n].querySelector('.bcms-step-title');
|
|
122
|
+
if(focus&&h)h.focus();
|
|
123
|
+
}
|
|
124
|
+
function next(){var c=bad(steps[at]);if(c){c.reportValidity();return;}show(at+1,true);}
|
|
125
|
+
steps.forEach(function(s,k){
|
|
126
|
+
var nav=document.createElement('div');nav.className='bcms-step-nav';
|
|
127
|
+
function btn(label,attr,fn){var b=document.createElement('button');b.type='button';b.setAttribute(attr,'');b.textContent=label;b.addEventListener('click',fn);nav.appendChild(b);}
|
|
128
|
+
if(k>0)btn('Back','data-bcms-step-back',function(){show(k-1,true);});
|
|
129
|
+
if(k<last)btn('Next','data-bcms-step-next',next);
|
|
130
|
+
s.appendChild(nav);
|
|
131
|
+
});
|
|
132
|
+
// Capture phase, so this runs ahead of the submit script however many copies of it are bound.
|
|
133
|
+
f.addEventListener('submit',function(e){
|
|
134
|
+
if(at<last){e.preventDefault();e.stopImmediatePropagation();next();return;}
|
|
135
|
+
for(var k=0;k<steps.length;k++){
|
|
136
|
+
var c=bad(steps[k]);
|
|
137
|
+
if(c){e.preventDefault();e.stopImmediatePropagation();show(k,false);c.reportValidity();return;}
|
|
138
|
+
}
|
|
139
|
+
},true);
|
|
140
|
+
f.addEventListener('reset',function(){show(0,false);});
|
|
141
|
+
show(0,false);
|
|
142
|
+
});`;
|
|
143
|
+
const STEPS_CSS = ".bcms-step{border:0;margin:0 0 14px;padding:0;min-width:0}.bcms-step-title{margin:0 0 12px;font-size:1.125em}.bcms-step-count{display:block;font-size:.8em;font-weight:500;color:var(--bcms-text-muted,#6b7280)}.bcms-step-nav{display:flex;gap:10px;margin-top:14px}.bcms-form .bcms-step-nav button[data-bcms-step-back]{background:transparent;color:inherit;border:1px solid #ccc}";
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
<form
|
|
147
|
+
class={`bcms-form${className ? ` ${className}` : ""}`}
|
|
148
|
+
method="post"
|
|
149
|
+
action={action}
|
|
150
|
+
data-bcms-form={form.id}
|
|
151
|
+
data-bcms-steps={pages ? pages.length : undefined}
|
|
152
|
+
data-bcms-form-success={form.successMessage ?? "Thanks!"}
|
|
153
|
+
data-bcms-form-redirect={form.redirectUrl ?? ""}
|
|
154
|
+
{...rest}
|
|
155
|
+
>
|
|
156
|
+
{groups.map((page, pageIndex) => (
|
|
157
|
+
<Wrap {...(pages ? { class: "bcms-step", "data-bcms-step": pageIndex + 1, "aria-labelledby": `bcms-step-${form.id}-${pageIndex + 1}` } : {})}>
|
|
158
|
+
{pages && (
|
|
159
|
+
<h3 class="bcms-step-title" id={`bcms-step-${form.id}-${pageIndex + 1}`} tabindex="-1" data-bcms-form-field={page.key ?? undefined}>
|
|
160
|
+
<span class="bcms-step-count">Step {pageIndex + 1} of {pages.length}</span>
|
|
161
|
+
{page.label && <Fragment> <span data-bcms-form-part="label">{page.label}</span></Fragment>}
|
|
162
|
+
</h3>
|
|
163
|
+
)}
|
|
164
|
+
{page.fields.map((f) => {
|
|
165
|
+
const wrapAttrs = {
|
|
166
|
+
"data-bcms-form-field": f.key,
|
|
167
|
+
...(f.showIf ? { "data-bcms-showif": JSON.stringify(f.showIf) } : {}),
|
|
168
|
+
};
|
|
169
|
+
// Every author-editable string carries a `data-bcms-form-part` so the Visual Editor can
|
|
170
|
+
// repaint it live. A string the dock can edit but cannot mark shows stale on the canvas.
|
|
171
|
+
const help = f.helpText
|
|
172
|
+
? <span class="bcms-field-help" data-bcms-form-part="help">{f.helpText}</span>
|
|
173
|
+
: null;
|
|
174
|
+
if (f.type === "hidden") return <input type="hidden" name={f.key} value={f.defaultValue ?? ""} />;
|
|
175
|
+
if (f.type === "checkbox" || f.type === "consent")
|
|
176
|
+
return (
|
|
177
|
+
<label class="bcms-check" {...wrapAttrs}>
|
|
178
|
+
<input type="checkbox" name={f.key} required={f.required} />
|
|
179
|
+
{/* The ` *` marker is emitted for EVERY field type, by every renderer — the
|
|
180
|
+
Visual Editor's live patch rewrites this span as `label + marker`, so a
|
|
181
|
+
renderer that omitted it would drop the star on edit and grow one back on
|
|
182
|
+
the next reload. */}
|
|
183
|
+
<span data-bcms-form-part="label">{f.label}{f.required ? " *" : ""}</span>
|
|
184
|
+
{help}
|
|
185
|
+
</label>
|
|
186
|
+
);
|
|
187
|
+
// Pick-one / pick-many are option GROUPS. Without this branch both fell through
|
|
188
|
+
// to the `<input type={INPUT_TYPE[f.type] ?? "text"}>` below and shipped as a
|
|
189
|
+
// single-line text box on every Astro site.
|
|
190
|
+
if (f.type === "radio" || f.type === "checkboxes") {
|
|
191
|
+
const multi = f.type === "checkboxes";
|
|
192
|
+
return (
|
|
193
|
+
<fieldset class={`bcms-field bcms-field--${multi ? "checkboxes" : "radio"}`} {...wrapAttrs}>
|
|
194
|
+
<legend data-bcms-form-part="label">{f.label}{f.required ? " *" : ""}</legend>
|
|
195
|
+
{(f.options ?? []).map((o) => (
|
|
196
|
+
<label class="bcms-option">
|
|
197
|
+
{/* `required` on each radio is the native "pick one"; on checkboxes it
|
|
198
|
+
would demand every box, so the API enforces non-empty instead. */}
|
|
199
|
+
<input type={multi ? "checkbox" : "radio"} name={f.key} value={o} required={!multi && f.required} />
|
|
200
|
+
<span data-bcms-form-part="option">{o}</span>
|
|
201
|
+
</label>
|
|
202
|
+
))}
|
|
203
|
+
{help}
|
|
204
|
+
</fieldset>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return (
|
|
208
|
+
<div class="bcms-field" {...wrapAttrs}>
|
|
209
|
+
<label for={`bcms-f-${f.key}`} data-bcms-form-part="label">{f.label}{f.required ? " *" : ""}</label>
|
|
210
|
+
{f.type === "textarea" ? (
|
|
211
|
+
<textarea id={`bcms-f-${f.key}`} name={f.key} placeholder={f.placeholder} required={f.required}>
|
|
212
|
+
{f.defaultValue ?? ""}
|
|
213
|
+
</textarea>
|
|
214
|
+
) : f.type === "select" ? (
|
|
215
|
+
<select id={`bcms-f-${f.key}`} name={f.key} required={f.required}>
|
|
216
|
+
{(f.options ?? []).map((o) => <option value={o} data-bcms-form-part="option">{o}</option>)}
|
|
217
|
+
</select>
|
|
218
|
+
) : f.type === "phone" && f.ui?.countryPicker ? (
|
|
219
|
+
/* A country select plus a tel input, submitting ONE E.164 value. `required` belongs
|
|
220
|
+
on the number only — a select always has a value and would never block. */
|
|
221
|
+
<span class="bcms-phone">
|
|
222
|
+
<select class="bcms-phone-country" name={`${f.key}__country`} aria-label="Country code">
|
|
223
|
+
<option value="" selected={!f.ui.defaultCountry}>International</option>
|
|
224
|
+
{countryList().map((c) => (
|
|
225
|
+
<option value={c.iso} data-cc={c.code} data-trunk={c.trunk ? "1" : undefined} selected={c.iso === f.ui!.defaultCountry}>
|
|
226
|
+
{c.name} +{c.code}
|
|
227
|
+
</option>
|
|
228
|
+
))}
|
|
229
|
+
</select>
|
|
230
|
+
<input
|
|
231
|
+
type="tel"
|
|
232
|
+
id={`bcms-f-${f.key}`}
|
|
233
|
+
name={f.key}
|
|
234
|
+
placeholder={f.placeholder}
|
|
235
|
+
value={f.defaultValue}
|
|
236
|
+
required={f.required}
|
|
237
|
+
autocomplete="tel"
|
|
238
|
+
dir="ltr"
|
|
239
|
+
/>
|
|
240
|
+
</span>
|
|
241
|
+
) : (
|
|
242
|
+
<input
|
|
243
|
+
type={INPUT_TYPE[f.type] ?? "text"}
|
|
244
|
+
id={`bcms-f-${f.key}`}
|
|
245
|
+
name={f.key}
|
|
246
|
+
placeholder={f.placeholder}
|
|
247
|
+
value={f.defaultValue}
|
|
248
|
+
required={f.required}
|
|
249
|
+
/>
|
|
250
|
+
)}
|
|
251
|
+
{help}
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
})}
|
|
255
|
+
</Wrap>
|
|
256
|
+
))}
|
|
257
|
+
|
|
258
|
+
{form.honeypotField && (
|
|
259
|
+
<input type="text" name={form.honeypotField} tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true" />
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
{showTurnstile && <div class="cf-turnstile" data-sitekey={turnstileSiteKey}></div>}
|
|
263
|
+
|
|
264
|
+
<button type="submit">{form.submitLabel || "Submit"}</button>
|
|
265
|
+
<p class="bcms-form-msg" hidden></p>
|
|
266
|
+
</form>
|
|
267
|
+
|
|
268
|
+
{pages && <style is:inline set:html={STEPS_CSS}></style>}
|
|
269
|
+
|
|
270
|
+
{showTurnstile && <script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>}
|
|
271
|
+
|
|
272
|
+
{pages && <script is:inline set:html={STEPS_JS}></script>}
|
|
273
|
+
|
|
274
|
+
<script is:inline>
|
|
275
|
+
// Compose one E.164 value from a country-picker phone field. CANONICAL COPY is
|
|
276
|
+
// PHONE_COMPOSE_JS in the backend's src/lib/sites/render-page.ts, and this file is pinned
|
|
277
|
+
// against it byte for byte by src/__tests__/content/form-script-pin.test.ts. All three
|
|
278
|
+
// vanilla lanes are inline script text on someone else's page and cannot import at runtime,
|
|
279
|
+
// so the rule genuinely exists more than once.
|
|
280
|
+
function bcmsPhone(cc,raw,trunk){var d=String(raw||'').trim();if(!d)return '';if(d.charAt(0)==='+'){var i=d.replace(/\D/g,'');return i?'+'+i:'';}if(!cc)return d;var n=d.replace(/\D/g,'');if(trunk&&n.charAt(0)==='0')n=n.slice(1);return n?'+'+cc+n:'';}
|
|
281
|
+
|
|
282
|
+
// One copy of this script ships per <BcmsForm> instance, and each copy sees EVERY
|
|
283
|
+
// form on the page — so two forms meant four bindings and a double submit. The
|
|
284
|
+
// `bcmsFormBound` flag makes binding idempotent no matter how many copies run,
|
|
285
|
+
// and the success/redirect config now lives on the FORM rather than on the script
|
|
286
|
+
// element, so form A's script can no longer apply form B's success message.
|
|
287
|
+
document.querySelectorAll("form[data-bcms-form]").forEach((form) => {
|
|
288
|
+
if (form.dataset.bcmsFormBound) return;
|
|
289
|
+
form.dataset.bcmsFormBound = "1";
|
|
290
|
+
|
|
291
|
+
const msg = form.querySelector(".bcms-form-msg");
|
|
292
|
+
const successMsg = form.getAttribute("data-bcms-form-success") || "Thanks!";
|
|
293
|
+
const redirect = form.getAttribute("data-bcms-form-redirect") || "";
|
|
294
|
+
|
|
295
|
+
// Conditional fields: show/hide based on another field's value.
|
|
296
|
+
const conds = form.querySelectorAll("[data-bcms-showif]");
|
|
297
|
+
const applyConds = () => {
|
|
298
|
+
conds.forEach((el) => {
|
|
299
|
+
try {
|
|
300
|
+
const { field, equals } = JSON.parse(el.getAttribute("data-bcms-showif"));
|
|
301
|
+
const input = form.elements[field];
|
|
302
|
+
// A radio/checkboxes group resolves to a RadioNodeList, which has no `.type`
|
|
303
|
+
// and whose `.value` is "" for checkbox groups — read the checked members.
|
|
304
|
+
let val;
|
|
305
|
+
if (input instanceof RadioNodeList) {
|
|
306
|
+
const checked = Array.from(input).filter((el) => el.checked).map((el) => el.value);
|
|
307
|
+
val = checked.includes(equals) ? equals : checked[0];
|
|
308
|
+
} else if (input && input.type === "checkbox") {
|
|
309
|
+
val = input.checked ? "true" : "false";
|
|
310
|
+
} else {
|
|
311
|
+
val = input?.value;
|
|
312
|
+
}
|
|
313
|
+
el.style.display = val === equals ? "" : "none";
|
|
314
|
+
} catch {}
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
form.addEventListener("input", applyConds);
|
|
318
|
+
applyConds();
|
|
319
|
+
|
|
320
|
+
form.addEventListener("submit", async (e) => {
|
|
321
|
+
e.preventDefault();
|
|
322
|
+
// A `checkboxes` group posts one FormData entry PER checked box under the same
|
|
323
|
+
// name; assigning them one at a time kept only the last. Collect repeats into
|
|
324
|
+
// an array, which is the shape the API and every other renderer expect.
|
|
325
|
+
const data = {};
|
|
326
|
+
new FormData(form).forEach((v, k) => {
|
|
327
|
+
// Turnstile's token is a protocol field, not a form answer — the API reads it
|
|
328
|
+
// from the envelope. Leaving it inside `data` stored it as a submission column.
|
|
329
|
+
if (k === "cf-turnstile-response") return;
|
|
330
|
+
if (k in data) data[k] = Array.isArray(data[k]) ? [...data[k], v] : [data[k], v];
|
|
331
|
+
else data[k] = v;
|
|
332
|
+
});
|
|
333
|
+
// Compose AFTER the FormData loop. A showIf pointing at a phone key must keep comparing
|
|
334
|
+
// the raw digits, and applyConds runs on every input event.
|
|
335
|
+
form.querySelectorAll('select[name$="__country"]').forEach((sel) => {
|
|
336
|
+
const k = sel.name.slice(0, -9);
|
|
337
|
+
const o = sel.options[sel.selectedIndex];
|
|
338
|
+
data[k] = bcmsPhone(o ? (o.getAttribute("data-cc") || "") : "", data[k], o ? o.getAttribute("data-trunk") === "1" : false);
|
|
339
|
+
delete data[k + "__country"];
|
|
340
|
+
});
|
|
341
|
+
const tsEl = form.querySelector('[name="cf-turnstile-response"]');
|
|
342
|
+
const body = { data };
|
|
343
|
+
if (tsEl?.value) body["cf-turnstile-response"] = tsEl.value;
|
|
344
|
+
try {
|
|
345
|
+
const res = await fetch(form.action, {
|
|
346
|
+
method: "POST",
|
|
347
|
+
headers: { "Content-Type": "application/json" },
|
|
348
|
+
body: JSON.stringify(body),
|
|
349
|
+
});
|
|
350
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
351
|
+
if (redirect) { window.location.href = redirect; return; }
|
|
352
|
+
form.reset();
|
|
353
|
+
applyConds();
|
|
354
|
+
if (msg) { msg.textContent = successMsg; msg.hidden = false; }
|
|
355
|
+
} catch {
|
|
356
|
+
if (msg) { msg.textContent = "Something went wrong. Please try again."; msg.hidden = false; }
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
</script>
|