generated from boboko/starter
174 lines
7.0 KiB
JavaScript
174 lines
7.0 KiB
JavaScript
import { Controller } from '@hotwired/stimulus'
|
|
import { formatPrice } from '../utils/format-price'
|
|
|
|
export default class extends Controller {
|
|
static targets = ['price', 'image', 'swatch', 'colorName', 'stockError']
|
|
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 <form>) — 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 = false
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
|
|
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')
|
|
}
|
|
})
|
|
}
|
|
}
|