2026-09-04 19:39:37 +03:00
|
|
|
import { Controller } from '@hotwired/stimulus'
|
|
|
|
|
import { csrfToken } from './csrf'
|
|
|
|
|
|
|
|
|
|
// Drives the slide-in cart drawer. One instance, on the drawer root in
|
|
|
|
|
// checkout/drawer.blade.php.
|
|
|
|
|
//
|
|
|
|
|
// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from
|
|
|
|
|
// this drawer's own line forms) and swaps in the server-rendered cart body
|
|
|
|
|
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
|
|
|
|
|
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
|
|
|
|
|
// (e.g. the header bag icon) can react
|
|
|
|
|
//
|
|
|
|
|
// 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 {
|
2026-09-17 21:52:27 +03:00
|
|
|
static targets = ['panel', 'body', 'error']
|
2026-09-04 19:39:37 +03:00
|
|
|
|
|
|
|
|
connect() {
|
|
|
|
|
this.onChanged = this.onChanged.bind(this)
|
|
|
|
|
this.onKeydown = this.onKeydown.bind(this)
|
2026-09-17 21:56:58 +03:00
|
|
|
this.updateTimers = new Map() // line id -> pending debounce timer
|
2026-09-04 19:39:37 +03:00
|
|
|
|
|
|
|
|
window.addEventListener('bbk-cart:changed', this.onChanged)
|
|
|
|
|
window.addEventListener('bbk-cart:open', this.open.bind(this))
|
|
|
|
|
document.addEventListener('keydown', this.onKeydown)
|
|
|
|
|
|
|
|
|
|
// Prime the host with the count rendered server-side on page load.
|
|
|
|
|
this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
disconnect() {
|
|
|
|
|
window.removeEventListener('bbk-cart:changed', this.onChanged)
|
|
|
|
|
document.removeEventListener('keydown', this.onKeydown)
|
2026-09-17 21:56:58 +03:00
|
|
|
this.updateTimers.forEach((timer) => clearTimeout(timer))
|
2026-09-04 19:39:37 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onChanged(event) {
|
|
|
|
|
if (event.detail?.html) this.replaceBody(event.detail.html)
|
|
|
|
|
this.open()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onKeydown(event) {
|
|
|
|
|
if (event.key === 'Escape' && !this.element.hidden) this.close()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
open() {
|
|
|
|
|
if (!this.element.hidden) return
|
|
|
|
|
this.element.hidden = false
|
|
|
|
|
// Next frame, so the panel transitions from its off-canvas start.
|
|
|
|
|
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
close() {
|
|
|
|
|
this.element.removeAttribute('data-bbk-cart-state')
|
|
|
|
|
|
|
|
|
|
const panel = this.panelTarget
|
|
|
|
|
const done = () => {
|
|
|
|
|
this.element.hidden = true
|
|
|
|
|
panel.removeEventListener('transitionend', done)
|
|
|
|
|
}
|
|
|
|
|
panel.addEventListener('transitionend', done)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// change on a line quantity input, or submit of a line's remove form
|
|
|
|
|
submit(event) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
const form = event.target.closest('form')
|
2026-09-17 21:56:58 +03:00
|
|
|
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)
|
2026-09-04 19:39:37 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// +/- stepper buttons inside a line
|
|
|
|
|
step(event) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
const form = event.target.closest('form')
|
|
|
|
|
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)
|
2026-09-17 21:56:58 +03:00
|
|
|
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))
|
2026-09-04 19:39:37 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async send(form) {
|
|
|
|
|
this.bodyTarget.setAttribute('aria-busy', 'true')
|
2026-09-17 21:52:27 +03:00
|
|
|
this.clearError()
|
2026-09-04 19:39:37 +03:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(form.action, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'X-CSRF-TOKEN': csrfToken(),
|
|
|
|
|
'X-Requested-With': 'XMLHttpRequest',
|
2026-09-17 21:52:27 +03:00
|
|
|
Accept: 'application/json',
|
2026-09-04 19:39:37 +03:00
|
|
|
},
|
|
|
|
|
body: new FormData(form),
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-17 21:52:27 +03:00
|
|
|
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
|
2026-09-04 19:39:37 +03:00
|
|
|
} finally {
|
|
|
|
|
this.bodyTarget.removeAttribute('aria-busy')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 21:52:27 +03:00
|
|
|
showError(message) {
|
|
|
|
|
if (!this.hasErrorTarget || !message) return
|
|
|
|
|
this.errorTarget.textContent = message
|
|
|
|
|
this.errorTarget.hidden = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearError() {
|
|
|
|
|
if (!this.hasErrorTarget) return
|
|
|
|
|
this.errorTarget.hidden = true
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 19:39:37 +03:00
|
|
|
replaceBody(html) {
|
|
|
|
|
this.bodyTarget.innerHTML = html
|
|
|
|
|
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
emitUpdated(node) {
|
|
|
|
|
if (!node) return
|
|
|
|
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('bbk-cart:updated', {
|
|
|
|
|
detail: {
|
|
|
|
|
count: parseInt(node.dataset.bbkCartCount || '0', 10),
|
|
|
|
|
total: parseInt(node.dataset.bbkCartTotal || '0', 10),
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|