From afa1993c53dc747ff107df7b763886c929b3ac28 Mon Sep 17 00:00:00 2001 From: elvira Date: Thu, 17 Sep 2026 21:52:27 +0300 Subject: [PATCH 01/22] 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', ); From af0245de437cf8ed52567385a6a4f242eea720a3 Mon Sep 17 00:00:00 2001 From: elvira Date: Thu, 17 Sep 2026 21:56:58 +0300 Subject: [PATCH 02/22] ux fix on product increase --- resources/js/checkout/bbk-cart-controller.js | 25 ++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/resources/js/checkout/bbk-cart-controller.js b/resources/js/checkout/bbk-cart-controller.js index d3eb98b..dfe5901 100644 --- a/resources/js/checkout/bbk-cart-controller.js +++ b/resources/js/checkout/bbk-cart-controller.js @@ -18,6 +18,7 @@ export default class extends Controller { connect() { this.onChanged = this.onChanged.bind(this) this.onKeydown = this.onKeydown.bind(this) + this.updateTimers = new Map() // line id -> pending debounce timer window.addEventListener('bbk-cart:changed', this.onChanged) window.addEventListener('bbk-cart:open', this.open.bind(this)) @@ -30,6 +31,7 @@ export default class extends Controller { disconnect() { window.removeEventListener('bbk-cart:changed', this.onChanged) document.removeEventListener('keydown', this.onKeydown) + this.updateTimers.forEach((timer) => clearTimeout(timer)) } onChanged(event) { @@ -63,7 +65,11 @@ export default class extends Controller { submit(event) { event.preventDefault() const form = event.target.closest('form') - if (form) this.send(form) + if (!form) return + + // A remove is a deliberate, one-shot action — only the quantity form + // (typing, or the +/- stepper below) benefits from debouncing. + form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form) } // +/- stepper buttons inside a line @@ -73,7 +79,22 @@ export default class extends Controller { const input = form.querySelector('input[type="number"]') const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir)) input.value = String(next) - this.send(form) + this.scheduleSend(form) + } + + // Repeated clicks (or spinner nudges) update the input instantly but only + // send once they settle for 300ms — sending on every single click was + // firing overlapping requests that raced each other and made the drawer + // visibly flicker/lag under quick clicking. + scheduleSend(form) { + const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId + if (!lineId) return this.send(form) + + clearTimeout(this.updateTimers.get(lineId)) + this.updateTimers.set(lineId, setTimeout(() => { + this.updateTimers.delete(lineId) + this.send(form) + }, 300)) } async send(form) { From a107184010781ac84726566a39db0a7b99a59dfc Mon Sep 17 00:00:00 2001 From: elvira Date: Mon, 21 Sep 2026 18:53:45 +0300 Subject: [PATCH 03/22] product options layout and add to cart button in homepage --- app/Http/Controllers/ProductController.php | 76 +++- composer.lock | 334 +++++------------- public/css/lunarphp/panel/lunar-panel.css | 0 public/js/app/components/apexcharts.js | 0 .../js/stimulus/product-form-controller.js | 45 ++- .../components/ui/color-swatch.blade.php | 24 +- .../components/ui/option-buttons.blade.php | 24 +- resources/views/home.blade.php | 13 +- resources/views/product/show.blade.php | 10 +- resources/views/product/show2.blade.php | 236 ------------- 10 files changed, 225 insertions(+), 537 deletions(-) mode change 100644 => 100755 public/css/lunarphp/panel/lunar-panel.css mode change 100644 => 100755 public/js/app/components/apexcharts.js delete mode 100644 resources/views/product/show2.blade.php diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index 3252786..315d2bd 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -40,26 +40,78 @@ public function show(string $locale, int $id) $collection = $product['collections'][0] ?? null; - $variantsData = $this->products->variantSummaries($product); - - $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)); + [$productOptions, $variantsData] = $this->buildOptionPicker($id); return view('product.show', [ 'collection' => $collection, 'product' => $product, - 'option' => $option, - 'optionIsColor' => $optionIsColor, + 'productOptions' => $productOptions, 'variantsData' => $variantsData, ]); } + /** + * One button/swatch group per product option (a product can have several + * — e.g. a custom-photo product with size + style + person-count, each + * combination resolved client-side to one exact ProductVariant, see + * product-form-controller.js) plus the per-variant data the picker + * resolves a selection against. + * + * Reads live Eloquent rather than the Meilisearch-indexed $product array + * the rest of the page uses (same reasoning as checkStock() below): the + * index has no concept of option/value display order (Lunar's + * `position` column) or a stable value id, both of which the picker + * needs — to render values in the merchant's intended order, and to + * match a combination back to one exact variant without relying on + * translated label strings staying unique. + * + * @return array{0: array, 1: array} + */ + private function buildOptionPicker(int $productId): array + { + $variants = ProductVariant::query() + ->where('product_id', $productId) + ->with(['values.option', 'prices', 'images']) + ->get(); + + $productOptions = $variants + ->flatMap(fn (ProductVariant $variant) => $variant->values) + ->unique('id') + ->groupBy(fn ($value) => $value->option->handle) + ->map(function ($values, $handle) { + $sorted = $values->sortBy([['position', 'asc'], ['id', 'asc']]); + + return [ + 'handle' => $handle, + 'label' => $sorted->first()->option->translate('name'), + // 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). + 'isColor' => $sorted->contains(fn ($v) => !empty($v->meta['hex'] ?? null)), + 'values' => $sorted->map(fn ($v) => [ + 'id' => $v->id, + 'label' => $v->translate('name'), + 'hex' => $v->meta['hex'] ?? null, + ])->values()->all(), + ]; + }) + ->values() + ->all(); + + $variantsData = $variants->map(fn (ProductVariant $variant) => [ + 'id' => $variant->id, + 'price' => $variant->prices->first()?->price?->decimal(), + 'image' => $variant->images->first()?->getUrl(), + // handle => selected value id, for matching a combination of + // selections back to this variant — see selectVariant() in + // product-form-controller.js. + 'options' => $variant->values->mapWithKeys(fn ($v) => [$v->option->handle => $v->id])->all(), + ])->values()->all(); + + return [$productOptions, $variantsData]; + } + /** * A storefront-owned, checkout-module-independent stock check — the * product page's "Add to cart" calls this first and only submits to the diff --git a/composer.lock b/composer.lock index fb66c1d..7bca38a 100644 --- a/composer.lock +++ b/composer.lock @@ -515,11 +515,11 @@ }, { "name": "boboko/core", - "version": "0.18.0", + "version": "0.19.0", "source": { "type": "git", "url": "https://code.radical-elements.com/boboko/core.git", - "reference": "2cc6f5e5f0d43102001d65b60810423a35e83e1f" + "reference": "0437057e5d604e3c05479089071fcbe79ad69bb7" }, "require": { "laravel/framework": "^12.0", @@ -558,7 +558,8 @@ "Modules\\Core\\Providers\\CartServiceProvider", "Modules\\Core\\Providers\\ReviewServiceProvider", "Modules\\Core\\Providers\\ShippingServiceProvider", - "Modules\\Core\\Providers\\OrderServiceProvider" + "Modules\\Core\\Providers\\OrderServiceProvider", + "Modules\\Core\\Providers\\PrivacyServiceProvider" ] } }, @@ -568,7 +569,7 @@ } }, "description": "Core module — authentication and shared panel behaviour", - "time": "2026-09-15T21:06:04+00:00" + "time": "2026-09-17T22:30:37+00:00" }, { "name": "brick/math", @@ -1062,16 +1063,16 @@ }, { "name": "danharrin/livewire-rate-limiting", - "version": "v2.2.1", + "version": "v2.3.0", "source": { "type": "git", "url": "https://github.com/danharrin/livewire-rate-limiting.git", - "reference": "69436717dc70e30f80d7f8fd02504c22992a9ad5" + "reference": "46c4ceaf7997c713c79afe7c507a39649f6c9d6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/danharrin/livewire-rate-limiting/zipball/69436717dc70e30f80d7f8fd02504c22992a9ad5", - "reference": "69436717dc70e30f80d7f8fd02504c22992a9ad5", + "url": "https://api.github.com/repos/danharrin/livewire-rate-limiting/zipball/46c4ceaf7997c713c79afe7c507a39649f6c9d6f", + "reference": "46c4ceaf7997c713c79afe7c507a39649f6c9d6f", "shasum": "" }, "require": { @@ -1112,7 +1113,7 @@ "type": "github" } ], - "time": "2026-08-06T08:41:51+00:00" + "time": "2026-09-18T07:38:01+00:00" }, { "name": "dflydev/dot-access-data", @@ -1329,27 +1330,25 @@ }, { "name": "doctrine/lexer", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c", + "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5", - "psalm/plugin-phpunit": "^0.18.3", - "vimeo/psalm": "^5.21" + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" }, "type": "library", "autoload": { @@ -1386,7 +1385,7 @@ ], "support": { "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/3.0.1" + "source": "https://github.com/doctrine/lexer/tree/3.0.2" }, "funding": [ { @@ -1402,7 +1401,7 @@ "type": "tidelift" } ], - "time": "2024-02-05T11:56:58+00:00" + "time": "2026-06-14T20:44:06+00:00" }, { "name": "dompdf/dompdf", @@ -1692,7 +1691,7 @@ }, { "name": "filament/actions", - "version": "v4.13.2", + "version": "v4.13.4", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", @@ -1741,16 +1740,16 @@ }, { "name": "filament/filament", - "version": "v4.13.2", + "version": "v4.13.4", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "ff848c7750b49f0bb532d44c6768f0e601fb09d2" + "reference": "1215d7d79b5609f66c1e432933610eefa0ab3ad6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/ff848c7750b49f0bb532d44c6768f0e601fb09d2", - "reference": "ff848c7750b49f0bb532d44c6768f0e601fb09d2", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/1215d7d79b5609f66c1e432933610eefa0ab3ad6", + "reference": "1215d7d79b5609f66c1e432933610eefa0ab3ad6", "shasum": "" }, "require": { @@ -1795,20 +1794,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-15T16:56:27+00:00" + "time": "2026-09-20T19:15:07+00:00" }, { "name": "filament/forms", - "version": "v4.13.2", + "version": "v4.13.4", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "18e3382115ed3062a074736c9e80cfcb3ffe2221" + "reference": "10d7ba430544ae706a406713105ba37b18f52008" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/18e3382115ed3062a074736c9e80cfcb3ffe2221", - "reference": "18e3382115ed3062a074736c9e80cfcb3ffe2221", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/10d7ba430544ae706a406713105ba37b18f52008", + "reference": "10d7ba430544ae706a406713105ba37b18f52008", "shasum": "" }, "require": { @@ -1845,20 +1844,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-15T17:00:54+00:00" + "time": "2026-09-20T18:45:30+00:00" }, { "name": "filament/infolists", - "version": "v4.13.2", + "version": "v4.13.4", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", - "reference": "b05093c8ff726d28eb75df29fa21bdd836386ded" + "reference": "1979591422ff823543622b87e0276cdca3f863e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/infolists/zipball/b05093c8ff726d28eb75df29fa21bdd836386ded", - "reference": "b05093c8ff726d28eb75df29fa21bdd836386ded", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/1979591422ff823543622b87e0276cdca3f863e8", + "reference": "1979591422ff823543622b87e0276cdca3f863e8", "shasum": "" }, "require": { @@ -1890,20 +1889,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-15T16:59:31+00:00" + "time": "2026-09-20T18:45:56+00:00" }, { "name": "filament/notifications", - "version": "v4.13.2", + "version": "v4.13.4", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", - "reference": "f481bcfb705ad650f96336e7496814419907639c" + "reference": "40989f93ec3dc36f5582024ca41f99333a4c0ea7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/notifications/zipball/f481bcfb705ad650f96336e7496814419907639c", - "reference": "f481bcfb705ad650f96336e7496814419907639c", + "url": "https://api.github.com/repos/filamentphp/notifications/zipball/40989f93ec3dc36f5582024ca41f99333a4c0ea7", + "reference": "40989f93ec3dc36f5582024ca41f99333a4c0ea7", "shasum": "" }, "require": { @@ -1937,20 +1936,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-15T16:55:37+00:00" + "time": "2026-09-20T18:48:58+00:00" }, { "name": "filament/query-builder", - "version": "v4.13.2", + "version": "v4.13.4", "source": { "type": "git", "url": "https://github.com/filamentphp/query-builder.git", - "reference": "f7c1eb9eeaded573838c1f3b8d0a63f55434331a" + "reference": "0428645f4cd0a232b6bc446437cbfafa366e56c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/query-builder/zipball/f7c1eb9eeaded573838c1f3b8d0a63f55434331a", - "reference": "f7c1eb9eeaded573838c1f3b8d0a63f55434331a", + "url": "https://api.github.com/repos/filamentphp/query-builder/zipball/0428645f4cd0a232b6bc446437cbfafa366e56c0", + "reference": "0428645f4cd0a232b6bc446437cbfafa366e56c0", "shasum": "" }, "require": { @@ -1983,20 +1982,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-15T16:55:48+00:00" + "time": "2026-09-20T18:53:32+00:00" }, { "name": "filament/schemas", - "version": "v4.13.2", + "version": "v4.13.4", "source": { "type": "git", "url": "https://github.com/filamentphp/schemas.git", - "reference": "fec2747e8cf1e6ca76d6c883c7f1019da994f771" + "reference": "dc4eeb57671b104b9e60f6d3f435f803c705e6df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/schemas/zipball/fec2747e8cf1e6ca76d6c883c7f1019da994f771", - "reference": "fec2747e8cf1e6ca76d6c883c7f1019da994f771", + "url": "https://api.github.com/repos/filamentphp/schemas/zipball/dc4eeb57671b104b9e60f6d3f435f803c705e6df", + "reference": "dc4eeb57671b104b9e60f6d3f435f803c705e6df", "shasum": "" }, "require": { @@ -2028,11 +2027,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-08T10:20:09+00:00" + "time": "2026-09-20T18:49:21+00:00" }, { "name": "filament/spatie-laravel-media-library-plugin", - "version": "v4.13.2", + "version": "v4.13.4", "source": { "type": "git", "url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git", @@ -2069,16 +2068,16 @@ }, { "name": "filament/support", - "version": "v4.13.2", + "version": "v4.13.4", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "6ff50570ff1f2c6c1caf6ed1e7a2ce146b901106" + "reference": "6bd7bdb5f5ff3b1a70cac6dabe28f5d996a36bcc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/6ff50570ff1f2c6c1caf6ed1e7a2ce146b901106", - "reference": "6ff50570ff1f2c6c1caf6ed1e7a2ce146b901106", + "url": "https://api.github.com/repos/filamentphp/support/zipball/6bd7bdb5f5ff3b1a70cac6dabe28f5d996a36bcc", + "reference": "6bd7bdb5f5ff3b1a70cac6dabe28f5d996a36bcc", "shasum": "" }, "require": { @@ -2124,20 +2123,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-15T16:59:53+00:00" + "time": "2026-09-20T19:14:52+00:00" }, { "name": "filament/tables", - "version": "v4.13.2", + "version": "v4.13.4", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "51f2f2d19eddae424b8b4a08ab9a14770e2acc9d" + "reference": "8a0c6634d752e0b71ed0871f92d533c0d03d7601" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/51f2f2d19eddae424b8b4a08ab9a14770e2acc9d", - "reference": "51f2f2d19eddae424b8b4a08ab9a14770e2acc9d", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/8a0c6634d752e0b71ed0871f92d533c0d03d7601", + "reference": "8a0c6634d752e0b71ed0871f92d533c0d03d7601", "shasum": "" }, "require": { @@ -2170,20 +2169,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-15T16:56:17+00:00" + "time": "2026-09-20T18:53:09+00:00" }, { "name": "filament/widgets", - "version": "v4.13.2", + "version": "v4.13.4", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", - "reference": "3d5a4dde469ad9f8e6cb2d5928e4882f6a92afe1" + "reference": "5ec044730102c3444d019232d8edc3d8922876c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/widgets/zipball/3d5a4dde469ad9f8e6cb2d5928e4882f6a92afe1", - "reference": "3d5a4dde469ad9f8e6cb2d5928e4882f6a92afe1", + "url": "https://api.github.com/repos/filamentphp/widgets/zipball/5ec044730102c3444d019232d8edc3d8922876c6", + "reference": "5ec044730102c3444d019232d8edc3d8922876c6", "shasum": "" }, "require": { @@ -2214,7 +2213,7 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-09-07T18:36:33+00:00" + "time": "2026-09-20T18:53:38+00:00" }, { "name": "fruitcake/php-cors", @@ -7844,20 +7843,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.3", + "version": "4.9.4", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", "shasum": "" }, "require": { - "brick/math": ">=0.8.16 <=0.18", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -7916,9 +7915,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" + "source": "https://github.com/ramsey/uuid/tree/4.9.4" }, - "time": "2026-06-18T03:57:49+00:00" + "time": "2026-09-16T11:39:30+00:00" }, { "name": "ryangjchandler/blade-capture-directive", @@ -8000,35 +7999,33 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v9.4.0", + "version": "v9.5.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", - "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f284e63b6e891e0c28631e54ba06c3ed102a9ef3", + "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3", "shasum": "" }, "require": { "ext-iconv": "*", - "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "1.4.0", "phpstan/extension-installer": "1.4.3", - "phpstan/phpstan": "1.12.33 || 2.2.2", - "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", - "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", - "phpunit/phpunit": "8.5.52", + "phpstan/phpstan": "1.12.33 || 2.2.9", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.18", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.12", + "phpunit/phpunit": "8.5.54", "rawr/phpunit-data-provider": "3.3.1", - "rector/rector": "1.2.10 || 2.4.6", - "rector/type-perfect": "1.0.0 || 2.1.3", - "squizlabs/php_codesniffer": "4.0.1", - "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" + "rector/rector": "1.2.10 || 2.6.2", + "rector/type-perfect": "1.0.0 || 2.1.4", + "squizlabs/php_codesniffer": "4.0.4" }, "suggest": { "ext-mbstring": "for parsing UTF-8 CSS" @@ -8036,7 +8033,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.5.x-dev" + "dev-main": "9.6.x-dev" } }, "autoload": { @@ -8074,9 +8071,9 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.5.0" }, - "time": "2026-06-18T15:10:53+00:00" + "time": "2026-09-20T15:02:00+00:00" }, { "name": "scrivo/highlight.php", @@ -12603,16 +12600,16 @@ }, { "name": "technikermathe/blade-lucide-icons", - "version": "v3.180.0", + "version": "v3.181.0", "source": { "type": "git", "url": "https://github.com/PascaleBeier/blade-lucide-icons.git", - "reference": "adac9da3a65910fedc271129eea5340093910203" + "reference": "64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/adac9da3a65910fedc271129eea5340093910203", - "reference": "adac9da3a65910fedc271129eea5340093910203", + "url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98", + "reference": "64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98", "shasum": "" }, "require": { @@ -12662,152 +12659,9 @@ ], "support": { "issues": "https://github.com/PascaleBeier/blade-lucide-icons/issues", - "source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.180.0" + "source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.181.0" }, - "time": "2026-09-15T02:25:56+00:00" - }, - { - "name": "thecodingmachine/safe", - "version": "v3.4.0", - "source": { - "type": "git", - "url": "https://github.com/thecodingmachine/safe.git", - "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", - "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpstan/phpstan": "^2", - "phpunit/phpunit": "^10", - "squizlabs/php_codesniffer": "^3.2" - }, - "type": "library", - "autoload": { - "files": [ - "lib/special_cases.php", - "generated/apache.php", - "generated/apcu.php", - "generated/array.php", - "generated/bzip2.php", - "generated/calendar.php", - "generated/classobj.php", - "generated/com.php", - "generated/cubrid.php", - "generated/curl.php", - "generated/datetime.php", - "generated/dir.php", - "generated/eio.php", - "generated/errorfunc.php", - "generated/exec.php", - "generated/fileinfo.php", - "generated/filesystem.php", - "generated/filter.php", - "generated/fpm.php", - "generated/ftp.php", - "generated/funchand.php", - "generated/gettext.php", - "generated/gmp.php", - "generated/gnupg.php", - "generated/hash.php", - "generated/ibase.php", - "generated/ibmDb2.php", - "generated/iconv.php", - "generated/image.php", - "generated/imap.php", - "generated/info.php", - "generated/inotify.php", - "generated/json.php", - "generated/ldap.php", - "generated/libxml.php", - "generated/lzf.php", - "generated/mailparse.php", - "generated/mbstring.php", - "generated/misc.php", - "generated/mysql.php", - "generated/mysqli.php", - "generated/network.php", - "generated/oci8.php", - "generated/opcache.php", - "generated/openssl.php", - "generated/outcontrol.php", - "generated/pcntl.php", - "generated/pcre.php", - "generated/pgsql.php", - "generated/posix.php", - "generated/ps.php", - "generated/pspell.php", - "generated/readline.php", - "generated/rnp.php", - "generated/rpminfo.php", - "generated/rrd.php", - "generated/sem.php", - "generated/session.php", - "generated/shmop.php", - "generated/sockets.php", - "generated/sodium.php", - "generated/solr.php", - "generated/spl.php", - "generated/sqlsrv.php", - "generated/ssdeep.php", - "generated/ssh2.php", - "generated/stream.php", - "generated/strings.php", - "generated/swoole.php", - "generated/uodbc.php", - "generated/uopz.php", - "generated/url.php", - "generated/var.php", - "generated/xdiff.php", - "generated/xml.php", - "generated/xmlrpc.php", - "generated/yaml.php", - "generated/yaz.php", - "generated/zip.php", - "generated/zlib.php" - ], - "classmap": [ - "lib/DateTime.php", - "lib/DateTimeImmutable.php", - "lib/Exceptions/", - "generated/Exceptions/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHP core functions that throw exceptions instead of returning FALSE on error", - "support": { - "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" - }, - "funding": [ - { - "url": "https://github.com/OskarStark", - "type": "github" - }, - { - "url": "https://github.com/shish", - "type": "github" - }, - { - "url": "https://github.com/silasjoisten", - "type": "github" - }, - { - "url": "https://github.com/staabm", - "type": "github" - } - ], - "time": "2026-02-04T18:08:13+00:00" + "time": "2026-09-18T02:09:04+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", diff --git a/public/css/lunarphp/panel/lunar-panel.css b/public/css/lunarphp/panel/lunar-panel.css old mode 100644 new mode 100755 diff --git a/public/js/app/components/apexcharts.js b/public/js/app/components/apexcharts.js old mode 100644 new mode 100755 diff --git a/resources/js/stimulus/product-form-controller.js b/resources/js/stimulus/product-form-controller.js index c742af5..ca090fd 100644 --- a/resources/js/stimulus/product-form-controller.js +++ b/resources/js/stimulus/product-form-controller.js @@ -16,13 +16,17 @@ export default class extends Controller { } connect() { - const params = new URLSearchParams(window.location.search) - const urlId = parseInt(params.get('variant')) - const defaultId = this.variantsValue[0]?.id + const params = new URLSearchParams(window.location.search) + const urlId = parseInt(params.get('variant')) + const urlVariant = this.variantsValue.find(v => v.id === urlId) + const initial = urlVariant ?? this.variantsValue[0] - this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId) - ? urlId - : defaultId + // A product can have several independent options (e.g. size + style + // + person-count) — this tracks the currently-picked value id per + // option handle, and selectVariant() below resolves the full + // combination back to one exact variant on every change. + this.selections = { ...initial?.options } + this.selectedValue = initial?.id // Capture phase, on this controller's own root element (an ancestor // of the checkout module's add-to-cart ) — runs BEFORE that @@ -108,11 +112,24 @@ export default class extends Controller { } selectVariant(event) { - const id = parseInt(event.currentTarget.dataset.variantId) - this.selectedValue = id + const option = event.currentTarget.dataset.option + const valueId = parseInt(event.currentTarget.dataset.valueId) + this.selections = { ...this.selections, [option]: valueId } + + const match = this.variantsValue.find(variant => + Object.keys(this.selections).every(key => variant.options?.[key] === this.selections[key]) + ) + + // No variant exists for this combination (e.g. an option value that + // isn't offered together with another currently-selected value) — + // leave the previous selection in place rather than pointing the + // add-to-cart form at nothing. + if (!match) return + + this.selectedValue = match.id const url = new URL(window.location) - url.searchParams.set('variant', id) + url.searchParams.set('variant', match.id) window.history.pushState({}, '', url) } @@ -140,12 +157,16 @@ export default class extends Controller { if (purchasableInput) purchasableInput.value = id this.swatchTargets.forEach(swatch => { - const isSelected = parseInt(swatch.dataset.variantId) === id + const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId) swatch.classList.toggle('is-selected', isSelected) swatch.setAttribute('aria-pressed', String(isSelected)) - if (isSelected && this.hasColorNameTarget) { - this.colorNameTarget.textContent = swatch.getAttribute('aria-label') + if (isSelected) { + // Each color-option group has its own colorName echo (see + // x-ui.color-swatch) — matched by option handle so a swatch + // in one group never overwrites another group's label. + const colorName = this.colorNameTargets.find(target => target.dataset.option === swatch.dataset.option) + if (colorName) colorName.textContent = swatch.getAttribute('aria-label') } }) } diff --git a/resources/views/components/ui/color-swatch.blade.php b/resources/views/components/ui/color-swatch.blade.php index c9b171b..bfa6aa0 100644 --- a/resources/views/components/ui/color-swatch.blade.php +++ b/resources/views/components/ui/color-swatch.blade.php @@ -1,12 +1,11 @@ -{{-- $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. --}} -@props(['variants', 'option' => null]) +{{-- $values: one product option's de-duplicated, ordered values, as built by + ProductController::buildOptionPicker() — [{id, label, hex}]. --}} +@props(['values', 'option' => null, 'optionHandle' => null])
@if($option)

- {{ $option }}: + {{ $option }}:

@endif @@ -15,22 +14,17 @@ class="flex flex-wrap gap-2" role="group" aria-label="{{ $option ?? 'Color' }}" > - @foreach($variants as $variant) - @php - $value = $variant['options'][0] ?? null; - $label = $value['value'] ?? ''; - $bg = $value['meta']['hex'] ?? '#cccccc'; - @endphp + @foreach($values as $value) @endforeach
diff --git a/resources/views/components/ui/option-buttons.blade.php b/resources/views/components/ui/option-buttons.blade.php index 9867ec9..578fced 100644 --- a/resources/views/components/ui/option-buttons.blade.php +++ b/resources/views/components/ui/option-buttons.blade.php @@ -1,9 +1,8 @@ -{{-- $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 for options that aren't a color - (no meta.hex), where a swatch dot has nothing meaningful to show. --}} -@props(['variants', 'option' => null]) +{{-- $values: one product option's de-duplicated, ordered values, as built by + ProductController::buildOptionPicker() — [{id, label, hex}]. Use this + instead of for options that aren't a color (no hex), + where a swatch dot has nothing meaningful to show. --}} +@props(['values', 'option' => null, 'optionHandle' => null])
@if($option) @@ -17,20 +16,17 @@ 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 + @foreach($values as $value) {{ $label }} + >{{ $value['label'] }} @endforeach
diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php index 4495bf3..307ab0e 100644 --- a/resources/views/home.blade.php +++ b/resources/views/home.blade.php @@ -60,9 +60,16 @@ class="w-full h-full object-cover" @endif - - {{ __('storefront.product.add_to_cart') }} - + @if ($product['variantId'] ?? null) + + + {{ __('storefront.product.add_to_cart') }} + + + @endif
diff --git a/resources/views/product/show.blade.php b/resources/views/product/show.blade.php index fb728f4..f51b7ff 100644 --- a/resources/views/product/show.blade.php +++ b/resources/views/product/show.blade.php @@ -169,13 +169,13 @@ class="absolute bottom-6 right-8 text-white text-sm" @endif
- @if($option && !empty($product['variants'])) - @if($optionIsColor) - + @foreach($productOptions as $productOption) + @if($productOption['isColor']) + @else - + @endif - @endif + @endforeach translateAttribute('name') . ' — ' . config('app.name')) -@section('description', $product->translateAttribute('description')) - -@section('content') -
- - @php $collection = $product->collections->first(); @endphp - - - -
- - {{-- Image --}} -
- @if($product->media->isNotEmpty()) - {{-- Thumbnails --}} -
- - -
- @foreach($product->media as $i => $media) - - @endforeach -
- - -
- - {{-- Main image --}} - - - {{-- Lightbox --}} - - @else -
- No image -
- @endif -
- - {{-- Info --}} -
- -

- {{ $product->translateAttribute('name') }} -

- - - - @if($product->variants->first()?->prices->isNotEmpty()) -

- -

- @endif - - @php - $desc = strip_tags($product->translateAttribute('description') ?? ''); - $descTruncated = Str::limit($desc, 137); - $descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.')); - @endphp -
- {{ $descTruncated }} - @if($descNeedsMore) - Περισσότερα - @endif -
- - @if($option && $product->variants->count() >= 1) - - @endif - -
- - Προσθήκη στο καλάθι -
- -
- -
- - - -
- {!! $product->translateAttribute('description') !!} -
- @if($product->translateAttribute('details')) -
{!! $product->translateAttribute('details') !!}
- @endif -
    -
  • Όλα τα προϊόντα εκτυπώνονται και προετοιμάζονται κατά παραγγελία. Ο χρόνος προετοιμασίας κυμαίνεται μεταξύ 2 και 7 εργάσιμων ημερών.
  • -
  • Όλα τα προϊόντα κατασκευάζονται με τρισδιάστατη εκτύπωση σε ειδικούς εκτυπωτές πλαστικού υλικού. Πιθανώς να έχουν εμφανείς γραμμές ένωσης, στρώσεις εκτύπωσης υλικού και μικρές ατέλειες. Είναι φυσιολογικό για το αποτέλεσμα αυτής της δημιουργικής διαδικασίας.
  • -
  • Όλα τα προϊόντα είναι σχεδιασμένα και κατασκευασμένα είτε για διακόσμηση είτε για χρήση σε λογικά, ρεαλιστικά πλαίσια. Υπερβολική ισχύς, υψηλότατες θερμοκρασίες και αποσυναρμολόγηση μπορεί να προκαλέσουν ζημιά στο προϊόν για την οποία δεν ευθύνεται το 3Dealer.
  • -
-
- - {{-- @if(count($reviews) > 0) -
- @foreach($reviews as $review) - - @endforeach -
- @else -

Δεν υπάρχουν αξιολογήσεις ακόμα.

- @endif - -

- {{ count($reviews) > 0 ? 'Πρόσθεσε μια' : 'Γράψε την πρώτη' }} αξιολόγηση για το «{{ $product->translateAttribute('name') }}» -

--}} - - -
-
- - - -
- - - -@endsection From 24851184c42796e1bfa424bdff7a7bd30c9d42a5 Mon Sep 17 00:00:00 2001 From: elvira Date: Tue, 22 Sep 2026 19:00:34 +0300 Subject: [PATCH 04/22] temp emails, reviews theming and form, minor change in checkout module, validation translations seeder --- .../Checkout/CheckoutController.php | 40 ++++++- app/Http/Controllers/ProductController.php | 102 ++++++++++++++++ composer.lock | 10 +- .../seeders/ValidationTranslationsSeeder.php | 110 ++++++++++++++++++ resources/css/checkout.css | 5 + resources/js/stimulus/index.js | 2 + .../js/stimulus/review-count-controller.js | 12 ++ .../js/stimulus/star-rating-controller.js | 19 ++- resources/js/stimulus/tabs-controller.js | 4 +- resources/views/checkout/drawer.blade.php | 2 +- .../checkout/partials/cart-line.blade.php | 21 +++- .../views/components/review-card.blade.php | 29 +++-- .../views/components/review-form.blade.php | 32 ++--- .../views/components/reviews-stars.blade.php | 15 ++- resources/views/components/ui/tabs.blade.php | 13 ++- resources/views/emails/layout.blade.php | 97 +++++++++++++++ resources/views/product/show.blade.php | 17 ++- .../views/vendor/core/auth/mail/otp.blade.php | 31 +++++ routes/web.php | 4 + 19 files changed, 518 insertions(+), 47 deletions(-) create mode 100644 database/seeders/ValidationTranslationsSeeder.php create mode 100644 resources/js/stimulus/review-count-controller.js create mode 100644 resources/views/emails/layout.blade.php create mode 100644 resources/views/vendor/core/auth/mail/otp.blade.php diff --git a/app/Http/Controllers/Checkout/CheckoutController.php b/app/Http/Controllers/Checkout/CheckoutController.php index 52c95bf..878855a 100644 --- a/app/Http/Controllers/Checkout/CheckoutController.php +++ b/app/Http/Controllers/Checkout/CheckoutController.php @@ -74,13 +74,27 @@ public function show(string $locale): View $cart->refresh()->recalculate(); } + $paymentMethods = $this->checkout->getPaymentMethods(); + + // Nothing checked yet (fresh cart), or the shopper's earlier pick is + // no longer offered (method disabled/removed since) — auto-select + // the first one, same as a manual click would, so the payment + // section (and the Stripe Element mounting under it) isn't sitting + // inert behind an unchecked radio. A still-valid previous choice is + // left alone. + $firstMethod = $paymentMethods->first(); + + if ($cart && $firstMethod && ! $paymentMethods->contains('type', data_get($cart, 'meta.payment_method'))) { + $cart = $this->checkout->selectPaymentMethod($firstMethod->type); + } + return view('checkout::page', [ 'cart' => $cart, 'lines' => $lines, 'billingAddress' => $cart?->billingAddress, 'shippingAddress' => $cart?->shippingAddress, 'shippingOptions' => $shippingOptions, - 'paymentMethods' => $this->checkout->getPaymentMethods(), + 'paymentMethods' => $paymentMethods, 'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true), 'storeCountry' => $storeCountry, 'countries' => $storeCountry @@ -300,6 +314,30 @@ public function placeOrder(string $locale, Request $request): JsonResponse ], 422); } + // Same check Lunar's own ValidateCartForOrderCreation runs inside + // initiatePayment() (a product unpublished/deleted after it was + // added to the cart) — checked here first so the shopper is told + // which product is the problem, rather than falling into the + // catch-all "complete your billing/shipping details" message below, + // which is what actually happened and is generic to every + // CartException reason, misleading when the real cause is a line, + // not an address. + $unavailableLines = $this->cart->activeLines($cart)->filter( + fn ($line) => ! $line->purchasable || ! $line->purchasable->isPurchasable(), + ); + + if ($unavailableLines->isNotEmpty()) { + $names = $unavailableLines + ->map(fn ($line) => $line->purchasable?->product?->translateAttribute('name') ?? $line->purchasable?->getIdentifier()) + ->filter() + ->implode(', '); + + return response()->json([ + 'status' => 'invalid', + 'message' => __('checkout.page.cart_line_unavailable', ['name' => $names]), + ], 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 diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index 315d2bd..db8c259 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -5,10 +5,14 @@ use App\Catalog\ProductListing; use App\Catalog\ProductListingPage; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Validator; +use Lunar\Models\Product; use Lunar\Models\ProductVariant; use Modules\Core\Catalog\Services\ProductService; +use Modules\Core\Review\Models\ProductReview; class ProductController extends Controller { @@ -38,6 +42,8 @@ public function show(string $locale, int $id) $product = $this->products->getById($id); abort_if($product === null, Response::HTTP_NOT_FOUND); + $product = $this->mergeJustSubmittedReview($product); + $collection = $product['collections'][0] ?? null; [$productOptions, $variantsData] = $this->buildOptionPicker($id); @@ -112,6 +118,40 @@ private function buildOptionPicker(int $productId): array return [$productOptions, $variantsData]; } + /** + * Meilisearch's own write API is itself async — addDocuments() enqueues an + * indexing task and returns immediately, and Laravel\Scout\Engines\ + * MeilisearchEngine::update() never waits on that task, so even + * $product->searchableSync() (which only skips OUR queue) can still land + * the shopper back on this page before Meilisearch has actually processed + * the write. storeReview() flashes the review it just created for exactly + * this one next request; splice it in here rather than trust the index is + * already caught up. Guarded by id so a race the other way — the index + * DID catch up in time — doesn't show the same review twice. + */ + private function mergeJustSubmittedReview(array $product): array + { + $justSubmitted = session('justSubmittedReview'); + + if (! $justSubmitted || (string) ($justSubmitted['product_id'] ?? null) !== (string) $product['id']) { + return $product; + } + + $items = $product['reviews']['items'] ?? []; + + if (collect($items)->contains('id', $justSubmitted['id'])) { + return $product; + } + + $items = [$justSubmitted, ...$items]; + + $product['reviews']['items'] = $items; + $product['reviews']['count'] = count($items); + $product['reviews']['average_rating'] = round(collect($items)->avg('rating'), 1); + + return $product; + } + /** * A storefront-owned, checkout-module-independent stock check — the * product page's "Add to cart" calls this first and only submits to the @@ -141,4 +181,66 @@ public function checkStock(string $locale, Request $request): JsonResponse 'stock' => $variant->purchasable === 'always' ? null : $variant->getTotalInventory(), ]); } + + /** + * boboko/core's product_reviews table has no moderation/status column, so + * this goes live immediately — no approval queue to land in. + */ + public function storeReview(string $locale, Request $request, Product $product): RedirectResponse + { + $reviewsUrl = route('product.show', [ + 'locale' => $locale, + 'id' => $product->id, + 'tab' => 'reviews', + ]).'#product-tabs'; + + $validator = Validator::make($request->all(), [ + 'rating' => ['required', 'integer', 'between:1,5'], + 'content' => ['required', 'string'], + 'name' => ['nullable', 'string', 'max:255'], + 'email' => ['required', 'email'], + ]); + + if ($validator->fails()) { + return redirect($reviewsUrl)->withErrors($validator)->withInput(); + } + + $data = $validator->validated(); + + // Not $product->reviews()->create(...): that relation only exists via a + // Product::macro() registered in CorePlugin::register(Panel $panel), which + // Filament calls solely when the /boboko admin panel boots — never on a + // plain storefront request, where the macro is simply undefined. + $review = ProductReview::create([ + 'product_id' => $product->id, + 'rating' => $data['rating'], + 'body' => $data['content'], + 'reviewer_name' => $data['name'] ?? null, + 'reviewer_email' => $data['email'], + 'reviewed_at' => now(), + 'source' => 'storefront', + ]); + + // ReviewServiceProvider also reindexes on the model's `created` event, but + // queued (SCOUT_QUEUE=true) — it wouldn't land before this redirect's page + // load. Syncing here skips our queue too, but Meilisearch's own write API + // is itself async on top of that (see mergeJustSubmittedReview()), so this + // alone still isn't a guarantee — it's the flash below that actually is. + $product->searchableSync(); + + return redirect($reviewsUrl) + ->with('reviewSubmitted', true) + ->with('justSubmittedReview', [ + 'id' => $review->id, + 'product_id' => $review->product_id, + 'title' => $review->title, + 'body' => $review->body, + 'rating' => $review->rating, + 'reviewed_at' => $review->reviewed_at?->timestamp, + 'reviewer_name' => $review->reviewer_name, + 'reply' => null, + 'replied_at' => null, + 'media' => [], + ]); + } } diff --git a/composer.lock b/composer.lock index 7bca38a..8c32ab3 100644 --- a/composer.lock +++ b/composer.lock @@ -3491,16 +3491,16 @@ }, { "name": "league/commonmark", - "version": "2.10.1", + "version": "2.10.3", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e" + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e", - "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f", + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f", "shasum": "" }, "require": { @@ -3594,7 +3594,7 @@ "type": "tidelift" } ], - "time": "2026-09-07T13:44:26+00:00" + "time": "2026-09-21T13:07:34+00:00" }, { "name": "league/config", diff --git a/database/seeders/ValidationTranslationsSeeder.php b/database/seeders/ValidationTranslationsSeeder.php new file mode 100644 index 0000000..e7a2367 --- /dev/null +++ b/database/seeders/ValidationTranslationsSeeder.php @@ -0,0 +1,110 @@ +validate()` call in the app + * (see CheckoutController::saveAddress(), CartController, ProductController), + * not just the checkout module. + * + * Spatie's DB loader (spatie/laravel-translation-loader, wired in boboko-core's + * LocalizationServiceProvider) merges this group over Laravel's file-based + * validation.php, which the project doesn't ship a `lang/` copy of — so without + * this, Greek requests fall back to Laravel's untranslated English defaults. + * Only the rule keys and field attributes actually in use are seeded; add more + * as new rules/fields show up. + * + * Additive and idempotent: a key that already exists is left untouched, so + * anything edited in the Filament Language Lines UI wins on a re-run. Runs + * explicitly — `php artisan db:seed --class=ValidationTranslationsSeeder` — it + * is not wired into DatabaseSeeder. + * + * Greek copy uses the project's informal register (εσύ/σου). + */ +class ValidationTranslationsSeeder extends Seeder +{ + public function run(): void + { + $translations = app(TranslationService::class); + + foreach ($this->lines() as $key => [$en, $el]) { + $exists = LanguageLine::query() + ->where('group', 'validation') + ->where('key', $key) + ->exists(); + + if ($exists) { + $this->command?->warn("validation.{$key} already exists — skipped"); + + continue; + } + + $translations->create('validation', $key, ['en' => $en, 'el' => $el]); + $this->command?->info("validation.{$key} added"); + } + } + + /** + * key => [English, Greek]. + * + * @return array + */ + private function lines(): array + { + return [ + // ── Rule messages ───────────────────────────────────────────── + 'required' => ['The :attribute field is required.', 'Το πεδίο :attribute είναι υποχρεωτικό.'], + 'email' => ['The :attribute field must be a valid email address.', 'Το πεδίο :attribute πρέπει να είναι έγκυρη διεύθυνση email.'], + 'string' => ['The :attribute field must be a string.', 'Το πεδίο :attribute πρέπει να είναι κείμενο.'], + 'integer' => ['The :attribute field must be an integer.', 'Το πεδίο :attribute πρέπει να είναι ακέραιος αριθμός.'], + 'boolean' => ['The :attribute field must be true or false.', 'Το πεδίο :attribute πρέπει να είναι true ή false.'], + 'min.numeric' => ['The :attribute field must be at least :min.', 'Το πεδίο :attribute πρέπει να είναι τουλάχιστον :min.'], + 'max.string' => ['The :attribute field must not be greater than :max characters.', 'Το πεδίο :attribute δεν πρέπει να ξεπερνά τους :max χαρακτήρες.'], + 'between.numeric' => ['The :attribute field must be between :min and :max.', 'Το πεδίο :attribute πρέπει να είναι μεταξύ :min και :max.'], + 'exists' => ['The selected :attribute is invalid.', 'Η επιλεγμένη τιμή για το πεδίο :attribute δεν είναι έγκυρη.'], + + // ── Field names (checkout: billing/shipping address) ────────── + 'attributes.contact_email' => ['email', 'email'], + 'attributes.billing_first_name' => ['first name', 'όνομα'], + 'attributes.billing_last_name' => ['last name', 'επώνυμο'], + 'attributes.billing_company_name' => ['company name', 'επωνυμία εταιρείας'], + 'attributes.billing_tax_identifier' => ['tax ID', 'ΑΦΜ'], + 'attributes.billing_line_one' => ['address', 'διεύθυνση'], + 'attributes.billing_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'], + 'attributes.billing_city' => ['city', 'πόλη'], + 'attributes.billing_state' => ['region', 'νομό / περιοχή'], + 'attributes.billing_postcode' => ['postcode', 'ταχυδρομικό κώδικα'], + 'attributes.billing_country_id' => ['country', 'χώρα'], + 'attributes.billing_contact_phone' => ['phone', 'τηλέφωνο'], + 'attributes.shipping_first_name' => ['first name', 'όνομα'], + 'attributes.shipping_last_name' => ['last name', 'επώνυμο'], + 'attributes.shipping_company_name' => ['company name', 'επωνυμία εταιρείας'], + 'attributes.shipping_line_one' => ['address', 'διεύθυνση'], + 'attributes.shipping_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'], + 'attributes.shipping_city' => ['city', 'πόλη'], + 'attributes.shipping_state' => ['region', 'νομό / περιοχή'], + 'attributes.shipping_postcode' => ['postcode', 'ταχυδρομικό κώδικα'], + 'attributes.shipping_country_id' => ['country', 'χώρα'], + 'attributes.shipping_contact_phone' => ['phone', 'τηλέφωνο'], + 'attributes.shipping_delivery_instructions' => ['delivery notes', 'σχόλια για την παράδοση'], + + // ── Field names (cart) ───────────────────────────────────────── + 'attributes.purchasable_id' => ['product', 'προϊόν'], + 'attributes.quantity' => ['quantity', 'ποσότητα'], + 'attributes.code' => ['coupon code', 'κωδικό κουπονιού'], + + // ── Field names (product reviews / stock check) ──────────────── + 'attributes.variant' => ['variant', 'παραλλαγή'], + 'attributes.rating' => ['rating', 'βαθμολογία'], + 'attributes.content' => ['review text', 'κείμενο κριτικής'], + 'attributes.name' => ['name', 'όνομα'], + 'attributes.email' => ['email', 'email'], + ]; + } +} diff --git a/resources/css/checkout.css b/resources/css/checkout.css index 41c52ad..2fa900f 100644 --- a/resources/css/checkout.css +++ b/resources/css/checkout.css @@ -200,10 +200,15 @@ .bbk-cart-item-media img { .bbk-cart-item-detail { min-width: 0; } .bbk-cart-item-title { + display: block; margin: 0 0 0.25rem; font-weight: 600; + color: inherit; + text-decoration: none; } +a.bbk-cart-item-title:hover { text-decoration: underline; } + .bbk-cart-item-variant { margin: 0 0 0.25rem; font-size: 0.8125rem; diff --git a/resources/js/stimulus/index.js b/resources/js/stimulus/index.js index d9341e6..1883e16 100644 --- a/resources/js/stimulus/index.js +++ b/resources/js/stimulus/index.js @@ -15,6 +15,7 @@ import ProductFormController from './product-form-controller' import ProductGalleryController from './product-gallery-controller' import QuantityController from './quantity-controller' import RangeSliderController from './range-slider-controller' +import ReviewCountController from './review-count-controller' import StarRatingController from './star-rating-controller' import TabsController from './tabs-controller' @@ -31,6 +32,7 @@ export function registerControllers(application) { application.register('product-gallery', ProductGalleryController) application.register('quantity', QuantityController) application.register('range-slider', RangeSliderController) + application.register('review-count', ReviewCountController) application.register('star-rating', StarRatingController) application.register('tabs', TabsController) } diff --git a/resources/js/stimulus/review-count-controller.js b/resources/js/stimulus/review-count-controller.js new file mode 100644 index 0000000..5091091 --- /dev/null +++ b/resources/js/stimulus/review-count-controller.js @@ -0,0 +1,12 @@ +import { Controller } from '@hotwired/stimulus' + +// The review count sits next to the star rating, outside the tabs markup — +// too far apart in the DOM for a plain data-action, hence the outlet. +export default class extends Controller { + static outlets = ['tabs'] + + activate() { + this.tabsOutlet.activate('reviews') + this.tabsOutlet.element.scrollIntoView({ block: 'start', behavior: 'smooth' }) + } +} diff --git a/resources/js/stimulus/star-rating-controller.js b/resources/js/stimulus/star-rating-controller.js index e7ceb14..bdea748 100644 --- a/resources/js/stimulus/star-rating-controller.js +++ b/resources/js/stimulus/star-rating-controller.js @@ -1,9 +1,25 @@ import { Controller } from '@hotwired/stimulus' export default class extends Controller { - static targets = ['star', 'input'] + static targets = ['star', 'input', 'error'] static values = { rating: { type: Number, default: 0 } } + connect() { + this.#fill(this.ratingValue) + } + + // Bound to the form's submit event (this controller sits on the + // itself, not just the star widget) — a plain `required` on the hidden + // rating input would never surface: browsers exclude type="hidden" from + // constraint validation entirely, so there'd be nothing to see or hear. + validate(event) { + if (this.ratingValue < 1) { + event.preventDefault() + this.errorTarget.hidden = false + this.starTargets[0]?.focus() + } + } + hover(event) { this.#fill(parseInt(event.currentTarget.dataset.value)) } @@ -16,6 +32,7 @@ export default class extends Controller { const val = parseInt(event.currentTarget.dataset.value) this.ratingValue = val this.inputTarget.value = val + this.errorTarget.hidden = true this.starTargets.forEach(star => { star.setAttribute('aria-pressed', String(parseInt(star.dataset.value) === val)) diff --git a/resources/js/stimulus/tabs-controller.js b/resources/js/stimulus/tabs-controller.js index 019ae22..2907c6b 100644 --- a/resources/js/stimulus/tabs-controller.js +++ b/resources/js/stimulus/tabs-controller.js @@ -4,10 +4,10 @@ export default class extends Controller { static targets = ['button', 'panel'] show(event) { - this.#activate(event.currentTarget.dataset.panel) + this.activate(event.currentTarget.dataset.panel) } - #activate(panelId) { + activate(panelId) { this.buttonTargets.forEach(btn => { const active = btn.dataset.panel === panelId btn.classList.toggle('is-active', active) diff --git a/resources/views/checkout/drawer.blade.php b/resources/views/checkout/drawer.blade.php index 0da4bb2..24a98ed 100644 --- a/resources/views/checkout/drawer.blade.php +++ b/resources/views/checkout/drawer.blade.php @@ -5,7 +5,7 @@ .bbk-* classes from its own stylesheet. No host components, no Tailwind. --}} - @@ -211,6 +218,10 @@ class="flex items-stretch gap-10" + @if(session('reviewSubmitted')) +

{{ __('storefront.review.thank_you') }}

+ @endif + @if(!empty($product['reviews']['items']))
@foreach($product['reviews']['items'] as $review) @@ -220,6 +231,8 @@ class="flex items-stretch gap-10" 'date' => $review['reviewed_at'] ? \Illuminate\Support\Carbon::createFromTimestamp($review['reviewed_at'])->translatedFormat('d M Y') : '', 'text' => $review['body'], 'image' => $review['media'][0]['url'] ?? null, + 'reply' => $review['reply'] ?? null, + 'replyDate' => $review['replied_at'] ? \Illuminate\Support\Carbon::createFromTimestamp($review['replied_at'])->translatedFormat('d M Y') : null, ]" /> @endforeach
diff --git a/resources/views/vendor/core/auth/mail/otp.blade.php b/resources/views/vendor/core/auth/mail/otp.blade.php new file mode 100644 index 0000000..411a06a --- /dev/null +++ b/resources/views/vendor/core/auth/mail/otp.blade.php @@ -0,0 +1,31 @@ +@extends('emails.layout') + +@section('title', 'Ο κωδικός σύνδεσής σου') + +@section('preheader', "Ο κωδικός σύνδεσής σου στο 3dealer: {$code}") + +@section('content') +

