diff --git a/app/Http/Controllers/WishlistController.php b/app/Http/Controllers/WishlistController.php index 5dcbc8f..07946da 100644 --- a/app/Http/Controllers/WishlistController.php +++ b/app/Http/Controllers/WishlistController.php @@ -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. diff --git a/app/Listeners/MergeGuestWishlistOnLogin.php b/app/Listeners/MergeGuestWishlistOnLogin.php deleted file mode 100644 index 5d1b90d..0000000 --- a/app/Listeners/MergeGuestWishlistOnLogin.php +++ /dev/null @@ -1,23 +0,0 @@ -wishlist->mergeGuestInto($event->user); - } -} diff --git a/app/Models/WishlistItem.php b/app/Models/WishlistItem.php deleted file mode 100644 index 2276309..0000000 --- a/app/Models/WishlistItem.php +++ /dev/null @@ -1,14 +0,0 @@ -app->scoped(\App\Services\Wishlist::class); - } - public function boot(): void { Telemetry::optOut(); diff --git a/app/Services/Wishlist.php b/app/Services/Wishlist.php deleted file mode 100644 index e9d8c95..0000000 --- a/app/Services/Wishlist.php +++ /dev/null @@ -1,126 +0,0 @@ -|null ids for this request, including a toggle just made */ - private ?array $guestIds = null; - - /** @return array 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 */ - 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 $ids */ - private function storeGuestIds(array $ids): void - { - $this->guestIds = $ids; - - Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES); - } -} diff --git a/database/migrations/2026_09_24_000001_create_wishlist_items_table.php b/database/migrations/2026_09_24_000001_create_wishlist_items_table.php deleted file mode 100644 index d830309..0000000 --- a/database/migrations/2026_09_24_000001_create_wishlist_items_table.php +++ /dev/null @@ -1,25 +0,0 @@ -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'); - } -}; diff --git a/resources/js/app.js b/resources/js/app.js index 20084e2..825e7bb 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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); diff --git a/resources/js/stimulus/index.js b/resources/js/stimulus/index.js index 669dd2a..de8ce1c 100644 --- a/resources/js/stimulus/index.js +++ b/resources/js/stimulus/index.js @@ -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) } diff --git a/resources/js/stimulus/wishlist-controller.js b/resources/js/stimulus/wishlist-controller.js deleted file mode 100644 index 160e977..0000000 --- a/resources/js/stimulus/wishlist-controller.js +++ /dev/null @@ -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 - } - } -} diff --git a/resources/views/components/wishlist-button.blade.php b/resources/views/components/wishlist-button.blade.php index f457057..ea306f8 100644 --- a/resources/views/components/wishlist-button.blade.php +++ b/resources/views/components/wishlist-button.blade.php @@ -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 diff --git a/routes/web.php b/routes/web.php index 8c30c03..99b0a7d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');