stock check, variants in cart, variant buttons in product page

This commit is contained in:
elvira
2026-09-17 21:52:39 +03:00
parent fe55cb5f33
commit afa1993c53
27 changed files with 564 additions and 155 deletions
+42 -3
View File
@@ -204,6 +204,12 @@ .bbk-cart-item-title {
font-weight: 600;
}
.bbk-cart-item-variant {
margin: 0 0 0.25rem;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-cart-item-unit {
margin: 0 0 0.625rem;
color: var(--bbk-color-muted);
@@ -361,6 +367,19 @@ .bbk-cart-coupon-error {
color: var(--bbk-color-danger);
}
.bbk-cart-error {
margin: 0;
padding: 0.75rem 1.5rem 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-add-to-cart-error {
margin: 0.375rem 0 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-cart-checkout {
display: block;
width: 100%;
@@ -747,6 +766,23 @@ .bbk-confirmation-heading {
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
.bbk-confirmation-meta {
margin: 0 0 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bbk-confirmation-meta-row {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.9375rem;
}
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
.bbk-confirmation-body {
margin: 2rem 0;
display: grid;
@@ -764,11 +800,14 @@ .bbk-confirmation-lines {
}
.bbk-confirmation-line {
display: flex;
justify-content: space-between;
gap: 1rem;
display: grid;
grid-template-columns: 72px 1fr auto;
align-items: start;
gap: 0.875rem;
}
.bbk-confirmation-line-detail { min-width: 0; }
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
@@ -6,12 +6,15 @@ import { csrfToken } from './csrf'
// `bbk-cart:changed` window event. No DOM building here — the drawer
// (bbk-cart-controller) owns rendering.
export default class extends Controller {
static targets = ['error']
async add(event) {
event.preventDefault()
const form = this.element
const submit = form.querySelector('[type="submit"]')
this.clearError()
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
if (submit) submit.disabled = true
@@ -21,11 +24,16 @@ export default class extends Controller {
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (!response.ok) return
if (!response.ok) {
const data = await response.json().catch(() => null)
this.showError(data?.error)
return
}
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: await response.text() },
@@ -35,4 +43,15 @@ export default class extends Controller {
if (submit) submit.disabled = false
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
}
+29 -2
View File
@@ -13,7 +13,7 @@ import { csrfToken } from './csrf'
// 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']
static targets = ['panel', 'body', 'error']
connect() {
this.onChanged = this.onChanged.bind(this)
@@ -78,6 +78,7 @@ export default class extends Controller {
async send(form) {
this.bodyTarget.setAttribute('aria-busy', 'true')
this.clearError()
try {
const response = await fetch(form.action, {
@@ -85,16 +86,42 @@ export default class extends Controller {
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (response.ok) this.replaceBody(await response.text())
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]'))
@@ -43,6 +43,16 @@ export default class extends Controller {
this.amountValue = total
this.elements.update({ amount: Math.max(total, 1) })
}
// Removing the last line while sitting on the checkout page (via
// the order summary's own remove form) must not leave "place
// order" clickable with nothing left to charge for — this fires
// from both the drawer and the checkout page's own summary
// instance, whichever the shopper actually used.
const count = event.detail?.count
if (typeof count === 'number' && this.hasSubmitTarget) {
this.submitTarget.disabled = count === 0
}
}
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)
@@ -2,8 +2,18 @@ import { Controller } from '@hotwired/stimulus'
import { formatPrice } from '../utils/format-price'
export default class extends Controller {
static targets = ['price', 'image', 'swatch', 'colorName']
static values = { variants: Array, selected: Number }
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)
@@ -13,6 +23,88 @@ export default class extends Controller {
this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId)
? urlId
: defaultId
// 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) {
@@ -30,6 +122,8 @@ export default class extends Controller {
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)
}
+2 -1
View File
@@ -14,7 +14,8 @@
@endpush
@section('content')
<div class="max-w-5xl mx-auto pt-26 pb-12">
{{-- <div class="max-w-5xl mx-auto pt-26 pb-12"> --}}
<div class="max-w-7xl mx-auto px-4 sm:px-8 pt-26 pb-12">
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
<h1 class="font-medium text-h2">{{ $collection['name'] }}</h1>
@@ -39,4 +39,6 @@
@endif
{{ $slot }}
<p class="bbk-add-to-cart-error" data-bbk-add-to-cart-target="error" hidden role="alert"></p>
</form>
@@ -11,19 +11,57 @@
<div class="bbk-confirmation">
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
<p class="bbk-confirmation-ref">
{{ __('checkout.page.confirmation_order_number') }}: <strong>{{ $order->reference }}</strong>
</p>
<dl class="bbk-confirmation-meta">
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
<dd>{{ $order->reference }}</dd>
</div>
@if ($order->billingAddress?->contact_email)
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.email_label') }}</dt>
<dd>{{ $order->billingAddress->contact_email }}</dd>
</div>
@endif
@if ($paymentMethodName)
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.payment_heading') }}</dt>
<dd>{{ $paymentMethodName }}</dd>
</div>
@endif
@if ($shippingLine = $order->lines->firstWhere('type', 'shipping'))
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.shipping_method_heading') }}</dt>
<dd>{{ $shippingLine->description }}</dd>
</div>
@endif
</dl>
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
<div class="bbk-confirmation-body">
<div class="bbk-confirmation-lines">
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
<div class="bbk-confirmation-line">
<span class="bbk-confirmation-line-name">
{{ $line->description }}
<span class="bbk-confirmation-line-qty">&times; {{ $line->quantity }}</span>
</span>
<div class="bbk-cart-item-media">
@if ($thumb = $line->purchasable?->getThumbnailImage())
<img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy">
@endif
</div>
<div class="bbk-confirmation-line-detail">
<span class="bbk-confirmation-line-name">
{{ $line->description }}
<span class="bbk-confirmation-line-qty">&times; {{ $line->quantity }}</span>
</span>
@if ($line->option)
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
@endif
</div>
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
</div>
@endforeach
@@ -24,6 +24,8 @@ class="bbk-cart-dismiss"
>&times;</button>
</header>
@include('checkout::partials.cart-error')
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
+2
View File
@@ -260,6 +260,7 @@ class="bbk-checkout-status"
class="bbk-checkout-continue"
data-bbk-payment-target="submit"
data-action="bbk-payment#placeOrder"
@disabled($lines->isEmpty())
>
{{ __('checkout.page.place_order') }}
</button>
@@ -277,6 +278,7 @@ class="bbk-checkout-continue"
<aside class="bbk-checkout-aside">
<div class="bbk-checkout-summary" data-controller="bbk-cart">
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
@include('checkout::partials.cart-error')
<div data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
@@ -0,0 +1,9 @@
{{--
Shared error slot for any host wrapping cart-body in a bbk-cart controller
instance (the drawer, and the checkout page's own order summary) —
bbk-cart-controller.js#showError() writes into whichever one is present.
Without this element in a given host, a rejected quantity update (e.g.
over stock) still gets rejected server-side, but the shopper never sees
why.
--}}
<p class="bbk-cart-error" data-bbk-cart-target="error" hidden role="alert"></p>
@@ -7,7 +7,11 @@
$variant = $line->purchasable;
$product = $variant?->product;
$name = $product?->translateAttribute('name') ?? $variant?->sku ?? '—';
$thumb = $product?->getThumbnailImage() ?: null;
// The variant's own image (falls back to the product's thumbnail
// internally — see ProductVariant::getThumbnail()) — the specific option
// the shopper picked, not just the product in general.
$thumb = $variant?->getThumbnailImage() ?: null;
$variantLabel = $variant?->getOption();
@endphp
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
@@ -19,6 +23,9 @@
<div class="bbk-cart-item-detail">
<p class="bbk-cart-item-title">{{ $name }}</p>
@if ($variantLabel)
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
@endif
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
<form
@@ -44,6 +51,7 @@ class="bbk-cart-qty-btn"
inputmode="numeric"
class="bbk-cart-qty-input"
data-action="change->bbk-cart#submit"
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
aria-label="{{ __('checkout.cart.quantity') }}"
>
@@ -24,6 +24,7 @@
:price="$product['price'] ?? null"
:image="$product['image'] ?? null"
:href="$product['href'] ?? '#'"
:variant-id="$product['variantId'] ?? null"
/>
@endforeach
</div>
+12 -1
View File
@@ -3,6 +3,7 @@
'href' => null,
'type' => 'button',
'size' => 'lg',
'variant' => 'primary',
'position' => 'relative',
])
@@ -15,6 +16,16 @@
default => 'py-5 px-[46px] text-[19px]',
};
// 'primary' is the CTA look (offset-shadow via .btn-primary's ::before/
// ::after, italic, uppercase). 'secondary' is a plain bordered toggle —
// no shadow layers, fills solid on hover/aria-pressed=true instead, used
// for option pickers (see x-ui.option-buttons) and anywhere else a
// secondary/toggle action shouldn't compete visually with the CTA.
$variantClasses = match($variant) {
'secondary' => 'font-semibold hover:bg-black hover:text-neutral-200 aria-pressed:bg-black aria-pressed:text-neutral-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-black',
default => 'btn-primary font-bold italic uppercase',
};
// $position defaults to 'relative' (needed so the ::before/::after layers
// in .btn-primary position against the button itself), but callers that
// need to place the button absolutely (e.g. a hover-reveal CTA over a
@@ -22,7 +33,7 @@
// "absolute" via the class prop — Tailwind's generated stylesheet always
// orders the "relative" utility after "absolute", so on a class clash
// "relative" silently wins and the button never actually gets positioned.
$class = 'btn-primary '.$position.' isolate inline-flex items-center justify-center border border-black font-display font-bold italic text-black cursor-pointer no-underline uppercase ' . $sizeClasses;
$class = trim($position.' isolate inline-flex items-center justify-center border border-black text-black cursor-pointer no-underline '.$variantClasses.' '.$sizeClasses);
$attrs = $href
? $attributes->merge(['href' => $href, 'class' => $class])
@@ -0,0 +1,36 @@
{{-- $variants: array of Modules\Core\Catalog\Services\ProductIndexer's mapVariant()
shape (id, options: [{option, value, meta}], ...) — plain arrays, not Eloquent
models, since this is fed from Modules\Core\Catalog\Services\ProductService.
Use this instead of <x-ui.color-swatch> for options that aren't a color
(no meta.hex), where a swatch dot has nothing meaningful to show. --}}
@props(['variants', 'option' => null])
<div {{ $attributes }}>
@if($option)
<p class="font-bold mb-3 text-sm uppercase tracking-wide">
{{ $option }}
</p>
@endif
<div
class="flex flex-wrap gap-2"
role="group"
aria-label="{{ $option ?? 'Option' }}"
>
@foreach($variants as $variant)
@php
$value = $variant['options'][0] ?? null;
$label = $value['value'] ?? '';
@endphp
<x-ui.button
variant="secondary"
size="sm"
data-product-form-target="swatch"
data-action="click->product-form#selectVariant"
data-variant-id="{{ $variant['id'] }}"
aria-label="{{ $label }}"
aria-pressed="false"
>{{ $label }}</x-ui.button>
@endforeach
</div>
</div>
@@ -1,8 +1,9 @@
@props([
'name' => '',
'price' => null,
'image' => null,
'href' => '#',
'name' => '',
'price' => null,
'image' => null,
'href' => '#',
'variantId' => null,
])
{{-- data-turbo-frame="_top" on the links: this card renders inside the
@@ -27,9 +28,20 @@ class="w-full h-auto block"
@endif
</a>
<x-ui.button size="md" position="absolute" class="opacity-0 group-hover:opacity-100 transition-opacity duration-100">
{{ __('storefront.product.add_to_cart') }}
</x-ui.button>
@if ($variantId)
{{-- has-[...] forces the button visible while an add-to-cart error
is showing, so it isn't only readable on hover — a shopper who
already moved off the card (mouse or the click itself) must
still see why nothing happened. --}}
<x-checkout::add-to-cart
:purchasable="$variantId"
class="absolute opacity-0 group-hover:opacity-100 has-[.bbk-add-to-cart-error:not([hidden])]:opacity-100 transition-opacity duration-100"
>
<x-ui.button type="submit" size="md">
{{ __('storefront.product.add_to_cart') }}
</x-ui.button>
</x-checkout::add-to-cart>
@endif
</div>
<div class="flex items-baseline justify-between gap-4">
+1
View File
@@ -155,6 +155,7 @@ class="max-w-xs"
:price="$product['price']"
:image="$product['image']"
:href="$product['href']"
:variant-id="$product['variantId'] ?? null"
/>
@endforeach
</div>
+14 -1
View File
@@ -17,6 +17,9 @@
class="grid grid-cols-1 md:grid-cols-2 gap-12"
data-controller="product-form"
data-product-form-variants-value="{{ json_encode($variantsData) }}"
data-product-form-stock-check-url-value="{{ route('product.stock-check', app()->getLocale()) }}"
data-product-form-stock-error-one-value="{{ trans_choice('storefront.product.add_to_cart_failed', 1) }}"
data-product-form-stock-error-many-value="{{ trans_choice('storefront.product.add_to_cart_failed', 2, ['count' => ':count']) }}"
>
{{-- Image --}}
@@ -167,7 +170,11 @@ class="absolute bottom-6 right-8 text-white text-sm"
</div>
@if($option && !empty($product['variants']))
<x-ui.color-swatch :variants="$product['variants']" :option="$option" />
@if($optionIsColor)
<x-ui.color-swatch :variants="$product['variants']" :option="$option" />
@else
<x-ui.option-buttons :variants="$product['variants']" :option="$option" />
@endif
@endif
<x-checkout::add-to-cart
@@ -179,6 +186,12 @@ class="flex items-stretch gap-10"
<x-ui.button type="submit" class="flex-1">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
</x-checkout::add-to-cart>
{{-- Storefront-owned, not part of the checkout module — the
local stock pre-check in product-form-controller.js stops
an over-limit submit before it ever reaches the module
and reports it here. --}}
<p class="text-sm text-red-600" data-product-form-target="stockError" hidden role="alert"></p>
</div>
</div>
+2 -1
View File
@@ -12,7 +12,8 @@
@endpush
@section('content')
<div class="max-w-5xl mx-auto pt-26 pb-12">
{{-- <div class="max-w-5xl mx-auto pt-26 pb-12"> --}}
<div class="max-w-7xl mx-auto px-4 sm:px-8 pt-26 pb-12">
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
<h1 class="font-medium text-h2">{{ __('storefront.shop.all_products') }}</h1>
+2 -1
View File
@@ -12,7 +12,8 @@
@endpush
@section('content')
<div class="max-w-5xl mx-auto pt-26 pb-12">
{{-- <div class="max-w-5xl mx-auto pt-26 pb-12"> --}}
<div class="max-w-7xl mx-auto px-4 sm:px-8 pt-26 pb-12">
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
<h1 class="font-medium text-h2">{{ $heading }}</h1>