import { Controller } from '@hotwired/stimulus' import { csrfToken } from './csrf' // Drives the checkout page's left column: contact tabs, the same-as-billing // toggle, and — the bulk of it — autosaving the address form and the shipping // method with no submit buttons. // // Flow: any `change` in the address form is debounced ~400ms, then the whole // form is POSTed to saveUrl. The server persists leniently and returns // { errors, shippingOptionsHtml, summaryHtml }. We swap the shipping-options // block in place and hand the summary fragment to the drawer's bbk-cart // controller via the `bbk-cart:changed` window event (same mechanism the drawer // already uses). Shipping-method radios post to selectShippingUrl the same way. export default class extends Controller { static targets = [ 'sameAsBilling', 'shippingFields', 'form', 'shippingOptions', 'status', ] static values = { saveUrl: String, selectShippingUrl: String, statusSaving: String, statusSaved: String, statusError: String, } connect() { this.saveTimer = null this.saveController = null this.statusTimer = null this.shippingPromise = null if (this.hasSameAsBillingTarget) this.applySameAsBilling() } disconnect() { clearTimeout(this.saveTimer) clearTimeout(this.statusTimer) this.saveController?.abort() } // ── Same as billing ──────────────────────────────────────────────── toggleSameAsBilling() { this.applySameAsBilling() } applySameAsBilling() { const on = this.sameAsBillingTarget.checked // Checked: shipping *is* billing — copy every value across, then hide + // disable so the browser doesn't submit them; the server reuses billing. // Unchecked: reveal them pre-filled from billing wherever still empty. this.element.querySelectorAll('[name^="billing_"]').forEach((billingField) => { const shippingField = this.element.querySelector( `[name="${billingField.name.replace(/^billing_/, 'shipping_')}"]`, ) if (shippingField && (on || !shippingField.value)) { shippingField.value = billingField.value } }) this.shippingFieldsTarget.hidden = on this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => { field.disabled = on }) } // ── Autosave ─────────────────────────────────────────────────────── scheduleSave(event) { // The shipping-method and payment radios live inside this controller's // element too, and this action is bound on .bbk-checkout-main to also // catch the contact email/consent that sit outside the
. Only // react to fields that actually belong to the address form. const el = event.target const belongsToForm = el.form?.id === 'bbk-address-form' if (!belongsToForm) return // No status during the wait — it only shows once the request is in flight, // so the indicator isn't flickering "saving" on every keystroke. clearTimeout(this.saveTimer) this.saveTimer = setTimeout(() => this.save(), 700) } // Called by bbk-payment right before place-order — a debounced save (and // the shipping-option auto-select that happens as part of it) might still // be pending when the shopper clicks "place order"; this guarantees the // server has processed the current form state first. async flush() { clearTimeout(this.saveTimer) await this.save() // A shipping-method radio click fires its own (undebounced) request — // still async, still racy against an immediate "place order" click. if (this.shippingPromise) await this.shippingPromise } async save() { this.saveController?.abort() this.saveController = new AbortController() this.setStatus('saving') try { const response = await fetch(this.saveUrlValue, { method: 'POST', headers: { 'X-CSRF-TOKEN': csrfToken(), 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json', }, body: new FormData(this.formTarget), signal: this.saveController.signal, }) if (!response.ok) return this.setStatus('error') this.applyResult(await response.json()) this.setStatus('saved') } catch (error) { if (error.name !== 'AbortError') this.setStatus('error') } } async selectShipping(event) { this.toggleBoxNowLocker(event.target.value) // Tracked so flush() can await it — nothing else stops "place order" // (a separate, unrelated click) from racing ahead of this request. this.shippingPromise = this.doSelectShipping(event.target.value) await this.shippingPromise } // The dummy Box Now locker