Skip to content
Coastal Healthcare Advocates

Source: pay.js

/**
 * @file Behavior for the payment page, `pay.html`.
 *
 * The page takes a payment in two steps, both authored in the page:
 *
 * 1. `#pay-form` — what the visitor is paying (an invoice by number, a fixed-price
 *    service, or another agreed amount), their name and email, and a Cloudflare
 *    Turnstile check that appears on their first edit. On submit it POSTs to
 *    `/.netlify/functions/pay`, which looks the amount up in Stripe and answers with
 *    a client secret (see `netlify/functions/pay.mjs`).
 * 2. `#pay-step-payment` — Stripe's Payment Element, mounted with that secret and
 *    styled from the site's own tokens and fonts, so card, Apple Pay and Google Pay
 *    sit inside the page's design. Card details go from the element to Stripe; this
 *    script never sees them.
 *
 * The result (`[data-pay-result]`) and every error (`[data-pay-error]`) are authored
 * in the page; this module only reveals the right one (The Three Layers Rule). The
 * two things it writes are data, not copy: the amount and what it's for, returned by
 * the function, and a decline reason from Stripe, each into an authored slot
 * (`[data-pay-slot]`).
 *
 * The button in an invoice email opens `pay.html?invoice=CHA-1001`. When the value looks
 * like an invoice number it is put in the invoice field; the visitor still gives their
 * name and email, and the function still checks the number against that email. The
 * email address is never carried in the URL.
 *
 * The invoice field carries its `CHA-` prefix as authored text beside the box, so the
 * visitor types only the digits; `wholeInvoiceNumber()` puts the prefix back before the
 * request, and takes a whole number pasted in just the same — including an older
 * invoice's own prefix (`MGK2PT28-0001`), which stands the `CHA-` aside.
 *
 * Stripe.js is loaded only when the payment step is reached, from
 * `https://js.stripe.com/endive/stripe.js` (the release matching the function's
 * `Stripe-Version`). A payment method that redirects comes back to this page with
 * `payment_intent_client_secret` in the URL, and the result is read from that.
 *
 * @module pay
 */

/** Stripe.js, version-pinned to the API release the function uses. */
const STRIPE_SRC = 'https://js.stripe.com/endive/stripe.js'; // Stripe's script URL
/** Cloudflare's Turnstile script, rendered explicitly into our placeholder. */
const TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=chaPayTurnstileReady'; // Cloudflare's script URL
/** The function that prices the payment and returns a client secret. */
const ENDPOINT = '/.netlify/functions/pay'; // same-origin function
/** How long a submit waits for a Turnstile token before going ahead without one. */
const TOKEN_WAIT_MS = 15000; // 15 seconds
/** The site's fonts, handed to the Payment Element by URL (it renders in Stripe's frame). */
const FONT_YSABEAU = '/assets/fonts/ysabeau-400.woff2'; // body face (stamped with ?v= at build)
const FONT_QUESTRIAL = '/assets/fonts/questrial-400.woff2'; // label face (stamped with ?v= at build)
/** The prefix every invoice we issue carries (Stripe numbers the account CHA-1001 and up). */
const INVOICE_PREFIX = 'CHA'; // the prefix authored beside the invoice field
/** A whole invoice number: Stripe's PREFIX-0001, the same shape netlify/functions/pay.mjs accepts. */
const INVOICE_RE = /^[A-Z0-9]{1,12}-\d{4,}$/; // matched against the normalized value

/**
 * Turns what's in the invoice field into a whole invoice number.
 *
 * The field shows `CHA-` beside it and asks for the digits alone, but a visitor who
 * pastes the number from their email gets the same answer, with or without the dash,
 * and an older invoice's own prefix is kept rather than replaced.
 *
 * @param {string} raw The field's value as typed or pasted.
 * @returns {string} The whole number in upper case, or the value unchanged when it
 *   isn't a number at all (the caller's test then fails it).
 */
