From afa1993c53dc747ff107df7b763886c929b3ac28 Mon Sep 17 00:00:00 2001 From: elvira Date: Thu, 17 Sep 2026 21:52:27 +0300 Subject: [PATCH] stock check, variants in cart, variant buttons in product page --- app/Catalog/ProductCard.php | 8 +- app/Catalog/ProductListingPage.php | 14 +- .../Controllers/Checkout/CartController.php | 51 +++++- .../Checkout/CheckoutController.php | 63 ++++++- app/Http/Controllers/ProductController.php | 40 +++++ composer.lock | 156 ++++++------------ resources/css/checkout.css | 45 ++++- .../js/checkout/bbk-add-to-cart-controller.js | 21 ++- resources/js/checkout/bbk-cart-controller.js | 31 +++- .../js/checkout/bbk-payment-controller.js | 10 ++ .../js/stimulus/product-form-controller.js | 98 ++++++++++- resources/views/category/show.blade.php | 3 +- .../checkout/components/add-to-cart.blade.php | 2 + .../views/checkout/confirmation.blade.php | 52 +++++- resources/views/checkout/drawer.blade.php | 2 + resources/views/checkout/page.blade.php | 2 + .../checkout/partials/cart-error.blade.php | 9 + .../checkout/partials/cart-line.blade.php | 10 +- .../views/components/product-grid.blade.php | 1 + .../views/components/ui/button.blade.php | 13 +- .../components/ui/option-buttons.blade.php | 36 ++++ .../components/ui/product-card.blade.php | 26 ++- resources/views/home.blade.php | 1 + resources/views/product/show.blade.php | 15 +- resources/views/products/index.blade.php | 3 +- resources/views/search/index.blade.php | 3 +- routes/web.php | 4 + 27 files changed, 564 insertions(+), 155 deletions(-) create mode 100644 resources/views/checkout/partials/cart-error.blade.php create mode 100644 resources/views/components/ui/option-buttons.blade.php diff --git a/app/Catalog/ProductCard.php b/app/Catalog/ProductCard.php index d0c00f2..74232d1 100644 --- a/app/Catalog/ProductCard.php +++ b/app/Catalog/ProductCard.php @@ -19,7 +19,7 @@ final class ProductCard { /** * @param array $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, ]; } } diff --git a/app/Catalog/ProductListingPage.php b/app/Catalog/ProductListingPage.php index dcf322e..2b8a625 100644 --- a/app/Catalog/ProductListingPage.php +++ b/app/Catalog/ProductListingPage.php @@ -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, ); diff --git a/app/Http/Controllers/Checkout/CartController.php b/app/Http/Controllers/Checkout/CartController.php index 438195f..8afcd5a 100644 --- a/app/Http/Controllers/Checkout/CartController.php +++ b/app/Http/Controllers/Checkout/CartController.php @@ -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); diff --git a/app/Http/Controllers/Checkout/CheckoutController.php b/app/Http/Controllers/Checkout/CheckoutController.php index f5452bb..52c95bf 100644 --- a/app/Http/Controllers/Checkout/CheckoutController.php +++ b/app/Http/Controllers/Checkout/CheckoutController.php @@ -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(); diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index e390712..3252786 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -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(), + ]); + } } diff --git a/composer.lock b/composer.lock index 9e13a72..fb66c1d 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/resources/css/checkout.css b/resources/css/checkout.css index 56b9437..41c52ad 100644 --- a/resources/css/checkout.css +++ b/resources/css/checkout.css @@ -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; } diff --git a/resources/js/checkout/bbk-add-to-cart-controller.js b/resources/js/checkout/bbk-add-to-cart-controller.js index 49abb02..1f1cdf2 100644 --- a/resources/js/checkout/bbk-add-to-cart-controller.js +++ b/resources/js/checkout/bbk-add-to-cart-controller.js @@ -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 + } } diff --git a/resources/js/checkout/bbk-cart-controller.js b/resources/js/checkout/bbk-cart-controller.js index 465e7c4..d3eb98b 100644 --- a/resources/js/checkout/bbk-cart-controller.js +++ b/resources/js/checkout/bbk-cart-controller.js @@ -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]')) diff --git a/resources/js/checkout/bbk-payment-controller.js b/resources/js/checkout/bbk-payment-controller.js index 96a43e7..9cc59a5 100644 --- a/resources/js/checkout/bbk-payment-controller.js +++ b/resources/js/checkout/bbk-payment-controller.js @@ -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) diff --git a/resources/js/stimulus/product-form-controller.js b/resources/js/stimulus/product-form-controller.js index 767b0a1..c742af5 100644 --- a/resources/js/stimulus/product-form-controller.js +++ b/resources/js/stimulus/product-form-controller.js @@ -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
) — 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) } diff --git a/resources/views/category/show.blade.php b/resources/views/category/show.blade.php index 9b36b80..bf4f9b5 100644 --- a/resources/views/category/show.blade.php +++ b/resources/views/category/show.blade.php @@ -14,7 +14,8 @@ @endpush @section('content') -
+ {{--
--}} +

{{ $collection['name'] }}

diff --git a/resources/views/checkout/components/add-to-cart.blade.php b/resources/views/checkout/components/add-to-cart.blade.php index bcd2b05..3f39e61 100644 --- a/resources/views/checkout/components/add-to-cart.blade.php +++ b/resources/views/checkout/components/add-to-cart.blade.php @@ -39,4 +39,6 @@ @endif {{ $slot }} + + diff --git a/resources/views/checkout/confirmation.blade.php b/resources/views/checkout/confirmation.blade.php index baa7c50..c76d4c1 100644 --- a/resources/views/checkout/confirmation.blade.php +++ b/resources/views/checkout/confirmation.blade.php @@ -11,19 +11,57 @@

{{ __('checkout.page.confirmation_heading') }}

-

- {{ __('checkout.page.confirmation_order_number') }}: {{ $order->reference }} -

+
+
+
{{ __('checkout.page.confirmation_order_number') }}
+
{{ $order->reference }}
+
+ + @if ($order->billingAddress?->contact_email) +
+
{{ __('checkout.page.email_label') }}
+
{{ $order->billingAddress->contact_email }}
+
+ @endif + + @if ($paymentMethodName) +
+
{{ __('checkout.page.payment_heading') }}
+
{{ $paymentMethodName }}
+
+ @endif + + @if ($shippingLine = $order->lines->firstWhere('type', 'shipping')) +
+
{{ __('checkout.page.shipping_method_heading') }}
+
{{ $shippingLine->description }}
+
+ @endif +
+

{{ __('checkout.page.confirmation_email_note') }}

@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
- - {{ $line->description }} - × {{ $line->quantity }} - +
+ @if ($thumb = $line->purchasable?->getThumbnailImage()) + {{ $line->description }} + @endif +
+ +
+ + {{ $line->description }} + × {{ $line->quantity }} + + + @if ($line->option) +

{{ $line->option }}

+ @endif +
+ {{ $line->sub_total?->formatted() }}
@endforeach diff --git a/resources/views/checkout/drawer.blade.php b/resources/views/checkout/drawer.blade.php index cd0ff6a..0da4bb2 100644 --- a/resources/views/checkout/drawer.blade.php +++ b/resources/views/checkout/drawer.blade.php @@ -24,6 +24,8 @@ class="bbk-cart-dismiss" >× + @include('checkout::partials.cart-error') +
@include('checkout::partials.cart-body')
diff --git a/resources/views/checkout/page.blade.php b/resources/views/checkout/page.blade.php index f3d344f..1d0fc32 100644 --- a/resources/views/checkout/page.blade.php +++ b/resources/views/checkout/page.blade.php @@ -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') }} @@ -277,6 +278,7 @@ class="bbk-checkout-continue"
@if($option && !empty($product['variants'])) - + @if($optionIsColor) + + @else + + @endif @endif {{ __('storefront.product.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. --}} + +
diff --git a/resources/views/products/index.blade.php b/resources/views/products/index.blade.php index 579e867..c857b70 100644 --- a/resources/views/products/index.blade.php +++ b/resources/views/products/index.blade.php @@ -12,7 +12,8 @@ @endpush @section('content') -
+ {{--
--}} +

{{ __('storefront.shop.all_products') }}

diff --git a/resources/views/search/index.blade.php b/resources/views/search/index.blade.php index bec63bf..22682b8 100644 --- a/resources/views/search/index.blade.php +++ b/resources/views/search/index.blade.php @@ -12,7 +12,8 @@ @endpush @section('content') -
+ {{--
--}} +

{{ $heading }}

diff --git a/routes/web.php b/routes/web.php index bcd3df1..3509120 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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', );