Compare commits

..
24 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
58 changed files with 1888 additions and 647 deletions
+211
View File
@@ -4,6 +4,217 @@ 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 ## [0.26.4] - 2026-09-28
### Fixed ### Fixed
+106 -43
View File
@@ -1,85 +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 ```bash
./bin/dc-core.sh exec app composer update boboko/core --with-all-dependencies bin/dc-core.sh exec app composer update boboko/core --with-all-dependencies
``` ```
Skipping this step is the most common cause of "my change isn't showing up." 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: no separate npm package ## JS/CSS: a real npm package
This package's JS (Stimulus controllers) and CSS ship as plain source files under `resources/js/` and `resources/css/`, read directly by a consumer app's own Vite build — there is no separate `@boboko/core` npm package, and no `npm install`/`file:` dependency step of any kind. 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.
The reason: Composer already gives every environment one single, unconditional path — `vendor/boboko/core` — whether that resolves to a real symlink into `../boboko-core` (local path repo) or a real installed copy (tagged VCS release). A consumer's `vite.config.js` and JS entry point just read straight from that path, so there is nothing to toggle on the JS side — whatever Composer resolved is exactly what Vite sees, automatically, in both dev and prod. Public entry points (`package.json` `exports`):
**Stable entry point.** A consumer imports from [resources/js/index.js](resources/js/index.js) only — never from a path reaching into a specific module's internals (e.g. `resources/js/checkout/index.js` directly). That barrel file re-exports whatever a consumer needs (currently just `registerCheckout`), so this package's internal file layout can change without breaking every consumer's own entry point: | 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 ```js
// consumer app's resources/js/app.js // consumer app's resources/js/app.js
import { registerCheckout } from "../../vendor/boboko/core/resources/js/index.js"; import { registerCheckout, registerWishlist } from "@boboko/core";
registerCheckout(application); 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 ```php
{{-- consumer app's layout --}} {{-- consumer app's layout --}}
@vite(['vendor/boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js']) @vite(['node_modules/@boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
``` ```
**What a consumer's `vite.config.js` needs**, because `vendor/boboko/core` is a symlink in local path-repo dev (not a real directory): `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.
```js ## Translations added in the UI
export default defineConfig({
server: { 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:
watch: {
// vendor/boboko/core is a symlink into ../boboko-core in local ```bash
// path-repo dev. Vite/chokidar don't follow symlinks for watched bin/dc-core.sh exec app php artisan boboko:translations:pull # local mode: writes into ../boboko-core
// files by default, so edits to core's source wouldn't otherwise bin/dc exec app php artisan boboko:translations:pull --dry-run # any mode: just list them
// trigger HMR. No-op against a real installed copy (tagged VCS
// release) in production — there's no symlink to follow, and
// production only ever runs a one-shot `npm run build`, which
// doesn't watch anything regardless.
followSymlinks: true,
},
},
});
``` ```
Bare imports inside this package's own JS (`leaflet`, `@hotwired/stimulus`) resolve against the *consumer's* `node_modules` — Node's normal upward `node_modules` resolution walks from `vendor/boboko/core/resources/js/...` up through `vendor/boboko/`, `vendor/`, to the consumer app's root, where `node_modules` lives. This works with zero extra config as long as `vendor/boboko/core` sits inside the consumer's own directory tree (true for both the symlink and the real-copy case) — a consuming app's `vite`-equivalent Docker service just needs the same bind mount PHP containers already get, landing at the same path: 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.
```yaml ## Releasing a version
# consumer app's docker-compose.core-dev.yml
services:
vite:
volumes:
- ../boboko-core:/app/vendor/boboko/core
```
(Match whatever the consumer's Vite container's working directory actually is — `/app` above, `/var/www/html` for the PHP containers in `boboko-test`'s convention.) 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
git tag v0.27.5
git push origin master v0.27.5
```
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
+1 -1
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.26.4", "version": "0.28.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -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']);
``` ```
--- ---
+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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@boboko/core", "name": "@boboko/core",
"version": "0.26.4", "version": "0.28.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.", "description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.",
+110 -26
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;
@@ -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;
@@ -905,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);
@@ -916,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;
} }
@@ -924,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;
@@ -940,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;
@@ -965,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;
+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
@@ -192,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()
} }
+1 -1
View File
@@ -1,5 +1,5 @@
// Single stable JS entry point for this package. A consuming app imports // Single stable JS entry point for this package. A consuming app imports
// from here (vendor/boboko/core/resources/js/index.js), never from a path // from here (`import { … } from "@boboko/core"`), never from a path
// reaching into a specific module's internals — so this file's exports can // reaching into a specific module's internals — so this file's exports can
// grow or its modules' internal layout can change without breaking every // grow or its modules' internal layout can change without breaking every
// consumer's own entry point. // consumer's own entry point.
+50 -20
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>{{ \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order) }}</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 aria-hidden="true">&times; {{ $line->quantity }}</span>
<span class="bbk-visually-hidden">— {{ __('checkout.cart.quantity') }}: {{ $line->quantity }}</span>
</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,9 +94,13 @@
@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"> <div class="bbk-cart-summary-row">
@@ -110,23 +132,31 @@
<span>{{ $order->total?->formatted() }}</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,32 +24,60 @@
<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">
<div class="bbk-cart-item-head">
@if ($productUrl) @if ($productUrl)
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a> <a href="{{ $productUrl }}" class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</a>
@else @else
<p class="bbk-cart-item-title">{{ $name }}</p> <p class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</p>
@endif @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>
<div class="bbk-cart-item-foot">
{{-- role=group + the title as its name: entering the stepper
announces which product's quantity is being changed. --}}
<form <form
class="bbk-cart-qty" class="bbk-cart-qty"
method="POST" method="POST"
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}" action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
role="group"
aria-labelledby="bbk-cart-item-title-{{ $line->id }}"
> >
@csrf @csrf
@method('PATCH') @method('PATCH')
@@ -59,7 +87,7 @@
data-action="bbk-cart#step" data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1" data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}" aria-label="{{ __('checkout.cart.decrease') }}"
>&minus;</button> ><span aria-hidden="true">&minus;</span></button>
<input <input
type="number" type="number"
@@ -79,25 +107,13 @@
data-action="bbk-cart#step" data-action="bbk-cart#step"
data-bbk-cart-dir-param="1" data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}" aria-label="{{ __('checkout.cart.increase') }}"
>+</button> ><span aria-hidden="true">+</span></button>
</form> </form>
<p class="bbk-cart-item-total">
<span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
{{ $line->subTotal?->formatted() }}
</p>
</div> </div>
<div class="bbk-cart-item-aside">
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
<form
method="POST"
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
>&times;</button>
</form>
</div> </div>
</li> </li>
+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)) {
@@ -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', 'Μεταφορικά'],
@@ -190,6 +191,10 @@ class CheckoutTranslationsSeeder extends Seeder
'Θέλεις να παρακολουθείς την παραγγελία σου; Δημιούργησε λογαριασμό ή', 'Θέλεις να παρακολουθείς την παραγγελία σου; Δημιούργησε λογαριασμό ή',
], ],
'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' => [ 'page.box_now_locker_label' => [
'Choose a Box Now locker', 'Choose a Box Now locker',
@@ -28,6 +28,7 @@ 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\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 /
@@ -554,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),
]); ]);
} }
@@ -646,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();
+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;
}
}
+1 -1
View File
@@ -150,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', 'Αφαίρεση'],
];
}
}
@@ -1,268 +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' => 'Πολλές προσπάθειες. Περίμενε λίγα λεπτά και ξαναδοκίμασε.',
],
'auth.captcha_failed' => [
'en' => 'Please complete the captcha and try again.',
'el' => 'Παρακαλούμε ολοκλήρωσε το captcha και ξαναδοκίμασε.',
],
// 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);
@@ -7,8 +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; 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) {}
@@ -25,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'];
} }
@@ -46,6 +65,7 @@ class OrderCapturedNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('Payment captured for your order :reference', ['reference' => $reference])) ->subject(__('Payment captured for your order :reference', ['reference' => $reference]))
->view('core::order.notifications.captured', [ ->view('core::order.notifications.captured', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
'amount' => $this->event->transaction->amount->formatted, 'amount' => $this->event->transaction->amount->formatted,
]); ]);
@@ -46,6 +46,7 @@ class OrderCompletedNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('Your order :reference is complete', ['reference' => $reference])) ->subject(__('Your order :reference is complete', ['reference' => $reference]))
->view('core::order.notifications.completed', [ ->view('core::order.notifications.completed', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
]); ]);
} }
@@ -46,6 +46,7 @@ class OrderDeliveredNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('Your order :reference has been delivered', ['reference' => $reference])) ->subject(__('Your order :reference has been delivered', ['reference' => $reference]))
->view('core::order.notifications.delivered', [ ->view('core::order.notifications.delivered', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
]); ]);
} }
@@ -51,6 +51,7 @@ class OrderDispatchedNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('Your order :reference is on its way', ['reference' => $reference])) ->subject(__('Your order :reference is on its way', ['reference' => $reference]))
->view('core::order.notifications.dispatched', [ ->view('core::order.notifications.dispatched', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
]); ]);
} }
@@ -56,6 +56,7 @@ class OrderPickupReadyNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('Your order :reference is ready for pickup', ['reference' => $reference])) ->subject(__('Your order :reference is ready for pickup', ['reference' => $reference]))
->view('core::order.notifications.pickup-ready', [ ->view('core::order.notifications.pickup-ready', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
]); ]);
} }
@@ -57,6 +57,7 @@ class OrderPlacedNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('Your order :reference is confirmed', ['reference' => $reference])) ->subject(__('Your order :reference is confirmed', ['reference' => $reference]))
->view('core::order.notifications.placed', [ ->view('core::order.notifications.placed', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
'total' => $order->total->formatted, 'total' => $order->total->formatted,
'lines' => $order->lines, 'lines' => $order->lines,
@@ -46,6 +46,7 @@ class OrderRefundedNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('A refund has been issued for your order :reference', ['reference' => $reference])) ->subject(__('A refund has been issued for your order :reference', ['reference' => $reference]))
->view('core::order.notifications.refunded', [ ->view('core::order.notifications.refunded', [
'order' => $order,
'reference' => $reference, '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\OrderStatusUpdated; use Modules\Core\Order\Events\OrderStatusUpdated;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Support\OrderReferenceDisplay; use Modules\Core\Order\Support\OrderReferenceDisplay;
class OrderStatusUpdatedNotification extends BaseNotification class OrderStatusUpdatedNotification extends BaseNotification
@@ -30,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
{ {
@@ -37,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'];
} }
@@ -54,12 +76,17 @@ class OrderStatusUpdatedNotification extends BaseNotification
$order = $this->event->order; $order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($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' => $reference])) ->subject(__('Your order :reference is now :status', [
->view('core::order.notifications.status-updated', [
'reference' => $reference, 'reference' => $reference,
'statusLabel' => config("lunar.orders.statuses.{$order->status}.label", $order->status), 'status' => $statusLabel,
]))
->view('core::order.notifications.status-updated', [
'order' => $order,
'reference' => $reference,
'statusLabel' => $statusLabel,
]); ]);
} }
} }
@@ -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,
@@ -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());
} }
@@ -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)
+33
View File
@@ -2,15 +2,48 @@
namespace Modules\Core\Providers; namespace Modules\Core\Providers;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Modules\Core\Store\Events\StoreDetailsUpdated; use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Listeners\FlushStoreDetailsCache; use Modules\Core\Store\Listeners\FlushStoreDetailsCache;
use Modules\Core\Store\Services\StoreDetailsService;
use Symfony\Component\Mime\Address;
class StoreServiceProvider extends ServiceProvider class StoreServiceProvider extends ServiceProvider
{ {
public function boot(): void public function boot(): void
{ {
Event::listen(StoreDetailsUpdated::class, FlushStoreDetailsCache::class); 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));
} }
} }
+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,13 +21,11 @@ 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)];
});
}
}
@@ -80,10 +80,19 @@ class ManageStoreDetails extends Page implements HasForms
TextInput::make('phone') TextInput::make('phone')
->label('Phone') ->label('Phone')
->tel(), ->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') Section::make('Legal')
->description('Shown on invoices and terms pages.') ->description('Shown on invoices and terms pages.')
->schema([ ->schema([
TranslatedText::make('legal_name')
->label('Legal name'),
TextInput::make('tax_identifier') TextInput::make('tax_identifier')
->label('Tax ID (ΑΦΜ)'), ->label('Tax ID (ΑΦΜ)'),
TextInput::make('registration_number') TextInput::make('registration_number')
+1
View File
@@ -21,5 +21,6 @@ class StoreDetails extends Model
'name' => 'array', 'name' => 'array',
'address' => 'array', 'address' => 'array',
'bank_transfer_instructions' => 'array', 'bank_transfer_instructions' => 'array',
'legal_name' => 'array',
]; ];
} }
@@ -2,9 +2,13 @@
namespace Modules\Core\Store\Services; namespace Modules\Core\Store\Services;
use Filament\Forms\Components\RichEditor\RichContentRenderer;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Lunar\Models\Language; 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\Events\StoreDetailsUpdated;
use Modules\Core\Store\Models\StoreDetails; use Modules\Core\Store\Models\StoreDetails;
@@ -35,6 +39,60 @@ class StoreDetailsService
); );
} }
/**
* 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 public function update(array $attributes): StoreDetails
{ {
$storeDetails = $this->firstOrCreate(); $storeDetails = $this->firstOrCreate();
@@ -67,6 +125,7 @@ class StoreDetailsService
'name' => $this->emptyPerLocale(), 'name' => $this->emptyPerLocale(),
'address' => $this->emptyPerLocale(), 'address' => $this->emptyPerLocale(),
'bank_transfer_instructions' => $this->emptyPerLocale(), 'bank_transfer_instructions' => $this->emptyPerLocale(),
'legal_name' => $this->emptyPerLocale(),
]); ]);
} }