Compare commits

...
6 Commits
15 changed files with 386 additions and 4 deletions
+30
View File
@@ -4,6 +4,36 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [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 ## [0.24.0] - 2026-09-28
### Added ### Added
+3 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.24.0", "version": "0.25.1",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -47,7 +47,8 @@
"Modules\\Core\\Providers\\FileServiceProvider", "Modules\\Core\\Providers\\FileServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider", "Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider", "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", "name": "@boboko/core",
"version": "0.24.0", "version": "0.25.1",
"private": true, "private": true,
"type": "module", "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.", "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 // 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. // standalone vendored script meant to be included directly, not imported.
export { registerCheckout } from './checkout/index.js' 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", "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.login_email_label' => ['Email', 'Email'],
'page.send_code' => ['Send code', 'Αποστολή κωδικού'], 'page.send_code' => ['Send code', 'Αποστολή κωδικού'],
'page.login_coming_soon' => [ 'page.login_coming_soon' => [
@@ -95,6 +101,7 @@ class CheckoutTranslationsSeeder extends Seeder
'page.first_name' => ['First name', 'Όνομα'], 'page.first_name' => ['First name', 'Όνομα'],
'page.last_name' => ['Last name', 'Επώνυμο'], 'page.last_name' => ['Last name', 'Επώνυμο'],
'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'], 'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'],
'page.wants_invoice' => ['I need an invoice', 'Θέλω τιμολόγιο'],
'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'], 'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'],
'page.address_line_one' => ['Address', 'Διεύθυνση'], 'page.address_line_one' => ['Address', 'Διεύθυνση'],
'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'], 'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'],
@@ -178,6 +185,10 @@ class CheckoutTranslationsSeeder extends Seeder
'Θα λάβεις email επιβεβαίωσης σύντομα.', 'Θα λάβεις email επιβεβαίωσης σύντομα.',
], ],
'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'], '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_billing' => ['Billing', 'Χρέωση'],
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'], 'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
'page.box_now_locker_label' => [ 'page.box_now_locker_label' => [
@@ -3,6 +3,7 @@
namespace Modules\Core\Privacy\Filament\Extensions; namespace Modules\Core\Privacy\Filament\Extensions;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\Checkbox;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\BaseExtension; 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 * docs/modules.md "Layering Module and App Configuration"), and this extension
* deliberately only implements headerActions(), so it never conflicts with an * deliberately only implements headerActions(), so it never conflicts with an
* app's own extension for the same resource. * 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 class CustomerErasureActionsExtension extends BaseExtension
{ {
public function headerActions(array $actions): array public function headerActions(array $actions): array
{ {
return [ return [
...$actions, ...array_filter($actions, fn ($action) => ! $action instanceof DeleteAction),
Action::make('requestErasure') Action::make('requestErasure')
->label('Request Erasure') ->label('Request Erasure')
->icon('heroicon-o-shield-exclamation') ->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');