cart drawer and general structure

This commit is contained in:
elvira
2026-09-04 19:39:37 +03:00
parent c5f7b28aa0
commit 7577426f49
22 changed files with 1075 additions and 8 deletions
+5
View File
@@ -10,8 +10,13 @@ window.Turbo.session.drive = false;
import { Application } from "@hotwired/stimulus";
import { registerControllers } from "./stimulus/index";
import { registerCheckout } from "./checkout";
const application = Application.start();
application.debug = false;
registerControllers(application);
// Portable cart + checkout module (destined for boboko-core). Owns its own
// bbk-* Stimulus controllers; this is the only wiring line it needs here.
registerCheckout(application);
@@ -0,0 +1,38 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Sits on an <x-checkout::add-to-cart> <form>. Submits the line to the cart
// via fetch and hands the server-rendered cart body to the drawer through the
// `bbk-cart:changed` window event. No DOM building here — the drawer
// (bbk-cart-controller) owns rendering.
export default class extends Controller {
async add(event) {
event.preventDefault()
const form = this.element
const submit = form.querySelector('[type="submit"]')
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
if (submit) submit.disabled = true
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: new FormData(form),
})
if (!response.ok) return
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: await response.text() },
}))
} finally {
form.removeAttribute('data-bbk-add-to-cart-state')
if (submit) submit.disabled = false
}
}
}
@@ -0,0 +1,113 @@
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']
connect() {
this.onChanged = this.onChanged.bind(this)
this.onKeydown = this.onKeydown.bind(this)
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)
}
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) 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.send(form)
}
async send(form) {
this.bodyTarget.setAttribute('aria-busy', 'true')
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: new FormData(form),
})
if (response.ok) this.replaceBody(await response.text())
} finally {
this.bodyTarget.removeAttribute('aria-busy')
}
}
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),
},
}))
}
}
+6
View File
@@ -0,0 +1,6 @@
// Reads the CSRF token from the standard <meta name="csrf-token"> tag every
// boboko host renders in its layout <head>. Kept as its own module so both
// checkout controllers share one source.
export function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
+15
View File
@@ -0,0 +1,15 @@
import BbkAddToCartController from './bbk-add-to-cart-controller'
import BbkCartController from './bbk-cart-controller'
// Registers the checkout module's Stimulus controllers onto the host app's
// Stimulus application. Call once from the host's JS entry point:
//
// import { registerCheckout } from './checkout'
// registerCheckout(application)
//
// When this module moves to boboko-core this file ships with it unchanged;
// only that one import line in the host entry point differs per project.
export function registerCheckout(application) {
application.register('bbk-add-to-cart', BbkAddToCartController)
application.register('bbk-cart', BbkCartController)
}
@@ -0,0 +1,31 @@
import { Controller } from '@hotwired/stimulus'
// 3dealer-side glue for the checkout module. The module owns the cart and emits
// `bbk-cart:updated` {count, total} on window after every change; this reflects
// the line count on the header bag icon. How (or whether) that count is shown
// is the host's call — hence this lives here, not in the module.
export default class extends Controller {
static targets = ['badge']
connect() {
this.onUpdate = (event) => this.render(event.detail?.count ?? 0)
window.addEventListener('bbk-cart:updated', this.onUpdate)
}
disconnect() {
window.removeEventListener('bbk-cart:updated', this.onUpdate)
}
// Header cart icon click — there's no separate cart page, the drawer IS
// the cart. `bbk-cart:open` is the module's own event, already listened
// for by bbk-cart-controller.
open() {
window.dispatchEvent(new CustomEvent('bbk-cart:open'))
}
render(count) {
if (!this.hasBadgeTarget) return
this.badgeTarget.textContent = String(count)
this.badgeTarget.hidden = count < 1
}
}
+2
View File
@@ -6,6 +6,7 @@
import AppearController from './appear-controller'
import AutoSubmitController from './auto-submit-controller'
import BackToTopController from './back-to-top-controller'
import CartCountController from './cart-count-controller'
import CarouselController from './carousel-controller'
import DropdownController from './dropdown-controller'
import FrameScrollController from './frame-scroll-controller'
@@ -21,6 +22,7 @@ export function registerControllers(application) {
application.register('appear', AppearController)
application.register('auto-submit', AutoSubmitController)
application.register('back-to-top', BackToTopController)
application.register('cart-count', CartCountController)
application.register('carousel', CarouselController)
application.register('dropdown', DropdownController)
application.register('frame-scroll', FrameScrollController)
@@ -38,6 +38,13 @@ export default class extends Controller {
this.imageTarget.src = variant.image
}
// Keep the checkout module's add-to-cart form pointed at the chosen
// variant. [data-bbk-purchasable-input] is that module's documented
// hook (see resources/views/checkout/components/add-to-cart.blade.php);
// this is the one place the two touch.
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
if (purchasableInput) purchasableInput.value = id
this.swatchTargets.forEach(swatch => {
const isSelected = parseInt(swatch.dataset.variantId) === id
swatch.classList.toggle('is-selected', isSelected)