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
+7 -1
View File
@@ -19,7 +19,7 @@ final class ProductCard
{
/**
* @param array<string, mixed> $product one item from ProductService's localized array shape
* @return array{name: ?string, price: ?string, image: ?string, href: string}
* @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int}
*/
public static function fromIndexed(array $product): array
{
@@ -28,6 +28,12 @@ public static function fromIndexed(array $product): array
'price' => $product['price'],
'image' => $product['media'][0]['url'] ?? null,
'href' => route('product.show', ['id' => $product['id']]),
// The card's quick "Add to cart" always adds this variant, same
// default Modules\Core\Catalog\Services\ProductService::
// variantSummaries() and product/show.blade.php both use — no
// picker at listing-grid scope, unlike the product page's own
// color swatches.
'variantId' => $product['variants'][0]['id'] ?? null,
];
}
}
+11 -3
View File
@@ -17,7 +17,14 @@
*/
final class ProductListingPage
{
private const PER_PAGE = 12;
private const PER_PAGE = 40;
/** Category pages rarely have enough products to fill a page, so this
* ceiling is high enough to act as "no pagination" in practice — the
* pagination component self-hides via hasPages() when everything fits.
* If a category ever exceeds it, pagination reappears as a safety net
* rather than silently truncating results. */
private const CATEGORY_PER_PAGE = 200;
public function __construct(
private readonly ProductService $products,
@@ -33,6 +40,7 @@ public function __construct(
public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array
{
$filters = $listing->filters($collectionId);
$perPage = $collectionId !== null ? self::CATEGORY_PER_PAGE : self::PER_PAGE;
// Listing reads from the Meilisearch index via ProductService/
// ProductSearchService, not Eloquent. Both return a
@@ -45,12 +53,12 @@ public function build(ProductListing $listing, Closure $url, ?int $collectionId
query: $query,
filters: $filters,
sort: $listing->sort,
perPage: self::PER_PAGE,
perPage: $perPage,
page: $listing->page,
)
: $this->products->list(
filters: $filters,
perPage: self::PER_PAGE,
perPage: $perPage,
page: $listing->page,
sort: $listing->sort,
);
@@ -3,8 +3,11 @@
namespace App\Http\Controllers\Checkout;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Models\CartLine;
use Lunar\Models\ProductVariant;
use Modules\Core\Cart\Exceptions\InvalidCouponException;
use Modules\Core\Cart\Services\CartService;
@@ -22,7 +25,18 @@ public function __construct(
private readonly CartService $cart,
) {}
public function add(string $locale, Request $request): View
/**
* CartException here is Lunar's own add_to_cart validation pipeline
* (CartLineQuantity/CartLineStock) rejecting the line — most commonly
* "not enough stock at this quantity" for a tracked (purchasable =
* in_stock) variant. Its own message is an untranslated, hardcoded
* English string not meant for storefront display, so this returns our
* own translated one instead rather than passing it through — a
* storefront.* key rather than checkout.*, since this is a catalog/stock
* concern the storefront owns, not something specific to the portable
* checkout module.
*/
public function add(string $locale, Request $request): View|JsonResponse
{
$data = $request->validate([
'purchasable_id' => ['required', 'integer'],
@@ -31,24 +45,49 @@ public function add(string $locale, Request $request): View
$variant = ProductVariant::findOrFail($data['purchasable_id']);
$this->cart->addLine($variant, $data['quantity'] ?? 1);
try {
$this->cart->addLine($variant, $data['quantity'] ?? 1);
} catch (CartException) {
return $this->stockError($variant);
}
return view('checkout::partials.cart-body');
}
public function updateLine(string $locale, Request $request, int $line): View
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
{
$quantity = (int) $request->validate([
'quantity' => ['required', 'integer', 'min:0'],
])['quantity'];
$quantity === 0
? $this->cart->removeLine($line)
: $this->cart->updateLine($line, $quantity);
try {
$quantity === 0
? $this->cart->removeLine($line)
: $this->cart->updateLine($line, $quantity);
} catch (CartException) {
$variant = CartLine::find($line)?->purchasable;
return $this->stockError($variant instanceof ProductVariant ? $variant : null);
}
return view('checkout::partials.cart-body');
}
/**
* getTotalInventory() is the same number canBeFulfilledAtQuantity()
* checked against (stock, for a tracked in_stock variant) — telling the
* shopper how many are actually left beats a generic "not enough stock"
* they'd otherwise have to guess around by trial and error.
*/
private function stockError(?ProductVariant $variant): JsonResponse
{
$available = $variant?->getTotalInventory() ?? 0;
return response()->json([
'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]),
], 422);
}
public function remove(string $locale, int $line): View
{
$this->cart->removeLine($line);
@@ -23,6 +23,7 @@
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Checkout\Services\CheckoutService;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* The checkout page — one page, sections (contact / billing / shipping /
@@ -275,6 +276,30 @@ public function placeOrder(string $locale, Request $request): JsonResponse
$cart = $this->cart->current();
// Captured now, before initiatePayment() can place the order — Lunar's
// CartSessionManager::fetchOrCreate() silently swaps the session onto a
// BRAND NEW empty cart the moment the current one hasCompletedOrders()
// (i.e. has an order with placed_at set), which happens synchronously
// for an immediately-captured payment. Any later $this->cart->current()
// call in this same flow (here, or in a subsequent orderStatus() poll
// once the 3-D Secure webhook sets placed_at) would then resolve to
// that fresh, order-less cart instead of the one that was just placed.
// Storing the real cart id ourselves, under our own session key,
// sidesteps CartSession entirely for the rest of the placement flow.
session(['checkout.cart_id' => $cart?->id]);
// Lunar's own ValidateCartForOrderCreation (order_create validator)
// never checks for this — an empty cart with a valid billing address
// sails straight through it and would place a real, zero-line order.
// The disabled "place order" button is only the client-side half of
// this fix; this is the half that actually matters.
if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) {
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.cart_empty'),
], 422);
}
// The one incomplete-cart case worth a specific message + pointing the
// shopper at the right section: a region resolving 2+ methods needs an
// explicit pick (no auto-select), easy to miss since nothing else on
@@ -312,13 +337,20 @@ public function placeOrder(string $locale, Request $request): JsonResponse
return response()->json(['error' => __('checkout.page.terms_required')], 422);
}
return match ($result->status) {
PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
PaymentResultStatus::Pending => response()->json([
// Pending with no continuation (cash-on-delivery, or any other
// deferred/offline method) means CheckoutService::initiatePayment()
// already created the placed order — money just hasn't changed
// hands yet. Only a Pending WITH a continuation (Stripe's client
// secret) means the shopper still has something to do before the
// order exists as far as the storefront is concerned.
return match (true) {
$result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
$result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale),
$result->status === PaymentResultStatus::Pending => response()->json([
'status' => 'pending',
'clientSecret' => $result->continuation?->value,
]),
PaymentResultStatus::Failed => response()->json([
default => response()->json([
'status' => 'failed',
'message' => $result->failureReason ?: __('checkout.page.payment_failed'),
'retriable' => $result->retriable,
@@ -350,14 +382,24 @@ public function confirmation(string $locale): View|RedirectResponse
$orderId = session('checkout.order_id');
$order = $orderId
? Order::with(['lines', 'shippingAddress', 'billingAddress'])->find($orderId)
? Order::with(['lines.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId)
: null;
if (! $order) {
return redirect()->route('products', $locale);
}
return view('checkout::confirmation', ['order' => $order]);
// Looked up by type rather than a stored relation — the method may since
// have been disabled/deleted, but the order still needs to show what was
// actually used at the time.
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
->first()
?->translate('name');
return view('checkout::confirmation', [
'order' => $order,
'paymentMethodName' => $paymentMethodName,
]);
}
private function orderPlacedResponse(string $locale): JsonResponse
@@ -373,8 +415,13 @@ private function orderPlacedResponse(string $locale): JsonResponse
private function placedOrder(): ?Order
{
return $this->cart->current()
?->orders()
$cartId = session('checkout.cart_id');
if ($cartId === null) {
return null;
}
return Order::where('cart_id', $cartId)
->whereNotNull('placed_at')
->latest('placed_at')
->first();
@@ -4,7 +4,10 @@
use App\Catalog\ProductListing;
use App\Catalog\ProductListingPage;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Lunar\Models\ProductVariant;
use Modules\Core\Catalog\Services\ProductService;
class ProductController extends Controller
@@ -42,11 +45,48 @@ public function show(string $locale, int $id)
$firstVariant = $product['variants'][0] ?? null;
$option = $firstVariant['options'][0]['option'] ?? null;
// The "Color" option type is the only one that writes a hex code into
// meta (see boboko/core's ColorOptionType) — its presence is how we
// tell a color option (swatches) from any other option (buttons).
$optionIsColor = collect($product['variants'])
->contains(fn (array $variant) => !empty($variant['options'][0]['meta']['hex'] ?? null));
return view('product.show', [
'collection' => $collection,
'product' => $product,
'option' => $option,
'optionIsColor' => $optionIsColor,
'variantsData' => $variantsData,
]);
}
/**
* A storefront-owned, checkout-module-independent stock check — the
* product page's "Add to cart" calls this first and only submits to the
* checkout module's own add-to-cart endpoint once this says `ok`. Reads
* the live Eloquent ProductVariant directly (not the Meilisearch index
* ProductService otherwise reads from, which can lag behind an actual
* sale until the next reindex) via the SAME method Lunar's own
* CartLineStock validator calls, so this can never disagree with what
* the module's own server-side check would decide.
*/
public function checkStock(string $locale, Request $request): JsonResponse
{
$data = $request->validate([
'variant' => ['required', 'integer'],
'quantity' => ['nullable', 'integer', 'min:1'],
]);
$variant = ProductVariant::find($data['variant']);
$quantity = $data['quantity'] ?? 1;
if ($variant === null) {
return response()->json(['ok' => true]);
}
return response()->json([
'ok' => $variant->canBeFulfilledAtQuantity($quantity),
'stock' => $variant->purchasable === 'always' ? null : $variant->getTotalInventory(),
]);
}
}
Generated
+47 -109
View File
@@ -515,11 +515,11 @@
},
{
"name": "boboko/core",
"version": "0.17.1",
"version": "0.18.0",
"source": {
"type": "git",
"url": "https://code.radical-elements.com/boboko/core.git",
"reference": "d9fb3bbde661bc8f49b3032462af029c5983b03a"
"reference": "2cc6f5e5f0d43102001d65b60810423a35e83e1f"
},
"require": {
"laravel/framework": "^12.0",
@@ -527,10 +527,10 @@
"lunarphp/lunar": "1.5.0",
"lunarphp/meilisearch": "*",
"lunarphp/search": "*",
"lunarphp/stripe": "^1.5",
"lunarphp/table-rate-shipping": "1.5.0",
"php": "^8.5",
"spatie/laravel-translation-loader": "^2.8",
"stripe/stripe-php": "^16.6",
"symfony/yaml": "^7.0"
},
"require-dev": {
@@ -568,7 +568,7 @@
}
},
"description": "Core module — authentication and shared panel behaviour",
"time": "2026-09-15T13:12:03+00:00"
"time": "2026-09-15T21:06:04+00:00"
},
{
"name": "brick/math",
@@ -1692,16 +1692,16 @@
},
{
"name": "filament/actions",
"version": "v4.13.1",
"version": "v4.13.2",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/actions.git",
"reference": "ca6b6e7fb2f8b3aee5540155474af3ac7f3e47ce"
"reference": "1eb39ac07f06302262cf6f5d77927bff1ed626fb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/actions/zipball/ca6b6e7fb2f8b3aee5540155474af3ac7f3e47ce",
"reference": "ca6b6e7fb2f8b3aee5540155474af3ac7f3e47ce",
"url": "https://api.github.com/repos/filamentphp/actions/zipball/1eb39ac07f06302262cf6f5d77927bff1ed626fb",
"reference": "1eb39ac07f06302262cf6f5d77927bff1ed626fb",
"shasum": ""
},
"require": {
@@ -1737,20 +1737,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-09-07T18:36:28+00:00"
"time": "2026-09-15T16:56:16+00:00"
},
{
"name": "filament/filament",
"version": "v4.13.1",
"version": "v4.13.2",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/panels.git",
"reference": "8c1adf1b10d43cc17cd34deb4d6f7c42f0bb776a"
"reference": "ff848c7750b49f0bb532d44c6768f0e601fb09d2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/panels/zipball/8c1adf1b10d43cc17cd34deb4d6f7c42f0bb776a",
"reference": "8c1adf1b10d43cc17cd34deb4d6f7c42f0bb776a",
"url": "https://api.github.com/repos/filamentphp/panels/zipball/ff848c7750b49f0bb532d44c6768f0e601fb09d2",
"reference": "ff848c7750b49f0bb532d44c6768f0e601fb09d2",
"shasum": ""
},
"require": {
@@ -1795,20 +1795,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-09-08T10:21:27+00:00"
"time": "2026-09-15T16:56:27+00:00"
},
{
"name": "filament/forms",
"version": "v4.13.1",
"version": "v4.13.2",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/forms.git",
"reference": "4da33a7be66aa238158816e439fa591aac0ac239"
"reference": "18e3382115ed3062a074736c9e80cfcb3ffe2221"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/forms/zipball/4da33a7be66aa238158816e439fa591aac0ac239",
"reference": "4da33a7be66aa238158816e439fa591aac0ac239",
"url": "https://api.github.com/repos/filamentphp/forms/zipball/18e3382115ed3062a074736c9e80cfcb3ffe2221",
"reference": "18e3382115ed3062a074736c9e80cfcb3ffe2221",
"shasum": ""
},
"require": {
@@ -1845,20 +1845,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-09-08T10:18:49+00:00"
"time": "2026-09-15T17:00:54+00:00"
},
{
"name": "filament/infolists",
"version": "v4.13.1",
"version": "v4.13.2",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/infolists.git",
"reference": "638b0b37ce492594567e5819f99e2b0f43b3a69b"
"reference": "b05093c8ff726d28eb75df29fa21bdd836386ded"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/infolists/zipball/638b0b37ce492594567e5819f99e2b0f43b3a69b",
"reference": "638b0b37ce492594567e5819f99e2b0f43b3a69b",
"url": "https://api.github.com/repos/filamentphp/infolists/zipball/b05093c8ff726d28eb75df29fa21bdd836386ded",
"reference": "b05093c8ff726d28eb75df29fa21bdd836386ded",
"shasum": ""
},
"require": {
@@ -1890,20 +1890,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-09-07T18:36:32+00:00"
"time": "2026-09-15T16:59:31+00:00"
},
{
"name": "filament/notifications",
"version": "v4.13.1",
"version": "v4.13.2",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/notifications.git",
"reference": "6279e7d1353b370cd4fa2dadaa245227c47add21"
"reference": "f481bcfb705ad650f96336e7496814419907639c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/notifications/zipball/6279e7d1353b370cd4fa2dadaa245227c47add21",
"reference": "6279e7d1353b370cd4fa2dadaa245227c47add21",
"url": "https://api.github.com/repos/filamentphp/notifications/zipball/f481bcfb705ad650f96336e7496814419907639c",
"reference": "f481bcfb705ad650f96336e7496814419907639c",
"shasum": ""
},
"require": {
@@ -1937,20 +1937,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-09-07T18:35:57+00:00"
"time": "2026-09-15T16:55:37+00:00"
},
{
"name": "filament/query-builder",
"version": "v4.13.1",
"version": "v4.13.2",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/query-builder.git",
"reference": "d5c5c94cbede3a3d56855e5de173c0a35da2328e"
"reference": "f7c1eb9eeaded573838c1f3b8d0a63f55434331a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/query-builder/zipball/d5c5c94cbede3a3d56855e5de173c0a35da2328e",
"reference": "d5c5c94cbede3a3d56855e5de173c0a35da2328e",
"url": "https://api.github.com/repos/filamentphp/query-builder/zipball/f7c1eb9eeaded573838c1f3b8d0a63f55434331a",
"reference": "f7c1eb9eeaded573838c1f3b8d0a63f55434331a",
"shasum": ""
},
"require": {
@@ -1983,11 +1983,11 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-09-07T18:36:00+00:00"
"time": "2026-09-15T16:55:48+00:00"
},
{
"name": "filament/schemas",
"version": "v4.13.1",
"version": "v4.13.2",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/schemas.git",
@@ -2032,7 +2032,7 @@
},
{
"name": "filament/spatie-laravel-media-library-plugin",
"version": "v4.13.1",
"version": "v4.13.2",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git",
@@ -2069,16 +2069,16 @@
},
{
"name": "filament/support",
"version": "v4.13.1",
"version": "v4.13.2",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/support.git",
"reference": "73fc0fda6692ac878b9cc0255dd3288577a50cb6"
"reference": "6ff50570ff1f2c6c1caf6ed1e7a2ce146b901106"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/support/zipball/73fc0fda6692ac878b9cc0255dd3288577a50cb6",
"reference": "73fc0fda6692ac878b9cc0255dd3288577a50cb6",
"url": "https://api.github.com/repos/filamentphp/support/zipball/6ff50570ff1f2c6c1caf6ed1e7a2ce146b901106",
"reference": "6ff50570ff1f2c6c1caf6ed1e7a2ce146b901106",
"shasum": ""
},
"require": {
@@ -2124,20 +2124,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-09-07T18:36:12+00:00"
"time": "2026-09-15T16:59:53+00:00"
},
{
"name": "filament/tables",
"version": "v4.13.1",
"version": "v4.13.2",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/tables.git",
"reference": "57662d3b2098e7d156ef507842d4ae98a892212b"
"reference": "51f2f2d19eddae424b8b4a08ab9a14770e2acc9d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/tables/zipball/57662d3b2098e7d156ef507842d4ae98a892212b",
"reference": "57662d3b2098e7d156ef507842d4ae98a892212b",
"url": "https://api.github.com/repos/filamentphp/tables/zipball/51f2f2d19eddae424b8b4a08ab9a14770e2acc9d",
"reference": "51f2f2d19eddae424b8b4a08ab9a14770e2acc9d",
"shasum": ""
},
"require": {
@@ -2170,11 +2170,11 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-09-07T18:36:28+00:00"
"time": "2026-09-15T16:56:17+00:00"
},
{
"name": "filament/widgets",
"version": "v4.13.1",
"version": "v4.13.2",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/widgets.git",
@@ -4757,68 +4757,6 @@
},
"time": "2026-08-26T14:50:54+00:00"
},
{
"name": "lunarphp/stripe",
"version": "1.5.0",
"source": {
"type": "git",
"url": "https://github.com/lunarphp/stripe.git",
"reference": "9c091d4f8f868966605f488e8d6a94c4af07f0bb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lunarphp/stripe/zipball/9c091d4f8f868966605f488e8d6a94c4af07f0bb",
"reference": "9c091d4f8f868966605f488e8d6a94c4af07f0bb",
"shasum": ""
},
"require": {
"lunarphp/core": "self.version",
"php": "^8.3",
"stripe/stripe-php": "^16.0"
},
"type": "project",
"extra": {
"lunar": {
"name": "Stripe Payments"
},
"laravel": {
"providers": [
"Lunar\\Stripe\\StripePaymentsServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Lunar\\Stripe\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Lunar",
"homepage": "https://lunarphp.io/"
}
],
"description": "Stripe payment driver for Lunar.",
"keywords": [
"cart",
"e-commerce",
"ecommerce",
"headless",
"laravel",
"lunarphp",
"shop",
"store",
"stripe"
],
"support": {
"source": "https://github.com/lunarphp/stripe/tree/1.5.0"
},
"time": "2026-08-26T14:51:06+00:00"
},
{
"name": "lunarphp/table-rate-shipping",
"version": "1.5.0",
+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>
+4
View File
@@ -29,6 +29,10 @@
'product.show',
);
Route::get('/products-stock-check', [ProductController::class, 'checkStock'])->name(
'product.stock-check',
);
Route::get('/category/{id}', [CategoryController::class, 'show'])->name(
'category.show',
);