diff --git a/database/migrations/2026_09_28_000001_create_wishlist_items_table.php b/database/migrations/2026_09_28_000001_create_wishlist_items_table.php new file mode 100644 index 0000000..d2df658 --- /dev/null +++ b/database/migrations/2026_09_28_000001_create_wishlist_items_table.php @@ -0,0 +1,42 @@ +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/index.js b/resources/js/index.js index 90e03b3..154fb46 100644 --- a/resources/js/index.js +++ b/resources/js/index.js @@ -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' diff --git a/resources/js/wishlist/index.js b/resources/js/wishlist/index.js new file mode 100644 index 0000000..add3860 --- /dev/null +++ b/resources/js/wishlist/index.js @@ -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) +} diff --git a/resources/js/wishlist/wishlist-controller.js b/resources/js/wishlist/wishlist-controller.js new file mode 100644 index 0000000..3591a92 --- /dev/null +++ b/resources/js/wishlist/wishlist-controller.js @@ -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 + } + } +} diff --git a/src/Providers/WishlistServiceProvider.php b/src/Providers/WishlistServiceProvider.php new file mode 100644 index 0000000..b40fdfb --- /dev/null +++ b/src/Providers/WishlistServiceProvider.php @@ -0,0 +1,26 @@ +app->scoped(WishlistService::class); + } + + public function boot(): void + { + $this->loadRoutesFrom(__DIR__.'/../Wishlist/routes/web.php'); + + Event::listen(UserAuthenticated::class, MergeGuestWishlistOnLogin::class); + } +} diff --git a/src/Wishlist/Http/Controllers/WishlistController.php b/src/Wishlist/Http/Controllers/WishlistController.php new file mode 100644 index 0000000..d1e166e --- /dev/null +++ b/src/Wishlist/Http/Controllers/WishlistController.php @@ -0,0 +1,41 @@ +exists(), 404); + + $active = $this->wishlist->toggle($productId); + + if ($request->expectsJson()) { + return response()->json(['active' => $active]); + } + + return back(); + } +} diff --git a/src/Wishlist/Listeners/MergeGuestWishlistOnLogin.php b/src/Wishlist/Listeners/MergeGuestWishlistOnLogin.php new file mode 100644 index 0000000..7e6e05a --- /dev/null +++ b/src/Wishlist/Listeners/MergeGuestWishlistOnLogin.php @@ -0,0 +1,23 @@ +wishlist->mergeGuestInto($event->user); + } +} diff --git a/src/Wishlist/Models/WishlistItem.php b/src/Wishlist/Models/WishlistItem.php new file mode 100644 index 0000000..97de7d0 --- /dev/null +++ b/src/Wishlist/Models/WishlistItem.php @@ -0,0 +1,14 @@ +|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/src/Wishlist/routes/web.php b/src/Wishlist/routes/web.php new file mode 100644 index 0000000..0e10b13 --- /dev/null +++ b/src/Wishlist/routes/web.php @@ -0,0 +1,9 @@ +whereNumber('productId') + ->middleware('throttle:60,1') + ->name('wishlist.toggle');