generated from boboko/starter
210 lines
7.5 KiB
JavaScript
210 lines
7.5 KiB
JavaScript
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 <form>. 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)
|
|
}
|
|
}
|
|
}
|