Compare commits

...
8 Commits
19 changed files with 499 additions and 51 deletions
+43
View File
@@ -4,6 +4,49 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.25.2] - 2026-09-28
### Fixed
- `BankTransferPaymentDriver::pay()` returned `Succeeded` and dispatched `PaymentCaptured`
immediately — treating a bank transfer like an instant-success gateway (Stripe), when in
reality no money has moved yet. Now returns `Pending` with no event dispatched, so the order
stays at `awaiting_payment` with `Order::paid` false, exactly like it should — checkout still
completes normally (`CheckoutController` already treats a `Pending` result with no
continuation as a placed order). `OrderStatusFlow::canMarkPaid()`/`isBankTransfer()` now also
recognize bank transfer, so staff can mark the order paid once the wire arrives, the same
"Mark Paid" action cash-on-delivery already uses — but unlike COD's version, this also
advances the order's status past `awaiting_payment`, since nothing else ever will.
## [0.25.1] - 2026-09-28
### Fixed
- `CustomerErasureActionsExtension` (Privacy) added "Request Erasure"/"Request Export" header
actions to the Customer edit/view pages but left Lunar's own plain `DeleteAction` in place
alongside them — bypassing the grace period, cascades, and audit trail an erasure request
provides. That header action is now stripped whenever Privacy is installed, so "Request
Erasure" is the only way to remove a Customer.
## [0.25.0] - 2026-09-28
### Added
- `Modules\Core\Wishlist\` — extracted the wishlist feature's business logic from 3dealer:
`WishlistService` (guest cookie / logged-in `wishlist_items` toggle, merge-on-login),
`WishlistItem` model, the `wishlist.toggle` route/controller, `MergeGuestWishlistOnLogin`
(listens on `Auth\Events\UserAuthenticated`, same pattern as `ClaimGuestOrdersOnLogin`), and
the `wishlist-controller.js` Stimulus controller (exported as `registerWishlist()` from this
package's JS entry point). Page rendering (the account/guest wishlist list views, and their
product-card presentation) stays app-specific, since it depends on each app's own UI
components. The `wishlist_items` migration checks `Schema::hasTable()` first, so a consumer
that already had its own copy of this table (e.g. 3dealer) isn't broken by this package now
also shipping it.
## [0.24.1] - 2026-09-28
### Fixed
- `CheckoutTranslationsSeeder` was missing five `checkout.page.*` lines actually referenced by
the checkout views — `logged_in_as`, `login_prompt`, `login_link`, `wants_invoice`, and
`confirmation_login_hint` — left blank on any storefront until seeded by hand.
## [0.24.0] - 2026-09-28
### Added
+3 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.24.0",
"version": "0.25.2",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -47,7 +47,8 @@
"Modules\\Core\\Providers\\FileServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider"
"Modules\\Core\\Providers\\PrivacyServiceProvider",
"Modules\\Core\\Providers\\WishlistServiceProvider"
]
}
},
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* One product on a logged-in user's wishlist. A guest's wishlist lives in a
* cookie instead (see Modules\Core\Wishlist\Services\Wishlist) — nothing is
* written here until Modules\Core\Wishlist\Listeners\MergeGuestWishlistOnLogin
* moves the cookie's ids across on login.
*
* hasTable() guard: this table previously lived in each consuming app's own
* migrations (e.g. 3dealer's create_wishlist_items_table, extracted here) —
* Laravel's migrations table tracks by filename, so a consumer that already
* ran its own copy would otherwise hit "table already exists" the first time
* this migration runs. Skips creation entirely if the table is already
* there; a fresh install with no prior wishlist table gets it created here.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('wishlist_items')) {
return;
}
Schema::create('wishlist_items', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained('lunar_products')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'product_id']);
});
}
public function down(): void
{
Schema::dropIfExists('wishlist_items');
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@boboko/core",
"version": "0.24.0",
"version": "0.25.2",
"private": true,
"type": "module",
"description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.",
+1
View File
@@ -7,3 +7,4 @@
// stoic_embed.js is not re-exported here: per its own docblock, it's a
// standalone vendored script meant to be included directly, not imported.
export { registerCheckout } from './checkout/index.js'
export { registerWishlist } from './wishlist/index.js'
+10
View File
@@ -0,0 +1,10 @@
import WishlistController from './wishlist-controller'
// Registers the wishlist module's Stimulus controller onto the host app's
// Stimulus application. Call once from the host's JS entry point:
//
// import { registerWishlist } from '@boboko/core'
// registerWishlist(application)
export function registerWishlist(application) {
application.register('wishlist', WishlistController)
}
@@ -0,0 +1,42 @@
import { Controller } from '@hotwired/stimulus'
// Heart toggle. Posts the form with fetch and reflects the server's answer on
// aria-pressed, which the consuming app's own CSS uses to swap the outline
// and filled heart. If the request fails, falls back to a normal form submit.
export default class extends Controller {
static targets = ['button', 'status']
static values = {
addLabel: String,
removeLabel: String,
addedMessage: String,
removedMessage: String,
}
async toggle(event) {
event.preventDefault()
if (this.busy) return
this.busy = true
try {
const response = await fetch(this.element.action, {
method: 'POST',
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: new FormData(this.element),
})
if (!response.ok) throw new Error(`Wishlist toggle failed: ${response.status}`)
const { active } = await response.json()
this.buttonTarget.setAttribute('aria-pressed', active ? 'true' : 'false')
this.buttonTarget.setAttribute('aria-label', active ? this.removeLabelValue : this.addLabelValue)
this.statusTarget.textContent = active ? this.addedMessageValue : this.removedMessageValue
} catch {
this.element.submit()
} finally {
this.busy = false
}
}
}
@@ -83,6 +83,12 @@ class CheckoutTranslationsSeeder extends Seeder
"Email me a reminder if I don't finish my order",
'Στείλε μου μια υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
],
'page.logged_in_as' => ['Logged in as', 'Συνδεδεμένος/η ως'],
'page.login_prompt' => [
'Already have an account?',
'Έχεις ήδη λογαριασμό;',
],
'page.login_link' => ['Log in', 'Σύνδεση'],
'page.login_email_label' => ['Email', 'Email'],
'page.send_code' => ['Send code', 'Αποστολή κωδικού'],
'page.login_coming_soon' => [
@@ -95,6 +101,7 @@ class CheckoutTranslationsSeeder extends Seeder
'page.first_name' => ['First name', 'Όνομα'],
'page.last_name' => ['Last name', 'Επώνυμο'],
'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'],
'page.wants_invoice' => ['I need an invoice', 'Θέλω τιμολόγιο'],
'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'],
'page.address_line_one' => ['Address', 'Διεύθυνση'],
'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'],
@@ -178,6 +185,10 @@ class CheckoutTranslationsSeeder extends Seeder
'Θα λάβεις email επιβεβαίωσης σύντομα.',
],
'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'],
'page.confirmation_login_hint' => [
'Want to track this order? Create an account or',
'Θέλεις να παρακολουθείς την παραγγελία σου; Δημιούργησε λογαριασμό ή',
],
'page.confirmation_billing' => ['Billing', 'Χρέωση'],
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
'page.box_now_locker_label' => [
+30 -17
View File
@@ -34,6 +34,7 @@ class OrderFulfillmentService
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
private readonly TransactionRecorder $transactions,
private readonly OrderPaymentResolutionService $resolution,
) {}
public function markReady(Order $order): OrderFulfillmentResult
@@ -114,9 +115,13 @@ class OrderFulfillmentService
}
/**
* Independent of `status` entirely — offered by the single "Update
* Status" action regardless of current status (see
* OrderStatusFlow::canMarkPaid()).
* For a COD order, independent of `status` entirely — offered by the
* single "Update Status" action regardless of current status (see
* OrderStatusFlow::canMarkPaid()). For a bank transfer order, status
* genuinely does advance here too (see below) — unlike COD, a bank
* transfer order has been sitting at 'awaiting_payment' since checkout
* (BankTransferPaymentDriver::pay() deliberately never advances it),
* and this click is the only thing that ever will.
*/
public function markPaid(Order $order): OrderFulfillmentResult
{
@@ -125,18 +130,20 @@ class OrderFulfillmentService
}
// canMarkPaid() only ever returns true for an order whose payment
// method resolves to the cash-on-delivery DRIVER (see
// OrderStatusFlow::isCod(), which checks PaymentMethod::driver,
// never the merchant-chosen `type` slug directly — a store could
// name that method "cod", "pay-on-delivery", anything). Such an
// order never runs through Payment's pay()/authorize() flow at
// checkout, so nothing else records a Transaction for it. Money
// changes hands right here, at this click, so this is the one
// place that write can happen; there is no earlier Payment event
// to hang it off of the way Modules\Core\Order\Listeners\
// RecordPaymentTransaction does for a gateway driver. See
// TransactionRecorder's own docblock — it already anticipated
// exactly this "manually-triggered ... from Filament" call site.
// method resolves to the cash-on-delivery or bank-transfer DRIVER
// (see OrderStatusFlow::isCod()/isBankTransfer(), which check
// PaymentMethod::driver, never the merchant-chosen `type` slug
// directly — a store could name that method "cod", "pay-on-delivery",
// "wire", anything). Neither ever runs a Transaction-recording event
// through to completion at checkout (COD dispatches nothing capture-
// shaped at all; bank transfer's pay() returns Pending with no event
// dispatched — see that driver's own docblock). Money changes hands
// right here, at this click, so this is the one place that write can
// happen; there is no earlier Payment event to hang it off of the way
// Modules\Core\Order\Listeners\RecordPaymentTransaction does for a
// gateway driver. See TransactionRecorder's own docblock — it already
// anticipated exactly this "manually-triggered ... from Filament"
// call site.
//
// $driver below is the payment method's own `type` slug (whatever
// the merchant named it, e.g. 'cash-on-delivery' or 'cod') —
@@ -146,19 +153,25 @@ class OrderFulfillmentService
// No fallback guess here: CheckoutService::initiatePayment() always
// writes Order.meta['payment_method'] before charging, and
// canMarkPaid() already guarantees this order got that far.
$type = (string) $order->meta['payment_method'];
$this->transactions->record(
$order,
type: 'capture',
driver: (string) $order->meta['payment_method'],
driver: $type,
result: new PaymentResult(
status: PaymentResultStatus::Succeeded,
reference: 'cod-manual-'.$order->id,
reference: "manual-{$type}-{$order->id}",
amount: $order->total,
),
);
$this->writer->markPaid($order, self::class.'::markPaid');
if ($this->flow->isBankTransfer($order)) {
$this->resolution->advancePastAwaitingPayment($order, self::class.'::markPaid');
}
return OrderFulfillmentResult::success('Order marked as paid.');
}
@@ -92,7 +92,14 @@ class OrderPaymentResolutionService
}
}
private function advancePastAwaitingPayment(Order $order, string $causeClass): void
/**
* Also called directly by OrderFulfillmentService::markPaid() for a
* bank transfer order — unlike a COD markPaid() (which never touches
* status, since nothing was ever awaited), a bank transfer order
* genuinely sat at 'awaiting_payment' until this moment, and nothing
* else will ever advance it if this doesn't.
*/
public function advancePastAwaitingPayment(Order $order, string $causeClass): void
{
if ($order->status !== 'awaiting_payment') {
return;
+25 -4
View File
@@ -54,6 +54,24 @@ class OrderStatusFlow
return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery';
}
/**
* Same meta-first/Transaction-fallback resolution as isCod(). Unlike COD
* — where nothing is ever awaited, since payment happens on delivery —
* a bank transfer order genuinely sits at 'awaiting_payment' until staff
* confirm the wire arrived (see BankTransferPaymentDriver's own
* docblock and OrderFulfillmentService::markPaid()).
*/
public function isBankTransfer(Order $order): bool
{
$type = $order->meta['payment_method'] ?? $order->transactions()->latest('id')->value('driver');
if ($type === null) {
return false;
}
return PaymentMethod::where('type', $type)->value('driver') === 'bank-transfer';
}
/**
* @return array<string, string> value => label — every status in the
* order's own branch (carrier or pickup), plus the refund options,
@@ -124,13 +142,16 @@ class OrderStatusFlow
/**
* Whether the "mark paid" option should be offered right now —
* entirely independent of $order->status. True whenever this is a
* cash-on-delivery order and payment hasn't been recorded yet,
* regardless of fulfillment progress (before OR after completed).
* entirely independent of $order->status for a COD order (true whenever
* payment hasn't been recorded yet, regardless of fulfillment progress,
* before OR after completed). A bank transfer order is also eligible,
* for the same "no earlier Payment event recorded this" reason (see
* OrderFulfillmentService::markPaid()), but unlike COD its own status
* genuinely does need advancing once marked paid — see that method.
*/
public function canMarkPaid(Order $order): bool
{
return ! $order->paid && $this->isCod($order);
return ! $order->paid && ($this->isCod($order) || $this->isBankTransfer($order));
}
/**
@@ -9,30 +9,47 @@ use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\Contracts\SupportsRefunds;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded;
/**
* Manual/attested, same trust model as OfflinePaymentDriver — there is no
* bank API to call, so both pay() and refund() decide success immediately
* on a staff member's say-so (they've already sent/received the wire
* outside the system). Distinct from OfflinePaymentDriver in intent: this
* exists so a payment taken through a DIFFERENT method (e.g.
* cash-on-delivery) can still be REFUNDED via bank transfer — an admin
* chooses this driver explicitly in the refund action, independent of
* which driver the original payment went through (see
* refund() is manual/attested, same trust model as OfflinePaymentDriver —
* there is no bank API to call, so it decides success immediately on a
* staff member's say-so (they've already sent the wire outside the
* system). Distinct from OfflinePaymentDriver in intent: this exists so a
* payment taken through a DIFFERENT method (e.g. cash-on-delivery) can
* still be REFUNDED via bank transfer — an admin chooses this driver
* explicitly in the refund action, independent of which driver the
* original payment went through (see
* Payment\Support\TransactionDriverAdapter::refundVia() and
* Order\Filament\Extensions\OrderActionsExtension). pay() exists so
* the same driver also covers receiving a payment by bank transfer, but
* the admin UI for that (bank reference, notes, proof-of-transfer upload)
* is deliberately not built yet — see the follow-up work tracked from this
* session; pay() itself is complete and usable via the registry today.
* Order\Filament\Extensions\OrderActionsExtension).
*
* pay() is the opposite trust direction from refund(): a bank transfer
* payment requires the money to arrive BEFORE the order can be
* considered paid (unlike cash-on-delivery, where payment happens on
* delivery — see CashOnDeliveryPaymentDriver's own docblock for that
* driver's mirror-image reasoning). So pay() returns Pending, dispatching
* no event at all — no PaymentCaptured (nothing has been paid yet), and
* deliberately NOT PaymentDeferred either (unlike COD, whose
* MarkOrderPlacedOnDeferredPayment listener immediately advances the
* order past 'awaiting_payment' since a COD order has nothing to await at
* checkout). A bank transfer order genuinely DOES have something to
* await: it stays at 'awaiting_payment' with Order::paid false until
* staff confirm the wire arrived via OrderFulfillmentService::markPaid(),
* which — unlike its COD path — also advances the order's status, since
* nothing else ever will (see that method's own docblock).
* CheckoutController::placeOrder() already treats a Pending result with
* no continuation as a fully placed order (see its own docblock), so the
* order is still created and visible to the shopper immediately; only its
* payment/status is what's left outstanding.
*
* $reference is generated here for the same reason as OfflinePaymentDriver's
* pay(): there is no gateway to hand one back. 'notes' in $context (not
* $data — refund() has no $data parameter) is folded into
* PaymentResult::$meta, which Order\Services\TransactionRecorder::record()
* already writes straight into Transaction.meta with no extra plumbing.
* pay(): there is no gateway to hand one back. refund()'s 'notes' (in
* $context — it has no $data parameter) is folded into PaymentResult::$meta,
* which Order\Services\TransactionRecorder::record() already writes straight
* into Transaction.meta with no extra plumbing; pay() has no equivalent
* write, since nothing ever records a Transaction from its own result (see
* above) — any notes a shopper enters at checkout would need surfacing some
* other way, e.g. when staff mark the order paid.
*/
class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRefunds
{
@@ -46,16 +63,11 @@ class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRe
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
$result = new PaymentResult(
status: PaymentResultStatus::Succeeded,
return new PaymentResult(
status: PaymentResultStatus::Pending,
reference: 'bank-transfer-'.Str::uuid(),
amount: $amount,
meta: array_filter(['notes' => $data['notes'] ?? null]),
);
PaymentCaptured::dispatch($type, $result, $context);
return $result;
}
public function refund(string $reference, Price $amount, array $context = []): PaymentResult
@@ -3,6 +3,7 @@
namespace Modules\Core\Privacy\Filament\Extensions;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Checkbox;
use Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\BaseExtension;
@@ -20,13 +21,18 @@ use Modules\Core\Privacy\Services\PrivacyService;
* docs/modules.md "Layering Module and App Configuration"), and this extension
* deliberately only implements headerActions(), so it never conflicts with an
* app's own extension for the same resource.
*
* Also strips Lunar's own plain DeleteAction from these pages — with Privacy
* installed, "Request Erasure" (grace period, cascades, audit trail via
* DataErasureRequest) is the only sanctioned way to remove a Customer; a
* direct delete would bypass all of that.
*/
class CustomerErasureActionsExtension extends BaseExtension
{
public function headerActions(array $actions): array
{
return [
...$actions,
...array_filter($actions, fn ($action) => ! $action instanceof DeleteAction),
Action::make('requestErasure')
->label('Request Erasure')
->icon('heroicon-o-shield-exclamation')
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Wishlist\Listeners\MergeGuestWishlistOnLogin;
use Modules\Core\Wishlist\Services\WishlistService;
class WishlistServiceProvider extends ServiceProvider
{
public function register(): void
{
// One instance per request: it caches the guest cookie's ids, so a
// toggle and a later has() in the same request agree.
$this->app->scoped(WishlistService::class);
}
public function boot(): void
{
$this->loadRoutesFrom(__DIR__.'/../Wishlist/routes/web.php');
Event::listen(UserAuthenticated::class, MergeGuestWishlistOnLogin::class);
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Core\Wishlist\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Lunar\Models\Product;
use Modules\Core\Wishlist\Services\WishlistService;
/**
* Adds or removes a product on the current shopper's wishlist — guests and
* logged-in shoppers alike (see WishlistService). Page rendering (the
* account/guest wishlist list views, with their product-card presentation)
* is app-specific and stays in the consuming app; this is only the toggle
* action a heart button or plain form posts to.
*/
class WishlistController extends Controller
{
public function __construct(
private readonly WishlistService $wishlist,
) {}
/**
* The heart button's Stimulus controller asks for JSON; without JS the
* form posts normally and comes back to the same page.
*/
public function toggle(Request $request, int $productId): JsonResponse|RedirectResponse
{
abort_unless(Product::whereKey($productId)->exists(), 404);
$active = $this->wishlist->toggle($productId);
if ($request->expectsJson()) {
return response()->json(['active' => $active]);
}
return back();
}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Wishlist\Listeners;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Wishlist\Services\WishlistService;
/**
* Registered from Providers\WishlistServiceProvider — UserAuthenticated fires
* inside the login request, so this can read the guest wishlist cookie and
* queue its removal.
*/
class MergeGuestWishlistOnLogin
{
public function __construct(
private readonly WishlistService $wishlist,
) {}
public function handle(UserAuthenticated $event): void
{
$this->wishlist->mergeGuestInto($event->user);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Wishlist\Models;
use Illuminate\Database\Eloquent\Model;
/**
* One product on a logged-in user's wishlist. Guests' wishlists live in a
* cookie instead — see Modules\Core\Wishlist\Services\Wishlist.
*/
class WishlistItem extends Model
{
protected $fillable = ['user_id', 'product_id'];
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace Modules\Core\Wishlist\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use Modules\Core\Wishlist\Models\WishlistItem;
/**
* The current shopper's wishlist, product ids only.
*
* Logged in: rows in wishlist_items. Guest: a 1-year cookie holding the ids
* (encrypted like every cookie, by the web group's EncryptCookies), so nothing
* is written to the database for anonymous visitors. On login the cookie is
* merged into the account and cleared (MergeGuestWishlistOnLogin).
*/
class WishlistService
{
public const COOKIE = 'wishlist';
private const COOKIE_MINUTES = 60 * 24 * 365;
// Keeps the cookie well under the 4KB browser limit.
private const GUEST_MAX = 100;
/** @var array<int>|null ids for this request, including a toggle just made */
private ?array $guestIds = null;
/** @return array<int> newest first */
public function ids(): array
{
if ($user = Auth::user()) {
return WishlistItem::where('user_id', $user->id)
->latest('id')
->pluck('product_id')
->all();
}
return $this->guestIds();
}
public function has(int $productId): bool
{
return in_array($productId, $this->ids(), true);
}
/**
* @return bool whether the product is on the wishlist afterwards
*/
public function toggle(int $productId): bool
{
if ($user = Auth::user()) {
$deleted = WishlistItem::where('user_id', $user->id)->where('product_id', $productId)->delete();
if ($deleted) {
return false;
}
WishlistItem::create(['user_id' => $user->id, 'product_id' => $productId]);
return true;
}
$ids = $this->guestIds();
if (in_array($productId, $ids, true)) {
$this->storeGuestIds(array_values(array_diff($ids, [$productId])));
return false;
}
$this->storeGuestIds(array_slice([$productId, ...$ids], 0, self::GUEST_MAX));
return true;
}
public function remove(int $productId): void
{
if ($this->has($productId)) {
$this->toggle($productId);
}
}
/**
* Moves the guest cookie's products onto $user's wishlist and clears it.
*/
public function mergeGuestInto(Authenticatable $user): void
{
$ids = $this->guestIds();
if ($ids === []) {
return;
}
// Oldest first, so the newest cookie item also ends up newest here.
foreach (array_reverse($ids) as $productId) {
WishlistItem::firstOrCreate(['user_id' => $user->id, 'product_id' => $productId]);
}
$this->guestIds = [];
Cookie::queue(Cookie::forget(self::COOKIE));
}
/** @return array<int> */
private function guestIds(): array
{
if ($this->guestIds !== null) {
return $this->guestIds;
}
$decoded = json_decode((string) request()->cookie(self::COOKIE), true);
return $this->guestIds = is_array($decoded)
? array_values(array_unique(array_filter(array_map('intval', $decoded))))
: [];
}
/** @param array<int> $ids */
private function storeGuestIds(array $ids): void
{
$this->guestIds = $ids;
Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES);
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Core\Wishlist\Http\Controllers\WishlistController;
Route::post('wishlist/{productId}', [WishlistController::class, 'toggle'])
->whereNumber('productId')
->middleware('throttle:60,1')
->name('wishlist.toggle');