Compare commits

...
49 Commits
Author SHA1 Message Date
arvanitakis ebf8fd7571 Bump version to 0.28.0 2026-09-30 12:11:12 +03:00
arvanitakis 536fe32e0d Docs: Adding to CONTRIBUTE.md for translations 2026-09-30 12:10:55 +03:00
arvanitakis 8293195395 Feat: Adding Translations Pull Command 2026-09-30 12:07:26 +03:00
arvanitakis d19fe1b6ea Docs: Updating CONTRIBUTE.md and index.js documentation for correct deploy 2026-09-30 11:33:39 +03:00
arvanitakis 44a2ddda4a Bump version to 0.27.5 2026-09-30 11:15:38 +03:00
arvanitakis 52f3036960 Feat: Adding hashes for otp codes 2026-09-30 11:15:27 +03:00
elvira 004f2382cb Bump version to 0.27.4 2026-09-29 21:30:41 +03:00
elvira 6cf142e35b Feat: Rework cart drawer line layout and order confirmation page 2026-09-29 21:25:28 +03:00
elvira 47851d36ec Bump version to 0.27.3 2026-09-29 20:29:04 +03:00
elvira c43682ef0c Feat: Show bank transfer instructions on order confirmation page 2026-09-29 20:25:43 +03:00
arvanitakis 332bf92e44 Fix: Adding check for duplicate transaction 2026-09-29 14:53:08 +03:00
arvanitakis cceeb83d5e Bump version to 0.27.1 2026-09-29 14:32:36 +03:00
arvanitakis 1a7e8704d0 Feat: Adding temp logging to for stripe webhook 2026-09-29 14:30:52 +03:00
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
89 changed files with 3870 additions and 685 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"
]
}
}
+410
View File
@@ -4,8 +4,417 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.28.0] - 2026-09-30
### Added
- `php artisan boboko:translations:pull` — the reverse of the translation
seeders: copies `storefront` / `checkout` / `validation` lines that exist in
the database (e.g. added through the Filament Language Lines UI while
building a storefront) but not in the matching seeder into that seeder's
`lines()`, appended under a marker comment. Keys a seeder already has are
never touched. Writes only against a local `../boboko-core` checkout (local
mode); `--dry-run` lists the lines from anywhere.
- Storefront translations for error pages: `storefront.errors.404_title`,
`storefront.errors.404_text`, `storefront.errors.back_home`. Re-run
`StorefrontTranslationsSeeder` in consuming apps (or restart the stack) to
add them.
### Changed
- `CONTRIBUTE.md` rewritten to match the actual setup: the local/repo modes
and the `bin/core-mode` switch, `@boboko/core` as a real npm package (the
old "no separate npm package" section was stale), releasing a version,
deploying a consumer app with `bin/deploy`, and pulling UI-added
translations into the seeders.
## [0.27.5] - 2026-09-30
### Security
- OTP codes (both customer login via `UserOtpService` and staff login via
`OtpService`) were stored in plaintext in `users.otp_code`/`lunar_staff.otp_code`
and compared against the plaintext guess. The staff path was additionally
weaker — a loose `!=` comparison with no timing-attack protection and no
attempt-limiting at all. Both columns are replaced with `otp_code_hash`
(bcrypt, via a `'hashed'` cast — same convention `password` already uses),
compared with `Hash::check()`. The cache-backed pending-signup OTP path
(an email with no `User` row yet) is hashed the same way. No backfill —
any code mid-flight when this deploys is invalidated (codes expire in 10
minutes regardless, so the practical impact is limited to a re-request).
- `App\Models\User`'s (3dealer) and `Modules\Core\Auth\Models\Staff`'s
`$hidden` arrays didn't list `otp_code`/`otp_expires_at`/`otp_attempts`/
`pending_email_code_hash`/etc. at all — any serialization of either model
(an API response, `Auth::user()` returned somewhere) would have leaked
those fields, including the (now-hashed, previously plaintext) OTP code
itself. `Staff` additionally never overrode Lunar's own base `$hidden`, so
it also lacked `password`/`remember_token` protection until now.
## [0.27.4] - 2026-09-29
### Added
- Generic `.bbk-notice` / `.bbk-notice--info` message box and a
`--bbk-color-info` custom property in `checkout.css`.
- Cart drawer focus handling: focus moves into the drawer on open, stays
inside it, returns to the opener on close, and is restored after each
cart update. Updates are announced as "Cart updated" instead of re-reading
the whole cart. New translation line `checkout.cart.updated` — re-run
`CheckoutTranslationsSeeder` in consuming apps to add it.
### Changed
- Cart drawer line: larger remove button on the title row, line total on
the quantity-stepper row, and screen-reader labels tied to the product
name. `.bbk-cart-item-aside` is removed — hosts restyling it should target
`.bbk-cart-item-head` / `.bbk-cart-item-foot` instead.
- Order confirmation page: narrower (560px), the confirmation-email note is
now an info box under the heading, the order summary comes before
shipping/billing (shown side by side), and the order number is prefixed
with `#`.
## [0.27.3] - 2026-09-29
### Added
- The checkout confirmation page now ends with the store's bank transfer
instructions (Store Details → Bank transfer) for a bank transfer order,
via `StoreDetailsService::bankTransferInstructionsFor()`. New translation
line `checkout.page.confirmation_bank_transfer_heading` — re-run
`CheckoutTranslationsSeeder` in consuming apps to add it.
### Fixed
- `StoreDetailsService::bankTransferInstructionsFor()` now fills a
`{order_reference}` (or `{{ order_reference }}`) typed into the bank
transfer instructions with the order's display reference
(`OrderReferenceDisplay`). It previously rendered literally in the order
confirmation email.
## [0.27.2] - 2026-09-29
### Fixed
- `Modules\Core\Order\Services\TransactionRecorder::record()` — made idempotent
on `(order_id, type, reference)`. A successful Stripe payment can legitimately
report `PaymentCaptured` twice for the same PaymentIntent (checkout's
synchronous capture via `pay()`, then the webhook confirming the same
outcome asynchronously via `handleCallback()`), both routing through
`resultFromIntent()`. With no dedupe check, this wrote two identical
`Transaction` rows for one real payment. Now returns the existing row
instead of creating a duplicate.
## [0.27.1] - 2026-09-29
### Added
- Temp logger for Stripe webhook
## [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 ## [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 ### Added
- `Modules\Core\Customer\Services\CustomerEmailChangeService` — changing an account's login - `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 email (core's login is passwordless, so the email IS the login): `request()` validates the new
@@ -100,6 +509,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.20.2] - 2026-09-25 ## [0.20.2] - 2026-09-25
## [0.22.0] - 2026-09-25
### Changed ### Changed
- Product custom fields (`Product::$custom_fields`) moved off the main product edit form onto - 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\ their own "Custom Fields" sub-page (`Modules\Core\Catalog\Filament\Pages\
+125 -12
View File
@@ -1,35 +1,148 @@
# Contributing to boboko-core # Contributing to boboko-core
This is a Composer library, not a runnable app — you can't `php artisan serve` it directly. To develop and verify changes, you need a consumer app wired to a local checkout via a Composer path repository, plus a real database, since a large part of this package (Lunar models, migrations, Filament panel resources) can only be meaningfully verified against a live Lunar install. This is a Composer library (and an npm package of the same name — see [JS/CSS](#jscss-a-real-npm-package)), not a runnable app — you can't `php artisan serve` it directly. To develop and verify changes, you need a consumer app wired to a local checkout, plus a real database, since a large part of this package (Lunar models, migrations, Filament panel resources) can only be meaningfully verified against a live Lunar install.
## Local dev setup ## Local dev setup
This works against any consumer app that follows the same convention — `boboko-test`, `boboko-starter`, `boboko-3dealer`, etc. — checked out next to this repo: Consumer apps (`3dealer`, `boboko-test`, …) are checked out next to this repo:
``` ```
RadicalElements/ RadicalElements/
├── boboko-core/ (this repo) ├── boboko-core/ (this repo)
└── boboko-test/ (or boboko-starter, boboko-3dealer, ... — consumer app, Docker-based) └── 3dealer/ (or boboko-test, ... — consumer app, Docker-based)
``` ```
Each of these consumer apps ships a `bin/dc-core.sh` helper that wraps the Docker Compose overlay needed to bind-mount a local `boboko-core` checkout into the app container: ### Two modes: local and repo
A consumer app can consume core in one of two modes, and carries the wiring for both. The inactive one is parked under an underscore-prefixed key:
| | **local** — your `../boboko-core` checkout | **repo** — tagged releases from the forge |
|---|---|---|
| `composer.json` | `repositories`: path repo `../boboko-core` (`"symlink": true`) | `repositories`: VCS repo `https://code.radical-elements.com/boboko/core.git` |
| `package.json` | `@boboko/core`: `file:../boboko-core` | `@boboko/core`: `git+https://code.radical-elements.com/boboko/core.git#semver:0.x` |
| Docker Compose | `bin/dc-core.sh` (dev + `docker-compose.core-dev.yml` overlay) | `bin/dc` (dev only) |
- **The committed state is always repo mode.** Local mode rewrites both lockfiles to point at `../boboko-core`, which doesn't exist on the server — never commit it.
- Both sides use an open `0.x` range: `"boboko/core": "0.*"` in Composer, `#semver:0.x` in npm. Don't use a caret: below 1.0.0, `^0.27.0` means `>=0.27.0 <0.28.0` in both tools, so it would silently refuse the next minor.
- `docker-compose.core-dev.yml` bind-mounts `../boboko-core` into the containers — at `/var/www/boboko-core` for `app`/`queue`/`scheduler` (where the path repo resolves from `/var/www/html`) and at `/boboko-core` for `vite` (where `file:../boboko-core` resolves from `/app`). Inside the app container, `vendor/boboko/core` is a symlink into that mount.
### Switching modes: `bin/core-mode`
Don't swap the keys by hand — each consumer app ships a `bin/core-mode` script:
```bash ```bash
./bin/dc-core.sh exec app <command> bin/core-mode # print the current mode
bin/core-mode local # work against ../boboko-core
bin/core-mode repo # back to tagged releases
``` ```
This is shorthand for `docker compose -f docker-compose.dev.yml -f docker-compose.core-dev.yml exec app <command>`. Use `./bin/dc-core.sh` for everything below instead of typing the full compose invocation. It swaps the `composer.json` / `package.json` wiring, runs `down` with the old mode's Compose wrapper and `up` with the new one, then waits until the entrypoints have re-resolved core. Running it for the mode you're already in skips the edits and just restarts the stack — in repo mode, that's how you pick up a newly pushed tag.
1. **Path repository.** In the consumer app's `composer.json`, the `repositories` array needs a path entry pointing at `../boboko-core`. If it only exists in a disabled block (e.g. `_repositories`), move it into the live array. (`3dealer` has `bin/core-mode` and `bin/deploy`; they're app-agnostic, so other consumer apps can copy them as-is.)
2. **Relaxed version constraint.** The consumer app's `composer.json` should require `"boboko/core": "0.*"` (not a tight `^0.0.1` caret) — otherwise Composer rejects newer `0.0.x` versions resolved from the path repo.
3. **Bind mount.** The consumer app's `docker-compose.core-dev.yml` overlays `../boboko-core` into the container at `/var/www/boboko-core`, matching where the path repo resolves it relative to `/var/www/html`. ### Day to day in local mode
4. **Re-resolve after every change.** Composer's path repo does not hot-reload — after editing anything in `boboko-core` (including adding new files, which need autoload discovery), the container needs to re-run `composer update boboko/core`. In `boboko-test`, the entrypoint does this automatically on every dev boot (see `docker/entrypoint.sh`), so `./bin/dc-core.sh up` alone picks up local core changes. If a consumer app's entrypoint doesn't do this yet, run it manually:
Use `bin/dc-core.sh` for every Compose command (`bin/dc-core.sh exec app …`, `bin/dc-core.sh logs -f`, …) — plain `docker compose` or `bin/dc` leaves the core mount out.
The dev entrypoints re-resolve core on **every boot**: `docker/entrypoint.sh` runs `composer update "boboko/*"` and `docker/entrypoint-vite.sh` runs `npm update @boboko/core`. So:
- **Whatever branch is checked out in `../boboko-core` is what the app runs.** Switching core branches switches the app's code — check which branch you're on before debugging "missing" features.
- PHP edits to existing files show up immediately (it's a symlink). **New classes, new migrations, or `composer.json` changes** need a re-resolve: restart the stack, or run
```bash
bin/dc-core.sh exec app composer update boboko/core --with-all-dependencies
```
Skipping this is the most common cause of "my change isn't showing up."
- In `3dealer`, the app container's `vendor/` is a named Docker volume, so the host's `vendor/` directory is stale — inspect packages inside the container, not on the host.
## JS/CSS: a real npm package
Core's Stimulus controllers and CSS ship as the `@boboko/core` npm package, installed into the consumer's `node_modules` — as a symlink to `../boboko-core` in local mode, as a real copy of the tagged release in repo mode. It's a real package (rather than files read out of `vendor/`) so npm installs core's own dependencies (`leaflet`, `@hotwired/stimulus`) transitively, the same way Composer does for PHP.
Public entry points (`package.json` `exports`):
| Import | File |
|---|---|
| `@boboko/core` | `resources/js/index.js` — the stable barrel (`registerCheckout`, `registerWishlist`, …) |
| `@boboko/core/vite-plugin` | `vite-plugin.js` — `boboko()` |
| `@boboko/core/css/*` | `resources/css/*` |
| `@boboko/core/checkout`, `@boboko/core/checkout/*` | `resources/js/checkout/…` |
A consumer imports from the `@boboko/core` barrel only — not from a module's internal files — so the internal layout here can change without breaking every consumer:
```js
// consumer app's resources/js/app.js
import { registerCheckout, registerWishlist } from "@boboko/core";
registerCheckout(application);
registerWishlist(application);
```
```js
// consumer app's vite.config.js
import { boboko } from "@boboko/core/vite-plugin";
export default defineConfig({
plugins: [
laravel({
input: [
// Core's structural checkout styles load first, so the app's own theming wins.
"node_modules/@boboko/core/resources/css/checkout.css",
"resources/css/app.css",
"resources/js/app.js",
],
}),
boboko(),
],
});
```
```php
{{-- consumer app's layout --}}
@vite(['node_modules/@boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
```
`boboko()` owns the Vite settings the local-mode symlink needs, so consumers don't hand-copy them: it excludes `@boboko/core` from dependency pre-bundling (otherwise Vite serves a stale cached copy after you edit core), pre-bundles `leaflet`/`@hotwired/stimulus` explicitly, and turns on `resolve.preserveSymlinks` and `server.watch.followSymlinks` so bare imports resolve from the consumer's `node_modules` and core edits trigger HMR. All of it is a harmless no-op against a real installed copy in repo mode.
## Translations added in the UI
Default translation lines ship in core's seeders (`StorefrontTranslationsSeeder`, `CheckoutTranslationsSeeder`, `ValidationTranslationsSeeder`), which every app runs on boot and which only ever add missing keys. Lines added while building a storefront usually start in the Filament Language Lines UI instead. To move them into core:
```bash
bin/dc-core.sh exec app php artisan boboko:translations:pull # local mode: writes into ../boboko-core
bin/dc exec app php artisan boboko:translations:pull --dry-run # any mode: just list them
```
It adds every `storefront` / `checkout` / `validation` key that's in the database but not in the matching seeder, appended at the end of `lines()` under a marker comment — move them into the right section before committing. Keys the seeder already has are never touched, even if their text was edited in the UI. `bin/deploy` runs it for you.
## Releasing a version
1. Bump `"version"` in **both** `composer.json` and `package.json` — they must match.
2. Add a `CHANGELOG.md` entry under the new version. While pre-1.0, a new capability for consuming apps is a **minor** bump (`0.27.x` → `0.28.0`); a fix, redesign or internal swap with no new capability is a **patch** bump.
3. Commit, tag `vX.Y.Z`, and push the commit **and** the tag:
```bash ```bash
./bin/dc-core.sh exec app composer update boboko/core --with-all-dependencies git tag v0.27.5
git push origin master v0.27.5
``` ```
Skipping this step is the most common cause of "my change isn't showing up." A tag alone changes nothing in production — each consumer app has to pick it up and deploy (below).
## Deploying a consumer app
Production only ever runs what the app's **committed lockfiles** pin. Each consumer app ships `bin/deploy`, which:
1. Refuses to run on the wrong branch, with uncommitted changes (other than the core wiring files), or behind `origin`.
2. Runs `php artisan boboko:translations:pull` (see [Translations added in the UI](#translations-added-in-the-ui)). In local mode, if it pulls any lines into `../boboko-core`, it stops — commit, tag and push core, then rerun. In repo mode it only checks, and stops if the database has lines core doesn't.
3. Runs `bin/core-mode repo` — switching from local mode if needed, restarting either way — so both lockfiles resolve the newest `0.x` tag. It warns if `../boboko-core` has a newer tag than what resolved (usually an unpushed tag).
4. Commits the lockfile bump (`Chore: Bumping boboko/core to X.Y.Z`) if there is one, shows what will be pushed, and asks for confirmation.
5. Pushes, then runs `vendor/bin/envoy run deploy` against the host in `.env.envoy`.
Envoy (`Envoy.blade.php`) then, on the server: `git reset --hard` + `git pull`, `docker compose build` (the `production` image target runs `composer install --no-dev` and `npm ci` from the committed lockfiles — this is where the core tag actually lands), `up -d`, caches config/routes/events, restarts `queue` and `scheduler`, and regenerates Stoic thumbnails. The production entrypoint skips Composer entirely and runs migrations (including core's), seeders, the Meilisearch sync, and `artisan optimize`.
After deploying you're left in repo mode — `bin/core-mode local` to go back.
When a change spans core and the app (e.g. a core migration plus an app model cast that depends on it), ship them together: tag core first, then commit the app change and deploy — `bin/deploy` bumps the lock to the new tag in the same deploy.
## Verifying changes against a real database ## Verifying changes against a real database
+4 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.22.0", "version": "0.28.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -47,7 +47,9 @@
"Modules\\Core\\Providers\\FileServiceProvider", "Modules\\Core\\Providers\\FileServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider", "Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider", "Modules\\Core\\Providers\\OrderServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider" "Modules\\Core\\Providers\\PrivacyServiceProvider",
"Modules\\Core\\Providers\\WishlistServiceProvider",
"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']);
});
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* otp_code was stored in plaintext (a raw 6-digit string) and compared
* with hash_equals() against the plaintext guess in
* Modules\Core\Auth\Services\UserOtpService — hash_equals() only
* prevents a timing attack, it does nothing to protect the code itself
* from anyone with read access to the row. Replaced with a bcrypt hash,
* same pattern Modules\Core\Customer\Services\CustomerEmailChangeService
* already uses for its own pending_email_code_hash column.
*
* No backfill: any code mid-flight when this deploys is invalidated —
* codes expire in 10 minutes anyway, so the real-world impact is a
* shopper re-requesting one, not lost work.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('otp_code_hash')->nullable()->after('password');
});
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('otp_code');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('otp_code', 6)->nullable()->after('password');
});
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('otp_code_hash');
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Same fix as 2026_09_30_000001_hash_otp_code_on_users_table.php, for
* staff logins — see that migration's own docblock. This path was
* additionally weaker: Modules\Core\Auth\Services\OtpService compared
* with a loose != rather than hash_equals(), so it had no timing-attack
* protection at all on top of the plaintext storage.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_staff', function (Blueprint $table) {
$table->string('otp_code_hash')->nullable()->after('password');
});
Schema::table('lunar_staff', function (Blueprint $table) {
$table->dropColumn('otp_code');
});
}
public function down(): void
{
Schema::table('lunar_staff', function (Blueprint $table) {
$table->string('otp_code', 6)->nullable()->after('password');
});
Schema::table('lunar_staff', function (Blueprint $table) {
$table->dropColumn('otp_code_hash');
});
}
};
+1 -1
View File
@@ -393,7 +393,7 @@ Because Filament instantiates `Lunar\Admin\Models\Staff` directly (not a subclas
```php ```php
use Lunar\Admin\Models\Staff as LunarStaff; use Lunar\Admin\Models\Staff as LunarStaff;
LunarStaff::addActivitylogExcept(['otp_code', 'otp_expires_at', 'password']); LunarStaff::addActivitylogExcept(['otp_code_hash', 'otp_expires_at', 'password']);
``` ```
--- ---
+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 ## Creating a New Module
**1. Create the repository and `composer.json`:** **1. Create the repository and `composer.json`:**
+3 -2
View File
@@ -30,11 +30,12 @@ Codes expire after **10 minutes**. After a successful validation the code is cle
### Database ### Database
Two columns on the `lunar_staff` table (added by `2026_05_06_000001_add_otp_to_lunar_staff_table`): Two columns on the `lunar_staff` table (added by `2026_05_06_000001_add_otp_to_lunar_staff_table`,
`otp_code` replaced with a hashed column by `2026_09_30_000002_hash_otp_code_on_lunar_staff_table`):
| Column | Type | Purpose | | Column | Type | Purpose |
|---|---|---| |---|---|---|
| `otp_code` | string, nullable | The generated code | | `otp_code_hash` | string, nullable | Bcrypt hash of the generated code (`'hashed'` cast on `Staff`) |
| `otp_expires_at` | timestamp, nullable | Expiry time | | `otp_expires_at` | timestamp, nullable | Expiry time |
### Login Page ### Login Page
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@boboko/core",
"version": "0.28.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"
}
}
+247 -28
View File
@@ -53,6 +53,7 @@
--bbk-color-accent: #18181b; --bbk-color-accent: #18181b;
--bbk-color-accent-text: #ffffff; --bbk-color-accent-text: #ffffff;
--bbk-color-danger: #dc2626; --bbk-color-danger: #dc2626;
--bbk-color-info: #2563eb;
--bbk-radius: 8px; --bbk-radius: 8px;
--bbk-radius-sm: 4px; --bbk-radius-sm: 4px;
@@ -153,6 +154,11 @@
outline-offset: 2px; outline-offset: 2px;
} }
/* Programmatic focus targets only (tabindex=-1) — never reached by Tab, so
no ring needed. */
.bbk-cart-heading:focus,
.bbk-checkout-summary-heading:focus { outline: none; }
.bbk-visually-hidden { .bbk-visually-hidden {
position: absolute; position: absolute;
width: 1px; width: 1px;
@@ -183,7 +189,7 @@
.bbk-cart-item { .bbk-cart-item {
display: grid; display: grid;
grid-template-columns: 72px 1fr auto; grid-template-columns: 72px 1fr;
gap: 0.875rem; gap: 0.875rem;
align-items: start; align-items: start;
} }
@@ -199,8 +205,23 @@
.bbk-cart-item-detail { min-width: 0; } .bbk-cart-item-detail { min-width: 0; }
/* Title + remove button share the first row; quantity stepper + line total
share the last one. */
.bbk-cart-item-head,
.bbk-cart-item-foot {
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
.bbk-cart-item-head { align-items: flex-start; }
.bbk-cart-item-foot { align-items: center; }
.bbk-cart-item-remove-form { flex: 0 0 auto; }
.bbk-cart-item-title { .bbk-cart-item-title {
display: block; display: block;
min-width: 0;
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
font-weight: 600; font-weight: 600;
color: inherit; color: inherit;
@@ -223,13 +244,13 @@ a.bbk-cart-item-title:hover { text-decoration: underline; }
font-size: 0.8125rem; font-size: 0.8125rem;
} }
.bbk-line-field span,
.bbk-line-field dt { .bbk-line-field dt {
color: var(--bbk-color-muted); color: var(--bbk-color-muted);
} }
.bbk-line-field dd { .bbk-line-field dd {
margin: 0; margin: 3px 0 0 0;
white-space: pre-line;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -252,24 +273,24 @@ a.bbk-cart-item-title:hover { text-decoration: underline; }
color: var(--bbk-color-muted); color: var(--bbk-color-muted);
} }
.bbk-cart-item-aside { .bbk-cart-item-total { margin: 0; font-weight: 600; white-space: nowrap; }
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.bbk-cart-item-total { margin: 0; font-weight: 600; }
/* 2.5rem hit area (same as the drawer's own close button), pulled up/right by
negative margins so the glyph lines up with the title's first line instead
of pushing the row taller. */
.bbk-cart-item-remove { .bbk-cart-item-remove {
font-size: 1.125rem; font-size: 1.75rem;
width: 1.5rem; width: 2.5rem;
height: 1.5rem; height: 2.5rem;
margin: -0.5rem -0.5rem 0 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--bbk-radius-sm);
} }
.bbk-cart-item-remove:hover { background: var(--bbk-color-bg-muted); }
.bbk-cart-qty { .bbk-cart-qty {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -656,6 +677,141 @@ textarea.bbk-field-input { resize: vertical; }
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); } .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 */ /* Continue / submit buttons — same look as the drawer's checkout CTA */
.bbk-checkout-continue { .bbk-checkout-continue {
@@ -770,10 +926,39 @@ textarea.bbk-field-input { resize: vertical; }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* ── Notice ────────────────────────────────────────────────────────────
Inline, static message box: `.bbk-notice` + a tone modifier. Static
content, so no live-region role — for messages injected after load, add
role="status" (or role="alert" for errors) on the element itself. */
.bbk-notice {
--bbk-notice-color: var(--bbk-color-text);
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.875rem 1rem;
border: 1px solid color-mix(in srgb, var(--bbk-notice-color) 25%, transparent);
border-radius: var(--bbk-radius-sm);
background: color-mix(in srgb, var(--bbk-notice-color) 6%, var(--bbk-color-bg));
color: var(--bbk-color-text);
font-size: 0.875rem;
}
.bbk-notice--info { --bbk-notice-color: var(--bbk-color-info); }
.bbk-notice-icon {
flex: 0 0 auto;
width: 1.25rem;
height: 1.25rem;
color: var(--bbk-notice-color);
}
.bbk-notice-text { margin: 0; align-self: center; }
/* ── Confirmation page ─────────────────────────────────────────────── */ /* ── Confirmation page ─────────────────────────────────────────────── */
.bbk-confirmation { .bbk-confirmation {
max-width: 720px; max-width: 560px;
margin: 0 auto; margin: 0 auto;
padding: 3rem 1.5rem 5rem; padding: 3rem 1.5rem 5rem;
font-family: var(--bbk-font); font-family: var(--bbk-font);
@@ -781,7 +966,7 @@ textarea.bbk-field-input { resize: vertical; }
} }
.bbk-confirmation-heading { .bbk-confirmation-heading {
margin: 0 0 1rem; margin: 0 0 1.25rem;
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
} }
@@ -789,7 +974,7 @@ textarea.bbk-field-input { resize: vertical; }
.bbk-confirmation-ref { margin: 0 0 0.25rem; } .bbk-confirmation-ref { margin: 0 0 0.25rem; }
.bbk-confirmation-meta { .bbk-confirmation-meta {
margin: 0 0 1rem; margin: 1.5rem 0 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
@@ -805,17 +990,22 @@ textarea.bbk-field-input { resize: vertical; }
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); } .bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; } .bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
.bbk-confirmation-body { .bbk-confirmation-section {
margin: 2rem 0; margin-top: 2rem;
display: grid; padding-top: 1.5rem;
gap: 2.5rem; border-top: 1px solid var(--bbk-color-border);
} }
@media (min-width: 640px) { .bbk-confirmation-section-heading {
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; } margin: 0 0 1rem;
font-size: 1.125rem;
font-weight: 700;
} }
.bbk-confirmation-lines { .bbk-confirmation-lines {
list-style: none;
margin: 0 0 1.25rem;
padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
@@ -830,22 +1020,51 @@ textarea.bbk-field-input { resize: vertical; }
.bbk-confirmation-line-detail { min-width: 0; } .bbk-confirmation-line-detail { min-width: 0; }
.bbk-confirmation-line-name { margin: 0 0 0.25rem; }
.bbk-confirmation-line-total { margin: 0; white-space: nowrap; }
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); } .bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
.bbk-confirmation-addresses { .bbk-confirmation-addresses {
display: flex; display: grid;
flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
} }
.bbk-confirmation-address-heading { @media (min-width: 480px) {
.bbk-confirmation-addresses { grid-template-columns: 1fr 1fr; }
}
.bbk-confirmation-address-heading,
.bbk-confirmation-bank-transfer-heading {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 700; font-weight: 700;
} }
/* Store-authored rich text (ManageStoreDetails' RichEditor) — may be
paragraphs, bold text or a bank/IBAN/BIC table. */
.bbk-confirmation-bank-transfer-body {
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.bbk-confirmation-bank-transfer-body > :first-child { margin-top: 0; }
.bbk-confirmation-bank-transfer-body > :last-child { margin-bottom: 0; }
.bbk-confirmation-bank-transfer-body table {
width: 100%;
border-collapse: collapse;
}
.bbk-confirmation-bank-transfer-body th,
.bbk-confirmation-bank-transfer-body td {
padding: 0.375rem 0.5rem;
border: 1px solid var(--bbk-color-border);
text-align: left;
vertical-align: top;
}
.bbk-address-lines { .bbk-address-lines {
font-style: normal; font-style: normal;
display: flex; display: flex;
@@ -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
}
}
+82 -3
View File
@@ -9,11 +9,13 @@ import { csrfToken } from './csrf'
// - handles the in-drawer quantity / remove forms (fetch + method spoofing) // - handles the in-drawer quantity / remove forms (fetch + method spoofing)
// - re-emits `bbk-cart:updated` {count, total} after every render so the host // - re-emits `bbk-cart:updated` {count, total} after every render so the host
// (e.g. the header bag icon) can react // (e.g. the header bag icon) can react
// - dialog focus handling: focus moves into the panel on open, Tab is kept
// inside it, and focus returns to whatever opened it on close
// //
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state // Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
// attribute on the root, nothing here touches styles or class lists. // attribute on the root, nothing here touches styles or class lists.
export default class extends Controller { export default class extends Controller {
static targets = ['panel', 'body', 'error'] static targets = ['panel', 'body', 'error', 'heading', 'status']
connect() { connect() {
this.onChanged = this.onChanged.bind(this) this.onChanged = this.onChanged.bind(this)
@@ -40,19 +42,31 @@ export default class extends Controller {
} }
onKeydown(event) { onKeydown(event) {
if (event.key === 'Escape' && !this.element.hidden) this.close() // Only the drawer instance is a dialog — the checkout page's summary
// reuses this controller without a panel.
if (!this.hasPanelTarget || this.element.hidden) return
if (event.key === 'Escape') this.close()
if (event.key === 'Tab') this.trapFocus(event)
} }
open() { open() {
if (!this.element.hidden) return if (!this.element.hidden) return
this.returnFocusTo = document.activeElement
this.element.hidden = false this.element.hidden = false
// Next frame, so the panel transitions from its off-canvas start. // Next frame, so the panel transitions from its off-canvas start.
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open')) requestAnimationFrame(() => {
this.element.setAttribute('data-bbk-cart-state', 'open')
if (this.hasHeadingTarget) this.headingTarget.focus({ preventScroll: true })
})
} }
close() { close() {
this.element.removeAttribute('data-bbk-cart-state') this.element.removeAttribute('data-bbk-cart-state')
if (this.returnFocusTo?.isConnected) this.returnFocusTo.focus({ preventScroll: true })
this.returnFocusTo = null
const panel = this.panelTarget const panel = this.panelTarget
const done = () => { const done = () => {
this.element.hidden = true this.element.hidden = true
@@ -132,6 +146,28 @@ export default class extends Controller {
} }
} }
// aria-modal hides the page from screen readers but doesn't stop Tab from
// walking out of the panel into it — wrap at either end instead.
trapFocus(event) {
const focusable = [...this.panelTarget.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)].filter((el) => !el.closest('[hidden], [aria-hidden="true"]'))
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
const active = document.activeElement
if (event.shiftKey && (active === first || !this.panelTarget.contains(active) || (this.hasHeadingTarget && active === this.headingTarget))) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && (active === last || !this.panelTarget.contains(active))) {
event.preventDefault()
first.focus()
}
}
showError(message) { showError(message) {
if (!this.hasErrorTarget || !message) return if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message this.errorTarget.textContent = message
@@ -144,10 +180,53 @@ export default class extends Controller {
} }
replaceBody(html) { replaceBody(html) {
const restore = this.focusSnapshot()
this.bodyTarget.innerHTML = html this.bodyTarget.innerHTML = html
restore()
this.announce()
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]')) this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
} }
// Swapping the body destroys whatever control had focus (a qty stepper,
// a remove button, the coupon field), dropping keyboard/screen-reader
// users back at the top of the document. Returns a callback that, after
// the swap, re-focuses the equivalent control in the new markup — or the
// heading, when that control is gone (e.g. its line was just removed).
focusSnapshot() {
const active = document.activeElement
if (!active || !this.bodyTarget.contains(active)) return () => {}
let selector = null
if (active.id) {
selector = `#${CSS.escape(active.id)}`
} else {
const lineId = active.closest('[data-bbk-line-id]')?.dataset.bbkLineId
const dir = active.dataset.bbkCartDirParam
const control = ['bbk-cart-qty-input', 'bbk-cart-qty-btn', 'bbk-cart-item-remove']
.find((name) => active.classList.contains(name))
if (lineId && control) {
selector = `[data-bbk-line-id="${CSS.escape(lineId)}"] .${control}`
+ (dir ? `[data-bbk-cart-dir-param="${CSS.escape(dir)}"]` : '')
}
}
return () => {
const target = selector && this.bodyTarget.querySelector(selector)
if (target) target.focus({ preventScroll: true })
else if (this.hasHeadingTarget) this.headingTarget.focus({ preventScroll: true })
}
}
// Polite "Cart updated" — cleared first so an identical message is
// re-announced on the next update.
announce() {
if (!this.hasStatusTarget) return
const message = this.statusTarget.dataset.bbkCartMessage || ''
this.statusTarget.textContent = ''
requestAnimationFrame(() => { this.statusTarget.textContent = message })
}
emitUpdated(node) { emitUpdated(node) {
if (!node) return if (!node) return
@@ -123,12 +123,34 @@ export default class extends Controller {
} }
async selectShipping(event) { async selectShipping(event) {
this.toggleBoxNowLocker(event.target.value)
// Tracked so flush() can await it — nothing else stops "place order" // Tracked so flush() can await it — nothing else stops "place order"
// (a separate, unrelated click) from racing ahead of this request. // (a separate, unrelated click) from racing ahead of this request.
this.shippingPromise = this.doSelectShipping(event.target.value) this.shippingPromise = this.doSelectShipping(event.target.value)
await this.shippingPromise 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) { async doSelectShipping(value) {
this.saveController?.abort() this.saveController?.abort()
this.setStatus('saving') this.setStatus('saving')
@@ -170,6 +192,16 @@ export default class extends Controller {
detail: { html: data.summaryHtml }, 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) { applyErrors(errors) {
@@ -17,7 +17,7 @@ const POLL_TIMEOUT = 30000
// poll /order-status until the webhook places it // poll /order-status until the webhook places it
// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable // { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable
export default class extends Controller { export default class extends Controller {
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText'] static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText', 'methods']
static values = { static values = {
selectUrl: String, selectUrl: String,
@@ -56,11 +56,26 @@ export default class extends Controller {
} }
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate) 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() if (this.selectedIsStripe()) this.mountStripe()
} }
disconnect() { disconnect() {
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate) window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
window.removeEventListener('bbk-checkout:payment-methods-changed', this.onPaymentMethodsChanged)
this.unmountStripe() this.unmountStripe()
} }
+3
View File
@@ -1,4 +1,5 @@
import BbkAddToCartController from './bbk-add-to-cart-controller' import BbkAddToCartController from './bbk-add-to-cart-controller'
import BbkBoxNowLockerController from './bbk-box-now-locker-controller'
import BbkCartController from './bbk-cart-controller' import BbkCartController from './bbk-cart-controller'
import BbkCheckoutFormController from './bbk-checkout-form-controller' import BbkCheckoutFormController from './bbk-checkout-form-controller'
import BbkPaymentController from './bbk-payment-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; // 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. // only that one import line in the host entry point differs per project.
export function registerCheckout(application) { 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-add-to-cart', BbkAddToCartController)
application.register('bbk-box-now-locker', BbkBoxNowLockerController)
application.register('bbk-cart', BbkCartController) application.register('bbk-cart', BbkCartController)
application.register('bbk-checkout-form', BbkCheckoutFormController) application.register('bbk-checkout-form', BbkCheckoutFormController)
application.register('bbk-payment', BbkPaymentController) 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 (`import { … } from "@boboko/core"`), 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
}
}
}
+76 -46
View File
@@ -2,6 +2,9 @@
Order confirmation. Reached only via a session flash of the placed order id Order confirmation. Reached only via a session flash of the placed order id
(CheckoutController::confirmation) — not deep-linkable. $order is a (CheckoutController::confirmation) — not deep-linkable. $order is a
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded. Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
$bankTransferInstructions is already-sanitized HTML from
StoreDetailsService::bankTransferInstructionsFor(), or null unless this
is a bank transfer order with instructions filled in for this locale.
--}} --}}
@extends('layouts.app') @extends('layouts.app')
@@ -11,10 +14,19 @@
<div class="bbk-confirmation"> <div class="bbk-confirmation">
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1> <h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
<div class="bbk-notice bbk-notice--info">
<svg class="bbk-notice-icon" aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="20" height="20">
<circle cx="10" cy="10" r="8.25" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M10 9v5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="10" cy="6.25" r="1" fill="currentColor"/>
</svg>
<p class="bbk-notice-text">{{ __('checkout.page.confirmation_email_note') }}</p>
</div>
<dl class="bbk-confirmation-meta"> <dl class="bbk-confirmation-meta">
<div class="bbk-confirmation-meta-row"> <div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt> <dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
<dd>{{ $order->reference }}</dd> <dd>#{{ \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order) }}</dd>
</div> </div>
@if ($order->billingAddress?->contact_email) @if ($order->billingAddress?->contact_email)
@@ -39,8 +51,6 @@
@endif @endif
</dl> </dl>
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
{{-- Guests: logging in with the order's email attaches it to an account {{-- Guests: logging in with the order's email attaches it to an account
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin), (boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
so it shows in their history. --}} so it shows in their history. --}}
@@ -53,21 +63,29 @@
@endif @endif
@endguest @endguest
<div class="bbk-confirmation-body"> <section class="bbk-confirmation-section" aria-labelledby="bbk-confirmation-summary-heading">
<div class="bbk-confirmation-lines"> <h2 class="bbk-confirmation-section-heading" id="bbk-confirmation-summary-heading">
{{ __('checkout.page.order_summary_heading') }}
</h2>
<ul class="bbk-confirmation-lines">
@foreach ($order->lines->where('type', '!=', 'shipping') as $line) @foreach ($order->lines->where('type', '!=', 'shipping') as $line)
<div class="bbk-confirmation-line"> <li class="bbk-confirmation-line">
<div class="bbk-cart-item-media"> <div class="bbk-cart-item-media">
{{-- alt="" — the description is right beside it. --}}
@if ($thumb = $line->purchasable?->getThumbnailImage()) @if ($thumb = $line->purchasable?->getThumbnailImage())
<img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy"> <img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
@endif @endif
</div> </div>
<div class="bbk-confirmation-line-detail"> <div class="bbk-confirmation-line-detail">
<span class="bbk-confirmation-line-name"> <p class="bbk-confirmation-line-name">
{{ $line->description }} {{ $line->description }}
<span class="bbk-confirmation-line-qty">&times; {{ $line->quantity }}</span> <span class="bbk-confirmation-line-qty">
</span> <span aria-hidden="true">&times; {{ $line->quantity }}</span>
<span class="bbk-visually-hidden">— {{ __('checkout.cart.quantity') }}: {{ $line->quantity }}</span>
</span>
</p>
@if ($line->option) @if ($line->option)
<p class="bbk-cart-item-variant">{{ $line->option }}</p> <p class="bbk-cart-item-variant">{{ $line->option }}</p>
@@ -76,57 +94,69 @@
@include('checkout::partials.line-custom-fields', ['line' => $line]) @include('checkout::partials.line-custom-fields', ['line' => $line])
</div> </div>
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span> <p class="bbk-confirmation-line-total">
</div> <span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
{{ $line->sub_total?->formatted() }}
</p>
</li>
@endforeach @endforeach
</ul>
<div class="bbk-cart-summary"> <div class="bbk-cart-summary">
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $order->sub_total?->formatted() }}</span>
</div>
@if ($order->discount_total?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $order->discount_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $order->shipping_total?->formatted() }}</span>
</div>
@if ($order->tax_total?->value > 0)
<div class="bbk-cart-summary-row"> <div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span> <span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $order->sub_total?->formatted() }}</span> <span>{{ $order->tax_total->formatted() }}</span>
</div> </div>
@endif
@if ($order->discount_total?->value > 0) <div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount"> <span>{{ __('checkout.cart.total') }}</span>
<span>{{ __('checkout.cart.discount') }}</span> <span>{{ $order->total?->formatted() }}</span>
<span>&minus;{{ $order->discount_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $order->shipping_total?->formatted() }}</span>
</div>
@if ($order->tax_total?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $order->tax_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $order->total?->formatted() }}</span>
</div>
</div> </div>
</div> </div>
</section>
<div class="bbk-confirmation-addresses"> @if ($order->shippingAddress || $order->billingAddress)
<div class="bbk-confirmation-section bbk-confirmation-addresses">
@if ($order->shippingAddress) @if ($order->shippingAddress)
<div class="bbk-confirmation-address"> <section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-shipping-heading">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2> <h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-shipping-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<x-checkout::address-lines :address="$order->shippingAddress" /> <x-checkout::address-lines :address="$order->shippingAddress" />
</div> </section>
@endif @endif
@if ($order->billingAddress) @if ($order->billingAddress)
<div class="bbk-confirmation-address"> <section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-billing-heading">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2> <h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-billing-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<x-checkout::address-lines :address="$order->billingAddress" /> <x-checkout::address-lines :address="$order->billingAddress" />
</div> </section>
@endif @endif
</div> </div>
</div> @endif
@if ($bankTransferInstructions)
<section class="bbk-confirmation-section bbk-confirmation-bank-transfer" aria-labelledby="bbk-confirmation-bank-transfer-heading">
<h2 class="bbk-confirmation-bank-transfer-heading" id="bbk-confirmation-bank-transfer-heading">{{ __('checkout.page.confirmation_bank_transfer_heading') }}</h2>
<div class="bbk-confirmation-bank-transfer-body">{!! $bankTransferInstructions !!}</div>
</section>
@endif
</div> </div>
@endsection @endsection
+13 -2
View File
@@ -15,7 +15,9 @@
data-bbk-cart-target="panel" data-bbk-cart-target="panel"
> >
<header class="bbk-cart-panel-header"> <header class="bbk-cart-panel-header">
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2> {{-- tabindex=-1: the controller moves focus here on open, and back
here when the focused line is removed from under the user. --}}
<h2 class="bbk-cart-heading" id="bbk-cart-heading" tabindex="-1" data-bbk-cart-target="heading">{{ __('checkout.cart.title') }}</h2>
<button <button
type="button" type="button"
class="bbk-cart-dismiss" class="bbk-cart-dismiss"
@@ -26,7 +28,16 @@
@include('checkout::partials.cart-error') @include('checkout::partials.cart-error')
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite"> {{-- A short announcement after each update, rather than aria-live on
the body itself, which re-read the whole cart on every change. --}}
<p
class="bbk-visually-hidden"
role="status"
data-bbk-cart-target="status"
data-bbk-cart-message="{{ __('checkout.cart.updated') }}"
></p>
<div class="bbk-cart-panel-body" data-bbk-cart-target="body">
@include('checkout::partials.cart-body') @include('checkout::partials.cart-body')
</div> </div>
</aside> </aside>
+9 -3
View File
@@ -219,7 +219,7 @@
<section class="bbk-checkout-section"> <section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2> <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', [ @include('checkout::partials.payment-methods', [
'paymentMethods' => $paymentMethods, 'paymentMethods' => $paymentMethods,
'cart' => $cart, 'cart' => $cart,
@@ -267,9 +267,15 @@
<aside class="bbk-checkout-aside"> <aside class="bbk-checkout-aside">
<div class="bbk-checkout-summary" data-controller="bbk-cart"> <div class="bbk-checkout-summary" data-controller="bbk-cart">
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2> <h2 class="bbk-checkout-summary-heading" tabindex="-1" data-bbk-cart-target="heading">{{ __('checkout.page.order_summary_heading') }}</h2>
@include('checkout::partials.cart-error') @include('checkout::partials.cart-error')
<div data-bbk-cart-target="body" aria-live="polite"> <p
class="bbk-visually-hidden"
role="status"
data-bbk-cart-target="status"
data-bbk-cart-message="{{ __('checkout.cart.updated') }}"
></p>
<div data-bbk-cart-target="body">
@include('checkout::partials.cart-body') @include('checkout::partials.cart-body')
</div> </div>
</div> </div>
@@ -24,80 +24,96 @@
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}"> <li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
<div class="bbk-cart-item-media"> <div class="bbk-cart-item-media">
@if ($thumb) @if ($thumb)
{{-- Decorative duplicate of the title link below — hidden from AT
and skipped by keyboard so the product isn't announced twice. --}}
@if ($productUrl) @if ($productUrl)
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1"> <a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy"> <img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
</a> </a>
@else @else
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy"> <img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
@endif @endif
@endif @endif
</div> </div>
<div class="bbk-cart-item-detail"> <div class="bbk-cart-item-detail">
@if ($productUrl) <div class="bbk-cart-item-head">
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a> @if ($productUrl)
@else <a href="{{ $productUrl }}" class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</a>
<p class="bbk-cart-item-title">{{ $name }}</p> @else
@endif <p class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</p>
@endif
<form
class="bbk-cart-item-remove-form"
method="POST"
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
{{-- Named "Remove", described by the product title, so AT
hears which line it removes rather than a bare "Remove". --}}
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
aria-describedby="bbk-cart-item-title-{{ $line->id }}"
><span aria-hidden="true">&times;</span></button>
</form>
</div>
@if ($variantLabel) @if ($variantLabel)
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p> <p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
@endif @endif
@include('checkout::partials.line-custom-fields', ['line' => $line]) @include('checkout::partials.line-custom-fields', ['line' => $line])
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p> <p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
<form <div class="bbk-cart-item-foot">
class="bbk-cart-qty" {{-- role=group + the title as its name: entering the stepper
method="POST" announces which product's quantity is being changed. --}}
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}" <form
> class="bbk-cart-qty"
@csrf method="POST"
@method('PATCH') action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
<button role="group"
type="button" aria-labelledby="bbk-cart-item-title-{{ $line->id }}"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}"
>&minus;</button>
<input
type="number"
name="quantity"
value="{{ $line->quantity }}"
min="0"
inputmode="numeric"
class="bbk-cart-qty-input"
data-action="change->bbk-cart#submit"
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
aria-label="{{ __('checkout.cart.quantity') }}"
> >
@csrf
@method('PATCH')
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}"
><span aria-hidden="true">&minus;</span></button>
<button <input
type="button" type="number"
class="bbk-cart-qty-btn" name="quantity"
data-action="bbk-cart#step" value="{{ $line->quantity }}"
data-bbk-cart-dir-param="1" min="0"
aria-label="{{ __('checkout.cart.increase') }}" inputmode="numeric"
>+</button> class="bbk-cart-qty-input"
</form> data-action="change->bbk-cart#submit"
</div> data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
aria-label="{{ __('checkout.cart.quantity') }}"
>
<div class="bbk-cart-item-aside"> <button
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p> type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}"
><span aria-hidden="true">+</span></button>
</form>
<form <p class="bbk-cart-item-total">
method="POST" <span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}" {{ $line->subTotal?->formatted() }}
data-action="submit->bbk-cart#submit" </p>
> </div>
@csrf
@method('DELETE')
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
>&times;</button>
</form>
</div> </div>
</li> </li>
@@ -19,9 +19,9 @@
<dl class="bbk-line-fields"> <dl class="bbk-line-fields">
@foreach ($fields as $field) @foreach ($fields as $field)
<div class="bbk-line-field"> <div class="bbk-line-field">
<dt>{{ $field['label'] }}</dt> @if ($field['type'] === 'file')
<dd> <dt>{{ $field['label'] }}:</dt>
@if ($field['type'] === 'file') <dd>
@php @php
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null); $file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
@endphp @endphp
@@ -39,11 +39,12 @@
@endif @endif
<span>{{ $file->original_name }}</span> <span>{{ $file->original_name }}</span>
</a> </a>
@endif @endif
@else </dd>
{{ $field['value'] }} @else
@endif <span>{{ $field['label'] }}: {{ $field['value'] }}</span>
</dd> @endif
</div> </div>
@endforeach @endforeach
</dl> </dl>
@@ -48,3 +48,59 @@
@endforeach @endforeach
</div> </div>
@endif @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>
+13 -1
View File
@@ -11,7 +11,7 @@ class Staff extends ModelsStaff
'last_name', 'last_name',
'admin', 'admin',
'email', 'email',
'otp_code', 'otp_code_hash',
'otp_expires_at', 'otp_expires_at',
]; ];
@@ -19,6 +19,18 @@ class Staff extends ModelsStaff
'admin' => 'bool', 'admin' => 'bool',
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'otp_code_hash' => 'hashed',
'otp_expires_at' => 'datetime', 'otp_expires_at' => 'datetime',
]; ];
// Overrides (doesn't merge with) Lunar\Admin\Models\Staff's own
// $hidden — repeats its password/remember_token here so this class
// doesn't silently drop that protection while adding otp_code_hash/
// otp_expires_at, which the base model has no reason to know about.
protected $hidden = [
'password',
'remember_token',
'otp_code_hash',
'otp_expires_at',
];
} }
+11 -3
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Auth\Services; namespace Modules\Core\Auth\Services;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Modules\Core\Auth\Mail\OtpMail; use Modules\Core\Auth\Mail\OtpMail;
use Modules\Core\Auth\Models\Staff; use Modules\Core\Auth\Models\Staff;
@@ -27,7 +28,10 @@ class OtpService
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT); $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
$staff->otp_code = $code; // otp_code_hash's 'hashed' cast (see Staff's own $casts) hashes
// this automatically on assignment, same as password — never
// stored or compared in plaintext.
$staff->otp_code_hash = $code;
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); $staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$staff->save(); $staff->save();
@@ -44,11 +48,15 @@ class OtpService
return null; return null;
} }
if (! $staff->otp_expires_at || $staff->otp_code != $code || now()->isAfter($staff->otp_expires_at)) { if (! $staff->otp_code_hash || ! $staff->otp_expires_at || now()->isAfter($staff->otp_expires_at)) {
return null; return null;
} }
$staff->otp_code = null; if (! Hash::check($code, $staff->otp_code_hash)) {
return null;
}
$staff->otp_code_hash = null;
$staff->otp_expires_at = null; $staff->otp_expires_at = null;
$staff->save(); $staff->save();
+17 -9
View File
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserAuthenticated; use Modules\Core\Auth\Events\UserAuthenticated;
@@ -40,8 +41,11 @@ use Modules\Core\Auth\Mail\UserOtpMail;
* at all — firstOrCreate() and UserCreated only fire from validate(), and * at all — firstOrCreate() and UserCreated only fire from validate(), and
* only once the code has actually been proven correct. An email that * only once the code has actually been proven correct. An email that
* already has a User row is unaffected: its OTP state still lives on that * already has a User row is unaffected: its OTP state still lives on that
* row's own otp_code/otp_expires_at/otp_attempts columns exactly as * row's own otp_code_hash/otp_expires_at/otp_attempts columns exactly as
* before, so a returning shopper's login is unchanged. * before, so a returning shopper's login is unchanged. otp_code_hash
* holds a bcrypt hash of the code (the 'otp_code_hash' => 'hashed' cast
* on App\Models\User hashes it automatically on assignment, same as
* password), not the code itself — compared via Hash::check().
* *
* Two independent throttles, both configured under core.auth.otp — see * Two independent throttles, both configured under core.auth.otp — see
* config/core.php's own comment for why they're separate: max_attempts * config/core.php's own comment for why they're separate: max_attempts
@@ -87,7 +91,7 @@ class UserOtpService
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT); $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
if ($user) { if ($user) {
$user->otp_code = $code; $user->otp_code_hash = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); $user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->otp_attempts = 0; $user->otp_attempts = 0;
$user->save(); $user->save();
@@ -96,8 +100,12 @@ class UserOtpService
// class's own docblock for why: creating one on every // class's own docblock for why: creating one on every
// generateAndSend() call let anyone mint real User/Customer // generateAndSend() call let anyone mint real User/Customer
// rows for an email nobody proved they owned. // rows for an email nobody proved they owned.
//
// Hashed even in the cache (not just on the DB-backed path)
// — a code sitting in Cache::get()-able storage is the same
// exposure as a plaintext DB column if anything can read it.
Cache::put($this->pendingKey($email), [ Cache::put($this->pendingKey($email), [
'code' => $code, 'code_hash' => Hash::make($code),
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp, 'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
'attempts' => 0, 'attempts' => 0,
], now()->addMinutes(self::EXPIRY_MINUTES)); ], now()->addMinutes(self::EXPIRY_MINUTES));
@@ -154,15 +162,15 @@ class UserOtpService
return DB::transaction(function () use ($model, $email, $code) { return DB::transaction(function () use ($model, $email, $code) {
$user = $model::where('email', $email)->lockForUpdate()->first(); $user = $model::where('email', $email)->lockForUpdate()->first();
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) { if (! $user || ! $user->otp_code_hash || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
return null; return null;
} }
if (! hash_equals((string) $user->otp_code, $code)) { if (! Hash::check($code, $user->otp_code_hash)) {
$user->otp_attempts++; $user->otp_attempts++;
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) { if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
$user->otp_code = null; $user->otp_code_hash = null;
$user->otp_expires_at = null; $user->otp_expires_at = null;
$user->otp_attempts = 0; $user->otp_attempts = 0;
} }
@@ -172,7 +180,7 @@ class UserOtpService
return null; return null;
} }
$user->otp_code = null; $user->otp_code_hash = null;
$user->otp_expires_at = null; $user->otp_expires_at = null;
$user->otp_attempts = 0; $user->otp_attempts = 0;
$user->save(); $user->save();
@@ -200,7 +208,7 @@ class UserOtpService
return null; return null;
} }
if (! hash_equals((string) $pending['code'], $code)) { if (! Hash::check($code, $pending['code_hash'])) {
$pending['attempts']++; $pending['attempts']++;
if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) { if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) {
+10 -1
View File
@@ -3,6 +3,8 @@
namespace Modules\Core\Catalog\Recommendations; namespace Modules\Core\Catalog\Recommendations;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Product as ProductContract;
use Lunar\Models\Product; use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule; use Modules\Core\Catalog\Contracts\RecommendationRule;
@@ -18,7 +20,14 @@ class RandomRule implements RecommendationRule
{ {
public function recommend(Product $product, int $limit, array $exclude): Collection 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) ->whereKeyNot($exclude)
->inRandomOrder() ->inRandomOrder()
->limit($limit) ->limit($limit)
+17
View File
@@ -189,6 +189,23 @@ class ProductIndexer extends BaseProductIndexer
'name' => $recommendation->translateAttribute('name'), 'name' => $recommendation->translateAttribute('name'),
'price' => $this->cheapestPrice($recommendation, $currency), 'price' => $this->cheapestPrice($recommendation, $currency),
'image' => $recommendation->media->first() ? $this->mapMedia($recommendation->media->first())['thumb'] : null, '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(); ->all();
@@ -60,6 +60,7 @@ class CheckoutTranslationsSeeder extends Seeder
'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'], 'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'],
'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'], 'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'],
'cart.remove' => ['Remove', 'Αφαίρεση'], 'cart.remove' => ['Remove', 'Αφαίρεση'],
'cart.updated' => ['Cart updated', 'Το καλάθι ενημερώθηκε'],
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'], 'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
'cart.discount' => ['Discount', 'Έκπτωση'], 'cart.discount' => ['Discount', 'Έκπτωση'],
'cart.shipping' => ['Shipping', 'Μεταφορικά'], 'cart.shipping' => ['Shipping', 'Μεταφορικά'],
@@ -83,6 +84,12 @@ class CheckoutTranslationsSeeder extends Seeder
"Email me a reminder if I don't finish my order", "Email me a reminder if I don't finish my order",
'Στείλε μου μια υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου', 'Στείλε μου μια υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
], ],
'page.logged_in_as' => ['Logged in as', 'Συνδεδεμένος/η ως'],
'page.login_prompt' => [
'Already have an account?',
'Έχεις ήδη λογαριασμό;',
],
'page.login_link' => ['Log in', 'Σύνδεση'],
'page.login_email_label' => ['Email', 'Email'], 'page.login_email_label' => ['Email', 'Email'],
'page.send_code' => ['Send code', 'Αποστολή κωδικού'], 'page.send_code' => ['Send code', 'Αποστολή κωδικού'],
'page.login_coming_soon' => [ 'page.login_coming_soon' => [
@@ -95,6 +102,7 @@ class CheckoutTranslationsSeeder extends Seeder
'page.first_name' => ['First name', 'Όνομα'], 'page.first_name' => ['First name', 'Όνομα'],
'page.last_name' => ['Last name', 'Επώνυμο'], 'page.last_name' => ['Last name', 'Επώνυμο'],
'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'], 'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'],
'page.wants_invoice' => ['I need an invoice', 'Θέλω τιμολόγιο'],
'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'], 'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'],
'page.address_line_one' => ['Address', 'Διεύθυνση'], 'page.address_line_one' => ['Address', 'Διεύθυνση'],
'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'], 'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'],
@@ -178,8 +186,44 @@ class CheckoutTranslationsSeeder extends Seeder
'Θα λάβεις email επιβεβαίωσης σύντομα.', 'Θα λάβεις email επιβεβαίωσης σύντομα.',
], ],
'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'], 'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'],
'page.confirmation_login_hint' => [
'Want to track this order? Create an account or',
'Θέλεις να παρακολουθείς την παραγγελία σου; Δημιούργησε λογαριασμό ή',
],
'page.confirmation_billing' => ['Billing', 'Χρέωση'], 'page.confirmation_billing' => ['Billing', 'Χρέωση'],
'page.confirmation_bank_transfer_heading' => [
'Bank transfer details',
'Στοιχεία τραπεζικής μεταφοράς',
],
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'], '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,15 @@ use Lunar\Models\Order;
use Lunar\Models\State; use Lunar\Models\State;
use Modules\Core\Cart\Services\CartService; use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\NoShippingAddressException;
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException; use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException; use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Checkout\Services\CheckoutService; use Modules\Core\Checkout\Services\CheckoutService;
use Modules\Core\Customer\Services\CustomerAccountService; use Modules\Core\Customer\Services\CustomerAccountService;
use Modules\Core\Payment\Enums\PaymentResultStatus; use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Store\Services\StoreDetailsService;
/** /**
* The checkout page — one page, sections (contact / billing / shipping / * The checkout page — one page, sections (contact / billing / shipping /
@@ -280,6 +283,77 @@ class CheckoutController extends Controller
return $this->fragments($cart, $options); 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 * Autosave-select a payment method (radio change). Persists it via
* CheckoutService (which also records it on Cart::meta and re-snapshots * CheckoutService (which also records it on Cart::meta and re-snapshots
@@ -481,6 +555,8 @@ class CheckoutController extends Controller
return view('checkout::confirmation', [ return view('checkout::confirmation', [
'order' => $order, 'order' => $order,
'paymentMethodName' => $paymentMethodName, 'paymentMethodName' => $paymentMethodName,
'bankTransferInstructions' => app(StoreDetailsService::class)
->bankTransferInstructionsFor($order, $locale),
]); ]);
} }
@@ -573,6 +649,18 @@ class CheckoutController extends Controller
])->render(), ])->render(),
// Composer (Providers\CheckoutModuleServiceProvider) fills $cart / $lines. // Composer (Providers\CheckoutModuleServiceProvider) fills $cart / $lines.
'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(), '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\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException; use Lunar\Exceptions\Carts\CartException;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Lunar\Base\Addressable; use Lunar\Base\Addressable;
use Lunar\DataTypes\ShippingOption; 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\NoShippingAddressException;
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException; use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException; use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Payment\Contracts\RequiresFulfillmentType; use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry; use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache; use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
use Modules\Core\Shipping\Support\FulfillmentType; use Modules\Core\Shipping\Support\FulfillmentType;
/** /**
@@ -253,42 +256,59 @@ class CheckoutService
* a courier delivery makes no sense (no staff member present at * a courier delivery makes no sense (no staff member present at
* handoff to take cash), and cash-on-delivery alongside store * handoff to take cash), and cash-on-delivery alongside store
* pickup is equally meaningless (OfflinePaymentDriver already * pickup is equally meaningless (OfflinePaymentDriver already
* covers that in-person moment). A cart with no shipping option * covers that in-person moment).
* selected yet imposes no constraint here — every method is * 5. for cash-on-delivery specifically, the cart's currently selected
* offered until a fulfillment type is actually known, the same * shipping method's own driver implements Modules\Core\Shipping\
* leniency setShippingAddress()'s own docblock describes for * Contracts\SupportsCashCollection — "carrier" alone
* required-field enforcement happening at the payment gate, not * (RequiresFulfillmentType) isn't precise enough, since an
* mid-checkout. * 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> * @return Collection<int, PaymentMethod>
*/ */
public function getPaymentMethods(): Collection public function getPaymentMethods(): Collection
{ {
$fulfillmentType = $this->currentFulfillmentType(); $shippingMethod = $this->currentShippingMethod();
$fulfillmentType = $shippingMethod ? FulfillmentType::resolve($shippingMethod) : null;
return $this->paymentMethods->all() return $this->paymentMethods->all()
->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null) ->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); $driver = $this->paymentDrivers->resolve($method->driver);
if (! $driver?->isConfigured()) { if (! $driver?->isConfigured()) {
return false; return false;
} }
if ($fulfillmentType === null || ! $driver instanceof RequiresFulfillmentType) { if ($fulfillmentType !== null && $driver instanceof RequiresFulfillmentType
return true; && $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(); ->values();
} }
/** private function currentShippingMethod(): ?ShippingMethod
* @return 'carrier'|'store_pickup'|null null when the cart has no
* shipping option selected yet
*/
private function currentFulfillmentType(): ?string
{ {
$identifier = $this->cart->currentOrCreate()->shippingAddress?->shipping_option; $identifier = $this->cart->currentOrCreate()->shippingAddress?->shipping_option;
@@ -296,9 +316,7 @@ class CheckoutService
return null; return null;
} }
$method = ShippingMethod::where('code', $identifier)->first(); return ShippingMethod::where('code', $identifier)->first();
return $method ? FulfillmentType::resolve($method) : null;
} }
/** /**
@@ -431,6 +449,22 @@ class CheckoutService
'terms_accepted' => true, 'terms_accepted' => true,
'terms_accepted_at' => now()->toIso8601String(), 'terms_accepted_at' => now()->toIso8601String(),
'terms_accepted_policy_version' => $policyVersion, '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(); $order->save();
+8
View File
@@ -14,6 +14,7 @@ use Modules\Core\Checkout\Http\Controllers\CheckoutController;
* Loaded from Providers\CheckoutModuleServiceProvider inside the `web` * Loaded from Providers\CheckoutModuleServiceProvider inside the `web`
* middleware group. * middleware group.
*/ */
Route::prefix('{locale}') Route::prefix('{locale}')
->middleware('locale') ->middleware('locale')
->group(function () { ->group(function () {
@@ -27,6 +28,13 @@ Route::prefix('{locale}')
Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption']) Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption'])
->name('checkout.shipping-option.select'); ->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']) Route::post('checkout/payment-method', [CheckoutController::class, 'selectPaymentMethod'])
->name('checkout.payment-method.select'); ->name('checkout.payment-method.select');
+2 -34
View File
@@ -18,9 +18,6 @@ use Lunar\Models\Product;
use Lunar\Models\ProductType; use Lunar\Models\ProductType;
use Lunar\Models\TaxClass; use Lunar\Models\TaxClass;
use Lunar\Models\TaxZone; 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; 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)'; 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...'); $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->components->info('Seeding payment method settings');
$this->seedPaymentMethods(); $this->seedPaymentMethods();
@@ -267,32 +261,6 @@ class InstallLunarCommand extends Command
$this->components->info('Lunar default data seeded.'); $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 — * A single, deliberately opinionated starter row on fresh install —
* `PaymentMethod` is now fully admin-creatable/deletable (see * `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. * order status should be called; that's a merchant decision.
* *
* Skip-if-exists on `type`, same idempotent convention as * 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; * this row (via the Filament Payment Methods resource) is left alone;
* re-running lunar:install never recreates a deleted starter row. * re-running lunar:install never recreates a deleted starter row.
* *
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Illuminate\Database\Seeder;
use Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder;
use Modules\Core\Localization\Database\Seeders\StorefrontTranslationsSeeder;
use Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder;
use Modules\Core\Localization\Models\LanguageLine;
use ReflectionClass;
use ReflectionMethod;
/**
* The reverse of the translation seeders: copies lines added in the Filament
* Language Lines UI (e.g. while building the storefront) into the matching
* seeder's lines(), so they ship with core and every app gets them.
*
* Only adds keys the seeder doesn't have yet — a key that already exists in
* the seeder is left alone even if its text was edited in the database.
* New lines are appended at the end of lines() under a marker comment, to be
* moved into the right section by hand.
*
* Writes into the seeder files the app actually loaded, so it only makes
* sense in local mode, where vendor/boboko/core is a symlink to the
* ../boboko-core checkout. Against an installed copy it refuses to write;
* --dry-run works anywhere.
*/
class PullTranslationsCommand extends Command
{
protected $signature = 'boboko:translations:pull {--dry-run : Show what would be added without writing}';
protected $description = 'Add translation lines that exist in the database but not in the core translation seeders';
/** @var array<string, class-string<Seeder>> */
private const SEEDERS = [
'storefront' => StorefrontTranslationsSeeder::class,
'checkout' => CheckoutTranslationsSeeder::class,
'validation' => ValidationTranslationsSeeder::class,
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$total = 0;
foreach (self::SEEDERS as $group => $seederClass) {
$file = realpath((new ReflectionClass($seederClass))->getFileName());
if (! $dryRun && str_contains($file, '/vendor/')) {
$this->error("{$file} is an installed copy, not your ../boboko-core checkout.");
$this->line('Switch to local mode first (bin/core-mode local), or use --dry-run.');
return self::FAILURE;
}
$known = (new ReflectionMethod($seederClass, 'lines'))->invoke(new $seederClass);
$missing = LanguageLine::query()
->where('group', $group)
->whereNotIn('key', array_keys($known))
->orderBy('key')
->get();
if ($missing->isEmpty()) {
$this->line("{$group}: nothing missing");
continue;
}
$entries = '';
foreach ($missing as $line) {
$en = $line->text['en'] ?? '';
$el = $line->text['el'] ?? '';
if ($en === '' || $el === '') {
$this->warn(" {$group}.{$line->key} has no ".($en === '' ? 'English' : 'Greek').' text — added empty, fill it in');
}
$entries .= $this->entry($line->key, $en, $el);
$this->info(" + {$group}.{$line->key}");
}
$total += $missing->count();
if (! $dryRun && ! $this->append($file, $entries)) {
return self::FAILURE;
}
}
$this->newLine();
$this->line($dryRun
? "{$total} line(s) would be added."
: "{$total} line(s) added — move them into the right section and commit core.");
return self::SUCCESS;
}
/**
* One lines() entry in the seeders' own style: single line when short,
* split over several lines when long.
*/
private function entry(string $key, string $en, string $el): string
{
[$key, $en, $el] = array_map(fn (string $value) => var_export($value, true), [$key, $en, $el]);
$single = " {$key} => [{$en}, {$el}],\n";
if (mb_strlen($single) <= 120) {
return $single;
}
return " {$key} => [\n {$en},\n {$el},\n ],\n";
}
/**
* Inserts the entries right before the closing `];` of lines(), then
* lints the file and restores the original if the result doesn't parse.
*/
private function append(string $file, string $entries): bool
{
$original = file_get_contents($file);
$method = strpos($original, 'function lines(): array');
$close = $method === false ? false : strpos($original, "\n ];\n }", $method);
if ($close === false) {
$this->error("Couldn't find the end of lines() in {$file} — add these by hand.");
return false;
}
$before = rtrim(substr($original, 0, $close));
// The last existing entry doesn't always have a trailing comma.
if (! str_ends_with($before, ',') && ! str_ends_with($before, '[')) {
$before .= ',';
}
$updated = $before
."\n\n // ── Pulled from the database (boboko:translations:pull) — move into the right section ──\n"
.$entries
.substr($original, $close + 1);
file_put_contents($file, $updated);
exec(PHP_BINARY.' -l '.escapeshellarg($file).' 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
file_put_contents($file, $original);
$this->error("Writing {$file} produced invalid PHP — restored the original:");
$this->line(implode("\n", $output));
return false;
}
return true;
}
}
+5 -1
View File
@@ -50,6 +50,7 @@ use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension; use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Resources\ManifestResource; use Modules\Core\Shipping\Filament\Resources\ManifestResource;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource; use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
use Modules\Core\Store\Filament\Pages\ManageStoreDetails;
class CorePlugin implements Plugin class CorePlugin implements Plugin
{ {
@@ -74,6 +75,9 @@ class CorePlugin implements Plugin
ShipmentResource::class, ShipmentResource::class,
ManifestResource::class, ManifestResource::class,
]) ])
->pages([
ManageStoreDetails::class,
])
->plugin(ShippingPlugin::make()); ->plugin(ShippingPlugin::make());
LunarPanel::extensions([ LunarPanel::extensions([
@@ -146,7 +150,7 @@ class CorePlugin implements Plugin
}); });
LunarStaff::addActivitylogExcept([ LunarStaff::addActivitylogExcept([
'otp_code', 'otp_code_hash',
'otp_expires_at', 'otp_expires_at',
'password', 'password',
'remember_token', 'remember_token',
@@ -115,7 +115,7 @@ class CustomerDataProvider implements PersonalDataProvider
// secret tied to an identity that no longer exists here — clear // secret tied to an identity that no longer exists here — clear
// it alongside name/email rather than leaving it to expire on // it alongside name/email rather than leaving it to expire on
// its own 10-minute window. // its own 10-minute window.
'otp_code' => null, 'otp_code_hash' => null,
'otp_expires_at' => null, 'otp_expires_at' => null,
'otp_attempts' => 0, 'otp_attempts' => 0,
]); ]);
@@ -23,7 +23,7 @@ use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
* hash of the code, expiry, wrong-guess count) lives on the user's own * hash of the code, expiry, wrong-guess count) lives on the user's own
* row (see the migration adding pending_email/pending_email_code_hash/ * row (see the migration adding pending_email/pending_email_code_hash/
* pending_email_expires_at/pending_email_attempts) — the same convention * pending_email_expires_at/pending_email_attempts) — the same convention
* Auth\Services\UserOtpService's otp_code/otp_expires_at/otp_attempts * Auth\Services\UserOtpService's otp_code_hash/otp_expires_at/otp_attempts
* already use — rather than the session, since a code arrives by email * already use — rather than the session, since a code arrives by email
* and is often opened on a different device/session than the one that * and is often opened on a different device/session than the one that
* requested it; a session-scoped pending change couldn't be confirmed * requested it; a session-scoped pending change couldn't be confirmed
@@ -0,0 +1,322 @@
<?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'],
// ── Error pages ─────────────────────────────────────────────
'errors.404_title' => ['Page not found', 'Η σελίδα δεν βρέθηκε'],
'errors.404_text' => [
'The page you are looking for does not exist or has been moved.',
'Η σελίδα που αναζητάς δεν υπάρχει ή έχει μετακινηθεί.',
],
'errors.back_home' => ['Back to home', 'Επιστροφή στην αρχική'],
// ── 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) { if (isset($event->delaySeconds) && $event->delaySeconds > 0) {
$notification->delay($event->delaySeconds); $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); $notification->notifiable()->notify($notification);
} catch (Throwable $e) { } catch (Throwable $e) {
report($e); report($e);
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Event;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Services\OrderPaymentResolutionService; use Modules\Core\Order\Services\OrderPaymentResolutionService;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Payment\Events\PaymentDeferred; 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 * visibility and stock decrement, but nothing ever moved `status` off its
* initial value, since resolveCaptureOrAuthorization() only does that for * initial value, since resolveCaptureOrAuthorization() only does that for
* an actual capture. Caught and fixed after the fact. * 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 class MarkOrderPlacedOnDeferredPayment
{ {
public function __construct( public function __construct(
private readonly OrderPaymentResolutionService $resolution, private readonly OrderPaymentResolutionService $resolution,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(PaymentDeferred $event): void public function handle(PaymentDeferred $event): void
@@ -53,7 +61,9 @@ class MarkOrderPlacedOnDeferredPayment
$order = Order::findOrFail($orderId); $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)) { if (! blank($order->placed_at)) {
return; return;
@@ -7,7 +7,23 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade; use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderCaptured; 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 class OrderCapturedNotification extends BaseNotification
{ {
public function __construct(private readonly OrderCaptured $event) {} public function __construct(private readonly OrderCaptured $event) {}
@@ -24,6 +40,10 @@ class OrderCapturedNotification extends BaseNotification
public function via(object $notifiable): array public function via(object $notifiable): array
{ {
if (! app(OrderStatusFlow::class)->isBankTransfer($this->event->order)) {
return [];
}
return ['mail']; return ['mail'];
} }
@@ -40,10 +60,13 @@ class OrderCapturedNotification extends BaseNotification
{ {
$order = $this->event->order; $order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage) 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', [ ->view('core::order.notifications.captured', [
'reference' => $order->reference, 'order' => $order,
'reference' => $reference,
'amount' => $this->event->transaction->amount->formatted, 'amount' => $this->event->transaction->amount->formatted,
]); ]);
} }
@@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade; use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderCompleted; use Modules\Core\Order\Events\OrderCompleted;
use Modules\Core\Order\Support\OrderReferenceDisplay;
class OrderCompletedNotification extends BaseNotification class OrderCompletedNotification extends BaseNotification
{ {
@@ -40,10 +41,13 @@ class OrderCompletedNotification extends BaseNotification
{ {
$order = $this->event->order; $order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage) 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', [ ->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 Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderDelivered; use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Order\Support\OrderReferenceDisplay;
class OrderDeliveredNotification extends BaseNotification class OrderDeliveredNotification extends BaseNotification
{ {
@@ -40,10 +41,13 @@ class OrderDeliveredNotification extends BaseNotification
{ {
$order = $this->event->order; $order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage) 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', [ ->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 Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderDispatched; use Modules\Core\Order\Events\OrderDispatched;
use Modules\Core\Order\Support\OrderReferenceDisplay;
/** /**
* Fills a real, previously-unfilled customer-communication gap — before * Fills a real, previously-unfilled customer-communication gap — before
@@ -45,10 +46,13 @@ class OrderDispatchedNotification extends BaseNotification
{ {
$order = $this->event->order; $order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage) 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', [ ->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 Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderReadyForPickup; use Modules\Core\Order\Events\OrderReadyForPickup;
use Modules\Core\Order\Support\OrderReferenceDisplay;
/** /**
* "Your order is ready to collect" — listens to the specific * "Your order is ready to collect" — listens to the specific
@@ -50,10 +51,13 @@ class OrderPickupReadyNotification extends BaseNotification
{ {
$order = $this->event->order; $order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage) 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', [ ->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 Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Support\OrderReferenceDisplay;
/** /**
* The order confirmation email — fires once, for every capture_mode and * The order confirmation email — fires once, for every capture_mode and
@@ -51,10 +52,13 @@ class OrderPlacedNotification extends BaseNotification
{ {
$order = $this->event->order; $order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage) 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', [ ->view('core::order.notifications.placed', [
'reference' => $order->reference, 'order' => $order,
'reference' => $reference,
'total' => $order->total->formatted, 'total' => $order->total->formatted,
'lines' => $order->lines, 'lines' => $order->lines,
]); ]);
@@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade; use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderRefunded; use Modules\Core\Order\Events\OrderRefunded;
use Modules\Core\Order\Support\OrderReferenceDisplay;
class OrderRefundedNotification extends BaseNotification class OrderRefundedNotification extends BaseNotification
{ {
@@ -40,10 +41,13 @@ class OrderRefundedNotification extends BaseNotification
{ {
$order = $this->event->order; $order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage) 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', [ ->view('core::order.notifications.refunded', [
'reference' => $order->reference, 'order' => $order,
'reference' => $reference,
'amount' => $this->event->transaction->amount->formatted, 'amount' => $this->event->transaction->amount->formatted,
]); ]);
} }
@@ -7,6 +7,8 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade; use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderStatusUpdated; use Modules\Core\Order\Events\OrderStatusUpdated;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Support\OrderReferenceDisplay;
class OrderStatusUpdatedNotification extends BaseNotification class OrderStatusUpdatedNotification extends BaseNotification
{ {
@@ -29,6 +31,22 @@ class OrderStatusUpdatedNotification extends BaseNotification
* NotificationRegistry, so without this the customer would get two * NotificationRegistry, so without this the customer would get two
* emails for that one transition. Returning no channels is the * emails for that one transition. Returning no channels is the
* standard Laravel way to suppress a notification outright. * 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 public function via(object $notifiable): array
{ {
@@ -36,6 +54,11 @@ class OrderStatusUpdatedNotification extends BaseNotification
return []; return [];
} }
if ($this->event->previousStatus === 'awaiting_payment'
&& ! app(OrderStatusFlow::class)->isBankTransfer($this->event->order)) {
return [];
}
return ['mail']; return ['mail'];
} }
@@ -52,11 +75,18 @@ class OrderStatusUpdatedNotification extends BaseNotification
{ {
$order = $this->event->order; $order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
$statusLabel = config("lunar.orders.statuses.{$order->status}.label", $order->status);
return (new MailMessage) 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', [ ->view('core::order.notifications.status-updated', [
'reference' => $order->reference, 'order' => $order,
'statusLabel' => config("lunar.orders.statuses.{$order->status}.label", $order->status), 'reference' => $reference,
'statusLabel' => $statusLabel,
]); ]);
} }
} }
+30 -17
View File
@@ -34,6 +34,7 @@ class OrderFulfillmentService
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow, private readonly OrderStatusFlow $flow,
private readonly TransactionRecorder $transactions, private readonly TransactionRecorder $transactions,
private readonly OrderPaymentResolutionService $resolution,
) {} ) {}
public function markReady(Order $order): OrderFulfillmentResult public function markReady(Order $order): OrderFulfillmentResult
@@ -114,9 +115,13 @@ class OrderFulfillmentService
} }
/** /**
* Independent of `status` entirely — offered by the single "Update * For a COD order, independent of `status` entirely — offered by the
* Status" action regardless of current status (see * single "Update Status" action regardless of current status (see
* OrderStatusFlow::canMarkPaid()). * 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 public function markPaid(Order $order): OrderFulfillmentResult
{ {
@@ -125,18 +130,20 @@ class OrderFulfillmentService
} }
// canMarkPaid() only ever returns true for an order whose payment // canMarkPaid() only ever returns true for an order whose payment
// method resolves to the cash-on-delivery DRIVER (see // method resolves to the cash-on-delivery or bank-transfer DRIVER
// OrderStatusFlow::isCod(), which checks PaymentMethod::driver, // (see OrderStatusFlow::isCod()/isBankTransfer(), which check
// never the merchant-chosen `type` slug directly — a store could // PaymentMethod::driver, never the merchant-chosen `type` slug
// name that method "cod", "pay-on-delivery", anything). Such an // directly — a store could name that method "cod", "pay-on-delivery",
// order never runs through Payment's pay()/authorize() flow at // "wire", anything). Neither ever runs a Transaction-recording event
// checkout, so nothing else records a Transaction for it. Money // through to completion at checkout (COD dispatches nothing capture-
// changes hands right here, at this click, so this is the one // shaped at all; bank transfer's pay() returns Pending with no event
// place that write can happen; there is no earlier Payment event // dispatched — see that driver's own docblock). Money changes hands
// to hang it off of the way Modules\Core\Order\Listeners\ // right here, at this click, so this is the one place that write can
// RecordPaymentTransaction does for a gateway driver. See // happen; there is no earlier Payment event to hang it off of the way
// TransactionRecorder's own docblock — it already anticipated // Modules\Core\Order\Listeners\RecordPaymentTransaction does for a
// exactly this "manually-triggered ... from Filament" call site. // 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 // $driver below is the payment method's own `type` slug (whatever
// the merchant named it, e.g. 'cash-on-delivery' or 'cod') — // the merchant named it, e.g. 'cash-on-delivery' or 'cod') —
@@ -146,19 +153,25 @@ class OrderFulfillmentService
// No fallback guess here: CheckoutService::initiatePayment() always // No fallback guess here: CheckoutService::initiatePayment() always
// writes Order.meta['payment_method'] before charging, and // writes Order.meta['payment_method'] before charging, and
// canMarkPaid() already guarantees this order got that far. // canMarkPaid() already guarantees this order got that far.
$type = (string) $order->meta['payment_method'];
$this->transactions->record( $this->transactions->record(
$order, $order,
type: 'capture', type: 'capture',
driver: (string) $order->meta['payment_method'], driver: $type,
result: new PaymentResult( result: new PaymentResult(
status: PaymentResultStatus::Succeeded, status: PaymentResultStatus::Succeeded,
reference: 'cod-manual-'.$order->id, reference: "manual-{$type}-{$order->id}",
amount: $order->total, amount: $order->total,
), ),
); );
$this->writer->markPaid($order, self::class.'::markPaid'); $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.'); 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') { if ($order->status !== 'awaiting_payment') {
return; return;
+25 -4
View File
@@ -54,6 +54,24 @@ class OrderStatusFlow
return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery'; 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 * @return array<string, string> value => label — every status in the
* order's own branch (carrier or pickup), plus the refund options, * 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 — * Whether the "mark paid" option should be offered right now —
* entirely independent of $order->status. True whenever this is a * entirely independent of $order->status for a COD order (true whenever
* cash-on-delivery order and payment hasn't been recorded yet, * payment hasn't been recorded yet, regardless of fulfillment progress,
* regardless of fulfillment progress (before OR after completed). * 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 public function canMarkPaid(Order $order): bool
{ {
return ! $order->paid && $this->isCod($order); return ! $order->paid && ($this->isCod($order) || $this->isBankTransfer($order));
} }
/** /**
@@ -39,8 +39,26 @@ class TransactionRecorder
* elsewhere in this codebase (see the old, now-removed * elsewhere in this codebase (see the old, now-removed
* TransactionRecorder this replaces). * TransactionRecorder this replaces).
*/ */
/**
* Idempotent on (order_id, type, reference): a successful payment
* outcome can legitimately be reported twice for the same gateway
* reference — e.g. Stripe's pay()/handleCallback() both call
* resultFromIntent() and both dispatch PaymentCaptured once a
* PaymentIntent reaches "succeeded" (checkout's synchronous capture,
* then the webhook confirming the same outcome asynchronously) — so
* this returns the existing row instead of writing a duplicate.
*/
public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction
{ {
$existing = $order->transactions()
->where('type', $type)
->where('reference', $result->reference)
->first();
if ($existing !== null) {
return $existing;
}
return $order->transactions()->create([ return $order->transactions()->create([
'success' => $result->status === PaymentResultStatus::Succeeded, 'success' => $result->status === PaymentResultStatus::Succeeded,
'type' => $type, 'type' => $type,
@@ -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\Contracts\SupportsRefunds;
use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus; use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentCaptured; use Modules\Core\Payment\Events\PaymentDeferred;
use Modules\Core\Payment\Events\PaymentRefunded; use Modules\Core\Payment\Events\PaymentRefunded;
/** /**
* Manual/attested, same trust model as OfflinePaymentDriver — there is no * refund() is manual/attested, same trust model as OfflinePaymentDriver —
* bank API to call, so both pay() and refund() decide success immediately * there is no bank API to call, so it decides success immediately on a
* on a staff member's say-so (they've already sent/received the wire * staff member's say-so (they've already sent the wire outside the
* outside the system). Distinct from OfflinePaymentDriver in intent: this * system). Distinct from OfflinePaymentDriver in intent: this exists so a
* exists so a payment taken through a DIFFERENT method (e.g. * payment taken through a DIFFERENT method (e.g. cash-on-delivery) can
* cash-on-delivery) can still be REFUNDED via bank transfer — an admin * still be REFUNDED via bank transfer — an admin chooses this driver
* chooses this driver explicitly in the refund action, independent of * explicitly in the refund action, independent of which driver the
* which driver the original payment went through (see * original payment went through (see
* Payment\Support\TransactionDriverAdapter::refundVia() and * Payment\Support\TransactionDriverAdapter::refundVia() and
* Order\Filament\Extensions\OrderActionsExtension). pay() exists so * Order\Filament\Extensions\OrderActionsExtension).
* the same driver also covers receiving a payment by bank transfer, but *
* the admin UI for that (bank reference, notes, proof-of-transfer upload) * pay() is the opposite trust direction from refund(): a bank transfer
* is deliberately not built yet — see the follow-up work tracked from this * payment requires the money to arrive BEFORE the order can be
* session; pay() itself is complete and usable via the registry today. * 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 * $reference is generated here for the same reason as OfflinePaymentDriver's
* pay(): there is no gateway to hand one back. 'notes' in $context (not * pay(): there is no gateway to hand one back. refund()'s 'notes' (in
* $data — refund() has no $data parameter) is folded into * $context — it has no $data parameter) is folded into PaymentResult::$meta,
* PaymentResult::$meta, which Order\Services\TransactionRecorder::record() * which Order\Services\TransactionRecorder::record() already writes straight
* already writes straight into Transaction.meta with no extra plumbing. * 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 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 public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{ {
$result = new PaymentResult( $result = new PaymentResult(
status: PaymentResultStatus::Succeeded, status: PaymentResultStatus::Pending,
reference: 'bank-transfer-'.Str::uuid(), reference: 'bank-transfer-'.Str::uuid(),
amount: $amount, amount: $amount,
meta: array_filter(['notes' => $data['notes'] ?? null]),
); );
PaymentCaptured::dispatch($type, $result, $context); PaymentDeferred::dispatch($type, $result, $context);
return $result; return $result;
} }
@@ -33,6 +33,17 @@ class StripeWebhookMiddleware
$secret $secret
); );
} catch (UnexpectedValueException|SignatureVerificationException $e) { } catch (UnexpectedValueException|SignatureVerificationException $e) {
\Illuminate\Support\Facades\Log::error('Stripe webhook signature verification failed', [
'signature_header' => $stripeSig,
'secret_prefix' => substr((string) $secret, 0, 12),
'secret_length' => strlen((string) $secret),
'body_length' => strlen($request->getContent()),
'body_sha256' => hash('sha256', $request->getContent()),
'body_raw' => $request->getContent(),
'content_type' => $request->header('Content-Type'),
'content_encoding' => $request->header('Content-Encoding'),
]);
abort(400, $e->getMessage()); abort(400, $e->getMessage());
} }
@@ -3,6 +3,7 @@
namespace Modules\Core\Privacy\Filament\Extensions; namespace Modules\Core\Privacy\Filament\Extensions;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\Checkbox;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\BaseExtension; use Lunar\Admin\Support\Extending\BaseExtension;
@@ -20,13 +21,18 @@ use Modules\Core\Privacy\Services\PrivacyService;
* docs/modules.md "Layering Module and App Configuration"), and this extension * docs/modules.md "Layering Module and App Configuration"), and this extension
* deliberately only implements headerActions(), so it never conflicts with an * deliberately only implements headerActions(), so it never conflicts with an
* app's own extension for the same resource. * app's own extension for the same resource.
*
* Also strips Lunar's own plain DeleteAction from these pages — with Privacy
* installed, "Request Erasure" (grace period, cascades, audit trail via
* DataErasureRequest) is the only sanctioned way to remove a Customer; a
* direct delete would bypass all of that.
*/ */
class CustomerErasureActionsExtension extends BaseExtension class CustomerErasureActionsExtension extends BaseExtension
{ {
public function headerActions(array $actions): array public function headerActions(array $actions): array
{ {
return [ return [
...$actions, ...array_filter($actions, fn ($action) => ! $action instanceof DeleteAction),
Action::make('requestErasure') Action::make('requestErasure')
->label('Request Erasure') ->label('Request Erasure')
->icon('heroicon-o-shield-exclamation') ->icon('heroicon-o-shield-exclamation')
@@ -18,13 +18,16 @@ use Modules\Core\Cart\Services\CartService;
* CheckoutTranslationsSeeder rather than shipped as lang/ files. * CheckoutTranslationsSeeder rather than shipped as lang/ files.
* *
* A consuming app wires this module in with: * A consuming app wires this module in with:
* 1. `php artisan vendor:publish --tag=core-checkout-assets` — copies * 1. `"@boboko/core": "file:../boboko-core"` as an npm dependency (see this
* resources/js/checkout/** and resources/css/checkout.css into the * package's own package.json `exports`), with a bind-mount of the core
* host's own resources/ tree. Vite only ever bundles from a host's * checkout into the host's Vite container so the `file:` symlink
* own resources/ directory, so these are published (an explicit, * resolves in dev (see 3dealer's docker-compose.core-dev.yml) and
* host-owned, re-publishable copy) rather than imported cross-package. * `resolve.preserveSymlinks: true` in the host's vite.config.js so bare
* 2. `import { registerCheckout } from './checkout'` in the host's own * imports (stimulus, leaflet) still resolve against the host's own
* JS entry point, and a @vite entry for the published checkout.css. * 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. * 3. `@include('checkout::drawer')` in the host's own layout.
* See config/checkout.php for the handful of per-site settings (login * See config/checkout.php for the handful of per-site settings (login
* route, single-country mode, ...) a host is expected to publish and * route, single-country mode, ...) a host is expected to publish and
@@ -5,6 +5,7 @@ namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Lunar\Models\Language; use Lunar\Models\Language;
use Modules\Core\Command\PullTranslationsCommand;
use Modules\Core\Localization\Events\LanguageCreated; use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted; use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated; use Modules\Core\Localization\Events\LanguageUpdated;
@@ -46,5 +47,9 @@ class LocalizationServiceProvider extends ServiceProvider
} }
Event::listen(LanguageUpdated::class, MigrateTranslationsForRenamedLanguage::class); Event::listen(LanguageUpdated::class, MigrateTranslationsForRenamedLanguage::class);
if ($this->app->runningInConsole()) {
$this->commands([PullTranslationsCommand::class]);
}
} }
} }
+19 -5
View File
@@ -10,6 +10,7 @@ use Livewire\Mechanisms\ComponentRegistry;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates; use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
use Lunar\Shipping\Interfaces\ShippingMethodManagerInterface;
use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Cart\Events\CartCleared; use Modules\Core\Cart\Events\CartCleared;
use Modules\Core\Cart\Events\CartLineAdded; 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\Carriers\BoxNow\BoxNowRateDriver;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
use Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver;
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions; use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
use Modules\Core\Shipping\Support\FulfillmentType; use Modules\Core\Shipping\Support\FulfillmentType;
use Modules\Core\Shipping\Support\ShippingManager;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
class ShippingServiceProvider extends ServiceProvider class ShippingServiceProvider extends ServiceProvider
@@ -81,10 +84,9 @@ class ShippingServiceProvider extends ServiceProvider
// resolveCarrier() for the same lookup pattern already used to // resolveCarrier() for the same lookup pattern already used to
// resolve a carrier driver from it). // resolve a carrier driver from it).
// //
// Resolves via Modules\Core\Shipping\Support\FulfillmentType (driver- // Resolves via Modules\Core\Shipping\Support\FulfillmentType
// declared for acs/box-now, merchant-configured data['fulfillment_type'] // (hardcoded per driver — see that class's own docblock) rather
// fallback for table-rate-shipping's generic drivers) rather than // than through a ShippingMethod::macro('isStorePickup', ...) —
// through a ShippingMethod::macro('isStorePickup', ...) —
// Lunar\Base\Traits\HasModelExtending::__callStatic() (used by // Lunar\Base\Traits\HasModelExtending::__callStatic() (used by
// Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel) // Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel)
// intercepts EVERY unmatched static call, including macro() // intercepts EVERY unmatched static call, including macro()
@@ -119,10 +121,22 @@ class ShippingServiceProvider extends ServiceProvider
// Deferred: the Shipping facade resolves a binding registered in // Deferred: the Shipping facade resolves a binding registered in
// lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(), // 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->booted(function () {
$this->app->bind(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class));
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class)); Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::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) $this->app->make(ConsoleSchedule::class)
->job(new WarmAcsAreaCacheJob) ->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 Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing; use Modules\Core\Shipping\Concerns\CachesLivePricing;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName; use Modules\Core\Shipping\Support\ShippingMethodName;
use Modules\Core\Shipping\Support\WeightCalculator; use Modules\Core\Shipping\Support\WeightCalculator;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType, SupportsCashCollection
{ {
use ResolvesFixedPricing; use ResolvesFixedPricing;
use CachesLivePricing; use CachesLivePricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate; public ShippingRate $shippingRate;
@@ -37,6 +40,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
return 'carrier'; return 'carrier';
} }
public function collectsCash(): bool
{
return true;
}
public function description(): string public function description(): string
{ {
return 'Live rate quote from ACS Courier.'; return 'Live rate quote from ACS Courier.';
@@ -48,6 +56,10 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
$shippingMethod = $shippingRate->shippingMethod; $shippingMethod = $shippingRate->shippingMethod;
$cart = $shippingOptionRequest->cart; $cart = $shippingOptionRequest->cart;
if ($this->cartHasExcludedProducts($shippingRate, $cart)) {
return null;
}
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') { if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart); return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
} }
@@ -6,6 +6,7 @@ use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
@@ -18,6 +19,7 @@ use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
{ {
use ResolvesFixedPricing; use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate; public ShippingRate $shippingRate;
@@ -38,6 +40,10 @@ class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{ {
if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) {
return null;
}
return $this->resolveFixedPrice( return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate, $shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod, $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; namespace Modules\Core\Shipping\Contracts;
/** /**
* Optional contract a shipping rate driver implements to declare whether * Every shipping rate driver this module registers implements this to
* it fulfils via carrier delivery or in-store pickup — e.g. * declare whether it fulfils via carrier delivery or in-store pickup —
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver and BoxNowRateDriver * Modules\Core\Shipping\Carriers\Acs\AcsRateDriver and BoxNowRateDriver
* are unambiguously carrier-only, so this is a hardcoded fact about the * are unambiguously carrier-only, Modules\Core\Shipping\Carriers\
* driver, not something a merchant should have to configure per row. * StorePickup\StorePickupRateDriver unambiguously store-pickup, so this
* * is a hardcoded fact about each driver, never something a merchant
* table-rate-shipping's own generic drivers (flat-rate, ship-by, * configures per row. See Modules\Core\Shipping\Support\FulfillmentType::
* free-shipping) don't implement this — they're genuinely ambiguous (a * resolve(), the single source of truth this feeds.
* 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.
*/ */
interface DeclaresFulfillmentType 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') ->label('Type')
->options(fn () => collect(Shipping::getSupportedDrivers()) ->options(fn () => collect(Shipping::getSupportedDrivers())
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()])) ->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\Extending\ResourceExtension;
use Lunar\Admin\Support\Forms\Components\TranslatedText; use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName; use Modules\Core\Shipping\Support\ShippingMethodName;
@@ -22,11 +21,9 @@ class ShippingMethodResourceExtension extends ResourceExtension
public function extendForm(Schema $schema): Schema public function extendForm(Schema $schema): Schema
{ {
return $schema->components( return $schema->components(
$this->replaceFulfillmentTypeField( $this->replaceChargeByField(
$this->replaceChargeByField( $this->replaceNameField(
$this->replaceNameField( $this->replaceDriverField($schema->getComponents())
$this->replaceDriverField($schema->getComponents())
)
) )
) )
); );
@@ -98,64 +95,6 @@ class ShippingMethodResourceExtension extends ResourceExtension
return $field; 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 * Extend the vendor's cart_total/weight charge_by Select with a third
* "live" option — only offered when the currently selected driver * "live" option — only offered when the currently selected driver
@@ -297,7 +236,7 @@ class ShippingMethodResourceExtension extends ResourceExtension
->label('Type') ->label('Type')
->options(fn () => collect(Shipping::getSupportedDrivers()) ->options(fn () => collect(Shipping::getSupportedDrivers())
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()])) ->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
->default('flat-rate') ->default('acs')
->live(); ->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 * The single source of truth for "is this ShippingMethod a carrier
* delivery or an in-store pickup" — replaces a merchant-facing * delivery or an in-store pickup" — every driver this module registers
* data['fulfillment_type'] Select that used to exist for every method * (ACS, Box Now, Modules\Core\Shipping\Carriers\StorePickup\
* regardless of driver. Modules\Core\Shipping\Carriers\Acs\AcsRateDriver * StorePickupRateDriver) implements DeclaresFulfillmentType, so this is
* and BoxNowRateDriver are unambiguously carrier-only (see * now a hardcoded fact about the driver, never a merchant choice. There
* Modules\Core\Shipping\Contracts\DeclaresFulfillmentType's own * used to be a merchant-facing data['fulfillment_type'] Select as a
* docblock), so asking a merchant to also pick "Carrier delivery" for * fallback for table-rate-shipping's own generic drivers (flat-rate,
* every ACS/Box Now method was redundant, error-prone config with no * ship-by, free-shipping, collection), which were genuinely ambiguous
* real decision behind it. * (a merchant could configure one for either purpose) — those drivers
* * are no longer offered at all (see Modules\Core\Shipping\Support\
* table-rate-shipping's own generic drivers (flat-rate, ship-by, * ShippingManager), so the fallback and the field it read from are gone.
* 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.
*/ */
class FulfillmentType class FulfillmentType
{ {
@@ -29,32 +25,11 @@ class FulfillmentType
{ {
$driver = collect(Shipping::getSupportedDrivers())->get($method->driver); $driver = collect(Shipping::getSupportedDrivers())->get($method->driver);
if ($driver instanceof DeclaresFulfillmentType) { return $driver instanceof DeclaresFulfillmentType ? $driver->fulfillmentType() : 'carrier';
return $driver->fulfillmentType();
}
return $method->data['fulfillment_type'] ?? 'carrier';
} }
public static function isStorePickup(ShippingMethod $method): bool public static function isStorePickup(ShippingMethod $method): bool
{ {
return static::resolve($method) === 'store_pickup'; 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',
];
}
+136
View File
@@ -0,0 +1,136 @@
<?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\Order\Support\OrderReferenceDisplay;
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 and page
* only show 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 $this->fillOrderReference(
RichContentRenderer::make($content)->toHtml(),
$order,
);
}
/**
* Replaces a `{order_reference}` (or `{{ order_reference }}`) the shop
* owner typed into the instructions with the order's display reference
* (OrderReferenceDisplay — same form as the email subject).
*
* A plain text replace rather than RichContentRenderer::mergeTags():
* that only fills genuine Tiptap mergeTag nodes, which this editor never
* creates — Lunar's TranslatedText can't pass mergeTags() through to its
* per-locale RichEditors, so the placeholder is always stored as
* ordinary typed text.
*/
private function fillOrderReference(string $html, Order $order): string
{
return preg_replace(
'/\{\{?\s*order_reference\s*\}\}?/',
e(OrderReferenceDisplay::resolve($order)),
$html,
);
}
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,
},
},
}
},
}
}