(function(){
var s=document.querySelector("script[data-checkout-runtime]");
var pageId=s.getAttribute("data-page-id");
// Baked into a converted migrated Classic pop-up (checkout-migration/convert-org-popup-checkouts.ts):
// the pop-up is its own document, framed by its host page, so mount up front and hand the
// post-payment redirect to the host, whose relay navigates the top window.
var hpInPopup=s.hasAttribute("data-checkout-popup");
var slug=s.getAttribute("data-page-slug");
var apiBase=s.getAttribute("data-api-url");
var gateways=["stripe"];
var corrId=0;
var pendingCallbacks={};
var inSandbox=false;
try{inSandbox=window!==window.parent&&!window.__lpJsEmbed;}catch(e){inSandbox=true;}
function getConfig(el){
// HP-5123 - capture the authored success block BEFORE any mount wipes the
// element's innerHTML, plus the authorable failure/pending copy. The block is
// real page content (hidden inline until purchase), so every editor tool works
// on it; missing block/attrs fall back to the hardcoded output below verbatim.
var sb=el.querySelector?el.querySelector("[data-checkout-success]"):null;
return{
successHtml:sb?sb.outerHTML:null,
failHeading:el.getAttribute("data-checkout-fail-heading")||null,
failText:el.getAttribute("data-checkout-fail-text")||null,
pendingText:el.getAttribute("data-checkout-pending-text")||null,
priceId:el.getAttribute("data-price-id")||null,
amount:el.getAttribute("data-amount")?parseInt(el.getAttribute("data-amount"),10):null,
currency:el.getAttribute("data-currency")||"usd",
productName:el.getAttribute("data-product-name")||"Purchase",
successUrl:el.getAttribute("data-success-url")||null,
cancelUrl:el.getAttribute("data-cancel-url")||null,
mode:el.getAttribute("data-mode")||"payment",
gateway:el.getAttribute("data-gateway")||null,
shopifyVariant:el.getAttribute("data-shopify-variant")||null,
lemonSqueezyVariant:el.getAttribute("data-lemonsqueezy-variant")||null,
gumroadProduct:el.getAttribute("data-gumroad-product")||null,
quantity:el.getAttribute("data-quantity")?parseInt(el.getAttribute("data-quantity"),10):1,
hpPriceId:el.getAttribute("data-checkout-price")||null,
hpCheckoutId:el.getAttribute("data-persist-checkout-id")||null,
hpClassicFormIdentifier:el.getAttribute("data-checkout-form-identifier")||null,
hpClassicFormUuid:el.getAttribute("data-persist-form-source-id")||null,
accent:el.getAttribute("data-checkout-accent")||null,
accentText:el.getAttribute("data-checkout-accent-text")||null,
heading:el.getAttribute("data-checkout-heading")||null,
redirect:el.getAttribute("data-checkout-redirect")||null,
fields:el.getAttribute("data-checkout-fields")||"",
theme:el.getAttribute("data-checkout-theme")||"brand",
hideLink:el.getAttribute("data-checkout-hide-link")==="1",
// Minimal-fields checkout (Classic parity). Both are OPT-OUTS: the attribute is
// absent on every checkout published before this shipped, and absent means the
// field keeps rendering, so no live page changes shape on deploy.
// noName drops OUR name input. minAddress narrows what Stripe's Payment Element
// asks for to the address fields the payment actually requires, which drops the
// country selector and leaves the postal code on a US card account - the
// Classic layout the ask is for. Stripe decides that minimum per method, so
// this is a request for the shortest valid form, not a promise about the exact
// rows: an account whose cards do not require a postal code loses that too.
// Narrows the intent to cards. Unlike hideLink this MUST reach the server -
// the method list lives on the PaymentIntent, not the Element. Safe to trust
// from the client: it only ever narrows the offer, never touches the amount.
cardOnly:el.getAttribute("data-checkout-card-only")==="1",
// "" = Payment Element (number / expiry / CVC on their own rows, wallets, every
// method). "single" = Stripe's Card Element, the one-line widget Classic used:
// number, expiry, CVC and postal-where-applicable in a single input. Additive -
// absent keeps the Payment Element, which is what every existing page has.
cardField:el.getAttribute("data-checkout-card-field")||"",
noName:el.getAttribute("data-checkout-no-name")==="1",
minAddress:el.getAttribute("data-checkout-min-address")==="1",
// HP-4692 order bump + discount code. The label/price text are display-only
// (authored in the inspector); the server re-derives every amount from the
// catalog, so a tampered price text changes what the buyer READS, never pays.
bumpPriceId:el.getAttribute("data-checkout-bump")||null,
bumpLabel:el.getAttribute("data-checkout-bump-label")||"",
bumpDesc:el.getAttribute("data-checkout-bump-desc")||"",
bumpPriceText:el.getAttribute("data-checkout-bump-price-text")||"",
// Border treatment for the bump card. Deliberately NOT a colour: the card
// derives its accent from data-checkout-accent, which is already paired with a
// legible text colour. A free colour here is the one thing that can make a
// payment form unreadable, which costs the merchant money directly.
bumpStyle:el.getAttribute("data-checkout-bump-style")||"dashed",
promo:el.getAttribute("data-checkout-promo")==="1",
promoLabel:el.getAttribute("data-checkout-promo-label")||"",
promoPlaceholder:el.getAttribute("data-checkout-promo-placeholder")||"",
promoApplyLabel:el.getAttribute("data-checkout-promo-apply-label")||"",
// Prompt treatment, same no-colour rule as the bump's emphasis.
promoStyle:el.getAttribute("data-checkout-promo-style")||"link",
// Skip the prompt entirely and show the field from the start, for funnels
// where the code is the point rather than an afterthought.
promoOpen:el.getAttribute("data-checkout-promo-open")==="1",
// HP-5172 - authorable label/placeholder for the fields WE render. Empty or
// absent falls back to today's literal in the field() calls below, so every
// existing page renders identically. Stripe-owned rows (address, card) take
// their labels from Stripe, not from these.
nameLabel:el.getAttribute("data-checkout-name-label")||"",
namePlaceholder:el.getAttribute("data-checkout-name-placeholder")||"",
emailLabel:el.getAttribute("data-checkout-email-label")||"",
emailPlaceholder:el.getAttribute("data-checkout-email-placeholder")||"",
phoneLabel:el.getAttribute("data-checkout-phone-label")||"",
phonePlaceholder:el.getAttribute("data-checkout-phone-placeholder")||"",
// HP-5172 (sizing) - form-level field size, corner radius and Pay button. All
// CLOSED ENUMS; mapped to safe literals below and never concatenated raw into
// style.cssText (the same reason the accent is hex-validated). Empty = today's
// values, so an unstyled checkout is byte-identical to before.
fieldSize:el.getAttribute("data-checkout-field-size")||"",
radius:el.getAttribute("data-checkout-radius")||"",
btnSize:el.getAttribute("data-checkout-btn-size")||"",
btnWidth:el.getAttribute("data-checkout-btn-width")||"",
btnAlign:el.getAttribute("data-checkout-btn-align")||"",
// Field labels: "" (above, as every checkout renders today) | "floating".
// Closed enum, same as the sizing keys.
//
// Floating rather than hidden on purpose. Stripe's appearance.labels takes
// auto|above|floating and offers no way to remove a label at all - a position
// its own API takes for its own fields - because a placeholder is not a label:
// it vanishes on the first keystroke, so a buyer who tabs back to fix a card
// error is left with an unnamed box. Moving the label inside the field gets
// the uncluttered form an author wants while the label stays visible, stays
// permanent, and stays attached to its input.
labels:el.getAttribute("data-checkout-labels")||""
};
}
function getVisitorId(){
try{var m=document.cookie.match(/(?:^|; )_hp_vid=([^;]+)/);return m?decodeURIComponent(m[1]):null;}catch(e){return null;}
}
// Fire a checkout_started analytics beacon if the page's analytics runtime
// exposed a push hook; harmless no-op otherwise.
function fireCheckoutStarted(priceId){
try{if(window.__hpTrack){window.__hpTrack("checkout_started",{priceId:priceId});}}catch(e){}
}
var stripeJsPromise=null;
function loadStripeJs(){
if(window.Stripe)return Promise.resolve(window.Stripe);
if(stripeJsPromise)return stripeJsPromise;
stripeJsPromise=new Promise(function(resolve,reject){
var sc=document.createElement("script");
sc.src="https://js.stripe.com/v3/";
sc.onload=function(){resolve(window.Stripe);};
sc.onerror=function(){reject(new Error("Stripe.js failed to load"));};
document.head.appendChild(sc);
});
return stripeJsPromise;
}
// Resolve a configured post-purchase redirect to a safe absolute http(s) URL —
// relative "/path" and "#anchor" resolve against this page; null if missing or a
// non-http(s) scheme (javascript:/data: never navigate).
function resolveRedirect(raw){
if(!raw)return null;
try{var rr=new URL((""+raw).replace(/^\s+|\s+$/g,""),location.href);if(rr.protocol==="http:"||rr.protocol==="https:")return rr.href;}catch(e){}
return null;
}
// Navigate to a post-purchase redirect. Returns true if it left the page; false
// when the target is THIS page (same origin+path — an #anchor, or an absolute URL
// to the current path) since that wouldn't reload, so the caller shows its own
// success UI in place. Compares origin+pathname+search (NOT hash) so only a true
// same-document #anchor stays on-page; a different path OR query string is a real
// navigation. The Stripe return params are stripped before this runs on the return
// path, so they don't make a same-page target look different.
function navigateRedirect(url){
var a;try{a=new URL(url,location.href);}catch(e){a=null;}
if(a&&a.origin===location.origin&&a.pathname===location.pathname&&a.search===location.search){
if(a.hash){try{location.hash=a.hash;}catch(e){}}
return false;
}
if(hpInPopup&&window!==window.parent&&a){
// The host relay (worker-pages scripts/popups.ts handleRedirectRelay) acks and navigates the top
// window; without an ack (no relay, e.g. an embed on another site) the frame navigates itself.
var acked=false;
var onAck=function(e){if(e&&e.data&&e.data.type==="hp-popup-redirect-ack")acked=true;};
window.addEventListener("message",onAck);
try{parent.postMessage({type:"hp-popup-redirect",url:a.href},"*");}catch(e){}
setTimeout(function(){window.removeEventListener("message",onAck);if(!acked)window.location.href=url;},400);
return true;
}
window.location.href=url;return true;
}
// The "free" argument is the $0.00 checkout: nothing was charged, so "Payment successful"
// would assert a payment that never happened - and the reader is most often the
// author walking their own form, for whom "no payment was charged" is the fact
// they are checking for.
function showCheckoutSuccess(host,free,config){
// The authored block (when present) speaks for BOTH the paid and the $0.00
// outcome - the author owns the copy. The hardcoded fallback below keeps the
// paid/free distinction for pages authored before HP-5123.
if(config&&config.successHtml){
host.innerHTML=config.successHtml;
var sb=host.firstElementChild;if(sb){sb.style.display="";}
return;
}
host.innerHTML="
\u2705
"+(free?"You\u2019re all set":"Payment successful")+"
"+(free?"Nothing was charged.":"Thank you for your purchase.")+"
";
}
// "ok-free" is the $0.00 checkout returning from a redirecting method. Without
// it this overlay asserted "Payment successful / Thank you for your purchase" on
// an order that charged nothing - the same claim the on-page panel avoids.
function ckEscape(v){return String(v).replace(/&/g,"&").replace(//g,">");}
function checkoutReturnHtml(kind,stash){
if(kind==="fail"&&stash&&(stash.failHeading||stash.failText))return "
"+ckEscape(stash.failHeading||"Payment not completed")+"
"+ckEscape(stash.failText||"Your payment was not completed. Please try again.")+"
Your payment is being processed. You can safely close this page.
";
}
// (Re)mount the live checkout into every inline element, replacing the static
// facsimile. Used when a return overlay is dismissed so the buyer never lands on a
// dead facsimile and can retry after a failed auth.
function remountInline(){
try{var els=document.querySelectorAll("[data-checkout-inline]");for(var i=0;i";
function settle(kind){
if(kind==="ok"){var u=resolveRedirect(ctx.stash.redirect);if(u&&navigateRedirect(u))return;
// HP-5123 - the authored success block rides the stash (captured at intent
// creation from the element that initiated THIS payment), so the overlay
// clones the right content even when the element is hidden, duplicated
// across plans, or already wiped by a re-mount.
if(ctx.stash.success){card.innerHTML=ctx.stash.success;var sb=card.firstElementChild;if(sb){sb.style.display="";}return;}
card.innerHTML=checkoutReturnHtml(ctx.kind==="setup"?"ok-free":"ok",ctx.stash);}
else if(kind==="pending"){card.innerHTML=checkoutReturnHtml("pending",ctx.stash);}
else{card.innerHTML=checkoutReturnHtml("fail",ctx.stash);}
}
loadStripeJs().then(function(Stripe){
var s=Stripe(ctx.stash.pk,ctx.stash.acct?{stripeAccount:ctx.stash.acct}:undefined);
var p=ctx.kind==="setup"?s.retrieveSetupIntent(ctx.cs):s.retrievePaymentIntent(ctx.cs);
return p.then(function(res){
var obj=res&&(res.paymentIntent||res.setupIntent);var st=obj&&obj.status;
// Two different success contracts, on purpose. A PAID "processing"
// PaymentIntent counts as success (existing behavior, unchanged). A setup that
// has not reached "succeeded" has NOT completed — the webhook that finalizes
// the free order fires on setup_intent.succeeded only — so "processing" and
// "requires_action" get the pending copy, mirroring the on-page branch, rather
// than asserting "You're all set" (or wrongly failing) while the order sits
// pending. A failed auth (requires_payment_method) still fails.
settle(ctx.kind==="setup"
?(st==="succeeded"?"ok":(st==="requires_payment_method"?"fail":"pending"))
:((st==="succeeded"||st==="processing")?"ok":"fail"));
});
}).catch(function(){card.innerHTML=checkoutReturnHtml("pending",ctx.stash);});
}
// Shared on-page checkout flow: builds the name/email + Payment Element form into
// the host element and wires the two-step confirm (collect email, create intent,
// mount Payment Element, confirm). Card never leaves Stripe's iframe. Used by both
// the modal (Buy Button / Card) and the inline element.
function renderCheckoutFlow(host,config){
fireCheckoutStarted(config.hpPriceId);
// Only accept hex colors. The accent rides on a free-text editor attribute, and
// below it is concatenated into style.cssText — a ';'-bearing value would inject
// extra CSS declarations. A hex value can't break out; anything else → default.
var accent=/^#[0-9a-fA-F]{3,8}$/.test(config.accent||"")?config.accent:"#635bff";
var accentText=/^#[0-9a-fA-F]{3,8}$/.test(config.accentText||"")?config.accentText:"#ffffff";
// HP-5172 sizing tokens. config.fieldSize / .radius / .btnSize are only ever
// used as KEYS into these maps (with a fallback), so a hostile attribute value
// can never reach style.cssText - the map VALUES are our own literals. The
// input font-size stays 16px at every size: below it iOS Safari auto-zooms the
// page on focus (HP-4525), so "compact" comes from padding + label, not text.
// fpad/ftop/flx are the floating-label variant: a taller input to make room for
// the label once it rises, how far from the top it lands, and the inset that
// keeps it aligned with the text below it. Literals, like every other value
// here, so a hostile attribute can never reach style.cssText.
var FIELD_SIZES={compact:{pad:"7px 10px",label:"11px",gap:"7px",space:"3px",stripeFont:"16px",fpad:"17px 10px 5px",ftop:"4px",flx:"10px"},standard:{pad:"10px 12px",label:"12px",gap:"10px",space:"4px",stripeFont:"16px",fpad:"21px 12px 7px",ftop:"5px",flx:"12px"},comfortable:{pad:"13px 16px",label:"13px",gap:"14px",space:"6px",stripeFont:"17px",fpad:"26px 16px 8px",ftop:"7px",flx:"16px"}};
var FS=FIELD_SIZES[config.fieldSize]||FIELD_SIZES.standard;
var RADII={none:0,small:4,medium:8,large:12,round:16};
var RAD=RADII.hasOwnProperty(config.radius)?RADII[config.radius]:null;
var inputRadius=RAD!=null?RAD+"px":"8px";
var BTN_SIZES={s:{pad:"9px 16px",font:"14px"},m:{pad:"12px",font:"15px"},l:{pad:"15px 20px",font:"17px"}};
var BS=BTN_SIZES[config.btnSize]||BTN_SIZES.m;
var btnRadius=RAD!=null?RAD+"px":"9px";
// One stylesheet per document for the floating variant. It exists because the
// states a floating label needs - :focus and :not(:placeholder-shown) - cannot
// be written as inline cssText, which is how everything else here is styled.
// The declarations are our own literals; the only per-checkout values ride in
// as custom properties set on the wrapper, so two checkouts at different field
// sizes on one page do not fight over one sheet.
// (No backticks anywhere in this file - it is one big template literal, so one
// would end the string.)
var flSeq=0;
function ensureFloatCss(){
if(document.getElementById("hp-co-float-css"))return;
var st=document.createElement("style");st.id="hp-co-float-css";
st.textContent=".hp-co-fl{position:relative}"
+".hp-co-fl>label{position:absolute;left:var(--hp-fl-x);top:50%;transform:translateY(-50%);font-size:16px;line-height:1;color:#6b7280;pointer-events:none;transition:top .12s ease,font-size .12s ease,transform .12s ease}"
+".hp-co-fl>input:focus+label,.hp-co-fl>input:not(:placeholder-shown)+label{top:var(--hp-fl-top);transform:none;font-size:var(--hp-fl-size)}"
// The authored placeholder is not lost, it waits its turn: at rest the label
// occupies that space, and showing both would be the same words twice.
+".hp-co-fl>input::placeholder{color:transparent}"
+".hp-co-fl>input:focus::placeholder{color:#9ca3af}";
(document.head||document.documentElement).appendChild(st);
}
function field(labelText,type,placeholder){
var floating=config.labels==="floating";
var wrap=document.createElement("div");
wrap.style.cssText="margin-bottom:"+FS.gap+";";
var inp=document.createElement("input");inp.type=type;
// :placeholder-shown is what tells the label whether the field is empty, and it
// only matches when a placeholder attribute is actually set - so an author who
// clears the placeholder must not strand the label in its raised position.
inp.placeholder=placeholder||(floating?" ":"");
// Force dark-on-white so typed text stays legible even when the host page
// theme inherits a light text color onto the modal. 16px keeps iOS Safari
// from auto-zooming the page on focus (it zooms for any input below 16px),
// which customers report makes the mobile form unusable (HP-4525).
inp.style.cssText="width:100%;box-sizing:border-box;padding:"+(floating?FS.fpad:FS.pad)+";border:1px solid #d1d5db;border-radius:"+inputRadius+";font-size:16px;color:#1a1a1a;background:#fff;";
var lab=document.createElement("label");lab.textContent=labelText;
if(floating){
ensureFloatCss();
// Explicitly associated, unlike the default branch below, because this one is
// new: a for/id pair is what makes the label the input's accessible NAME
// rather than decoration sitting near it.
var fid="hp-co-f"+(++flSeq);
inp.id=fid;lab.htmlFor=fid;
wrap.className="hp-co-fl";
wrap.style.setProperty("--hp-fl-x",FS.flx);
wrap.style.setProperty("--hp-fl-top",FS.ftop);
wrap.style.setProperty("--hp-fl-size",FS.label);
// Input BEFORE label: the raised state is selected with input:focus+label.
wrap.appendChild(inp);wrap.appendChild(lab);
}else{
// Untouched from before this setting existed - a checkout that has not opted
// in renders exactly the DOM it always has, including the long-standing gap
// that this label is associated with nothing (HP-5349).
lab.style.cssText="display:block;font-size:"+FS.label+";color:inherit;opacity:0.7;margin-bottom:4px;";
wrap.appendChild(lab);wrap.appendChild(inp);
}
host.appendChild(wrap);return inp;
}
var wantFields=("," + (config.fields||"") + ",");
var nameInput=config.noName?null:field(config.nameLabel||"Name","text",config.namePlaceholder||"Your name");
var emailInput=field(config.emailLabel||"Email","email",config.emailPlaceholder||"you@example.com");
// Every read of the name goes through this - nameInput is null on a
// minimal-fields checkout, and an unguarded .value there kills the whole form
// (no card fields, no Pay) rather than just dropping a name.
function nameVal(){return nameInput?(nameInput.value||"").trim():"";}
var phoneInput=wantFields.indexOf(",phone,")!==-1?field(config.phoneLabel||"Phone","tel",config.phonePlaceholder||"(555) 555-5555"):null;
var status=document.createElement("div");
status.style.cssText="font-size:13px;margin:8px 0;min-height:16px;";
host.appendChild(status);
function setStatus(msg,err){status.textContent=msg||"";status.style.color=err?"#b91c1c":"#2563eb";}
// Optional Stripe Address Element (shipping) — mounted on the same elements
// instance so confirmPayment includes the address automatically.
var wantAddress=wantFields.indexOf(",address,")!==-1;
var addressZone=document.createElement("div");if(wantAddress)host.appendChild(addressZone);
var payZone=document.createElement("div");host.appendChild(payZone);
// HP-4692: bump, then code, then the order summary — appended now so they sit
// between the card fields and the Pay button; populated once the Pay button exists.
var bumpZone=document.createElement("div");host.appendChild(bumpZone);
var promoZone=document.createElement("div");host.appendChild(promoZone);
var summaryZone=document.createElement("div");host.appendChild(summaryZone);
// Stripe Appearance API theme from the configured payment-form style.
// The Card Element predates the Appearance API and reads a style object
// instead, so buildAppearance() alone would leave it Stripe-default: a light
// input sitting inside a dark form on the night theme. Mirrors the colours the
// field() helper above uses for our own inputs.
function buildCardStyle(){
var dark=config.theme==="night";
return{base:{color:dark?"#e5e7eb":"#1a1a1a",fontFamily:"system-ui,-apple-system,sans-serif",fontSize:FS.stripeFont,"::placeholder":{color:"#9ca3af"}},invalid:{color:"#b91c1c",iconColor:"#b91c1c"}};
}
// The Card Element draws NO box of its own. The Payment Element themes its own
// inputs through the Appearance API, so payZone can stay a bare div for it -
// mount a Card Element in that same bare div and you get what shipped: the
// brand icon and "Card number" placeholder floating with no border, no padding
// and no label, jammed against the Pay button. This wrapper is what makes the
// widget read as one more field in the stack. Same border, radius and padding
// as field() above, so it lines up with the Name and Email inputs above it.
var cardErr=null;
function buildCardShell(){
var wrap=document.createElement("div");wrap.style.cssText="margin-bottom:"+FS.gap+";";
// Left above the widget in BOTH modes. The Card Element is outside the
// Appearance API (it takes a Style object), so neither Stripe's floating
// labels nor ours can reach inside it - and floating one over an iframe means
// guessing at a height Stripe controls, on the one confirm path that has never
// taken a real charge. An above-label on that row is the safe end of that
// trade.
var lab=document.createElement("label");
lab.textContent="Card details";lab.style.cssText="display:block;font-size:"+FS.label+";color:inherit;opacity:0.7;margin-bottom:4px;";
// Both colours are set explicitly, and both have to agree with buildCardStyle()
// - it picks the TEXT colour off the theme, so letting the host page supply the
// backdrop is how you end up with near-white digits on white. Night mirrors
// Stripe's own night Appearance inputs (#30313d) so switching between the two
// layouts doesn't change what the form looks like; light mirrors field().
var dark=config.theme==="night";
var box=document.createElement("div");
box.style.cssText="box-sizing:border-box;padding:"+FS.pad+";border:1px solid "+(dark?"#424353":"#d1d5db")+";border-radius:"+inputRadius+";background:"+(dark?"#30313d":"#fff")+";";
cardErr=document.createElement("div");
cardErr.style.cssText="font-size:12px;color:#b91c1c;margin-top:4px;";
wrap.appendChild(lab);wrap.appendChild(box);wrap.appendChild(cardErr);
payZone.appendChild(wrap);
return box;
}
// The Card Element reports validation but renders no message of its own - the
// Payment Element does - so without this a bad number just turned red and the
// buyer had to press Pay to find out what was wrong with it.
// Clears the status line on the way in, because the two say the SAME sentence
// from opposite sides: status is appended above payZone, this sits below it,
// and Stripe hands the identical "Your card number is incomplete." to the
// change event and to the confirm rejection. Not while a confirm is in flight,
// or editing the card mid-pay would wipe "Processing payment...".
function setCardError(msg){
if(!cardErr)return;
cardErr.textContent=msg||"";
if(msg&&!paying)setStatus("",false);
}
function buildAppearance(){
var base;
if(config.theme==="night")base={theme:"night"};
else if(config.theme==="light")base={theme:"stripe"};
else base={theme:"stripe",variables:{colorPrimary:accent}};
// Layer the size/radius appearance variables ONLY when the author set them, so
// an untouched checkout sends the exact same appearance object as before. The
// 16px floor applies to fontSizeBase too - iOS zoom does not care whose input
// it is.
var extra={};
if(config.fieldSize){extra.fontSizeBase=FS.stripeFont;extra.spacingUnit=FS.space;}
if(RAD!=null)extra.borderRadius=RAD+"px";
for(var k in extra){if(!base.variables)base.variables={};base.variables[k]=extra[k];}
// The same word on both sides now: our fields float their labels and Stripe's
// rows float theirs. Set explicitly rather than left at the "auto" default,
// which follows the inputs variant and would keep them above. Top-level key,
// not a variable.
if(config.labels==="floating")base.labels="floating";
return base;
}
var primary=document.createElement("button");
primary.type="button";
primary.textContent="Pay";
// Label state: the verb is fixed at init but the amount moves when the buyer
// toggles the bump or applies a code, so both live in setPayLabel.
var payVerb="Pay",payCurrency=null,payTotal=null;
function setPayLabel(amt){
if(typeof amt==="number")payTotal=amt;
// A null CHECK, not a truthiness one: a FREE checkout's total is 0, and testing the number
// itself dropped the amount from exactly the price whose amount is the
// surprising part - a bare "Pay" reads as a button with no price attached. No
// PAID checkout can reach 0 here (a code that would zero the order is refused
// server-side), so this only ever adds the $0.00 label.
primary.textContent=payVerb+(payTotal!=null?(" \u2014 "+formatAmount(payTotal,payCurrency)):"");
}
// "fit" shrinks the button to its content (display:table) and margin-auto
// aligns it; "full" (default) keeps the exact width:100% string that shipped.
var btnLayout=config.btnWidth==="fit"?("display:table;width:auto;"+(config.btnAlign==="left"?"margin-right:auto;":config.btnAlign==="right"?"margin-left:auto;":"margin-left:auto;margin-right:auto;")):"width:100%;";
primary.style.cssText=btnLayout+"padding:"+BS.pad+";border:none;border-radius:"+btnRadius+";background:"+accent+";color:"+accentText+";font-size:"+BS.font+";font-weight:600;cursor:pointer;margin-top:6px;";
host.appendChild(primary);
// One-step flow: the intent is created and the Payment Element mounted as
// soon as the form renders, so card fields are visible on first paint with
// no click or focus and no mid-flow reflow (HP-4511: buyers read the empty
// form as a broken checkout). The focus listener below only retries a
// failed mount-time init. Cost: every mounted checkout now spawns an
// intent up front, including ones the buyer never completes.
var stripe,elements,cardEl=null,orderId=null,initialized=false,initPromise=null,patchedEmail=null,returnStashKey=null,repriceSecret=null,stashPk=null,stashAcct=null;
// True when the secret above belongs to a SetupIntent rather than a
// PaymentIntent - the $0.00 checkout. Latched at init and read by the confirm.
var isSetup=false;
// Latched for the lifetime of a confirm attempt (HP-4692): blocks a bump/code
// change from re-pricing an intent that is mid-confirmation.
var paying=false;
// Shortening the card form goes through address:"if_required", NEVER through
// "never". Stripe's contract on the fields option is explicit: "If you disable
// collecting certain fields with the fields option, you must pass that same
// data to stripe.confirmPayment or we'll reject the payment." This form has no
// country to pass - it collects one nowhere - so "never" would fail every
// payment on the checkout, not shorten it.
//
// "if_required" is the option built for this: it hides the OPTIONAL address
// fields (the country selector) and carries no supply-it-yourself obligation,
// while leaving whatever the payment genuinely needs - on a US card account
// that is the postal code the ask names. It is also per-method, so a checkout
// still offering Klarna or a bank debit keeps collecting what those require.
//
// noName sets nothing here on purpose: dropping OUR input is the whole change,
// and name:"never" would take on the same obligation for a value we have
// deliberately stopped collecting. If the merchant's Dashboard turns on
// cardholder name, the Element may still ask for it - a shorter form than
// today either way, and a working one.
function paymentElementOptions(){
var o={};
if(config.minAddress)o.fields={billingDetails:{address:"if_required"}};
// The block authors complain about is not a payment method - Stripe: "The
// default Payment Element integration includes a Link prompt in the card form."
// It rides the card form, which is why HP-4867's server-side method narrowing
// never removed it. wallets.link is the switch, and it lives on the Element, so
// hideLink no longer reaches the server at all: suppressing Link has nothing to
// do with which methods the intent was created with.
if(config.hideLink)o.wallets={link:"never"};
return o;
}
// silent=true (the mount-time call) must not stamp an error into the form
// on load - when checkout is off or rate-limited the buyer just sees the
// form, and the error surfaces on their first real interaction (focus / Pay).
function ensureInit(silent){
if(initialized)return Promise.resolve();
if(initPromise)return initPromise;
initPromise=createIntent(config,(emailInput.value||"").trim(),nameVal(),phoneInput?(phoneInput.value||"").trim():null).then(function(res){
if(!res||!res.clientSecret||!res.publishableKey){throw new Error((res&&res.error)||"init failed");}
orderId=res.orderId||null;
// A FREE ($0.00) price has no PaymentIntent behind it - Stripe has no
// zero-amount one - so the server mints a SetupIntent and the confirm below has
// to be confirmSetup. The Payment Element mounts from either secret identically,
// which is the whole point of doing it this way: the form an author walks at
// $0.00 is the form they ship.
isSetup=res.intentType==="setup";
// Bump and discount code both move the CHARGED amount on a live PaymentIntent,
// and a free order has none - /reprice answers 409 on its
// providerPaymentIntentId guard. Drop both controls up front rather than letting
// the buyer toggle one and collect an error they have no way to clear (same
// reasoning as the flag check below); reprice() carries the matching bail for a
// click that beat init.
if(isSetup)disableBumpAndPromo();
// HP-4869 — if order bump / discount codes are not live for buyers (the flag is
// not globally on), the session-less /reprice would 404 and tear these down on
// the buyer's FIRST click. Remove them up front the moment we know, so they
// never appear-then-vanish. Only an explicit false acts; an absent field (older
// API) leaves the prior render-then-degrade behaviour untouched.
if(res.bumpDiscountLive===false){disableBumpAndPromo();}
// Stash what the return handler needs to verify the PaymentIntent if this turns
// into a full-page redirect (3DS/bank). Keyed by the PaymentIntent id (the part
// of the client secret before _secret) so multiple checkouts on one page don't
// collide and the return matches the intent that actually paid. sessionStorage
// survives the round-trip; cleared on the on-page success path below.
// Held for /reprice: it is the capability that proves this browser owns the
// intent (HP-4692). Never logged, never put in a URL.
repriceSecret=res.clientSecret||null;
stashPk=res.publishableKey;stashAcct=res.connectedAccountId||null;
writeReturnStash(res.clientSecret||"");
return loadStripeJs().then(function(Stripe){
stripe=Stripe(res.publishableKey,res.connectedAccountId?{stripeAccount:res.connectedAccountId}:undefined);
// Single-line card field (Classic parity), but only where it is safe.
// A SHIPPING address is collected by an Address Element that confirmPayment
// picks up automatically and confirmCardPayment does not, so honouring the
// single-line layout there would mean quietly not sending an address the
// merchant asked for. That case falls back to the Payment Element - a longer
// form that works beats a shorter one that drops data.
// SUBSCRIPTIONS are NOT excluded. They confirm against the first invoice's
// confirmation_secret, which Stripe documents as "the client_secret of the
// PaymentIntent that Stripe creates during invoice finalization" - an ordinary
// PI secret, which is exactly what confirmCardPayment takes. (This is the
// pre-Payment-Element subscriptions integration Stripe shipped for years.)
// save_default_payment_method on the subscription still attaches the card
// confirmCardPayment creates, because that is settled server-side on payment
// success and does not care which Stripe.js call confirmed.
// cardOnly is part of the condition, not an assumption: the Card Element takes
// cards and nothing else, so mounting it while the intent still carries Klarna
// and friends would offer the buyer methods they cannot reach. The inspector
// clears cardField alongside cardOnly, and this is the belt to that braces -
// a stale attribute on an already-published page must not strand a buyer.
var useCard=config.cardField==="single"&&config.cardOnly&&!wantAddress;
if(useCard){
// No clientSecret on this group: that mode is for the Payment Element. The
// Card Element takes the secret at confirm time instead.
elements=stripe.elements({appearance:buildAppearance()});
try{
// hidePostalCode mirrors the Billing address chip: left showing, Stripe asks
// for a postal code only where the card needs one; switched off, it goes.
// Without this the chip was a dead control on this path. Verified against a
// live US connected account: the Payment Element on address:"if_required"
// renders neither the country selector NOR the ZIP, so dropping the postal
// here is what keeps one control meaning one thing across both layouts.
cardEl=elements.create("card",{hidePostalCode:config.minAddress===true,style:buildCardStyle()});
cardEl.mount(buildCardShell());
cardEl.on("change",function(ev){setCardError(ev&&ev.error?ev.error.message:"");});
}catch(e){
// Same reasoning as the Payment Element fallback below - never leave the
// buyer with a Pay button and nowhere to type a card. The shell goes with it:
// the Payment Element mounts into payZone next, and an abandoned bordered box
// sitting above it reads as a second, broken card field.
payZone.innerHTML="";cardErr=null;cardEl=null;useCard=false;
}
}
if(!useCard){
elements=stripe.elements({clientSecret:res.clientSecret,appearance:buildAppearance()});
// SetupIntents carry NO shipping field, and confirmSetup has nowhere to put an
// address - so on a free checkout this Element would collect the merchant's
// shipping address and drop it on the floor. Not asking is the honest option;
// the inspector hides the chip for a $0.00 price for the same reason. Exactly
// the defect the Card Element guard below refuses to commit.
if(wantAddress&&!isSetup){try{elements.create("address",{mode:"shipping"}).mount(addressZone);}catch(e){}}
// Author-driven options ride this call, so a value Stripe.js rejects would
// reject ensureInit - and the mount-time call is silent, so the buyer would get
// an email field, a Pay button and no card fields, with no error. Fall back to
// an un-optioned Element: a checkout that is longer than the author asked for
// still takes money, one with no card fields does not.
// Create and mount in one try, but recover on a FRESH elements group: Stripe
// allows only one Element of a type per group, so retrying create() on the same
// one throws "Can only create one Element of type payment" and produces exactly
// the empty form this fallback exists to prevent.
try{elements.create("payment",paymentElementOptions()).mount(payZone);}
catch(e){
elements=stripe.elements({clientSecret:res.clientSecret,appearance:buildAppearance()});
// The Address Element belongs to the group we just replaced, and confirmPayment
// only reads Elements from the group it is handed - so leaving it behind would
// confirm successfully with NO shipping address on the intent, silently losing
// the one the buyer typed and the merchant asked for. Recreate it on the fresh
// group, clearing the orphan's iframe first so the two don't stack.
if(wantAddress&&!isSetup){addressZone.innerHTML="";try{elements.create("address",{mode:"shipping"}).mount(addressZone);}catch(e2){}}
// A failure here is fatal, not swallowable: there is no card field to pay with.
// Swallowing it left initialized=true, a buyer staring at an email box and a Pay
// button, and nothing in the console - and a Pay click that confirmed against an
// empty group. The "init failed" sentinel is ensureInit's existing signal for
// "show the generic copy": it clears initPromise so the focus listener can retry
// and leaves initialized false.
try{elements.create("payment").mount(payZone);}
catch(e2){
try{if(window.console&&console.error)console.error("[hp-checkout] payment element failed",e,e2);}catch(e3){}
throw new Error("init failed");
}
}
}
payVerb=(res.mode==="subscription"?"Subscribe":"Pay");payCurrency=res.currency;setPayLabel(res.amount);
initialized=true;
// Stamp any email typed while init was in flight (the blur handler skips it
// before the order exists), so an abandoned-cart lead is still captured.
patchOrder();
});
}).catch(function(err){
initPromise=null;
if(!silent){setStatus((err&&err.message&&err.message!=="init failed")?err.message:"Couldn't start checkout. Please try again.",true);}
});
return initPromise;
}
// Abandoned-cart capture: stamp the email onto the order as it's typed (before
// paying) so the buyer is logged as a lead even if they never complete.
function patchOrder(){
var email=(emailInput.value||"").trim();
if(!orderId||!email||email.indexOf("@")===-1||email===patchedEmail)return;
patchedEmail=email;
try{fetch(apiBase+"/api/checkout/order/"+encodeURIComponent(orderId),{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({buyerEmail:email,buyerName:nameVal(),visitorId:getVisitorId()})}).catch(function(){});}catch(e){}
}
host.addEventListener("focusin",function(){ensureInit();});
emailInput.addEventListener("blur",patchOrder);
// Shared recovery for both ways a confirm can fail without resolving: a rejected
// Stripe promise and a synchronous throw out of the confirm block.
// Deliberately generic: this path fires on integration errors and dead
// connections, whose messages are developer text. And it cannot promise nothing
// was charged - a connection that dies after Stripe accepted the confirm rejects
// here too - so the copy points at the receipt rather than asserting a failure
// that may not have happened. The resolved-with-r.error branch is different:
// those messages are buyer-facing by contract, so it keeps them.
function confirmRejected(err){
paying=false;
try{if(window.console&&console.error)console.error("[hp-checkout] confirm rejected",err);}catch(e2){}
// The receipt hedge exists because a connection that dies AFTER Stripe accepted
// the confirm rejects here too, so that path cannot promise nothing was charged.
// On a FREE checkout it can: there is no charge and no receipt to look for, so
// that copy would send the buyer hunting for an email that cannot exist.
// (An init FAILURE never reaches here: ensureInit swallows its own errors, so
// the click chain early-returns on !initialized with the init error shown.)
setStatus(isSetup
?"We couldn't finish setting up your card. Nothing was charged \u2014 please try again."
:"We couldn't confirm your payment. Check your email for a receipt before trying again.",true);
primary.disabled=false;primary.style.opacity="";
}
primary.onclick=function(){
if(primary.disabled)return;
var email=(emailInput.value||"").trim();
if(!email||email.indexOf("@")===-1){setStatus("Enter a valid email.",true);try{emailInput.focus();}catch(e){}return;}
// Disable before init resolves so a fast second tap can't queue a second
// confirm for the same intent (the click-time guard above misses clicks
// that land while ensureInit is still in flight).
primary.disabled=true;primary.style.opacity="0.6";
paying=true;
ensureInit().then(function(){
if(!initialized){paying=false;primary.disabled=false;primary.style.opacity="";return;} // init failed - error already shown
patchOrder();
setStatus(isSetup?"Finishing up\u2026":"Processing payment\u2026",false);
// return_url is THIS page so handleCheckoutReturn can verify the PaymentIntent
// for full-redirect methods; the configured post-purchase redirect (CHECKOUT
// actions) is applied here on the on-page success path and by the return handler
// after a redirect.
// Omitted rather than sent empty when there is no Name field to read.
var nm=nameVal();var billingDetails={email:email};if(nm)billingDetails.name=nm;
// Two confirm calls because there are two widgets. The Card Element is not part
// of a clientSecret-bound elements group, so it takes the secret directly and
// the card instance explicitly; 3DS comes back as a modal rather than a
// full-page redirect, which is why no return_url is needed on this path.
// Everything downstream - the r.error check, the stash cleanup, the configured
// redirect, the success panel - is shared.
// Two axes, not one: WHICH WIDGET is mounted (Card Element vs Payment Element,
// already the reason there are two calls here) and WHICH STRIPE OBJECT the
// secret belongs to. The second cannot be folded into the first - handing a
// setup secret to confirmPayment is a Stripe integration error thrown at a buyer
// who has already typed a card, not a declined card - so the free checkout gets
// its own pair. receipt_email is absent from both setup calls: there is no
// charge to receipt and Stripe rejects the param there.
var confirmed=isSetup
?(cardEl
? stripe.confirmCardSetup(repriceSecret,{payment_method:{card:cardEl,billing_details:billingDetails}})
: stripe.confirmSetup({elements:elements,confirmParams:{return_url:location.href,payment_method_data:{billing_details:billingDetails}},redirect:"if_required"}))
:(cardEl
? stripe.confirmCardPayment(repriceSecret,{payment_method:{card:cardEl,billing_details:billingDetails},receipt_email:email})
: stripe.confirmPayment({elements:elements,confirmParams:{return_url:location.href,receipt_email:email,payment_method_data:{billing_details:billingDetails}},redirect:"if_required"}));
confirmed.then(function(r){
// setCardError("") is the other half of that handoff - Stripe rejects an
// incomplete card with the same sentence the change event already put below
// the field, and leaving both up brackets the input in duplicate red.
// Stripe's own message is buyer-facing by contract and covers a setup as well
// as a payment ("Your card was declined"), so it is kept either way; only the
// fallback for a message-less error has to know which one this is.
if(r.error){paying=false;setCardError("");setStatus(r.error.message||(isSetup?"We couldn't save your card.":"Payment failed."),true);primary.disabled=false;primary.style.opacity="";return;}
// A resolve with no error is not the same as a finished setup. The free
// checkout's automatic methods include ones that complete out of band -
// us_bank_account resolves "requires_action" while microdeposits are verified -
// and Stripe emits no setup_intent.succeeded until that lands, so the order
// would stay pending. Showing the success panel there tells the buyer they are
// done and leaves the merchant nothing.
// Scoped to the setup path deliberately: the payment path treats a "processing"
// PaymentIntent as success today, and changing that would move live paid
// checkouts for reasons unrelated to a $0.00 price.
if(isSetup&&r.setupIntent&&r.setupIntent.status!=="succeeded"){
paying=false;
setStatus("Your details were received. We\u2019ll email you once your bank confirms.",false);
// Re-enable Pay: an out-of-band setup (us_bank_account microdeposits) can be
// re-confirmed on the same intent — Stripe re-enters verification — and a
// frozen button forces the buyer to reload, which mints a SECOND order for the
// same buyer and, if both eventually succeed, two completed sales.
primary.disabled=false;primary.style.opacity="";
if(returnStashKey){try{sessionStorage.removeItem(returnStashKey);}catch(e){}}
return;
}// Paid on-page (no full redirect) — drop the return stash so a later same-tab
// load carrying payment params can't reuse it.
if(returnStashKey){try{sessionStorage.removeItem(returnStashKey);}catch(e){}}
var url=resolveRedirect(config.redirect);
// Same-page (#anchor / same-path) target: navigateRedirect returns false and we
// show success in place; a cross-page target navigates away.
if(url&&navigateRedirect(url))return;
showCheckoutSuccess(host,isSetup,config);
// confirmPayment REJECTS (rather than resolving with r.error) on an integration
// error or a dead connection. Deliberately the SECOND argument to then, not a
// chained .catch: a .catch here would also see anything the success handler
// throws, which would tell a buyer whose card was just charged that the payment
// failed and re-enable Pay for a second confirm against the same intent.
},confirmRejected);
// A synchronous throw out of the callback above - stripe.confirmPayment does
// exactly that when the elements group it is handed has no Payment Element -
// would otherwise be an unhandled rejection, leaving paying latched and the
// button frozen on "Processing payment...".
// Has to be a chained .catch, not then's second argument: an onRejected handler
// does not see a throw from its own onFulfilled sibling. Safe here because the
// success handler runs inside the inner then whose promise is NOT returned, so
// nothing a charged buyer's success path throws can reach this.
}).catch(confirmRejected);
};
// ── HP-4692: order bump + discount code (one-time prices only) ─────────────
// Both mechanics move the CHARGED AMOUNT, which lives on the PaymentIntent the
// eager mount above already created. Rather than deferring intent creation (and
// losing the HP-4511 first-paint card fields), each change re-prices the live
// intent server-side: /reprice re-derives every amount from the catalog and
// Stripe's own coupon, so nothing here is trusted with money.
var bumpOn=false,appliedCode=null,repricing=false;
// The amount is authoritative on the intent, so a confirm that lands mid-reprice
// would charge whichever total won the race. Serialize: Pay is dead while a
// reprice is in flight.
function setRepricing(on){
repricing=on;
primary.disabled=on;primary.style.opacity=on?"0.6":"";
}
function summaryRow(label,value,strong){
var r=document.createElement("div");
r.style.cssText="display:flex;justify-content:space-between;gap:12px;padding-bottom:2px;font-size:"+(strong?"14px":"13px")+";"+(strong?"font-weight:600;color:#111;padding-top:6px;border-top:1px solid #e5e7eb;margin-top:6px;":"color:#555;");
var l=document.createElement("span");l.textContent=label;
var v=document.createElement("span");v.textContent=value;v.style.cssText="flex:none;";
r.appendChild(l);r.appendChild(v);
return r;
}
// Itemise only once there IS something to itemise - a plain single-price
// checkout keeps its original clean "Pay - $X" and no breakdown. Built with
// textContent (never innerHTML): productName and the code come from the
// merchant's Stripe catalog, which is authored data, not trusted markup.
function renderSummary(data){
summaryZone.innerHTML="";
if(!data)return;
var items=data.lineItems||[];
if(items.length<2&&!data.discount)return;
var box=document.createElement("div");box.style.cssText="margin:10px 0 2px;";
for(var i=0;i1){
showPicker(el,config);
}
}
var elements=document.querySelectorAll("[data-checkout]");
for(var i=0;idy>