Files
3dealer/resources/js/checkout/bbk-checkout-form-controller.js
T

210 lines
7.5 KiB
JavaScript
Raw Normal View History

2026-09-08 20:41:49 +03:00
import { Controller } from '@hotwired/stimulus'
2026-09-09 13:51:00 +03:00
import { csrfToken } from './csrf'
2026-09-08 20:41:49 +03:00
2026-09-09 13:51:00 +03:00
// 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.
2026-09-08 20:41:49 +03:00
export default class extends Controller {
static targets = [
'guestTab', 'loginTab', 'guestPanel', 'loginPanel',
'sameAsBilling', 'shippingFields',
2026-09-09 13:51:00 +03:00
'form', 'shippingOptions', 'status',
2026-09-08 20:41:49 +03:00
]
2026-09-09 13:51:00 +03:00
static values = {
saveUrl: String,
selectShippingUrl: String,
statusSaving: String,
statusSaved: String,
statusError: String,
}
2026-09-08 20:41:49 +03:00
connect() {
2026-09-09 13:51:00 +03:00
this.saveTimer = null
this.saveController = null
this.statusTimer = null
if (this.hasSameAsBillingTarget) this.applySameAsBilling()
}
disconnect() {
clearTimeout(this.saveTimer)
clearTimeout(this.statusTimer)
this.saveController?.abort()
2026-09-08 20:41:49 +03:00
}
2026-09-09 13:51:00 +03:00
// ── Contact tabs ────────────────────────────────────────────────────
2026-09-08 20:41:49 +03:00
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')
}
2026-09-09 13:51:00 +03:00
// ── Same as billing ────────────────────────────────────────────────
2026-09-08 20:41:49 +03:00
toggleSameAsBilling() {
2026-09-09 13:51:00 +03:00
this.applySameAsBilling()
2026-09-08 20:41:49 +03:00
}
2026-09-09 13:51:00 +03:00
applySameAsBilling() {
const on = this.sameAsBillingTarget.checked
2026-09-08 20:41:49 +03:00
2026-09-09 13:51:00 +03:00
// 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
2026-09-08 20:41:49 +03:00
this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => {
2026-09-09 13:51:00 +03:00
field.disabled = on
2026-09-08 20:41:49 +03:00
})
}
2026-09-09 13:51:00 +03:00
// ── Autosave ───────────────────────────────────────────────────────
scheduleSave(event) {
2026-09-09 19:16:20 +03:00
// 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
2026-09-09 13:51:00 +03:00
// 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)
}
2026-09-14 20:13:40 +03:00
// 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()
}
2026-09-09 13:51:00 +03:00
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)
}
}
2026-09-08 20:41:49 +03:00
}