Compare commits

...
36 Commits
Author SHA1 Message Date
arvanitakis 9ce0c625ef Bump version to 0.27.0 2026-09-29 01:10:32 +03:00
arvanitakis 40888bf197 Chore: Adding override for email from 2026-09-29 01:09:57 +03:00
arvanitakis e9d90418fc Fix: Correcting subject on mail status update to display actuall status 2026-09-29 01:03:35 +03:00
arvanitakis 1bcc6acd27 Fix: Guarding against order status, fixing store details render in email 2026-09-29 01:01:39 +03:00
arvanitakis 359b645c62 Feat: Hiding unused shipping types and removing unused fulfillment types 2026-09-29 00:43:48 +03:00
arvanitakis d6c6bf6a1c Fix: Correct shipment resolving 2026-09-29 00:35:29 +03:00
arvanitakis 57d7a716bc Chore: Updates to Notifications 2026-09-28 23:54:24 +03:00
arvanitakis 76b807d672 Feat: Moving Storefront Labels into a proper seeder 2026-09-28 22:34:49 +03:00
arvanitakis 0797e3ab7a Fix: Adding 4 missing translations for Storefront 2026-09-28 22:29:20 +03:00
arvanitakis 5f0dbbb734 Bump version to 0.26.5 2026-09-28 17:36:39 +03:00
arvanitakis 839335ed3f Feat: Adding Contact options to Store Details 2026-09-28 17:36:37 +03:00
arvanitakis 0a6958eb9e Bump version to 0.26.4 2026-09-28 17:10:59 +03:00
arvanitakis d189a7559a Fix: Marking orded placed and setting order as awaiting payment when payment is bank transfer 2026-09-28 17:10:57 +03:00
arvanitakis 7f2ddea240 Bump version to 0.26.3 2026-09-28 13:46:30 +03:00
arvanitakis 45df93692a Chore: Adding Validation Translation Seeder from 3dealer 2026-09-28 13:45:27 +03:00
arvanitakis b806db611f Fix: Adding translation 2026-09-28 13:45:27 +03:00
elvira c1874c277e Bump version to 0.26.2 2026-09-28 13:28:21 +03:00
elvira 05f00ed1fd wishlist route fix 2026-09-28 12:49:41 +03:00
elvira 569eb310e9 Merge branch 'master' of ssh://code.radical-elements.com:2727/boboko/core 2026-09-28 12:32:21 +03:00
elvira 0387b38ab3 minor changes in checkout module css 2026-09-28 12:31:50 +03:00
arvanitakis 0a51d912a0 Bump version to 0.26.1 2026-09-28 11:58:09 +03:00
arvanitakis 1214f9b748 Feat: Adding OrderReferenceDisplay Service that trims down order leading 0s 2026-09-28 11:57:30 +03:00
arvanitakis effbce195c Bump version to 0.26.0 2026-09-28 11:32:20 +03:00
arvanitakis dca6944153 Feat: Adding Store Details Page 2026-09-28 11:30:15 +03:00
arvanitakis 68ebe35b8e Bump version to 0.25.2 2026-09-28 10:16:19 +03:00
arvanitakis ee4d9b7952 Fix: Bank transfer orders should stay in awaiting payment 2026-09-28 10:15:23 +03:00
arvanitakis e5679afa25 Bump version to 0.25.1 2026-09-28 10:05:50 +03:00
arvanitakis a55411aaf2 Fix: Removing Delete button from customer view, deletes should flow only through the privacy services 2026-09-28 10:05:40 +03:00
arvanitakis 088d8cb809 Bump version to 0.25.0 2026-09-28 09:53:05 +03:00
arvanitakis 492447be51 Chore: Extracting Wishlist from 3dealer to Core 2026-09-28 09:52:53 +03:00
arvanitakis e7c896e168 Bump version to 0.24.1 2026-09-28 09:40:35 +03:00
arvanitakis 309602fe93 Fix: Adding missing translations for checkout 2026-09-28 09:02:41 +03:00
arvanitakis 9683a31c5e Bump version to o.24.0 2026-09-28 08:43:14 +03:00
arvanitakis 667e2ea2e5 Feat: Creating vite-plugin.js for boboko/core 2026-09-28 08:41:48 +03:00
arvanitakis fd3bbbe7de Chore: Updating Box Now controllers, updates to product indexer for recommended products 2026-09-25 22:01:55 +03:00
arvanitakis beb7d5faba Bump version to 0.23.0 2026-09-25 22:01:21 +03:00
73 changed files with 2983 additions and 520 deletions
+41
View File
@@ -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"
]
}
}
+319
View File
@@ -4,8 +4,326 @@ 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.27.0] - 2026-09-29
### Added
- `Modules\Core\Shipping\Contracts\SupportsCashCollection` — a shipping driver
implements this to say a person is physically present at handoff to collect
cash. `AcsRateDriver` implements it (a courier can); `BoxNowRateDriver` and
the new `StorePickupRateDriver` don't (an unattended locker or a store
clerk deciding to accept cash isn't the same fact as a courier collecting
it on delivery). `Modules\Core\Checkout\Services\CheckoutService::
getPaymentMethods()` now checks this for cash-on-delivery specifically, so
COD is no longer offered alongside Box Now — `RequiresFulfillmentType`
alone couldn't distinguish "an unattended carrier" from "an attended one,"
both being `carrier`.
- `Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver` — a
first-party replacement for `lunarphp/table-rate-shipping`'s own
`Collection` driver, following the same shape as `AcsRateDriver`/
`BoxNowRateDriver`. Fixes two bugs the vendor driver had: it read
`ShippingMethod->name` directly instead of decoding the translatable JSON
column, so the storefront showed the raw JSON blob instead of a
translated name; and it never checked product exclusions the same way
the vendor's own generic drivers did.
- `Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts` — shared
product-exclusion check (a shipping zone's configured "this product can't
ship via this method" list), now applied consistently by every
first-party driver (ACS, Box Now, Store Pickup). Previously only Lunar's
own generic drivers honored exclusions at all — ACS and Box Now silently
ignored them.
- `Modules\Core\Localization\Database\Seeders\StorefrontTranslationsSeeder`
— the `storefront` translation group is now seeded the same way
`checkout`/`validation` already are (a dedicated, idempotent seeder run
from the entrypoint), rather than as a private method inside
`InstallLunarCommand`. `Modules\Core\Localization\Services\
StorefrontLabels` is removed; its ~160 lines moved into the new seeder.
- `StoreDetailsService::bankTransferInstructionsFor()` — sanitized HTML for
a bank transfer order's confirmation email, gated on
`OrderStatusFlow::isBankTransfer()`.
- `StoreServiceProvider::applyMailFromName()` — listens for
`Illuminate\Mail\Events\MessageSending` and renames the From header's
display name to Store Details' `mail_from_name`, when set, for every
outgoing email app-wide (order notifications, OTP codes, contact form
confirmations, etc.), no per-mailable changes needed. Only touches a
message whose From address still matches `config('mail.from.address')`
— the sending address itself is untouched (a deliverability/domain-
authentication concern, not a merchant-editable text field), and a
mailable/notification that already set its own explicit sender is left
alone entirely.
- `Order.meta['locale']` — the shopper's locale at checkout, saved by
`CheckoutService::initiatePayment()` so every order notification renders
in the locale they actually checked out in (via `Notification::locale()`
in `NotificationRegistry::register()`), independent of whatever locale
happens to be active when the notification is actually sent (a payment
webhook, a queued job, a staff action).
- `Order.meta['coupon_code']` — copied from the cart at checkout, so the
confirmation email always shows the code the order was actually placed
with, rather than reading the cart row (which may since have been
cleared/reused).
- Contact details on the storefront and product confirmation emails now
accept `$order` directly (all 8 order notification views), removing two
workarounds: `placed.blade.php` no longer reaches the order through
`$lines->first()->order`, and `status-updated.blade.php` no longer
reverse-searches a status key from its English label.
### Changed
- The vendor's own generic shipping drivers (`free-shipping`, `flat-rate`,
`ship-by`, `collection`) are no longer offered at all — every shipping
method now uses a first-party driver (ACS, Box Now, or Store Pickup),
each of which declares its own fulfillment type. The `data.fulfillment_type`
Select on the shipping method form is removed, along with the
merchant-facing fallback it existed for — every registered driver now
hardcodes this fact about itself rather than a merchant configuring it
per row.
- `OrderCapturedNotification` ("payment captured") no longer sends for any
payment method except bank transfer. For every other method, placing the
order and paying for it are the same moment from the shopper's
perspective — `OrderPlacedNotification` already covers it. Bank transfer
is the one case where payment confirmation is a genuine, later, separate
event (staff marking the order paid once the wire arrives).
- `OrderStatusUpdatedNotification` no longer sends for an order's first
transition off `awaiting_payment`, except for a bank transfer order —
for every other payment method that transition happens in the same
request as checkout itself, so it's not new information the shopper
hasn't already been told.
- `OrderStatusUpdatedNotification`'s subject line now names the order's
actual current status ("Your order :reference is now :status") instead
of a generic "has been updated."
- The checkout page's payment methods now refresh live when the shipping
option changes, instead of requiring a full page reload — switching to
store pickup correctly drops cash-on-delivery and offers "pay in store"
immediately.
### Fixed
- `StoreDetailsService::bankTransferInstructionsFor()` returned the raw
Tiptap JSON array `bank_transfer_instructions` is stored as, instead of
rendering it to HTML — the order confirmation email for a bank transfer
order failed outright (`TypeError`, queued job marked `FAIL`) rather than
showing the store's payment instructions. Now converts via Filament's own
`RichContentRenderer`, the same converter the admin panel itself uses to
render a `RichEditor` field's content read-only.
## [0.26.5] - 2026-09-28
### Added
- Three new fields on the "Store Details" settings page
(`Modules\Core\Store\Filament\Pages\ManageStoreDetails`):
- **Contact email** (`contact_email`) — used both as the receiver address for the
contact form and shown to customers as the store's contact email.
- **Mail from name** (`mail_from_name`) — the sender name on outgoing emails
(`MAIL_FROM_NAME`).
- **Legal name** (`legal_name`) — the registered company's legal name, distinct
from the storefront-facing `name`. Locale-keyed JSON, like `name`/`address`/
`bank_transfer_instructions`, since a registered name can legitimately differ
per locale.
`contact_email`/`mail_from_name` are plain nullable strings, matching
`phone`/`tax_identifier`. The migration backfills all four translatable
`store_details` columns with an empty per-locale array on any row where they're
still `NULL`, and `StoreDetailsService::firstOrCreate()` now seeds `legal_name`
the same way it already seeds `name`/`address`/`bank_transfer_instructions` —
both needed so `TranslatedText` doesn't silently drop keystrokes on a
NULL-starting translatable field (see that service's own docblock).
## [0.26.4] - 2026-09-28
### Fixed
- Bank transfer orders were never marked placed. `BankTransferPaymentDriver::pay()` had
dead code after an early `return` and never dispatched `PaymentDeferred`, so nothing
ever set `Order::placed_at` or fired `OrderPlaced` — no order confirmation email, no
stock decrement, the order missing from the customer's order history, and checkout
erroring out instead of showing the confirmation page. `pay()` now dispatches
`PaymentDeferred` the same way `CashOnDeliveryPaymentDriver::pay()` does.
- `MarkOrderPlacedOnDeferredPayment` now skips
`OrderPaymentResolutionService::resolveDeferredPayment()` for a bank transfer order
(`OrderStatusFlow::isBankTransfer()`), so it correctly stays at `awaiting_payment` /
unpaid until staff mark it paid, instead of being advanced past that status the moment
checkout completes.
## [0.26.3] - 2026-09-28
### Added
- `Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder` — moved in from
3dealer's own `database/seeders/`, alongside the existing
`Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder`, so the `validation` and
`checkout` translation groups are seeded from core rather than duplicated per consuming app.
Same idempotent per-key upsert as `lunar:install`'s own seeding — a key already present (or
since edited via the Filament Language Lines UI) is left untouched. Both are run explicitly
via `php artisan db:seed --class=...`, wired into 3dealer's entrypoint right after
`lunar:install`.
- `storefront.auth.captcha_failed` — was referenced by `HCaptcha`, `LoginController`, and
`ContactRequest` but never seeded in `StorefrontLabels`, so it silently fell back to the raw
key. Re-running `lunar:install` now fills it in for any existing store.
## [0.26.2] - 2026-09-28
### Changed
- Checkout line-item custom fields (`checkout/partials/line-custom-fields.blade.php`):
non-file fields now render inline as a single muted `Label: value` line instead of a
separate `<dt>`/`<dd>` pair. File fields keep the stacked label/link layout, with a
colon after the label and a small top margin on the value.
### Fixed
- Wishlist routes are now registered inside the `web` middleware group
(`WishlistServiceProvider`). Before, `loadRoutesFrom()` loaded them with no middleware,
so they had no session, cookies, CSRF protection or authenticated user. That broke
anything that depends on the current guest or logged-in user.
## [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 +418,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\
+50
View File
@@ -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:
+4 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.22.0",
"version": "0.27.0",
"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"
]
}
},
@@ -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');
}
};
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A single-row table for the store's own contact/legal details — edited via
* the Filament "Store Details" settings page (Modules\Core\Store\Filament\
* Pages\ManageStoreDetails) and read via Modules\Core\Store\Services\
* StoreDetailsService. Not config, since a shop owner needs to change these
* (e.g. a new IBAN, a new address) without a code deploy.
*
* name/address/bank_transfer_instructions are locale-keyed JSON — same
* shape/resolution as Modules\Core\Payment\Models\PaymentMethod::$name (see
* that model's own docblock): $storeDetails->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');
}
};
@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Lunar\Models\Language;
/**
* contact_email/mail_from_name join tax_identifier/registration_number/
* phone as plain, non-locale-dependent strings on the store_details
* singleton (see that table's own migration docblock).
*
* legal_name is locale-keyed JSON instead — same shape/resolution as
* name/address/bank_transfer_instructions (HasTranslations, see
* StoreDetails's own docblock) — since a registered company name can
* legitimately differ per locale (e.g. a transliterated/translated legal
* form). Distinct from the storefront-facing brand name in `name`.
*
* Backfills every translatable column (name/address/
* bank_transfer_instructions/legal_name) with an empty per-locale array
* on any row that's still genuinely NULL there — StoreDetailsService::
* firstOrCreate() only seeds columns on INSERT, so an existing singleton
* row (or one created before a Language row existed, leaving
* emptyPerLocale() an empty array at the time) could otherwise keep a
* translatable column NULL forever. That's the exact condition
* TranslatedText breaks on (see StoreDetailsService's own docblock: a
* NULL-starting translatable field silently drops every keystroke and
* never persists) — backfilled here for all four columns, not just the
* new one, so the same fix covers however the existing row got there.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('store_details', function (Blueprint $table) {
$table->string('contact_email')->nullable()->after('phone');
$table->string('mail_from_name')->nullable()->after('contact_email');
$table->json('legal_name')->nullable()->after('registration_number');
});
$emptyPerLocale = json_encode(
Language::query()->pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all()
);
foreach (['name', 'address', 'bank_transfer_instructions', 'legal_name'] as $column) {
DB::table('store_details')->whereNull($column)->update([$column => $emptyPerLocale]);
}
}
public function down(): void
{
Schema::table('store_details', function (Blueprint $table) {
$table->dropColumn(['contact_email', 'mail_from_name', 'legal_name']);
});
}
};
+57
View File
@@ -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/<module>`.
This mirrors the PHP story above exactly: Composer already gives every environment one single, unconditional path — `vendor/boboko/<module>` — 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/<module>` 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/<module> is a symlink into ../boboko-<module> 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/<module>/resources/js/...` — no extra config needed, as long as `vendor/boboko/<module>` 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`:**
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@boboko/core",
"version": "0.27.0",
"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"
}
}
+137 -2
View File
@@ -223,13 +223,13 @@ a.bbk-cart-item-title:hover { text-decoration: underline; }
font-size: 0.8125rem;
}
.bbk-line-field span,
.bbk-line-field dt {
color: var(--bbk-color-muted);
}
.bbk-line-field dd {
margin: 0;
white-space: pre-line;
margin: 3px 0 0 0;
overflow-wrap: anywhere;
}
@@ -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 {
@@ -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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="46" viewBox="0 0 34 46">
<path
d="M17 0C7.6 0 0 7.6 0 17c0 12.75 17 29 17 29s17-16.25 17-29C34 7.6 26.4 0 17 0Z"
fill="${color}"
stroke="#ffffff"
stroke-width="1.5"
/>
<circle cx="17" cy="17" r="7" fill="#ffffff" />
</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: '&copy; 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 `
<div class="bbk-box-now-popup">
${locker.image ? `<img class="bbk-box-now-popup-image" src="${locker.image}" alt="">` : ''}
<p class="bbk-box-now-popup-name">${locker.name}</p>
<p class="bbk-box-now-popup-address">
${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')}
${locker.postalCode ? ` ${locker.postalCode}` : ''}
</p>
${locker.note ? `<p class="bbk-box-now-popup-note">${locker.note}</p>` : ''}
<button
type="button"
class="bbk-box-now-popup-select${isSelected ? ' bbk-box-now-popup-select--selected' : ''}"
data-locker-id="${locker.id}"
${isSelected ? 'disabled' : ''}
>
${isSelected ? this.selectedLabelValue : this.selectLabelValue}
</button>
</div>
`
}
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
}
}
@@ -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 <select> (see shipping-options.blade.php)
// lives inside the #bbk-shipping-options fragment this controller
// re-renders wholesale on every shipping-option change — so its own
// Stimulus controller reconnects fresh each time and has no memory of
// which option was previously selected. This is the one place that
// knows the newly-chosen option's identifier, so it also owns
// showing/hiding the picker.
toggleBoxNowLocker(identifier) {
const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker')
if (!picker) return
const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker')
if (identifier === 'box-now') {
controller?.show()
} else {
controller?.hide()
}
}
async doSelectShipping(value) {
this.saveController?.abort()
this.setStatus('saving')
@@ -170,6 +192,16 @@ export default class extends Controller {
detail: { html: data.summaryHtml },
}))
}
// bbk-payment is a sibling controller (both sit on
// .bbk-checkout-main), not a target of this one — dispatched as an
// event rather than reached into directly, same shape as
// bbk-cart:changed above.
if (data.paymentMethodsHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-checkout:payment-methods-changed', {
detail: { html: data.paymentMethodsHtml },
}))
}
}
applyErrors(errors) {
@@ -17,7 +17,7 @@ const POLL_TIMEOUT = 30000
// poll /order-status until the webhook places it
// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable
export default class extends Controller {
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText']
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText', 'methods']
static values = {
selectUrl: String,
@@ -56,11 +56,26 @@ export default class extends Controller {
}
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)
// Fired by bbk-checkout-form after a shipping-option change —
// getPaymentMethods() filters by fulfillment type, so the offered
// methods (and which one, if any, is still validly selected) can
// change without this controller's own element ever reconnecting.
this.onPaymentMethodsChanged = (event) => {
const html = event.detail?.html
if (html == null || !this.hasMethodsTarget) return
this.methodsTarget.innerHTML = html
if (!this.selectedIsStripe()) this.unmountStripe()
}
window.addEventListener('bbk-checkout:payment-methods-changed', this.onPaymentMethodsChanged)
if (this.selectedIsStripe()) this.mountStripe()
}
disconnect() {
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
window.removeEventListener('bbk-checkout:payment-methods-changed', this.onPaymentMethodsChanged)
this.unmountStripe()
}
+3
View File
@@ -1,4 +1,5 @@
import BbkAddToCartController from './bbk-add-to-cart-controller'
import BbkBoxNowLockerController from './bbk-box-now-locker-controller'
import BbkCartController from './bbk-cart-controller'
import BbkCheckoutFormController from './bbk-checkout-form-controller'
import BbkPaymentController from './bbk-payment-controller'
@@ -12,7 +13,9 @@ import BbkPaymentController from './bbk-payment-controller'
// When this module moves to boboko-core this file ships with it unchanged;
// only that one import line in the host entry point differs per project.
export function registerCheckout(application) {
console.log('[@boboko/core] checkout module loaded from', import.meta.url, '- test 2')
application.register('bbk-add-to-cart', BbkAddToCartController)
application.register('bbk-box-now-locker', BbkBoxNowLockerController)
application.register('bbk-cart', BbkCartController)
application.register('bbk-checkout-form', BbkCheckoutFormController)
application.register('bbk-payment', BbkPaymentController)
+10
View File
@@ -0,0 +1,10 @@
// Single stable JS entry point for this package. A consuming app imports
// from here (vendor/boboko/core/resources/js/index.js), never from a path
// reaching into a specific module's internals — so this file's exports can
// grow or its modules' internal layout can change without breaking every
// consumer's own entry point.
//
// stoic_embed.js is not re-exported here: per its own docblock, it's a
// standalone vendored script meant to be included directly, not imported.
export { registerCheckout } from './checkout/index.js'
export { registerWishlist } from './wishlist/index.js'
+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
}
}
}
@@ -14,7 +14,7 @@
<dl class="bbk-confirmation-meta">
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
<dd>{{ $order->reference }}</dd>
<dd>{{ \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order) }}</dd>
</div>
@if ($order->billingAddress?->contact_email)
+1 -1
View File
@@ -219,7 +219,7 @@
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
<div id="bbk-payment-methods">
<div id="bbk-payment-methods" data-bbk-payment-target="methods">
@include('checkout::partials.payment-methods', [
'paymentMethods' => $paymentMethods,
'cart' => $cart,
@@ -19,9 +19,9 @@
<dl class="bbk-line-fields">
@foreach ($fields as $field)
<div class="bbk-line-field">
<dt>{{ $field['label'] }}</dt>
<dd>
@if ($field['type'] === 'file')
@if ($field['type'] === 'file')
<dt>{{ $field['label'] }}:</dt>
<dd>
@php
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
@endphp
@@ -39,11 +39,12 @@
@endif
<span>{{ $file->original_name }}</span>
</a>
@endif
@else
{{ $field['value'] }}
@endif
</dd>
</dd>
@else
<span>{{ $field['label'] }}: {{ $field['value'] }}</span>
@endif
</div>
@endforeach
</dl>
@@ -48,3 +48,59 @@
@endforeach
</div>
@endif
{{--
Dummy Box Now locker picker — a Leaflet map standing in for Box Now's own
Destination Map JS widget, which only talks to their Production API (not
Stage/sandbox — see their Partner API manual §4.1), making it useless
for local/staging development. Backed by the same GET /destinations data
(including lat/lng) via CheckoutController::boxNowLockers(). Only shown
once the "box-now" shipping option is selected (bbk-checkout-form
toggles [hidden] on shipping-option change; see bbk-box-now-locker
Stimulus controller). Persists the choice via a separate autosave POST
(checkout.box-now.locker.select) rather than piggybacking on the
shipping-option field, since the two are independent pieces of state
(method vs. destination) that CheckoutService models as two calls
(selectShippingOption() / selectBoxNowLocker()).
--}}
<div
id="bbk-box-now-locker"
class="bbk-checkout-box-now-locker"
data-controller="bbk-box-now-locker"
data-bbk-box-now-locker-lockers-url-value="{{ route('checkout.box-now.lockers', app()->getLocale()) }}"
data-bbk-box-now-locker-select-url-value="{{ route('checkout.box-now.locker.select', app()->getLocale()) }}"
data-bbk-box-now-locker-loading-value="{{ __('checkout.page.box_now_locker_loading') }}"
data-bbk-box-now-locker-select-label-value="{{ __('checkout.page.box_now_locker_select') }}"
data-bbk-box-now-locker-selected-label-value="{{ __('checkout.page.box_now_locker_selected') }}"
data-bbk-box-now-locker-no-results-value="{{ __('checkout.page.box_now_locker_no_results') }}"
@if ($selected !== 'box-now') hidden @endif
>
<p class="bbk-checkout-box-now-locker-label">
{{ __('checkout.page.box_now_locker_label') }}
</p>
<input
type="search"
class="bbk-checkout-box-now-locker-search"
placeholder="{{ __('checkout.page.box_now_locker_search') }}"
data-bbk-box-now-locker-target="search"
data-action="input->bbk-box-now-locker#search"
autocomplete="off"
>
<div
id="bbk-box-now-locker-map"
class="bbk-checkout-box-now-locker-map"
data-bbk-box-now-locker-target="map"
></div>
<p
class="bbk-checkout-box-now-locker-chosen"
data-bbk-box-now-locker-target="chosen"
hidden
></p>
<p
class="bbk-checkout-status"
data-bbk-box-now-locker-target="status"
role="status"
aria-live="polite"
hidden
></p>
</div>
+10 -1
View File
@@ -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)
+17
View File
@@ -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();
@@ -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 για αυτή την αναζήτηση.',
]
];
}
}
@@ -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
@@ -573,6 +646,18 @@ class CheckoutController extends Controller
])->render(),
// Composer (Providers\CheckoutModuleServiceProvider) fills $cart / $lines.
'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(),
// getPaymentMethods() filters by the cart's CURRENT fulfillment
// type (Modules\Core\Checkout\Services\CheckoutService's own
// docblock) — a shipping-option change can change that filter's
// result (e.g. switching to store pickup should drop
// cash-on-delivery and offer "pay in store" instead), so this
// needs to be re-rendered every time shipping options are,
// otherwise the payment section silently goes stale until a
// full page reload.
'paymentMethodsHtml' => $options === null ? null : view('checkout::partials.payment-methods', [
'paymentMethods' => $this->checkout->getPaymentMethods(),
'cart' => $cart,
])->render(),
]);
}
+53 -19
View File
@@ -5,6 +5,7 @@ namespace Modules\Core\Checkout\Services;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Event;
use Lunar\Base\Addressable;
use Lunar\DataTypes\ShippingOption;
@@ -21,11 +22,13 @@ 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 Lunar\Shipping\Facades\Shipping;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
use Modules\Core\Shipping\Support\FulfillmentType;
/**
@@ -253,42 +256,59 @@ class CheckoutService
* a courier delivery makes no sense (no staff member present at
* handoff to take cash), and cash-on-delivery alongside store
* pickup is equally meaningless (OfflinePaymentDriver already
* covers that in-person moment). A cart with no shipping option
* selected yet imposes no constraint here — every method is
* offered until a fulfillment type is actually known, the same
* leniency setShippingAddress()'s own docblock describes for
* required-field enforcement happening at the payment gate, not
* mid-checkout.
* covers that in-person moment).
* 5. for cash-on-delivery specifically, the cart's currently selected
* shipping method's own driver implements Modules\Core\Shipping\
* Contracts\SupportsCashCollection — "carrier" alone
* (RequiresFulfillmentType) isn't precise enough, since an
* unattended parcel locker network (Modules\Core\Shipping\
* Carriers\BoxNow\BoxNowRateDriver) is a carrier delivery with
* nobody there to collect cash, unlike an actual courier
* (Modules\Core\Shipping\Carriers\Acs\AcsRateDriver).
* Checks 4 and 5 both impose no constraint when the cart has no
* shipping option selected yet — every method is offered until a
* shipping method is actually known, the same leniency
* setShippingAddress()'s own docblock describes for required-field
* enforcement happening at the payment gate, not mid-checkout.
*
* @return Collection<int, PaymentMethod>
*/
public function getPaymentMethods(): Collection
{
$fulfillmentType = $this->currentFulfillmentType();
$shippingMethod = $this->currentShippingMethod();
$fulfillmentType = $shippingMethod ? FulfillmentType::resolve($shippingMethod) : null;
return $this->paymentMethods->all()
->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null)
->filter(function (PaymentMethod $method) use ($fulfillmentType) {
->filter(function (PaymentMethod $method) use ($fulfillmentType, $shippingMethod) {
$driver = $this->paymentDrivers->resolve($method->driver);
if (! $driver?->isConfigured()) {
return false;
}
if ($fulfillmentType === null || ! $driver instanceof RequiresFulfillmentType) {
return true;
if ($fulfillmentType !== null && $driver instanceof RequiresFulfillmentType
&& $driver->requiredFulfillmentType() !== $fulfillmentType) {
return false;
}
return $driver->requiredFulfillmentType() === $fulfillmentType;
// collect(...)->get() rather than Shipping::driver(), which
// throws for an unregistered key — a stale ShippingMethod
// row (e.g. still pointing at a removed generic driver)
// must fail this check quietly, the same as any other
// driver this module doesn't recognize.
if ($shippingMethod !== null && $method->driver === 'cash-on-delivery') {
$shippingDriver = collect(Shipping::getSupportedDrivers())->get($shippingMethod->driver);
return $shippingDriver instanceof SupportsCashCollection && $shippingDriver->collectsCash();
}
return true;
})
->values();
}
/**
* @return 'carrier'|'store_pickup'|null null when the cart has no
* shipping option selected yet
*/
private function currentFulfillmentType(): ?string
private function currentShippingMethod(): ?ShippingMethod
{
$identifier = $this->cart->currentOrCreate()->shippingAddress?->shipping_option;
@@ -296,9 +316,7 @@ class CheckoutService
return null;
}
$method = ShippingMethod::where('code', $identifier)->first();
return $method ? FulfillmentType::resolve($method) : null;
return ShippingMethod::where('code', $identifier)->first();
}
/**
@@ -431,6 +449,22 @@ class CheckoutService
'terms_accepted' => true,
'terms_accepted_at' => now()->toIso8601String(),
'terms_accepted_policy_version' => $policyVersion,
// Order.locale doesn't exist as a column — saved here so every
// order notification (OrderPaymentResolutionService/
// MarkOrderPlacedOnDeferredPayment fire OrderPlaced synchronously
// in THIS request, but a later one — e.g. a Stripe 3-D Secure
// webhook, or a staff status change from Filament — has no
// request-scoped locale of its own) can render in the locale the
// shopper actually checked out in, not whatever locale (or none)
// happens to be active when the notification is sent. See
// NotificationRegistry::register(), which reads this back.
'locale' => App::getLocale(),
// Lunar copies the discount onto Order.discount_breakdown but not
// the coupon CODE itself — that stays on the cart row, which may
// since have been cleared/reused for a new cart. Copied here so
// the confirmation email always shows what code THIS order was
// actually placed with.
'coupon_code' => $cart->coupon_code,
];
$order->save();
+8
View File
@@ -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');
+2 -34
View File
@@ -18,9 +18,6 @@ use Lunar\Models\Product;
use Lunar\Models\ProductType;
use Lunar\Models\TaxClass;
use Lunar\Models\TaxZone;
use Modules\Core\Localization\Models\LanguageLine;
use Modules\Core\Localization\Services\StorefrontLabels;
use Modules\Core\Localization\Services\TranslationService;
use Modules\Core\Payment\Models\PaymentMethod;
/**
@@ -35,7 +32,7 @@ class InstallLunarCommand extends Command
protected $description = 'Seed the default Lunar store data (countries, channel, currency, tax zone, attributes, product type)';
public function handle(TranslationService $translations): void
public function handle(): void
{
$this->components->info('Seeding default Lunar store data...');
@@ -255,9 +252,6 @@ class InstallLunarCommand extends Command
}
});
$this->components->info('Seeding storefront label translations');
$this->seedStorefrontLabels($translations);
$this->components->info('Seeding payment method settings');
$this->seedPaymentMethods();
@@ -267,32 +261,6 @@ class InstallLunarCommand extends Command
$this->components->info('Lunar default data seeded.');
}
/**
* Per-key upsert, not an all-or-nothing "only seed if the group is empty" guard —
* a key already present in the database (including one an admin has since edited
* via the Filament Languages resource) is left untouched; only keys missing
* entirely are created. This is what makes it safe to add new keys to
* StorefrontLabels later and re-run this on an already-installed store without
* either skipping the new keys (the old all-or-nothing guard) or reverting an
* admin's edits back to the hardcoded default (a naive updateOrCreate would).
*/
private function seedStorefrontLabels(TranslationService $translations): void
{
$labels = StorefrontLabels::all();
$existingKeys = LanguageLine::where('group', 'storefront')
->whereIn('key', array_keys($labels))
->pluck('key');
foreach ($labels as $key => $text) {
if ($existingKeys->contains($key)) {
continue;
}
$translations->create('storefront', $key, $text);
}
}
/**
* A single, deliberately opinionated starter row on fresh install —
* `PaymentMethod` is now fully admin-creatable/deletable (see
@@ -304,7 +272,7 @@ class InstallLunarCommand extends Command
* order status should be called; that's a merchant decision.
*
* Skip-if-exists on `type`, same idempotent convention as
* seedStorefrontLabels() — an admin who has since edited or deleted
* StorefrontTranslationsSeeder — an admin who has since edited or deleted
* this row (via the Filament Payment Methods resource) is left alone;
* re-running lunar:install never recreates a deleted starter row.
*
+4
View File
@@ -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([
@@ -0,0 +1,314 @@
<?php
namespace Modules\Core\Localization\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
/**
* Default `storefront` translation lines — nav, cart, product, shop, auth,
* account, orders, contact, reviews, wishlist (see the storefront views
* under resources/views for where each is used).
*
* Additive and idempotent: a group/key that already exists is left untouched,
* so anything edited in the Filament Language Lines UI wins on a re-run. Runs
* explicitly — `php artisan db:seed --class="Modules\Core\Localization\
* Database\Seeders\StorefrontTranslationsSeeder"` — it is not wired into any
* app's own DatabaseSeeder.
*
* Greek copy uses an informal register (εσύ/σου) — a consuming app with a
* different house style overrides individual lines from the Filament
* Language Lines UI same as any other translation, rather than forking
* this class.
*/
class StorefrontTranslationsSeeder extends Seeder
{
public function run(): void
{
$translations = app(TranslationService::class);
foreach ($this->lines() as $key => [$en, $el]) {
$exists = LanguageLine::query()
->where('group', 'storefront')
->where('key', $key)
->exists();
if ($exists) {
$this->command?->warn("storefront.{$key} already exists — skipped");
continue;
}
$translations->create('storefront', $key, ['en' => $en, 'el' => $el]);
$this->command?->info("storefront.{$key} added");
}
}
/**
* key => [English, Greek].
*
* @return array<string, array{0: string, 1: string}>
*/
private function lines(): array
{
return [
// ── Navigation ──────────────────────────────────────────────
'nav.home' => ['Home', 'Αρχική'],
'nav.products' => ['Products', 'Προϊόντα'],
'nav.cart' => ['Cart', 'Καλάθι'],
'nav.account' => ['Account', 'Λογαριασμός'],
'nav.back' => ['Back', 'Πίσω'],
'nav.contact' => ['Contact', 'Επικοινωνία'],
'nav.close' => ['Close', 'Κλείσιμο'],
// ── Cart ────────────────────────────────────────────────────
'cart.empty' => ['Your cart is empty', 'Το καλάθι σας είναι άδειο'],
'cart.checkout' => ['Checkout', 'Ολοκλήρωση Παραγγελίας'],
'cart.total' => ['Total', 'Σύνολο'],
'cart.remove' => ['Remove', 'Αφαίρεση'],
// ── Product ─────────────────────────────────────────────────
'product.add_to_cart' => ['Add to Cart', 'Προσθήκη στο Καλάθι'],
'product.out_of_stock' => ['Out of Stock', 'Εξαντλήθηκε'],
'product.sold' => ['Sold', 'Εξαντλήθηκε'],
'product.price' => ['Price', 'Τιμή'],
'product.description' => ['Description', 'Περιγραφή'],
'product.no_image' => ['No image', 'Χωρίς εικόνα'],
'product.read_more' => ['Read more', 'Περισσότερα'],
'product.reviews' => ['Reviews', 'Αξιολογήσεις'],
'product.related' => ['You may also like', 'Μπορεί επίσης να σου αρέσει'],
// ── Auth (login link) ───────────────────────────────────────
'auth.login' => ['Log In', 'Σύνδεση'],
'auth.logout' => ['Log Out', 'Αποσύνδεση'],
// ── Search ──────────────────────────────────────────────────
'search.placeholder' => ['Search products…', 'Αναζήτηση προϊόντων…'],
'search.results_for' => ['Search results for ', 'Αποτελέσματα αναζήτησης για '],
'customer_reviews' => [
'{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
'{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
],
// ── Pagination ──────────────────────────────────────────────
'pagination.nav_label' => ['Pagination', 'Σελιδοποίηση'],
'pagination.next' => ['Next page', 'Επόμενη σελίδα'],
'pagination.previous' => ['Previous page', 'Προηγούμενη σελίδα'],
'pagination.page' => ['Page :page', 'Σελίδα :page'],
// ── Reviews ─────────────────────────────────────────────────
'review.rating' => ['Rating', 'Βαθμολογία'],
'review.write_label' => ['Write a review', 'Γράψε μια αξιολόγηση'],
'review.name' => ['Name', 'Όνομα'],
'review.name_optional' => ['Optional', 'Προαιρετικό'],
'review.email' => ['Email', 'Email'],
'review.email_not_published' => ['Will not be published', 'Δεν θα δημοσιευτεί'],
'review.save_info' => [
'Save my name and email for the next time I comment.',
'Αποθήκευσε το όνομα και το email μου για την επόμενη φορά που θα σχολιάσω.',
],
'review.submit' => ['Submit', 'Υποβολή'],
'review.stars_count' => ['{1} :count star|[2,*] :count stars', '{1} :count αστέρι|[2,*] :count αστέρια'],
'review.no_reviews_yet' => ['No reviews yet.', 'Δεν υπάρχουν αξιολογήσεις ακόμα.'],
'review.write_first' => ['Write the first review', 'Γράψε την πρώτη'],
'review.write_new' => ['Add a review', 'Πρόσθεσε μια'],
'review.for_product' => ['review for ":name"', 'αξιολόγηση για το «:name»'],
'review.rating_required' => ['Please select a rating.', 'Παρακαλούμε επίλεξε βαθμολογία.'],
'review.reply' => ['Reply', 'Απάντηση'],
'review.thank_you' => ['Thanks for your review!', 'Ευχαριστούμε για την αξιολόγησή σου!'],
// ── Shop / listing page ─────────────────────────────────────
'shop.showing_results' => [
'{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
'{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
],
'shop.all_products' => ['All Products', 'Όλα τα Προϊόντα'],
'shop.sort_label' => ['Sort products', 'Ταξινόμηση προϊόντων'],
'shop.sort_default' => ['Default sorting', 'Προεπιλεγμένη ταξινόμηση'],
'shop.sort_popularity' => ['Popularity', 'Δημοφιλή'],
'shop.sort_price_asc' => ['Price: Low to High', 'Τιμή: Αύξουσα'],
'shop.sort_price_desc' => ['Price: High to Low', 'Τιμή: Φθίνουσα'],
'shop.sort_newest' => ['Newest', 'Νεότερα'],
'shop.no_products' => ['No products found in this category.', 'Δεν βρέθηκαν προϊόντα σε αυτή την κατηγορία.'],
'shop.search_label' => ['Search products', 'Αναζήτηση προϊόντων'],
'shop.search_placeholder' => ['Search products…', 'Αναζήτησε προϊόντα…'],
'shop.filter_price' => ['Filter by price', 'Φίλτρο τιμής'],
'shop.price_min' => ['Min price', 'Ελάχιστη τιμή'],
'shop.price_max' => ['Max price', 'Μέγιστη τιμή'],
'shop.reset' => ['Reset', 'Επαναφορά'],
'shop.apply' => ['Apply', 'Εφαρμογή'],
'shop.availability' => ['Availability', 'Διαθεσιμότητα'],
'shop.in_stock_only' => ['In-stock products only', 'Μόνο διαθέσιμα προϊόντα'],
// ── Passwordless login (Auth\Services\UserOtpService) ───────
'auth.login_intro' => [
'Enter your email and we\'ll send you a code to sign in — no password needed.',
'Γράψε το email σου και θα σου στείλουμε έναν κωδικό σύνδεσης — δεν χρειάζεται κωδικός πρόσβασης.',
],
'auth.email' => ['Email', 'Email'],
'auth.terms_notice' => [
'By continuing, you accept the <a href=":terms">Terms of Use</a> and have read the <a href=":privacy">Privacy Policy</a>.',
'Συνεχίζοντας, αποδέχεσαι τους <a href=":terms">Όρους Χρήσης</a> και έχεις διαβάσει την <a href=":privacy">Πολιτική Απορρήτου</a>.',
],
'auth.send_code' => ['Send code', 'Αποστολή κωδικού'],
'auth.enter_code' => ['Enter the code', 'Εισάγετε τον κωδικό'],
'auth.code_sent_to' => ['We sent a code to', 'Στείλαμε έναν κωδικό στο'],
'auth.code' => ['Code', 'Κωδικός'],
'auth.resend_code' => ['Resend code', 'Επαναποστολή κωδικού'],
'auth.code_resent' => ['A new code was sent.', 'Στάλθηκε νέος κωδικός.'],
'auth.change_email' => ['Use a different email', 'Χρήση διαφορετικού email'],
'auth.invalid_code' => ['That code is invalid or has expired.', 'Ο κωδικός δεν είναι έγκυρος ή έχει λήξει.'],
'auth.too_many_codes' => [
'Too many attempts. Please wait a few minutes and try again.',
'Πολλές προσπάθειες. Περίμενε λίγα λεπτά και ξαναδοκίμασε.',
],
'auth.captcha_failed' => [
'Please complete the captcha and try again.',
'Παρακαλούμε ολοκλήρωσε το captcha και ξαναδοκίμασε.',
],
// ── Account profile page (account/show.blade.php) ───────────
'account.nav_profile' => ['Profile', 'Προφίλ'],
'account.nav_orders' => ['Orders', 'Παραγγελίες'],
'account.nav_wishlist' => ['Wishlist', 'Λίστα επιθυμιών'],
'account.email_heading' => ['Login email', 'Email σύνδεσης'],
'account.email_change' => ['Change email', 'Αλλαγή email'],
'account.details_heading' => ['Your details', 'Τα στοιχεία σου'],
'account.first_name' => ['First name', 'Όνομα'],
'account.last_name' => ['Last name', 'Επώνυμο'],
'account.invoice' => ['I need an invoice', 'Χρειάζομαι τιμολόγιο'],
'account.company_name' => ['Company name', 'Επωνυμία εταιρείας'],
'account.tax_identifier' => ['Tax ID (VAT)', 'ΑΦΜ'],
'account.address_heading' => ['Address', 'Διεύθυνση'],
'account.line_one' => ['Address', 'Διεύθυνση'],
'account.city' => ['City', 'Πόλη'],
'account.postcode' => ['Postcode', 'Ταχυδρομικός κώδικας'],
'account.state' => ['Region', 'Περιοχή'],
'account.state_placeholder' => ['Select a region', 'Επίλεξε περιοχή'],
'account.phone' => ['Phone', 'Τηλέφωνο'],
'account.emails_heading' => ['Emails', 'Ειδοποιήσεις email'],
'account.recovery_consent' => [
'Email me a reminder if I don\'t finish my order',
'Στείλε μου υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
],
'account.save' => ['Save changes', 'Αποθήκευση'],
'account.saved' => ['Your details were saved.', 'Τα στοιχεία σου αποθηκεύτηκαν.'],
'account.delete_heading' => ['Delete account', 'Διαγραφή λογαριασμού'],
'account.delete_text' => [
'This permanently deletes your account and personal data. This cannot be undone.',
'Αυτό διαγράφει οριστικά τον λογαριασμό και τα προσωπικά σου δεδομένα. Δεν μπορεί να αναιρεθεί.',
],
'account.delete' => ['Delete my account', 'Διαγραφή λογαριασμού'],
'account.delete_confirm_heading' => ['Are you sure?', 'Είσαι σίγουρος/η;'],
'account.delete_confirm_text' => [
'This cannot be undone. Your account and personal data will be permanently deleted.',
'Αυτό δεν μπορεί να αναιρεθεί. Ο λογαριασμός και τα προσωπικά σου δεδομένα θα διαγραφούν οριστικά.',
],
'account.delete_confirm' => ['Yes, delete my account', 'Ναι, διαγραφή λογαριασμού'],
'account.delete_cancel' => ['Cancel', 'Ακύρωση'],
'account.deletion_requested' => [
'Your account deletion has been requested.',
'Ζητήθηκε η διαγραφή του λογαριασμού σου.',
],
// ── Account email-change flow (account/email.blade.php, account/email-code.blade.php) ──
'account.email_change_heading' => ['Change your email', 'Αλλαγή email'],
'account.email_current' => ['Your current email is', 'Το τρέχον email σου είναι'],
'account.email_new' => ['New email', 'Νέο email'],
'account.email_new_hint' => [
'We\'ll send a code to this address to confirm it\'s yours.',
'Θα στείλουμε έναν κωδικό σε αυτή τη διεύθυνση για να επιβεβαιώσουμε ότι είναι δική σου.',
],
'account.email_confirm' => ['Confirm', 'Επιβεβαίωση'],
'account.email_same' => ['That\'s already your current email.', 'Αυτό είναι ήδη το τρέχον email σου.'],
'account.email_taken' => [
'That email address is already in use.',
'Αυτή η διεύθυνση email χρησιμοποιείται ήδη.',
],
'account.email_changed' => ['Your email was changed.', 'Το email σου άλλαξε.'],
// ── Order history (account/orders/index.blade.php, account/orders/show.blade.php) ──
'orders.empty' => ['You have no orders yet.', 'Δεν έχεις παραγγελίες ακόμα.'],
'orders.shop_now' => ['Shop now', 'Αγόρασε τώρα'],
'orders.date' => ['Date', 'Ημερομηνία'],
'orders.number' => ['Order', 'Παραγγελία'],
'orders.status' => ['Status', 'Κατάσταση'],
'orders.total' => ['Total', 'Σύνολο'],
'orders.view' => ['View', 'Προβολή'],
'orders.view_order' => ['View order :number', 'Προβολή παραγγελίας :number'],
'orders.order_title' => ['Order :number', 'Παραγγελία :number'],
'orders.back' => ['Back to orders', 'Πίσω στις παραγγελίες'],
'orders.payment' => ['Payment method', 'Τρόπος πληρωμής'],
'orders.shipping_method' => ['Shipping method', 'Τρόπος αποστολής'],
'orders.tracking' => ['Tracking', 'Παρακολούθηση αποστολής'],
'orders.items' => ['Items', 'Προϊόντα'],
'orders.subtotal' => ['Subtotal', 'Μερικό σύνολο'],
'orders.discount' => ['Discount', 'Έκπτωση'],
'orders.shipping' => ['Shipping', 'Μεταφορικά'],
'orders.tax' => ['Tax', 'ΦΠΑ'],
'orders.shipping_to' => ['Shipping to', 'Αποστολή σε'],
'orders.billing' => ['Billing details', 'Στοιχεία τιμολόγησης'],
// ── Contact details (components/contact-details.blade.php) ──
'contact.address' => ['Address', 'Διεύθυνση'],
'contact.phone' => ['Phone', 'Τηλέφωνο'],
// ── Contact form (contact.blade.php, ContactController, emails.contact-confirmation) ──
'contact.sent' => [
'Your message was sent — we\'ll get back to you soon.',
'Το μήνυμά σου στάλθηκε — θα σου απαντήσουμε σύντομα.',
],
'contact.send_failed' => [
'Something went wrong sending your message. Please try again.',
'Κάτι πήγε στραβά κατά την αποστολή. Παρακαλούμε δοκίμασε ξανά.',
],
'contact.too_many' => [
'Too many messages sent. Please wait a while before trying again.',
'Στάλθηκαν πολλά μηνύματα. Περίμενε λίγο πριν ξαναδοκιμάσεις.',
],
'contact.confirmation_subject' => ['We received your message', 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_preheader' => [
'Thanks for reaching out — here\'s a copy of your message.',
'Ευχαριστούμε για την επικοινωνία — εδώ είναι ένα αντίγραφο του μηνύματός σου.',
],
'contact.confirmation_heading' => ['We received your message', 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_body' => [
'Thanks for getting in touch. We\'ll reply as soon as we can.',
'Ευχαριστούμε που επικοινώνησες μαζί μας. Θα απαντήσουμε το συντομότερο δυνατό.',
],
'contact.confirmation_footer' => [
'This is a copy of the message you sent us.',
'Αυτό είναι ένα αντίγραφο του μηνύματος που μας έστειλες.',
],
// ── Product page — custom fields, add-to-cart failure (product/show.blade.php,
// components/product-custom-fields.blade.php) ─────────────
'product.personalize' => ['Personalize', 'Εξατομίκευση'],
'product.custom_field_photo_hint' => ['Max file size: :size MB.', 'Μέγιστο μέγεθος αρχείου: :size MB.'],
'product.custom_field_uploading' => ['Uploading…', 'Μεταφόρτωση…'],
'product.custom_field_upload_failed' => [
'Upload failed. Please try again.',
'Η μεταφόρτωση απέτυχε. Παρακαλούμε δοκίμασε ξανά.',
],
'product.add_to_cart_failed' => [
'{0} Sorry, that\'s out of stock|{1} Only :count left in stock|[2,*] Only :count left in stock',
'{0} Λυπούμαστε, εξαντλήθηκε|{1} Απομένει μόνο :count κομμάτι|[2,*] Απομένουν μόνο :count κομμάτια',
],
// ── Wishlist (components/wishlist-button.blade.php, wishlist/guest.blade.php, wishlist/list.blade.php) ──
'wishlist.add' => ['Add to wishlist', 'Προσθήκη στη λίστα επιθυμιών'],
'wishlist.remove' => ['Remove from wishlist', 'Αφαίρεση από τη λίστα επιθυμιών'],
'wishlist.added' => ['Added to wishlist', 'Προστέθηκε στη λίστα επιθυμιών'],
'wishlist.removed' => ['Removed from wishlist', 'Αφαιρέθηκε από τη λίστα επιθυμιών'],
'wishlist.empty' => ['Your wishlist is empty.', 'Η λίστα επιθυμιών σου είναι άδεια.'],
'wishlist.guest_hint' => [
'Log in to keep your wishlist across devices.',
'Συνδέσου για να κρατήσεις τη λίστα επιθυμιών σου σε όλες τις συσκευές.',
],
'wishlist.remove_named' => ['Remove :name from wishlist', 'Αφαίρεση :name από τη λίστα επιθυμιών'],
'wishlist.remove_short' => ['Remove', 'Αφαίρεση'],
];
}
}
@@ -0,0 +1,114 @@
<?php
namespace Modules\Core\Localization\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
/**
* Default `validation` translation lines — Laravel's own error-message group
* (`validation.required`, `validation.email`, `validation.attributes.*`, …),
* resolved by every `Validator::make()`/`$request->validate()` call in a
* consuming app (checkout addresses, cart, product reviews, stock checks).
*
* Spatie's DB loader (spatie/laravel-translation-loader, wired in
* LocalizationServiceProvider) merges this group over Laravel's file-based
* validation.php — without this, Greek requests fall back to Laravel's
* untranslated English defaults. Only the rule keys and field attributes
* actually in use are seeded; add more as new rules/fields show up.
*
* Additive and idempotent: a key that already exists is left untouched, so
* anything edited in the Filament Language Lines UI wins on a re-run. Runs
* explicitly — `php artisan db:seed --class="Modules\Core\Localization\
* Database\Seeders\ValidationTranslationsSeeder"` — it is not wired into any
* app's own DatabaseSeeder.
*
* Greek copy uses the project's informal register (εσύ/σου).
*/
class ValidationTranslationsSeeder extends Seeder
{
public function run(): void
{
$translations = app(TranslationService::class);
foreach ($this->lines() as $key => [$en, $el]) {
$exists = LanguageLine::query()
->where('group', 'validation')
->where('key', $key)
->exists();
if ($exists) {
$this->command?->warn("validation.{$key} already exists — skipped");
continue;
}
$translations->create('validation', $key, ['en' => $en, 'el' => $el]);
$this->command?->info("validation.{$key} added");
}
}
/**
* key => [English, Greek].
*
* @return array<string, array{0: string, 1: string}>
*/
private function lines(): array
{
return [
// ── Rule messages ─────────────────────────────────────────────
'required' => ['The :attribute field is required.', 'Το πεδίο :attribute είναι υποχρεωτικό.'],
'email' => ['The :attribute field must be a valid email address.', 'Το πεδίο :attribute πρέπει να είναι έγκυρη διεύθυνση email.'],
'string' => ['The :attribute field must be a string.', 'Το πεδίο :attribute πρέπει να είναι κείμενο.'],
'integer' => ['The :attribute field must be an integer.', 'Το πεδίο :attribute πρέπει να είναι ακέραιος αριθμός.'],
'boolean' => ['The :attribute field must be true or false.', 'Το πεδίο :attribute πρέπει να είναι true ή false.'],
'min.numeric' => ['The :attribute field must be at least :min.', 'Το πεδίο :attribute πρέπει να είναι τουλάχιστον :min.'],
'max.string' => ['The :attribute field must not be greater than :max characters.', 'Το πεδίο :attribute δεν πρέπει να ξεπερνά τους :max χαρακτήρες.'],
'between.numeric' => ['The :attribute field must be between :min and :max.', 'Το πεδίο :attribute πρέπει να είναι μεταξύ :min και :max.'],
'exists' => ['The selected :attribute is invalid.', 'Η επιλεγμένη τιμή για το πεδίο :attribute δεν είναι έγκυρη.'],
// Product custom-field photo uploads (CustomFieldUploadController, CartController).
'file' => ['The :attribute field must be a file.', 'Το πεδίο :attribute πρέπει να είναι αρχείο.'],
'mimes' => ['The :attribute field must be a file of type: :values.', 'Το πεδίο :attribute πρέπει να είναι αρχείο τύπου: :values.'],
'max.file' => ['The :attribute field must not be greater than :max kilobytes.', 'Το αρχείο στο πεδίο :attribute δεν πρέπει να ξεπερνά τα :max kilobytes.'],
'uploaded' => ['The :attribute failed to upload.', 'Η μεταφόρτωση στο πεδίο :attribute απέτυχε.'],
// ── Field names (checkout: billing/shipping address) ──────────
'attributes.contact_email' => ['email', 'email'],
'attributes.billing_first_name' => ['first name', 'όνομα'],
'attributes.billing_last_name' => ['last name', 'επώνυμο'],
'attributes.billing_company_name' => ['company name', 'επωνυμία εταιρείας'],
'attributes.billing_tax_identifier' => ['tax ID', 'ΑΦΜ'],
'attributes.billing_line_one' => ['address', 'διεύθυνση'],
'attributes.billing_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'],
'attributes.billing_city' => ['city', 'πόλη'],
'attributes.billing_state' => ['region', 'νομό / περιοχή'],
'attributes.billing_postcode' => ['postcode', 'ταχυδρομικό κώδικα'],
'attributes.billing_country_id' => ['country', 'χώρα'],
'attributes.billing_contact_phone' => ['phone', 'τηλέφωνο'],
'attributes.shipping_first_name' => ['first name', 'όνομα'],
'attributes.shipping_last_name' => ['last name', 'επώνυμο'],
'attributes.shipping_company_name' => ['company name', 'επωνυμία εταιρείας'],
'attributes.shipping_line_one' => ['address', 'διεύθυνση'],
'attributes.shipping_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'],
'attributes.shipping_city' => ['city', 'πόλη'],
'attributes.shipping_state' => ['region', 'νομό / περιοχή'],
'attributes.shipping_postcode' => ['postcode', 'ταχυδρομικό κώδικα'],
'attributes.shipping_country_id' => ['country', 'χώρα'],
'attributes.shipping_contact_phone' => ['phone', 'τηλέφωνο'],
'attributes.shipping_delivery_instructions' => ['delivery notes', 'σχόλια για την παράδοση'],
// ── Field names (cart) ─────────────────────────────────────────
'attributes.purchasable_id' => ['product', 'προϊόν'],
'attributes.quantity' => ['quantity', 'ποσότητα'],
'attributes.code' => ['coupon code', 'κωδικό κουπονιού'],
// ── Field names (product reviews / stock check) ────────────────
'attributes.variant' => ['variant', 'παραλλαγή'],
'attributes.rating' => ['rating', 'βαθμολογία'],
'attributes.content' => ['review text', 'κείμενο κριτικής'],
'attributes.name' => ['name', 'όνομα'],
'attributes.email' => ['email', 'email'],
];
}
}
@@ -1,264 +0,0 @@
<?php
namespace Modules\Core\Localization\Services;
/**
* Default storefront UI label translations (group `storefront`), seeded by
* Modules\Core\Command\InstallLunarCommand. Kept as its own class, separate from
* the seeding logic, so the actual label list can be scanned/diffed without wading
* through the upsert mechanics — see InstallLunarCommand::seedStorefrontLabels()
* for how (and how safely) these get written.
*/
class StorefrontLabels
{
/**
* @return array<string, array<string, string>> keyed by `group.key` dot-notation,
* each value a locale => text map (`en`/`el`).
*/
public static function all(): array
{
return [
'nav.home' => ['en' => 'Home', 'el' => 'Αρχική'],
'nav.products' => ['en' => 'Products', 'el' => 'Προϊόντα'],
'nav.cart' => ['en' => 'Cart', 'el' => 'Καλάθι'],
'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'],
'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'],
'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'],
'nav.close' => ['en' => 'Close', 'el' => 'Κλείσιμο'],
'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'],
'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'],
'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
'cart.remove' => ['en' => 'Remove', 'el' => 'Αφαίρεση'],
'product.add_to_cart' => ['en' => 'Add to Cart', 'el' => 'Προσθήκη στο Καλάθι'],
'product.out_of_stock' => ['en' => 'Out of Stock', 'el' => 'Εξαντλήθηκε'],
'product.price' => ['en' => 'Price', 'el' => 'Τιμή'],
'product.description' => ['en' => 'Description', 'el' => 'Περιγραφή'],
'product.no_image' => ['en' => 'No image', 'el' => 'Χωρίς εικόνα'],
'product.read_more' => ['en' => 'Read more', 'el' => 'Περισσότερα'],
'product.reviews' => ['en' => 'Reviews', 'el' => 'Αξιολογήσεις'],
'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'],
'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'],
'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'],
'search.results_for' => ['en' => 'Search results for ', 'el' => 'Αποτελέσματα αναζήτησης για '],
'customer_reviews' => [
'en' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
'el' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
],
'pagination.nav_label' => ['en' => 'Pagination', 'el' => 'Σελιδοποίηση'],
'pagination.next' => ['en' => 'Next page', 'el' => 'Επόμενη σελίδα'],
'pagination.previous' => ['en' => 'Previous page', 'el' => 'Προηγούμενη σελίδα'],
'pagination.page' => ['en' => 'Page :page', 'el' => 'Σελίδα :page'],
'review.rating' => ['en' => 'Rating', 'el' => 'Βαθμολογία'],
'review.write_label' => ['en' => 'Write a review', 'el' => 'Γράψε μια αξιολόγηση'],
'review.name' => ['en' => 'Name', 'el' => 'Όνομα'],
'review.name_optional' => ['en' => 'Optional', 'el' => 'Προαιρετικό'],
'review.email' => ['en' => 'Email', 'el' => 'Email'],
'review.email_not_published' => ['en' => 'Will not be published', 'el' => 'Δεν θα δημοσιευτεί'],
'review.save_info' => [
'en' => 'Save my name and email for the next time I comment.',
'el' => 'Αποθήκευσε το όνομα και το email μου για την επόμενη φορά που θα σχολιάσω.',
],
'review.submit' => ['en' => 'Submit', 'el' => 'Υποβολή'],
'review.stars_count' => ['en' => '{1} :count star|[2,*] :count stars', 'el' => '{1} :count αστέρι|[2,*] :count αστέρια'],
'review.no_reviews_yet' => ['en' => 'No reviews yet.', 'el' => 'Δεν υπάρχουν αξιολογήσεις ακόμα.'],
'review.write_first' => ['en' => 'Write the first review', 'el' => 'Γράψε την πρώτη'],
'review.write_new' => ['en' => 'Add a review', 'el' => 'Πρόσθεσε μια'],
'review.for_product' => ['en' => 'review for ":name"', 'el' => 'αξιολόγηση για το «:name»'],
'shop.showing_results' => [
'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
],
'shop.all_products' => ['en' => 'All Products', 'el' => 'Όλα τα Προϊόντα'],
'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'],
'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'],
'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'],
'shop.sort_price_asc' => ['en' => 'Price: Low to High', 'el' => 'Τιμή: Αύξουσα'],
'shop.sort_price_desc' => ['en' => 'Price: High to Low', 'el' => 'Τιμή: Φθίνουσα'],
'shop.sort_newest' => ['en' => 'Newest', 'el' => 'Νεότερα'],
'shop.no_products' => ['en' => 'No products found in this category.', 'el' => 'Δεν βρέθηκαν προϊόντα σε αυτή την κατηγορία.'],
'shop.search_label' => ['en' => 'Search products', 'el' => 'Αναζήτηση προϊόντων'],
'shop.search_placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτησε προϊόντα…'],
'shop.filter_price' => ['en' => 'Filter by price', 'el' => 'Φίλτρο τιμής'],
'shop.price_min' => ['en' => 'Min price', 'el' => 'Ελάχιστη τιμή'],
'shop.price_max' => ['en' => 'Max price', 'el' => 'Μέγιστη τιμή'],
'shop.reset' => ['en' => 'Reset', 'el' => 'Επαναφορά'],
'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'],
'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'],
'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'],
// Passwordless login (Auth\Services\UserOtpService)
'auth.login_intro' => [
'en' => 'Enter your email and we\'ll send you a code to sign in — no password needed.',
'el' => 'Γράψε το email σου και θα σου στείλουμε έναν κωδικό σύνδεσης — δεν χρειάζεται κωδικός πρόσβασης.',
],
'auth.email' => ['en' => 'Email', 'el' => 'Email'],
'auth.terms_notice' => [
'en' => 'By continuing, you accept the <a href=":terms">Terms of Use</a> and have read the <a href=":privacy">Privacy Policy</a>.',
'el' => 'Συνεχίζοντας, αποδέχεσαι τους <a href=":terms">Όρους Χρήσης</a> και έχεις διαβάσει την <a href=":privacy">Πολιτική Απορρήτου</a>.',
],
'auth.send_code' => ['en' => 'Send code', 'el' => 'Αποστολή κωδικού'],
'auth.enter_code' => ['en' => 'Enter the code', 'el' => 'Εισάγετε τον κωδικό'],
'auth.code_sent_to' => ['en' => 'We sent a code to', 'el' => 'Στείλαμε έναν κωδικό στο'],
'auth.code' => ['en' => 'Code', 'el' => 'Κωδικός'],
'auth.resend_code' => ['en' => 'Resend code', 'el' => 'Επαναποστολή κωδικού'],
'auth.code_resent' => ['en' => 'A new code was sent.', 'el' => 'Στάλθηκε νέος κωδικός.'],
'auth.change_email' => ['en' => 'Use a different email', 'el' => 'Χρήση διαφορετικού email'],
'auth.invalid_code' => ['en' => 'That code is invalid or has expired.', 'el' => 'Ο κωδικός δεν είναι έγκυρος ή έχει λήξει.'],
'auth.too_many_codes' => [
'en' => 'Too many attempts. Please wait a few minutes and try again.',
'el' => 'Πολλές προσπάθειες. Περίμενε λίγα λεπτά και ξαναδοκίμασε.',
],
// Account profile page (account/show.blade.php)
'account.nav_profile' => ['en' => 'Profile', 'el' => 'Προφίλ'],
'account.nav_orders' => ['en' => 'Orders', 'el' => 'Παραγγελίες'],
'account.nav_wishlist' => ['en' => 'Wishlist', 'el' => 'Λίστα επιθυμιών'],
'account.email_heading' => ['en' => 'Login email', 'el' => 'Email σύνδεσης'],
'account.email_change' => ['en' => 'Change email', 'el' => 'Αλλαγή email'],
'account.details_heading' => ['en' => 'Your details', 'el' => 'Τα στοιχεία σου'],
'account.first_name' => ['en' => 'First name', 'el' => 'Όνομα'],
'account.last_name' => ['en' => 'Last name', 'el' => 'Επώνυμο'],
'account.invoice' => ['en' => 'I need an invoice', 'el' => 'Χρειάζομαι τιμολόγιο'],
'account.company_name' => ['en' => 'Company name', 'el' => 'Επωνυμία εταιρείας'],
'account.tax_identifier' => ['en' => 'Tax ID (VAT)', 'el' => 'ΑΦΜ'],
'account.address_heading' => ['en' => 'Address', 'el' => 'Διεύθυνση'],
'account.line_one' => ['en' => 'Address', 'el' => 'Διεύθυνση'],
'account.city' => ['en' => 'City', 'el' => 'Πόλη'],
'account.postcode' => ['en' => 'Postcode', 'el' => 'Ταχυδρομικός κώδικας'],
'account.state' => ['en' => 'Region', 'el' => 'Περιοχή'],
'account.state_placeholder' => ['en' => 'Select a region', 'el' => 'Επίλεξε περιοχή'],
'account.phone' => ['en' => 'Phone', 'el' => 'Τηλέφωνο'],
'account.emails_heading' => ['en' => 'Emails', 'el' => 'Ειδοποιήσεις email'],
'account.recovery_consent' => [
'en' => 'Email me a reminder if I don\'t finish my order',
'el' => 'Στείλε μου υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
],
'account.save' => ['en' => 'Save changes', 'el' => 'Αποθήκευση'],
'account.saved' => ['en' => 'Your details were saved.', 'el' => 'Τα στοιχεία σου αποθηκεύτηκαν.'],
'account.delete_heading' => ['en' => 'Delete account', 'el' => 'Διαγραφή λογαριασμού'],
'account.delete_text' => [
'en' => 'This permanently deletes your account and personal data. This cannot be undone.',
'el' => 'Αυτό διαγράφει οριστικά τον λογαριασμό και τα προσωπικά σου δεδομένα. Δεν μπορεί να αναιρεθεί.',
],
'account.delete' => ['en' => 'Delete my account', 'el' => 'Διαγραφή λογαριασμού'],
'account.delete_confirm_heading' => ['en' => 'Are you sure?', 'el' => 'Είσαι σίγουρος/η;'],
'account.delete_confirm_text' => [
'en' => 'This cannot be undone. Your account and personal data will be permanently deleted.',
'el' => 'Αυτό δεν μπορεί να αναιρεθεί. Ο λογαριασμός και τα προσωπικά σου δεδομένα θα διαγραφούν οριστικά.',
],
'account.delete_confirm' => ['en' => 'Yes, delete my account', 'el' => 'Ναι, διαγραφή λογαριασμού'],
'account.delete_cancel' => ['en' => 'Cancel', 'el' => 'Ακύρωση'],
'account.deletion_requested' => [
'en' => 'Your account deletion has been requested.',
'el' => 'Ζητήθηκε η διαγραφή του λογαριασμού σου.',
],
// Account email-change flow (account/email.blade.php, account/email-code.blade.php)
'account.email_change_heading' => ['en' => 'Change your email', 'el' => 'Αλλαγή email'],
'account.email_current' => ['en' => 'Your current email is', 'el' => 'Το τρέχον email σου είναι'],
'account.email_new' => ['en' => 'New email', 'el' => 'Νέο email'],
'account.email_new_hint' => [
'en' => 'We\'ll send a code to this address to confirm it\'s yours.',
'el' => 'Θα στείλουμε έναν κωδικό σε αυτή τη διεύθυνση για να επιβεβαιώσουμε ότι είναι δική σου.',
],
'account.email_confirm' => ['en' => 'Confirm', 'el' => 'Επιβεβαίωση'],
'account.email_same' => [
'en' => 'That\'s already your current email.',
'el' => 'Αυτό είναι ήδη το τρέχον email σου.',
],
'account.email_taken' => [
'en' => 'That email address is already in use.',
'el' => 'Αυτή η διεύθυνση email χρησιμοποιείται ήδη.',
],
'account.email_changed' => ['en' => 'Your email was changed.', 'el' => 'Το email σου άλλαξε.'],
// Order history (account/orders/index.blade.php, account/orders/show.blade.php)
'orders.empty' => ['en' => 'You have no orders yet.', 'el' => 'Δεν έχεις παραγγελίες ακόμα.'],
'orders.shop_now' => ['en' => 'Shop now', 'el' => 'Αγόρασε τώρα'],
'orders.date' => ['en' => 'Date', 'el' => 'Ημερομηνία'],
'orders.number' => ['en' => 'Order', 'el' => 'Παραγγελία'],
'orders.status' => ['en' => 'Status', 'el' => 'Κατάσταση'],
'orders.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
'orders.view' => ['en' => 'View', 'el' => 'Προβολή'],
'orders.view_order' => ['en' => 'View order :number', 'el' => 'Προβολή παραγγελίας :number'],
'orders.order_title' => ['en' => 'Order :number', 'el' => 'Παραγγελία :number'],
'orders.back' => ['en' => 'Back to orders', 'el' => 'Πίσω στις παραγγελίες'],
'orders.payment' => ['en' => 'Payment method', 'el' => 'Τρόπος πληρωμής'],
'orders.shipping_method' => ['en' => 'Shipping method', 'el' => 'Τρόπος αποστολής'],
'orders.tracking' => ['en' => 'Tracking', 'el' => 'Παρακολούθηση αποστολής'],
'orders.items' => ['en' => 'Items', 'el' => 'Προϊόντα'],
'orders.subtotal' => ['en' => 'Subtotal', 'el' => 'Μερικό σύνολο'],
'orders.discount' => ['en' => 'Discount', 'el' => 'Έκπτωση'],
'orders.shipping' => ['en' => 'Shipping', 'el' => 'Μεταφορικά'],
'orders.tax' => ['en' => 'Tax', 'el' => 'ΦΠΑ'],
'orders.shipping_to' => ['en' => 'Shipping to', 'el' => 'Αποστολή σε'],
'orders.billing' => ['en' => 'Billing details', 'el' => 'Στοιχεία τιμολόγησης'],
// Contact form (contact.blade.php, ContactController, emails.contact-confirmation)
'contact.sent' => [
'en' => 'Your message was sent — we\'ll get back to you soon.',
'el' => 'Το μήνυμά σου στάλθηκε — θα σου απαντήσουμε σύντομα.',
],
'contact.send_failed' => [
'en' => 'Something went wrong sending your message. Please try again.',
'el' => 'Κάτι πήγε στραβά κατά την αποστολή. Παρακαλούμε δοκίμασε ξανά.',
],
'contact.too_many' => [
'en' => 'Too many messages sent. Please wait a while before trying again.',
'el' => 'Στάλθηκαν πολλά μηνύματα. Περίμενε λίγο πριν ξαναδοκιμάσεις.',
],
'contact.confirmation_subject' => ['en' => 'We received your message', 'el' => 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_preheader' => [
'en' => 'Thanks for reaching out — here\'s a copy of your message.',
'el' => 'Ευχαριστούμε για την επικοινωνία — εδώ είναι ένα αντίγραφο του μηνύματός σου.',
],
'contact.confirmation_heading' => ['en' => 'We received your message', 'el' => 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_body' => [
'en' => 'Thanks for getting in touch. We\'ll reply as soon as we can.',
'el' => 'Ευχαριστούμε που επικοινώνησες μαζί μας. Θα απαντήσουμε το συντομότερο δυνατό.',
],
'contact.confirmation_footer' => [
'en' => 'This is a copy of the message you sent us.',
'el' => 'Αυτό είναι ένα αντίγραφο του μηνύματος που μας έστειλες.',
],
// Product page — custom fields, add-to-cart failure (product/show.blade.php,
// components/product-custom-fields.blade.php)
'product.personalize' => ['en' => 'Personalize', 'el' => 'Εξατομίκευση'],
'product.custom_field_photo_hint' => [
'en' => 'Max file size: :size MB.',
'el' => 'Μέγιστο μέγεθος αρχείου: :size MB.',
],
'product.custom_field_uploading' => ['en' => 'Uploading…', 'el' => 'Μεταφόρτωση…'],
'product.custom_field_upload_failed' => [
'en' => 'Upload failed. Please try again.',
'el' => 'Η μεταφόρτωση απέτυχε. Παρακαλούμε δοκίμασε ξανά.',
],
'product.add_to_cart_failed' => [
'en' => '{0} Sorry, that\'s out of stock|{1} Only :count left in stock|[2,*] Only :count left in stock',
'el' => '{0} Λυπούμαστε, εξαντλήθηκε|{1} Απομένει μόνο :count κομμάτι|[2,*] Απομένουν μόνο :count κομμάτια',
],
// Reviews (components/review-form.blade.php, review-card.blade.php, product/show.blade.php)
'review.rating_required' => ['en' => 'Please select a rating.', 'el' => 'Παρακαλούμε επίλεξε βαθμολογία.'],
'review.reply' => ['en' => 'Reply', 'el' => 'Απάντηση'],
'review.thank_you' => [
'en' => 'Thanks for your review!',
'el' => 'Ευχαριστούμε για την αξιολόγησή σου!',
],
// Wishlist (components/wishlist-button.blade.php, wishlist/guest.blade.php, wishlist/list.blade.php)
'wishlist.add' => ['en' => 'Add to wishlist', 'el' => 'Προσθήκη στη λίστα επιθυμιών'],
'wishlist.remove' => ['en' => 'Remove from wishlist', 'el' => 'Αφαίρεση από τη λίστα επιθυμιών'],
'wishlist.added' => ['en' => 'Added to wishlist', 'el' => 'Προστέθηκε στη λίστα επιθυμιών'],
'wishlist.removed' => ['en' => 'Removed from wishlist', 'el' => 'Αφαιρέθηκε από τη λίστα επιθυμιών'],
'wishlist.empty' => ['en' => 'Your wishlist is empty.', 'el' => 'Η λίστα επιθυμιών σου είναι άδεια.'],
'wishlist.guest_hint' => [
'en' => 'Log in to keep your wishlist across devices.',
'el' => 'Συνδέσου για να κρατήσεις τη λίστα επιθυμιών σου σε όλες τις συσκευές.',
],
'wishlist.remove_named' => ['en' => 'Remove :name from wishlist', 'el' => 'Αφαίρεση :name από τη λίστα επιθυμιών'],
'wishlist.remove_short' => ['en' => 'Remove', 'el' => 'Αφαίρεση'],
];
}
}
+11
View File
@@ -44,6 +44,17 @@ class NotificationRegistry
if (isset($event->delaySeconds) && $event->delaySeconds > 0) {
$notification->delay($event->delaySeconds);
}
// Every order-notification event carries ->order, whose
// ->meta['locale'] was saved at checkout (Checkout\
// Services\CheckoutService::initiatePayment()) — the
// request that actually sends this may have no locale of
// its own (a payment webhook, a queued job, a staff
// action from Filament), so this is the only reliable
// source. Falls back to the app default for an order
// placed before this existed.
if (isset($event->order) && $locale = $event->order->meta['locale'] ?? null) {
$notification->locale($locale);
}
$notification->notifiable()->notify($notification);
} catch (Throwable $e) {
report($e);
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Services\OrderPaymentResolutionService;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Payment\Events\PaymentDeferred;
/**
@@ -36,11 +37,18 @@ use Modules\Core\Payment\Events\PaymentDeferred;
* visibility and stock decrement, but nothing ever moved `status` off its
* initial value, since resolveCaptureOrAuthorization() only does that for
* an actual capture. Caught and fixed after the fact.
*
* The status advance is skipped for a bank transfer order
* (OrderStatusFlow::isBankTransfer()) — unlike COD, it genuinely has
* something to await at 'awaiting_payment': the wire itself. Only
* OrderFulfillmentService::markPaid() ever advances it past that point
* (see BankTransferPaymentDriver's own docblock).
*/
class MarkOrderPlacedOnDeferredPayment
{
public function __construct(
private readonly OrderPaymentResolutionService $resolution,
private readonly OrderStatusFlow $flow,
) {}
public function handle(PaymentDeferred $event): void
@@ -53,7 +61,9 @@ class MarkOrderPlacedOnDeferredPayment
$order = Order::findOrFail($orderId);
$this->resolution->resolveDeferredPayment($order, self::class);
if (! $this->flow->isBankTransfer($order)) {
$this->resolution->resolveDeferredPayment($order, self::class);
}
if (! blank($order->placed_at)) {
return;
@@ -7,7 +7,23 @@ 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\Services\OrderStatusFlow;
use Modules\Core\Order\Support\OrderReferenceDisplay;
/**
* "Payment captured" is only a meaningful, distinct update for a bank
* transfer order — placing that order and paying for it are genuinely two
* separate moments (Order::paid stays false at checkout; a shopper is
* told their order is confirmed and awaiting a wire, then separately told
* once staff mark it paid). For every other payment method (card,
* authorize-then-capture, COD), placing the order already means the
* shopper did everything they need to on their end — a card payment is
* captured in the very same request that places the order, so a second
* "payment captured" email would just repeat what OrderPlacedNotification
* already said. See OrderStatusFlow's own docblock for why payment method
* never affects the order's status SEQUENCE, only this kind of business
* decision about which events actually matter to the shopper.
*/
class OrderCapturedNotification extends BaseNotification
{
public function __construct(private readonly OrderCaptured $event) {}
@@ -24,6 +40,10 @@ class OrderCapturedNotification extends BaseNotification
public function via(object $notifiable): array
{
if (! app(OrderStatusFlow::class)->isBankTransfer($this->event->order)) {
return [];
}
return ['mail'];
}
@@ -40,10 +60,13 @@ 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,
'order' => $order,
'reference' => $reference,
'amount' => $this->event->transaction->amount->formatted,
]);
}
@@ -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,13 @@ 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,
'order' => $order,
'reference' => $reference,
]);
}
}
@@ -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,13 @@ 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,
'order' => $order,
'reference' => $reference,
]);
}
}
@@ -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,13 @@ 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,
'order' => $order,
'reference' => $reference,
]);
}
}
@@ -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,13 @@ 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,
'order' => $order,
'reference' => $reference,
]);
}
}
@@ -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,13 @@ 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,
'order' => $order,
'reference' => $reference,
'total' => $order->total->formatted,
'lines' => $order->lines,
]);
@@ -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,13 @@ 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,
'order' => $order,
'reference' => $reference,
'amount' => $this->event->transaction->amount->formatted,
]);
}
@@ -7,6 +7,8 @@ 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\Services\OrderStatusFlow;
use Modules\Core\Order\Support\OrderReferenceDisplay;
class OrderStatusUpdatedNotification extends BaseNotification
{
@@ -29,6 +31,22 @@ class OrderStatusUpdatedNotification extends BaseNotification
* NotificationRegistry, so without this the customer would get two
* emails for that one transition. Returning no channels is the
* standard Laravel way to suppress a notification outright.
*
* Also suppressed for the order's very first transition off
* 'awaiting_payment' — for every payment method except bank transfer,
* that transition happens in the SAME request as checkout itself
* (Modules\Core\Order\Services\OrderPaymentResolutionService::
* resolveCaptureOrAuthorization()/resolveDeferredPayment(), both
* called synchronously alongside Checkout\Events\OrderPlaced), so it's
* not new information — the shopper was already told their order is
* confirmed by Modules\Core\Order\Notifications\
* OrderPlacedNotification moments earlier. A bank transfer order is
* the one genuine exception: it sits at 'awaiting_payment' until
* staff separately mark it paid (Modules\Core\Order\Services\
* OrderFulfillmentService::markPaid()), a real, later event the
* shopper does need to hear about — see OrderStatusFlow's own
* docblock for why bank transfer is the only payment method where
* placement and payment aren't the same moment.
*/
public function via(object $notifiable): array
{
@@ -36,6 +54,11 @@ class OrderStatusUpdatedNotification extends BaseNotification
return [];
}
if ($this->event->previousStatus === 'awaiting_payment'
&& ! app(OrderStatusFlow::class)->isBankTransfer($this->event->order)) {
return [];
}
return ['mail'];
}
@@ -52,11 +75,18 @@ class OrderStatusUpdatedNotification extends BaseNotification
{
$order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
$statusLabel = config("lunar.orders.statuses.{$order->status}.label", $order->status);
return (new MailMessage)
->subject(__('Your order :reference has been updated', ['reference' => $order->reference]))
->subject(__('Your order :reference is now :status', [
'reference' => $reference,
'status' => $statusLabel,
]))
->view('core::order.notifications.status-updated', [
'reference' => $order->reference,
'statusLabel' => config("lunar.orders.statuses.{$order->status}.label", $order->status),
'order' => $order,
'reference' => $reference,
'statusLabel' => $statusLabel,
]);
}
}
+30 -17
View File
@@ -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.');
}
@@ -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;
+25 -4
View File
@@ -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<string, string> 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));
}
/**
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Order\Support;
use Lunar\Models\Order;
/**
* A shorter, human-facing form of Order::reference for emails/views —
* Lunar\Base\OrderReferenceGenerator pads the order's own id out to a fixed
* length (config('lunar.orders.reference_format'), 8 characters/'0'-padded
* by default), so a shop's first real orders read as "#00000001" rather
* than "#1". Strips leading zeros until the first non-zero digit; if
* nothing but zeros remain (or the reference is empty), shows "0".
*
* $order->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';
}
}
@@ -9,30 +9,52 @@ 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\PaymentDeferred;
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 and DOES
* dispatch PaymentDeferred, same as COD — without it, nothing ever sets
* Order::placed_at or fires OrderPlaced, leaving the order invisible in
* customer order history, un-decremented in stock, and the checkout
* confirmation page unable to find it (see PaymentDeferred's and
* CashOnDeliveryPaymentDriver's own docblocks for that failure mode).
* Unlike COD, though, a bank transfer order genuinely DOES have something
* to await: MarkOrderPlacedOnDeferredPayment skips
* OrderPaymentResolutionService::resolveDeferredPayment() for a bank
* transfer order (via OrderStatusFlow::isBankTransfer()), so 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
{
@@ -47,13 +69,12 @@ class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRe
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
$result = new PaymentResult(
status: PaymentResultStatus::Succeeded,
status: PaymentResultStatus::Pending,
reference: 'bank-transfer-'.Str::uuid(),
amount: $amount,
meta: array_filter(['notes' => $data['notes'] ?? null]),
);
PaymentCaptured::dispatch($type, $result, $context);
PaymentDeferred::dispatch($type, $result, $context);
return $result;
}
@@ -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')
@@ -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
+19 -5
View File
@@ -10,6 +10,7 @@ use Livewire\Mechanisms\ComponentRegistry;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
use Lunar\Shipping\Interfaces\ShippingMethodManagerInterface;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Cart\Events\CartCleared;
use Modules\Core\Cart\Events\CartLineAdded;
@@ -25,9 +26,11 @@ use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
use Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver;
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
use Modules\Core\Shipping\Support\FulfillmentType;
use Modules\Core\Shipping\Support\ShippingManager;
use Modules\Core\Shipping\Models\Shipment;
class ShippingServiceProvider extends ServiceProvider
@@ -81,10 +84,9 @@ class ShippingServiceProvider extends ServiceProvider
// resolveCarrier() for the same lookup pattern already used to
// resolve a carrier driver from it).
//
// Resolves via Modules\Core\Shipping\Support\FulfillmentType (driver-
// declared for acs/box-now, merchant-configured data['fulfillment_type']
// fallback for table-rate-shipping's generic drivers) rather than
// through a ShippingMethod::macro('isStorePickup', ...) —
// Resolves via Modules\Core\Shipping\Support\FulfillmentType
// (hardcoded per driver — see that class's own docblock) rather
// than through a ShippingMethod::macro('isStorePickup', ...) —
// Lunar\Base\Traits\HasModelExtending::__callStatic() (used by
// Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel)
// intercepts EVERY unmatched static call, including macro()
@@ -119,10 +121,22 @@ class ShippingServiceProvider extends ServiceProvider
// Deferred: the Shipping facade resolves a binding registered in
// lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(),
// and provider boot order between packages isn't guaranteed.
// and provider boot order between packages isn't guaranteed — in
// fact composer's package discovery registers boboko/core BEFORE
// lunarphp/table-rate-shipping (alphabetical), so a bind() in this
// provider's own register()/boot() runs first and gets silently
// overwritten by the vendor's own later bind() of the same
// abstract. booted() is the first point every provider's
// register()/boot() has definitely already run, so this is also
// where the ShippingMethodManagerInterface override belongs (must
// run before the Shipping::extend() calls below, which resolve —
// and the facade then CACHES — whatever's bound at that moment).
$this->app->booted(function () {
$this->app->bind(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class));
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
Shipping::extend('store-pickup', fn ($app) => $app->make(StorePickupRateDriver::class));
$this->app->make(ConsoleSchedule::class)
->job(new WarmAcsAreaCacheJob)
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Listeners\FlushStoreDetailsCache;
use Modules\Core\Store\Services\StoreDetailsService;
use Symfony\Component\Mime\Address;
class StoreServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(StoreDetailsUpdated::class, FlushStoreDetailsCache::class);
Event::listen(MessageSending::class, [$this, 'applyMailFromName']);
}
/**
* Renames the From header's display name to the store's own Store
* Details setting, when filled in — leaves the address itself alone
* (a deliverability/domain-authentication concern, not something a
* merchant should freely edit via a text field) and leaves an
* EXPLICIT sender alone too: anything whose From address isn't
* config('mail.from.address') deliberately chose a different sender
* (e.g. a mailable/notification that calls ->from() itself), which
* this provider has no business second-guessing.
* StoreDetailsService::current() is forever-cached (see its own
* docblock), so this costs nothing extra per send.
*/
public function applyMailFromName(MessageSending $event): void
{
$name = app(StoreDetailsService::class)->current()->mail_from_name;
if (blank($name)) {
return;
}
$from = $event->message->getFrom()[0] ?? null;
if ($from === null || $from->getAddress() !== config('mail.from.address')) {
return;
}
$event->message->from(new Address($from->getAddress(), $name));
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
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
{
Route::middleware('web')->group(__DIR__.'/../Wishlist/routes/web.php');
Event::listen(UserAuthenticated::class, MergeGuestWishlistOnLogin::class);
}
}
+13 -1
View File
@@ -9,16 +9,19 @@ use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName;
use Modules\Core\Shipping\Support\WeightCalculator;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType, SupportsCashCollection
{
use ResolvesFixedPricing;
use CachesLivePricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate;
@@ -37,6 +40,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
return 'carrier';
}
public function collectsCash(): bool
{
return true;
}
public function description(): string
{
return 'Live rate quote from ACS Courier.';
@@ -48,6 +56,10 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
$shippingMethod = $shippingRate->shippingMethod;
$cart = $shippingOptionRequest->cart;
if ($this->cartHasExcludedProducts($shippingRate, $cart)) {
return null;
}
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
}
@@ -6,6 +6,7 @@ use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
@@ -18,6 +19,7 @@ use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
{
use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate;
@@ -38,6 +40,10 @@ class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) {
return null;
}
return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod,
@@ -0,0 +1,75 @@
<?php
namespace Modules\Core\Shipping\Carriers\StorePickup;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
/**
* First-party replacement for lunarphp/table-rate-shipping's own Collection
* driver — same shape as AcsRateDriver/BoxNowRateDriver, unlike the vendor
* generic drivers (flat-rate/ship-by/free-shipping/collection), which this
* module no longer registers (see ShippingServiceProvider). Two reasons
* this exists rather than keeping the vendor driver:
*
* 1. Collection::resolve() reads $shippingMethod->name directly instead of
* through Modules\Core\Shipping\Support\ShippingMethodName::resolve() —
* ShippingMethod has no cast for that locale-keyed JSON column (see
* ShippingMethodName's own docblock), so the storefront showed the raw
* JSON blob as the option's name instead of the translated string.
* 2. Unambiguously store pickup, like ACS/Box Now are unambiguously
* carrier — implementing DeclaresFulfillmentType means this is a
* hardcoded fact about the driver, never a merchant configuration
* choice (see that contract's own docblock).
*
* No live pricing — there's no API for in-person pickup, just the method's
* own charge_by + price-break configuration (usually free), the same
* mechanism Box Now uses.
*/
class StorePickupRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
{
use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate;
public function name(): string
{
return 'Store Pickup';
}
public function fulfillmentType(): string
{
return 'store_pickup';
}
public function description(): string
{
return 'Collect your order in store.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) {
return null;
}
return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod,
$shippingOptionRequest->cart,
);
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Shipping\Concerns;
use Lunar\Models\Cart;
use Lunar\Models\Product;
use Lunar\Shipping\Models\ShippingRate;
/**
* A shipping zone can list specific products that can't go through it (see
* the Filament "Shipping Exclusions" relation manager on ShippingZoneResource)
* — a merchant's way of saying "this item physically can't be handled by
* this carrier/method," independent of price. lunarphp/table-rate-shipping's
* own generic drivers (FlatRate, ShipBy, FreeShipping, Collection) each
* check this before returning an option; every first-party driver in this
* module (ACS, Box Now, store pickup) shares it from here instead, so a
* merchant-configured exclusion is honored no matter which driver a
* shipping method uses — not just the vendor's generic ones. Call this
* FIRST in resolve(), before any pricing lookup (live or fixed): an
* excluded product should make the option disappear entirely, the same
* "return null" a driver already uses for "no rate matched."
*/
trait ExcludesRestrictedProducts
{
private function cartHasExcludedProducts(ShippingRate $shippingRate, Cart $cart): bool
{
$productIds = $cart->lines->load('purchasable')->pluck('purchasable.product_id');
return $shippingRate->shippingZone->shippingExclusions()
->whereHas('exclusions', function ($query) use ($productIds) {
$query->wherePurchasableType(Product::morphName())
->whereIn('purchasable_id', $productIds);
})->exists();
}
}
@@ -3,18 +3,14 @@
namespace Modules\Core\Shipping\Contracts;
/**
* Optional contract a shipping rate driver implements to declare whether
* it fulfils via carrier delivery or in-store pickup — e.g.
* Every shipping rate driver this module registers implements this to
* declare whether it fulfils via carrier delivery or in-store pickup —
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver and BoxNowRateDriver
* are unambiguously carrier-only, so this is a hardcoded fact about the
* driver, not something a merchant should have to configure per row.
*
* table-rate-shipping's own generic drivers (flat-rate, ship-by,
* free-shipping) don't implement this — they're genuinely ambiguous (a
* merchant could configure one for either carrier delivery or store
* pickup), so Modules\Core\Shipping\Support\FulfillmentType::resolve()
* falls back to ShippingMethod.data['fulfillment_type'] (still merchant-
* overridable) only for drivers that don't implement this contract.
* are unambiguously carrier-only, Modules\Core\Shipping\Carriers\
* StorePickup\StorePickupRateDriver unambiguously store-pickup, so this
* is a hardcoded fact about each driver, never something a merchant
* configures per row. See Modules\Core\Shipping\Support\FulfillmentType::
* resolve(), the single source of truth this feeds.
*/
interface DeclaresFulfillmentType
{
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Shipping\Contracts;
/**
* A shipping rate driver implements this to say a person is physically
* present at handoff to collect cash — true for a courier like
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver, false for an
* unattended parcel locker network like Modules\Core\Shipping\Carriers\
* BoxNow\BoxNowRateDriver, where no one is there to take payment. Checked
* by Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()
* for cash-on-delivery specifically (Modules\Core\Payment\Drivers\
* CashOnDeliveryPaymentDriver) — separate from Modules\Core\Shipping\
* Contracts\DeclaresFulfillmentType, which only distinguishes carrier vs.
* store_pickup and can't tell ACS and Box Now apart (both 'carrier').
*
* Not implemented at all means "no" — a driver with no opinion here
* (any of table-rate-shipping's own generic drivers, if one were ever
* re-added) is treated as not supporting cash collection, the safer
* default for a driver this module knows nothing about.
*/
interface SupportsCashCollection
{
public function collectsCash(): bool;
}
@@ -48,6 +48,6 @@ class ShippingMethodListExtension extends BaseExtension
->label('Type')
->options(fn () => collect(Shipping::getSupportedDrivers())
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
->default('flat-rate');
->default('acs');
}
}
@@ -13,7 +13,6 @@ use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName;
@@ -22,11 +21,9 @@ class ShippingMethodResourceExtension extends ResourceExtension
public function extendForm(Schema $schema): Schema
{
return $schema->components(
$this->replaceFulfillmentTypeField(
$this->replaceChargeByField(
$this->replaceNameField(
$this->replaceDriverField($schema->getComponents())
)
$this->replaceChargeByField(
$this->replaceNameField(
$this->replaceDriverField($schema->getComponents())
)
)
);
@@ -98,64 +95,6 @@ class ShippingMethodResourceExtension extends ResourceExtension
return $field;
}
/**
* Inserts the `fulfillment_type` Select right after `charge_by`, in
* the SAME Group (vendor's own `Group::make([getChargeByFormComponent()])
* ->columns(2)`) — formerly appended at the very end of the whole
* form, disconnected from `driver`/`charge_by`, the decisions it
* actually relates to. Only rendered at all for a driver that DOESN'T
* already declare its own fulfillment type (see Modules\Core\Shipping\
* Contracts\DeclaresFulfillmentType, Modules\Core\Shipping\Support\
* FulfillmentType) — acs/box-now are unambiguously carrier-only, so
* asking a merchant to also pick "Carrier delivery" for every ACS/Box
* Now method was redundant, error-prone config with no real decision
* behind it. Still offered for table-rate-shipping's generic drivers
* (flat-rate, ship-by, free-shipping), which are genuinely ambiguous.
*/
private function replaceFulfillmentTypeField(array $components): array
{
$result = [];
foreach ($components as $component) {
$result[] = $component;
if (method_exists($component, 'getName') && $component->getName() === 'charge_by') {
$result[] = $this->fulfillmentTypeSelect();
} elseif (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema($this->replaceFulfillmentTypeField($component->getChildComponents()));
}
}
return $result;
}
private function fulfillmentTypeSelect(): Select
{
return Select::make('data.fulfillment_type')
->label('Fulfillment type')
->options([
'carrier' => 'Carrier delivery',
'store_pickup' => 'Collect in store',
])
->default('carrier')
->required()
->visible(fn (Get $get) => $this->driverIsFulfillmentAmbiguous($get('../driver')))
->helperText('Whether an order using this method is handed to a carrier, or collected by the customer in person.');
}
private function driverIsFulfillmentAmbiguous(?string $driver): bool
{
if (! $driver) {
return true;
}
try {
return ! Shipping::driver($driver) instanceof DeclaresFulfillmentType;
} catch (InvalidArgumentException) {
return true;
}
}
/**
* Extend the vendor's cart_total/weight charge_by Select with a third
* "live" option — only offered when the currently selected driver
@@ -297,7 +236,7 @@ class ShippingMethodResourceExtension extends ResourceExtension
->label('Type')
->options(fn () => collect(Shipping::getSupportedDrivers())
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
->default('flat-rate')
->default('acs')
->live();
}
}
+11 -36
View File
@@ -8,20 +8,16 @@ use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
/**
* The single source of truth for "is this ShippingMethod a carrier
* delivery or an in-store pickup" — replaces a merchant-facing
* data['fulfillment_type'] Select that used to exist for every method
* regardless of driver. Modules\Core\Shipping\Carriers\Acs\AcsRateDriver
* and BoxNowRateDriver are unambiguously carrier-only (see
* Modules\Core\Shipping\Contracts\DeclaresFulfillmentType's own
* docblock), so asking a merchant to also pick "Carrier delivery" for
* every ACS/Box Now method was redundant, error-prone config with no
* real decision behind it.
*
* table-rate-shipping's own generic drivers (flat-rate, ship-by,
* free-shipping) don't implement DeclaresFulfillmentType — a merchant
* could genuinely configure one for either purpose (e.g. "Flat Rate —
* Athens Store Pickup") — so those still fall back to the merchant-set
* data['fulfillment_type'], defaulting to 'carrier' when unset.
* delivery or an in-store pickup" — every driver this module registers
* (ACS, Box Now, Modules\Core\Shipping\Carriers\StorePickup\
* StorePickupRateDriver) implements DeclaresFulfillmentType, so this is
* now a hardcoded fact about the driver, never a merchant choice. There
* used to be a merchant-facing data['fulfillment_type'] Select as a
* fallback for table-rate-shipping's own generic drivers (flat-rate,
* ship-by, free-shipping, collection), which were genuinely ambiguous
* (a merchant could configure one for either purpose) — those drivers
* are no longer offered at all (see Modules\Core\Shipping\Support\
* ShippingManager), so the fallback and the field it read from are gone.
*/
class FulfillmentType
{
@@ -29,32 +25,11 @@ class FulfillmentType
{
$driver = collect(Shipping::getSupportedDrivers())->get($method->driver);
if ($driver instanceof DeclaresFulfillmentType) {
return $driver->fulfillmentType();
}
return $method->data['fulfillment_type'] ?? 'carrier';
return $driver instanceof DeclaresFulfillmentType ? $driver->fulfillmentType() : 'carrier';
}
public static function isStorePickup(ShippingMethod $method): bool
{
return static::resolve($method) === 'store_pickup';
}
/**
* Whether the merchant-facing "Fulfillment type" Select should be
* shown at all for a given driver — hidden entirely for a driver that
* already declares its own fulfillment type, since there is no real
* decision left for the merchant to make.
*/
public static function isConfigurableFor(?string $driverKey): bool
{
if ($driverKey === null) {
return true;
}
$driver = collect(Shipping::getSupportedDrivers())->get($driverKey);
return ! $driver instanceof DeclaresFulfillmentType;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Modules\Core\Shipping\Support;
use Lunar\Shipping\Managers\ShippingManager as VendorShippingManager;
/**
* Drops lunarphp/table-rate-shipping's own generic drivers (free-shipping,
* flat-rate, ship-by, collection) from getSupportedDrivers() — every
* shipping method in this app now uses a first-party driver (ACS, Box
* Now, or Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver,
* registered via Shipping::extend() in ShippingServiceProvider), each of
* which declares its own fulfillment type and correctly decodes
* ShippingMethod's translatable `name` column (see StorePickupRateDriver's
* own docblock for why the vendor `collection` driver specifically had to
* go — it read $shippingMethod->name raw, showing the storefront the raw
* JSON blob instead of a translated string). The generic drivers'
* create{X}Driver() methods still exist on the parent (harmless — nothing
* calls them once their key is gone from getSupportedDrivers()), so this
* only needs to override the one method that lists what's offered.
*
* Bound over the vendor's own ShippingMethodManagerInterface binding in
* ShippingServiceProvider, from inside its own $this->app->booted(...)
* callback — a plain register()-time bind() here runs BEFORE the
* vendor's own (composer discovers boboko/core before lunarphp/
* table-rate-shipping alphabetically), so the vendor's later bind()
* would silently win instead. See that provider's own comment.
*/
class ShippingManager extends VendorShippingManager
{
public function getSupportedDrivers(): \Illuminate\Support\Collection
{
return collect($this->customCreators)
->mapWithKeys(function ($creator, $key) {
return [$key => $this->callCustomCreator($key)];
});
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Store\Events;
use Modules\Core\Store\Models\StoreDetails;
/**
* Dispatched by StoreDetailsService — the only place StoreDetails is ever
* created/updated, mirroring Modules\Core\Localization\Services\
* TranslationService's own create()/update() shape. Modules\Core\Store\
* Listeners\FlushStoreDetailsCache reacts to this to invalidate
* StoreDetailsService::current()'s forever-cache.
*/
class StoreDetailsUpdated
{
public function __construct(
public readonly StoreDetails $storeDetails,
) {}
}
@@ -0,0 +1,137 @@
<?php
namespace Modules\Core\Store\Filament\Pages;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Modules\Core\Store\Services\StoreDetailsService;
/**
* Singleton settings page — no resource, no record list, always edits the
* one StoreDetails row (see that model's own docblock). Filament ships no
* built-in "settings page" type; this follows the same shape Filament's own
* password-reset-request page uses (see Filament\Auth\Pages\PasswordReset\
* RequestPasswordReset): a content(Schema) composed of a Form(EmbeddedSchema)
* with the save action(s) in its own footer(), rather than a hand-written
* Blade view — Filament v4 has no `x-filament-panels::form.actions` Blade
* component to fall back on for a plain Page.
*/
class ManageStoreDetails extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationLabel = 'Store Details';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-storefront';
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
public ?array $data = [];
public function mount(): void
{
$this->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(),
TextInput::make('contact_email')
->label('Contact email')
->email()
->helperText('Receives the contact form, and is shown to customers as the store\'s contact email.'),
TextInput::make('mail_from_name')
->label('Mail from name')
->helperText('The sender name on outgoing emails (MAIL_FROM_NAME).'),
]),
Section::make('Legal')
->description('Shown on invoices and terms pages.')
->schema([
TranslatedText::make('legal_name')
->label('Legal name'),
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();
}
}
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Store\Listeners;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Services\StoreDetailsService;
/**
* Same shape as Modules\Core\Localization\Listeners\FlushTranslationCache —
* StoreDetailsService::current() caches forever (this is read on every
* storefront request that shows store details, e.g. the checkout
* confirmation page's bank transfer instructions), so the only way it ever
* becomes stale is a write through this same service. Not queued: unlike
* FlushTranslationCache (which only affects a LATER storefront request),
* StoreDetailsUpdated fires from the staff member's own save action, and
* StoreDetailsService::current() may be called again within that same
* request/response cycle.
*/
class FlushStoreDetailsCache
{
public function handle(StoreDetailsUpdated $event): void
{
Cache::forget(StoreDetailsService::CACHE_KEY);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Store\Models;
use Illuminate\Database\Eloquent\Model;
use Lunar\Base\Traits\HasTranslations;
/**
* Singleton — always exactly one row, fetched/created via
* Modules\Core\Store\Services\StoreDetailsService::current(). See that
* table's own migration docblock for why name/address/
* bank_transfer_instructions are locale-keyed JSON and the rest are plain.
*/
class StoreDetails extends Model
{
use HasTranslations;
protected $guarded = [];
protected $casts = [
'name' => 'array',
'address' => 'array',
'bank_transfer_instructions' => 'array',
'legal_name' => 'array',
];
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace Modules\Core\Store\Services;
use Filament\Forms\Components\RichEditor\RichContentRenderer;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Language;
use Lunar\Models\Order;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Models\StoreDetails;
/**
* The only entrypoint that creates/updates the StoreDetails singleton — same
* shape as Modules\Core\Localization\Services\TranslationService: every
* write goes through here so it can dispatch StoreDetailsUpdated, which
* Modules\Core\Store\Listeners\FlushStoreDetailsCache reacts to. Never call
* StoreDetails::query()->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(),
);
}
/**
* Null for any non-bank-transfer order — the confirmation email only
* shows this block when there's actually a wire to send (see
* BankTransferPaymentDriver's own docblock for why a bank transfer
* order stays at 'awaiting_payment' until staff confirm the wire
* arrived). Null also when the store hasn't filled the field in for
* $locale, so the caller's own @if($bankTransferInstructions) guard
* covers both cases identically.
*
* bank_transfer_instructions is a TranslatedRichEditor field (see
* ManageStoreDetails), so translate() returns Filament's Tiptap JSON
* document structure for that locale, not a plain string —
* RichContentRenderer::make() is Filament's own converter from that
* structure to sanitized HTML (the same one the admin panel itself
* uses to render a RichEditor's content read-only).
*/
public function bankTransferInstructionsFor(Order $order, string $locale): ?string
{
if (! app(OrderStatusFlow::class)->isBankTransfer($order)) {
return null;
}
$content = $this->current()->translate('bank_transfer_instructions', $locale);
if (blank($content)) {
return null;
}
return RichContentRenderer::make($content)->toHtml();
}
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(),
'legal_name' => $this->emptyPerLocale(),
]);
}
private function emptyPerLocale(): array
{
return Language::query()->pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all();
}
}
@@ -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');
+59
View File
@@ -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,
},
},
}
},
}
}