generated from boboko/starter
checkout and shipping part
This commit is contained in:
@@ -1,20 +1,47 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import { csrfToken } from './csrf'
|
||||
|
||||
// Static, client-only interactivity for the checkout page's address form — no
|
||||
// fetch, no cart mutation, just show/hide. Nothing here talks to the server;
|
||||
// CheckoutController::saveAddress() is what actually persists anything.
|
||||
// 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',
|
||||
]
|
||||
|
||||
connect() {
|
||||
// Reflect whatever the server rendered (old input on a validation
|
||||
// redisplay, or the default) before any click happens.
|
||||
if (this.hasSameAsBillingTarget) this.syncShippingFields()
|
||||
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
|
||||
@@ -29,18 +56,142 @@ export default class extends Controller {
|
||||
this.loginTabTarget.setAttribute('aria-selected', 'true')
|
||||
}
|
||||
|
||||
// ── Same as billing ────────────────────────────────────────────────
|
||||
|
||||
toggleSameAsBilling() {
|
||||
this.syncShippingFields()
|
||||
this.applySameAsBilling()
|
||||
}
|
||||
|
||||
// Disabled fields are simply never submitted by the browser — the server
|
||||
// never sees stale shipping values while "same as billing" is checked.
|
||||
syncShippingFields() {
|
||||
const sameAsBilling = this.sameAsBillingTarget.checked
|
||||
applySameAsBilling() {
|
||||
const on = this.sameAsBillingTarget.checked
|
||||
|
||||
this.shippingFieldsTarget.hidden = sameAsBilling
|
||||
// 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 = sameAsBilling
|
||||
field.disabled = on
|
||||
})
|
||||
}
|
||||
|
||||
// ── Autosave ───────────────────────────────────────────────────────
|
||||
|
||||
scheduleSave(event) {
|
||||
// The shipping-method radios live inside this controller's element too,
|
||||
// but they have their own handler — don't also autosave the address for them.
|
||||
if (this.hasShippingOptionsTarget && this.shippingOptionsTarget.contains(event.target)) {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user