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