/**
* @file Shared contact-form behavior for the intake form and its dialog copy.
*
* Provides:
* - inline validation with accessible error messages and an error summary;
* - a live character count for the message box;
* - automatic ticking of the "preferred contact method" boxes;
* - spam protection: a honeypot field, a 2-second time-trap, and a Cloudflare
* Turnstile widget whose token `netlify/functions/submission-created.mjs`
* verifies server-side (see CLAUDE.md, "Forms & spam protection");
* - an AJAX submit to Netlify Forms, with success and error states.
*
* This module sets up the inline `#intake-form` itself as soon as it's
* imported (see the bottom of the file), and `modal.js` imports
* {@link initContactForm} to set up the dialog's cloned copy.
*
* Without JavaScript the form still works: it POSTs natively to `/thanks`,
* which Netlify records and then serves `index.html` for.
*
* ## Field ids
*
* Ids are prefixed per form instance — `in-` for the inline form, `cf-` for
* the dialog copy (e.g. `in-name`, `cf-name`). Each validated field `X` has an
* error element `X-err`.
*
* @module contact-form
*/
/**
* Options for {@link initContactForm}. All optional; each falls back to
* looking inside/near the form.
*
* @typedef {Object} ContactFormOptions
* @property {HTMLElement} [errorSummary] Box listing all errors at once.
* Default: `[data-contact-errors]` inside the form.
* @property {HTMLElement} [successPanel] Panel shown after a successful send.
* Default: `[data-contact-success]` beside the form.
* @property {HTMLButtonElement} [submitBtn] The submit button.
* Default: the form's `button[type="submit"]`.
* @property {function(): void} [onSuccess] Called after a successful send.
* @property {function(): void} [onReset] Called at the end of `reset()`.
*/
/**
* Handle returned by {@link initContactForm} for controlling a form.
*
* @typedef {Object} ContactFormController
* @property {HTMLFormElement} form The form being controlled.
* @property {function(): boolean} validate Runs validation, shows any errors,
* and returns `true` if the form is valid.
* @property {function(): void} stampOpened Restarts the spam time-trap clock
* (call when a dialog opens).
* @property {function(string=): void} setContext Sets the hidden `context`
* field (`'crisis'` by default, or e.g. `'referrer'`).
* @property {boolean} submitted Read-only: whether the success panel is showing.
* @property {boolean} dirty Read-only: whether the visitor has entered or
* changed anything (ignoring the privacy tick box and spam fields).
* @property {function(): void} reset Returns to a blank, visible form with no
* errors.
* @property {function(): void} showSuccess Shows the success panel without
* submitting (for the no-JS `/thanks` landing).
*/
// ---------------------------------------------------------------------------
// Cloudflare Turnstile script (shared by every form on the page)
// ---------------------------------------------------------------------------
/**
* Cloudflare's Turnstile script. `render=explicit` stops it rendering widgets
* on its own (each form renders and owns its widget), and `onload` names the
* global it calls once ready. The host is allowed by the site's CSP
* (`script-src` and `frame-src` in `index.html` and `netlify.toml`).
*/
const TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=chaTurnstileReady'; // Cloudflare's script URL
/** The in-flight or finished load of the script, or null before the first request. @type {Promise<void>|null} */
let turnstileLoad = null; // no load started yet
/**
* Loads Cloudflare's Turnstile script once, on demand — the first time someone
* starts filling a form in — rather than on every page view. Resolves when
* `window.turnstile` is ready. Rejects if the script can't load (blocked or
* offline), and the next call tries again.
*
* @returns {Promise<void>}
*/
function loadTurnstile() { // define loadTurnstile()
if (window.turnstile?.render) return Promise.resolve(); // already loaded: done
if (!turnstileLoad) { // no load in flight:
turnstileLoad = new Promise((resolve, reject) => { // start one that…
window.chaTurnstileReady = resolve; // …resolves when api.js calls the onload global
const script = document.createElement('script'); // create a <script>
script.src = TURNSTILE_SRC; // pointing at Cloudflare's api.js
script.async = true; // load without blocking parsing
script.onerror = () => { // if it fails to load:
script.remove(); // remove the dead tag
turnstileLoad = null; // let a later call try again
reject(new Error('Turnstile script failed to load')); // and report the failure
};
document.head.append(script); // add it to the page
});
}
return turnstileLoad; // the shared promise
}
/**
* Adds validation, spam protection and AJAX submission to a contact form.
*
* @param {HTMLFormElement|null} form The form to enhance.
* @param {ContactFormOptions} [opts={}] Optional elements and callbacks.
* @returns {ContactFormController|null} A controller, or `null` if no form
* was given.
*/
export function initContactForm(form, opts = {}) { // export initContactForm(form, opts)
if (!form) return null; // no form given: nothing to do
// -------------------------------------------------------------------------
// Elements and hidden fields
// -------------------------------------------------------------------------
const errorSummary = opts.errorSummary || form.querySelector('[data-contact-errors]'); // error summary box: passed in, or found in the form
const successPanel = // success panel:
opts.successPanel || form.parentElement?.querySelector('[data-contact-success]'); // passed in, or found beside the form
const submitBtn = opts.submitBtn || form.querySelector('button[type="submit"]'); // submit button: passed in, or found in the form
// Remember the button's authored label so it can be restored after "Sending…".
const submitLabel = submitBtn ? submitBtn.textContent : 'Send message'; // its current label (or a default)
// Hidden fields submitted with the form:
// context — which audience path the visitor came from (crisis / referrer)
// page — the page the form was sent from
// opened_at — when the form was shown (ms), for the spam time-trap
const contextField = form.querySelector('input[name="context"]'); // find the hidden context field
const pageField = form.querySelector('input[name="page"]'); // find the hidden page field
// Create the opened_at field if the markup doesn't include one.
let openedAtField = form.querySelector('input[name="opened_at"]'); // find the hidden opened_at field
if (!openedAtField) { // not there?
openedAtField = document.createElement('input'); // create an <input>
openedAtField.type = 'hidden'; // make it hidden
openedAtField.name = 'opened_at'; // name it opened_at
form.appendChild(openedAtField); // add it to the form
}
openedAtField.value = String(Date.now()); // record when the form was set up
if (pageField) pageField.value = location.pathname; // record which page the form is on
// A link such as /index.html?context=referrer#intake (For professionals page) pre-sets the audience path.
const urlContext = new URLSearchParams(location.search).get('context'); // read ?context= from the URL
if (contextField && (urlContext === 'referrer' || urlContext === 'crisis')) contextField.value = urlContext; // accept only known values
// Field-id prefix, e.g. "cf" (modal) or "in" (inline).
const prefix = (form.elements.name?.id || 'cf-name').replace(/-name$/, ''); // take the name field's id and strip "-name" to get the prefix
/** Ids of every field that can show an error, e.g. `in-name`, `in-email`… */
const errIds = ['name', 'email', 'phone', 'consent', 'turnstile'].map((k) => `${prefix}-${k}`); // build the id for each validated field
// Tie each field to its error span, so a screen reader announces the message
// whenever focus lands on an invalid field (not only via the error summary).
// Appends `X-err` to the field's existing aria-describedby list (once).
errIds.forEach((id) => { // for each validated field id:
const input = document.getElementById(id); // find the field
if (!input || !document.getElementById(`${id}-err`)) return; // no field or no error element: skip
const ids = (input.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean); // its current aria-describedby ids as a list
if (!ids.includes(`${id}-err`)) ids.push(`${id}-err`); // add the error element's id if missing
input.setAttribute('aria-describedby', ids.join(' ')); // write the list back
});
// -------------------------------------------------------------------------
// Message character count
// -------------------------------------------------------------------------
// Message length: a visible "n of 600 characters" count, plus a polite screen-reader note
// only at 50, 20 and 0 characters left (not on every keystroke).
/** The message textarea. @type {HTMLTextAreaElement|undefined} */
const messageField = form.elements.message; // find the message textarea
/** Visible "(n of 600 characters)" text. @type {HTMLElement|null} */
const countEl = document.getElementById(`${prefix}-message-count`); // find the visible count
/** Visually hidden live region for screen-reader milestones. @type {HTMLElement|null} */
const countLive = document.getElementById(`${prefix}-message-count-live`); // find the screen-reader live region
/**
* Refreshes the character count.
*
* - `is-near` class when 50 or fewer characters remain;
* - removes `is-over` once there's room again;
* - announces "50 / 20 characters left" and "Character limit reached"
* exactly at those points.
*
* The limit comes from the textarea's `maxlength` (600 if missing).
*
* @returns {void}
*/
function updateCount() { // define updateCount()
if (!messageField || !countEl) return; // no textarea or count element: stop
const max = Number(messageField.getAttribute('maxlength')) || 600; // the character limit
const used = messageField.value.length; // characters typed so far
const left = max - used; // characters remaining
countEl.textContent = `(${used}\u00a0of\u00a0${max} characters)`; // show "(n of max characters)"; it may only wrap before "characters"
countEl.classList.toggle('is-near', left <= 50); // highlight when 50 or fewer remain
if (left > 0) countEl.classList.remove('is-over'); // room left again: clear the over-limit highlight
if (countLive && [50, 20, 0].includes(left)) { // at exactly 50, 20 or 0 remaining:
countLive.textContent = left === 0 ? 'Character limit reached.' : `${left} characters left.`; // announce it to screen readers
}
}
// Recount on every edit.
messageField?.addEventListener('input', updateCount); // recount whenever the text changes
// maxlength silently swallows the overflow (and fires no input event when it blocks a
// keystroke outright), so catch the attempt here: a typed character, line break or paste
// that would take the message past the limit turns the count Coral until it's trimmed.
messageField?.addEventListener('beforeinput', (e) => { // just before any edit is applied:
// Only insertions can push past the limit (not deletions).
if (!countEl || !e.inputType.startsWith('insert')) return; // no count element, or not an insertion: stop
const max = Number(messageField.getAttribute('maxlength')) || 600; // the character limit
// Selected text will be replaced, so it doesn't count toward the total.
const selected = messageField.selectionEnd - messageField.selectionStart; // characters currently selected
// Typed text is in e.data; pasted/dropped text in dataTransfer; a line
// break (insertLineBreak/insertParagraph) has neither, so count it as 1.
const added = (e.data ?? e.dataTransfer?.getData('text/plain') ?? '\n').length; // length of the text being inserted
if (messageField.value.length - selected + added > max) countEl.classList.add('is-over'); // would it go over? highlight the count
});
// Initial count (the field may be pre-filled, e.g. by the browser).
updateCount(); // set the count right away
// -------------------------------------------------------------------------
// Preferred contact method
// -------------------------------------------------------------------------
// Preferred contact method: a ticked method needs its matching detail.
/**
* Whether the visitor has ticked a given preferred contact method.
*
* @param {'Email'|'Phone'} method The checkbox value to look for.
* @returns {boolean} `true` if that box is ticked.
*/
const prefers = (method) => !!form.querySelector(`input[name="preferred"][value="${method}"]:checked`); // true if that method's checkbox is ticked
// Filling in an email or phone ticks its matching method; clearing it unticks,
// so an emptied field can't leave a stale preference that fails validation.
// Only an empty ↔ filled transition touches the box, so a visitor who unticks
// one isn't overruled on every keystroke. Autofill is why this listens so
// widely: Safari and some password managers fire `input` but not `change`, and
// some extensions set the value with no event at all — so it also re-syncs
// when focus moves within the form and just before validation.
/**
* Each contact field paired with its "preferred method" checkbox.
* `filled` records whether the field had a value at the last sync, so only
* an empty ↔ filled change touches the checkbox.
*
* @type {Array<{input: HTMLInputElement, box: HTMLInputElement, filled: (boolean|undefined)}>}
*/
const contactPairs = [['email', 'Email'], ['phone', 'Phone']] // for the email and phone fields…
.map(([field, method]) => ({ // …build a pair of…
input: form.elements[field], // …the text field…
box: form.querySelector(`input[name="preferred"][value="${method}"]`), // …and its preferred-method checkbox…
}))
.filter((p) => p.input && p.box); // …keeping only pairs where both exist
contactPairs.forEach((p) => { // for each pair:
p.filled = !!p.input.value.trim(); // note whether the field starts filled
});
/**
* Ticks a contact-method box when its field goes from empty to filled, and
* unticks it when the field is cleared. Leaves the box alone otherwise, so a
* visitor's manual untick isn't overridden while they type.
*
* @returns {void}
*/
function syncPreferred() { // define syncPreferred()
contactPairs.forEach((p) => { // for each pair:
const filled = !!p.input.value.trim(); // is the field filled now?
if (filled !== p.filled) p.box.checked = filled; // if that changed, tick or untick the box to match
p.filled = filled; // remember the new state
});
}
contactPairs.forEach((p) => { // for each pair:
p.input.addEventListener('input', syncPreferred); // sync as the visitor types
p.input.addEventListener('change', syncPreferred); // sync when the value is committed
});
form.addEventListener('focusin', syncPreferred); // sync whenever focus moves within the form
// -------------------------------------------------------------------------
// Cloudflare Turnstile
// -------------------------------------------------------------------------
// The form carries a `[data-turnstile]` placeholder (site key and action in
// its data attributes) and a hidden `cf-turnstile-response` field, which
// Netlify registers at deploy time like any other field. The whole field is
// authored `hidden`: it appears, and Cloudflare's script is fetched (see
// loadTurnstile), only once the visitor starts filling the form in. The
// widget is rendered explicitly, so this form owns its widget id — the
// dialog's clone reveals and renders one of its own. The widget's callback
// writes the token into the hidden field; the submit handler waits for it if
// it hasn't arrived yet. Tokens are single-use, so the widget is reset after
// a failed send and on reset().
// netlify/functions/submission-created.mjs verifies the token with
// Cloudflare: a missing or bad token means no email and no Airtable row.
// A form without a placeholder sends as before. With one, the send waits for
// a token and stops (with a message) if none comes, since the function would
// drop the submission anyway.
/** The widget placeholder, or null when Turnstile isn't on this form. @type {HTMLElement|null} */
const captchaEl = form.querySelector('[data-turnstile]'); // find the placeholder
/** The field around it — authored `hidden`, revealed on the first edit. @type {HTMLElement|null} */
const captchaWrap = captchaEl?.closest('[data-turnstile-field]') || captchaEl; // the field to reveal, or the placeholder itself
/** The hidden field that carries the token to Netlify. @type {HTMLInputElement|null} */
const captchaField = form.querySelector('input[name="cf-turnstile-response"]'); // find the token field
/** Whether the check is showing (set by the visitor's first edit, cleared by reset). */
let captchaShown = false; // still hidden
/** The rendered widget's id (from `turnstile.render`), or null until rendered. @type {string|null} */
let captchaId = null; // not rendered yet
/** Whether a render is in progress (waiting for the script). */
let captchaStarting = false; // no render started
/** The theme the widget was rendered with, to re-render on a theme change. */
let captchaThemeShown = ''; // nothing rendered yet
/** Why the widget can't produce a token: '' while it's fine, else an error code or 'load'. */
let captchaFailure = ''; // no failure
/** Sends waiting for a token (see {@link captchaToken}). @type {Array<function(string): void>} */
let captchaWaiters = []; // nobody waiting
/**
* Stores a token (or `''` on expiry, error, or reset) in the hidden field and
* hands it to any send that is waiting for one.
*
* @param {string} token The token from the widget, or `''`.
* @returns {void}
*/
function setCaptchaToken(token) { // define setCaptchaToken(token)
if (captchaField) captchaField.value = token; // store it in the hidden field
if (token) clearError(`${prefix}-turnstile`); // a fresh token clears any earlier error
const waiters = captchaWaiters; // take the current waiters…
captchaWaiters = []; // …leaving an empty list…
waiters.forEach((resolve) => resolve(token)); // …and give each the token
}
/**
* Reveals the security check. Called on the visitor's first edit (and by
* {@link renderCaptcha}, since a widget has to be visible to be measured and
* to be solved).
*
* @returns {void}
*/
function showCaptcha() { // define showCaptcha()
captchaShown = true; // remember that it's out
if (captchaWrap) captchaWrap.hidden = false; // reveal the field
}
/**
* Puts the check away again, back to the state a visitor who hasn't typed
* anything sees. The widget itself stays rendered, ready for the next edit.
*
* @returns {void}
*/
function hideCaptcha() { // define hideCaptcha()
captchaShown = false; // it's away again
if (captchaWrap) captchaWrap.hidden = true; // hide the field
}
/**
* The widget theme matching the site's `data-theme` on `<html>`. With no
* forced theme the site follows the OS, and so does Turnstile's `auto`.
*
* @returns {string} `'light'`, `'dark'`, or `'auto'`.
*/
function captchaTheme() { // define captchaTheme()
const theme = document.documentElement.getAttribute('data-theme'); // the forced theme, if any
return theme === 'dark' || theme === 'light' ? theme : 'auto'; // a forced theme, else auto
}
/**
* Reveals the check and renders this form's widget once Cloudflare's script
* is available, loading it if needed. Safe to call repeatedly: after the
* first call only the reveal repeats (until a load failure, after which a
* later call tries the render again).
*
* @returns {void}
*/
function renderCaptcha() { // define renderCaptcha()
if (!captchaEl || !captchaEl.isConnected) return; // no placeholder, or detached: stop
showCaptcha(); // out it comes — a hidden widget has no width to size itself by
if (captchaId !== null || captchaStarting) return; // already rendered or in progress: nothing more to do
captchaStarting = true; // mark the render as started
loadTurnstile().then(() => { // once the script is ready:
captchaStarting = false; // the render is no longer pending
if (captchaId !== null || !captchaEl.isConnected) return; // rendered meanwhile, or detached: stop
captchaThemeShown = captchaTheme(); // remember the theme used
captchaId = window.turnstile.render(captchaEl, { // render the widget with…
sitekey: captchaEl.dataset.sitekey, // …the public site key from the markup
action: captchaEl.dataset.action || 'intake', // …the action the function expects
theme: captchaThemeShown, // …the site's theme
// …full width (300px minimum, 65px tall), or the 150px-wide compact
// box where the card is narrower than that (a 320–375px phone)
size: captchaEl.clientWidth && captchaEl.clientWidth < 300 ? 'compact' : 'flexible',
'response-field': false, // …no hidden field of its own: the authored one carries the token
'refresh-expired': 'auto', // …a fresh token when one expires (after 5 minutes)
callback: (token) => { // when the visitor passes:
captchaFailure = ''; // clear any earlier failure
setCaptchaToken(token); // store the token
},
'expired-callback': () => setCaptchaToken(''), // token expired: clear it (the widget refreshes)
'timeout-callback': () => setCaptchaToken(''), // interactive challenge timed out: clear it
'error-callback': (code) => { // the widget failed (network, unknown domain, …):
captchaFailure = String(code || 'error'); // remember why
setCaptchaToken(''); // no token
},
});
}).catch(() => { // the script didn't load:
captchaStarting = false; // allow a later retry
captchaFailure = 'load'; // remember why
setCaptchaToken(''); // release any waiting send with no token
});
}
// Show and render the check on the visitor's first edit — the first `input`
// or `change` from a real field — and at no other moment: not on page load,
// not when the form scrolls into view, not on focus, not when the dialog
// opens. Someone who only reads the form never meets a bot check and never
// fetches Cloudflare's script; someone who starts writing gets the check
// while they finish the rest of the fields, well before they press send
// (and the send waits for the token anyway — see captchaToken).
if (captchaEl) { // only with a placeholder:
/**
* Reveals and renders the widget the first time the visitor puts something
* into the form. Ignores our own two hidden fields, so neither the
* honeypot nor the token write can bring the check out by itself.
*
* @param {Event} event An `input` or `change` event from within the form.
* @returns {void}
*/
const onFirstEntry = (event) => { // define onFirstEntry(event)
if (captchaShown) return; // already out: nothing to do
const name = /** @type {Element} */ (event.target)?.getAttribute?.('name') || ''; // the control's name, if it has one
if (name === 'company' || name === 'cf-turnstile-response') return; // honeypot or the token field: not the visitor typing
renderCaptcha(); // show the check and start loading Cloudflare's script
};
form.addEventListener('input', onFirstEntry); // typing in a text field or textarea
form.addEventListener('change', onFirstEntry); // choosing in a select, or ticking a box
// A theme change in Site preferences re-renders the widget to match
// (Turnstile can't restyle a rendered widget). Any token is dropped;
// the widget issues a new one.
document.addEventListener('cha:prefs', () => { // when a preference changes:
if (captchaId === null || captchaTheme() === captchaThemeShown) return; // not rendered, or same theme: nothing to do
try { // the widget may already be gone
window.turnstile.remove(captchaId); // remove the old widget
} catch { /* nothing to remove */ }
captchaId = null; // forget it
setCaptchaToken(''); // drop its token
renderCaptcha(); // render again in the new theme
});
}
/**
* This form's token, waiting up to `timeoutMs` for the widget to produce
* one. Resolves to `''` when the form has no widget, the widget has failed,
* or the wait times out — the caller decides what that means.
*
* @param {number} [timeoutMs=15000] How long to wait for a token.
* @returns {Promise<string>}
*/
function captchaToken(timeoutMs = 15000) { // define captchaToken(timeoutMs)
if (!captchaEl) return Promise.resolve(''); // no widget on this form: nothing to wait for
renderCaptcha(); // make sure it's rendering (a no-op if it already is)
if (captchaField?.value) return Promise.resolve(captchaField.value); // token already there: use it
if (captchaFailure) return Promise.resolve(''); // the widget has failed: don't wait
return new Promise((resolve) => { // otherwise wait for the callback:
const done = (token) => { // when a token (or '') arrives:
clearTimeout(timer); // cancel the timeout
resolve(token); // hand it over
};
const timer = setTimeout(() => { // if nothing arrives in time:
captchaWaiters = captchaWaiters.filter((w) => w !== done); // stop waiting
done(''); // report no token
}, timeoutMs); // after timeoutMs
captchaWaiters.push(done); // register as a waiter
});
}
/**
* Drops this form's token and asks the widget for a fresh one. Tokens are
* single-use, so this runs after a failed send and on reset.
*
* @returns {void}
*/
function resetCaptcha() { // define resetCaptcha()
if (!captchaEl) return; // no widget: nothing to reset
setCaptchaToken(''); // drop the token
if (captchaId === null) return; // not rendered: nothing more to do
try { // the widget may be gone
window.turnstile.reset(captchaId); // ask for a new token
} catch { /* nothing to reset */ }
}
// -------------------------------------------------------------------------
// Validation
// -------------------------------------------------------------------------
/**
* A validation error, as collected by {@link validate}.
*
* @typedef {Object} FieldError
* @property {string} id Id of the invalid field.
* @property {string} message Message shown to the visitor.
*/
/**
* Marks a field invalid and shows its error message.
*
* @param {string} id The field's id (its error element is `${id}-err`).
* @param {string} message Message to display.
* @returns {FieldError} The error, for collecting into a list.
*/
function setError(id, message) { // define setError(id, message)
const input = document.getElementById(id); // find the field
const err = document.getElementById(`${id}-err`); // find its error element
if (input) input.setAttribute('aria-invalid', 'true'); // mark the field invalid for assistive tech
if (err) { // if there's an error element:
err.textContent = message; // write the message into it
err.hidden = false; // show it
}
return { id, message }; // return the error for the caller's list
}
/**
* Marks a field invalid without showing a message of its own (used when one
* message covers two fields).
*
* @param {string} id The field's id.
* @returns {void}
*/
function markInvalid(id) { // define markInvalid(id)
document.getElementById(id)?.setAttribute('aria-invalid', 'true'); // mark the field invalid, if it exists
}
/**
* Clears a field's invalid state and hides its error message.
*
* @param {string} id The field's id.
* @returns {void}
*/
function clearError(id) { // define clearError(id)
const input = document.getElementById(id); // find the field
const err = document.getElementById(`${id}-err`); // find its error element
if (input) input.removeAttribute('aria-invalid'); // remove the invalid mark
if (err) { // if there's an error element:
err.textContent = ''; // empty it
err.hidden = true; // hide it
}
}
/**
* Checks the whole form, showing an error beside each problem field and a
* summary at the top (which receives focus, so screen-reader users hear it).
*
* Rules:
* - a name is required;
* - an email, if given, must look like an email address;
* - at least one of email or phone is required;
* - a ticked preferred method needs its matching detail;
* - the privacy-notice box must be ticked.
*
* The Turnstile token isn't checked here: it arrives asynchronously, so the
* submit handler waits for it once validation has passed.
*
* @returns {boolean} `true` if there are no errors.
*/
function validate() { // define validate()
/** @type {FieldError[]} */
const errors = []; // start with no errors
syncPreferred(); // catch an autofill that fired no events
errIds.forEach(clearError); // clear every previous error
const name = form.elements.name.value.trim(); // trimmed name
const email = form.elements.email.value.trim(); // trimmed email
const phone = form.elements.phone.value.trim(); // trimmed phone
const consent = form.elements.consent.checked; // privacy box ticked?
if (!name) errors.push(setError(`${prefix}-name`, 'Enter your name.')); // no name: error
if (email && !EMAIL_RE.test(email)) // email given but malformed?
errors.push(setError(`${prefix}-email`, 'That email address doesn’t look right.')); // error on email
if (!email && !phone) { // neither email nor phone?
errors.push(setError(`${prefix}-phone`, 'Enter an email address or a phone number.')); // error on phone
markInvalid(`${prefix}-email`); // and flag email too
}
// Skip the preferred-method messages if that field already has an error.
if (prefers('Phone') && !phone && !errors.some((er) => er.id === `${prefix}-phone`)) // Phone ticked but no number (and no phone error yet)?
errors.push(setError(`${prefix}-phone`, 'You chose phone as your contact method. Add a phone number, or untick Phone.')); // error on phone
if (prefers('Email') && !email && !errors.some((er) => er.id === `${prefix}-email`)) // Email ticked but no address (and no email error yet)?
errors.push(setError(`${prefix}-email`, 'You chose email as your contact method. Add an email address, or untick Email.')); // error on email
if (!consent) // privacy box not ticked?
errors.push(setError(`${prefix}-consent`, 'Confirm you’ve read the Privacy Notice.')); // error on the box
if (errors.length && errorSummary) { // errors, and there's a summary box:
// Build the summary: one link per error, pointing at its field.
// Messages are fixed strings from this file, so innerHTML is safe here.
errorSummary.innerHTML = // fill the summary with…
'<strong>Check the form:</strong><ul style="margin:.4rem 0 0;padding-left:1.1rem">' + // …a heading and the start of a list…
errors.map((er) => `<li><a href="#${er.id}">${er.message}</a></li>`).join('') + // …one linked item per error…
'</ul>'; // …and the end of the list
errorSummary.hidden = false; // show the summary
// Make the summary focusable by script and move focus to it.
errorSummary.setAttribute('tabindex', '-1'); // make it focusable by script
errorSummary.focus(); // focus it so it's announced
// Clicking an error link focuses the field directly (rather than
// following the "#id" link, which would scroll the page and change the URL).
errorSummary.querySelectorAll('a').forEach((a) => { // for each link in the summary:
a.addEventListener('click', (ev) => { // when it's clicked:
ev.preventDefault(); // stop the default jump
document.getElementById(a.getAttribute('href').slice(1))?.focus(); // focus the matching field
});
});
} else if (errorSummary) { // no errors, but there's a summary box:
errorSummary.hidden = true; // hide it
}
return errors.length === 0; // true if valid
}
// Re-check a flagged field when the visitor leaves it: clear the error only once
// the value is actually fixed, so tabbing through never wipes a live error.
/**
* Loose email check: something@something.something, with no spaces. Real
* verification happens when someone replies.
*
* Declared after validate() but before any call to it, so it's always
* initialised by the time validate() runs.
*
* @type {RegExp}
*/
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // pattern: no-space text, @, no-space text, dot, no-space text
/**
* Re-validates a single field after the visitor edits it, clearing its
* error only if the problem is now fixed. Never adds new errors — that
* only happens on submit.
*
* Email, phone and the preferred-method boxes depend on each other, so a
* change to any of them re-checks both the email and phone errors.
*
* @param {HTMLInputElement|HTMLTextAreaElement} el The field that changed.
* @returns {void}
*/
function recheck(el) { // define recheck(el)
const email = form.elements.email.value.trim(); // trimmed email
const phone = form.elements.phone.value.trim(); // trimmed phone
const hasContact = !!(email || phone); // is at least one contact detail given?
const emailOk = !email || EMAIL_RE.test(email); // is the email empty or well-formed?
switch (el.name) { // depending on which field changed:
case 'name': // name field:
if (el.value.trim()) clearError(el.id); // clear its error if it now has a value
break; // done
case 'email': // email field,
case 'phone': // phone field,
case 'preferred': // or a preferred-method box:
if (hasContact && (!prefers('Phone') || phone)) clearError(`${prefix}-phone`); // phone requirement met: clear the phone error
if (hasContact && emailOk && (!prefers('Email') || email)) clearError(`${prefix}-email`); // email requirement met: clear the email error
break; // done
case 'consent': // privacy box:
if (el.checked) clearError(el.id); // clear its error if it's now ticked
break; // done
}
}
// Checkboxes re-check on change; text fields when focus leaves them. Only
// fields already flagged are re-checked — plus the interdependent contact
// fields, whose errors may sit on a different field.
form.querySelectorAll('input, textarea').forEach((el) => { // for each input and textarea:
const evt = el.type === 'checkbox' ? 'change' : 'blur'; // checkboxes use change; others use blur
el.addEventListener(evt, () => { // on that event:
if (el.getAttribute('aria-invalid') === 'true' || ['email', 'phone', 'preferred'].includes(el.name)) recheck(el); // re-check if it's flagged or a contact-related field
});
});
// -------------------------------------------------------------------------
// Submit → Netlify Forms
// -------------------------------------------------------------------------
/** Minimum time (ms) between the form opening and it being sent. */
const MIN_FILL_MS = 2000; // two seconds
/** Timer id of a send held back by the time-trap, or null. @type {number|null} */
let deferredSubmit = null; // no send held back yet
form.addEventListener('submit', async (e) => { // when the form is submitted:
// Always handle the submit here instead of letting the browser post.
e.preventDefault(); // stop the browser's own submission
// Honeypot: bots fill the hidden field — drop silently.
if (form.elements.company && form.elements.company.value !== '') return; // honeypot field filled: silently ignore
// Time trap: a person using autofill can beat 2 s too, so instead of
// silently discarding, validate now and hold the send until the window has
// passed (bots rarely wait; people see "Sending…" for a moment).
const elapsed = Date.now() - Number(openedAtField.value || 0); // milliseconds since the form opened
if (elapsed < MIN_FILL_MS) { // submitted too quickly?
// Invalid, or a held send is already queued: do nothing more.
if (!validate() || deferredSubmit) return; // stop if invalid or already waiting
if (submitBtn) { // if there's a submit button:
submitBtn.disabled = true; // disable it
submitBtn.textContent = 'Sending…'; // show "Sending…"
}
// Re-submit once the window has passed (+50 ms margin). requestSubmit()
// fires a real submit event, so this handler runs again; the fallback
// dispatches one manually for older browsers.
deferredSubmit = setTimeout(() => { // schedule a re-submit that:
deferredSubmit = null; // clears the held-send marker
if (submitBtn) submitBtn.disabled = false; // re-enables the button
form.requestSubmit ? form.requestSubmit() : form.dispatchEvent(new Event('submit', { cancelable: true })); // submits the form again
}, MIN_FILL_MS - elapsed + 50); // …once two seconds have passed, plus 50 ms
return; // stop here for now
}
if (!validate()) return; // invalid: stop
// Show a busy state while sending.
if (submitBtn) { // if there's a submit button:
submitBtn.setAttribute('aria-busy', 'true'); // mark it busy for assistive tech
submitBtn.disabled = true; // disable it
submitBtn.textContent = 'Sending…'; // show "Sending…"
}
try { // the security check and the network request can both fail
// Turnstile: wait for the token (usually already there). Without one the
// function would drop the submission, so don't send — say so instead.
const token = await captchaToken(); // the token, or '' after a failure or timeout
if (captchaEl && !token) { // a widget, but no token?
setError(`${prefix}-turnstile`, 'The security check didn’t finish.'); // flag the widget
showFallback('The security check didn’t finish, so your message wasn’t sent.'); // and explain, with the contact details
return; // stop (finally restores the button)
}
// Netlify Forms expects a URL-encoded POST to any page path. FormData
// includes every field — hidden ones, the honeypot, the form-name, and
// the cf-turnstile-response field the widget's callback filled in.
const data = new FormData(form); // collect every field's value
const body = new URLSearchParams(); // create an empty URL-encoded body
for (const [k, v] of data.entries()) body.append(k, v); // copy each name/value pair into it
const res = await fetch('/', { // POST to the site root…
method: 'POST', // …as a POST…
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, // …URL-encoded, asking for a JSON reply…
body: body.toString(), // …with the encoded fields
});
if (!res.ok) throw new Error(`HTTP ${res.status}`); // server error: treat as a failure
// Success: swap the form for the success panel and focus it so the
// confirmation is announced.
form.hidden = true; // hide the form
if (errorSummary) errorSummary.hidden = true; // hide any error summary
if (successPanel) { // if there's a success panel:
successPanel.hidden = false; // show it
successPanel.setAttribute('tabindex', '-1'); // make it focusable by script
successPanel.focus(); // focus it so it's announced
}
opts.onSuccess?.(); // run the caller's success callback, if any
window.chaTrack?.('contact_submitted'); // no field data
} catch (err) { // on failure:
resetCaptcha(); // the token is single-use — hand the visitor a fresh challenge
showFallback('Something went wrong sending your message.'); // explain, with the contact details
} finally { // whether it worked or not:
// Either way, restore the submit button.
if (submitBtn) { // if there's a submit button:
submitBtn.removeAttribute('aria-busy'); // clear the busy state
submitBtn.disabled = false; // re-enable it
submitBtn.textContent = submitLabel; // restore its label
}
}
});
/**
* Shows a failure message in the error summary, followed by the phone number
* and email address (built by email.js, since neither may be written
* literally in the source), and moves focus to it so it's announced.
*
* @param {string} lead The sentence that says what went wrong. Plain text.
* @returns {void}
*/
function showFallback(lead) { // define showFallback(lead)
if (!errorSummary) return; // no summary box: nothing to show
errorSummary.innerHTML = // fill it with…
`${lead} Try again, call ` + // …the lead sentence and the first option…
'<a href="#intake" data-tel-area="757" data-tel-line="5740771">us</a> (Monday–Friday, 8:00 AM–5:00 PM ET), or email ' + // …a phone link placeholder…
'<a href="#intake" data-email-user="info" data-email-domain="coastalhealthcareadvocates.org">us</a>.'; // …and an email link placeholder
window.chaEmailLinks?.(errorSummary); // email.js turns the placeholders into the number and address
errorSummary.hidden = false; // show it
errorSummary.setAttribute('tabindex', '-1'); // make it focusable by script
errorSummary.focus(); // focus it so it's announced
}
// -------------------------------------------------------------------------
// Public controller
// -------------------------------------------------------------------------
return { // return the controller object:
form, // the form itself
validate, // the validate function
/** Re-baseline the time-trap (call when a modal opens). */
stampOpened() { // define stampOpened()
openedAtField.value = String(Date.now()); // reset the opened-at time to now
},
/**
* Set the hidden context field (crisis / referrer).
* @param {string} [ctx] The context value; defaults to `'crisis'`.
*/
setContext(ctx) { // define setContext(ctx)
if (contextField) contextField.value = ctx || 'crisis'; // store the context, defaulting to "crisis"
},
/** Whether the success panel is currently showing. */
get submitted() { // define the read-only submitted property
return form.hidden && !!successPanel && !successPanel.hidden; // true if the form is hidden and the success panel shown
},
/** Whether the visitor has typed or chosen anything (the privacy tick aside). */
get dirty() { // define the read-only dirty property
// Compare every control against its authored default.
return [...form.elements].some((el) => { // true if any control differs from its default:
// Ignore the privacy tick, the honeypot and the Turnstile token.
if (['consent', 'company', 'cf-turnstile-response'].includes(el.name)) return false; // skip the privacy box, honeypot and token
if (el.type === 'checkbox' || el.type === 'radio') return el.checked !== el.defaultChecked; // checkbox/radio: changed if its ticked state differs
if (el.tagName === 'SELECT') { // select box:
// The default option is the one marked `selected`, else the first.
const def = [...el.options].findIndex((o) => o.defaultSelected); // index of the default option (-1 if none)
return el.selectedIndex !== (def === -1 ? 0 : def); // changed if the selection differs from it
}
// Skip buttons, fieldsets, etc., and non-typed inputs.
if (el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT') return false; // not a text input or textarea: skip
if (['hidden', 'submit', 'button'].includes(el.type)) return false; // hidden or button inputs: skip
return el.value.trim() !== el.defaultValue.trim(); // text: changed if the trimmed value differs
});
},
/** Return to a blank, visible form, errors cleared (the modal calls this on open and close). */
reset() { // define reset()
if (deferredSubmit) { // a send is being held back?
// A held (time-trapped) send never went out — hand back a live button.
clearTimeout(deferredSubmit); // cancel it
deferredSubmit = null; // clear the marker
if (submitBtn) { // if there's a submit button:
submitBtn.disabled = false; // re-enable it
submitBtn.textContent = submitLabel; // restore its label
}
}
// Restore authored defaults, then bring the derived state in line.
form.reset(); // reset every field to its default
contactPairs.forEach((p) => { // for each contact pair:
p.filled = false; // mark the field as empty
});
updateCount(); // refresh the character count
errIds.forEach(clearError); // clear every error
resetCaptcha(); // drop the Turnstile token and ask for a fresh one
if (errorSummary) errorSummary.hidden = true; // hide the error summary
if (successPanel) successPanel.hidden = true; // hide the success panel
form.hidden = false; // show the form
hideCaptcha(); // put the security check away: a blank form hasn't earned one yet
opts.onReset?.(); // run the caller's reset callback, if any
},
/** Show the success state without a submit (the no-JS /thanks landing). */
showSuccess() { // define showSuccess()
form.hidden = true; // hide the form
if (successPanel) successPanel.hidden = false; // show the success panel
},
};
}
// ---------------------------------------------------------------------------
// Self-initialise the inline form
// ---------------------------------------------------------------------------
// Inline intake form: self-initialise on import when it's present on the page.
const inlineForm = document.getElementById('intake-form'); // find the inline form
if (inlineForm) { // only if it's on this page
const inline = initContactForm(inlineForm); // set it up
// A native (no-JS) POST lands on /thanks, which Netlify serves as index.html.
// If scripts are running on that URL, confirm the send instead of showing a
// blank form that invites a duplicate submission.
if (inline && location.pathname.replace(/\/$/, '') === '/thanks') { // set up and we're on /thanks?
inline.showSuccess(); // show the success panel
document.getElementById('intake')?.scrollIntoView({ block: 'start' }); // scroll the intake section into view
}
}