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 = [ 'guestTab', 'loginTab', 'guestPanel', 'loginPanel', '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 if (this.hasSameAsBillingTarget) this.applySameAsBilling() } disconnect() { clearTimeout(this.saveTimer) clearTimeout(this.statusTimer) this.saveController?.abort() } // ── Contact tabs ──────────────────────────────────────────────────── showGuest() { this.guestPanelTarget.hidden = false this.loginPanelTarget.hidden = true this.guestTabTarget.setAttribute('aria-selected', 'true') this.loginTabTarget.setAttribute('aria-selected', 'false') } showLogin() { this.guestPanelTarget.hidden = true this.loginPanelTarget.hidden = false this.guestTabTarget.setAttribute('aria-selected', 'false') this.loginTabTarget.setAttribute('aria-selected', 'true') } // ── 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' || el.closest('[data-bbk-checkout-form-target="guestPanel"]') 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() } 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.saveController?.abort() this.setStatus('saving') const body = new FormData() body.append('shipping_option', event.target.value) try { const response = await fetch(this.selectShippingUrlValue, { method: 'POST', headers: { 'X-CSRF-TOKEN': csrfToken(), 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json', }, body, }) if (!response.ok) return this.setStatus('error') this.applyResult(await response.json()) this.setStatus('saved') } catch { this.setStatus('error') } } applyResult(data) { this.applyErrors(data.errors || {}) if (data.shippingOptionsHtml != null) { this.shippingOptionsTarget.innerHTML = data.shippingOptionsHtml } if (data.summaryHtml != null) { window.dispatchEvent(new CustomEvent('bbk-cart:changed', { detail: { html: data.summaryHtml }, })) } } applyErrors(errors) { this.element.querySelectorAll('[data-bbk-field-error]').forEach((el) => { const message = errors[el.dataset.bbkFieldError] el.textContent = message || '' el.hidden = !message const field = this.element.querySelector(`[name="${el.dataset.bbkFieldError}"]`) field?.classList.toggle('bbk-field-input--error', Boolean(message)) }) } setStatus(state) { if (!this.hasStatusTarget) return const text = { saving: this.statusSavingValue, saved: this.statusSavedValue, error: this.statusErrorValue, }[state] this.statusTarget.textContent = text this.statusTarget.hidden = false this.statusTarget.dataset.state = state clearTimeout(this.statusTimer) if (state === 'saved') { this.statusTimer = setTimeout(() => { this.statusTarget.hidden = true }, 2000) } } }