Skip to content
Coastal Healthcare Advocates

Source: contact-form.js

/**
 * @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
  }
}