generated from boboko/starter
cart drawer and general structure
This commit is contained in:
@@ -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),
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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') || ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user