function wholeInvoiceNumber(raw) { // define wholeInvoiceNumber(raw)
  const v = (raw || '').toUpperCase().replace(/\s+/g, '').replace(/-+/g, '-'); // letters up, spaces out, one dash
  if (/^\d{4,}$/.test(v)) return `${INVOICE_PREFIX}-${v}`; // the digits alone: our own prefix
  const m = v.match(/^([A-Z0-9]{1,12})-?(\d{4,})$/); // a whole number, dash optional
  return m ? `${m[1]}-${m[2]}` : v; // normalized, or back as typed to fail validation
}

/** The form, or null on any other page. @type {HTMLFormElement|null} */
const form = document.querySelector('[data-pay-form]'); // find the form
if (form) initPay(form); // only on pay.html

/**
 * Loads a classic script once and resolves when it has run.
 *
 * @param {string} src The script URL.
 * @returns {Promise<void>} Resolves on load, rejects on error.
 */
function loadScript(src) { // define loadScript(src)
  return new Promise((resolve, reject) => { // a promise that…
    const existing = document.querySelector(`script[src="${src}"]`); // …reuses a tag already added
    if (existing?.dataset.loaded) return resolve(); // already loaded: done
    const el = existing || document.createElement('script'); // the tag to watch
    el.addEventListener('load', () => { el.dataset.loaded = '1'; resolve(); }, { once: true }); // loaded: mark and resolve
    el.addEventListener('error', () => { el.remove(); reject(new Error(`failed to load ${src}`)); }, { once: true }); // failed: allow a retry
    if (!existing) { el.src = src; el.async = true; document.head.append(el); } // add it if it's new
  });
}

/**
 * Reads a CSS custom property from `<html>`, so the Payment Element follows the
 * theme the page is showing.
 *
 * @param {string} name The property, for example `'--ink'`.
 * @returns {string} Its computed value, trimmed.
 */
function token(name) { // define token(name)
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); // the computed value
}

/**
 * The Payment Element's appearance, built from the site's tokens: the same inks,
 * surface, line, focus ring and 8px field corners as the page's own form.
 *
 * @returns {Object} A Stripe Elements appearance object.
 */
function appearance() { // define appearance()
  const dark = token('--bg').toLowerCase() === '#0a0f24'; // Midnight ground means the dark theme
  return { // the appearance:
    theme: 'flat', // flat base, so our borders and fills decide the look
    variables: { // design tokens:
      colorPrimary: token('--cta-bg'), // Navy in light, Sky in dark, as .btn-primary
      colorBackground: token('--surface'), // field fill
      colorText: token('--ink'), // body ink
      colorTextSecondary: token('--ink-soft'), // hints and labels
      colorTextPlaceholder: token('--ink-soft'), // placeholders
      colorDanger: token('--coral-ink'), // errors
      colorIcon: token('--ink-soft'), // card-brand and field icons
      fontFamily: 'Ysabeau, system-ui, sans-serif', // the body face
      fontSizeBase: '17px', // readable for older visitors, as the page's fields
      fontLineHeight: '1.5', // matches the page
      borderRadius: '8px', // the page's field corners
      spacingUnit: '4px', // comfortable spacing
      focusBoxShadow: `0 0 0 3px ${token('--ring')}`, // the site's 3px focus ring
      focusOutline: 'none', // the ring replaces the outline
    },
    rules: { // component rules:
      '.Label': { fontFamily: 'Questrial, system-ui, sans-serif', fontSize: '15px', color: token('--ink'), marginBottom: '6px' }, // as .field-label
      '.Input': { border: `1px solid ${token('--ink-soft')}`, boxShadow: 'none', padding: '12px' }, // as .input
      '.Input:focus': { borderColor: token('--ink') }, // darker border on focus, as .input:focus
      '.Input--invalid': { borderColor: token('--coral'), borderWidth: '2px', boxShadow: 'none' }, // as [aria-invalid]
      '.AccordionItem': { border: `1px solid ${token('--line')}`, backgroundColor: token('--surface'), boxShadow: 'none' }, // each payment method's row
      '.AccordionItem--selected': { borderColor: token('--ink'), boxShadow: `0 0 0 1px ${token('--ink')}` }, // the chosen one
      '.Error': { fontSize: '14px', color: dark ? token('--coral-ink') : token('--ink') }, // ink text; the red border carries the signal
    },
  };
}