+ Ο κωδικός σύνδεσής σου +

+ +

Γεια σου {{ $name }},

+ +

Χρησιμοποίησε τον παρακάτω κωδικό για να συνδεθείς στο διαχειριστικό του 3dealer.

+ + + + + +
+ {{ $code }} +
+ +

Ο κωδικός ισχύει για περιορισμένο χρονικό διάστημα και μπορεί να χρησιμοποιηθεί μία μόνο φορά.

+ +

Αν δεν ζήτησες εσύ αυτόν τον κωδικό, αγνόησε αυτό το email — ο λογαριασμός σου παραμένει ασφαλής.

+@endsection + +@section('footer') +

Αυτό το email στάλθηκε επειδή ζητήθηκε σύνδεση στο διαχειριστικό του 3dealer.

+@endsection diff --git a/routes/web.php b/routes/web.php index 3509120..908da3d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -33,6 +33,10 @@ 'product.stock-check', ); + Route::post('/products/{product}/reviews', [ProductController::class, 'storeReview'])->name( + 'product.reviews.store', + ); + Route::get('/category/{id}', [CategoryController::class, 'show'])->name( 'category.show', ); From e3a6267059415769139f2d24a4bdf2b66e53552a Mon Sep 17 00:00:00 2001 From: elvira Date: Tue, 22 Sep 2026 19:18:55 +0300 Subject: [PATCH 05/22] fix in product page links and tabs, fixes in email template, minor change in reviews theming --- resources/js/stimulus/index.js | 4 ++-- resources/js/stimulus/review-count-controller.js | 12 ------------ resources/js/stimulus/tab-link-controller.js | 15 +++++++++++++++ .../views/components/reviews-stars.blade.php | 2 +- resources/views/emails/layout.blade.php | 2 +- resources/views/product/show.blade.php | 14 +++++++++++--- .../views/vendor/core/auth/mail/otp.blade.php | 4 ++-- 7 files changed, 32 insertions(+), 21 deletions(-) delete mode 100644 resources/js/stimulus/review-count-controller.js create mode 100644 resources/js/stimulus/tab-link-controller.js diff --git a/resources/js/stimulus/index.js b/resources/js/stimulus/index.js index 1883e16..f9ed941 100644 --- a/resources/js/stimulus/index.js +++ b/resources/js/stimulus/index.js @@ -15,8 +15,8 @@ import ProductFormController from './product-form-controller' import ProductGalleryController from './product-gallery-controller' import QuantityController from './quantity-controller' import RangeSliderController from './range-slider-controller' -import ReviewCountController from './review-count-controller' import StarRatingController from './star-rating-controller' +import TabLinkController from './tab-link-controller' import TabsController from './tabs-controller' export function registerControllers(application) { @@ -32,7 +32,7 @@ export function registerControllers(application) { application.register('product-gallery', ProductGalleryController) application.register('quantity', QuantityController) application.register('range-slider', RangeSliderController) - application.register('review-count', ReviewCountController) application.register('star-rating', StarRatingController) + application.register('tab-link', TabLinkController) application.register('tabs', TabsController) } diff --git a/resources/js/stimulus/review-count-controller.js b/resources/js/stimulus/review-count-controller.js deleted file mode 100644 index 5091091..0000000 --- a/resources/js/stimulus/review-count-controller.js +++ /dev/null @@ -1,12 +0,0 @@ -import { Controller } from '@hotwired/stimulus' - -// The review count sits next to the star rating, outside the tabs markup — -// too far apart in the DOM for a plain data-action, hence the outlet. -export default class extends Controller { - static outlets = ['tabs'] - - activate() { - this.tabsOutlet.activate('reviews') - this.tabsOutlet.element.scrollIntoView({ block: 'start', behavior: 'smooth' }) - } -} diff --git a/resources/js/stimulus/tab-link-controller.js b/resources/js/stimulus/tab-link-controller.js new file mode 100644 index 0000000..9bfe268 --- /dev/null +++ b/resources/js/stimulus/tab-link-controller.js @@ -0,0 +1,15 @@ +import { Controller } from '@hotwired/stimulus' + +// Jumps to a tab panel from an element outside the tabs' own markup — the +// review count and the "read more" link both sit elsewhere in the DOM, too +// far apart from the tabs for a plain data-action, hence the outlet. +export default class extends Controller { + static outlets = ['tabs'] + static values = { panel: String } + + activate(event) { + event?.preventDefault() + this.tabsOutlet.activate(this.panelValue) + this.tabsOutlet.element.scrollIntoView({ block: 'start', behavior: 'smooth' }) + } +} diff --git a/resources/views/components/reviews-stars.blade.php b/resources/views/components/reviews-stars.blade.php index ca29a13..d7fcbbd 100644 --- a/resources/views/components/reviews-stars.blade.php +++ b/resources/views/components/reviews-stars.blade.php @@ -33,7 +33,7 @@ class="flex items-center gap-1.5 text-brand" @if ($linkable) diff --git a/resources/views/emails/layout.blade.php b/resources/views/emails/layout.blade.php index 3994053..2e10913 100644 --- a/resources/views/emails/layout.blade.php +++ b/resources/views/emails/layout.blade.php @@ -70,7 +70,7 @@ - +
-
+