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 { 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)) 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) this.updateTimers.forEach((timer) => clearTimeout(timer)) } 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') 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 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) 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, { method: 'POST', headers: { 'X-CSRF-TOKEN': csrfToken(), 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json', }, body: new FormData(form), }) 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]')) } 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), }, })) } }