Merge branch 'cart-temp' into box-now-test-

This commit is contained in:
2026-09-25 21:49:30 +03:00
131 changed files with 4559 additions and 2688 deletions
@@ -6,12 +6,15 @@ import { csrfToken } from './csrf'
// `bbk-cart:changed` window event. No DOM building here — the drawer
// (bbk-cart-controller) owns rendering.
export default class extends Controller {
static targets = ['error']
async add(event) {
event.preventDefault()
const form = this.element
const submit = form.querySelector('[type="submit"]')
this.clearError()
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
if (submit) submit.disabled = true
@@ -21,11 +24,16 @@ export default class extends Controller {
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (!response.ok) return
if (!response.ok) {
const data = await response.json().catch(() => null)
this.showError(data?.error)
return
}
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: await response.text() },
@@ -35,4 +43,15 @@ export default class extends Controller {
if (submit) submit.disabled = false
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
}
+52 -4
View File
@@ -13,11 +13,12 @@ import { csrfToken } from './csrf'
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
// attribute on the root, nothing here touches styles or class lists.
export default class extends Controller {
static targets = ['panel', 'body']
static targets = ['panel', 'body', 'error']
connect() {
this.onChanged = this.onChanged.bind(this)
this.onKeydown = this.onKeydown.bind(this)
this.updateTimers = new Map() // line id -> pending debounce timer
window.addEventListener('bbk-cart:changed', this.onChanged)
window.addEventListener('bbk-cart:open', this.open.bind(this))
@@ -30,6 +31,7 @@ export default class extends Controller {
disconnect() {
window.removeEventListener('bbk-cart:changed', this.onChanged)
document.removeEventListener('keydown', this.onKeydown)
this.updateTimers.forEach((timer) => clearTimeout(timer))
}
onChanged(event) {
@@ -63,7 +65,11 @@ export default class extends Controller {
submit(event) {
event.preventDefault()
const form = event.target.closest('form')
if (form) this.send(form)
if (!form) return
// A remove is a deliberate, one-shot action — only the quantity form
// (typing, or the +/- stepper below) benefits from debouncing.
form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form)
}
// +/- stepper buttons inside a line
@@ -73,11 +79,27 @@ export default class extends Controller {
const input = form.querySelector('input[type="number"]')
const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir))
input.value = String(next)
this.send(form)
this.scheduleSend(form)
}
// Repeated clicks (or spinner nudges) update the input instantly but only
// send once they settle for 300ms — sending on every single click was
// firing overlapping requests that raced each other and made the drawer
// visibly flicker/lag under quick clicking.
scheduleSend(form) {
const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId
if (!lineId) return this.send(form)
clearTimeout(this.updateTimers.get(lineId))
this.updateTimers.set(lineId, setTimeout(() => {
this.updateTimers.delete(lineId)
this.send(form)
}, 300))
}
async send(form) {
this.bodyTarget.setAttribute('aria-busy', 'true')
this.clearError()
try {
const response = await fetch(form.action, {
@@ -85,16 +107,42 @@ export default class extends Controller {
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (response.ok) this.replaceBody(await response.text())
if (response.ok) {
this.replaceBody(await response.text())
return
}
const data = await response.json().catch(() => null)
this.showError(data?.error)
// The rejected quantity (typed, or from a +/- click) is left
// sitting in the input with nothing to correct it — the update
// never reached the cart, so the input must be put back to what
// the cart actually still holds, not just left showing whatever
// was rejected.
const input = form.querySelector('[data-bbk-cart-confirmed-quantity]')
if (input) input.value = input.dataset.bbkCartConfirmedQuantity
} finally {
this.bodyTarget.removeAttribute('aria-busy')
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
replaceBody(html) {
this.bodyTarget.innerHTML = html
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
@@ -13,7 +13,6 @@ import { csrfToken } from './csrf'
// 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',
]
@@ -41,22 +40,6 @@ export default class extends Controller {
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() {
@@ -93,7 +76,6 @@ export default class extends Controller {
// 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,
@@ -43,6 +43,16 @@ export default class extends Controller {
this.amountValue = total
this.elements.update({ amount: Math.max(total, 1) })
}
// Removing the last line while sitting on the checkout page (via
// the order summary's own remove form) must not leave "place
// order" clickable with nothing left to charge for — this fires
// from both the drawer and the checkout page's own summary
// instance, whichever the shopper actually used.
const count = event.detail?.count
if (typeof count === 'number' && this.hasSubmitTarget) {
this.submitTarget.disabled = count === 0
}
}
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)