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

This commit is contained in:
elvira
2026-09-17 21:52:39 +03:00
parent fe55cb5f33
commit afa1993c53
27 changed files with 564 additions and 155 deletions
@@ -3,8 +3,11 @@
namespace App\Http\Controllers\Checkout;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Models\CartLine;
use Lunar\Models\ProductVariant;
use Modules\Core\Cart\Exceptions\InvalidCouponException;
use Modules\Core\Cart\Services\CartService;
@@ -22,7 +25,18 @@ public function __construct(
private readonly CartService $cart,
) {}
public function add(string $locale, Request $request): View
/**
* CartException here is Lunar's own add_to_cart validation pipeline
* (CartLineQuantity/CartLineStock) rejecting the line — most commonly
* "not enough stock at this quantity" for a tracked (purchasable =
* in_stock) variant. Its own message is an untranslated, hardcoded
* English string not meant for storefront display, so this returns our
* own translated one instead rather than passing it through — a
* storefront.* key rather than checkout.*, since this is a catalog/stock
* concern the storefront owns, not something specific to the portable
* checkout module.
*/
public function add(string $locale, Request $request): View|JsonResponse
{
$data = $request->validate([
'purchasable_id' => ['required', 'integer'],
@@ -31,24 +45,49 @@ public function add(string $locale, Request $request): View
$variant = ProductVariant::findOrFail($data['purchasable_id']);
$this->cart->addLine($variant, $data['quantity'] ?? 1);
try {
$this->cart->addLine($variant, $data['quantity'] ?? 1);
} catch (CartException) {
return $this->stockError($variant);
}
return view('checkout::partials.cart-body');
}
public function updateLine(string $locale, Request $request, int $line): View
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
{
$quantity = (int) $request->validate([
'quantity' => ['required', 'integer', 'min:0'],
])['quantity'];
$quantity === 0
? $this->cart->removeLine($line)
: $this->cart->updateLine($line, $quantity);
try {
$quantity === 0
? $this->cart->removeLine($line)
: $this->cart->updateLine($line, $quantity);
} catch (CartException) {
$variant = CartLine::find($line)?->purchasable;
return $this->stockError($variant instanceof ProductVariant ? $variant : null);
}
return view('checkout::partials.cart-body');
}
/**
* getTotalInventory() is the same number canBeFulfilledAtQuantity()
* checked against (stock, for a tracked in_stock variant) — telling the
* shopper how many are actually left beats a generic "not enough stock"
* they'd otherwise have to guess around by trial and error.
*/
private function stockError(?ProductVariant $variant): JsonResponse
{
$available = $variant?->getTotalInventory() ?? 0;
return response()->json([
'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]),
], 422);
}
public function remove(string $locale, int $line): View
{
$this->cart->removeLine($line);
@@ -23,6 +23,7 @@
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Checkout\Services\CheckoutService;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* The checkout page — one page, sections (contact / billing / shipping /
@@ -275,6 +276,30 @@ public function placeOrder(string $locale, Request $request): JsonResponse
$cart = $this->cart->current();
// Captured now, before initiatePayment() can place the order — Lunar's
// CartSessionManager::fetchOrCreate() silently swaps the session onto a
// BRAND NEW empty cart the moment the current one hasCompletedOrders()
// (i.e. has an order with placed_at set), which happens synchronously
// for an immediately-captured payment. Any later $this->cart->current()
// call in this same flow (here, or in a subsequent orderStatus() poll
// once the 3-D Secure webhook sets placed_at) would then resolve to
// that fresh, order-less cart instead of the one that was just placed.
// Storing the real cart id ourselves, under our own session key,
// sidesteps CartSession entirely for the rest of the placement flow.
session(['checkout.cart_id' => $cart?->id]);
// Lunar's own ValidateCartForOrderCreation (order_create validator)
// never checks for this — an empty cart with a valid billing address
// sails straight through it and would place a real, zero-line order.
// The disabled "place order" button is only the client-side half of
// this fix; this is the half that actually matters.
if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) {
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.cart_empty'),
], 422);
}
// The one incomplete-cart case worth a specific message + pointing the
// shopper at the right section: a region resolving 2+ methods needs an
// explicit pick (no auto-select), easy to miss since nothing else on
@@ -312,13 +337,20 @@ public function placeOrder(string $locale, Request $request): JsonResponse
return response()->json(['error' => __('checkout.page.terms_required')], 422);
}
return match ($result->status) {
PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
PaymentResultStatus::Pending => response()->json([
// Pending with no continuation (cash-on-delivery, or any other
// deferred/offline method) means CheckoutService::initiatePayment()
// already created the placed order — money just hasn't changed
// hands yet. Only a Pending WITH a continuation (Stripe's client
// secret) means the shopper still has something to do before the
// order exists as far as the storefront is concerned.
return match (true) {
$result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
$result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale),
$result->status === PaymentResultStatus::Pending => response()->json([
'status' => 'pending',
'clientSecret' => $result->continuation?->value,
]),
PaymentResultStatus::Failed => response()->json([
default => response()->json([
'status' => 'failed',
'message' => $result->failureReason ?: __('checkout.page.payment_failed'),
'retriable' => $result->retriable,
@@ -350,14 +382,24 @@ public function confirmation(string $locale): View|RedirectResponse
$orderId = session('checkout.order_id');
$order = $orderId
? Order::with(['lines', 'shippingAddress', 'billingAddress'])->find($orderId)
? Order::with(['lines.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId)
: null;
if (! $order) {
return redirect()->route('products', $locale);
}
return view('checkout::confirmation', ['order' => $order]);
// Looked up by type rather than a stored relation — the method may since
// have been disabled/deleted, but the order still needs to show what was
// actually used at the time.
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
->first()
?->translate('name');
return view('checkout::confirmation', [
'order' => $order,
'paymentMethodName' => $paymentMethodName,
]);
}
private function orderPlacedResponse(string $locale): JsonResponse
@@ -373,8 +415,13 @@ private function orderPlacedResponse(string $locale): JsonResponse
private function placedOrder(): ?Order
{
return $this->cart->current()
?->orders()
$cartId = session('checkout.cart_id');
if ($cartId === null) {
return null;
}
return Order::where('cart_id', $cartId)
->whereNotNull('placed_at')
->latest('placed_at')
->first();
@@ -4,7 +4,10 @@
use App\Catalog\ProductListing;
use App\Catalog\ProductListingPage;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Lunar\Models\ProductVariant;
use Modules\Core\Catalog\Services\ProductService;
class ProductController extends Controller
@@ -42,11 +45,48 @@ public function show(string $locale, int $id)
$firstVariant = $product['variants'][0] ?? null;
$option = $firstVariant['options'][0]['option'] ?? null;
// The "Color" option type is the only one that writes a hex code into
// meta (see boboko/core's ColorOptionType) — its presence is how we
// tell a color option (swatches) from any other option (buttons).
$optionIsColor = collect($product['variants'])
->contains(fn (array $variant) => !empty($variant['options'][0]['meta']['hex'] ?? null));
return view('product.show', [
'collection' => $collection,
'product' => $product,
'option' => $option,
'optionIsColor' => $optionIsColor,
'variantsData' => $variantsData,
]);
}
/**
* A storefront-owned, checkout-module-independent stock check — the
* product page's "Add to cart" calls this first and only submits to the
* checkout module's own add-to-cart endpoint once this says `ok`. Reads
* the live Eloquent ProductVariant directly (not the Meilisearch index
* ProductService otherwise reads from, which can lag behind an actual
* sale until the next reindex) via the SAME method Lunar's own
* CartLineStock validator calls, so this can never disagree with what
* the module's own server-side check would decide.
*/
public function checkStock(string $locale, Request $request): JsonResponse
{
$data = $request->validate([
'variant' => ['required', 'integer'],
'quantity' => ['nullable', 'integer', 'min:1'],
]);
$variant = ProductVariant::find($data['variant']);
$quantity = $data['quantity'] ?? 1;
if ($variant === null) {
return response()->json(['ok' => true]);
}
return response()->json([
'ok' => $variant->canBeFulfilledAtQuantity($quantity),
'stock' => $variant->purchasable === 'always' ? null : $variant->getTotalInventory(),
]);
}
}