import { Controller } from '@hotwired/stimulus' import { formatPrice } from '../utils/format-price' export default class extends Controller { static targets = ['price', 'image', 'swatch', 'colorName', 'stockError', 'submit'] static values = { variants: Array, selected: Number, stockCheckUrl: String, // Two pre-rendered translated templates (see product/show.blade.php) // rather than one — this controller doesn't reimplement Laravel's // pluralization rules, it just picks whichever of these two the // count actually needs and fills in the number. stockErrorOne: String, stockErrorMany: String, } connect() { const params = new URLSearchParams(window.location.search) const urlId = parseInt(params.get('variant')) const urlVariant = this.variantsValue.find(v => v.id === urlId) const initial = urlVariant ?? this.variantsValue[0] // A product can have several independent options (e.g. size + style // + person-count) — this tracks the currently-picked value id per // option handle, and selectVariant() below resolves the full // combination back to one exact variant on every change. this.selections = { ...initial?.options } this.selectedValue = initial?.id // Capture phase, on this controller's own root element (an ancestor // of the checkout module's add-to-cart
) — runs BEFORE that // form's own bubble-phase submit handler (bbk-add-to-cart#add), so a // failed check can stop it from ever reaching the module at all. The // module itself is never touched or modified for this: it keeps // validating server-side regardless, this is purely an up-front, // storefront-owned check (see [[project_checkout_module]] for why // that split matters — stock UX is a catalog concern, not something // the portable checkout module should own) — and a REAL, live check // against the backend (ProductController::checkStock(), reading the // Eloquent model directly), not page-load data that can go stale. this.onSubmitCapture = this.checkStock.bind(this) this.element.addEventListener('submit', this.onSubmitCapture, true) } disconnect() { this.element.removeEventListener('submit', this.onSubmitCapture, true) } checkStock(event) { const form = event.target if (!form.matches('.bbk-add-to-cart')) return // The re-submit this itself triggers below, once the backend has // confirmed the quantity is fine — let that one through to the // module's own submit handler instead of checking a second time. if (form.dataset.bbkStockChecked) { delete form.dataset.bbkStockChecked return } event.preventDefault() event.stopPropagation() this.verifyStock(form) } async verifyStock(form) { this.clearStockError() const submit = form.querySelector('[type="submit"]') if (submit) submit.disabled = true const purchasableId = form.querySelector('[data-bbk-purchasable-input]')?.value const quantity = form.querySelector('[name="quantity"]')?.value || '1' try { const url = new URL(this.stockCheckUrlValue, window.location.origin) url.searchParams.set('variant', purchasableId) url.searchParams.set('quantity', quantity) const response = await fetch(url, { headers: { Accept: 'application/json' } }) const data = await response.json() if (!data.ok) { this.showStockError(data.stock) return } } catch { // Network hiccup — fall through and let the checkout module's // own server-side check have the final word rather than // silently blocking the shopper here. } finally { if (submit) submit.disabled = !this.selectedVariant?.inStock } form.dataset.bbkStockChecked = 'true' form.requestSubmit() } showStockError(available) { if (!this.hasStockErrorTarget) return this.stockErrorTarget.textContent = available === 1 ? this.stockErrorOneValue : this.stockErrorManyValue.replace(':count', String(available)) this.stockErrorTarget.hidden = false } clearStockError() { if (!this.hasStockErrorTarget) return this.stockErrorTarget.hidden = true } get selectedVariant() { return this.variantsValue.find(v => v.id === this.selectedValue) } selectVariant(event) { const option = event.currentTarget.dataset.option const valueId = parseInt(event.currentTarget.dataset.valueId) this.selections = { ...this.selections, [option]: valueId } const match = this.variantsValue.find(variant => Object.keys(this.selections).every(key => variant.options?.[key] === this.selections[key]) ) // No variant exists for this combination (e.g. an option value that // isn't offered together with another currently-selected value) — // leave the previous selection in place rather than pointing the // add-to-cart form at nothing. if (!match) return this.selectedValue = match.id const url = new URL(window.location) url.searchParams.set('variant', match.id) window.history.pushState({}, '', url) } selectedValueChanged(id) { if (!id) return const variant = this.variantsValue.find(v => v.id === id) if (!variant) return this.clearStockError() if (this.hasPriceTarget && variant.price !== null) { this.priceTarget.textContent = formatPrice(variant.price) } if (this.hasImageTarget && variant.image) { 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 // Out-of-stock variant (as of page load) can't be added at all. if (this.hasSubmitTarget) this.submitTarget.disabled = !variant.inStock this.swatchTargets.forEach(swatch => { const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId) swatch.classList.toggle('is-selected', isSelected) swatch.setAttribute('aria-pressed', String(isSelected)) if (isSelected) { // Each color-option group has its own colorName echo (see // x-ui.color-swatch) — matched by option handle so a swatch // in one group never overwrites another group's label. const colorName = this.colorNameTargets.find(target => target.dataset.option === swatch.dataset.option) if (colorName) colorName.textContent = swatch.getAttribute('aria-label') } }) } }