Chore: Extracting Wishlist from 3dealer to Core

This commit is contained in:
2026-09-28 09:52:53 +03:00
parent e7c896e168
commit 492447be51
10 changed files with 334 additions and 0 deletions
+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');