Feature: Creating Cart Service, Cart Events, Save For Later functionality, Adding Filament Views for viewing abandoned carts
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Lunar\Models\Cart;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
use Modules\Core\Recovery\Events\CartAbandoned;
|
||||
use Modules\Core\Recovery\Events\CheckoutAbandoned;
|
||||
|
||||
/**
|
||||
* "Abandoned" is a derived state (Cart::updated_at older than
|
||||
* config('core.cart.abandoned_after')) — nothing transitions a cart into it
|
||||
* via a normal Eloquent write, so there's no model-event hook to dispatch
|
||||
* CartAbandoned/CheckoutAbandoned from directly. This command is the only
|
||||
* place that moment gets detected; run it on a schedule (see docs/cart.md).
|
||||
*
|
||||
* Splits Cart::scopeActive()'s two branches into their own events —
|
||||
* see CartAbandoned/CheckoutAbandoned's docblocks for why they're distinct,
|
||||
* not one combined "abandoned" state: a cart with no order at all is a much
|
||||
* weaker purchase-intent signal than one with a draft order that was never
|
||||
* placed.
|
||||
*
|
||||
* Deliberately does NOT write anything to Cart/Order — dispatch only. An
|
||||
* earlier version recorded an "already notified" marker on Cart::meta/
|
||||
* Order::meta, but that write bumped updated_at as an Eloquent side effect,
|
||||
* which un-staled the very cart being marked abandoned (the same field
|
||||
* abandonment staleness is computed from) — see docs/cart.md's former
|
||||
* "Known bug" note. Cart/Checkout must have no way of writing abandonment
|
||||
* state at all; every cart still matching the query below refires its event
|
||||
* on every run until Recovery (not yet built — see
|
||||
* docs/recovery-strategies.md) owns its own dedup/tracking table.
|
||||
*/
|
||||
class DetectAbandonedCarts extends Command
|
||||
{
|
||||
protected $signature = 'boboko:cart:detect-abandoned';
|
||||
|
||||
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$cutoff = CartResource::abandonedCutoff();
|
||||
|
||||
$cartsAbandoned = 0;
|
||||
$checkoutsAbandoned = 0;
|
||||
|
||||
Cart::query()
|
||||
->whereDoesntHave('orders')
|
||||
->where('updated_at', '<=', $cutoff)
|
||||
->with('lines')
|
||||
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
|
||||
foreach ($carts as $cart) {
|
||||
if ($cart->lines->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Event::dispatch(new CartAbandoned($cart));
|
||||
|
||||
$cartsAbandoned++;
|
||||
}
|
||||
});
|
||||
|
||||
Cart::query()
|
||||
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
|
||||
->where('updated_at', '<=', $cutoff)
|
||||
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
|
||||
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
|
||||
foreach ($carts as $cart) {
|
||||
$order = $cart->orders->first();
|
||||
|
||||
if ($order === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Event::dispatch(new CheckoutAbandoned($cart, $order));
|
||||
|
||||
$checkoutsAbandoned++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->components->info("Dispatched CartAbandoned for {$cartsAbandoned} cart(s), CheckoutAbandoned for {$checkoutsAbandoned} checkout(s).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
class CartCleared
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}> $lines
|
||||
* Snapshot of every line that was in the cart before clearing — Cart::clear()
|
||||
* deletes all rows directly, so nothing here can be fresh CartLine instances.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly array $lines,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
class CartCouponApplied
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly string $code,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
class CartCouponRemoved
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly string $code,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
|
||||
class CartLineAdded
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly CartLine $line,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
|
||||
/**
|
||||
* The reverse of CartLineSaved — a previously saved-for-later line moved back
|
||||
* into the purchasable cart (now counted in totals again).
|
||||
*/
|
||||
class CartLineMovedToCart
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly CartLine $line,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
class CartLineRemoved
|
||||
{
|
||||
/**
|
||||
* @param array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array} $line
|
||||
* Snapshot of the removed line — the row is already deleted by the time this
|
||||
* event dispatches, so nothing here can be a fresh CartLine model instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly array $line,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
|
||||
/**
|
||||
* A line was moved OUT of the purchasable cart and into "saved for later" —
|
||||
* not a removal (the row still exists), but distinct from CartLineUpdated
|
||||
* since it's a state transition worth its own hook (e.g. abandoned-cart
|
||||
* recovery treating a saved line very differently from a deleted one).
|
||||
*/
|
||||
class CartLineSaved
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly CartLine $line,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
|
||||
class CartLineUpdated
|
||||
{
|
||||
/**
|
||||
* @param array{quantity: int, meta: array} $old Snapshot before the update.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly CartLine $line,
|
||||
public readonly array $old,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown by CartService::applyCoupon() when the given code doesn't match any
|
||||
* currently-active, non-exhausted Discount — Lunar's own
|
||||
* Discounts::validateCoupon() only returns a bool, it has no matching
|
||||
* exception type of its own to reuse here.
|
||||
*/
|
||||
class InvalidCouponException extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly string $code)
|
||||
{
|
||||
parent::__construct("The coupon code \"{$code}\" is not valid.");
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,13 @@ class CartResource extends Resource
|
||||
}
|
||||
|
||||
/**
|
||||
* Count only, not a fetch — no rows are loaded. Scoped to genuinely abandoned
|
||||
* carts specifically (mirrors ListCarts::getTabs()'s "Abandoned" query, not
|
||||
* "Ongoing"), since that's the number a staff member glancing at the sidebar
|
||||
* actually wants: how many carts might need following up on, not the total
|
||||
* including ones someone is actively shopping in right now.
|
||||
* Count only, not a fetch — no rows are loaded. Combines BOTH abandoned
|
||||
* states (`active()` already covers "no order at all" and "draft order,
|
||||
* never placed" together — see ListCarts::getTabs()'s "Abandoned Cart" /
|
||||
* "Abandoned Checkout" tabs for where they're split apart), not "Ongoing"
|
||||
* — the badge is meant to answer "how many carts might need following up
|
||||
* on," not the total including ones someone is actively shopping in right
|
||||
* now.
|
||||
*/
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
|
||||
@@ -17,19 +17,34 @@ class ListCarts extends ListRecords
|
||||
* "Completed" instead means the cart has an order with `placed_at` set (a
|
||||
* placed, not just drafted, order).
|
||||
*
|
||||
* "Ongoing" vs "Abandoned" both start from `Cart::scopeActive()` (not yet
|
||||
* converted to an order) and split on `updated_at` against
|
||||
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness signal
|
||||
* of its own, so recent activity is the only thing distinguishing a cart
|
||||
* someone is shopping in right now from one genuinely left behind.
|
||||
* `Cart::scopeActive()` (not yet converted to an order) actually mixes two
|
||||
* distinct states: no order started at all, vs. a draft order exists
|
||||
* (`placed_at IS NULL`) but was never placed — checkout was started, not
|
||||
* finished. That's a real difference in purchase intent (a cart with a
|
||||
* draft order is a much stronger signal than one with no order at all) and
|
||||
* in reachability (checkout usually captures an email even for a guest),
|
||||
* so they get separate tabs rather than one combined "no order yet"
|
||||
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
|
||||
* Modules\Core\Recovery\Events\CheckoutAbandoned draw.
|
||||
*
|
||||
* "Ongoing" vs the two abandoned tabs all split on `updated_at` against
|
||||
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness
|
||||
* signal of its own, so recent activity is the only thing distinguishing a
|
||||
* cart someone is shopping in right now from one genuinely left behind.
|
||||
*/
|
||||
public function getTabs(): array
|
||||
{
|
||||
return [
|
||||
'ongoing' => Tab::make('Ongoing')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())),
|
||||
'abandoned' => Tab::make('Abandoned')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->active()->where('updated_at', '<=', CartResource::abandonedCutoff())),
|
||||
'abandoned_cart' => Tab::make('Abandoned Cart')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query
|
||||
->whereDoesntHave('orders')
|
||||
->where('updated_at', '<=', CartResource::abandonedCutoff())),
|
||||
'abandoned_checkout' => Tab::make('Abandoned Checkout')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query
|
||||
->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at'))
|
||||
->where('updated_at', '<=', CartResource::abandonedCutoff())),
|
||||
'completed' => Tab::make('Completed')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->whereHas(
|
||||
'orders',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Pipelines;
|
||||
|
||||
use Closure;
|
||||
use Lunar\DataTypes\Price;
|
||||
use Lunar\Models\Contracts\CartLine as CartLineContract;
|
||||
|
||||
/**
|
||||
* Runs in config('lunar.cart.pipelines.cart_lines'), after GetUnitPrice —
|
||||
* zeroes out unitPrice/unitPriceInclTax for any line flagged
|
||||
* meta.saved_for_later, BEFORE Lunar's own CalculateLines pipeline step reads
|
||||
* unitPrice to compute subTotal/total. A saved-for-later item is deliberately
|
||||
* parked, not pending purchase, so it shouldn't inflate Cart::total — and
|
||||
* since CalculateLines sums every CartLine unconditionally with no meta-based
|
||||
* exclusion of its own, zeroing the price here (rather than patching subTotal
|
||||
* after the fact) is what makes every downstream total naturally correct
|
||||
* without a second pass.
|
||||
*/
|
||||
class ZeroSavedForLaterPrice
|
||||
{
|
||||
public function handle(CartLineContract $cartLine, Closure $next): mixed
|
||||
{
|
||||
if ($cartLine->meta['saved_for_later'] ?? false) {
|
||||
$currency = $cartLine->cart->currency;
|
||||
|
||||
$cartLine->unitPrice = new Price(0, $currency, 1);
|
||||
$cartLine->unitPriceInclTax = new Price(0, $currency, 1);
|
||||
}
|
||||
|
||||
return $next($cartLine);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Lunar\Actions\Carts\GetExistingCartLine;
|
||||
use Lunar\Base\Purchasable;
|
||||
use Lunar\Facades\CartSession;
|
||||
use Lunar\Facades\Discounts;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
use Modules\Core\Cart\Events\CartCleared;
|
||||
use Modules\Core\Cart\Events\CartCouponApplied;
|
||||
use Modules\Core\Cart\Events\CartCouponRemoved;
|
||||
use Modules\Core\Cart\Events\CartLineAdded;
|
||||
use Modules\Core\Cart\Events\CartLineMovedToCart;
|
||||
use Modules\Core\Cart\Events\CartLineRemoved;
|
||||
use Modules\Core\Cart\Events\CartLineSaved;
|
||||
use Modules\Core\Cart\Events\CartLineUpdated;
|
||||
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
||||
|
||||
/**
|
||||
* Storefront-facing cart operations, mirroring Modules\Core\Catalog\Services\
|
||||
* ProductService/CollectionService's shape — one boboko-owned API a storefront
|
||||
* calls, so Lunar's own CartSession/Cart stay an implementation detail rather
|
||||
* than something a consuming app depends on directly.
|
||||
*
|
||||
* Every mutating method dispatches a matching domain event
|
||||
* (Modules\Core\Cart\Events\*) after the underlying Lunar operation completes —
|
||||
* Lunar itself dispatches zero cart events (see docs/lunar.md's Cart gotchas),
|
||||
* so without this, nothing in a consuming app has anything to react to when a
|
||||
* cart actually changes (reindexing, notifications, analytics, etc.).
|
||||
*
|
||||
* All mutating methods return the recalculated Cart — matching Lunar's own
|
||||
* Cart::add()/updateLine()/etc., which already return $this after
|
||||
* refresh()->recalculate() — so a caller gets fresh totals in the same call,
|
||||
* no second fetch needed.
|
||||
*/
|
||||
class CartService
|
||||
{
|
||||
/**
|
||||
* The current session's cart, or null if none exists yet. Does NOT
|
||||
* auto-create one — see currentOrCreate() for that.
|
||||
*/
|
||||
public function current(): ?Cart
|
||||
{
|
||||
return CartSession::current();
|
||||
}
|
||||
|
||||
/**
|
||||
* The current session's cart, creating one if none exists yet — the right
|
||||
* call for "add to cart" style flows where a cart must exist by the time
|
||||
* the method returns.
|
||||
*/
|
||||
public function currentOrCreate(): Cart
|
||||
{
|
||||
return CartSession::manager();
|
||||
}
|
||||
|
||||
public function addLine(Purchasable $purchasable, int $quantity = 1, array $meta = []): Cart
|
||||
{
|
||||
$cart = $this->currentOrCreate()->add($purchasable, $quantity, $meta);
|
||||
|
||||
$line = app(config('lunar.cart.actions.get_existing_cart_line', GetExistingCartLine::class))
|
||||
->execute($cart, $purchasable, $meta);
|
||||
|
||||
if ($line !== null) {
|
||||
Event::dispatch(new CartLineAdded($cart, $line));
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function updateLine(int $cartLineId, int $quantity, ?array $meta = null): Cart
|
||||
{
|
||||
$before = CartLine::findOrFail($cartLineId);
|
||||
$old = ['quantity' => $before->quantity, 'meta' => $before->meta->toArray()];
|
||||
|
||||
$cart = $this->currentOrCreate()->updateLine($cartLineId, $quantity, $meta);
|
||||
|
||||
$line = $cart->lines->firstWhere('id', $cartLineId);
|
||||
|
||||
if ($line !== null) {
|
||||
Event::dispatch(new CartLineUpdated($cart, $line, $old));
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function removeLine(int $cartLineId): Cart
|
||||
{
|
||||
$line = CartLine::findOrFail($cartLineId);
|
||||
$snapshot = $this->snapshotLine($line);
|
||||
|
||||
$cart = $this->currentOrCreate()->remove($cartLineId);
|
||||
|
||||
Event::dispatch(new CartLineRemoved($cart, $snapshot));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function clear(): Cart
|
||||
{
|
||||
$cart = $this->currentOrCreate();
|
||||
$snapshots = $cart->lines->map($this->snapshotLine(...))->all();
|
||||
|
||||
$cart = $cart->clear();
|
||||
|
||||
Event::dispatch(new CartCleared($cart, $snapshots));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cart's coupon code, which the ApplyDiscounts pipeline step picks
|
||||
* up on the next calculate() — there's no dedicated Lunar action for this
|
||||
* (unlike add/update/remove, coupon_code is a plain cast attribute), so
|
||||
* this is the closest thing to one for a consuming app to call.
|
||||
*
|
||||
* Validated via Discounts::validateCoupon() (does a matching, currently
|
||||
* active, non-exhausted Discount exist?) before it's set — CouponString's
|
||||
* cast only normalizes casing, it doesn't validate anything, so setting
|
||||
* coupon_code directly would silently accept a bogus code and just not
|
||||
* discount anything once calculated.
|
||||
*
|
||||
* @throws InvalidCouponException if the code doesn't match a valid, active,
|
||||
* non-exhausted Discount
|
||||
*/
|
||||
public function applyCoupon(string $code): Cart
|
||||
{
|
||||
if (! Discounts::validateCoupon($code)) {
|
||||
throw new InvalidCouponException($code);
|
||||
}
|
||||
|
||||
$cart = $this->currentOrCreate();
|
||||
$cart->coupon_code = $code;
|
||||
$cart->save();
|
||||
$cart = $cart->recalculate();
|
||||
|
||||
Event::dispatch(new CartCouponApplied($cart, $cart->coupon_code));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function removeCoupon(): Cart
|
||||
{
|
||||
$cart = $this->currentOrCreate();
|
||||
$code = $cart->coupon_code;
|
||||
|
||||
if ($code === null) {
|
||||
return $cart;
|
||||
}
|
||||
|
||||
$cart->coupon_code = null;
|
||||
$cart->save();
|
||||
$cart = $cart->recalculate();
|
||||
|
||||
Event::dispatch(new CartCouponRemoved($cart, $code));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines currently counted toward the cart's totals — everything except
|
||||
* ones flagged meta.saved_for_later (see savedLines()). This is the set a
|
||||
* cart page's main list / checkout would iterate, since a saved line
|
||||
* isn't pending purchase.
|
||||
*
|
||||
* @return Collection<int, CartLine>
|
||||
*/
|
||||
public function activeLines(?Cart $cart = null): Collection
|
||||
{
|
||||
$cart ??= $this->currentOrCreate();
|
||||
|
||||
return $cart->lines->reject(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines a shopper has deliberately parked rather than deleted — excluded
|
||||
* from Cart totals (see Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice)
|
||||
* and from activeLines(). A cart page's "Saved for later" section iterates
|
||||
* this set.
|
||||
*
|
||||
* @return Collection<int, CartLine>
|
||||
*/
|
||||
public function savedLines(?Cart $cart = null): Collection
|
||||
{
|
||||
$cart ??= $this->currentOrCreate();
|
||||
|
||||
return $cart->lines->filter(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a line OUT of the purchasable cart without deleting it — it stays
|
||||
* on the cart (still visible, still re-addable) but is excluded from
|
||||
* totals via meta.saved_for_later, zeroed by ZeroSavedForLaterPrice before
|
||||
* Lunar's own CalculateLines sums the cart (which has no meta-based
|
||||
* exclusion of its own).
|
||||
*/
|
||||
public function saveForLater(int $cartLineId): Cart
|
||||
{
|
||||
$line = CartLine::findOrFail($cartLineId);
|
||||
$meta = [...$line->meta->toArray(), 'saved_for_later' => true];
|
||||
|
||||
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
|
||||
|
||||
$line = $cart->lines->firstWhere('id', $cartLineId);
|
||||
|
||||
if ($line !== null) {
|
||||
Event::dispatch(new CartLineSaved($cart, $line));
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reverse of saveForLater() — moves a line back into the purchasable
|
||||
* cart, counted in totals again.
|
||||
*/
|
||||
public function moveToCart(int $cartLineId): Cart
|
||||
{
|
||||
$line = CartLine::findOrFail($cartLineId);
|
||||
$meta = [...$line->meta->toArray(), 'saved_for_later' => false];
|
||||
|
||||
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
|
||||
|
||||
$line = $cart->lines->firstWhere('id', $cartLineId);
|
||||
|
||||
if ($line !== null) {
|
||||
Event::dispatch(new CartLineMovedToCart($cart, $line));
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}
|
||||
*/
|
||||
private function snapshotLine(CartLine $line): array
|
||||
{
|
||||
return [
|
||||
'id' => $line->id,
|
||||
'purchasable_type' => $line->purchasable_type,
|
||||
'purchasable_id' => $line->purchasable_id,
|
||||
'quantity' => $line->quantity,
|
||||
'meta' => $line->meta->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user