Chore: Moving Wishlist from 3dealer to Core

This commit is contained in:
2026-09-28 10:10:50 +03:00
parent b1162761de
commit 36806cac5b
11 changed files with 16 additions and 272 deletions
+7 -23
View File
@@ -3,19 +3,21 @@
namespace App\Http\Controllers;
use App\Catalog\ProductCard;
use App\Services\Wishlist;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\View\View;
use Lunar\Models\Product;
use Modules\Core\Catalog\Services\ProductService;
use Modules\Core\Wishlist\Services\WishlistService;
/**
* Page rendering only — the toggle action itself lives in core
* (Modules\Core\Wishlist\Http\Controllers\WishlistController, route
* `wishlist.toggle`), since it needs no app-specific presentation.
*/
class WishlistController extends Controller
{
public function __construct(
private readonly Wishlist $wishlist,
private readonly WishlistService $wishlist,
) {}
/**
@@ -39,24 +41,6 @@ public function guest(string $locale, ProductService $products): View|RedirectRe
return view('wishlist.guest', ['products' => $this->products($products)]);
}
/**
* Adds or removes a product, for guests and logged-in shoppers alike. 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(string $locale, 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();
}
/**
* Product cards for the current wishlist, newest first. Products no longer
* in the search index (deleted, unpublished) are simply skipped.
@@ -1,23 +0,0 @@
<?php
namespace App\Listeners;
use App\Services\Wishlist;
use Modules\Core\Auth\Events\UserAuthenticated;
/**
* Picked up by Laravel's listener discovery (app/Listeners), no manual
* registration. Runs inside the login request, so it can read the guest
* wishlist cookie and queue its removal.
*/
class MergeGuestWishlistOnLogin
{
public function __construct(
private readonly Wishlist $wishlist,
) {}
public function handle(UserAuthenticated $event): void
{
$this->wishlist->mergeGuestInto($event->user);
}
}
-14
View File
@@ -1,14 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* One product on a logged-in user's wishlist. Guests' wishlists live in a
* cookie instead — see App\Services\Wishlist.
*/
class WishlistItem extends Model
{
protected $fillable = ['user_id', 'product_id'];
}
-7
View File
@@ -17,13 +17,6 @@
class AppServiceProvider 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(\App\Services\Wishlist::class);
}
public function boot(): void
{
Telemetry::optOut();
-126
View File
@@ -1,126 +0,0 @@
<?php
namespace App\Services;
use App\Models\WishlistItem;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
/**
* 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 Wishlist
{
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);
}
}
@@ -1,25 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
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');
}
};
+5 -4
View File
@@ -10,14 +10,15 @@ window.Turbo.session.drive = false;
import { Application } from "@hotwired/stimulus";
import { registerControllers } from "./stimulus/index";
import { registerCheckout } from "@boboko/core";
import { registerCheckout, registerWishlist } from "@boboko/core";
const application = Application.start();
application.debug = false;
registerControllers(application);
// Portable cart + checkout module, installed as a real npm dependency (see
// package.json). Owns its own bbk-* Stimulus controllers; this is the only
// wiring line it needs here.
// Portable cart + checkout + wishlist modules, installed as a real npm
// dependency (see package.json). Each owns its own Stimulus controllers;
// these are the only wiring lines they need here.
registerCheckout(application);
registerWishlist(application);
-2
View File
@@ -19,7 +19,6 @@ import RangeSliderController from './range-slider-controller'
import StarRatingController from './star-rating-controller'
import TabLinkController from './tab-link-controller'
import TabsController from './tabs-controller'
import WishlistController from './wishlist-controller'
export function registerControllers(application) {
application.register('appear', AppearController)
@@ -38,5 +37,4 @@ export function registerControllers(application) {
application.register('star-rating', StarRatingController)
application.register('tab-link', TabLinkController)
application.register('tabs', TabsController)
application.register('wishlist', WishlistController)
}
@@ -1,42 +0,0 @@
import { Controller } from '@hotwired/stimulus'
// Heart toggle (x-wishlist-button). Posts the form with fetch and reflects the
// server's answer on aria-pressed, which the 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
}
}
}
@@ -8,7 +8,7 @@
@props(['productId'])
@php
$active = app(\App\Services\Wishlist::class)->has((int) $productId);
$active = app(\Modules\Core\Wishlist\Services\WishlistService::class)->has((int) $productId);
$addLabel = __('storefront.wishlist.add');
$removeLabel = __('storefront.wishlist.remove');
@endphp
+3 -5
View File
@@ -98,12 +98,10 @@
->name('logout');
// Heart button: works for guests too (cookie), merged into the account
// on login — see App\Services\Wishlist.
// on login — see Modules\Core\Wishlist\Services\WishlistService. The
// toggle action itself (route `wishlist.toggle`) is registered by
// core's own WishlistServiceProvider, not here.
Route::get('/wishlist', [WishlistController::class, 'guest'])->name('wishlist');
Route::post('/wishlist/{productId}', [WishlistController::class, 'toggle'])
->whereNumber('productId')
->middleware('throttle:60,1')
->name('wishlist.toggle');
Route::middleware('auth')->prefix('/account')->group(function () {
Route::get('/', [AccountController::class, 'show'])->name('account');