1import { Controller } from "@hotwired/stimulus" 2import { waitForStripe, STRIPE_BLOCKED_MESSAGE } from "controllers/ecommerce/stripe_loader" 3 4// Education / government public suffixes (and exact institution domains) that 5// auto-qualify for purchase orders. Mirrors Ecommerce::Customer's 6// EDUCATIONAL_SUFFIXES / GOVERNMENT_SUFFIXES / PURCHASE_ORDER_DOMAINS â keep in 7// sync. UI hint only; the server gate is authoritative. 8const EDUCATIONAL_SUFFIXES = ["edu", "edu.au", "edu.sg", "ac.nz", "school.nz"] 9const GOVERNMENT_SUFFIXES = ["gov", "gov.au", "govt.nz"] 10const PURCHASE_ORDER_DOMAINS = ["csiro.au"] 11 12// Bare honorifics/titles a customer might type instead of their real name. A 13// small, stable, CLOSED set â not open-ended semantic matching â so a literal 14// list is the right tool here (this is synchronous checkout validation with no 15// AI flow to delegate to, and it mirrors the explicit-allowlist pattern above). 16// Deliberately conservative: matches only when the WHOLE name is a title, and 17// excludes rank abbreviations that collide with real short names / nicknames 18// (ColâColin, GenâGenevieve, Maj, Capâ¦). 19const HONORIFICS = new Set([ 20 "mr", "mrs", "ms", "miss", "mx", "mister", "madam", "madame", "missus", 21 "sir", "dame", "dr", "doctor", "prof", "professor", "rev", "reverend" 22]) 23 24// Ceiling on how long a "Place Order" click waits for the Stripe Payment Element 25// to commit a just-typed keystroke / autofill burst before falling through to 26// elements.submit(). Resolves early the moment the element reports complete, so 27// this is only the worst case for a genuinely-still-settling field. 28const CARD_COMPLETE_GRACE_MS = 400 29 30export default class extends Controller { 31 static targets = [ 32 "billingFields", "billingRadio", 33 "summaryContent", "summaryToggle", 34 "paymentMethodRadio", "poFields", "poInput", "purchaseOrderOption", 35 "cardFields", "newCardFields", "stripeCardElement", "paymentIntentId", "stripeErrors", "stripeErrorMessage", 36 "paypalFields", "paypalOption", "paypalOrderId", 37 "saveCardCheckbox", "savedCardRadio", 38 "step", "stepIndicator", "stepLabel", "stepLine", 39 "submitButton", "backLink", "backLinkText", 40 "shippingRatesContainer", "shippingLoading", "shippingError", "shippingErrorMessage", "shippingRatesList", 41 "summaryShipping", "summaryTotal", 42 "phone", "phoneError", "phoneLabel", "phoneHint", "shippingCountry", "addressError", "nameError", 43 "addressCards", "addressFormFields", "saveAddressCheckbox", 44 "phoneFixPanel", "phoneFix", "phoneFixError", "phoneFixLabel", "phoneFixMessage", 45 "billingSavedAddresses" 46 ] 47 48 static values = { 49 current: { type: Number, default: 1 }, 50 shippingRatesUrl: String, 51 stripePublishableKey: String, 52 createPaymentIntentUrl: String, 53 trackUrl: String, 54 customerLoggedIn: { type: Boolean, default: false }, 55 // False when the server has already waived the carrier's mobile rule for 56 // this buyer (an institutional customer, or a cart from an uploaded PO). 57 mobileRequired: { type: Boolean, default: true }, 58 needsDeferredCapture: { type: Boolean, default: false }, 59 authorizeOnlyThresholdCents: { type: Number, default: 0 }, 60 // Present when this checkout was filled in somewhere else (today: an 61 // uploaded purchase order). See Ecommerce::CheckoutPrefill. 62 prefillToken: String, 63 prefillPaymentMethod: String, 64 prefillShippingMethod: String, 65 prefillPoNumber: String 66 } 67 68 get storageKey() { 69 return "checkout_form_data" 70 } 71 72 // Which prefill this browser has already accepted. Without it, the 73 // localStorage restore below would overwrite a freshly server-rendered 74 // prefill with whatever this browser last typed into a checkout. 75 get prefillStorageKey() { 76 return "checkout_prefill_token" 77 } 78 79 // Separate from prefillStorageKey: the form data is cleared once per prefill, 80 // and the payment method is FORCED once per prefill, but they are different 81 // decisions and a future change to one must not silently re-arm the other. 82 get prefillPaymentStorageKey() { 83 return "checkout_prefill_payment" 84 } 85 86 // Client-side mirror of Ecommerce::MobilePhone::RULES â one entry per country we 87 // ship to (Ecommerce::ShipsTo). Kept in lock-step with the server so the client 88 // never blocks (or passes) a number the server wouldn't. The server is authoritative. 89 get mobileRules() { 90 return { 91 AU: { 92 regex: /^(\+?61|0)4\d{8}$/, 93 label: "Australian mobile number",
94 placeholder: "e.g. 0412 345 678", 95 message: "Effective from 31st May 2026 Australia Post now requires an accurate " + 96 "Australian mobile number so they can send delivery updates. Please enter " + 97 "a valid mobile (e.g. 0412 345 678 or +61 412 345 678).", 98 inlineError: "Please enter a valid Australian mobile number (e.g. 0412 345 678).", 99 blankError: "A mobile phone number is required for Australian deliveries.", 100 fixError: "Please enter a valid Australian mobile to continue.", 101 typingError: "That doesn't look like an Australian mobile yet â e.g. 0412 345 678.", 102 allowForeign: false 103 }, 104 NZ: { 105 regex: /^(\+?64|0)2\d{7,9}$/, 106 label: "Mobile number", 107 placeholder: "e.g. 021 234 5678", 108 message: "New Zealand deliveries need an accurate mobile number so the courier can " + 109 "send delivery updates. Please enter a valid NZ mobile (e.g. 021 234 5678), " + 110 "or your own mobile with its country code (e.g. +61 412 345 678).", 111 inlineError: "Please enter a valid mobile number â an NZ mobile (e.g. 021 234 5678) or one with its country code (e.g. +61 412 345 678).", 112 blankError: "A mobile phone number is required for New Zealand deliveries.", 113 fixError: "Please enter a valid mobile number to continue.", 114 typingError: "That doesn't look like a mobile number yet â e.g. 021 234 5678 or +61 412 345 678.", 115 allowForeign: true 116 } 117 } 118 } 119 120 // Client-side mirror of Ecommerce::CourierSafe (the server is authoritative; 121 // this is early UX + an orphaned-charge guard before payment). A value is 122 // courier-safe if it reduces to printable ASCII the same way the server does. 123 // See isLatinValue. These two maps mirror the stroke/ligature letters and smart 124 // punctuation the server's transliteration table folds to ASCII. 125 get smartPunctuationMap() { 126 return { 127 "â": "'", "â": "'", "â": "'", "â": "'", 128 "â": '"', "â": '"', "â": '"', 129 "â": "-", "â": "-", "â": "-", "â": "-" 130 } 131 } 132 133 get strokeLetterMap() { 134 return { 135 "Ä": "D", "Ä": "d", "Ã": "O", "ø": "o", "Å": "L", "Å": "l", 136 "Ã": "ss", "Ã": "AE", "æ": "ae", "Å": "OE", "Å": "oe", 137 "Ã": "Th", "þ": "th", "Ã": "D", "ð": "d", "Å": "N", "Å": "n" 138 } 139 } 140 141 connect() { 142 // Add-to-order mode renders no checkout steps and no payment form â the 143 // page is just the "add to existing order" confirmation panel, a plain 144 // form. Skip all checkout machinery: grabbing querySelector("form") here 145 // would otherwise intercept THAT panel's submit, and mounting Stripe would 146 // invite a payment the add-to-order flow never records (#LB2617634). 147 if (!this.hasStepTarget) return 148 149 this.selectedShippingPrice = 0 150 this.ratesFetched = false 151 this.stripeInitialized = false 152 this.stripePaymentInProgress = false 153 this.checkoutTracked = false 154 // Payment Element completion state, tracked via its "change" event. The 155 // iframe commits the customer's last keystroke (or a Link/autofill burst) 156 // ASYNCHRONOUSLY, so a click landing before that settles would otherwise see 157 // elements.submit() return "Card details incomplete" for a card that is in 158 // fact filled (prod feedback on #LB2617535 â error on click 1, success on an 159 // unchanged click 2). We gate submission on these flags + a bounded grace 160 // wait instead. Defaults defined here so they're safe before Elements mounts. 161 this.cardComplete = false 162 this.cardEmpty = true 163 this.cardCompleteResolvers = [] 164 this.pendingElementsUpdate = null 165 this.consumePrefill() 166 this.restoreFormData() 167 this.toggleBilling() 168 this.togglePaymentFields() 169 this.applyMobileRuleCopy() 170 this.showStep() 171 172 this.form = this.element.querySelector("form") 173 if (this.form) { 174 this.saveHandler = () => this.saveFormData() 175 this.form.addEventListener("input", this.saveHandler) 176 this.form.addEventListener("change", this.saveHandler) 177 this.submitHandler = (e) => this.interceptSubmit(e) 178 this.form.addEventListener("submit", this.submitHandler) 179 this.latinHandler = (e) => this.addressFieldInput(e) 180 this.form.addEventListener("input", this.latinHandler) 181 // Also on "change": a Google Places selection fills the address field 182 // without an "input" event (that would reopen its dropdown), and the 183 // filled value still needs checking â or clearing an earlier red ring. 184 this.form.addEventListener("change", this.latinHandler) 185 this.nameHandler = (e) => this.nameFieldInput(e) 186 this.form.addEventListener("input", this.nameHandler) 187 } 188
189 // Purchase order is restricted to educational / approved customers (the 190 // server enforces this too). Reveal it once the entered email qualifies. 191 this.emailInput = this.form?.querySelector("#email") 192 if (this.emailInput) { 193 this.poEligibilityHandler = () => this.evaluatePurchaseOrderEligibility() 194 this.emailInput.addEventListener("input", this.poEligibilityHandler) 195 this.emailInput.addEventListener("change", this.poEligibilityHandler) 196 } 197 this.evaluatePurchaseOrderEligibility() 198 // Only after eligibility has run: the purchase-order option is hidden until 199 // the entered email qualifies, and selecting a hidden payment method would 200 // leave the customer looking at a form with nothing chosen. 201 this.applyPrefilledPaymentMethod() 202 203 // If address cards exist and a saved address is pre-selected, fill hidden fields 204 if (this.hasAddressCardsTarget) { 205 const checked = this.addressCardsTarget.querySelector('input[name="selected_address"]:checked') 206 if (checked && checked.value !== "new") { 207 this.fillShippingFromData(checked.dataset) 208 } 209 } 210 211 // The card-fill above overwrites #shipping_phone with the saved card's number; 212 // if the customer had already corrected it (restored from localStorage), put 213 // the valid number back. Then show/hide the correction callout to match. 214 if (this.restoredShippingPhone && this.isValidMobile(this.restoredShippingPhone) && this.hasPhoneTarget) { 215 this.phoneTarget.value = this.restoredShippingPhone 216 } 217 this.updatePhoneFixPanel() 218 219 // If restored to shipping step, fetch rates 220 if (this.currentValue === 2 && !this.ratesFetched) { 221 this.fetchShippingRates() 222 } 223 224 // Returning to checkout already past the information step means details 225 // were entered earlier â make sure it's recorded. 226 if (this.currentValue >= 2) { 227 this.trackCheckout() 228 } 229 230 // A redirect-based payment confirmation (Link pay-by-bank, some 3DS 231 // challenges) left this page mid-checkout and Stripe has now returned the 232 // customer with ?payment_intent=â¦&redirect_status=⦠â money may already 233 // have moved with no order created yet. Finish (or fail) the checkout now. 234 this.handleStripeRedirectReturn() 235 } 236 237 disconnect() { 238 if (this.form) { 239 this.form.removeEventListener("input", this.saveHandler) 240 this.form.removeEventListener("change", this.saveHandler) 241 this.form.removeEventListener("submit", this.submitHandler) 242 this.form.removeEventListener("input", this.latinHandler) 243 this.form.removeEventListener("change", this.latinHandler) 244 this.form.removeEventListener("input", this.nameHandler) 245 } 246 if (this.emailInput && this.poEligibilityHandler) { 247 this.emailInput.removeEventListener("input", this.poEligibilityHandler) 248 this.emailInput.removeEventListener("change", this.poEligibilityHandler) 249 } 250 // Drop any pending card-complete waiters so a disconnect mid-grace-window 251 // can't leak a resolver (Stripe tears its own listeners down on unmount). 252 this.cardCompleteResolvers = [] 253 } 254 255 interceptSubmit(event) { 256 // NOTE: we deliberately do NOT clear the saved form data here. Submitting is 257 // not the same as a placed order â the server can still bounce us back to 258 // checkout with a 422 (AU phone / courier-safe address / payment-method gate, 259 // or a payment that authorised but failed to convert to an order). The form 260 // fields carry no server-rendered values, so the saved localStorage copy is 261 // the ONLY thing that repopulates them on that re-render. Clearing it on 262 // submit meant a payment problem wiped everything the customer had typed â 263 // they had to re-enter their details (prod feedback on #LB2617286). The data 264 // is cleared on the order confirmation page instead, once the order is real. 265 266 // If Stripe payment is in progress (we set the hidden field and re-submitted), let it through 267 if (this.stripePaymentInProgress) { 268 this.stripePaymentInProgress = false 269 return 270 } 271 272 // PayPal is driven by its own button (onApprove -> submitWithPaypalOrder 273 // sets the in-progress flag consumed above). Any OTHER submission with 274 // PayPal selected â an Enter keypress in a form field is the real case â 275 // has no approved payment to book, so point the customer at the button 276 // rather than posting a checkout the server will bounce. 277 if (this.isPaypalSelected() && this.currentValue === this.stepTargets.length && 278 (!this.hasPaypalOrderIdTarget || !this.paypalOrderIdTarget.value)) { 279 event.preventDefault() 280 this.dispatch("paypal-submit-blocked") 281 return 282 } 283 284 // If credit card is selected and we're on the payment step, intercept 285 if (this.isCreditCardSelected() && this.currentValue === this.stepTargets.length) { 286 event.preventDefault() 287 this.handleCreditCardSubmit(event) 288 } else { 289 // Non-card submit (e.g. purchase order). The step-1 transition already 290 // checked this, but re-run it here so a restored-to-a-later-step session or 291 // a bypassed step can't submit an unprintable address or junk name (the 292 // server gate is authoritative for courier-safety regardless). 293 if (!this.validateLatinAddress() || !this.validateNames()) { 294 event.preventDefault() 295 if (this.currentValue !== 1) { 296 this.currentValue = 1 297 this.showStep() 298 this.validateLatinAddress() 299 this.validateNames() 300 } 301 } 302 } 303 } 304 305 nextStep(event) { 306 event.preventDefault() 307 308 // Validate current step fields before advancing 309 if (!this.validateCurrentStep()) return 310 311 if (this.currentValue < this.stepTargets.length) { 312 this.currentValue++ 313 314 // Entering step 2 means the customer has filled in their contact and 315 // shipping details â fetch rates and record the started checkout. 316 if (this.currentValue === 2) { 317 this.fetchShippingRates() 318 this.trackCheckout() 319 } 320 321 this.showStep() 322 this.element.scrollIntoView({ behavior: "smooth", block: "start" }) 323 } 324 } 325 326 prevStep(event) { 327 event.preventDefault() 328 if (this.currentValue > 1) { 329 this.currentValue-- 330 this.showStep() 331 this.element.scrollIntoView({ behavior: "smooth", block: "start" }) 332 } 333 } 334 335 showStep() { 336 const current = this.currentValue 337 338 // Show/hide step content
339 this.stepTargets.forEach((step, idx) => { 340 step.classList.toggle("hidden", idx + 1 !== current) 341 }) 342 343 // Update progress indicators 344 this.stepIndicatorTargets.forEach((indicator, idx) => { 345 const stepNum = idx + 1 346 if (stepNum < current) { 347 // Completed 348 indicator.className = "flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 text-sm font-bold text-white" 349 indicator.innerHTML = '<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>' 350 } else if (stepNum === current) { 351 // Current 352 indicator.className = "flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 text-sm font-bold text-white" 353 indicator.textContent = stepNum 354 } else { 355 // Upcoming 356 indicator.className = "flex h-8 w-8 items-center justify-center rounded-full border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-950 text-sm font-medium text-gray-500 dark:text-gray-400" 357 indicator.textContent = stepNum 358 } 359 }) 360 361 // Update step labels 362 this.stepLabelTargets.forEach((label, idx) => { 363 const stepNum = idx + 1 364 if (stepNum <= current) { 365 label.className = label.className.replace(/text-gray-500 dark:text-gray-400/g, "text-indigo-600 dark:text-indigo-400") 366 label.className = label.className.replace(/font-medium/g, "font-semibold") 367 } else { 368 label.className = label.className.replace(/text-indigo-600 dark:text-indigo-400/g, "text-gray-500 dark:text-gray-400") 369 label.className = label.className.replace(/font-semibold/g, "font-medium") 370 } 371 }) 372 373 // Update connecting lines 374 this.stepLineTargets.forEach((line, idx) => { 375 const stepNum = idx + 1 376 if (stepNum < current) { 377 line.className = line.className.replace(/bg-gray-200 dark:bg-gray-700/g, "bg-indigo-600 dark:bg-indigo-500") 378 } else { 379 line.className = line.className.replace(/bg-indigo-600 dark:bg-indigo-500/g, "bg-gray-200 dark:bg-gray-700") 380 } 381 }) 382 383 // Update submit button text 384 if (this.hasSubmitButtonTarget) { 385 const labels = ["Continue to Shipping", "Continue to Payment", "Place Order"] 386 this.submitButtonTarget.value = labels[current - 1] || "Place Order" 387 this.submitButtonTarget.dataset.disableWith = current === this.stepTargets.length ? "Processing order..." : labels[current - 1] 388 389 // Only actually submit on the last step 390 if (current < this.stepTargets.length) { 391 this.submitButtonTarget.type = "button" 392 this.submitButtonTarget.dataset.action = "click->ecommerce--checkout#nextStep" 393 } else { 394 this.submitButtonTarget.type = "submit" 395 delete this.submitButtonTarget.dataset.action 396 } 397 } 398 399 // Update back link 400 if (this.hasBackLinkTextTarget) { 401 if (current === 1) { 402 this.backLinkTargets[0].href = this.backLinkTargets[0].dataset.cartPath 403 this.backLinkTargets[0].dataset.action = "" 404 this.backLinkTextTarget.textContent = "Return to cart" 405 } else { 406 this.backLinkTargets[0].href = "#" 407 this.backLinkTargets[0].dataset.action = "click->ecommerce--checkout#prevStep" 408 const prevLabels = ["", "Return to information", "Return to shipping"] 409 this.backLinkTextTarget.textContent = prevLabels[current - 1] 410 } 411 } 412 413 // On the payment step, reconcile Stripe Elements with the final total and 414 // capture method now that a shipping rate has been chosen. Keep the in-flight 415 // promise so a very fast "Place Order" click can await it before submitting 416 // (closing the same update/submit race the card-complete gate guards against). 417 // Swallow rejections here so an un-clicked reconcile can't raise an unhandled 418 // rejection; a genuine failure still surfaces later via elements.submit(). 419 if (current === this.stepTargets.length) { 420 this.pendingElementsUpdate = this.refreshStripeElements() 421 .then(() => this.ensurePaymentElementRendered()) 422 .catch(() => {}) 423 } 424 425 // PayPal availability tracks the (total-dependent) capture method, and the 426 // shared submit button steps aside for the PayPal button on the payment 427 // step; landing on any step re-evaluates both. The render event covers a 428 // session restored straight onto the payment step with PayPal selected. 429 this.refreshPaypalAvailability() 430 this.updateSubmitButtonForPaymentMethod() 431 if (current === this.stepTargets.length && this.isPaypalSelected()) { 432 this.dispatch("paypal-selected") 433 } 434 } 435 436 // Repair a Payment Element that mounted but never drew anything. 437 // 438 // We create and mount Elements as soon as Credit Card is the selected payment 439 // method, which is usually while the customer is still on the information step 440 // â so the mount happens inside a `hidden` (display:none) step on purpose, to
441 // warm Stripe up before they get there. Stripe sizes the Element's iframe 442 // itself, and a frame laid out at zero height while hidden does not always 443 // recover when the step is finally revealed: the customer lands on the payment 444 // step and sees an empty box where the card fields should be, so they can 445 // never pay and no PaymentIntent is ever created (prod outage 2026-08-14, 446 // zero orders for ~3h with add-to-carts running at their normal rate). 447 // 448 // Remounting the same element once the step is genuinely on screen makes it 449 // draw, and preserves whatever the customer had already typed. The height 450 // check means a healthy Element is never touched. 451 ensurePaymentElementRendered(attempt = 0) { 452 if (!this.paymentElement || !this.hasStripeCardElementTarget) return 453 454 const target = this.stripeCardElementTarget 455 // Still off-screen (payment step hidden, or a saved card is selected so the 456 // new-card block is collapsed) â nothing to lay out against yet. 457 if (target.offsetParent === null) return 458 459 const frame = target.querySelector("iframe") 460 if (frame && frame.getBoundingClientRect().height > 0) return 461 462 try { 463 this.paymentElement.unmount() 464 this.paymentElement.mount(target) 465 } catch { 466 // An element Stripe already considers unmounted (or torn down) throws 467 // here; the next attempt â or elements.submit() â surfaces the real state. 468 } 469 470 // Stripe needs a beat to draw the remounted frame, and a slow connection can 471 // need more than one, so re-check a couple of times rather than assuming the 472 // first remount took. Each pass no-ops as soon as the frame has height. 473 if (attempt < 2) { 474 setTimeout(() => this.ensurePaymentElementRendered(attempt + 1), 1200) 475 } 476 } 477 478 // Tells the server a checkout has started (contact + shipping details entered) 479 // so it shows up in the admin storefront funnel and abandoned-checkout recovery. 480 // Fire-and-forget; only sent once per page load and never blocks the UI. 481 trackCheckout() { 482 if (this.checkoutTracked || !this.hasTrackUrlValue) return 483 484 const email = this.form?.querySelector("#email")?.value?.trim() 485 if (!email) return 486 487 this.checkoutTracked = true 488 489 const fieldFor = (sel) => this.form?.querySelector(sel)?.value?.trim() || "" 490 const shippingAddress = { 491 first_name: fieldFor("#shipping_first_name"), 492 last_name: fieldFor("#shipping_last_name"), 493 address1: fieldFor("#shipping_address1"), 494 address2: fieldFor("#shipping_address2"), 495 city: fieldFor("#shipping_city"), 496 province: fieldFor("#shipping_province"), 497 country: fieldFor("#shipping_country"), 498 zip: fieldFor("#shipping_zip"), 499 phone: fieldFor("#shipping_phone") 500 } 501 502 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content 503 fetch(this.trackUrlValue, { 504 method: "POST", 505 headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken }, 506 body: JSON.stringify({ email: email, shipping_address: shippingAddress }), 507 keepalive: true 508 }).catch(() => { 509 // Best-effort analytics ping â a failure here must not affect checkout. 510 this.checkoutTracked = false 511 }) 512 } 513 514 async fetchShippingRates() { 515 let postcode = this.form?.querySelector("#shipping_zip")?.value?.trim() 516 517 // Fallback: read from selected saved address radio 518 if (!postcode && this.hasAddressCardsTarget) { 519 const checked = this.addressCardsTarget.querySelector('input[name="selected_address"]:checked') 520 if (checked && checked.value !== "new" && checked.dataset.zip) { 521 postcode = checked.dataset.zip.trim() 522 } 523 } 524 525 // Domestic needs a postcode to quote; international rates key off country, so 526 // an overseas address can fetch rates without one. 527 if (!postcode && this.isAuShipping()) { 528 if (this.hasShippingLoadingTarget) this.shippingLoadingTarget.classList.add("hidden") 529 if (this.hasShippingErrorTarget) { 530 if (this.hasShippingErrorMessageTarget) { 531 this.shippingErrorMessageTarget.textContent = "Please enter a postcode to calculate shipping rates." 532 } 533 this.shippingErrorTarget.classList.remove("hidden") 534 } 535 return 536 } 537 538 // Show loading, hide others 539 if (this.hasShippingLoadingTarget) this.shippingLoadingTarget.classList.remove("hidden") 540 if (this.hasShippingErrorTarget) this.shippingErrorTarget.classList.add("hidden") 541 if (this.hasShippingRatesListTarget) this.shippingRatesListTarget.innerHTML = "" 542 543 // Collect shipping address for more accurate GoPeople quotes (and so the 544 // hold sized in create_payment_intent uses the SAME country) â shared with 545 // paymentIntentShippingParams via collectShippingAddress. 546 const shippingAddress = this.collectShippingAddress() 547 548 try { 549 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content 550 const response = await fetch(this.shippingRatesUrlValue, { 551 method: "POST", 552 headers: { 553 "Content-Type": "application/json", 554 "Accept": "application/json", 555 "X-CSRF-Token": csrfToken 556 }, 557 body: JSON.stringify({ postcode: postcode, country: shippingAddress.country, shipping_address: shippingAddress }) 558 }) 559 560 const data = await response.json() 561 562 if (!response.ok || data.error) { 563 throw new Error(data.error || "Failed to fetch shipping rates") 564 } 565 566 this.renderShippingRates(data.rates) 567 this.ratesFetched = true 568 } catch (error) { 569 console.error("Shipping rates error:", error) 570 if (this.hasShippingErrorTarget) { 571 if (this.hasShippingErrorMessageTarget) { 572 this.shippingErrorMessageTarget.textContent = error.message 573 } 574 this.shippingErrorTarget.classList.remove("hidden") 575 } 576 } finally { 577 if (this.hasShippingLoadingTarget) this.shippingLoadingTarget.classList.add("hidden") 578 } 579 } 580 581 renderShippingRates(rates) { 582 if (!this.hasShippingRatesListTarget) return 583 if (!rates?.length) { 584 if (this.hasShippingErrorTarget) { 585 if (this.hasShippingErrorMessageTarget) { 586 this.shippingErrorMessageTarget.textContent = "No shipping rates available for this address." 587 } 588 this.shippingErrorTarget.classList.remove("hidden") 589 } 590 return 591 } 592 593 // A prefilled checkout already asked which delivery service they wanted 594 // (an uploaded purchase order is priced before it gets here), so honour 595 // that instead of silently reverting to the cheapest option. 596 const preselected = rates.findIndex((rate) => rate.code === this.prefillShippingMethodValue) 597 const chosen = preselected >= 0 ? preselected : 0 598 599 const html = rates.map((rate, idx) => { 600 const isGopeople = rate.carrier === "gopeople" 601 const isCollection = rate.code === "click_and_collect" 602 const badge = isGopeople 603 ? `<span class="inline-flex items-center rounded-full bg-green-50 dark:bg-green-900/30 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:text-green-400 ring-1 ring-inset ring-green-600/20">Same Day</span>` 604 : (isCollection ? `<span class="inline-flex items-center rounded-full bg-blue-50 dark:bg-blue-900/30 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:text-blue-300 ring-1 ring-inset ring-blue-600/20">Pickup</span>` : "") 605 return ` 606 <label class="group flex items-center justify-between gap-4 roun
606ded-lg bg-white dark:bg-gray-800 ring-1 ring-gray-200 dark:ring-gray-700 p-4 cursor-pointer has-[:checked]:ring-2 has-[:checked]:ring-indigo-600 has-[:checked]:bg-indigo-50 dark:has-[:checked]:bg-indigo-950 transition-all"> 607 <div class="flex items-center gap-3"> 608 <input type="radio" name="shipping_method" value="${this.escapeHtml(rate.code)}" 609 ${idx === chosen ? "checked" : ""} 610 data-price="${rate.price}" 611 data-action="change->ecommerce--checkout#shippingMethodChanged" 612 class="h-4 w-4 border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-0 focus:ring-offset-0 group-has-[:checked]:border-indigo-600"> 613 <div> 614 <span class="text-sm font-medium text-gray-900 dark:text-white group-has-[:checked]:text-indigo-900 dark:group-has-[:checked]:text-indigo-100">${this.escapeHtml(rate.title)} ${badge}</span> 615 <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 group-has-[:checked]:text-indigo-600 dark:group-has-[:checked]:text-indigo-300">${this.escapeHtml(rate.estimated_days)}</p> 616 </div> 617 </div> 618 <span class="text-sm font-semibold text-gray-900 dark:text-white group-has-[:checked]:text-indigo-900 dark:group-has-[:checked]:text-indigo-100">${isCollection ? "Free" : `$${parseFloat(rate.price).toFixed(2)}`}</span> 619 </label>` 620 }).join("") 621 622 this.shippingRatesListTarget.innerHTML = html 623 624 // Commit the selected rate's price to the summary (and to the amount the 625 // PaymentIntent is sized on) â the cheapest option, or the one a prefill 626 // already chose. 627 if (rates.length > 0) { 628 this.selectedShippingPrice = parseFloat(rates[chosen].price) 629 this.updateOrderSummary() 630 } 631 } 632 633 shippingMethodChanged(event) { 634 this.selectedShippingPrice = parseFloat(event.target.dataset.price) || 0 635 this.updateOrderSummary() 636 } 637 638 updateOrderSummary() { 639 const price = this.selectedShippingPrice 640 641 if (this.hasSummaryShippingTarget) { 642 if (price > 0) { 643 this.summaryShippingTarget.textContent = `$${price.toFixed(2)}` 644 this.summaryShippingTarget.className = "text-sm font-medium text-gray-900 dark:text-white" 645 } else if (this.selectedShippingMethodCode() === "click_and_collect") { 646 this.summaryShippingTarget.textContent = "Free â Click & Collect" 647 this.summaryShippingTarget.className = "text-sm font-medium text-blue-700 dark:text-blue-300" 648 } else { 649 this.summaryShippingTarget.textContent = "Calculated at next step" 650 this.summaryShippingTarget.className = "text-sm text-gray-500 dark:text-gray-400" 651 } 652 } 653 654 if (this.hasSummaryTotalTarget) { 655 const subtotal = parseFloat(this.summaryTotalTarget.dataset.subtotal) || 0 656 const total = subtotal + price 657 this.summaryTotalTarget.textContent = `$${total.toFixed(2)}` 658 } 659 660 // The shipping choice moves the total, and the total decides whether the 661 // order is authorize-only â which PayPal (immediate-capture) can't serve. 662 this.refreshPaypalAvailability() 663 } 664 665 escapeHtml(text) { 666 const div = document.createElement("div") 667 div.textContent = text 668 return div.innerHTML 669 } 670 671 validateCurrentStep() { 672 const currentStep = this.stepTargets[this.currentValue - 1] 673 if (!currentStep) return true 674 675 // On step 1 (information): names/addresses must be courier-printable, the 676 // name must look real, then the delivery must carry a reachable mobile. 677 if (this.currentValue === 1) { 678 if (!this.validateLatinAddress()) return false 679 if (!this.validateNames()) return false 680 if (!this.validateShippingPhone()) return false 681 } 682 683 // On step 2 (shipping), ensure a rate is selected 684 if (this.currentValue === 2) { 685 const selected = currentStep.querySelector('input[name="shipping_method"]:checked') 686 if (!selected) { 687 return false 688 } 689 } 690 691 const inputs = currentStep.querySelectorAll("input[required], select[required], textarea[required]") 692 693 for (const input of inputs) { 694 if (input.disabled) continue 695 if (input.checkValidity()) continue 696 697 // A required field can be invalid while HIDDEN â most often a blank
698 // name/address inside the collapsed saved-address form (a returning 699 // guest-checkout customer has no name on file, so the saved card fills it 700 // blank). reportValidity() can't show its prompt on a non-focusable 701 // element, so without this the button would silently do nothing and the 702 // customer would never learn what's missing. Reveal the field first. 703 this.revealForValidation(input) 704 if (input.getClientRects().length > 0) { 705 input.reportValidity() 706 input.focus() 707 } else { 708 // Still not focusable for some reason â never dead-end the button; 709 // guarantee a visible message instead. 710 this.showAddressError("Please complete the required shipping details before continuing.") 711 } 712 return false 713 } 714 return true 715 } 716 717 // Un-hide the container of a required field that failed validation while 718 // hidden, so reportValidity()/focus() can actually surface it. Two 719 // collapsible groups hold required fields: the saved-address form and the 720 // billing-address form (hidden-but-enabled when a saved billing address is 721 // selected). Reveal whichever holds the field (saved values stay populated) 722 // so the customer can fill the gap. 723 revealForValidation(input) { 724 if (input.getClientRects().length > 0) return // already visible 725 if (this.hasAddressFormFieldsTarget && this.addressFormFieldsTarget.contains(input)) { 726 this.addressFormFieldsTarget.classList.remove("hidden") 727 if (this.hasSaveAddressCheckboxTarget) this.saveAddressCheckboxTarget.classList.remove("hidden") 728 } 729 if (this.hasBillingFieldsTarget && this.billingFieldsTarget.contains(input)) { 730 this.billingFieldsTarget.classList.remove("hidden") 731 } 732 } 733 734 isAuShipping() { 735 return this.shippingCountry() === "AU" 736 } 737 738 // Blank folds to AU, mirroring Ecommerce::ShipsTo.normalize. 739 shippingCountry() { 740 if (!this.hasShippingCountryTarget) return "AU" 741 return this.shippingCountryTarget.value.trim().toUpperCase() || "AU" 742 } 743 744 // The mobile rule for the destination, or null for a country we don't ship to 745 // (the server refuses those anyway â Ecommerce::ShipsTo). 746 mobileRule() { 747 if (!this.mobileRequired()) return null 748 return this.mobileRules[this.shippingCountry()] || null 749 } 750 751 // Whether the mobile rule applies to THIS buyer. It doesn't for an 752 // institutional one: a school, university, council or department answers on a 753 // switchboard, and the teacher or admin officer placing the order has no 754 // mobile to give in its place, so the rule can only refuse the order â it 755 // can't produce the number it's asking for. Same population as the 756 // purchase-order gate, which is what the server reuses too 757 // (CheckoutsController#validate_shipping_phone -> #purchase_order_allowed?). 758 // UI only; the server decides. 759 mobileRequired() { 760 // The server already decided for the signed-in customer and for a cart that 761 // arrived from an uploaded purchase order â neither is visible from the DOM, 762 // and a client that re-derived only the email rule would red-ring a number 763 // the server is about to accept. 764 if (this.hasMobileRequiredValue && !this.mobileRequiredValue) return false 765 if (this.isPurchaseOrderDomain((this.emailInput?.value || "").trim())) return false 766 return !this.purchaseOrderOptionTargets.some((o) => o.dataset.poEligible === "true") 767 } 768 769 // The phone field is one control serving two rules, so its label, hint and 770 // placeholder have to say which one is in force â otherwise a school reads 771 // "Mobile phone *" over a switchboard number we've just accepted. 772 applyPhoneRequirementCopy() { 773 const required = this.mobileRequired() 774 if (this.hasPhoneLabelTarget) this.phoneLabelTarget.textContent = required ? "Mobile phone" : "Contact phone" 775 if (this.hasPhoneHintTarget) { 776 this.phoneHintTarget.textContent = required 777 ? "So the courier can send you delivery updates." 778 : "So the courier can reach you on delivery. A switchboard or reception number is fine." 779 } 780 if (this.hasPhoneTarget) { 781 this.phoneTarget.placeholder = required 782 ? (this.mobileRules[this.shippingCountry()]?.placeholder || "")
783 : "e.g. 02 8383 7333" 784 } 785 if (!required) { this.clearPhoneError(); this.clearPhoneFixError() } 786 } 787 788 // Mirrors Ecommerce::MobilePhone.clean â strip the separators a keypad/paste inserts. 789 cleanPhone(phone) { 790 return (phone || "").replace(/[\s\-()]/g, "") 791 } 792 793 // Mirrors Ecommerce::MobilePhone.valid_mobile? / .foreign_mobile? â the destination's 794 // own mobile shape, or (where the rule allows it) another shipped-to country's mobile 795 // written unambiguously with its + country code. 796 isValidMobile(phone) { 797 const rule = this.mobileRule() 798 if (!rule) return false 799 800 const cleaned = this.cleanPhone(phone) 801 if (rule.regex.test(cleaned)) return true 802 if (!rule.allowForeign || !cleaned.startsWith("+")) return false 803 return Object.values(this.mobileRules).some((r) => r.regex.test(cleaned)) 804 } 805 806 validateShippingPhone() { 807 if (!this.hasPhoneTarget) return true 808 809 const rule = this.mobileRule() 810 if (!rule) { 811 // No mobile rule in force (a country we don't ship to â the server's 812 // ShipsTo gate speaks first â or an institutional buyer). The courier 813 // still needs SOME number to ring, and the field is marked required. 814 if (this.mobileRequired() || this.cleanPhone(this.phoneTarget.value)) { 815 this.clearPhoneError() 816 return true 817 } 818 this.showPhoneError("Please enter a phone number the courier can call on delivery.") 819 return false 820 } 821 822 if (this.isValidMobile(this.phoneTarget.value)) { 823 this.clearPhoneError() 824 this.clearPhoneFixError() 825 return true 826 } 827 828 // Saved-address path: the editable phone field lives in the hidden address 829 // form, so focusing it does nothing. Surface the visible correction panel and 830 // send the customer there instead (the "fix it then and there" affordance). 831 if (this.selectedSavedAddressRadio()) { 832 this.updatePhoneFixPanel() 833 if (this.hasPhoneFixPanelTarget) { 834 this.phoneFixPanelTarget.classList.remove("hidden") 835 this.phoneFixPanelTarget.scrollIntoView({behavior: "smooth", block: "center"}) 836 } 837 this.showPhoneFixError(rule.fixError) 838 if (this.hasPhoneFixTarget) this.phoneFixTarget.focus() 839 return false 840 } 841 842 // Typed "new address" path: inline error under the phone field. 843 const message = this.cleanPhone(this.phoneTarget.value) ? rule.inlineError : rule.blankError 844 this.showPhoneError(message) 845 return false 846 } 847 848 // The text fields whose values get printed on a courier label (skip 849 // phone/zip/country â they have their own formats and aren't transliterated). 850 latinAddressFields() { 851 const selector = 'input[name^="shipping_address["], input[name^="billing_address["]' 852 return Array.from(this.element.querySelectorAll(selector)).filter((input) => { 853 return !input.disabled && !/\[(phone|zip|country)\]$/.test(input.name) 854 }) 855 } 856 857 // Accepts Latin incl. accented & Vietnamese (José, Nguyá» n, Äặng â printable); 858 // flags CJK, Korean, Japanese, Cyrillic, emoji and unprintable symbols. Mirrors 859 // the server's prepare(): drop control/zero-width chars, NFKD-fold accents & 860 // fullwidth forms, map stroke/ligature letters and smart punctuation, then 861 // require nothing non-ASCII to remain. 862 isLatinValue(value) { 863 if (!value) return true 864 try { 865 let s = value.replace(/[\p{Cc}\p{Cf}]/gu, "") 866 s = s.normalize("NFKD").replace(/\p{Mn}+/gu, "") 867 s = s.replace(/[âââââââââââ]/g, (m) => this.smartPunctuationMap[m] || m) 868 s = s.replace(/[ÄÄÃøÅÅÃÃæÅÅÃþÃðÅÅ]/g, (m) => this.strokeLetterMap[m] || m) 869 return !/[^\x20-\x7E]/.test(s) 870 } catch (e) { 871 // Browser without Unicode property escapes: defer to the authoritative server. 872 return true 873 } 874 } 875 876 validateLatinAddress() { 877 const fields = this.latinAddressFields() 878 let firstBad = null 879 for (const input of fields) { 880 if (this.isLatinValue(input.value)) { 881 input.classList.remove("ring-red-500") 882 } else { 883 input.classList.add("ring-red-500") 884 if (!firstBad) firstBad = input 885 } 886 } 887 888 if (firstBad) { 889 this.showAddressError(this.latinErrorMessage) 890 // The shared banner sits in the shipping section; scroll the offending 891 // field (which may be a billing field further down) into view too. 892 firstBad.scrollIntoView({ block: "center", behavior: "smooth" }) 893 firstBad.focus() 894 return false 895 } 896 this.clearAddressError() 897 return true 898 } 899 900 // Live feedback as the customer types, so they don't reach "Continue" before 901 // discovering a glyph won't ship. 902 addressFieldInput(event) { 903 const input = event.target 904 if (!input.name || !/^(shipping|billing)_address\[/.test(input.name)) return 905 if (/\[(phone|zip|country)\]$/.test(input.name)) return 906 907 if (this.isLatinValue(input.value)) { 908 input.classList.remove("ring-red-500") 909 if (!this.latinAddressFields().some((f) => f.classList.contains("ring-red-500"))) { 910 this.clearAddressError() 911 } 912 } else { 913 input.classList.add("ring-red-500") 914 this.showAddressError(this.latinErrorMessage) 915 } 916 } 917 918 get latinErrorMessage() { 919 return "Please use English (Latin) letters only â couriers like Australia Post can't print Chinese, Korean or other non-Latin characters on a
919shipping label." 920 } 921 922 showAddressError(message) { 923 if (this.hasAddressErrorTarget) { 924 this.addressErrorTarget.textContent = message 925 this.addressErrorTarget.classList.remove("hidden") 926 } 927 } 928 929 clearAddressError() { 930 if (this.hasAddressErrorTarget) { 931 this.addressErrorTarget.textContent = "" 932 this.addressErrorTarget.classList.add("hidden") 933 } 934 } 935 936 // ---- Name quality (>= 3 chars each, first != last) ----------------------- 937 // Applies only to names the customer actually types: a field is "edited" when 938 // its value differs from its server-rendered baseline (defaultValue). Prefilled 939 // returning-customer names and saved-address selections keep their baseline, so 940 // historic data is never flagged. Uses the native Constraint Validation API 941 // (setCustomValidity) so it integrates with the form, plus a visible banner for 942 // immediate feedback. 943 get nameMinLength() { 944 return 2 945 } 946 947 nameFieldPairs() { 948 const pairs = [] 949 const add = (firstSel, lastSel) => { 950 const first = this.element.querySelector(firstSel) 951 const last = this.element.querySelector(lastSel) 952 if (first && last && !first.disabled && !last.disabled) pairs.push({ first, last }) 953 } 954 add('input[name="shipping_address[first_name]"]', 'input[name="shipping_address[last_name]"]') 955 add('input[name="billing_address[first_name]"]', 'input[name="billing_address[last_name]"]') 956 return pairs 957 } 958 959 nameFieldEdited(field) { 960 return field.value.trim() !== (field.defaultValue || "").trim() 961 } 962 963 namePairProblem(first, last) { 964 const fEdited = this.nameFieldEdited(first) 965 const lEdited = this.nameFieldEdited(last) 966 if (!fEdited && !lEdited) return null // both untouched / historic â ignore 967 968 const f = first.value.trim() 969 const l = last.value.trim() 970 const min = this.nameMinLength 971 972 if (fEdited && f.length > 0 && f.length < min) { 973 return { field: first, message: `First name must be at least ${min} characters.` } 974 } 975 if (lEdited && l.length > 0 && l.length < min) { 976 return { field: last, message: `Last name must be at least ${min} characters.` } 977 } 978 if (fEdited && this.isHonorific(f)) { 979 return { field: first, message: "Please enter your first name, not a title (e.g. Mr, Ms, Dr)." } 980 } 981 if (lEdited && this.isHonorific(l)) { 982 return { field: last, message: "Please enter your last name, not a title (e.g. Mr, Ms, Dr)." } 983 } 984 if (f.length > 0 && l.length > 0 && f.toLocaleLowerCase() === l.toLocaleLowerCase()) { 985 return { field: lEdited ? last : first, message: "Please enter a different first and last name." } 986 } 987 return null 988 } 989 990 // True when the whole name is just a title (Mr, Ms, Dr, "Dr." â¦). Trailing 991 // periods and case are ignored; partial names like "Mr John" are NOT flagged. 992 isHonorific(value) { 993 return HONORIFICS.has(value.trim().toLowerCase().replace(/\.+$/, "")) 994 } 995 996 // Updates validity, red rings and the banner without moving the viewport â safe 997 // to call on every keystroke. Returns the first offending field, or null. 998 refreshNames() { 999 let firstBad = null 1000 let message = "" 1001 for (const { first, last } of this.nameFieldPairs()) { 1002 const problem = this.namePairProblem(first, last) 1003 for (const field of [first, last]) { 1004 const isBad = problem && problem.field === field 1005 field.setCustomValidity(isBad ? problem.message : "") 1006 field.classList.toggle("ring-red-500", !!isBad) 1007 } 1008 if (problem && !firstBad) { 1009 firstBad = problem.field 1010 message = problem.message 1011 } 1012 } 1013 if (firstBad) this.showNameError(message, firstBad) 1014 else this.clearNameError() 1015 return firstBad 1016 } 1017 1018 validateNames() { 1019 const firstBad = this.refreshNames() 1020 if (firstBad) { 1021 firstBad.scrollIntoView({ block: "center", behavior: "smooth" }) 1022 firstBad.focus() 1023 return false 1024 } 1025 return true 1026 } 1027 1028 nameFieldInput(event) { 1029 const input = event.target 1030 if (!input.name || !/^(shipping|billing)_address\[(first_name|last_name)\]$/.test(input.name)) return 1031 this.refreshNames() 1032 } 1033 1034 // After programmatically filling names from a saved address (historic), reset 1035 // their baseline so the name-quality check keeps ignoring them until the 1036 // customer actually types something. Scoped to the side that was filled â 1037 // re-baselining the OTHER side would silently "bless" a junk name the customer 1038 // typed there (shipping and billing are both on step 1 at once). 1039 syncNameBaselines(scope) { 1040 const prefix = scope === "billing" ? "billing" : "shipping" 1041 const selectors = [ 1042 `input[name="${prefix}_address[first_name]"]`, 1043 `input[name="${prefix}_address[last_name]"]` 1044 ] 1045 for (const sel of selectors) { 1046 const field = this.element.querySelector(sel) 1047 if (field) field.defaultValue = field.value 1048 } 1049 this.refreshNames() 1050 } 1051 1052 // Show the message in the banner that sits in the offending field's own 1053 // section (shipping vs billing), so the explanation tracks the focused field; 1054 // hide the other section's banner. 1055 showNameError(message, field) { 1056 const scope = field && /^billing_address\[/.test(field.name) ? "billing" : "shipping"
1057 this.nameErrorTargets.forEach((el) => { 1058 if (el.dataset.nameErrorScope === scope) { 1059 el.textContent = message 1060 el.classList.remove("hidden") 1061 } else { 1062 el.textContent = "" 1063 el.classList.add("hidden") 1064 } 1065 }) 1066 } 1067 1068 clearNameError() { 1069 this.nameErrorTargets.forEach((el) => { 1070 el.textContent = "" 1071 el.classList.add("hidden") 1072 }) 1073 } 1074 1075 showPhoneError(message) { 1076 if (this.hasPhoneErrorTarget) { 1077 this.phoneErrorTarget.textContent = message 1078 this.phoneErrorTarget.classList.remove("hidden") 1079 } 1080 if (this.hasPhoneTarget) { 1081 this.phoneTarget.classList.add("ring-red-500") 1082 this.phoneTarget.focus() 1083 } 1084 } 1085 1086 clearPhoneError() { 1087 if (this.hasPhoneErrorTarget) { 1088 this.phoneErrorTarget.textContent = "" 1089 this.phoneErrorTarget.classList.add("hidden") 1090 } 1091 if (this.hasPhoneTarget) { 1092 this.phoneTarget.classList.remove("ring-red-500") 1093 } 1094 } 1095 1096 showPhoneFixError(message) { 1097 if (this.hasPhoneFixErrorTarget) { 1098 this.phoneFixErrorTarget.textContent = message 1099 this.phoneFixErrorTarget.classList.remove("hidden") 1100 } 1101 if (this.hasPhoneFixTarget) this.phoneFixTarget.classList.add("ring-red-500") 1102 } 1103 1104 clearPhoneFixError() { 1105 if (this.hasPhoneFixErrorTarget) { 1106 this.phoneFixErrorTarget.textContent = "" 1107 this.phoneFixErrorTarget.classList.add("hidden") 1108 } 1109 if (this.hasPhoneFixTarget) this.phoneFixTarget.classList.remove("ring-red-500") 1110 } 1111 1112 // The checked saved-address radio, or null when none/the "Add a new address" 1113 // option is selected (i.e. the typed-form path is active). 1114 selectedSavedAddressRadio() { 1115 if (!this.hasAddressCardsTarget) return null 1116 const checked = this.addressCardsTarget.querySelector('input[name="selected_address"]:checked') 1117 return checked && checked.value !== "new" ? checked : null 1118 } 1119 1120 // True when a saved address is selected and the phone we'd submit for it isn't a 1121 // valid mobile for its destination. Reads the LIVE submitted phone (not the 1122 // server's flag) so a correction immediately clears the condition. 1123 selectedSavedCardNeedsMobile() { 1124 if (!this.selectedSavedAddressRadio()) return false 1125 if (!this.mobileRule()) return false 1126 return !this.isValidMobile(this.hasPhoneTarget ? this.phoneTarget.value : "") 1127 } 1128 1129 // Show the correction callout exactly when the selected saved address still needs 1130 // a valid mobile; otherwise hide it. Also retargets its copy at the destination, 1131 // since the same panel serves an AU and an NZ delivery. Idempotent â safe to call 1132 // on connect, on address change and after each keystroke in the fix field. 1133 updatePhoneFixPanel() { 1134 if (!this.hasPhoneFixPanelTarget) return 1135 const needsFix = this.selectedSavedCardNeedsMobile() 1136 this.phoneFixPanelTarget.classList.toggle("hidden", !needsFix) 1137 if (needsFix) this.applyMobileRuleCopy() 1138 if (!needsFix) this.clearPhoneFixError() 1139 } 1140 1141 // Point every piece of destination-specific phone copy at the current country. 1142 applyMobileRuleCopy() { 1143 const rule = this.mobileRule() 1144 if (!rule) return 1145 1146 if (this.hasPhoneTarget) this.phoneTarget.placeholder = rule.placeholder 1147 if (this.hasPhoneFixTarget) this.phoneFixTarget.placeholder = rule.placeholder 1148 if (this.hasPhoneFixLabelTarget) this.phoneFixLabelTarget.textContent = rule.label 1149 if (this.hasPhoneFixMessageTarget) this.phoneFixMessageTarget.textContent = rule.message 1150 } 1151 1152 resetPhoneFix() { 1153 if (this.hasPhoneFixTarget) this.phoneFixTarget.value = "" 1154 this.clearPhoneFixError() 1155 } 1156 1157 // Customer types a corrected number in the callout. Mirror it into the canonical 1158 // (submitted) phone field, keep the selected card's dataset in sync so a re-fill 1159 // can't restore the bad number, persist it, and hide the callout once it's valid. 1160 phoneFixInput() { 1161 if (!this.hasPhoneFixTarget) return 1162 const value = this.phoneFixTarget.value 1163 const valid = this.isValidMobile(value) 1164 1165 if (this.hasPhoneTarget) { 1166 this.phoneTarget.value = value 1167 const radio = this.selectedSavedAddressRadio() 1168 if (radio) { 1169 radio.dataset.phone = value 1170 if (valid) delete radio.dataset.invalidPhone 1171 } 1172 this.saveFormData() 1173 } 1174 1175 if (valid) { 1176 this.clearPhoneFixError() 1177 this.clearPhoneError() 1178 if (this.hasPhoneFixPanelTarget) this.phoneFixPanelTarget.classList.add("hidden") 1179 } else if (this.cleanPhone(value)) { 1180 this.showPhoneFixError(this.mobileRule()?.typingError || "") 1181 } else { 1182 this.clearPhoneFixError() 1183 } 1184 } 1185 1186 countryChanged() { 1187 this.applyMobileRuleCopy() 1188 this.clearPhoneError() 1189 this.updatePhoneFixPanel() 1190 // Re-quote: AU and international price very differently, and a stale quote 1191 // would diverge from what the server bills. Refetch if we're on the shipping 1192 // step, otherwise let the step-2 entry fetch fresh. 1193 this.ratesFetched = false 1194 if (this.currentValue === 2) this.fetchShippingRates() 1195 } 1196 1197 selectAddress(event) { 1198 const radio = event.target 1199 // Switching address starts the correction field fresh; the panel re-evaluates 1200 // below against whichever card is now selected. 1201 this.resetPhoneFix() 1202 if (radio.value === "new") { 1203 // Show form fields, clear them, show save checkbox 1204 if (this.hasAddressFormFieldsTarget) { 1205 this.addressFormFieldsTarget.classList.remove("hidden") 1206 } 1207 this.clearShippingFields() 1208 if (this.hasSaveAddressCheckboxTarget) { 1209 this.saveAddressCheckboxTarget.classList.remove("hidden") 1210 } 1211 } else {
1212 // Hide form fields, fill hidden inputs from data attributes 1213 if (this.hasAddressFormFieldsTarget) { 1214 this.addressFormFieldsTarget.classList.add("hidden") 1215 } 1216 this.fillShippingFromData(radio.dataset) 1217 if (this.hasSaveAddressCheckboxTarget) { 1218 this.saveAddressCheckboxTarget.classList.add("hidden") 1219 } 1220 } 1221 this.updatePhoneFixPanel() 1222 } 1223 1224 fillShippingFromData(data) { 1225 const fieldMap = { 1226 firstName: "#shipping_first_name", 1227 lastName: "#shipping_last_name", 1228 company: "#shipping_company", 1229 address1: "#shipping_address1", 1230 address2: "#shipping_address2", 1231 city: "#shipping_city", 1232 province: "#shipping_province", 1233 zip: "#shipping_zip", 1234 phone: "#shipping_phone", 1235 deliveryInstructions: "#shipping_delivery_instructions" 1236 } 1237 1238 for (const [key, selector] of Object.entries(fieldMap)) { 1239 const el = this.form?.querySelector(selector) 1240 if (el) el.value = data[key] || "" 1241 } 1242 1243 // Set country select 1244 const countrySelect = this.form?.querySelector("#shipping_country") 1245 if (countrySelect && data.country) { 1246 countrySelect.value = data.country 1247 countrySelect.dispatchEvent(new Event("change", { bubbles: true })) 1248 } 1249 1250 this.syncNameBaselines("shipping") 1251 } 1252 1253 clearShippingFields() { 1254 const fields = [ 1255 "#shipping_first_name", "#shipping_last_name", "#shipping_company", 1256 "#shipping_address1", "#shipping_address2", 1257 "#shipping_city", "#shipping_province", 1258 "#shipping_zip", "#shipping_phone", 1259 "#shipping_delivery_instructions" 1260 ] 1261 1262 for (const selector of fields) { 1263 const el = this.form?.querySelector(selector) 1264 if (el) el.value = "" 1265 } 1266 1267 // Reset country to AU default 1268 const countrySelect = this.form?.querySelector("#shipping_country") 1269 if (countrySelect) { 1270 countrySelect.value = "AU" 1271 countrySelect.dispatchEvent(new Event("change", { bubbles: true })) 1272 } 1273 1274 this.syncNameBaselines("shipping") 1275 } 1276 1277 toggleBilling() { 1278 if (!this.hasBillingFieldsTarget) return 1279 1280 const checked = this.billingRadioTargets.find(r => r.checked) 1281 if (!checked) return 1282 1283 const isDifferent = checked.value === "different" 1284 1285 // Show/hide saved address options 1286 if (this.hasBillingSavedAddressesTarget) { 1287 this.billingSavedAddressesTarget.classList.toggle("hidden", !isDifferent) 1288 if (!isDifferent) { 1289 // Reset saved address selection to "Enter manually" 1290 const manualRadio = this.billingSavedAddressesTarget.querySelector('input[value="new"]') 1291 if (manualRadio) manualRadio.checked = true 1292 } 1293 } 1294 1295 if (isDifferent) { 1296 // Show the form by default (manual entry selected) 1297 this.billingFieldsTarget.classList.remove("hidden") 1298 this.billingFieldsTarget.querySelectorAll("input, select").forEach(input => { 1299 input.disabled = false 1300 }) 1301 } else { 1302 // "same" â hide everything 1303 this.billingFieldsTarget.classList.add("hidden") 1304 this.billingFieldsTarget.querySelectorAll("input, select").forEach(input => { 1305 input.disabled = true 1306 }) 1307 // Drop any name-quality error left on the now-hidden billing fields so it 1308 // can't linger as a stale ring / banner. 1309 for (const sel of ['input[name="billing_address[first_name]"]', 'input[name="billing_address[last_name]"]']) { 1310 const field = this.element.querySelector(sel) 1311 if (field) { 1312 field.setCustomValidity("") 1313 field.classList.remove("ring-red-500") 1314 } 1315 } 1316 } 1317 1318 // Reconcile the name banner/rings against the now-visible pairs. 1319 this.refreshNames() 1320 } 1321 1322 selectBillingAddress(event) { 1323 const radio = event.target 1324 if (radio.value === "new") { 1325 // Show manual form 1326 this.billingFieldsTarget.classList.remove("hidden") 1327 this.billingFieldsTarget.querySelectorAll("input, select").forEach(input => { 1328 input.disabled = false 1329 }) 1330 } else { 1331 // Fill from saved address, hide form but keep fields enabled so they submit 1332 this.fillBillingFromData(radio.dataset) 1333 this.billingFieldsTarget.classList.add("hidden") 1334 this.billingFieldsTarget.querySelectorAll("input, select").forEach(input => { 1335 input.disabled = false 1336 }) 1337 } 1338 } 1339 1340 fillBillingFromData(data) { 1341 const fieldMap = { 1342 firstName: "#billing_first_name", 1343 lastName: "#billing_last_name", 1344 address1: "#billing_address1", 1345 address2: "#billing_address2", 1346 city: "#billing_city", 1347 province: "#billing_province", 1348 zip: "#billing_zip", 1349 phone: "#billing_phone" 1350 } 1351 1352 for (const [key, selector] of Object.entries(fieldMap)) { 1353 const el = this.form?.querySelector(selector) 1354 if (el) el.value = data[key] || "" 1355 } 1356 1357 const countrySelect = this.form?.querySelector("#billing_country") 1358 if (countrySelect && data.country) { 1359 countrySelect.value = data.country 1360 } 1361 1362 this.syncNameBaselines("billing") 1363 } 1364 1365 togglePaymentFields() { 1366 const selected = this.paymentMethodRadioTargets.find(r => r.checked) 1367 const showPo = selected && selected.value === "purchase_order" 1368 const showCard = selected && selected.value === "credit_card" 1369 const showPaypal = selected && selected.value === "paypal" 1370 1371 if (this.hasPoFieldsTarget) { 1372 this.poFieldsTarget.classList.toggle("hidden", !showPo) 1373 if (this.hasPoInputTarget) { 1374 this.poInputTarget.disabled = !showPo 1375 if (showPo) { 1376 this.poInputTarget.required = true 1377 } else { 1378 this.poInputTarget.required = false 1379 this.poInputTarget.value = "" 1380 } 1381 } 1382 } 1383 1384 if (this.hasCardFieldsTarget) { 1385 this.cardFieldsTarget.classList.toggle("hidden", !showCard) 1386 if (showCard && !this.stripeInitialized) { 1387 this.initializeStripeElements() 1388 } 1389 } 1390 1391 if (this.hasPaypalFieldsTarget) { 1392 this.paypalFieldsTarget.classList.toggle("hidden", !showPaypal) 1393 // A stale id must never ride along once the customer switches methods â 1394 // the server treats payment_method+paypal_order_id as "capture this". 1395 if (!showPaypal && this.hasPaypalOrderIdTarget) this.paypalOrderIdTarget.value = "" 1396 if (showPaypal) this.dispatch("paypal-selected") 1397 } 1398 1399 // The PayPal button owns its own click lifecycle â the shared submit 1400 // button would place an orderless POST beside it, so it steps aside. 1401 this.updateSubmitButtonForPaymentMethod() 1402 } 1403 1404 isPaypalSelected() { 1405 const selected = this.paymentMethodRadioTargets.find(r => r.checked) 1406 return !!(selected && selected.value === "paypal") 1407 } 1408 1409 // Hide the shared submit button while PayPal is selected on the payment 1410 // step â the PayPal button drives the submission (via onApprove â 1411 // submitWithPaypalOrder). Inline style rather than the `hidden` class: the 1412 // button carries display utilities of its own, and losing that fight would 1413 // leave two competing submit affordances on the money step. 1414 updateSubmitButtonForPaymentMethod() { 1415 if (!this.hasSubmitButtonTarget) return 1416 const hide = this.currentValue === this.stepTargets.length && this.isPaypalSelected() 1417 this.submitButtonTarget.style.display = hide ? "none" : "" 1418 } 1419 1420 // Phase 1: PayPal is immediate-capture only, so the option withdraws when 1421 // the order would be authorize-only (over the store threshold â mirrors 1422 // currentCaptureMethod, which create_paypal_order enforces server-side). 1423 // Deferred-capture carts never render the option at all (server-side gate). 1424 refreshPaypalAvailability() { 1425 if (!this.hasPaypalOptionTarget) return 1426 const available = this.currentCaptureMethod() === "automatic"
1427 this.paypalOptionTargets.forEach((option) => option.classList.toggle("hidden", !available)) 1428 1429 // Never leave a hidden option selected (same recovery as the 1430 // purchase-order eligibility toggle). 1431 const checked = this.paymentMethodRadioTargets.find(r => r.checked) 1432 if (checked && checked.value === "paypal" && !available) { 1433 checked.checked = false 1434 const isHidden = (radio) => !!radio.closest("label")?.classList.contains("hidden") 1435 const firstVisible = this.paymentMethodRadioTargets.find(r => !isHidden(r)) 1436 if (firstVisible) firstVisible.checked = true 1437 this.togglePaymentFields() 1438 } 1439 } 1440 1441 // The card path's validate-before-charging, shared with the PayPal button's 1442 // onClick: never mint a PayPal order for an address the server will bounce. 1443 validateForPaypal() { 1444 if (!this.validateLatinAddress() || !this.validateNames()) { 1445 if (this.currentValue !== 1) { 1446 this.currentValue = 1 1447 this.showStep() 1448 this.validateLatinAddress() 1449 this.validateNames() 1450 } 1451 return false 1452 } 1453 return true 1454 } 1455 1456 // The approved PayPal order id goes into the hidden field and the REAL 1457 // checkout form submits â CheckoutService captures server-side. The 1458 // in-progress flag (named for Stripe, shared by every processor) lets 1459 // interceptSubmit pass this synthetic submission straight through. 1460 submitWithPaypalOrder(orderId) { 1461 if (!this.form || !this.hasPaypalOrderIdTarget) return 1462 this.paypalOrderIdTarget.value = orderId 1463 this.stripePaymentInProgress = true 1464 this.form.requestSubmit() 1465 } 1466 1467 // Show the Purchase Order option only when the customer is allowed to use it: 1468 // a logged-in eligible customer (data-po-eligible) or anyone whose entered 1469 // email auto-qualifies (.edu/.edu.au, .gov/.gov.au, or a trusted institution). 1470 // Mirrors the server gate in CheckoutsController#purchase_order_allowed? / 1471 // Customer.purchase_order_domain? â the server still enforces it, this is just 1472 // to avoid offering an option that would be rejected on submit. If a hidden PO 1473 // option was selected, fall back to the first still-visible method. 1474 evaluatePurchaseOrderEligibility() { 1475 // Before the early return: the phone copy follows the same email, and a 1476 // store with no purchase-order method configured still has this rule. 1477 this.applyPhoneRequirementCopy() 1478 this.updatePhoneFixPanel() 1479 if (!this.hasPurchaseOrderOptionTarget) return 1480 const email = (this.emailInput?.value || "").trim() 1481 const poEmail = this.isPurchaseOrderDomain(email) 1482 1483 this.purchaseOrderOptionTargets.forEach((option) => { 1484 const eligible = option.dataset.poEligible === "true" || poEmail 1485 option.classList.toggle("hidden", !eligible) 1486 }) 1487 1488 // Never leave a hidden option selected, and never leave the form with no 1489 // payment method when a visible one exists â the server default-denies a 1490 // blank method, so an unselected form would dead-end on submit. 1491 const isHidden = (radio) => !!radio.closest("label")?.classList.contains("hidden") 1492 const checked = this.paymentMethodRadioTargets.find(r => r.checked) 1493 if (!checked || isHidden(checked)) { 1494 if (checked) checked.checked = false 1495 const firstVisible = this.paymentMethodRadioTargets.find(r => !isHidden(r)) 1496 if (firstVisible) firstVisible.checked = true 1497 this.togglePaymentFields() 1498 } 1499 } 1500 1501 // Whether the entered email's domain auto-qualifies for purchase orders â 1502 // educational (.edu/.edu.au/.ac.nz/.school.nz), government (.gov/.gov.au/.govt.nz), 1503 // or a trusted institution domain. Mirrors Ecommerce::Customer.purchase_order_domain?. 1504 // UI hint only; the server gate is authoritative. 1505 isPurchaseOrderDomain(email) { 1506 return this.domainUnderAny(email, EDUCATIONAL_SUFFIXES) || 1507 this.domainUnderAny(email, GOVERNMENT_SUFFIXES) || 1508 PURCHASE_ORDER_DOMAINS.includes(this.emailDomain(email)) 1509 } 1510 1511 // The email's domain is a strict subdomain of one of `suffixes` (ends with 1512 // ".suffix"). The leading dot anchors at a label boundary, so a domain merely 1513 // containing the label (`x.edu.attacker.io`, `notgov.au`) is NOT offered the 1514 // option. Mirrors Ecommerce::Customer.domain_under_any?. 1515 domainUnderAny(email, suffixes) { 1516 const domain = this.emailDomain(email) 1517 if (!domain) return false 1518 return suffixes.some((suffix) => domain.endsWith(`.${suffix}`)) 1519 } 1520 1521 // Lower-cased domain of a single well-formed mailbox, or "" â mirrors 1522 // Ecommerce::Customer.email_domain so multi-`@` / whitespace / trailing-dot 1523 // strings (`[email protected]@x.gov.au`, `x@ mit.edu`) don't reveal the option. 1524 emailDomain(email) { 1525 if (!/^[^\s@]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(email)) return "" 1526 return email.split("@")[1].toLowerCase() 1527 } 1528 1529 toggleNewCardFields() { 1530 if (!this.hasNewCardFieldsTarget) return 1531 const selectedSaved = this.savedCardRadioTargets.find(r => r.checked) 1532 const showNewCard = !selectedSaved || selectedSaved.value === "" 1533 this.newCardFieldsTarget.classList.toggle("hidden", !showNewCard) 1534 1535 // The block was display:none until just now, so the Element inside it may 1536 // have a zero-height frame that needs remounting before it will draw. 1537 if (showNewCard) this.ensurePaymentElementRendered() 1538 1539 // If Stripe.js is blocked, the "couldn't load payment system" banner only 1540 // applies when a NEW card is needed: surface it when switching to a new card, 1541 // clear it when falling back to a saved card that doesn't need Elements. 1542 if (this.stripeUnavailable) { 1543 if (showNewCard) this.showStripeError(STRIPE_BLOCKED_MESSAGE)
1544 else this.clearStripeError() 1545 } 1546 } 1547 1548 async initializeStripeElements() { 1549 if (this.stripeInitialized || !this.stripePublishableKeyValue) return 1550 1551 // Wait for Stripe.js to load. If an ad blocker / privacy extension / firewall 1552 // / DNS blocklist stopped js.stripe.com, this rejects instead of hanging 1553 // forever â surface a real message at the card field rather than leaving a 1554 // blank box (and a later "Place Order" click dead-ending on an undefined 1555 // this.elements). The customer can disable the blocker, or pick another 1556 // payment method if one is available. 1557 try { 1558 await waitForStripe() 1559 } catch { 1560 this.stripeUnavailable = true 1561 // A saved-card customer doesn't need Stripe.js â their charge is taken 1562 // server-side off-session â so don't alarm them with a "payment system 1563 // blocked" banner next to a card they can actually use. Show it only when a 1564 // new card (which DOES need Elements) is the active choice; 1565 // toggleNewCardFields surfaces/clears it as they switch savedânew. 1566 if (!this.usingSavedCard()) this.showStripeError(STRIPE_BLOCKED_MESSAGE) 1567 return 1568 } 1569 this.stripeUnavailable = false 1570 1571 this.stripe = Stripe(this.stripePublishableKeyValue) 1572 // captureMethod must match the PaymentIntent the server creates in 1573 // CheckoutsController#create_payment_intent, or confirmPayment is rejected 1574 // with "capture_method (manual) does not match (automatic)". 1575 this.stripeCaptureMethod = this.currentCaptureMethod() 1576 const elementsOptions = { 1577 mode: "payment", 1578 amount: this.calculateTotalCents(), 1579 currency: "aud", 1580 captureMethod: this.stripeCaptureMethod, 1581 appearance: { 1582 theme: document.documentElement.classList.contains("dark") ? "night" : "stripe", 1583 variables: { borderRadius: "8px" } 1584 } 1585 } 1586 // Must match what the server sets on the PaymentIntent; otherwise Stripe 1587 // rejects the confirm call with "setup_future_usage mismatch". 1588 const sfu = this.currentSetupFutureUsage() 1589 if (sfu) elementsOptions.setupFutureUsage = sfu 1590 1591 this.elements = this.stripe.elements(elementsOptions) 1592 1593 if (this.hasStripeCardElementTarget) { 1594 // Name + billing address are collected by OUR checkout form, not the 1595 // Element, so tell Stripe not to render/collect them ("never") and hand 1596 // them over at confirmPayment instead (payment_method_data.billing_details 1597 // built by collectStripeBillingDetails). That's what makes the card 1598 // networks run the AVS postcode/street checks â without it every charge 1599 // lands in Stripe with an empty billing address and "Not provided" 1600 // checks. Email/phone stay "auto": Link owns those inside the Element, 1601 // and passing a field the Element also collects makes confirmPayment 1602 // throw an IntegrationError. 1603 this.paymentElement = this.elements.create("payment", { 1604 fields: { billingDetails: { name: "never", address: "never" } } 1605 }) 1606 // Track the element's completion state so a submit that lands before the 1607 // iframe has committed the last keystroke/autofill can wait for it to 1608 // settle rather than surfacing a false "Card details incomplete". A fresh 1609 // (re)create resets these to "not yet complete", which is correct â the 1610 // capture-method rebuild path re-runs this method, re-registering here. 1611 this.cardComplete = false 1612 this.cardEmpty = true 1613 this.paymentElement.on("change", (e) => { 1614 this.cardComplete = e.complete 1615 this.cardEmpty = e.empty 1616 if (e.complete) { 1617 const resolvers = this.cardCompleteResolvers 1618 this.cardCompleteResolvers = [] 1619 resolvers.forEach((resolve) => resolve()) 1620 } 1621 }) 1622 this.paymentElement.mount(this.stripeCardElementTarget) 1623 this.stripeInitialized = true 1624 } 1625 } 1626 1627 // Resolves as soon as the Payment Element reports complete:true, or after a 1628 // short ceiling if it doesn't â NEVER rejects. This closes the race window 1629 // (uncommitted keystroke / in-flight autofill) without ever blocking the
1630 // customer for more than the grace period; elements.submit() remains the final 1631 // arbiter, so a genuinely-incomplete card still gets Stripe's precise error. 1632 awaitCardComplete(timeoutMs = CARD_COMPLETE_GRACE_MS) { 1633 if (this.cardComplete) return Promise.resolve() 1634 return new Promise((resolve) => { 1635 let settled = false 1636 const done = () => { 1637 if (settled) return 1638 settled = true 1639 this.cardCompleteResolvers = this.cardCompleteResolvers.filter((r) => r !== done) 1640 resolve() 1641 } 1642 this.cardCompleteResolvers.push(done) 1643 setTimeout(done, timeoutMs) 1644 }) 1645 } 1646 1647 currentSetupFutureUsage() { 1648 if (!this.customerLoggedInValue) return null 1649 if (!this.hasSaveCardCheckboxTarget) return null 1650 return this.saveCardCheckboxTarget.checked ? "off_session" : null 1651 } 1652 1653 // Mirrors Ecommerce::Storefront::CheckoutsController#create_payment_intent: 1654 // capture is "manual" (authorize now, capture on fulfilment) when the cart 1655 // holds backorder/supplier items, or the order total reaches the store's 1656 // authorize-only threshold. MUST stay in step with that controller â Stripe 1657 // rejects confirmPayment if this differs from the PaymentIntent's 1658 // capture_method. 1659 currentCaptureMethod() { 1660 if (this.needsDeferredCaptureValue) return "manual" 1661 const threshold = this.authorizeOnlyThresholdCentsValue 1662 if (threshold > 0 && this.calculateTotalCents() >= threshold) return "manual" 1663 return "automatic" 1664 } 1665 1666 // Keeps Elements aligned with the final order total once a shipping rate is 1667 // chosen. The amount can be updated in place, but Stripe.js can't change 1668 // captureMethod via update(); if the total has crossed the authorize-only 1669 // threshold we rebuild Elements instead. Safe to rebuild here â card details 1670 // are entered on the payment step, so we only reach this before anything has 1671 // been typed. 1672 async refreshStripeElements() { 1673 if (!this.stripeInitialized || !this.elements) return 1674 if (this.currentCaptureMethod() !== this.stripeCaptureMethod) { 1675 if (this.paymentElement) this.paymentElement.unmount() 1676 this.elements = null 1677 this.paymentElement = null 1678 this.stripeInitialized = false 1679 await this.initializeStripeElements() 1680 return 1681 } 1682 // Awaited so an in-flight amount update can't still be settling when a fast 1683 // click reaches elements.submit() â same class of race the card-complete 1684 // gate guards against. 1685 await this.elements.update({ amount: this.calculateTotalCents() }) 1686 } 1687 1688 async saveCardToggled() { 1689 if (!this.stripeInitialized || !this.elements) return 1690 // Keep Elements in sync with the value the server will use when creating 1691 // the PaymentIntent. Pass `null` (not undefined) to explicitly clear it. 1692 await this.elements.update({ setupFutureUsage: this.currentSetupFutureUsage() }) 1693 } 1694 1695 calculateTotalCents() { 1696 const subtotal = this.hasSummaryTotalTarget 1697 ? parseFloat(this.summaryTotalTarget.dataset.subtotal) || 0 1698 : 0 1699 const shipping = this.selectedShippingPrice || 0 1700 return Math.round((subtotal + shipping) * 100) || 100 1701 } 1702 1703 // Shipping context for create_payment_intent. The server re-resolves postage 1704 // from method + postcode and sizes the hold on THAT (not shipping_price, which 1705 // can lag/zero out), so we send them alongside the client's quoted price. 1706 selectedShippingMethodCode() { 1707 return this.form?.querySelector('input[name="shipping_method"]:checked')?.value || "" 1708 } 1709 1710 currentPostcode() { 1711 let postcode = this.form?.querySelector("#shipping_zip")?.value?.trim() 1712 if (!postcode && this.hasAddressCardsTarget) { 1713 const checked = this.addressCardsTarget.querySelector('input[name="selected_address"]:checked') 1714 if (checked && checked.value !== "new" && checked.dataset.zip) postcode = checked.dataset.zip.trim() 1715 } 1716 return postcode || "" 1717 } 1718 1719 // The destination address the customer entered (or the selected saved card), 1720 // shared by the rate fetch and the payment-intent call so BOTH size shipping 1721 // for the SAME country. Reads form fields first, falling back to the checked 1722 // saved-address radio's data attributes. 1723 collectShippingAddress() { 1724 const savedAddr = this.hasAddressCardsTarget 1725 ? this.addressCardsTarget.querySelector('input[name="selected_address"]:checked')?.dataset 1726 : null 1727 const useSaved = savedAddr && savedAddr.zip 1728 const field = (sel) => this.form?.querySelector(sel)?.value?.trim() 1729 return { 1730 first_name: field("#shipping_first_name") || (useSaved && savedAddr.firstName) || "", 1731 last_name: field("#shipping_last_name") || (useSaved && savedAddr.lastName) || "", 1732 company: field("#shipping_company") || "", 1733 address1: field("#shipping_address1") || (useSaved && savedAddr.address1) || "", 1734 city: field("#shipping_city") || (useSaved && savedAddr.city) || "", 1735 province: field("#shipping_province") || (useSaved && savedAddr.province) || "", 1736 zip: field("#shipping_zip") || (useSaved && savedAddr.zip) || "", 1737 phone: field("#shipping_phone") || (useSaved && savedAddr.phone) || "", 1738 country: (this.hasShippingCountryTarget ? this.shippingCountryTarget.value : null) || (useSaved && savedAddr.country) || "AU" 1739 } 1740 } 1741 1742 // The billing details the customer already gave us on step 1, shaped for 1743 // Stripe's payment_method_data.billing_details. Uses the dedicated billing
1744 // fields when "use a different billing address" is selected, otherwise the 1745 // shipping address â mirroring CheckoutService#billing_address_hash, so the 1746 // address Stripe verifies is the same one the order records. The Payment 1747 // Element is created with fields.billingDetails name/address "never", which 1748 // makes passing this at confirmPayment mandatory. 1749 collectStripeBillingDetails() { 1750 try { 1751 const presence = (v) => { 1752 const trimmed = typeof v === "string" ? v.trim() : "" 1753 return trimmed.length ? trimmed : null 1754 } 1755 const field = (sel) => presence(this.form?.querySelector(sel)?.value) 1756 1757 // "Different billing" only counts when it actually has an address â 1758 // if the billing form somehow reaches payment blank (the fields are 1759 // required, but belt-and-braces), fall back to the shipping address so 1760 // Stripe always gets real data rather than nulls. 1761 const differentBilling = this.hasBillingFieldsTarget && 1762 this.billingRadioTargets.find(r => r.checked)?.value === "different" && 1763 !!field("#billing_address1") 1764 1765 let first, last, address 1766 if (differentBilling) { 1767 first = field("#billing_first_name") 1768 last = field("#billing_last_name") 1769 address = { 1770 line1: field("#billing_address1"), 1771 line2: field("#billing_address2"), 1772 city: field("#billing_city"), 1773 state: field("#billing_province"), 1774 postal_code: field("#billing_zip"), 1775 country: field("#billing_country") || "AU" 1776 } 1777 } else { 1778 const shipping = this.collectShippingAddress() 1779 first = presence(shipping.first_name) 1780 last = presence(shipping.last_name) 1781 address = { 1782 line1: presence(shipping.address1), 1783 line2: null, 1784 city: presence(shipping.city), 1785 state: presence(shipping.province), 1786 postal_code: presence(shipping.zip), 1787 country: presence(shipping.country) || "AU" 1788 } 1789 } 1790 return { name: [first, last].filter(Boolean).join(" ") || null, address } 1791 } catch { 1792 // Never let a DOM quirk here block payment: with name/address set to 1793 // "never" on the Element, billing_details MUST be passed at confirm â 1794 // send the minimum rather than throwing before confirmPayment runs. 1795 // Every key the Element was told "never" to collect must still be 1796 // PRESENT here (explicit null is fine, a missing key is not) â a 1797 // partial object would itself throw at confirmPayment. 1798 return { name: null, address: { line1: null, line2: null, city: null, state: null, postal_code: null, country: "AU" } } 1799 } 1800 } 1801 1802 paymentIntentShippingParams() { 1803 // CRITICAL: send the full shipping_address (incl. country), not just the 1804 // postcode. The server's authorized_shipping_cents defaults country to "AU" 1805 // when no shipping_address is posted, which quoted DOMESTIC postage for an 1806 // overseas order and under-held the hold while the order billed the higher 1807 // international rate â capturing short (#LB2617308 to Canada, #LB2617338 to 1808 // NZ). fetchShippingRates already sends country (so the customer SEES the 1809 // international price); the hold must be sized on the SAME country. 1810 const shippingAddress = this.collectShippingAddress() 1811 return { 1812 shipping_price: this.selectedShippingPrice || 0, 1813 shipping_method: this.selectedShippingMethodCode(), 1814 postcode: this.currentPostcode(), 1815 country: shippingAddress.country, 1816 shipping_address: shippingAddress 1817 } 1818 } 1819 1820 isCreditCardSelected() { 1821 const selected = this.paymentMethodRadioTargets.find(r => r.checked) 1822 return selected && selected.value === "credit_card" 1823 } 1824 1825 usingSavedCard() { 1826 const selected = this.savedCardRadioTargets.find(r => r.checked) 1827 return selected && selected.value !== "" 1828 } 1829 1830 // confirmPayment runs with redirect: "if_required" and the in-page success 1831 // path submits the order form itself â but a payment method that NEEDS a 1832 // redirect (Link pay-by-bank on mobile Safari did this in prod: $36.25 1833 // captured, no order â pi_3TtqjwCm4MbCKwrv0oeVrPiI) navigates away before 1834 // that promise resolves and returns to return_url with payment_intent + 1835 // redirect_status query params, which nothing used to handle. The form
1836 // fields have already been restored from localStorage by restoreFormData(); 1837 // all that's missing is the PaymentIntent id and the submit â do both. The 1838 // server re-verifies the PI with Stripe before trusting it, and 1839 // convert_cart_once makes a replayed submit return the existing order rather 1840 // than duplicating it. The webhook-driven ReconcileOrphanedPaymentJob remains 1841 // the backstop if this page never loads at all. 1842 handleStripeRedirectReturn() { 1843 const params = new URLSearchParams(window.location.search) 1844 const piId = params.get("payment_intent") 1845 const redirectStatus = params.get("redirect_status") 1846 if (!piId || !this.form || !this.hasPaymentIntentIdTarget) return 1847 1848 // Drop Stripe's params from the address bar so a refresh can't re-run this 1849 // with a stale intent (the current render still acts on them below). 1850 const cleaned = new URL(window.location.href) 1851 cleaned.searchParams.delete("payment_intent") 1852 cleaned.searchParams.delete("payment_intent_client_secret") 1853 cleaned.searchParams.delete("redirect_status") 1854 window.history.replaceState({}, "", cleaned.toString()) 1855 1856 if (redirectStatus === "succeeded") { 1857 this.paymentIntentIdTarget.value = piId 1858 this.stripePaymentInProgress = true 1859 this.setSubmitLoading(true) 1860 this.form.requestSubmit() 1861 } else if (redirectStatus === "pending") { 1862 // Bank-debit rails can take a moment to settle. If it succeeds, the 1863 // payment_intent.succeeded webhook + reconcile job build the order 1864 // server-side â never charge them a second time here. 1865 this.showStripeError("Your payment is still processing. Don't pay again â your order will be created automatically once the payment settles, and we'll email your confirmation.") 1866 } else { 1867 this.showStripeError("Your payment could not be completed and you have not been charged. Please try again.") 1868 } 1869 } 1870 1871 async handleCreditCardSubmit(event) { 1872 event.preventDefault() 1873 1874 // One payment attempt at a time. setSubmitLoading disables the button, but 1875 // an Enter-key submit (or a click landing before the disable paints) can 1876 // re-enter here while the first attempt is mid-flight â each entry creates 1877 // and confirms its OWN PaymentIntent, double-charging the card (prod 1878 // #LB2617634: two Link charges one second apart). The flag is set below, 1879 // after the synchronous validation bounce (which must not latch it), and 1880 // clears on every failure path via setSubmitLoading(false); on success we 1881 // navigate away. 1882 if (this.paymentAttemptInFlight) return 1883 1884 // Never authorize/charge a card for a name/address the server will reject as 1885 // unprintable for couriers â that would leave an orphaned Stripe charge with 1886 // no order. Bounce the customer back to the address step to fix it first. 1887 if (!this.validateLatinAddress() || !this.validateNames()) { 1888 if (this.currentValue !== 1) { 1889 this.currentValue = 1 1890 this.showStep() 1891 this.validateLatinAddress() 1892 this.validateNames() 1893 } 1894 return 1895 } 1896 1897 this.paymentAttemptInFlight = true 1898 this.setSubmitLoading(true) 1899 this.clearStripeError() 1900 1901 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content 1902 1903 if (this.usingSavedCard()) { 1904 // Off-session charge with saved card 1905 const savedPmId = this.savedCardRadioTargets.find(r => r.checked).value 1906 try { 1907 const response = await fetch(this.createPaymentIntentUrlValue, { 1908 method: "POST", 1909 headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": csrfToken }, 1910 body: JSON.stringify({ 1911 saved_payment_method_id: savedPmId, 1912 ...this.paymentIntentShippingParams() 1913 }) 1914 }) 1915 const data = await response.json() 1916 if (data.error) { 1917 this.showStripeError(data.error) 1918 this.setSubmitLoading(false) 1919 return 1920 } 1921 // "requires_capture" = a manual-capture authorization succeeded (the 1922 // charge is captured later at fulfilment); treat it as success, same as 1923 // an immediately-captured "succeeded" intent. 1924 if (data.status === "succeeded" || data.status === "requires_capture") { 1925 this.paymentIntentIdTarget.value = data.payment_intent_id 1926 this.stripePaymentInProgress = true 1927 this.form.requestSubmit() 1928 } else {
1929 this.showStripeError("Payment could not be completed. Please try again.") 1930 this.setSubmitLoading(false) 1931 } 1932 } catch (err) { 1933 this.showStripeError("An error occurred. Please try again.") 1934 this.setSubmitLoading(false) 1935 } 1936 } else { 1937 // New card â needs Stripe.js / Elements. 1938 // If Stripe.js only finished loading AFTER our bounded wait (slow network or 1939 // a tab that was backgrounded during load), recover in place and mount 1940 // Elements now, rather than staying stuck on the blocked message. 1941 if (this.stripeUnavailable && typeof Stripe !== "undefined") { 1942 this.stripeUnavailable = false 1943 this.stripeInitialized = false 1944 await this.initializeStripeElements() 1945 } 1946 // Genuinely blocked (ad blocker / firewall / DNS block): tell the customer 1947 // why and re-enable the button rather than dead-ending on an undefined 1948 // this.elements. 1949 if (this.stripeUnavailable) { 1950 this.showStripeError(STRIPE_BLOCKED_MESSAGE) 1951 this.setSubmitLoading(false) 1952 return 1953 } 1954 // Still mounting Elements (a fast click during a normal load): silently 1955 // re-enable so the next click proceeds once the card field is ready â no 1956 // false "blocked" message for what is just a race. 1957 if (!this.stripe || !this.elements) { 1958 this.setSubmitLoading(false) 1959 return 1960 } 1961 1962 // Everything that touches Stripe runs inside one try/catch so a throw in 1963 // elements.submit() (which used to sit OUTSIDE it) can't leave the button 1964 // stuck on "Processing paymentâ¦" with no message.
1965 try { 1966 // A shipping-rate change on entering this step may have left an 1967 // elements.update() (amount/setupFutureUsage) still settling; wait for it 1968 // so a very fast click can't race submit() against an in-flight update. 1969 if (this.pendingElementsUpdate) await this.pendingElementsUpdate 1970 1971 // The customer has typed into the card field but the iframe may not have 1972 // committed the final keystroke (or an autofill burst) yet â submitting 1973 // now would falsely report "Card details incomplete". Give it a brief 1974 // moment to settle. A truly-empty field skips the wait and falls straight 1975 // through to Stripe's normal inline error. 1976 if (!this.cardComplete && !this.cardEmpty) { 1977 await this.awaitCardComplete() 1978 } 1979 1980 const { error: submitError } = await this.elements.submit() 1981 if (submitError) { 1982 this.showStripeError(submitError.message) 1983 this.setSubmitLoading(false) 1984 return 1985 } 1986 1987 const saveCard = this.hasSaveCardCheckboxTarget && this.saveC
1987ardCheckboxTarget.checked ? "1" : "0" 1988 const response = await fetch(this.createPaymentIntentUrlValue, { 1989 method: "POST", 1990 headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": csrfToken }, 1991 body: JSON.stringify({ 1992 save_card: saveCard, 1993 ...this.paymentIntentShippingParams() 1994 }) 1995 }) 1996 const data = await response.json() 1997 if (data.error) { 1998 this.showStripeError(data.error) 1999 this.setSubmitLoading(false) 2000 return 2001 } 2002 2003 const { error, paymentIntent } = await this.stripe.confirmPayment({ 2004 elements: this.elements, 2005 clientSecret: data.client_secret, 2006 confirmParams: { 2007 return_url: window.location.href, 2008 // Required: the Element's name/address fields are "never" (see 2009 // initializeStripeElements), so the billing details our form 2010 // collected must ride along here for AVS checks to run. 2011 payment_method_data: { 2012 billing_details: this.collectStripeBillingDetails() 2013 } 2014 }, 2015 redirect: "if_required" 2016 }) 2017 2018 if (error) { 2019 this.showStripeError(error.message) 2020 this.setSubmitLoading(false) 2021 } else if (paymentIntent && (paymentIntent.status === "succeeded" || paymentIntent.status === "requires_capture")) { 2022 // "requires_capture" = manual-capture authorization succeeded; the 2023 // charge is captured later at fulfilment. The order is placed now. 2024 this.paymentIntentIdTarget.value = paymentIntent.id 2025 this.stripePaymentInProgress = true 2026 this.form.requestSubmit() 2027 } else { 2028 this.showStripeError("Payment could not be completed. Please try again.") 2029 this.setSubmitLoading(false) 2030 } 2031 } catch (err) { 2032 this.showStripeError("An error occurred. Please try again.") 2033 this.setSubmitLoading(false) 2034 } 2035 } 2036 } 2037 2038 showStripeError(message) { 2039 if (this.hasStripeErrorsTarget && this.hasStripeErrorMessageTarget) { 2040 this.stripeErrorMessageTarget.textContent = message 2041 this.stripeErrorsTarget.classList.remove("hidden") 2042 } 2043 } 2044 2045 clearStripeError() { 2046 if (this.hasStripeErrorsTarget) { 2047 this.stripeErrorsTarget.classList.add("hidden") 2048 } 2049 } 2050 2051 setSubmitLoading(loading) { 2052 // Leaving the loading state means the payment attempt is over (error or 2053 // decline) â allow the customer to try again. 2054 if (!loading) this.paymentAttemptInFlight = false 2055 if (this.hasSubmitButtonTarget) { 2056 this.submitButtonTarget.disabled = loading 2057 if (loading) { 2058 this.submitButtonTarget.value = "Processing payment..." 2059 this.submitButtonTarget.dataset.disableWith = "Processing payment..." 2060 } else { 2061 this.submitButtonTarget.value = "Place Order" 2062 this.submitButtonTarget.dataset.disableWith = "Processing order..." 2063 } 2064 } 2065 } 2066 2067 toggleSummary() { 2068 if (!this.hasSummaryContentTarget) return 2069 this.summaryContentTarget.classList.toggle("hidden") 2070 2071 if (this.hasSummaryToggleTarget) { 2072 const isHidden = this.summaryContentTarget.classList.contains("hidden") 2073 this.summaryToggleTarget.textContent = isHidden ? "Show order summary" : "Hide order summary" 2074 } 2075 } 2076 2077 saveFormData() { 2078 const form = this.element.querySelector("form") 2079 if (!form) return 2080 2081 const data = {} 2082 form.querySelectorAll("input, select, textarea").forEach(el => { 2083 if (!el.name) return 2084 if (el.type === "radio") { 2085 if (el.checked) data[`radio:${el.name}`] = el.value 2086 } else if (el.type === "checkbox") { 2087 data[`checkbox:${el.name}`] = el.checked 2088 } else if (el.id) { 2089 data[el.id] = el.value 2090 } 2091 }) 2092 2093 data._step = this.currentValue 2094 localStorage.setItem(this.storageKey, JSON.stringify(data)) 2095 } 2096 2097 // A checkout rendered from a prefill (an uploaded purchase order) carries 2098 // server-rendered values for the whole form. restoreFormData() would happily 2099 // paste this browser's last checkout over the top of them, so drop that saved 2100 // copy the FIRST time each prefill is seen. 2101 // 2102 // First time only, deliberately: a server 422 re-renders this page, and by 2103 // then the localStorage copy holds edits the customer made here that the 2104 // stale server-side prefill knows nothing about. Remembering which prefill we 2105 // already consumed keeps both cases right. 2106 consumePrefill() { 2107 const token = this.prefillTokenValue 2108 if (!token) return 2109 if (localStorage.getItem(this.prefillStorageKey) === token) return 2110 2111 localStorage.removeItem(this.storageKey) 2112 localStorage.setItem(this.prefillStorageKey, token) 2113 } 2114 2115 // Select the payment method the prefill asked for, but only if the customer 2116 // can actually see it â evaluatePurchaseOrderEligibility() decides that, and 2117 // must have run first. 2118 applyPrefilledPaymentMethod() { 2119 const wanted = this.prefillPaymentMethodValue 2120 if (!wanted || !this.hasPaymentMethodRadioTarget) return 2121 // Once per prefill, like consumePrefill: this runs on every connect, and a 2122 // 422 bounce (or any re-render) would otherwise drag a customer who had 2123 // deliberately switched to paying by card back onto the purchase order. 2124 if (localStorage.getItem(this.prefillPaymentStorageKey) === this.prefillTokenValue) return 2125 localStorage.setItem(this.prefillPaymentStorageKey, this.prefillTokenValue) 2126 2127 const radio = this.paymentMethodRadioTargets.find((el) => el.value === wanted) 2128 if (!radio || radio.closest("label")?.classList.contains("hidden")) return 2129 2130 radio.checked = true 2131 this.togglePaymentFields() 2132 2133 // AFTER the toggle, never before: togglePaymentFields() blanks #po_number 2134 // whenever purchase order isn't the selected method, and on connect it runs 2135 // once against whichever method the server checked first â which would wipe 2136 // the number we read off the customer's own PO. 2137 if (this.prefillPoNumberValue && this.hasPoInputTarget && !this.poInputTarget.disabled) { 2138 this.poInputTarget.value = this.prefillPoNumberValue 2139 } 2140 } 2141 2142 restoreFormData() { 2143 const raw = localStorage.getItem(this.storageKey) 2144 if (!raw) return 2145 2146 try { 2147 const data = JSON.parse(raw) 2148 const form = this.element.querySelector("form") 2149 if (!form) return 2150 2151 form.querySelectorAll("input, select, textarea").forEach(el => { 2152 if (!el.name) return 2153 if (el.type === "radio") { 2154 const saved = data[`radio:${el.name}`] 2155 if (saved !== undefined) el.checked = (el.value === saved) 2156 } else if (el.type === "checkbox") { 2157 const saved = data[`checkbox:${el.name}`] 2158 if (saved !== undefined) el.checked = saved 2159 } else if (el.id && data[el.id]) { 2160 el.value = data[el.id] 2161 } 2162 }) 2163 2164 if (data._step) this.currentValue = data._step 2165 // Remember a previously-corrected phone so connect's card-fill (which would 2166 // otherwise overwrite #shipping_phone with the saved card's stale number) 2167 // can re-apply it â keeps an inline fix across a server 422 bounce.
2168 this.restoredShippingPhone = data["shipping_phone"] 2169 } catch { 2170 // Ignore corrupted data 2171 } 2172 } 2173}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.