diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0d322e8 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,41 @@ +{ + "permissions": { + "allow": [ + "Bash(find /home/konstantinos/Projects/RadicalElements/boboko-core/docs/scratch -iname \"*cart*\" 2>/dev/null; find /home/konstantinos/Projects/RadicalElements -iname \"*cart-feature*\" -o -iname \"*feature-survey*\" 2>/dev/null)", + "Read(//home/konstantinos/Projects/RadicalElements/**)", + "Bash(find /home/konstantinos/Projects/RadicalElements/3dealer -path \"*config/lunar/payments.php\" 2>/dev/null; find /home/konstantinos/Projects/RadicalElements -maxdepth 4 -iname \"*stripe*\" -type d 2>/dev/null)", + "Bash(grep -n 'process\\(\\\\|->using\\\\|\\\\$data' /home/konstantinos/Projects/RadicalElements/boboko-core/vendor/filament/actions/src/CreateAction.php)", + "Bash(php -l src/Order/Commands/CloseExpiredReturnWindows.php)", + "Bash(php -l config/core.php)", + "Bash(./bin/dc-core.sh exec *)", + "Bash(php -l src/Shipping/Extensions/OrderViewExtension.php)", + "Bash(php -l src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php)", + "Bash(./bin/dc-core.sh exec app php artisan tinker '--execute= *)", + "Bash(mkdir -p /home/konstantinos/Projects/RadicalElements/boboko-core/src/Cart/Http/Controllers)", + "Bash(rmdir /home/konstantinos/Projects/RadicalElements/boboko-core/src/Checkout/routes)", + "Bash(mkdir -p /home/konstantinos/Projects/RadicalElements/boboko-core/src/Checkout/routes)", + "Bash(php -l src/Cart/Http/Controllers/CartController.php)", + "Bash(php -l src/Checkout/Http/Controllers/CheckoutController.php)", + "Bash(php -l src/Providers/CheckoutModuleServiceProvider.php)", + "Bash(php -l src/Providers/CheckoutServiceProvider.php)", + "Bash(php -l src/Checkout/routes/checkout.php)", + "Bash(php -l config/checkout.php)", + "Bash(cp /home/konstantinos/Projects/RadicalElements/3dealer/resources/css/checkout.css /home/konstantinos/Projects/RadicalElements/boboko-core/resources/css/)", + "Bash(cp /home/konstantinos/Projects/RadicalElements/3dealer/resources/js/checkout/*.js /home/konstantinos/Projects/RadicalElements/boboko-core/resources/js/checkout/)", + "Bash(rm /home/konstantinos/Projects/RadicalElements/3dealer/app/Providers/CheckoutModuleServiceProvider.php)", + "Bash(rm -rf /home/konstantinos/Projects/RadicalElements/3dealer/app/Http/Controllers/Checkout)", + "Bash(rm /home/konstantinos/Projects/RadicalElements/3dealer/routes/checkout.php)", + "Bash(rm -rf /home/konstantinos/Projects/RadicalElements/3dealer/resources/js/checkout)", + "Bash(rm /home/konstantinos/Projects/RadicalElements/3dealer/resources/css/checkout.css)", + "Bash(composer dump-autoload *)", + "Bash(curl -s -o /tmp/checkout_test.html -w \"%{http_code}\\\\n\" http://localhost:8091/en/checkout)", + "Read(//tmp/**)", + "Bash(curl -s -o /tmp/home_test.html -w \"%{http_code}\\\\n\" http://localhost:8091/en/)", + "Bash(php -l bootstrap/providers.php)" + ], + "additionalDirectories": [ + "/home/konstantinos/Projects/RadicalElements/3dealer/bootstrap", + "/home/konstantinos/Projects/RadicalElements/3dealer/config" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index a74812f..d66e11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,162 @@ 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/). +## [0.26.1] - 2026-09-28 + +### Added +- `Modules\Core\Order\Support\OrderReferenceDisplay` — a human-facing form of + `Order::reference` for emails and storefront views. `Lunar\Base\OrderReferenceGenerator` + zero-pads the order's own id out to a fixed length (8 characters by default), so a shop's + first real orders read as `#00000001` rather than `#1`. This strips leading zeros until the + first non-zero digit (falling back to `0` if none remain), purely for display — the raw, + padded `reference` is untouched everywhere else (DB lookups, the order-status API, staff + search, GDPR exports). Wired into the checkout confirmation page and all customer-facing + order notification emails. + +## [0.26.0] - 2026-09-28 + +### Added +- `Modules\Core\Store\` — a "Store Details" Filament settings page (under Settings) for a + shop's own contact/legal details: store name, address (both translatable), phone, tax + identifier (ΑΦΜ), company registration number (ΓΕΜΗ), and rich-text bank transfer + instructions (IBANs, formatted as a table if needed — `TranslatedText::optionRichtext()`, + which ships table insert/edit in its default toolbar). Backed by a single-row + `StoreDetails` model, read via `StoreDetailsService::current()` (forever-cached, + invalidated by the new `StoreDetailsUpdated` event whenever `StoreDetailsService::update()` + is the one write path used — never write to the model directly). + +### Fixed +- `StoreDetailsService`'s singleton row was created with every translatable column + (`name`/`address`/`bank_transfer_instructions`) left `NULL`. Lunar's own `TranslatedText` + Filament component silently discards every keystroke on re-render when the field it edits + starts out `NULL` rather than an empty per-locale array — invisible for `PaymentMethod`'s + own translatable `name` (always created already-filled, through the same form), but exactly + the gap this brand-new singleton hits, since it's created blank and opened for editing in + the same visit. The row is now seeded with an empty string per configured language from + creation, so every translatable field is editable from the very first save. + +## [0.25.2] - 2026-09-28 + +### Fixed +- `BankTransferPaymentDriver::pay()` returned `Succeeded` and dispatched `PaymentCaptured` + immediately — treating a bank transfer like an instant-success gateway (Stripe), when in + reality no money has moved yet. Now returns `Pending` with no event dispatched, so the order + stays at `awaiting_payment` with `Order::paid` false, exactly like it should — checkout still + completes normally (`CheckoutController` already treats a `Pending` result with no + continuation as a placed order). `OrderStatusFlow::canMarkPaid()`/`isBankTransfer()` now also + recognize bank transfer, so staff can mark the order paid once the wire arrives, the same + "Mark Paid" action cash-on-delivery already uses — but unlike COD's version, this also + advances the order's status past `awaiting_payment`, since nothing else ever will. + +## [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 + +### Added +- Box Now locker picker support for the newly-extracted checkout module: `CheckoutController:: + boxNowLockers()`/`selectBoxNowLocker()`, the `bbk-box-now-locker-controller.js` Stimulus + controller, its picker markup in `shipping-options.blade.php`, and its styles in + `checkout.css` — the routes and translation seeder for this already existed from the previous + extraction, but the controller methods and JS/CSS themselves hadn't been carried over, leaving + the `checkout.box-now.lockers` route throwing `BadMethodCallException`. +- `ProductIndexer`'s `recommendations` entries now include `variant_id` and `has_custom_fields` — + previously only `{id, name, price, image}`, which left a recommended product's card with + neither an "Add to cart" nor a "Personalize" button, since a storefront card needs one of + those two fields to decide which to show at all. +- A real `package.json` for this package's JS (Stimulus controllers) and CSS, installed by a + consuming app as a normal npm dependency (`file:../boboko-core` in local dev, a tagged git + install — `git+https://...#semver:0.x`, mirroring `composer.json`'s own `0.*` constraint — in + prod) so `npm install`/`npm update @boboko/core` resolves this package's own JS dependencies + (`leaflet`, `@hotwired/stimulus`) transitively, the same way `composer update boboko/*` already + does for PHP. A consuming app registers `boboko()` from the new `vite-plugin.js` export in its + own `vite.config.js`, which encapsulates every quirk of that installation method (symlink + resolution, HMR watching, dependency pre-bundling) so the consumer's own config stays a + one-line plugin registration. Consumers import this package's JS from one stable entry point, + `resources/js/index.js` (`@boboko/core`'s package root export), rather than reaching into a + specific module's internal file layout directly — see this package's `CONTRIBUTE.md` and + `docs/modules.md`. + +### Fixed +- `Catalog\Recommendations\RandomRule` resolved products via the base `Lunar\Models\Product` + directly instead of through `ModelManifest`, silently losing `custom_fields` (which only the + registered `Modules\Core\Catalog\Models\Product` subclass can read) for any recommendation it + produced. `SameCategoryRule` was already correct, since `Collection::products()` resolves via + Lunar's own `Product::modelClass()`. + +## [0.23.0] - 2026-09-25 + +### Added +- `Modules\Core\Checkout\` - extracted Checkout and Cart view, resources, Controllers, + services, etc. to Core + + ## [0.22.0] - 2026-09-25 +### Added +- `Modules\Core\Customer\Services\CustomerEmailChangeService` — changing an account's login + email (core's login is passwordless, so the email IS the login): `request()` validates the new + address is free and throttled (3 codes/10min), `confirm()` allows 5 wrong guesses per code, + re-checks the address is still free, switches it, notifies the old address (masked new + address), and claims guest orders for the new email. The pending change lives on the user's + own row (`pending_email`/`pending_email_code_hash`/`pending_email_expires_at`/ + `pending_email_attempts` — new migration), the same convention as the existing OTP login + columns, rather than the session — a code arrives by email and is often opened on a different + device/session than the one that requested it. New core-owned mailables + (`Auth\Mail\EmailChangeCodeMail`/`EmailChangedNoticeMail`) with default views, overridable + per-app the same way `UserOtpMail`'s already is. Dispatches a new `Auth\Events\ + UserEmailChanged` event. +- `Modules\Core\Customer\Services\CustomerAccountService::setRecoveryConsent()` — the account's + standing "email me a reminder if I don't finish my order" opt-in, written to the customer's + meta in the same shape `Checkout\Services\CheckoutService::setRecoveryConsent()` already writes + on the cart. Skips the write when nothing changed; dispatches a new `Customer\Events\ + CustomerRecoveryConsentSet` event (also wired into the existing account-activity audit log). + 3dealer's own duplicated implementations in `CheckoutController`/`AccountController` now call + this instead. +- `terms_accepted_at`/`terms_version`/`privacy_policy_version` columns on `users` — recorded once, + by a new `Auth\Listeners\RecordLegalAcceptanceForNewUser` (listening on `UserCreated`), the + moment a genuinely new signup requests their first OTP code; never touched again for an + existing user. Included in the User-scope privacy export (`CustomerDataProvider:: + exportForUser()`). +- ~90 previously-unseeded `storefront.*` translation keys (login/OTP copy, account profile and + email-change flow, order history, contact form, product custom-fields and stock-error + messages, reviews, wishlist) added to `Localization\Services\StorefrontLabels` — these were + already called via `__()`/`trans_choice()` across a consuming app's views with no seeded + value at all, silently rendering the raw translation key in production. + +### Fixed +- `CustomerAccountService::WRITABLE_PROFILE_FIELDS` listed `vat_no`, but Lunar's `customers` + column has been `tax_identifier` since a 2025 Lunar migration — passing `vat_no` was silently + dropped by the allowlist, and `tax_identifier` couldn't be written through `updateProfile()` at + + ### Added - `Modules\Core\Customer\Services\CustomerEmailChangeService` — changing an account's login email (core's login is passwordless, so the email IS the login): `request()` validates the new @@ -100,6 +254,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [0.20.2] - 2026-09-25 +## [0.22.0] - 2026-09-25 ### Changed - Product custom fields (`Product::$custom_fields`) moved off the main product edit form onto their own "Custom Fields" sub-page (`Modules\Core\Catalog\Filament\Pages\ diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index d2dc4a1..b441007 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -31,6 +31,56 @@ This is shorthand for `docker compose -f docker-compose.dev.yml -f docker-compos Skipping this step is the most common cause of "my change isn't showing up." +## JS/CSS: no separate npm package + +This package's JS (Stimulus controllers) and CSS ship as plain source files under `resources/js/` and `resources/css/`, read directly by a consumer app's own Vite build — there is no separate `@boboko/core` npm package, and no `npm install`/`file:` dependency step of any kind. + +The reason: Composer already gives every environment one single, unconditional path — `vendor/boboko/core` — whether that resolves to a real symlink into `../boboko-core` (local path repo) or a real installed copy (tagged VCS release). A consumer's `vite.config.js` and JS entry point just read straight from that path, so there is nothing to toggle on the JS side — whatever Composer resolved is exactly what Vite sees, automatically, in both dev and prod. + +**Stable entry point.** A consumer imports from [resources/js/index.js](resources/js/index.js) only — never from a path reaching into a specific module's internals (e.g. `resources/js/checkout/index.js` directly). That barrel file re-exports whatever a consumer needs (currently just `registerCheckout`), so this package's internal file layout can change without breaking every consumer's own entry point: + +```js +// consumer app's resources/js/app.js +import { registerCheckout } from "../../vendor/boboko/core/resources/js/index.js"; +registerCheckout(application); +``` + +```php +{{-- consumer app's layout --}} +@vite(['vendor/boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js']) +``` + +**What a consumer's `vite.config.js` needs**, because `vendor/boboko/core` is a symlink in local path-repo dev (not a real directory): + +```js +export default defineConfig({ + server: { + watch: { + // vendor/boboko/core is a symlink into ../boboko-core in local + // path-repo dev. Vite/chokidar don't follow symlinks for watched + // files by default, so edits to core's source wouldn't otherwise + // trigger HMR. No-op against a real installed copy (tagged VCS + // release) in production — there's no symlink to follow, and + // production only ever runs a one-shot `npm run build`, which + // doesn't watch anything regardless. + followSymlinks: true, + }, + }, +}); +``` + +Bare imports inside this package's own JS (`leaflet`, `@hotwired/stimulus`) resolve against the *consumer's* `node_modules` — Node's normal upward `node_modules` resolution walks from `vendor/boboko/core/resources/js/...` up through `vendor/boboko/`, `vendor/`, to the consumer app's root, where `node_modules` lives. This works with zero extra config as long as `vendor/boboko/core` sits inside the consumer's own directory tree (true for both the symlink and the real-copy case) — a consuming app's `vite`-equivalent Docker service just needs the same bind mount PHP containers already get, landing at the same path: + +```yaml +# consumer app's docker-compose.core-dev.yml +services: + vite: + volumes: + - ../boboko-core:/app/vendor/boboko/core +``` + +(Match whatever the consumer's Vite container's working directory actually is — `/app` above, `/var/www/html` for the PHP containers in `boboko-test`'s convention.) + ## Verifying changes against a real database There is no automated test suite for this package — too much of Lunar's behavior (table prefixing, nested sets, translatable attributes, Filament panel filters) only breaks in combination, against real Postgres, in a way that's impractical to fake in isolation. Instead, verify changes directly against a consumer app's live database. The practical workflow used throughout this package's `MigrateImport` feature: diff --git a/composer.json b/composer.json index bb07335..9e39079 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.22.0", + "version": "0.26.1", "autoload": { "psr-4": { "Modules\\Core\\": "src/" @@ -47,7 +47,9 @@ "Modules\\Core\\Providers\\FileServiceProvider", "Modules\\Core\\Providers\\ShippingServiceProvider", "Modules\\Core\\Providers\\OrderServiceProvider", - "Modules\\Core\\Providers\\PrivacyServiceProvider" + "Modules\\Core\\Providers\\PrivacyServiceProvider", + "Modules\\Core\\Providers\\WishlistServiceProvider", + "Modules\\Core\\Providers\\StoreServiceProvider" ] } }, 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/database/migrations/2026_09_28_000002_create_store_details_table.php b/database/migrations/2026_09_28_000002_create_store_details_table.php new file mode 100644 index 0000000..2498b4b --- /dev/null +++ b/database/migrations/2026_09_28_000002_create_store_details_table.php @@ -0,0 +1,44 @@ +translate('name'). tax_identifier + * (ΑΦΜ) and registration_number (ΓΕΜΗ) are legal identifiers, not + * locale-dependent text, so they stay plain strings — same for phone. + * + * No seeder inserting the singleton row — StoreDetailsService::current() + * lazily creates it (all-null) on first read, same shape as any other + * firstOrCreate()-backed singleton in this codebase. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::create('store_details', function (Blueprint $table) { + $table->id(); + $table->json('name')->nullable(); + $table->json('address')->nullable(); + $table->string('phone')->nullable(); + $table->string('tax_identifier')->nullable(); + $table->string('registration_number')->nullable(); + $table->json('bank_transfer_instructions')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_details'); + } +}; diff --git a/docs/modules.md b/docs/modules.md index 7d05580..8a44d8d 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -122,6 +122,63 @@ Docker Compose merges `volumes:` lists additively across `-f` files, so the over --- +## Frontend Assets (JS/CSS) + +A module's JS (Stimulus controllers) and CSS ship as plain source files under `resources/js/` and `resources/css/` — **there is no separate npm package per module.** A module is never `npm install`ed; its frontend assets are read directly by the consuming app's own Vite build, straight out of `vendor/boboko/`. + +This mirrors the PHP story above exactly: Composer already gives every environment one single, unconditional path — `vendor/boboko/` — whether that resolves to a symlink into a sibling checkout (local path repo) or a real installed copy (tagged VCS release). A consumer's `vite.config.js` and JS entry point read from that same path, so there is nothing to toggle on the JS side — whatever Composer resolved is exactly what Vite sees, in both dev and prod, automatically. + +**Each module exposes one stable JS entry point** — `resources/js/index.js` — that re-exports whatever a consumer needs, e.g. `boboko-core`'s: + +```js +// boboko-core/resources/js/index.js +export { registerCheckout } from './checkout/index.js' +``` + +A consuming app imports from that one file only, never from a path reaching into a module's internal folder structure directly: + +```js +// consumer app's resources/js/app.js +import { registerCheckout } from "../../vendor/boboko/core/resources/js/index.js"; +registerCheckout(application); +``` + +```php +{{-- consumer app's layout --}} +@vite(['vendor/boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js']) +``` + +This keeps a module's internal file layout free to change without breaking every consumer's entry point — the same reasoning as PSR-4 namespaces for PHP, just for JS imports. + +**A consuming app's `vite.config.js` needs one addition**, because `vendor/boboko/` is a symlink in local path-repo dev (not a real directory Vite would otherwise watch through): + +```js +export default defineConfig({ + server: { + watch: { + // vendor/boboko/ is a symlink into ../boboko- in + // local path-repo dev. Vite/chokidar don't follow symlinks for + // watched files by default, so edits to a module's source + // wouldn't otherwise trigger HMR. No-op against a real installed + // copy (tagged VCS release) in production. + followSymlinks: true, + }, + }, +}); +``` + +Bare imports inside a module's own JS (e.g. `leaflet`, `@hotwired/stimulus`) resolve against the **consumer's** `node_modules` via Node's normal upward resolution walk from `vendor/boboko//resources/js/...` — no extra config needed, as long as `vendor/boboko/` sits inside the consumer's own directory tree (true for both the symlink and real-copy case). The consumer's Vite Docker service (if any) needs the same bind mount the PHP containers already get, landing at the equivalent path relative to its own working directory: + +```yaml +# consumer app's docker-compose.core-dev.yml +services: + vite: + volumes: + - ../boboko-core:/app/vendor/boboko/core # match /app to the vite service's actual workdir +``` + +--- + ## Creating a New Module **1. Create the repository and `composer.json`:** diff --git a/package.json b/package.json new file mode 100644 index 0000000..124a3ac --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "@boboko/core", + "version": "0.26.1", + "private": true, + "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.", + "exports": { + ".": "./resources/js/index.js", + "./checkout": "./resources/js/checkout/index.js", + "./checkout/*": "./resources/js/checkout/*", + "./css/*": "./resources/css/*", + "./vite-plugin": "./vite-plugin.js" + }, + "dependencies": { + "@hotwired/stimulus": "^3.2.2", + "leaflet": "^1.9.4" + }, + "peerDependencies": { + "vite": "^8.0.0" + } +} diff --git a/resources/css/checkout.css b/resources/css/checkout.css index 5c3b3de..fb05402 100644 --- a/resources/css/checkout.css +++ b/resources/css/checkout.css @@ -656,6 +656,141 @@ textarea.bbk-field-input { resize: vertical; } .bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); } +/* Dummy Box Now locker picker — see shipping-options.blade.php. */ +.bbk-checkout-box-now-locker { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.75rem; + padding: 0.875rem 1rem; + border: 1px solid var(--bbk-color-border); + border-radius: var(--bbk-radius-sm); +} + +.bbk-checkout-box-now-locker-label { + font-weight: 600; + font-size: 0.8125rem; +} + +.bbk-checkout-box-now-locker-map { + width: 100%; + height: 480px; + border-radius: var(--bbk-radius-sm); + z-index: 0; +} + +.bbk-checkout-box-now-locker-search { + width: 100%; + padding: 0.625rem 0.875rem; + border: 1px solid var(--bbk-color-border); + border-radius: var(--bbk-radius-sm); + font-size: 0.875rem; +} + +.bbk-checkout-box-now-locker-search:focus { + outline: none; + border-color: var(--bbk-color-accent); +} + +.bbk-checkout-box-now-locker-chosen { + margin: 0; + font-size: 0.8125rem; + font-weight: 600; + color: var(--bbk-color-accent); +} + +/* Custom SVG pin (see bbk-box-now-locker-controller.js pinIcon()) — no + default Leaflet drop-shadow image, so a CSS shadow stands in for it. */ +.bbk-box-now-pin svg { + filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.35)); +} + +/* Leaflet's own popup chrome, restyled to match the checkout's cards + instead of the library's square-cornered default. */ +.leaflet-popup-content-wrapper { + border-radius: 0.75rem; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.18); +} + +.leaflet-popup-content { + margin: 0.875rem; +} + +.bbk-box-now-popup { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 220px; +} + +.bbk-box-now-popup-image { + width: calc(100% + 1.75rem); + margin: -0.875rem -0.875rem 0.125rem; + height: 110px; + object-fit: cover; + border-radius: 0.75rem 0.75rem 0 0; +} + +.bbk-box-now-popup-name { + display: flex; + align-items: center; + gap: 0.375rem; + margin: 0; + font-weight: 700; + font-size: 0.9375rem; +} + +.bbk-box-now-popup-name::before { + content: ''; + flex: 0 0 auto; + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: #00c389; +} + +.bbk-box-now-popup-address { + margin: 0; + padding-left: 0.875rem; + font-size: 0.8125rem; + line-height: 1.4; + color: var(--bbk-color-muted); +} + +.bbk-box-now-popup-note { + margin: 0 0 0 0.875rem; + padding: 0.5rem 0.625rem; + background: color-mix(in srgb, #00c389 8%, transparent); + border-radius: var(--bbk-radius-sm); + font-size: 0.75rem; + font-style: italic; + color: var(--bbk-color-muted); +} + +.bbk-box-now-popup-select { + margin-top: 0.25rem; + padding: 0.625rem 0.875rem; + width: 100%; + border: none; + border-radius: var(--bbk-radius-sm); + background: #00c389; + color: #ffffff; + font-weight: 700; + font-size: 0.8125rem; + letter-spacing: 0.01em; + cursor: pointer; + transition: background-color 0.15s ease, transform 0.1s ease; +} + +.bbk-box-now-popup-select:hover { background: #00a876; transform: translateY(-1px); } +.bbk-box-now-popup-select:active { transform: translateY(0); } + +.bbk-box-now-popup-select--selected, +.bbk-box-now-popup-select--selected:hover { + background: var(--bbk-color-muted); + cursor: default; +} + /* Continue / submit buttons — same look as the drawer's checkout CTA */ .bbk-checkout-continue { diff --git a/resources/js/checkout/bbk-box-now-locker-controller.js b/resources/js/checkout/bbk-box-now-locker-controller.js new file mode 100644 index 0000000..37b649e --- /dev/null +++ b/resources/js/checkout/bbk-box-now-locker-controller.js @@ -0,0 +1,220 @@ +import { Controller } from '@hotwired/stimulus' +import L from 'leaflet' +import 'leaflet/dist/leaflet.css' +import { csrfToken } from './csrf' + +// Box Now's own brand green, used for the pin instead of Leaflet's default +// blue teardrop — a small SVG data URI rather than another bundled asset. +const PIN_COLOR = '#00c389' +const PIN_COLOR_SELECTED = '#0a7a52' + +function pinIcon(color) { + const svg = ` + + + + + ` + + return L.divIcon({ + className: 'bbk-box-now-pin', + html: svg, + iconSize: [34, 46], + iconAnchor: [17, 46], + popupAnchor: [0, -40], + }) +} + +const ICON = pinIcon(PIN_COLOR) +const ICON_SELECTED = pinIcon(PIN_COLOR_SELECTED) + +// A self-hosted Leaflet map standing in for Box Now's own Destination Map +// JS widget — that widget only talks to Box Now's Production API (see +// their Partner API manual §4.1), so it can't be used while developing +// against Stage credentials. Same underlying /destinations data, rendered +// with OpenStreetMap tiles instead of Box Now's map. +// +// Visibility is toggled by bbk-checkout-form (see its own +// toggleBoxNowLocker()) whenever the "box-now" shipping option becomes +// selected/deselected — this controller only owns loading the locker list +// once visible, rendering pins, and autosaving the chosen one. +export default class extends Controller { + static targets = ['map', 'search', 'status', 'chosen'] + + static values = { + lockersUrl: String, + selectUrl: String, + loading: String, + selectLabel: String, + selectedLabel: String, + noResults: String, + } + + // Athens — a reasonable default center before any locker is loaded. + static DEFAULT_CENTER = [37.9838, 23.7275] + + connect() { + this.map = null + this.markers = new Map() + this.selectedId = null + this.loaded = false + + if (!this.element.hidden) this.show() + } + + disconnect() { + this.map?.remove() + this.map = null + } + + // Called by bbk-checkout-form right after it un-hides this element. + show() { + this.element.hidden = false + + // Leaflet measures its container's size on init — doing that while + // the element (or an ancestor) is still `hidden` produces a + // collapsed/blank map, so this is deferred to the same tick `hidden` + // is cleared, then Leaflet is nudged once more via invalidateSize(). + requestAnimationFrame(() => { + if (!this.map) this.initMap() + this.map.invalidateSize() + + if (!this.loaded) this.loadLockers() + }) + } + + hide() { + this.element.hidden = true + } + + initMap() { + this.map = L.map(this.mapTarget).setView(this.constructor.DEFAULT_CENTER, 10) + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19, + }).addTo(this.map) + + // Delegated: popup content is re-inserted by Leaflet on every open, + // so a listener bound once on the map's container beats binding (and + // losing) one on the button each time a popup renders. + this.map.getContainer().addEventListener('click', (event) => { + const button = event.target.closest('[data-locker-id]') + if (button) this.select(button.dataset.lockerId) + }) + } + + async loadLockers() { + this.setStatus(this.loadingValue) + + try { + const response = await fetch(this.lockersUrlValue, { + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) return + + const { lockers } = await response.json() + this.loaded = true + this.lockers = new Map(lockers.map((locker) => [String(locker.id), locker])) + this.renderMarkers(lockers) + this.setStatus('') + } catch { + this.setStatus('') + } + } + + renderMarkers(lockers) { + this.markers.forEach((marker) => marker.remove()) + this.markers = new Map(lockers.map((locker) => { + const marker = L.marker([locker.lat, locker.lng], { icon: ICON }) + .addTo(this.map) + .bindPopup(this.popupHtml(locker), { maxWidth: 260 }) + + return [String(locker.id), marker] + })) + + if (this.markers.size) { + this.map.fitBounds(L.featureGroup([...this.markers.values()]).getBounds().pad(0.2)) + } + } + + popupHtml(locker) { + const isSelected = String(locker.id) === this.selectedId + + return ` +
+ ${locker.image ? `` : ''} +

${locker.name}

+

+ ${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')} + ${locker.postalCode ? ` ${locker.postalCode}` : ''} +

+ ${locker.note ? `

${locker.note}

` : ''} + +
+ ` + } + + async select(lockerId) { + const locker = this.lockers?.get(String(lockerId)) + if (!locker) return + + const previousId = this.selectedId + this.selectedId = String(lockerId) + + this.restyleMarker(previousId, ICON) + this.restyleMarker(this.selectedId, ICON_SELECTED) + this.markers.get(this.selectedId)?.setPopupContent(this.popupHtml(locker)) + + this.chosenTarget.hidden = false + this.chosenTarget.textContent = locker.addressLine1 + ? `${locker.name} — ${locker.addressLine1}` + : locker.name + + const body = new FormData() + body.append('locker_id', locker.id) + body.append('locker_name', locker.name ?? '') + body.append('locker_address', locker.addressLine1 ?? '') + + try { + await fetch(this.selectUrlValue, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': csrfToken(), + 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', + }, + body, + }) + } catch { + // Best-effort autosave, same as the rest of checkout — a failed + // save here surfaces later at place-order time via the normal + // shipment-creation error path, not as an inline field error. + } + } + + restyleMarker(lockerId, icon) { + if (!lockerId) return + this.markers.get(lockerId)?.setIcon(icon) + } + + setStatus(text) { + if (!this.hasStatusTarget) return + + this.statusTarget.textContent = text + this.statusTarget.hidden = !text + } +} diff --git a/resources/js/checkout/bbk-checkout-form-controller.js b/resources/js/checkout/bbk-checkout-form-controller.js index a8e720e..aa310ec 100644 --- a/resources/js/checkout/bbk-checkout-form-controller.js +++ b/resources/js/checkout/bbk-checkout-form-controller.js @@ -123,12 +123,34 @@ export default class extends Controller { } async selectShipping(event) { + this.toggleBoxNowLocker(event.target.value) + // Tracked so flush() can await it — nothing else stops "place order" // (a separate, unrelated click) from racing ahead of this request. this.shippingPromise = this.doSelectShipping(event.target.value) await this.shippingPromise } + // The dummy Box Now locker +
+ + + diff --git a/src/Catalog/Recommendations/RandomRule.php b/src/Catalog/Recommendations/RandomRule.php index c55621f..4808b20 100644 --- a/src/Catalog/Recommendations/RandomRule.php +++ b/src/Catalog/Recommendations/RandomRule.php @@ -3,6 +3,8 @@ namespace Modules\Core\Catalog\Recommendations; use Illuminate\Support\Collection; +use Lunar\Facades\ModelManifest; +use Lunar\Models\Contracts\Product as ProductContract; use Lunar\Models\Product; use Modules\Core\Catalog\Contracts\RecommendationRule; @@ -18,7 +20,14 @@ class RandomRule implements RecommendationRule { public function recommend(Product $product, int $limit, array $exclude): Collection { - return Product::query() + // ModelManifest::get(), not Product::query() directly — the base + // Lunar\Models\Product has no custom_fields cast/fillable entry + // (see Catalog\Models\Product's own docblock), so a recommendation + // resolved as the base class silently lost that field once + // ProductIndexer started reading it for recommendations.has_custom_fields. + $model = ModelManifest::get(ProductContract::class); + + return $model::query() ->whereKeyNot($exclude) ->inRandomOrder() ->limit($limit) diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php index b80892e..a30c874 100644 --- a/src/Catalog/Services/ProductIndexer.php +++ b/src/Catalog/Services/ProductIndexer.php @@ -189,6 +189,23 @@ class ProductIndexer extends BaseProductIndexer 'name' => $recommendation->translateAttribute('name'), 'price' => $this->cheapestPrice($recommendation, $currency), 'image' => $recommendation->media->first() ? $this->mapMedia($recommendation->media->first())['thumb'] : null, + // Same fields ProductCard::fromIndexed() (3dealer) reads off + // a normal listing document to decide which button a card + // shows at all — a recommendation with neither used to + // render no button whatsoever, since it's built from this + // embedded shape rather than a full ProductService document. + // variant_id: same "first variant, no picker at card scope" + // default every other listing card uses. custom_fields is + // only readable at all because every RecommendationRule now + // resolves products through ModelManifest (see Recommendations\ + // RandomRule) rather than the base Lunar\Models\Product + // directly — that class has no custom_fields cast/fillable + // entry (see Catalog\Models\Product's own docblock), so a + // recommendation resolved as the base class would have + // silently read null here regardless of the product's real + // custom fields. + 'variant_id' => $recommendation->variants->first()?->id, + 'has_custom_fields' => ! empty($recommendation->custom_fields), ]) ->all(); diff --git a/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php b/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php index 5890967..1a10180 100644 --- a/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php +++ b/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php @@ -83,6 +83,12 @@ class CheckoutTranslationsSeeder extends Seeder "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.send_code' => ['Send code', 'Αποστολή κωδικού'], 'page.login_coming_soon' => [ @@ -95,6 +101,7 @@ class CheckoutTranslationsSeeder extends Seeder 'page.first_name' => ['First name', 'Όνομα'], 'page.last_name' => ['Last name', 'Επώνυμο'], 'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'], + 'page.wants_invoice' => ['I need an invoice', 'Θέλω τιμολόγιο'], 'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'], 'page.address_line_one' => ['Address', 'Διεύθυνση'], 'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'], @@ -178,8 +185,40 @@ class CheckoutTranslationsSeeder extends Seeder 'Θα λάβεις email επιβεβαίωσης σύντομα.', ], '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_continue' => ['Continue shopping', 'Συνέχεια αγορών'], + 'page.box_now_locker_label' => [ + 'Choose a Box Now locker', + 'Επίλεξε Box Now locker', + ], + 'page.box_now_locker_loading' => [ + 'Loading lockers…', + 'Φόρτωση lockers…', + ], + 'page.box_now_locker_required' => [ + 'Choose a Box Now locker to continue.', + 'Επίλεξε ένα Box Now locker για να συνεχίσεις.', + ], + 'page.box_now_locker_select' => [ + 'Select this locker', + 'Επιλογή αυτού του locker', + ], + 'page.box_now_locker_selected' => [ + 'Selected', + 'Επιλέχθηκε', + ], + 'page.box_now_locker_search' => [ + 'Search by area or address…', + 'Αναζήτηση με περιοχή ή διεύθυνση…', + ], + 'page.box_now_locker_no_results' => [ + 'No lockers match your search.', + 'Δεν βρέθηκαν lockers για αυτή την αναζήτηση.', + ] ]; } } diff --git a/src/Checkout/Http/Controllers/CheckoutController.php b/src/Checkout/Http/Controllers/CheckoutController.php index 3efefc5..c686531 100644 --- a/src/Checkout/Http/Controllers/CheckoutController.php +++ b/src/Checkout/Http/Controllers/CheckoutController.php @@ -20,12 +20,14 @@ use Lunar\Models\Order; use Lunar\Models\State; use Modules\Core\Cart\Services\CartService; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; +use Modules\Core\Checkout\Exceptions\NoShippingAddressException; use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException; use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException; use Modules\Core\Checkout\Services\CheckoutService; use Modules\Core\Customer\Services\CustomerAccountService; use Modules\Core\Payment\Enums\PaymentResultStatus; use Modules\Core\Payment\Models\PaymentMethod; +use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient; /** * The checkout page — one page, sections (contact / billing / shipping / @@ -280,6 +282,77 @@ class CheckoutController extends Controller return $this->fragments($cart, $options); } + /** + * A plain, own-hosted stand-in for Box Now's Destination Map widget — + * that widget only talks to their Production environment (see their + * Partner API manual §4.1), which is useless while developing against + * Stage credentials. Same underlying data (GET /destinations), no map. + */ + public function boxNowLockers(string $locale, BoxNowClient $boxNow): JsonResponse + { + $lockers = collect($boxNow->destinations()) + // Drops entries with a blank `name` (e.g. id 8288, "Virtual + // Locker" in Sudan at lat 12.3/lng 25.3) — a real, in-range + // coordinate, but sandbox test fixture noise rather than an + // actual pickup point, and it alone was enough to make + // fitBounds() below zoom the map out to the whole Balkans/ + // Middle East to fit every marker's cluster in Greece. + ->filter(fn (array $destination) => filled($destination['name'] ?? null)) + ->map(fn (array $destination) => [ + 'id' => $destination['id'], + 'name' => $destination['name'] ?? $destination['title'] ?? $destination['id'], + 'addressLine1' => $destination['addressLine1'] ?? null, + 'addressLine2' => $destination['addressLine2'] ?? null, + 'postalCode' => $destination['postalCode'] ?? null, + 'country' => $destination['country'] ?? null, + 'note' => $destination['note'] ?? null, + 'image' => $destination['image'] ?? null, + 'lat' => isset($destination['lat']) ? (float) $destination['lat'] : null, + 'lng' => isset($destination['lng']) ? (float) $destination['lng'] : null, + ]) + // Box Now's own Stage/sandbox data has at least one malformed + // entry observed in practice (locker id 47: lat/lng as huge + // integers with the decimal point apparently dropped, e.g. + // 96065874308606 instead of ~37.96) — a single such point blows + // out L.featureGroup().getBounds() on the frontend, zooming the + // map out to near-nothing with every real marker imperceptible + // at that scale. Valid latitude/longitude ranges are absolute, + // not guesswork, so filtering on them is safe regardless of + // what BoxNow's API does or doesn't fix upstream. + ->filter(fn (array $locker) => $locker['lat'] !== null && $locker['lng'] !== null + && abs($locker['lat']) <= 90 && abs($locker['lng']) <= 180) + ->values(); + + return response()->json(['lockers' => $lockers]); + } + + /** + * Persists the shopper's chosen locker (radio/select change, same + * autosave shape as selectShippingOption()) via + * CheckoutService::selectBoxNowLocker() onto the cart's shipping + * address meta. + */ + public function selectBoxNowLocker(string $locale, Request $request): JsonResponse + { + $locationId = (string) $request->input('locker_id'); + + if ($locationId === '') { + return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422); + } + + try { + $this->checkout->selectBoxNowLocker([ + 'locationId' => $locationId, + 'name' => (string) $request->input('locker_name'), + 'addressLine1' => (string) $request->input('locker_address'), + ]); + } catch (NoShippingAddressException) { + return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422); + } + + return response()->json(['ok' => true]); + } + /** * Autosave-select a payment method (radio change). Persists it via * CheckoutService (which also records it on Cart::meta and re-snapshots diff --git a/src/Checkout/routes/checkout.php b/src/Checkout/routes/checkout.php index bda5764..7d01880 100644 --- a/src/Checkout/routes/checkout.php +++ b/src/Checkout/routes/checkout.php @@ -14,6 +14,7 @@ use Modules\Core\Checkout\Http\Controllers\CheckoutController; * Loaded from Providers\CheckoutModuleServiceProvider inside the `web` * middleware group. */ + Route::prefix('{locale}') ->middleware('locale') ->group(function () { @@ -27,6 +28,13 @@ Route::prefix('{locale}') Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption']) ->name('checkout.shipping-option.select'); + Route::get('checkout/box-now/lockers', [CheckoutController::class, 'boxNowLockers']) + ->name('checkout.box-now.lockers'); + + Route::post('checkout/box-now/locker', [CheckoutController::class, 'selectBoxNowLocker']) + ->name('checkout.box-now.locker.select'); + + Route::post('checkout/payment-method', [CheckoutController::class, 'selectPaymentMethod']) ->name('checkout.payment-method.select'); diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 35777b5..afd9a47 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -50,6 +50,7 @@ use Modules\Core\Shipping\Extensions\ShippingMethodListExtension; use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension; use Modules\Core\Shipping\Filament\Resources\ManifestResource; use Modules\Core\Shipping\Filament\Resources\ShipmentResource; +use Modules\Core\Store\Filament\Pages\ManageStoreDetails; class CorePlugin implements Plugin { @@ -74,6 +75,9 @@ class CorePlugin implements Plugin ShipmentResource::class, ManifestResource::class, ]) + ->pages([ + ManageStoreDetails::class, + ]) ->plugin(ShippingPlugin::make()); LunarPanel::extensions([ diff --git a/src/Order/Notifications/OrderCapturedNotification.php b/src/Order/Notifications/OrderCapturedNotification.php index 87f59ab..0d2a444 100644 --- a/src/Order/Notifications/OrderCapturedNotification.php +++ b/src/Order/Notifications/OrderCapturedNotification.php @@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Notification as NotificationFacade; use Modules\Core\Notification\BaseNotification; use Modules\Core\Order\Events\OrderCaptured; +use Modules\Core\Order\Support\OrderReferenceDisplay; class OrderCapturedNotification extends BaseNotification { @@ -40,10 +41,12 @@ class OrderCapturedNotification extends BaseNotification { $order = $this->event->order; + $reference = OrderReferenceDisplay::resolve($order); + return (new MailMessage) - ->subject(__('Payment captured for your order :reference', ['reference' => $order->reference])) + ->subject(__('Payment captured for your order :reference', ['reference' => $reference])) ->view('core::order.notifications.captured', [ - 'reference' => $order->reference, + 'reference' => $reference, 'amount' => $this->event->transaction->amount->formatted, ]); } diff --git a/src/Order/Notifications/OrderCompletedNotification.php b/src/Order/Notifications/OrderCompletedNotification.php index 14ef7cd..7be46e0 100644 --- a/src/Order/Notifications/OrderCompletedNotification.php +++ b/src/Order/Notifications/OrderCompletedNotification.php @@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Notification as NotificationFacade; use Modules\Core\Notification\BaseNotification; use Modules\Core\Order\Events\OrderCompleted; +use Modules\Core\Order\Support\OrderReferenceDisplay; class OrderCompletedNotification extends BaseNotification { @@ -40,10 +41,12 @@ class OrderCompletedNotification extends BaseNotification { $order = $this->event->order; + $reference = OrderReferenceDisplay::resolve($order); + return (new MailMessage) - ->subject(__('Your order :reference is complete', ['reference' => $order->reference])) + ->subject(__('Your order :reference is complete', ['reference' => $reference])) ->view('core::order.notifications.completed', [ - 'reference' => $order->reference, + 'reference' => $reference, ]); } } diff --git a/src/Order/Notifications/OrderDeliveredNotification.php b/src/Order/Notifications/OrderDeliveredNotification.php index 8c68d47..84931e8 100644 --- a/src/Order/Notifications/OrderDeliveredNotification.php +++ b/src/Order/Notifications/OrderDeliveredNotification.php @@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Notification as NotificationFacade; use Modules\Core\Notification\BaseNotification; use Modules\Core\Order\Events\OrderDelivered; +use Modules\Core\Order\Support\OrderReferenceDisplay; class OrderDeliveredNotification extends BaseNotification { @@ -40,10 +41,12 @@ class OrderDeliveredNotification extends BaseNotification { $order = $this->event->order; + $reference = OrderReferenceDisplay::resolve($order); + return (new MailMessage) - ->subject(__('Your order :reference has been delivered', ['reference' => $order->reference])) + ->subject(__('Your order :reference has been delivered', ['reference' => $reference])) ->view('core::order.notifications.delivered', [ - 'reference' => $order->reference, + 'reference' => $reference, ]); } } diff --git a/src/Order/Notifications/OrderDispatchedNotification.php b/src/Order/Notifications/OrderDispatchedNotification.php index 46bc43c..ac32ed8 100644 --- a/src/Order/Notifications/OrderDispatchedNotification.php +++ b/src/Order/Notifications/OrderDispatchedNotification.php @@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Notification as NotificationFacade; use Modules\Core\Notification\BaseNotification; use Modules\Core\Order\Events\OrderDispatched; +use Modules\Core\Order\Support\OrderReferenceDisplay; /** * Fills a real, previously-unfilled customer-communication gap — before @@ -45,10 +46,12 @@ class OrderDispatchedNotification extends BaseNotification { $order = $this->event->order; + $reference = OrderReferenceDisplay::resolve($order); + return (new MailMessage) - ->subject(__('Your order :reference is on its way', ['reference' => $order->reference])) + ->subject(__('Your order :reference is on its way', ['reference' => $reference])) ->view('core::order.notifications.dispatched', [ - 'reference' => $order->reference, + 'reference' => $reference, ]); } } diff --git a/src/Order/Notifications/OrderPickupReadyNotification.php b/src/Order/Notifications/OrderPickupReadyNotification.php index 49c5844..b3b108b 100644 --- a/src/Order/Notifications/OrderPickupReadyNotification.php +++ b/src/Order/Notifications/OrderPickupReadyNotification.php @@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Notification as NotificationFacade; use Modules\Core\Notification\BaseNotification; use Modules\Core\Order\Events\OrderReadyForPickup; +use Modules\Core\Order\Support\OrderReferenceDisplay; /** * "Your order is ready to collect" — listens to the specific @@ -50,10 +51,12 @@ class OrderPickupReadyNotification extends BaseNotification { $order = $this->event->order; + $reference = OrderReferenceDisplay::resolve($order); + return (new MailMessage) - ->subject(__('Your order :reference is ready for pickup', ['reference' => $order->reference])) + ->subject(__('Your order :reference is ready for pickup', ['reference' => $reference])) ->view('core::order.notifications.pickup-ready', [ - 'reference' => $order->reference, + 'reference' => $reference, ]); } } diff --git a/src/Order/Notifications/OrderPlacedNotification.php b/src/Order/Notifications/OrderPlacedNotification.php index b2138c3..c9d32ba 100644 --- a/src/Order/Notifications/OrderPlacedNotification.php +++ b/src/Order/Notifications/OrderPlacedNotification.php @@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Notification as NotificationFacade; use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Notification\BaseNotification; +use Modules\Core\Order\Support\OrderReferenceDisplay; /** * The order confirmation email — fires once, for every capture_mode and @@ -51,10 +52,12 @@ class OrderPlacedNotification extends BaseNotification { $order = $this->event->order; + $reference = OrderReferenceDisplay::resolve($order); + return (new MailMessage) - ->subject(__('Your order :reference is confirmed', ['reference' => $order->reference])) + ->subject(__('Your order :reference is confirmed', ['reference' => $reference])) ->view('core::order.notifications.placed', [ - 'reference' => $order->reference, + 'reference' => $reference, 'total' => $order->total->formatted, 'lines' => $order->lines, ]); diff --git a/src/Order/Notifications/OrderRefundedNotification.php b/src/Order/Notifications/OrderRefundedNotification.php index b5a4d3e..d494e5f 100644 --- a/src/Order/Notifications/OrderRefundedNotification.php +++ b/src/Order/Notifications/OrderRefundedNotification.php @@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Notification as NotificationFacade; use Modules\Core\Notification\BaseNotification; use Modules\Core\Order\Events\OrderRefunded; +use Modules\Core\Order\Support\OrderReferenceDisplay; class OrderRefundedNotification extends BaseNotification { @@ -40,10 +41,12 @@ class OrderRefundedNotification extends BaseNotification { $order = $this->event->order; + $reference = OrderReferenceDisplay::resolve($order); + return (new MailMessage) - ->subject(__('A refund has been issued for your order :reference', ['reference' => $order->reference])) + ->subject(__('A refund has been issued for your order :reference', ['reference' => $reference])) ->view('core::order.notifications.refunded', [ - 'reference' => $order->reference, + 'reference' => $reference, 'amount' => $this->event->transaction->amount->formatted, ]); } diff --git a/src/Order/Notifications/OrderStatusUpdatedNotification.php b/src/Order/Notifications/OrderStatusUpdatedNotification.php index 099c0a0..ac7dcf9 100644 --- a/src/Order/Notifications/OrderStatusUpdatedNotification.php +++ b/src/Order/Notifications/OrderStatusUpdatedNotification.php @@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Notification as NotificationFacade; use Modules\Core\Notification\BaseNotification; use Modules\Core\Order\Events\OrderStatusUpdated; +use Modules\Core\Order\Support\OrderReferenceDisplay; class OrderStatusUpdatedNotification extends BaseNotification { @@ -52,10 +53,12 @@ class OrderStatusUpdatedNotification extends BaseNotification { $order = $this->event->order; + $reference = OrderReferenceDisplay::resolve($order); + return (new MailMessage) - ->subject(__('Your order :reference has been updated', ['reference' => $order->reference])) + ->subject(__('Your order :reference has been updated', ['reference' => $reference])) ->view('core::order.notifications.status-updated', [ - 'reference' => $order->reference, + 'reference' => $reference, 'statusLabel' => config("lunar.orders.statuses.{$order->status}.label", $order->status), ]); } diff --git a/src/Order/Services/OrderFulfillmentService.php b/src/Order/Services/OrderFulfillmentService.php index 70eec8d..20768af 100644 --- a/src/Order/Services/OrderFulfillmentService.php +++ b/src/Order/Services/OrderFulfillmentService.php @@ -34,6 +34,7 @@ class OrderFulfillmentService private readonly OrderStatusWriter $writer, private readonly OrderStatusFlow $flow, private readonly TransactionRecorder $transactions, + private readonly OrderPaymentResolutionService $resolution, ) {} public function markReady(Order $order): OrderFulfillmentResult @@ -114,9 +115,13 @@ class OrderFulfillmentService } /** - * Independent of `status` entirely — offered by the single "Update - * Status" action regardless of current status (see - * OrderStatusFlow::canMarkPaid()). + * For a COD order, independent of `status` entirely — offered by the + * single "Update Status" action regardless of current status (see + * OrderStatusFlow::canMarkPaid()). For a bank transfer order, status + * genuinely does advance here too (see below) — unlike COD, a bank + * transfer order has been sitting at 'awaiting_payment' since checkout + * (BankTransferPaymentDriver::pay() deliberately never advances it), + * and this click is the only thing that ever will. */ public function markPaid(Order $order): OrderFulfillmentResult { @@ -125,18 +130,20 @@ class OrderFulfillmentService } // canMarkPaid() only ever returns true for an order whose payment - // method resolves to the cash-on-delivery DRIVER (see - // OrderStatusFlow::isCod(), which checks PaymentMethod::driver, - // never the merchant-chosen `type` slug directly — a store could - // name that method "cod", "pay-on-delivery", anything). Such an - // order never runs through Payment's pay()/authorize() flow at - // checkout, so nothing else records a Transaction for it. Money - // changes hands right here, at this click, so this is the one - // place that write can happen; there is no earlier Payment event - // to hang it off of the way Modules\Core\Order\Listeners\ - // RecordPaymentTransaction does for a gateway driver. See - // TransactionRecorder's own docblock — it already anticipated - // exactly this "manually-triggered ... from Filament" call site. + // method resolves to the cash-on-delivery or bank-transfer DRIVER + // (see OrderStatusFlow::isCod()/isBankTransfer(), which check + // PaymentMethod::driver, never the merchant-chosen `type` slug + // directly — a store could name that method "cod", "pay-on-delivery", + // "wire", anything). Neither ever runs a Transaction-recording event + // through to completion at checkout (COD dispatches nothing capture- + // shaped at all; bank transfer's pay() returns Pending with no event + // dispatched — see that driver's own docblock). Money changes hands + // right here, at this click, so this is the one place that write can + // happen; there is no earlier Payment event to hang it off of the way + // Modules\Core\Order\Listeners\RecordPaymentTransaction does for a + // gateway driver. See TransactionRecorder's own docblock — it already + // anticipated exactly this "manually-triggered ... from Filament" + // call site. // // $driver below is the payment method's own `type` slug (whatever // the merchant named it, e.g. 'cash-on-delivery' or 'cod') — @@ -146,19 +153,25 @@ class OrderFulfillmentService // No fallback guess here: CheckoutService::initiatePayment() always // writes Order.meta['payment_method'] before charging, and // canMarkPaid() already guarantees this order got that far. + $type = (string) $order->meta['payment_method']; + $this->transactions->record( $order, type: 'capture', - driver: (string) $order->meta['payment_method'], + driver: $type, result: new PaymentResult( status: PaymentResultStatus::Succeeded, - reference: 'cod-manual-'.$order->id, + reference: "manual-{$type}-{$order->id}", amount: $order->total, ), ); $this->writer->markPaid($order, self::class.'::markPaid'); + if ($this->flow->isBankTransfer($order)) { + $this->resolution->advancePastAwaitingPayment($order, self::class.'::markPaid'); + } + return OrderFulfillmentResult::success('Order marked as paid.'); } diff --git a/src/Order/Services/OrderPaymentResolutionService.php b/src/Order/Services/OrderPaymentResolutionService.php index d990382..8431d8b 100644 --- a/src/Order/Services/OrderPaymentResolutionService.php +++ b/src/Order/Services/OrderPaymentResolutionService.php @@ -92,7 +92,14 @@ class OrderPaymentResolutionService } } - private function advancePastAwaitingPayment(Order $order, string $causeClass): void + /** + * Also called directly by OrderFulfillmentService::markPaid() for a + * bank transfer order — unlike a COD markPaid() (which never touches + * status, since nothing was ever awaited), a bank transfer order + * genuinely sat at 'awaiting_payment' until this moment, and nothing + * else will ever advance it if this doesn't. + */ + public function advancePastAwaitingPayment(Order $order, string $causeClass): void { if ($order->status !== 'awaiting_payment') { return; diff --git a/src/Order/Services/OrderStatusFlow.php b/src/Order/Services/OrderStatusFlow.php index 763289c..f105e2d 100644 --- a/src/Order/Services/OrderStatusFlow.php +++ b/src/Order/Services/OrderStatusFlow.php @@ -54,6 +54,24 @@ class OrderStatusFlow return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery'; } + /** + * Same meta-first/Transaction-fallback resolution as isCod(). Unlike COD + * — where nothing is ever awaited, since payment happens on delivery — + * a bank transfer order genuinely sits at 'awaiting_payment' until staff + * confirm the wire arrived (see BankTransferPaymentDriver's own + * docblock and OrderFulfillmentService::markPaid()). + */ + public function isBankTransfer(Order $order): bool + { + $type = $order->meta['payment_method'] ?? $order->transactions()->latest('id')->value('driver'); + + if ($type === null) { + return false; + } + + return PaymentMethod::where('type', $type)->value('driver') === 'bank-transfer'; + } + /** * @return array value => label — every status in the * order's own branch (carrier or pickup), plus the refund options, @@ -124,13 +142,16 @@ class OrderStatusFlow /** * Whether the "mark paid" option should be offered right now — - * entirely independent of $order->status. True whenever this is a - * cash-on-delivery order and payment hasn't been recorded yet, - * regardless of fulfillment progress (before OR after completed). + * entirely independent of $order->status for a COD order (true whenever + * payment hasn't been recorded yet, regardless of fulfillment progress, + * before OR after completed). A bank transfer order is also eligible, + * for the same "no earlier Payment event recorded this" reason (see + * OrderFulfillmentService::markPaid()), but unlike COD its own status + * genuinely does need advancing once marked paid — see that method. */ public function canMarkPaid(Order $order): bool { - return ! $order->paid && $this->isCod($order); + return ! $order->paid && ($this->isCod($order) || $this->isBankTransfer($order)); } /** diff --git a/src/Order/Support/OrderReferenceDisplay.php b/src/Order/Support/OrderReferenceDisplay.php new file mode 100644 index 0000000..a6e4556 --- /dev/null +++ b/src/Order/Support/OrderReferenceDisplay.php @@ -0,0 +1,26 @@ +reference itself is untouched anywhere else (DB lookups, the + * order-status API, staff search) — this is purely a display helper. + */ +class OrderReferenceDisplay +{ + public static function resolve(Order $order): string + { + $reference = ltrim((string) $order->reference, '0'); + + return $reference !== '' ? $reference : '0'; + } +} diff --git a/src/Payment/Drivers/BankTransferPaymentDriver.php b/src/Payment/Drivers/BankTransferPaymentDriver.php index da8ab1f..1280a9f 100644 --- a/src/Payment/Drivers/BankTransferPaymentDriver.php +++ b/src/Payment/Drivers/BankTransferPaymentDriver.php @@ -9,30 +9,47 @@ use Modules\Core\Payment\Contracts\SupportsPay; use Modules\Core\Payment\Contracts\SupportsRefunds; use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\Enums\PaymentResultStatus; -use Modules\Core\Payment\Events\PaymentCaptured; use Modules\Core\Payment\Events\PaymentRefunded; /** - * Manual/attested, same trust model as OfflinePaymentDriver — there is no - * bank API to call, so both pay() and refund() decide success immediately - * on a staff member's say-so (they've already sent/received the wire - * outside the system). Distinct from OfflinePaymentDriver in intent: this - * exists so a payment taken through a DIFFERENT method (e.g. - * cash-on-delivery) can still be REFUNDED via bank transfer — an admin - * chooses this driver explicitly in the refund action, independent of - * which driver the original payment went through (see + * refund() is manual/attested, same trust model as OfflinePaymentDriver — + * there is no bank API to call, so it decides success immediately on a + * staff member's say-so (they've already sent the wire outside the + * system). Distinct from OfflinePaymentDriver in intent: this exists so a + * payment taken through a DIFFERENT method (e.g. cash-on-delivery) can + * still be REFUNDED via bank transfer — an admin chooses this driver + * explicitly in the refund action, independent of which driver the + * original payment went through (see * Payment\Support\TransactionDriverAdapter::refundVia() and - * Order\Filament\Extensions\OrderActionsExtension). pay() exists so - * the same driver also covers receiving a payment by bank transfer, but - * the admin UI for that (bank reference, notes, proof-of-transfer upload) - * is deliberately not built yet — see the follow-up work tracked from this - * session; pay() itself is complete and usable via the registry today. + * Order\Filament\Extensions\OrderActionsExtension). + * + * pay() is the opposite trust direction from refund(): a bank transfer + * payment requires the money to arrive BEFORE the order can be + * considered paid (unlike cash-on-delivery, where payment happens on + * delivery — see CashOnDeliveryPaymentDriver's own docblock for that + * driver's mirror-image reasoning). So pay() returns Pending, dispatching + * no event at all — no PaymentCaptured (nothing has been paid yet), and + * deliberately NOT PaymentDeferred either (unlike COD, whose + * MarkOrderPlacedOnDeferredPayment listener immediately advances the + * order past 'awaiting_payment' since a COD order has nothing to await at + * checkout). A bank transfer order genuinely DOES have something to + * await: it stays at 'awaiting_payment' with Order::paid false until + * staff confirm the wire arrived via OrderFulfillmentService::markPaid(), + * which — unlike its COD path — also advances the order's status, since + * nothing else ever will (see that method's own docblock). + * CheckoutController::placeOrder() already treats a Pending result with + * no continuation as a fully placed order (see its own docblock), so the + * order is still created and visible to the shopper immediately; only its + * payment/status is what's left outstanding. * * $reference is generated here for the same reason as OfflinePaymentDriver's - * pay(): there is no gateway to hand one back. 'notes' in $context (not - * $data — refund() has no $data parameter) is folded into - * PaymentResult::$meta, which Order\Services\TransactionRecorder::record() - * already writes straight into Transaction.meta with no extra plumbing. + * pay(): there is no gateway to hand one back. refund()'s 'notes' (in + * $context — it has no $data parameter) is folded into PaymentResult::$meta, + * which Order\Services\TransactionRecorder::record() already writes straight + * into Transaction.meta with no extra plumbing; pay() has no equivalent + * write, since nothing ever records a Transaction from its own result (see + * above) — any notes a shopper enters at checkout would need surfacing some + * other way, e.g. when staff mark the order paid. */ class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRefunds { @@ -46,16 +63,11 @@ class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRe public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult { - $result = new PaymentResult( - status: PaymentResultStatus::Succeeded, + return new PaymentResult( + status: PaymentResultStatus::Pending, reference: 'bank-transfer-'.Str::uuid(), amount: $amount, - meta: array_filter(['notes' => $data['notes'] ?? null]), ); - - PaymentCaptured::dispatch($type, $result, $context); - - return $result; } public function refund(string $reference, Price $amount, array $context = []): PaymentResult diff --git a/src/Privacy/Filament/Extensions/CustomerErasureActionsExtension.php b/src/Privacy/Filament/Extensions/CustomerErasureActionsExtension.php index e9eddd7..6d2df32 100644 --- a/src/Privacy/Filament/Extensions/CustomerErasureActionsExtension.php +++ b/src/Privacy/Filament/Extensions/CustomerErasureActionsExtension.php @@ -3,6 +3,7 @@ namespace Modules\Core\Privacy\Filament\Extensions; use Filament\Actions\Action; +use Filament\Actions\DeleteAction; use Filament\Forms\Components\Checkbox; use Filament\Notifications\Notification; 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 * deliberately only implements headerActions(), so it never conflicts with an * 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 { public function headerActions(array $actions): array { return [ - ...$actions, + ...array_filter($actions, fn ($action) => ! $action instanceof DeleteAction), Action::make('requestErasure') ->label('Request Erasure') ->icon('heroicon-o-shield-exclamation') diff --git a/src/Providers/CheckoutModuleServiceProvider.php b/src/Providers/CheckoutModuleServiceProvider.php index 8a0a539..df3a288 100644 --- a/src/Providers/CheckoutModuleServiceProvider.php +++ b/src/Providers/CheckoutModuleServiceProvider.php @@ -18,13 +18,16 @@ use Modules\Core\Cart\Services\CartService; * CheckoutTranslationsSeeder rather than shipped as lang/ files. * * A consuming app wires this module in with: - * 1. `php artisan vendor:publish --tag=core-checkout-assets` — copies - * resources/js/checkout/** and resources/css/checkout.css into the - * host's own resources/ tree. Vite only ever bundles from a host's - * own resources/ directory, so these are published (an explicit, - * host-owned, re-publishable copy) rather than imported cross-package. - * 2. `import { registerCheckout } from './checkout'` in the host's own - * JS entry point, and a @vite entry for the published checkout.css. + * 1. `"@boboko/core": "file:../boboko-core"` as an npm dependency (see this + * package's own package.json `exports`), with a bind-mount of the core + * checkout into the host's Vite container so the `file:` symlink + * resolves in dev (see 3dealer's docker-compose.core-dev.yml) and + * `resolve.preserveSymlinks: true` in the host's vite.config.js so bare + * imports (stimulus, leaflet) still resolve against the host's own + * node_modules through that symlink. + * 2. `import { registerCheckout } from '@boboko/core/checkout'` in the + * host's own JS entry point, and a @vite entry for + * `node_modules/@boboko/core/resources/css/checkout.css`. * 3. `@include('checkout::drawer')` in the host's own layout. * See config/checkout.php for the handful of per-site settings (login * route, single-country mode, ...) a host is expected to publish and diff --git a/src/Providers/StoreServiceProvider.php b/src/Providers/StoreServiceProvider.php new file mode 100644 index 0000000..af603af --- /dev/null +++ b/src/Providers/StoreServiceProvider.php @@ -0,0 +1,16 @@ +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/Store/Events/StoreDetailsUpdated.php b/src/Store/Events/StoreDetailsUpdated.php new file mode 100644 index 0000000..620e9d7 --- /dev/null +++ b/src/Store/Events/StoreDetailsUpdated.php @@ -0,0 +1,19 @@ +form->fill( + app(StoreDetailsService::class)->current()->attributesToArray() + ); + } + + public function content(Schema $schema): Schema + { + return $schema->components([ + Form::make([EmbeddedSchema::make('form')]) + ->id('form') + ->livewireSubmitHandler('save') + ->footer([ + Actions::make($this->getFormActions()) + ->key('form-actions'), + ]), + ]); + } + + public function form(Schema $schema): Schema + { + return $schema + ->statePath('data') + ->components([ + Section::make('Store') + ->schema([ + // Deliberately not ->required(): TranslatedText's own + // state is the whole locale-keyed array, and its + // required-rule generation validates that array + // itself rather than deferring to its per-locale + // children — it fires "required" even when every + // locale sub-field is genuinely filled in. The + // column is nullable and nothing reads it yet, so + // there's no real need to enforce this here. + TranslatedText::make('name') + ->label('Store name'), + TranslatedText::make('address') + ->label('Address'), + TextInput::make('phone') + ->label('Phone') + ->tel(), + ]), + Section::make('Legal') + ->description('Shown on invoices and terms pages.') + ->schema([ + TextInput::make('tax_identifier') + ->label('Tax ID (ΑΦΜ)'), + TextInput::make('registration_number') + ->label('Company registration number (ΓΕΜΗ)'), + ]), + Section::make('Bank transfer') + ->description('Shown to a shopper on the order confirmation page when they chose to pay by bank transfer.') + ->schema([ + // Rich, not plain Textarea — a shop owner may want a + // formatted table (bank name / IBAN / BIC columns) or + // bold text, not just line breaks. RichEditor's + // 'table' toolbar button ships in its default toolbar + // (RichEditor::getDefaultToolbarButtons()), so this + // needs no extra config to get table insert/edit. + TranslatedText::make('bank_transfer_instructions') + ->label('Instructions') + ->optionRichtext(true), + ]), + ]); + } + + protected function getFormActions(): array + { + return [ + Action::make('save') + ->label('Save') + ->submit('save'), + ]; + } + + public function save(): void + { + $state = $this->form->getState(); + + app(StoreDetailsService::class)->update($state); + + Notification::make() + ->title('Store details saved') + ->success() + ->send(); + } +} diff --git a/src/Store/Listeners/FlushStoreDetailsCache.php b/src/Store/Listeners/FlushStoreDetailsCache.php new file mode 100644 index 0000000..a316eaa --- /dev/null +++ b/src/Store/Listeners/FlushStoreDetailsCache.php @@ -0,0 +1,26 @@ + 'array', + 'address' => 'array', + 'bank_transfer_instructions' => 'array', + ]; +} diff --git a/src/Store/Services/StoreDetailsService.php b/src/Store/Services/StoreDetailsService.php new file mode 100644 index 0000000..c5d9452 --- /dev/null +++ b/src/Store/Services/StoreDetailsService.php @@ -0,0 +1,77 @@ +update(...) or $storeDetails->save() directly — a + * write bypassing this service leaves current()'s forever-cache stale. + */ +class StoreDetailsService +{ + public const CACHE_KEY = 'store-details'; + + /** + * Forever-cached — read on every storefront request that shows store + * details (e.g. the checkout confirmation page's bank transfer + * instructions), so this should never re-query the database on a normal + * request. Only ever invalidated by update() below, via + * FlushStoreDetailsCache reacting to StoreDetailsUpdated. + */ + public function current(): StoreDetails + { + return Cache::rememberForever( + self::CACHE_KEY, + fn () => $this->firstOrCreate(), + ); + } + + public function update(array $attributes): StoreDetails + { + $storeDetails = $this->firstOrCreate(); + + $storeDetails->update($attributes); + + Event::dispatch(new StoreDetailsUpdated($storeDetails)); + + return $storeDetails; + } + + /** + * A freshly-created row must never leave a translatable column + * genuinely NULL — Lunar's own TranslatedText component (Modules\Core\ + * Store\Filament\Pages\ManageStoreDetails's `name`/`address`/ + * `bank_transfer_instructions` fields) silently drops every keystroke + * on re-render when the field it's editing starts out NULL rather than + * an empty per-locale array. Real-world precedent (PaymentMethod's own + * translatable `name` column) never hits this, because every + * PaymentMethod row is created THROUGH the same Filament form that + * immediately fills `name` — this singleton is instead created blank + * and opened for editing in the same visit, which is exactly the gap + * that surfaces the bug. Caught and fixed after the fact, verified via + * tinker: seeding a real (non-null) array made typing into the field + * persist correctly, confirming NULL was the trigger. + */ + private function firstOrCreate(): StoreDetails + { + return StoreDetails::query()->firstOrCreate([], [ + 'name' => $this->emptyPerLocale(), + 'address' => $this->emptyPerLocale(), + 'bank_transfer_instructions' => $this->emptyPerLocale(), + ]); + } + + private function emptyPerLocale(): array + { + return Language::query()->pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all(); + } +} 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'); diff --git a/vite-plugin.js b/vite-plugin.js new file mode 100644 index 0000000..41d7288 --- /dev/null +++ b/vite-plugin.js @@ -0,0 +1,59 @@ +// Vite integration for @boboko/core, mirroring how CoreServiceProvider owns +// and ships its own PHP wiring instead of making every consumer hand-copy +// it. A consuming app's vite.config.js just does: +// +// import { boboko } from '@boboko/core/vite-plugin' +// export default defineConfig({ plugins: [..., boboko()] }) +// +// All of the settings below exist only because @boboko/core is typically +// installed as a local `file:../boboko-core` path dependency in dev +// (symlinked into node_modules by npm) rather than a real installed copy — +// see this package's own CONTRIBUTE.md. +export function boboko() { + return { + name: 'boboko-core', + config() { + return { + optimizeDeps: { + // @boboko/core is a live local dependency in dev, not a + // stable third-party lib. Vite's dependency pre-bundler + // otherwise caches it once under node_modules/.vite/deps + // and never re-scans it on a plain source edit, silently + // serving a stale bundle. Excluding it makes Vite treat + // it like first-party source: always transformed live. + exclude: ['@boboko/core'], + // Excluding @boboko/core above means its own dependencies + // (leaflet, @hotwired/stimulus) are no longer discovered + // by Vite's dependency scanner, since that scanner only + // crawls from already-optimized entry points. Without + // this, leaflet is served straight from its raw UMD + // source instead of the pre-bundled ESM shim, and + // `import L from 'leaflet'` fails with "does not provide + // an export named 'default'". Forces pre-bundling + // regardless of how they're reached in the import graph. + include: ['leaflet', '@hotwired/stimulus'], + }, + resolve: { + // The local `file:../boboko-core` form installs as a + // symlink, same as npm always does for a local `file:` + // target. Without this, Vite resolves the symlink's bare + // imports relative to its real path outside the + // consumer's own root, where there's no node_modules, + // instead of from the symlink's location in the + // consumer's own node_modules. Harmless no-op against a + // real installed copy (tagged VCS release). + preserveSymlinks: true, + }, + server: { + watch: { + // Same symlink as above: Vite/chokidar don't follow + // symlinks for watched files by default, so edits to + // core's source wouldn't otherwise trigger HMR. + // No-op against a real installed copy. + followSymlinks: true, + }, + }, + } + }, + } +}