@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.
@@ -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
- <form
81
- class={`bcms-form${className ? ` ${className}` : ""}`}
82
- method="post"
83
- action={action}
84
- data-bcms-form={form.id}
85
- data-bcms-form-success={form.successMessage ?? "Thanks!"}
86
- data-bcms-form-redirect={form.redirectUrl ?? ""}
87
- {...rest}
88
- >
89
- {form.fields?.map((f) => {
90
- const wrapAttrs = {
91
- "data-bcms-form-field": f.key,
92
- ...(f.showIf ? { "data-bcms-showif": JSON.stringify(f.showIf) } : {}),
93
- };
94
- // Every author-editable string carries a `data-bcms-form-part` so the Visual Editor can
95
- // repaint it live. A string the dock can edit but cannot mark shows stale on the canvas.
96
- const help = f.helpText
97
- ? <span class="bcms-field-help" data-bcms-form-part="help">{f.helpText}</span>
98
- : null;
99
- if (f.type === "hidden") return <input type="hidden" name={f.key} value={f.defaultValue ?? ""} />;
100
- if (f.type === "checkbox" || f.type === "consent")
101
- return (
102
- <label class="bcms-check" {...wrapAttrs}>
103
- <input type="checkbox" name={f.key} required={f.required} />
104
- {/* The ` *` marker is emitted for EVERY field type, by every renderer — the
105
- Visual Editor's live patch rewrites this span as `label + marker`, so a
106
- renderer that omitted it would drop the star on edit and grow one back on
107
- the next reload. */}
108
- <span data-bcms-form-part="label">{f.label}{f.required ? " *" : ""}</span>
109
- {help}
110
- </label>
111
- );
112
- // Pick-one / pick-many are option GROUPS. Without this branch both fell through
113
- // to the `<input type={INPUT_TYPE[f.type] ?? "text"}>` below and shipped as a
114
- // single-line text box on every Astro site.
115
- if (f.type === "radio" || f.type === "checkboxes") {
116
- const multi = f.type === "checkboxes";
117
- return (
118
- <fieldset class={`bcms-field bcms-field--${multi ? "checkboxes" : "radio"}`} {...wrapAttrs}>
119
- <legend data-bcms-form-part="label">{f.label}{f.required ? " *" : ""}</legend>
120
- {(f.options ?? []).map((o) => (
121
- <label class="bcms-option">
122
- {/* `required` on each radio is the native "pick one"; on checkboxes it
123
- would demand every box, so the API enforces non-empty instead. */}
124
- <input type={multi ? "checkbox" : "radio"} name={f.key} value={o} required={!multi && f.required} />
125
- <span data-bcms-form-part="option">{o}</span>
126
- </label>
127
- ))}
128
- {help}
129
- </fieldset>
130
- );
131
- }
132
- return (
133
- <div class="bcms-field" {...wrapAttrs}>
134
- <label for={`bcms-f-${f.key}`} data-bcms-form-part="label">{f.label}{f.required ? " *" : ""}</label>
135
- {f.type === "textarea" ? (
136
- <textarea id={`bcms-f-${f.key}`} name={f.key} placeholder={f.placeholder} required={f.required}>
137
- {f.defaultValue ?? ""}
138
- </textarea>
139
- ) : f.type === "select" ? (
140
- <select id={`bcms-f-${f.key}`} name={f.key} required={f.required}>
141
- {(f.options ?? []).map((o) => <option value={o} data-bcms-form-part="option">{o}</option>)}
142
- </select>
143
- ) : f.type === "phone" && f.ui?.countryPicker ? (
144
- /* A country select plus a tel input, submitting ONE E.164 value. `required` belongs
145
- on the number only — a select always has a value and would never block. */
146
- <span class="bcms-phone">
147
- <select class="bcms-phone-country" name={`${f.key}__country`} aria-label="Country code">
148
- <option value="" selected={!f.ui.defaultCountry}>International</option>
149
- {countryList().map((c) => (
150
- <option value={c.iso} data-cc={c.code} data-trunk={c.trunk ? "1" : undefined} selected={c.iso === f.ui!.defaultCountry}>
151
- {c.name} +{c.code}
152
- </option>
153
- ))}
154
- </select>
155
- <input
156
- type="tel"
157
- id={`bcms-f-${f.key}`}
158
- name={f.key}
159
- placeholder={f.placeholder}
160
- value={f.defaultValue}
161
- required={f.required}
162
- autocomplete="tel"
163
- dir="ltr"
164
- />
165
- </span>
166
- ) : (
167
- <input
168
- type={INPUT_TYPE[f.type] ?? "text"}
169
- id={`bcms-f-${f.key}`}
170
- name={f.key}
171
- placeholder={f.placeholder}
172
- value={f.defaultValue}
173
- required={f.required}
174
- />
175
- )}
176
- {help}
177
- </div>
178
- );
179
- })}
180
-
181
- {form.honeypotField && (
182
- <input type="text" name={form.honeypotField} tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true" />
183
- )}
184
-
185
- {showTurnstile && <div class="cf-turnstile" data-sitekey={turnstileSiteKey}></div>}
186
-
187
- <button type="submit">{form.submitLabel || "Submit"}</button>
188
- <p class="bcms-form-msg" hidden></p>
189
- </form>
190
-
191
- {showTurnstile && <script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>}
192
-
193
- <script is:inline>
194
- // Compose one E.164 value from a country-picker phone field. CANONICAL COPY is
195
- // PHONE_COMPOSE_JS in the backend's src/lib/sites/render-page.ts, and this file is pinned
196
- // against it byte for byte by src/__tests__/content/form-script-pin.test.ts. All three
197
- // vanilla lanes are inline script text on someone else's page and cannot import at runtime,
198
- // so the rule genuinely exists more than once.
199
- 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:'';}
200
-
201
- // One copy of this script ships per <BcmsForm> instance, and each copy sees EVERY
202
- // form on the page — so two forms meant four bindings and a double submit. The
203
- // `bcmsFormBound` flag makes binding idempotent no matter how many copies run,
204
- // and the success/redirect config now lives on the FORM rather than on the script
205
- // element, so form A's script can no longer apply form B's success message.
206
- document.querySelectorAll("form[data-bcms-form]").forEach((form) => {
207
- if (form.dataset.bcmsFormBound) return;
208
- form.dataset.bcmsFormBound = "1";
209
-
210
- const msg = form.querySelector(".bcms-form-msg");
211
- const successMsg = form.getAttribute("data-bcms-form-success") || "Thanks!";
212
- const redirect = form.getAttribute("data-bcms-form-redirect") || "";
213
-
214
- // Conditional fields: show/hide based on another field's value.
215
- const conds = form.querySelectorAll("[data-bcms-showif]");
216
- const applyConds = () => {
217
- conds.forEach((el) => {
218
- try {
219
- const { field, equals } = JSON.parse(el.getAttribute("data-bcms-showif"));
220
- const input = form.elements[field];
221
- // A radio/checkboxes group resolves to a RadioNodeList, which has no `.type`
222
- // and whose `.value` is "" for checkbox groups — read the checked members.
223
- let val;
224
- if (input instanceof RadioNodeList) {
225
- const checked = Array.from(input).filter((el) => el.checked).map((el) => el.value);
226
- val = checked.includes(equals) ? equals : checked[0];
227
- } else if (input && input.type === "checkbox") {
228
- val = input.checked ? "true" : "false";
229
- } else {
230
- val = input?.value;
231
- }
232
- el.style.display = val === equals ? "" : "none";
233
- } catch {}
234
- });
235
- };
236
- form.addEventListener("input", applyConds);
237
- applyConds();
238
-
239
- form.addEventListener("submit", async (e) => {
240
- e.preventDefault();
241
- // A `checkboxes` group posts one FormData entry PER checked box under the same
242
- // name; assigning them one at a time kept only the last. Collect repeats into
243
- // an array, which is the shape the API and every other renderer expect.
244
- const data = {};
245
- new FormData(form).forEach((v, k) => {
246
- // Turnstile's token is a protocol field, not a form answer — the API reads it
247
- // from the envelope. Leaving it inside `data` stored it as a submission column.
248
- if (k === "cf-turnstile-response") return;
249
- if (k in data) data[k] = Array.isArray(data[k]) ? [...data[k], v] : [data[k], v];
250
- else data[k] = v;
251
- });
252
- // Compose AFTER the FormData loop. A showIf pointing at a phone key must keep comparing
253
- // the raw digits, and applyConds runs on every input event.
254
- form.querySelectorAll('select[name$="__country"]').forEach((sel) => {
255
- const k = sel.name.slice(0, -9);
256
- const o = sel.options[sel.selectedIndex];
257
- data[k] = bcmsPhone(o ? (o.getAttribute("data-cc") || "") : "", data[k], o ? o.getAttribute("data-trunk") === "1" : false);
258
- delete data[k + "__country"];
259
- });
260
- const tsEl = form.querySelector('[name="cf-turnstile-response"]');
261
- const body = { data };
262
- if (tsEl?.value) body["cf-turnstile-response"] = tsEl.value;
263
- try {
264
- const res = await fetch(form.action, {
265
- method: "POST",
266
- headers: { "Content-Type": "application/json" },
267
- body: JSON.stringify(body),
268
- });
269
- if (!res.ok) throw new Error(String(res.status));
270
- if (redirect) { window.location.href = redirect; return; }
271
- form.reset();
272
- applyConds();
273
- if (msg) { msg.textContent = successMsg; msg.hidden = false; }
274
- } catch {
275
- if (msg) { msg.textContent = "Something went wrong. Please try again."; msg.hidden = false; }
276
- }
277
- });
278
- });
279
- </script>
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>