PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/ecommerce/checkout_controller-948bb301.js

js littlebirdelectronics.com.au collected 2026-09-25 14:10:03 UTC 94,656 bytes, 2,173 lines download raw bytes

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.