/**
 * The fonts the Payment Element may use, as absolute URLs (it renders on Stripe's
 * origin; netlify.toml sends the CORS header that allows this).
 *
 * @returns {Array<Object>} Stripe Elements font sources.
 */
function fonts() { // define fonts()
  return [ // two faces:
    { family: 'Ysabeau', src: `url(${new URL(FONT_YSABEAU, location.origin).href})`, weight: '400 600', display: 'swap' }, // body
    { family: 'Questrial', src: `url(${new URL(FONT_QUESTRIAL, location.origin).href})`, weight: '400', display: 'swap' }, // labels
  ];
}

/**
 * Formats an amount in the smallest currency unit, for the summary slot.
 *
 * @param {number} cents The amount, for example 22500.
 * @param {string} currency The ISO currency, for example `'usd'`.
 * @returns {string} For example `$225.00`.
 */
function money(cents, currency) { // define money(cents, currency)
  return new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.toUpperCase() }).format(cents / 100); // e.g. $225.00
}

/**
 * Sets up the payment page.
 *
 * @param {HTMLFormElement} formEl The `[data-pay-form]` form.
 * @returns {void}
 */
function initPay(formEl) { // define initPay(formEl)
  /** @type {HTMLElement} */
  const errors = formEl.querySelector('[data-pay-errors]'); // the error summary box
  const submitBtn = formEl.querySelector('[data-pay-submit]'); // "Continue to payment"
  const stepPay = document.querySelector('[data-pay-step="payment"]'); // the payment step
  const stepDone = document.querySelector('[data-pay-step="done"]'); // the result panel
  const confirmBtn = document.querySelector('[data-pay-confirm]'); // "Pay now"
  const confirmError = document.querySelector('[data-pay-confirm-error]'); // the decline box
  const backBtn = document.querySelector('[data-pay-back]'); // "Change what you're paying"
  const mount = document.querySelector('[data-pay-element]'); // where the Payment Element goes
  const slot = (name) => document.querySelector(`[data-pay-slot="${name}"]`); // a data slot by name

  const testBanner = document.querySelector('[data-pay-test]'); // "Test only", authored hidden

  // Say so up front when this is the sandbox. The function answers a plain GET with the
  // publishable key for the mode this deploy may use, and a sandbox key starts pk_test_, so
  // the page needs no list of hosts of its own. A failed request leaves the banner hidden:
  // the payment itself never starts without the same key.
  if (testBanner) { // only on a page that has one
    fetch(ENDPOINT).then((r) => r.json()).then(({ publishableKey: key }) => { // ask for the key
      if (String(key || '').startsWith('pk_test_')) testBanner.hidden = false; // sandbox: show it
    }).catch(() => {}); // can't tell: leave it hidden
  }

  let stripe = null; // the Stripe instance, once loaded
  let elements = null; // the Elements group for the current payment
  let paymentElement = null; // the mounted Payment Element
  let publishableKey = ''; // the key the function returned

  // --- Busy states: both labels are authored; swap which one shows ---------
  /**
   * Shows a button's busy label (or its idle one) and disables it while busy.
   *
   * @param {HTMLButtonElement} btn The button.
   * @param {boolean} busy Whether it's working.
   * @returns {void}
   */
  function setBusy(btn, busy) { // define setBusy(btn, busy)
    btn.disabled = busy; // no double submits
    btn.setAttribute('aria-busy', String(busy)); // tell assistive tech
    btn.querySelector('[data-pay-label="idle"]').hidden = busy; // idle label off while busy
    btn.querySelector('[data-pay-label="busy"]').hidden = !busy; // busy label on while busy
  }

  // --- Which kind of payment: show the matching panel ----------------------
  const panels = [...formEl.querySelectorAll('[data-pay-panel]')]; // the three panels
  /** The chosen kind: 'invoice', 'service', or 'amount'. */
  const kind = () => formEl.querySelector('input[name="kind"]:checked')?.value || 'invoice'; // the checked radio
  /**
   * Reveals the panel for the chosen kind and hides the others (their fields are
   * then left out of validation and the request).
   *
   * @returns {void}
   */
  function showPanel() { // define showPanel()
    panels.forEach((p) => { p.hidden = p.dataset.payPanel !== kind(); }); // only the chosen one
  }
  formEl.addEventListener('change', (e) => { if (e.target.name === 'kind') showPanel(); }); // follow the radios
  showPanel(); // match a radio the browser restored

  // --- The invoice field's CHA- prefix: shown unless the value brings its own -----
  const invoiceField = document.getElementById('pay-invoice'); // the number field
  const invoiceGroup = invoiceField?.closest('[data-invoice-group]'); // the box around it and the prefix
  /**
   * States whether the authored prefix still applies to what's in the field. CSS does
   * the rest: a value that carries a prefix of its own (a pasted `CHA-1001`, or an older
   * invoice's `MGK2PT28-0001`) would otherwise read as two prefixes.
   *
   * @returns {void}
   */
  function syncAffix() { // define syncAffix()
    if (!invoiceGroup) return; // not on this page
    invoiceGroup.dataset.affix = /[^\d\s]/.test(invoiceField.value) ? 'off' : 'on'; // anything but digits: stand aside
  }
  invoiceField?.addEventListener('input', syncAffix); // follow typing and pasting
  invoiceField?.addEventListener('change', syncAffix); // and an autofill

  // --- A link from an invoice email carries ?invoice=CHA-1001: fill the number in ---
  const linked = (new URLSearchParams(location.search).get('invoice') || '').trim(); // the number the button carried
  if (linked && invoiceField && !invoiceField.value && INVOICE_RE.test(wholeInvoiceNumber(linked))) { // only a number that looks like one
    const whole = wholeInvoiceNumber(linked); // normalized, so ?invoice=1001 works too
    // Our own numbers lose the prefix to the authored one; an older invoice keeps its own.
    invoiceField.value = whole.startsWith(`${INVOICE_PREFIX}-`) ? whole.slice(INVOICE_PREFIX.length + 1) : whole;
    // The link is an explicit ask to pay that invoice, so choose the panel as well: a radio the
    // browser restored from an earlier visit would otherwise hide the number just filled in.
    const invoiceRadio = formEl.querySelector('input[name="kind"][value="invoice"]'); // the first radio
    if (invoiceRadio && !invoiceRadio.checked) { invoiceRadio.checked = true; showPanel(); } // and reveal it
  }
  syncAffix(); // match a value the browser restored, or the one just filled in

  // --- Field errors: messages are authored; toggle them ---------------------
  /**
   * Marks a field invalid or valid and shows or hides its authored message.
   *
   * @param {string} id The field's id, for example `'pay-email'`.
   * @param {boolean} bad Whether it's invalid.
   * @returns {boolean} `bad`, for chaining.
   */
  function flag(id, bad) { // define flag(id, bad)
    const field = document.getElementById(id); // the control
    const msg = document.getElementById(`${id}-err`); // its message
    if (field) field.setAttribute('aria-invalid', String(bad)); // state for CSS and AT
    if (msg) msg.hidden = !bad; // show or hide the message
    if (field && msg) { // join or drop the message from the description
      const ids = new Set((field.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean)); // current ids
      if (bad) ids.add(msg.id); else ids.delete(msg.id); // add or remove the message
      field.setAttribute('aria-describedby', [...ids].join(' ')); // write them back
    }
    return bad; // pass the verdict on
  }

  /**
   * Shows one authored message in the error summary (or clears it), and moves
   * focus there so it's announced.
   *
   * @param {string} code A `[data-pay-error]` value, or `''` to clear.
   * @returns {void}
   */
  function showError(code) { // define showError(code)
    let found = false; // whether the code has a message
    errors.querySelectorAll('[data-pay-error]').forEach((p) => { // each authored message:
      const on = p.dataset.payError === code; // is it the one?
      p.hidden = !on; // show only that one
      found ||= on; // remember a match
    });
    if (code && !found) errors.querySelector('[data-pay-error="server"]').hidden = false; // unknown code: the general one
    errors.hidden = !code; // show or hide the box
    if (code) errors.focus(); // announce it
  }

  /**
   * Validates the visible fields, flags any problems, and returns the request
   * body, or null when something needs fixing.
   *
   * @returns {Object|null} The JSON body for the function, or null.
   */
  function collect() { // define collect()
    const get = (name) => (formEl.elements.namedItem(name)?.value || '').trim(); // a trimmed field value
    const k = kind(); // the chosen kind
    const body = { kind: k, name: get('name'), email: get('email') }; // the common fields
    let bad = false; // nothing wrong yet
    bad = flag('pay-name', !body.name) || bad; // a name is required
    bad = flag('pay-email', !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) || bad; // a plausible email
    if (k === 'invoice') { // paying an invoice:
      body.invoiceNumber = wholeInvoiceNumber(get('invoiceNumber')); // the digits, with the prefix put back
      bad = flag('pay-invoice', !INVOICE_RE.test(body.invoiceNumber)) || bad; // PREFIX-0001
    } else if (k === 'service') { // a fixed-price service:
      body.service = get('service'); // its lookup key
      bad = flag('pay-service', !body.service) || bad; // one must be chosen
    } else { // another amount:
      body.amount = get('amount'); // the amount as typed
      body.reference = get('reference'); // what it's for
      const n = Number(body.amount.replace(/[$,\s]/g, '')); // as a number
      bad = flag('pay-amount', !(n >= 1 && n <= 10000) || !/^\$?[\d,]+(\.\d{1,2})?$/.test(body.amount)) || bad; // $1–$10,000, cents optional
      bad = flag('pay-reference', !body.reference) || bad; // a reference is required
    }
    return bad ? null : body; // the body, or null to stop
  }

  // --- Cloudflare Turnstile: shown and loaded on the first edit -------------
  const captchaEl = formEl.querySelector('[data-turnstile]'); // the placeholder
  const captchaWrap = captchaEl?.closest('[data-turnstile-field]'); // its field (authored hidden)
  let captchaId = null; // the rendered widget's id
  let captchaToken = ''; // the latest token
  let captchaFailed = false; // the widget can't produce a token (unknown host, blocked, …)
  let waiters = []; // submits waiting for a token

  /**
   * Stores a token (or `''`) and releases any submit waiting for one.
   *
   * @param {string} t The token, or `''`.
   * @returns {void}
   */
  function setToken(t) { // define setToken(t)
    captchaToken = t; // keep it
    const w = waiters; waiters = []; // take the waiters
    w.forEach((resolve) => resolve(t)); // hand each the token
  }

  /**
   * Reveals the check and renders the widget, loading Cloudflare's script first.
   *
   * @returns {void}
   */
  function renderCaptcha() { // define renderCaptcha()
    if (!captchaEl || captchaId !== null) return; // no placeholder, or already rendered
    captchaWrap.hidden = false; // reveal it (a hidden widget can't size itself)
    captchaId = 'pending'; // don't start twice
    new Promise((resolve, reject) => { // load Cloudflare's script:
      if (window.turnstile?.render) return resolve(); // already there
      window.chaPayTurnstileReady = resolve; // api.js calls this when ready
      loadScript(TURNSTILE_SRC).catch(reject); // fetch it
    }).then(() => { // once ready:
      const theme = document.documentElement.getAttribute('data-theme'); // forced theme, if any
      captchaId = window.turnstile.render(captchaEl, { // render with…
        sitekey: captchaEl.dataset.sitekey, // …the public site key from the markup
        action: captchaEl.dataset.action, // …"pay", which the function checks
        theme: theme === 'dark' || theme === 'light' ? theme : 'auto', // …the site's theme
        size: captchaEl.clientWidth && captchaEl.clientWidth < 300 ? 'compact' : 'flexible', // …the card's width
        'refresh-expired': 'auto', // …a fresh token on expiry
        callback: (t) => { captchaFailed = false; setToken(t); }, // passed: keep the token
        'expired-callback': () => setToken(''), // expired: clear it
        'error-callback': () => { captchaFailed = true; setToken(''); }, // failed: release waiters
      });
    }).catch(() => { captchaFailed = true; setToken(''); }); // script blocked: release waiters
  }

  /**
   * Resolves with the current token, waiting up to {@link TOKEN_WAIT_MS} for one.
   * Resolves `''` straight away if the widget has failed; the function decides
   * whether a missing token is acceptable (it is where no secret is set).
   *
   * @returns {Promise<string>} The token, or `''`.
   */
  function awaitToken() { // define awaitToken()
    if (!captchaEl || captchaToken || captchaFailed) return Promise.resolve(captchaToken); // nothing to wait for
    renderCaptcha(); // make sure it's out
    return new Promise((resolve) => { // wait…
      waiters.push(resolve); // …for the callback…
      setTimeout(() => resolve(captchaToken), TOKEN_WAIT_MS); // …or the timeout
    });
  }

  /** Tokens are single-use: drop it and ask the widget for a fresh one. */
  function resetCaptcha() { // define resetCaptcha()
    captchaToken = ''; // forget the used token
    if (typeof captchaId === 'string' && captchaId !== 'pending' && window.turnstile) window.turnstile.reset(captchaId); // fresh challenge
  }

  formEl.addEventListener('input', renderCaptcha, { once: true }); // first typing brings the check out
  formEl.addEventListener('change', renderCaptcha, { once: true }); // so does a first choice

  // --- Stripe ---------------------------------------------------------------
  /**
   * Loads Stripe.js and creates the Stripe instance for a publishable key.
   *
   * @param {string} key The publishable key from the function.
   * @returns {Promise<Object>} The Stripe instance.
   */
  async function getStripe(key) { // define getStripe(key)
    if (stripe && publishableKey === key) return stripe; // reuse it
    await loadScript(STRIPE_SRC); // fetch Stripe.js
    publishableKey = key; // remember the key
    stripe = window.Stripe(key); // create the instance
    return stripe; // hand it back
  }

  /**
   * Moves from the form to the payment step and mounts the Payment Element.
   *
   * @param {Object} res The function's answer.
   * @param {Object} body What the visitor entered (for the element's defaults).
   * @returns {Promise<void>}
   */
  async function showPayment(res, body) { // define showPayment(res, body)
    await getStripe(res.publishableKey); // Stripe.js and the instance
    elements = stripe.elements({ clientSecret: res.clientSecret, appearance: appearance(), fonts: fonts() }); // a group for this payment
    paymentElement = elements.create('payment', { // the Payment Element…
      // Methods as full-width rows, not tabs: Stripe sizes tabs itself and puts the ones that
      // don't fit behind a narrow "more" box, so the row read as four boxes and a stub. Rows are
      // the same width whatever is enabled in the Dashboard, and visibleAccordionItemsCount: 0
      // keeps every one of them on the page instead of hiding any behind a More button.
      layout: { type: 'accordion', defaultCollapsed: false, radios: 'always', spacedAccordionItems: false, visibleAccordionItemsCount: 0 },
      defaultValues: { billingDetails: { name: body.name, email: body.email } }, // …prefilled from the form
    });
    paymentElement.mount(mount); // into the authored container
    slot('label').textContent = res.label; // what it's for (data from the function)
    slot('amount').textContent = money(res.amount, res.currency); // how much (data from the function)
    confirmError.hidden = true; // no old decline showing
    formEl.hidden = true; // the form steps aside
    stepPay.hidden = false; // the payment step appears
    stepPay.focus(); // and takes focus, so it's announced
  }

  /** Back to the form, discarding this payment's element. */
  function backToForm() { // define backToForm()
    paymentElement?.destroy(); // remove the element
    paymentElement = null; elements = null; // forget them
    stepPay.hidden = true; // hide the payment step
    formEl.hidden = false; // the form returns
    formEl.querySelector('input[name="kind"]:checked')?.focus(); // focus where they'd start
  }

  /**
   * Shows the result panel for a PaymentIntent status.
   *
   * @param {string} status `'succeeded'`, `'processing'`, or anything else (failed).
   * @returns {void}
   */
  function showResult(status) { // define showResult(status)
    const key = status === 'succeeded' || status === 'processing' ? status : 'failed'; // the panel to show
    stepDone.querySelectorAll('[data-pay-result]').forEach((p) => { p.hidden = p.dataset.payResult !== key; }); // just that one
    paymentElement?.destroy(); // done with the element
    formEl.hidden = true; // hide the form
    stepPay.hidden = true; // and the payment step
    stepDone.hidden = false; // show the result
    stepDone.focus(); // announce it
  }

  // Submit the form: validate, wait for the bot check, ask the function, go to payment.
  formEl.addEventListener('submit', async (e) => { // on submit:
    e.preventDefault(); // stay on the page
    showError(''); // clear old errors
    const body = collect(); // validate and build the body
    if (!body) { showError('fields'); return; } // something to fix: say so
    setBusy(submitBtn, true); // busy
    try { // ask the function:
      body.token = await awaitToken(); // the Turnstile token (or '')
      const r = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); // POST it
      const res = await r.json().catch(() => ({ error: 'server' })); // read the answer
      if (!r.ok || res.error) { showError(res.error || 'server'); return; } // a known failure: its message
      await showPayment(res, body); // on to payment
    } catch { // network failure or Stripe.js blocked:
      showError('server'); // the general message
    } finally { // either way:
      resetCaptcha(); // the token is spent
      setBusy(submitBtn, false); // idle again
    }
  });

  backBtn?.addEventListener('click', backToForm); // "Change what you're paying"

  // Confirm the payment with Stripe. Redirect-based methods leave and come back.
  confirmBtn?.addEventListener('click', async () => { // on "Pay now":
    if (!stripe || !elements) return; // nothing mounted
    setBusy(confirmBtn, true); // busy
    confirmError.hidden = true; // clear an old decline
    try { // confirm:
      const url = new URL(location.href); url.search = ''; url.hash = ''; // this page, clean
      const { error, paymentIntent } = await stripe.confirmPayment({ // ask Stripe to…
        elements, // …use the details in the element…
        confirmParams: { return_url: url.href }, // …and come back here after a redirect…
        redirect: 'if_required', // …only redirecting when the method needs it
      });
      if (error) { // declined or invalid:
        if (error.type === 'validation_error') return; // the element shows field errors itself
        slot('decline').textContent = error.message || ''; // Stripe's reason (data, localized by Stripe)
        confirmError.hidden = false; // show the decline box
        confirmError.focus(); // announce it
        return; // let them try again
      }
      showResult(paymentIntent.status); // succeeded or processing
    } catch { // Stripe.js failed:
      slot('decline').textContent = ''; // no reason to show
      confirmError.hidden = false; // the general decline box
    } finally { // either way:
      setBusy(confirmBtn, false); // idle again
    }
  });

  // Follow a theme change: re-style the element from the new tokens.
  window.addEventListener('cha:prefs', () => { elements?.update({ appearance: appearance() }); }); // new theme, new appearance

  // Back from a redirect-based payment method: read and show the result.
  const params = new URLSearchParams(location.search); // the query string
  const returning = params.get('payment_intent_client_secret'); // set by Stripe on return
  if (returning) { // returning from a redirect:
    history.replaceState(null, '', location.pathname); // clean the address bar
    fetch(ENDPOINT).then((r) => r.json()).then(async ({ publishableKey: key }) => { // get the key
      const s = await getStripe(key); // Stripe.js
      const { paymentIntent } = await s.retrievePaymentIntent(returning); // the payment
      showResult(paymentIntent?.status || 'failed'); // its result
    }).catch(() => showResult('processing')); // can't tell: the safe "we'll email you" panel
  }
}