Compare commits

..
Author SHA1 Message Date
arvanitakis f411dec1da Merge branch 'master' into Shipping-Updates 2026-09-30 13:18:01 +03:00
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 1b06486b2b Feat: Adding Elta integrations 2026-09-29 14:31:30 +03:00
arvanitakis 1a7e8704d0 Feat: Adding temp logging to for stripe webhook 2026-09-29 14:30:52 +03:00
arvanitakis 6f6ae03428 Feat: Updating Favicon for core plugin 2026-09-29 01:25:35 +03:00
arvanitakis 9ce0c625ef Bump version to 0.27.0 2026-09-29 01:10:32 +03:00
arvanitakis 40888bf197 Chore: Adding override for email from 2026-09-29 01:09:57 +03:00
arvanitakis e9d90418fc Fix: Correcting subject on mail status update to display actuall status 2026-09-29 01:03:35 +03:00
arvanitakis 1bcc6acd27 Fix: Guarding against order status, fixing store details render in email 2026-09-29 01:01:39 +03:00
arvanitakis 359b645c62 Feat: Hiding unused shipping types and removing unused fulfillment types 2026-09-29 00:43:48 +03:00
arvanitakis d6c6bf6a1c Fix: Correct shipment resolving 2026-09-29 00:35:29 +03:00
arvanitakis 57d7a716bc Chore: Updates to Notifications 2026-09-28 23:54:24 +03:00
arvanitakis 76b807d672 Feat: Moving Storefront Labels into a proper seeder 2026-09-28 22:34:49 +03:00
arvanitakis 0797e3ab7a Fix: Adding 4 missing translations for Storefront 2026-09-28 22:29:20 +03:00
arvanitakis 5f0dbbb734 Bump version to 0.26.5 2026-09-28 17:36:39 +03:00
arvanitakis 839335ed3f Feat: Adding Contact options to Store Details 2026-09-28 17:36:37 +03:00
arvanitakis 0a6958eb9e Bump version to 0.26.4 2026-09-28 17:10:59 +03:00
arvanitakis d189a7559a Fix: Marking orded placed and setting order as awaiting payment when payment is bank transfer 2026-09-28 17:10:57 +03:00
arvanitakis 7f2ddea240 Bump version to 0.26.3 2026-09-28 13:46:30 +03:00
arvanitakis 45df93692a Chore: Adding Validation Translation Seeder from 3dealer 2026-09-28 13:45:27 +03:00
arvanitakis b806db611f Fix: Adding translation 2026-09-28 13:45:27 +03:00
elvira c1874c277e Bump version to 0.26.2 2026-09-28 13:28:21 +03:00
elvira 05f00ed1fd wishlist route fix 2026-09-28 12:49:41 +03:00
elvira 569eb310e9 Merge branch 'master' of ssh://code.radical-elements.com:2727/boboko/core 2026-09-28 12:32:21 +03:00
elvira 0387b38ab3 minor changes in checkout module css 2026-09-28 12:31:50 +03:00
81 changed files with 3580 additions and 670 deletions
+255
View File
@@ -4,6 +4,261 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.28.0] - 2026-09-30
### Added
- `php artisan boboko:translations:pull` — the reverse of the translation
seeders: copies `storefront` / `checkout` / `validation` lines that exist in
the database (e.g. added through the Filament Language Lines UI while
building a storefront) but not in the matching seeder into that seeder's
`lines()`, appended under a marker comment. Keys a seeder already has are
never touched. Writes only against a local `../boboko-core` checkout (local
mode); `--dry-run` lists the lines from anywhere.
- Storefront translations for error pages: `storefront.errors.404_title`,
`storefront.errors.404_text`, `storefront.errors.back_home`. Re-run
`StorefrontTranslationsSeeder` in consuming apps (or restart the stack) to
add them.
### Changed
- `CONTRIBUTE.md` rewritten to match the actual setup: the local/repo modes
and the `bin/core-mode` switch, `@boboko/core` as a real npm package (the
old "no separate npm package" section was stale), releasing a version,
deploying a consumer app with `bin/deploy`, and pulling UI-added
translations into the seeders.
## [0.27.5] - 2026-09-30
### Security
- OTP codes (both customer login via `UserOtpService` and staff login via
`OtpService`) were stored in plaintext in `users.otp_code`/`lunar_staff.otp_code`
and compared against the plaintext guess. The staff path was additionally
weaker — a loose `!=` comparison with no timing-attack protection and no
attempt-limiting at all. Both columns are replaced with `otp_code_hash`
(bcrypt, via a `'hashed'` cast — same convention `password` already uses),
compared with `Hash::check()`. The cache-backed pending-signup OTP path
(an email with no `User` row yet) is hashed the same way. No backfill —
any code mid-flight when this deploys is invalidated (codes expire in 10
minutes regardless, so the practical impact is limited to a re-request).
- `App\Models\User`'s (3dealer) and `Modules\Core\Auth\Models\Staff`'s
`$hidden` arrays didn't list `otp_code`/`otp_expires_at`/`otp_attempts`/
`pending_email_code_hash`/etc. at all — any serialization of either model
(an API response, `Auth::user()` returned somewhere) would have leaked
those fields, including the (now-hashed, previously plaintext) OTP code
itself. `Staff` additionally never overrode Lunar's own base `$hidden`, so
it also lacked `password`/`remember_token` protection until now.
## [0.27.4] - 2026-09-29
### Added
- Generic `.bbk-notice` / `.bbk-notice--info` message box and a
`--bbk-color-info` custom property in `checkout.css`.
- Cart drawer focus handling: focus moves into the drawer on open, stays
inside it, returns to the opener on close, and is restored after each
cart update. Updates are announced as "Cart updated" instead of re-reading
the whole cart. New translation line `checkout.cart.updated` — re-run
`CheckoutTranslationsSeeder` in consuming apps to add it.
### Changed
- Cart drawer line: larger remove button on the title row, line total on
the quantity-stepper row, and screen-reader labels tied to the product
name. `.bbk-cart-item-aside` is removed — hosts restyling it should target
`.bbk-cart-item-head` / `.bbk-cart-item-foot` instead.
- Order confirmation page: narrower (560px), the confirmation-email note is
now an info box under the heading, the order summary comes before
shipping/billing (shown side by side), and the order number is prefixed
with `#`.
## [0.27.3] - 2026-09-29
### Added
- The checkout confirmation page now ends with the store's bank transfer
instructions (Store Details → Bank transfer) for a bank transfer order,
via `StoreDetailsService::bankTransferInstructionsFor()`. New translation
line `checkout.page.confirmation_bank_transfer_heading` — re-run
`CheckoutTranslationsSeeder` in consuming apps to add it.
### Fixed
- `StoreDetailsService::bankTransferInstructionsFor()` now fills a
`{order_reference}` (or `{{ order_reference }}`) typed into the bank
transfer instructions with the order's display reference
(`OrderReferenceDisplay`). It previously rendered literally in the order
confirmation email.
## [0.27.2] - 2026-09-29
### Fixed
- `Modules\Core\Order\Services\TransactionRecorder::record()` — made idempotent
on `(order_id, type, reference)`. A successful Stripe payment can legitimately
report `PaymentCaptured` twice for the same PaymentIntent (checkout's
synchronous capture via `pay()`, then the webhook confirming the same
outcome asynchronously via `handleCallback()`), both routing through
`resultFromIntent()`. With no dedupe check, this wrote two identical
`Transaction` rows for one real payment. Now returns the existing row
instead of creating a duplicate.
## [0.27.1] - 2026-09-29
### Added
- Temp logger for Stripe webhook
## [0.27.0] - 2026-09-29
### Added
- `Modules\Core\Shipping\Contracts\SupportsCashCollection` — a shipping driver
implements this to say a person is physically present at handoff to collect
cash. `AcsRateDriver` implements it (a courier can); `BoxNowRateDriver` and
the new `StorePickupRateDriver` don't (an unattended locker or a store
clerk deciding to accept cash isn't the same fact as a courier collecting
it on delivery). `Modules\Core\Checkout\Services\CheckoutService::
getPaymentMethods()` now checks this for cash-on-delivery specifically, so
COD is no longer offered alongside Box Now — `RequiresFulfillmentType`
alone couldn't distinguish "an unattended carrier" from "an attended one,"
both being `carrier`.
- `Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver` — a
first-party replacement for `lunarphp/table-rate-shipping`'s own
`Collection` driver, following the same shape as `AcsRateDriver`/
`BoxNowRateDriver`. Fixes two bugs the vendor driver had: it read
`ShippingMethod->name` directly instead of decoding the translatable JSON
column, so the storefront showed the raw JSON blob instead of a
translated name; and it never checked product exclusions the same way
the vendor's own generic drivers did.
- `Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts` — shared
product-exclusion check (a shipping zone's configured "this product can't
ship via this method" list), now applied consistently by every
first-party driver (ACS, Box Now, Store Pickup). Previously only Lunar's
own generic drivers honored exclusions at all — ACS and Box Now silently
ignored them.
- `Modules\Core\Localization\Database\Seeders\StorefrontTranslationsSeeder`
— the `storefront` translation group is now seeded the same way
`checkout`/`validation` already are (a dedicated, idempotent seeder run
from the entrypoint), rather than as a private method inside
`InstallLunarCommand`. `Modules\Core\Localization\Services\
StorefrontLabels` is removed; its ~160 lines moved into the new seeder.
- `StoreDetailsService::bankTransferInstructionsFor()` — sanitized HTML for
a bank transfer order's confirmation email, gated on
`OrderStatusFlow::isBankTransfer()`.
- `StoreServiceProvider::applyMailFromName()` — listens for
`Illuminate\Mail\Events\MessageSending` and renames the From header's
display name to Store Details' `mail_from_name`, when set, for every
outgoing email app-wide (order notifications, OTP codes, contact form
confirmations, etc.), no per-mailable changes needed. Only touches a
message whose From address still matches `config('mail.from.address')`
— the sending address itself is untouched (a deliverability/domain-
authentication concern, not a merchant-editable text field), and a
mailable/notification that already set its own explicit sender is left
alone entirely.
- `Order.meta['locale']` — the shopper's locale at checkout, saved by
`CheckoutService::initiatePayment()` so every order notification renders
in the locale they actually checked out in (via `Notification::locale()`
in `NotificationRegistry::register()`), independent of whatever locale
happens to be active when the notification is actually sent (a payment
webhook, a queued job, a staff action).
- `Order.meta['coupon_code']` — copied from the cart at checkout, so the
confirmation email always shows the code the order was actually placed
with, rather than reading the cart row (which may since have been
cleared/reused).
- Contact details on the storefront and product confirmation emails now
accept `$order` directly (all 8 order notification views), removing two
workarounds: `placed.blade.php` no longer reaches the order through
`$lines->first()->order`, and `status-updated.blade.php` no longer
reverse-searches a status key from its English label.
### Changed
- The vendor's own generic shipping drivers (`free-shipping`, `flat-rate`,
`ship-by`, `collection`) are no longer offered at all — every shipping
method now uses a first-party driver (ACS, Box Now, or Store Pickup),
each of which declares its own fulfillment type. The `data.fulfillment_type`
Select on the shipping method form is removed, along with the
merchant-facing fallback it existed for — every registered driver now
hardcodes this fact about itself rather than a merchant configuring it
per row.
- `OrderCapturedNotification` ("payment captured") no longer sends for any
payment method except bank transfer. For every other method, placing the
order and paying for it are the same moment from the shopper's
perspective — `OrderPlacedNotification` already covers it. Bank transfer
is the one case where payment confirmation is a genuine, later, separate
event (staff marking the order paid once the wire arrives).
- `OrderStatusUpdatedNotification` no longer sends for an order's first
transition off `awaiting_payment`, except for a bank transfer order —
for every other payment method that transition happens in the same
request as checkout itself, so it's not new information the shopper
hasn't already been told.
- `OrderStatusUpdatedNotification`'s subject line now names the order's
actual current status ("Your order :reference is now :status") instead
of a generic "has been updated."
- The checkout page's payment methods now refresh live when the shipping
option changes, instead of requiring a full page reload — switching to
store pickup correctly drops cash-on-delivery and offers "pay in store"
immediately.
### Fixed
- `StoreDetailsService::bankTransferInstructionsFor()` returned the raw
Tiptap JSON array `bank_transfer_instructions` is stored as, instead of
rendering it to HTML — the order confirmation email for a bank transfer
order failed outright (`TypeError`, queued job marked `FAIL`) rather than
showing the store's payment instructions. Now converts via Filament's own
`RichContentRenderer`, the same converter the admin panel itself uses to
render a `RichEditor` field's content read-only.
## [0.26.5] - 2026-09-28
### Added
- Three new fields on the "Store Details" settings page
(`Modules\Core\Store\Filament\Pages\ManageStoreDetails`):
- **Contact email** (`contact_email`) — used both as the receiver address for the
contact form and shown to customers as the store's contact email.
- **Mail from name** (`mail_from_name`) — the sender name on outgoing emails
(`MAIL_FROM_NAME`).
- **Legal name** (`legal_name`) — the registered company's legal name, distinct
from the storefront-facing `name`. Locale-keyed JSON, like `name`/`address`/
`bank_transfer_instructions`, since a registered name can legitimately differ
per locale.
`contact_email`/`mail_from_name` are plain nullable strings, matching
`phone`/`tax_identifier`. The migration backfills all four translatable
`store_details` columns with an empty per-locale array on any row where they're
still `NULL`, and `StoreDetailsService::firstOrCreate()` now seeds `legal_name`
the same way it already seeds `name`/`address`/`bank_transfer_instructions` —
both needed so `TranslatedText` doesn't silently drop keystrokes on a
NULL-starting translatable field (see that service's own docblock).
## [0.26.4] - 2026-09-28
### Fixed
- Bank transfer orders were never marked placed. `BankTransferPaymentDriver::pay()` had
dead code after an early `return` and never dispatched `PaymentDeferred`, so nothing
ever set `Order::placed_at` or fired `OrderPlaced` — no order confirmation email, no
stock decrement, the order missing from the customer's order history, and checkout
erroring out instead of showing the confirmation page. `pay()` now dispatches
`PaymentDeferred` the same way `CashOnDeliveryPaymentDriver::pay()` does.
- `MarkOrderPlacedOnDeferredPayment` now skips
`OrderPaymentResolutionService::resolveDeferredPayment()` for a bank transfer order
(`OrderStatusFlow::isBankTransfer()`), so it correctly stays at `awaiting_payment` /
unpaid until staff mark it paid, instead of being advanced past that status the moment
checkout completes.
## [0.26.3] - 2026-09-28
### Added
- `Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder` — moved in from
3dealer's own `database/seeders/`, alongside the existing
`Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder`, so the `validation` and
`checkout` translation groups are seeded from core rather than duplicated per consuming app.
Same idempotent per-key upsert as `lunar:install`'s own seeding — a key already present (or
since edited via the Filament Language Lines UI) is left untouched. Both are run explicitly
via `php artisan db:seed --class=...`, wired into 3dealer's entrypoint right after
`lunar:install`.
- `storefront.auth.captcha_failed` — was referenced by `HCaptcha`, `LoginController`, and
`ContactRequest` but never seeded in `StorefrontLabels`, so it silently fell back to the raw
key. Re-running `lunar:install` now fills it in for any existing store.
## [0.26.2] - 2026-09-28
### Changed
- Checkout line-item custom fields (`checkout/partials/line-custom-fields.blade.php`):
non-file fields now render inline as a single muted `Label: value` line instead of a
separate `<dt>`/`<dd>` pair. File fields keep the stacked label/link layout, with a
colon after the label and a small top margin on the value.
### Fixed
- Wishlist routes are now registered inside the `web` middleware group
(`WishlistServiceProvider`). Before, `loadRoutesFrom()` loaded them with no middleware,
so they had no session, cookies, CSRF protection or authenticated user. That broke
anything that depends on the current guest or logged-in user.
## [0.26.1] - 2026-09-28 ## [0.26.1] - 2026-09-28
### Added ### Added
+108 -45
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`.
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:
```bash ### Day to day in local mode
./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." 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.
## JS/CSS: no separate npm package 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:
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. - **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
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. ```bash
bin/dc-core.sh exec app composer update boboko/core --with-all-dependencies
```
**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: Skipping this is the most common cause of "my change isn't showing up."
- In `3dealer`, the app container's `vendor/` is a named Docker volume, so the host's `vendor/` directory is stale — inspect packages inside the container, not on the host.
## JS/CSS: a real npm package
Core's Stimulus controllers and CSS ship as the `@boboko/core` npm package, installed into the consumer's `node_modules` — as a symlink to `../boboko-core` in local mode, as a real copy of the tagged release in repo mode. It's a real package (rather than files read out of `vendor/`) so npm installs core's own dependencies (`leaflet`, `@hotwired/stimulus`) transitively, the same way Composer does for PHP.
Public entry points (`package.json` `exports`):
| Import | File |
|---|---|
| `@boboko/core` | `resources/js/index.js` — the stable barrel (`registerCheckout`, `registerWishlist`, …) |
| `@boboko/core/vite-plugin` | `vite-plugin.js` — `boboko()` |
| `@boboko/core/css/*` | `resources/css/*` |
| `@boboko/core/checkout`, `@boboko/core/checkout/*` | `resources/js/checkout/…` |
A consumer imports from the `@boboko/core` barrel only — not from a module's internal files — so the internal layout here can change without breaking every consumer:
```js ```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
+4 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.26.1", "version": "0.28.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -18,7 +18,9 @@
"lunarphp/search": "*", "lunarphp/search": "*",
"lunarphp/meilisearch": "*", "lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8", "spatie/laravel-translation-loader": "^2.8",
"stripe/stripe-php": "^16.6" "stripe/stripe-php": "^16.6",
"picqer/php-barcode-generator": "^3.3",
"barryvdh/laravel-dompdf": "^3.1"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
+70
View File
@@ -0,0 +1,70 @@
<?php
/*
|--------------------------------------------------------------------------
| ELTA Courier credentials
|--------------------------------------------------------------------------
|
| ELTA's API is SOAP (unlike ACS's JSON gateway or Box Now's REST +
| OAuth2). Two operation families live at the same endpoint — see
| Modules\Core\Shipping\Carriers\Elta\EltaClient's docblock for why only
| the "*NEW" family (create/track/station/cancel) is used; the old
| family (including its label-print operation) is unreachable with this
| account and isn't called anywhere in this integration. Labels are
| rendered locally instead — see
| Modules\Core\Shipping\Carriers\Elta\EltaLabelRenderer — which is why
| sender identity (name/address, unlike PELVGNEW's request which needs
| only apost_code) is configured here.
|
| Set these via environment variables — never commit real values.
|
| ELTA_SERVICE_LOCATION SOAP endpoint (defaults to the live production
| host; only needed if ELTA gives you a separate
| sandbox host).
| ELTA_USER_CODE Account user code (pel_user_code / pel_user).
| ELTA_USER_PASS Account security code — not used by anything the
| *NEW family calls; kept only in case ELTA ever
| asks for it.
| ELTA_APOST_CODE Sender/account code (pel_apost_code).
| ELTA_APOST_SUB_CODE Sender sub-code (pel_apost_sub_code).
| ELTA_SENDER_NAME Sender name printed on the label — PELVGNEW's
| own request has no such field, so this only
| matters for our own locally-rendered label.
| ELTA_SENDER_ADDRESS Sender street address printed on the label.
| ELTA_SENDER_POSTCODE Sender postcode printed on the label.
| ELTA_SENDER_AREA Sender area/city printed on the label.
| ELTA_SENDER_PHONE Sender phone printed on the label.
| ELTA_ORIGIN_STATION_CODE This account's home ELTA station code — shown
| on the label as "Γ.Κατάθεσης"/"Από". ELTA's own
| client gets this from a PELLOGINNEW response
| (user_station); configured here instead so a
| label print doesn't need an extra API call.
| ELTA_LABEL_PAPER_SIZE "a4" (default, 3 copies per page) or "a6"
| (single thermal label) — matches the real
| client's own one-time printer-setup choice, not
| varied per shipment.
|
*/
return [
'service_location' => env('ELTA_SERVICE_LOCATION', 'http://212.205.47.226:9003'),
'user_code' => env('ELTA_USER_CODE'),
'user_pass' => env('ELTA_USER_PASS'),
'apost_code' => env('ELTA_APOST_CODE'),
'apost_sub_code' => env('ELTA_APOST_SUB_CODE'),
'sender_name' => env('ELTA_SENDER_NAME'),
'sender_address' => env('ELTA_SENDER_ADDRESS'),
'sender_postcode' => env('ELTA_SENDER_POSTCODE'),
'sender_area' => env('ELTA_SENDER_AREA'),
'sender_phone' => env('ELTA_SENDER_PHONE'),
'origin_station_code' => env('ELTA_ORIGIN_STATION_CODE'),
'label_paper_size' => env('ELTA_LABEL_PAPER_SIZE', 'a4'),
'timeout' => env('ELTA_HTTP_TIMEOUT', 15),
];
@@ -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.1", "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.",
+112 -28
View File
@@ -53,6 +53,7 @@
--bbk-color-accent: #18181b; --bbk-color-accent: #18181b;
--bbk-color-accent-text: #ffffff; --bbk-color-accent-text: #ffffff;
--bbk-color-danger: #dc2626; --bbk-color-danger: #dc2626;
--bbk-color-info: #2563eb;
--bbk-radius: 8px; --bbk-radius: 8px;
--bbk-radius-sm: 4px; --bbk-radius-sm: 4px;
@@ -153,6 +154,11 @@
outline-offset: 2px; outline-offset: 2px;
} }
/* Programmatic focus targets only (tabindex=-1) — never reached by Tab, so
no ring needed. */
.bbk-cart-heading:focus,
.bbk-checkout-summary-heading:focus { outline: none; }
.bbk-visually-hidden { .bbk-visually-hidden {
position: absolute; position: absolute;
width: 1px; width: 1px;
@@ -183,7 +189,7 @@
.bbk-cart-item { .bbk-cart-item {
display: grid; display: grid;
grid-template-columns: 72px 1fr auto; grid-template-columns: 72px 1fr;
gap: 0.875rem; gap: 0.875rem;
align-items: start; align-items: start;
} }
@@ -199,8 +205,23 @@
.bbk-cart-item-detail { min-width: 0; } .bbk-cart-item-detail { min-width: 0; }
/* Title + remove button share the first row; quantity stepper + line total
share the last one. */
.bbk-cart-item-head,
.bbk-cart-item-foot {
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
.bbk-cart-item-head { align-items: flex-start; }
.bbk-cart-item-foot { align-items: center; }
.bbk-cart-item-remove-form { flex: 0 0 auto; }
.bbk-cart-item-title { .bbk-cart-item-title {
display: block; display: block;
min-width: 0;
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
font-weight: 600; font-weight: 600;
color: inherit; color: inherit;
@@ -223,13 +244,13 @@ a.bbk-cart-item-title:hover { text-decoration: underline; }
font-size: 0.8125rem; font-size: 0.8125rem;
} }
.bbk-line-field span,
.bbk-line-field dt { .bbk-line-field dt {
color: var(--bbk-color-muted); color: var(--bbk-color-muted);
} }
.bbk-line-field dd { .bbk-line-field dd {
margin: 0; margin: 3px 0 0 0;
white-space: pre-line;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -252,24 +273,24 @@ a.bbk-cart-item-title:hover { text-decoration: underline; }
color: var(--bbk-color-muted); color: var(--bbk-color-muted);
} }
.bbk-cart-item-aside { .bbk-cart-item-total { margin: 0; font-weight: 600; white-space: nowrap; }
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.bbk-cart-item-total { margin: 0; font-weight: 600; }
/* 2.5rem hit area (same as the drawer's own close button), pulled up/right by
negative margins so the glyph lines up with the title's first line instead
of pushing the row taller. */
.bbk-cart-item-remove { .bbk-cart-item-remove {
font-size: 1.125rem; font-size: 1.75rem;
width: 1.5rem; width: 2.5rem;
height: 1.5rem; height: 2.5rem;
margin: -0.5rem -0.5rem 0 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--bbk-radius-sm);
} }
.bbk-cart-item-remove:hover { background: var(--bbk-color-bg-muted); }
.bbk-cart-qty { .bbk-cart-qty {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="151" height="150" viewBox="0 0 151 150"><metadata><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Description><dc:creator>RealFaviconGenerator</dc:creator><dc:source>https://realfavicongenerator.net</dc:source></rdf:Description></rdf:RDF></metadata><image width="151" height="150" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJcAAACWCAYAAADTwxrcAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAACYRJREFUeAHtnctuG0cWhv8qJuuRgWSAWU1nMrO28gRuP4FkzANIegJZyAXIStQqQOLA8hOY2ieR/QSmnyDMOgnSuwCJAzLrmF05p6spURSvYpNddfp8gO6UxGb9fc6p21+AoiiKoiiKoiiKoiiKoiiKoiiKoiiKoiiKRAyUxVz2d+j9TvH5o3sZlKVQcY1z2d9FjhQW9+GwS99JMBLVbbLizaCHIV6jhS4JbwDlChXXZT+l93skpkPMFtKydOnvXOD/9zpQGiwuFpXDKX2Wono4onXo40WT02jzxLVZUU3iRfbo3hkaSHPE5YvyUxLWY2wfFtnDpkUxiybAhbrD9zUJi0nof/9Cz+MUDUK+uF72D6hhX8H3/OrFoU0Ce4qGIDstcqTgBg2PLr3yj6QPXcgVV7jCGtEr6zCxApOZFr/p7wcuLIbrwEsIRp64LvsJXdVzxEEquQaTlRZ5uIF7hSEU76vg02MXwpAWubirnyA2HEVaPw4nCjni8mNZdY1jrUsCf2OIQo64Yi+O+cbwk+hikCGub/uHiDEdTuJkRS8Z4jJiGiWVFL3iF5dvjARScNiHECRErgPI4kBKzzF+cQm600t2kMu4prjF5VOiuPEhWPsAAohbXPlWVpNuH5dr5KodAxF3+BR2ijnSyIm95pKXEq/ZReTELq7oG2Amefw3TrziEjjRO0GCyIk5ckkXV/Q0Y/ePUgsqLmVjqLiUjRGvuOTvXo5+V1DskUvuvj9bWDRFTeziyiAXjVw18wPk0kPkxC0uhy5k0pOwEztucVmx4noNAcQtLt9j7EIaBi8gAAkrUUXc5WNkUnZfxy8ui3NIGpIwEGNxGb+4fOF7ARlkEJTmpexblBG9nCz3Zxni4gZxeIa4ycoULwY5E9e+YTLECtdawlwG5YiLG8bgCDFiLHvVdyAMWUtufBc+tvSYAbnIQxBkGu5+12dr8BThw9H2I6nLh2QuFmQb7hjqL07jgtelyRSXr78eImSBeWGJmOaZhdxlzhwRwhTYoBRWB8KRvYZ+JLBwJoJHB0x10ACac2rZt/12rQ6ExpDA3VGTTpNt1nmLbO7hjXm3aQPAafCkKdFqnGaeFHvZPyzNbRNsjkExJcUzBw09+7rZZ1x7kbHtZYrqaLyoRugB6oz3wuLDqPZwN6ENyk4DL/0Rsf69ClRck3j3nN3ijW2MLP596zEOf9Irl9HPB8U6/gYfkq4oiqIoiqIoiqIoihIIWxnn+hVfJNO+/y98nkHZCO6XwpB4minxwHywnW14lYqrj6c7b/GWBiDtnoFLDMyuo4+Y/wR6Dsjosa+HGHb/ic+itw7aNoWQhjTD4E8U4QHgBPPdrnlGoUeDwb3CDqGFHgkuQ8VUIq7f8VVK4jigt30S01oW3vQ3MhJbtwV7dg8nGZSpFILKcUgv2B4JJMW6GJppyHFh/osOKmItcb3B14d0mVVP/I5hOiqym5Tp7piE8Bib8eLnzbkd+nixbjS7k7jKSMVLVlJsBRXZFkQ1Ce9iP1snkq0kLq6pcuSnlPoeY8uU6fLsPXzcQcMgYaUkqueo48gWTpcGR3eJYkuLi4S1S8K6XFSgbxoHc/4+Pj5BQyBhcbSq20OCo9gJRbGV9iIsJa43eMLF+vm6xXpVcBSzsA8lp8myYOdotY9QsGhTBFt6d/hCcf2BrzkNthEYkgVWCot3jW9zrf9yrCCwueL6A0+Oqc4J1tZHqsDcz/geYZ8l2TEfLjZ9mblvkVLhfsjCYrj+ozrwFdWDCYRAUespwj+k9JCe58JtelPFxY1FUeEpIoAFRiP7zyEAilin5VBD+ORok8AO5j3ETv+9/FXdvcIVSWnsLY5GmUEx3AC0ERPUi6Xnncz68S1xlQX8zF8IFY60v+HL0NPJVIoG8j3D2Nih1DHzed8QF6fDEHuGy0LFfRSpfArHqGOAtApoXpPS+dSscUNcAmqX1M93xkMZtaJO6cRpOT11gytx8Xwh4nDjmwuNrdRnNnIX8sie73R2pt0gV+IqJ6Kjh+vFWKJXWQwfQgbHk9GrEFc5TpRCDO4AMSAjao3w68vGKMQ1RC7pIpm0TPOhk0ISpvDauML67wm7SE+KgHE/Fs8vgSS45ziWGi0vpYlxXGsRFmYPIWMDWu1QJWOp0VJKjHLgcRE0L7rLixsRLg8gEYv715/CiRQX43ciBYvM1z2/Lke45roPuQTZgFSXiL2hMVZHWhrfSiCUYK/tLUJO12szmsy2Eov5Mf6BEDHCeom3KW4e2YccKHWh4lI2i4pL2RgqLmVjcG8xg1z+RJhsxcKoRjJ+x4OoYi+UesIZQqQV8UHvy1FoiqZ/8AOEEnBUziCXK3M5SouuB6HkyDMESPniZ5AIm8qVcEEvUlwctYJ2KWRHP4mMXZd9H592IbDAZHdChE0XErHX11UMReTAS4jDhX1NrWCORq6SjFJ+d/RFuRLVdSAITonv4ZOgG6+ou5y46NUd/6IQl7TUGEFK9Ljlva6iwN68nqsRehoTegYhsH8qIsD8j24COdGrM2lteSWud/DOuYzRetOJyq9LSvSyt6/jSlzUIAM2tEXE8M0RS9QaISR6daYZ8t6YuC6dkruIFL45onQZbCFmA+FsWtRibq2KaKHFdoQRFvemE6uNON31PNgbp8DYq36Gjfgtcfk730R1oTGmw0nMhziPMD3OPQRhpuHuGzxhP9RjBA4Ja2BhP5JgulvaKbGLc4LQMeia/+DhvIfMXCxIg5BsiXOBwBlieCLFzblIL7ZosNDLkoxP1Vj0oLkrUan+emwCntjOkR/R5HQHgohAYMXzW+a4lrni4uEJixZfaFBTKZwKJQprRFHge4FlCIvessJilj7753d81Q7BIK482OARCV/sOrQRQdVgDs8olbVXOWV2pVPL2POKGvZ5fRtpzQvqFR5xREWDcD+iTRGjrht7QAI/o8He8xV/bzVxMexCSEU0F/tb60n64/DcSegrHTZJLVFsjePw/K/fERbZXxi2qWjbmEVkWVs943nPpkWrWbifiqOHOYol2BQsqmERrbpYgzuLa8QmRKaiWkwhMn7NTYUOihWJ6vrPVQQbrQ2R7+dwaQvmwap1Gae+IdxLA/eiXF+mLEGZLvep4N67g9AGxYYKh5d8rvUqxfoyVCauSVhsbL5mYBOHPMHUf26zHMPeu3g30whVDYX315AE5510plk18QrYAfX8uuaD4IY6FEVRFEVRFEVRFEVRFEVRFEVRFEVRFEVRFKXJ/A2oC/VTZ0o/1AAAAABJRU5ErkJggg=="></image></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+76 -46
View File
@@ -2,6 +2,9 @@
Order confirmation. Reached only via a session flash of the placed order id Order confirmation. Reached only via a session flash of the placed order id
(CheckoutController::confirmation) — not deep-linkable. $order is a (CheckoutController::confirmation) — not deep-linkable. $order is a
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded. Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
$bankTransferInstructions is already-sanitized HTML from
StoreDetailsService::bankTransferInstructionsFor(), or null unless this
is a bank transfer order with instructions filled in for this locale.
--}} --}}
@extends('layouts.app') @extends('layouts.app')
@@ -11,10 +14,19 @@
<div class="bbk-confirmation"> <div class="bbk-confirmation">
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1> <h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
<div class="bbk-notice bbk-notice--info">
<svg class="bbk-notice-icon" aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="20" height="20">
<circle cx="10" cy="10" r="8.25" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M10 9v5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="10" cy="6.25" r="1" fill="currentColor"/>
</svg>
<p class="bbk-notice-text">{{ __('checkout.page.confirmation_email_note') }}</p>
</div>
<dl class="bbk-confirmation-meta"> <dl class="bbk-confirmation-meta">
<div class="bbk-confirmation-meta-row"> <div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt> <dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
<dd>{{ \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> <span aria-hidden="true">&times; {{ $line->quantity }}</span>
<span class="bbk-visually-hidden">— {{ __('checkout.cart.quantity') }}: {{ $line->quantity }}</span>
</span>
</p>
@if ($line->option) @if ($line->option)
<p class="bbk-cart-item-variant">{{ $line->option }}</p> <p class="bbk-cart-item-variant">{{ $line->option }}</p>
@@ -76,57 +94,69 @@
@include('checkout::partials.line-custom-fields', ['line' => $line]) @include('checkout::partials.line-custom-fields', ['line' => $line])
</div> </div>
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span> <p class="bbk-confirmation-line-total">
</div> <span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
{{ $line->sub_total?->formatted() }}
</p>
</li>
@endforeach @endforeach
</ul>
<div class="bbk-cart-summary"> <div class="bbk-cart-summary">
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $order->sub_total?->formatted() }}</span>
</div>
@if ($order->discount_total?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $order->discount_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $order->shipping_total?->formatted() }}</span>
</div>
@if ($order->tax_total?->value > 0)
<div class="bbk-cart-summary-row"> <div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span> <span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $order->sub_total?->formatted() }}</span> <span>{{ $order->tax_total->formatted() }}</span>
</div> </div>
@endif
@if ($order->discount_total?->value > 0) <div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount"> <span>{{ __('checkout.cart.total') }}</span>
<span>{{ __('checkout.cart.discount') }}</span> <span>{{ $order->total?->formatted() }}</span>
<span>&minus;{{ $order->discount_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $order->shipping_total?->formatted() }}</span>
</div>
@if ($order->tax_total?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $order->tax_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $order->total?->formatted() }}</span>
</div>
</div> </div>
</div> </div>
</section>
<div class="bbk-confirmation-addresses"> @if ($order->shippingAddress || $order->billingAddress)
<div class="bbk-confirmation-section bbk-confirmation-addresses">
@if ($order->shippingAddress) @if ($order->shippingAddress)
<div class="bbk-confirmation-address"> <section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-shipping-heading">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2> <h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-shipping-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<x-checkout::address-lines :address="$order->shippingAddress" /> <x-checkout::address-lines :address="$order->shippingAddress" />
</div> </section>
@endif @endif
@if ($order->billingAddress) @if ($order->billingAddress)
<div class="bbk-confirmation-address"> <section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-billing-heading">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2> <h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-billing-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<x-checkout::address-lines :address="$order->billingAddress" /> <x-checkout::address-lines :address="$order->billingAddress" />
</div> </section>
@endif @endif
</div> </div>
</div> @endif
@if ($bankTransferInstructions)
<section class="bbk-confirmation-section bbk-confirmation-bank-transfer" aria-labelledby="bbk-confirmation-bank-transfer-heading">
<h2 class="bbk-confirmation-bank-transfer-heading" id="bbk-confirmation-bank-transfer-heading">{{ __('checkout.page.confirmation_bank_transfer_heading') }}</h2>
<div class="bbk-confirmation-bank-transfer-body">{!! $bankTransferInstructions !!}</div>
</section>
@endif
</div> </div>
@endsection @endsection
+13 -2
View File
@@ -15,7 +15,9 @@
data-bbk-cart-target="panel" data-bbk-cart-target="panel"
> >
<header class="bbk-cart-panel-header"> <header class="bbk-cart-panel-header">
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2> {{-- tabindex=-1: the controller moves focus here on open, and back
here when the focused line is removed from under the user. --}}
<h2 class="bbk-cart-heading" id="bbk-cart-heading" tabindex="-1" data-bbk-cart-target="heading">{{ __('checkout.cart.title') }}</h2>
<button <button
type="button" type="button"
class="bbk-cart-dismiss" class="bbk-cart-dismiss"
@@ -26,7 +28,16 @@
@include('checkout::partials.cart-error') @include('checkout::partials.cart-error')
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite"> {{-- A short announcement after each update, rather than aria-live on
the body itself, which re-read the whole cart on every change. --}}
<p
class="bbk-visually-hidden"
role="status"
data-bbk-cart-target="status"
data-bbk-cart-message="{{ __('checkout.cart.updated') }}"
></p>
<div class="bbk-cart-panel-body" data-bbk-cart-target="body">
@include('checkout::partials.cart-body') @include('checkout::partials.cart-body')
</div> </div>
</aside> </aside>
+9 -3
View File
@@ -219,7 +219,7 @@
<section class="bbk-checkout-section"> <section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2> <h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
<div id="bbk-payment-methods"> <div id="bbk-payment-methods" data-bbk-payment-target="methods">
@include('checkout::partials.payment-methods', [ @include('checkout::partials.payment-methods', [
'paymentMethods' => $paymentMethods, 'paymentMethods' => $paymentMethods,
'cart' => $cart, 'cart' => $cart,
@@ -267,9 +267,15 @@
<aside class="bbk-checkout-aside"> <aside class="bbk-checkout-aside">
<div class="bbk-checkout-summary" data-controller="bbk-cart"> <div class="bbk-checkout-summary" data-controller="bbk-cart">
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2> <h2 class="bbk-checkout-summary-heading" tabindex="-1" data-bbk-cart-target="heading">{{ __('checkout.page.order_summary_heading') }}</h2>
@include('checkout::partials.cart-error') @include('checkout::partials.cart-error')
<div data-bbk-cart-target="body" aria-live="polite"> <p
class="bbk-visually-hidden"
role="status"
data-bbk-cart-target="status"
data-bbk-cart-message="{{ __('checkout.cart.updated') }}"
></p>
<div data-bbk-cart-target="body">
@include('checkout::partials.cart-body') @include('checkout::partials.cart-body')
</div> </div>
</div> </div>
@@ -24,80 +24,96 @@
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}"> <li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
<div class="bbk-cart-item-media"> <div class="bbk-cart-item-media">
@if ($thumb) @if ($thumb)
{{-- Decorative duplicate of the title link below — hidden from AT
and skipped by keyboard so the product isn't announced twice. --}}
@if ($productUrl) @if ($productUrl)
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1"> <a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy"> <img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
</a> </a>
@else @else
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy"> <img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
@endif @endif
@endif @endif
</div> </div>
<div class="bbk-cart-item-detail"> <div class="bbk-cart-item-detail">
@if ($productUrl) <div class="bbk-cart-item-head">
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a> @if ($productUrl)
@else <a href="{{ $productUrl }}" class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</a>
<p class="bbk-cart-item-title">{{ $name }}</p> @else
@endif <p class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</p>
@endif
<form
class="bbk-cart-item-remove-form"
method="POST"
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
{{-- Named "Remove", described by the product title, so AT
hears which line it removes rather than a bare "Remove". --}}
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
aria-describedby="bbk-cart-item-title-{{ $line->id }}"
><span aria-hidden="true">&times;</span></button>
</form>
</div>
@if ($variantLabel) @if ($variantLabel)
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p> <p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
@endif @endif
@include('checkout::partials.line-custom-fields', ['line' => $line]) @include('checkout::partials.line-custom-fields', ['line' => $line])
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p> <p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
<form <div class="bbk-cart-item-foot">
class="bbk-cart-qty" {{-- role=group + the title as its name: entering the stepper
method="POST" announces which product's quantity is being changed. --}}
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}" <form
> class="bbk-cart-qty"
@csrf method="POST"
@method('PATCH') action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
<button role="group"
type="button" aria-labelledby="bbk-cart-item-title-{{ $line->id }}"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}"
>&minus;</button>
<input
type="number"
name="quantity"
value="{{ $line->quantity }}"
min="0"
inputmode="numeric"
class="bbk-cart-qty-input"
data-action="change->bbk-cart#submit"
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
aria-label="{{ __('checkout.cart.quantity') }}"
> >
@csrf
@method('PATCH')
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}"
><span aria-hidden="true">&minus;</span></button>
<button <input
type="button" type="number"
class="bbk-cart-qty-btn" name="quantity"
data-action="bbk-cart#step" value="{{ $line->quantity }}"
data-bbk-cart-dir-param="1" min="0"
aria-label="{{ __('checkout.cart.increase') }}" inputmode="numeric"
>+</button> class="bbk-cart-qty-input"
</form> data-action="change->bbk-cart#submit"
</div> data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
aria-label="{{ __('checkout.cart.quantity') }}"
>
<div class="bbk-cart-item-aside"> <button
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p> type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}"
><span aria-hidden="true">+</span></button>
</form>
<form <p class="bbk-cart-item-total">
method="POST" <span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}" {{ $line->subTotal?->formatted() }}
data-action="submit->bbk-cart#submit" </p>
> </div>
@csrf
@method('DELETE')
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
>&times;</button>
</form>
</div> </div>
</li> </li>
@@ -19,9 +19,9 @@
<dl class="bbk-line-fields"> <dl class="bbk-line-fields">
@foreach ($fields as $field) @foreach ($fields as $field)
<div class="bbk-line-field"> <div class="bbk-line-field">
<dt>{{ $field['label'] }}</dt> @if ($field['type'] === 'file')
<dd> <dt>{{ $field['label'] }}:</dt>
@if ($field['type'] === 'file') <dd>
@php @php
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null); $file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
@endphp @endphp
@@ -39,11 +39,12 @@
@endif @endif
<span>{{ $file->original_name }}</span> <span>{{ $file->original_name }}</span>
</a> </a>
@endif @endif
@else </dd>
{{ $field['value'] }} @else
@endif <span>{{ $field['label'] }}: {{ $field['value'] }}</span>
</dd> @endif
</div> </div>
@endforeach @endforeach
</dl> </dl>
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="utf-8">
<style>
{{--
Geometry is transcribed directly from ELTA_PEL.sydetaE.rdlc
(the "delivery copy" RDLC ELTA's own client selects for
printer_size==1, i.e. plain A4, per Sydeta.cs::print_vg()) —
every .mc-*/.ps-* absolute position below is that report's
own Top/Left in cm, walked through its Rectangle/Textbox
nesting so each value is already relative to its own
containing band. Static label text and font sizes/weights
also come from the RDLC's own Textbox/Style elements, not
guessed from the screenshot in ELTA's setup manual (that
manual image was used only as a sanity check afterwards).
--}}
@page { margin: 0; size: 21cm 29.7cm; }
html, body { margin: 0; padding: 0; }
body { font-family: 'DejaVu Sans', sans-serif; font-size: 6.5pt; color: #000; }
.page { position: relative; width: 21cm; height: 29.7cm; }
/* Each RDLC "band" (one ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ copy) is its own
positioning context, so every child offset below is a direct
transcription of the RDLC's own relative-to-band Top/Left —
no manual offset arithmetic. */
.band { position: absolute; left: 0.27cm; }
.band > * { position: absolute; }
.mc-logo, .mc-company, .mc-box, .mc-cell, .mc-partybox { position: absolute; }
.mc-logo img { width: 100%; height: 100%; object-fit: contain; }
.mc-company { font-size: 4.6pt; line-height: 1.25; }
.mc-company-name { font-weight: bold; font-size: 5.2pt; }
.mc-text.mc-small { font-size: 4.6pt; }
.mc-box { border: 1px solid #000; box-sizing: border-box; }
.mc-title { position: absolute; font-weight: bold; font-size: 8pt; text-align: center; white-space: nowrap; }
.mc-barcode { position: absolute; text-align: center; }
.mc-barcode img { height: 26px; }
.mc-voucher { position: absolute; text-align: center; font-weight: bold; font-size: 10.5pt; letter-spacing: 0.5px; }
.mc-copylabel { position: absolute; text-align: center; font-weight: bold; font-size: 8pt; }
.mc-cell { border: 1px solid #000; box-sizing: border-box; padding: 1px 2px; }
.mc-cell-label { font-size: 5.5pt; }
.mc-cell-val { font-weight: bold; font-size: 8pt; margin-top: 3px; }
.mc-cell-service { text-align: center; }
/* 5-cell deposit/destination/weight/pieces/volumetric row: a real
table with border-collapse so each internal shared edge is
drawn once, not twice (see _main-copy.blade.php comment). */
.mc-cell-row { position: absolute; border-collapse: collapse; table-layout: fixed; }
.mc-cell-td { border: 1px solid #000; box-sizing: border-box; padding: 1px 2px; vertical-align: top; }
.mc-partybox { border: 1px solid #000; box-sizing: border-box; }
.mc-party-title { position: absolute; font-weight: bold; font-size: 9pt; }
.mc-party-line { position: absolute; font-size: 6.3pt; white-space: nowrap; overflow: hidden; }
.mc-charge { text-align: center; font-weight: bold; font-size: 8pt; box-sizing: border-box; padding-top: 2px; }
/* Payment stub (copy 3) reuses the .mc-* classes but at smaller
scale, matching the RDLC's own narrower band. */
.ps-brandbox { border: 1.5px solid #c8102e; box-sizing: border-box; text-align: center; padding-top: 4px; }
.ps-brand-logo img { width: 60%; margin: 0 auto 8px; display: block; }
.ps-brand-tag { color: #c8102e; font-weight: bold; font-size: 9pt; }
.ps-brandbox-small { border: 1px solid #000; box-sizing: border-box; text-align: center; padding: 2px; }
.ps-brand-logo-sm img { width: 40%; margin: 2px auto; display: block; }
.ps-legal-sm { font-size: 4.6pt; line-height: 1.3; }
/* Perforated tear-off strip on the right of copy 1 only, with
the rotated "3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ" label — RDLC's
image10 (x=15.42cm) is the dashed perforation line itself;
reproduced here as a CSS dashed border since we can't extract
that embedded bitmap. transform:rotate() (not
writing-mode:vertical-rl, confirmed broken in dompdf) rotates
a normal-flow block. */
.stub-strip { position: absolute; top: 2.25cm; left: 15.42cm; width: 5.58cm; height: 6.16cm; border-left: 1px dashed #000; }
.stub-vtext {
position: absolute; top: 2.6cm; left: -1.3cm; width: 6cm;
text-align: center; font-size: 6pt; font-weight: bold;
white-space: nowrap; transform: rotate(-90deg);
}
.cut-line { position: absolute; left: 0; width: 20.9cm; border-top: 1px dashed #000; text-align: center; font-weight: bold; font-size: 7pt; }
.cut-line span { position: relative; top: -5px; background: #fff; padding: 0 6px; }
.footer-ocr { position: absolute; top: 27.35cm; left: 0; width: 21cm; text-align: center; font-family: monospace; font-size: 8pt; letter-spacing: 1.5px; }
</style>
</head>
<body>
<div class="page">
{{-- Copy 1: delivery copy — RDLC band top=0 --}}
<div class="band" style="top:0.05cm; width:15.75cm; height:8.7cm;">
@include('core::shipping.carriers.elta.partials._main-copy', ['copyType' => 'delivery'])
</div>
<div class="stub-strip">
<div class="stub-vtext">3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ</div>
</div>
<div class="cut-line" style="top:9.0cm;"><span>✂ ΑΠΟΚΟΨΤΕ ΕΔΩ</span></div>
{{-- Copy 2: sender's copy — RDLC band top≈8.94cm on the full page --}}
<div class="band" style="top:9.05cm; width:15.75cm; height:9.9cm;">
@include('core::shipping.carriers.elta.partials._main-copy', ['copyType' => 'sender'])
</div>
<div class="cut-line" style="top:18.75cm;"><span>✂ ΑΠΟΚΟΨΤΕ ΕΔΩ</span></div>
{{-- Copy 3: ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ / ΜΕΤΑΒΙΒΑΣΗ stub — RDLC band top≈19.22cm --}}
<div style="position:absolute; top:19.05cm; left:0.27cm; width:20.4cm; font-weight:bold; font-size:7pt; text-align:center;">
ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ / ΜΕΤΑΒΙΒΑΣΗ &nbsp; Ο. Αριθμός Λογ/μού Ταχυπληρωμής {{ $siimvasi }}
</div>
<div class="band" style="top:19.45cm; width:16.75cm; height:7.4cm;">
@include('core::shipping.carriers.elta.partials._payment-stub')
</div>
<div class="cut-line" style="top:26.85cm;"><span>ΜΗ ΣΗΜΕΙΩΝΕΤΕ ΚΑΤΩ ΑΠΟ ΑΥΤΗ ΤΗ ΓΡΑΜΜΗ</span></div>
<div class="footer-ocr">&gt;{{ $ocr_line }}&lt;</div>
</div>
</body>
</html>
@@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="utf-8">
<style>
{{--
Geometry transcribed directly from ELTA_PEL.SydetaLabelE.rdlc
— the RDLC ELTA's own client selects for printer_size==2
(the A6/"thermal label" printer-setup radio button), per
Sydeta.cs::print_vg(); RDLCPrinter.cs overrides the actual
print DeviceInfo to 10.4cm x 14.8cm for this printer_size
regardless of what PageWidth/PageHeight the RDLC itself
declares (all ELTA RDLCs declare A4 internally). Every
.a6-* absolute position below is that report's own
Top/Left in cm.
--}}
@page { margin: 0; size: 10.4cm 14.8cm; }
html, body { margin: 0; padding: 0; }
body { font-family: 'DejaVu Sans', sans-serif; font-size: 6.3pt; color: #000; }
{{--
height is deliberately a hair under the true 14.8cm page —
dompdf's border-box math isn't quite exact at this scale, and
a .page box sized to exactly fill the page (even with
box-sizing:border-box) was empirically confirmed to overflow
a fraction of a point past the page canvas and silently push
a second, blank page (reproduced with an otherwise-empty
.page div: only the border's presence, not any content,
triggered it — 14.7cm was stable, 14.75cm was not).
--}}
.page { position: relative; width: 10.4cm; height: 14.65cm; border: 1.5px solid #000; box-sizing: border-box; overflow: hidden; }
.page > * { position: absolute; box-sizing: border-box; }
.a6-logo img { width: 100%; height: 100%; object-fit: contain; }
.a6-service-box { border: 1px solid #000; text-align: center; }
.a6-service-code { font-weight: bold; font-size: 7pt; }
.a6-service-name { font-weight: bold; font-size: 6pt; text-align: center; }
.a6-title { font-weight: bold; font-size: 7pt; }
.a6-datetime { font-size: 6pt; }
.a6-copy { font-weight: bold; font-size: 7pt; text-align: right; }
.a6-station-box { border: 1px solid #000; }
.a6-station-label { font-size: 6pt; font-weight: bold; }
.a6-station-val { font-weight: bold; font-size: 8pt; }
.a6-cell { border: 1px solid #000; padding: 1px 3px; }
.a6-cell-label { font-size: 6pt; }
.a6-cell-val { font-weight: bold; font-size: 8pt; }
/* ΧΡΕΩΣΗ/REFERENCE/ΣΥΜΒΑΣΗ row and the Γρ.Κατάθεσης/ΒΑΡΟΣ/
ΟΓΚΟΜΕΤΡΙΚΟ/Τεμάχια row: real tables with border-collapse so
each internal shared edge is drawn once (see label-a6.blade.php
comment above their markup). */
.a6-cell-row { position: absolute; border-collapse: collapse; border-spacing: 0; table-layout: fixed; }
.a6-cell-td { border: 1px solid #000; box-sizing: border-box; padding: 1px 3px; vertical-align: top; overflow: hidden; }
.a6-box { border: 1px solid #000; padding: 2px 3px; }
.a6-party-title { font-weight: bold; font-size: 7pt; }
.a6-party-line { font-size: 8pt; white-space: nowrap; overflow: hidden; }
.a6-return-title { font-weight: bold; font-size: 6pt; }
.a6-return-item { font-size: 6pt; }
.a6-checkbox { border: 1px solid #000; display: inline-block; width: 6px; height: 6px; margin-right: 3px; vertical-align: middle; }
.a6-small-title { font-weight: bold; font-size: 5pt; }
.a6-small-val { font-size: 6pt; }
.a6-legal { font-size: 5pt; text-align: center; line-height: 1.4; }
.a6-barcode { text-align: center; }
.a6-barcode img { height: 30px; }
.a6-voucher { text-align: center; font-weight: bold; font-size: 12pt; letter-spacing: 1px; }
</style>
</head>
<body>
<div class="page">
{{-- Header: logo (0.10,0.12,1.66x1.26) + service box (6.42,0.10,3.93x0.84) --}}
<div class="a6-logo" style="top:0.12cm;left:0.10cm;width:1.66cm;height:1.26cm;">
<img src="{{ $eltaLogo }}" alt="ELTA Courier">
</div>
<div class="a6-service-box" style="top:0.10cm;left:6.42cm;width:3.93cm;height:0.84cm;">
<div class="a6-service-code" style="position:absolute;top:0.02cm;left:0.09cm;">ΥΠΗΡΕΣΙΑ: {{ $service_code }}</div>
<div class="a6-service-name" style="position:absolute;top:0.40cm;left:0.04cm;width:3.81cm;">{{ $service_name }}</div>
</div>
<div class="a6-licence-box" style="top:1.05cm;left:6.42cm;width:3.88cm;height:0.62cm;border:1px solid #000;text-align:center;font-size:5.3pt;font-weight:bold;line-height:1.3;padding-top:2px;">
ΕΕΤΤ ΑΜ: 99-150 Γενική Άδεια<br>Ταχ/κων Υπηρεσιών
</div>
<div class="a6-datetime" style="top:1.68cm;left:0.13cm;width:1.68cm;">{{ $date }}</div>
<div class="a6-title" style="top:1.79cm;left:1.93cm;width:5.89cm;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div>
{{-- The RDLC's own barcode textbox here uses the "Free 3 of 9
Extended" barcode font — we don't have that font, so this
would-be *{voucher}* fallback text is dropped in favor of the
real Code 128 barcode image rendered near the bottom instead. --}}
<div class="a6-datetime" style="top:2.00cm;left:0.16cm;width:1.60cm;">{{ $time }}</div>
<div class="a6-station-box" style="top:2.33cm;left:0.10cm;width:10.20cm;height:0.44cm;">
<div class="a6-station-label" style="position:absolute;top:0.05cm;left:0.10cm;">Γρ. Επίδοσης :</div>
<div class="a6-station-val" style="position:absolute;top:0.03cm;left:2.50cm;">{{ $station_pros }}</div>
<div class="a6-station-val" style="position:absolute;top:0.03cm;left:3.90cm;width:6.06cm;">{{ $station_pros_title }}</div>
</div>
{{-- ΧΡΕΩΣΗ / REFERENCE / ΣΥΜΒΑΣΗ 3-cell row, and the Γρ.Κατάθεσης /
ΒΑΡΟΣ / ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ / Τεμάχια row right below it: both are
genuinely tabular single rows of fixed-width cells, so — like
the equivalent A4 rows — they're built as real
<table border-collapse:collapse> instead of independently-
bordered absolute divs, which was drawing every shared edge
(each cell-to-cell seam, and the seam between these two rows)
twice, visibly doubling/thickening those lines versus a real
printed ELTA label. Each table is positioned at the row's own
RDLC top/left; the first table's bottom border is dropped since
the second table's top border already draws that shared line. --}}
<table class="a6-cell-row" style="top:2.82cm;left:0.10cm;width:10.16cm;">
<colgroup>
<col style="width:5.41cm;"><col style="width:2.84cm;"><col style="width:1.91cm;">
</colgroup>
<tr>
<td class="a6-cell-td" style="height:0.47cm;border-bottom:none;">
<div style="font-size:7pt;">{{ $xreosi }}</div>
</td>
<td class="a6-cell-td" style="height:0.47cm;text-align:center;border-bottom:none;">
<div style="font-size:7pt;">{{ $ocr_reference }}</div>
</td>
<td class="a6-cell-td" style="height:0.47cm;text-align:center;border-bottom:none;">
<div style="font-size:7pt;">{{ $siimvasi }}</div>
</td>
</tr>
</table>
<table class="a6-cell-row" style="top:3.39cm;left:0.10cm;width:9.97cm;">
<colgroup>
<col style="width:2.00cm;"><col style="width:2.00cm;"><col style="width:4.12cm;"><col style="width:1.85cm;">
</colgroup>
<tr>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">Γρ.Κατάθεσης:</div>
<div class="a6-cell-val">{{ $station_apo }}</div>
</td>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">ΒΑΡΟΣ(Kgr)</div>
<div class="a6-cell-val">{{ $weight }}</div>
</td>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ(Kgr)</div>
<div class="a6-cell-val" style="font-size:7pt;">{{ $volumetric_weight ?? '0.000' }}</div>
</td>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">Τεμάχια</div>
<div class="a6-cell-val">{{ $package_label }}</div>
</td>
</tr>
</table>
{{-- ΑΠΟΣΤΟΛΕΑΣ + ΑΙΤΙΑ ΕΠΙΣΤΡΟΦΗΣ --}}
<div class="a6-box" style="top:4.43cm;left:0.10cm;width:7.24cm;height:2.44cm;">
@include('core::shipping.carriers.elta.partials._party-box', [
'title' => 'ΑΠΟΣΤΟΛΕΑΣ',
'lines' => $sender_lines,
])
</div>
<div class="a6-box" style="top:4.43cm;left:7.55cm;width:2.80cm;height:2.44cm;">
<div class="a6-return-title">ΑΙΤΙΑ ΕΠΙΣΤΡΟΦΗΣ</div>
<div style="margin-top:4px;">
<div class="a6-return-item"><span class="a6-checkbox"></span>Άγνωστος</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Ελλειπή Δ/νση</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Απαράδεκτο</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Άλλαξε Δ/νση</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Αζήτητο</div>
</div>
</div>
{{-- ΠΑΡΑΛΗΠΤΗΣ + ΑΝΤΙΚΑΤΑΒΟΛΗ column --}}
<div class="a6-box" style="top:6.97cm;left:0.10cm;width:7.26cm;height:2.78cm;">
@include('core::shipping.carriers.elta.partials._party-box', [
'title' => 'ΠΑΡΑΛΗΠΤΗΣ',
'lines' => $recipient_lines,
])
</div>
<div class="a6-box" style="top:6.97cm;left:7.48cm;width:2.85cm;height:2.79cm;">
@if ($antik_1 ?? null)
<div style="font-size:6pt;">{{ $antik_1 }}</div>
@endif
</div>
{{-- REFERENCE / ΕΠΙΒΑΡΥΝΣΕΙΣ --}}
<div class="a6-box" style="top:9.84cm;left:0.10cm;width:5.81cm;height:0.69cm;">
<div class="a6-small-title">REFERENCE</div>
<div class="a6-small-val" style="margin-top:3px; font-weight:bold;">{{ $order_reference }}</div>
</div>
<div class="a6-box" style="top:9.84cm;left:6.00cm;width:4.37cm;height:1.78cm;">
<div class="a6-small-title">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div>
</div>
{{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}}
<div class="a6-box" style="top:10.60cm;left:0.10cm;width:5.82cm;height:1.01cm;">
<div class="a6-small-title">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
</div>
@if ($multiPiece)
<div class="a6-box" style="top:11.68cm;left:0.10cm;width:10.23cm;height:0.54cm;text-align:center;">
<div style="font-weight:bold; font-size:9pt; margin-top:2px;">* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ * {{ $package_label }}</div>
</div>
@endif
<div class="a6-box" style="top:12.20cm;left:0.11cm;width:10.23cm;height:0.57cm;">
<div class="a6-legal">
Ισχύουν οι Γενικοί Οροι Παραχής Υπηρεσιών οι οποίοι βρίσκονται αναρτημένοι στο www.elta-courier.gr<br>
και είναι διαθέσιμοι σε ολα τα καταστήματα της εταιρίας.
</div>
</div>
@if ($antik_1 ?? null)
<div class="a6-box" style="top:12.70cm;left:0.10cm;width:10.23cm;height:0.51cm;text-align:center;">
<div style="font-weight:bold; font-size:9pt; margin-top:4px;">{{ $antik_1 }}</div>
</div>
@endif
<div class="a6-barcode" style="top:13.20cm;left:0.10cm;width:10.27cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
<div class="a6-voucher" style="top:13.95cm;left:0.10cm;width:10.30cm;">{{ $voucher_no }}</div>
</div>
</body>
</html>
@@ -0,0 +1,153 @@
{{--
One "ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ" copy (delivery or sender's),
geometry transcribed directly from ELTA_PEL.sydetaE.rdlc's delivery-
copy band (Top/Left values there are relative to that band; here
they're absolute cm offsets from THIS partial's own 0,0, since the
caller wraps it in a `position:relative` container sized 15.75cm x
8.75cm — the same width/height as the RDLC band, read off its own
rectangle8/rectangle9/rectangle11/rectangle12 extents).
Expects: $copyType ('delivery'|'sender') and all of
EltaLabelRenderer's $data.
--}}
<div class="mc-logo" style="top:0.09cm;left:0.32cm;width:2.65cm;height:1.93cm;">
<img src="{{ $eltaLogo }}" alt="ELTA Courier">
</div>
<div class="mc-company" style="top:0.12cm;left:3.15cm;width:5.19cm;">
<div class="mc-company-name">ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.</div>
<div>Έδρα: Απελλού 1, 105 51, Αθήνα</div>
<div>Υποκ/μα: Λ. Μεσογείων 395, 153 43, Αγ. Παρασκευή</div>
<div>Α.Φ.Μ.: 094026421 | Δ.Ο.Υ.: ΚΕΦΟ.Δ.Ε. ΑΤΤΙΚΗΣ</div>
<div>Τ. 210-6073000 | Ε. info@elta-courier.gr</div>
<div>www.eltacourier.gr</div>
</div>
<div class="mc-text mc-small" style="top:1.80cm;left:3.15cm;width:5.03cm;">ΕΕΤΤ ΑΜ 99-150 Γενική Αδεια Ταχ/κων Υπηρεσιών</div>
{{-- Title / barcode / voucher, centered column at x=8.52..15.58 --}}
<div class="mc-box" style="top:0.05cm;left:8.52cm;width:7.06cm;height:1.75cm;">
<div class="mc-title" style="top:0.19cm;left:0.05cm;width:6.85cm;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div>
<div class="mc-barcode" style="top:0.57cm;left:0.24cm;width:6.67cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
{{-- The RDLC's own barcode textbox uses the "Free 3 of 9 Extended"
barcode font (literal *{voucher}* text rendered as bars by that
font) — we don't have that font, so we render a real Code 128
barcode image above instead (visually equivalent, actually
scannable), making this second plain-text *{voucher}* line
redundant with it rather than a distinct field. --}}
<div class="mc-voucher" style="top:1.29cm;left:0.24cm;width:6.64cm;">{{ $voucher_no }}</div>
</div>
{{-- 5-cell deposit/destination/weight/pieces/volumetric row — a genuinely
tabular single row of fixed-width cells, so it's built as a real
<table border-collapse:collapse> rather than independently-bordered
absolute divs: that was drawing every shared internal edge twice
(once per adjacent cell), producing a visibly doubled/thickened line
that real printed ELTA labels don't show. The table is positioned via
the same absolute top/left/width the RDLC geometry gives the row as a
whole; each <td> keeps its own RDLC-derived width via <col>. --}}
<table class="mc-cell-row" style="top:2.20cm;left:0.27cm;width:8.44cm;height:0.82cm;">
<colgroup>
<col style="width:1.51cm;"><col style="width:1.67cm;"><col style="width:1.35cm;"><col style="width:1.08cm;"><col style="width:2.83cm;">
</colgroup>
<tr>
<td class="mc-cell-td">
<div class="mc-cell-label">Γρ.Κατάθεσης</div>
<div class="mc-cell-val">{{ $station_apo }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Γρ.Προορισμού</div>
<div class="mc-cell-val">{{ $station_pros }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Βάρος</div>
<div class="mc-cell-val">{{ $weight }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Τεμάχια</div>
<div class="mc-cell-val">{{ $package_label }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Ογκ/κο Βάρος</div>
<div class="mc-cell-val" style="font-size:7pt;">{{ $volumetric_weight ?? '0.000' }}</div>
</td>
</tr>
</table>
<div class="mc-cell mc-cell-service" style="top:2.20cm;left:9.19cm;width:6.19cm;height:0.82cm;">
<div style="position:absolute;top:0.05cm;left:0.32cm;font-size:6.5pt;">ΥΠΗΡΕΣΙΑ :</div>
<div style="position:absolute;top:0.03cm;left:3.21cm;font-size:7.5pt;">{{ $service_code }}</div>
<div style="position:absolute;top:0.42cm;left:0.05cm;width:6.08cm;text-align:center;font-size:7.5pt;">{{ $service_name }}</div>
</div>
{{-- Sender box --}}
<div class="mc-box mc-partybox" style="top:3.12cm;left:0.27cm;width:6.24cm;height:2.53cm;">
<div class="mc-party-title" style="top:0.05cm;left:0.10cm;">ΑΠΟΣΤΟΛΕΑΣ</div>
@foreach ($sender_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.53 + $i * 0.395 }}cm;left:0.05cm;width:6.11cm;">{{ $line }}</div>
@endif
@endforeach
</div>
{{-- Recipient box --}}
<div class="mc-box mc-partybox" style="top:5.76cm;left:0.29cm;width:6.20cm;height:2.68cm;">
<div class="mc-party-title" style="top:0.05cm;left:0.05cm;">ΠΑΡΑΛΗΠΤΗΣ</div>
@foreach ($recipient_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.54 + $i * 0.445 }}cm;left:0.05cm;width:6.11cm;">{{ $line }}</div>
@endif
@endforeach
</div>
{{-- Charge banner + right-column panel stack (ΧΡΕΩΣΗ / Συν.Χρέωσης /
REFERENCE / ΠΑΡΑΤΗΡΗΣΕΙΣ, and the Πρόσθετες Υπηρεσίες column beside
them): these panels sit only a hair apart (~0.09-0.10cm, the RDLC's
own geometry, left untouched), close enough that each pair's two
independent borders read as one thick/doubled line at print
resolution. Each panel below keeps only the border sides it "owns"
on a shared seam (border-top/border-left removed where the
neighbouring panel above/left already draws that same line), so
every seam is drawn exactly once while every panel's outward-facing
sides keep their border. --}}
<div class="mc-box mc-charge" style="top:3.09cm;left:6.60cm;width:8.78cm;height:0.48cm;">{{ str_replace(' ΠΙΣΤΩΣΗ', ' ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ', $xreosi) }}</div>
<div class="mc-box" style="top:3.66cm;left:6.60cm;width:4.37cm;height:0.44cm;border-top:none;">
<div style="position:absolute;top:0.05cm;left:0.05cm;font-size:7pt;font-weight:bold;">Συν.Χρέωσης (€):</div>
</div>
<div class="mc-box" style="top:3.66cm;left:11.07cm;width:4.31cm;height:2.65cm;border-top:none;">
<div style="position:absolute;top:0.04cm;left:0.05cm;font-size:6pt;">Πρόσθετες Υπηρεσίες</div>
@foreach ([$sur_1 ?? null, $sur_2 ?? null, $sur_3 ?? null, $sur_4 ?? null] as $i => $sur)
@if (!empty($sur))
<div style="position:absolute;top:{{ 0.35 + $i * 0.365 }}cm;left:0.08cm;width:4.18cm;font-size:6pt;">{{ $sur }}</div>
@endif
@endforeach
</div>
<div class="mc-box" style="top:4.20cm;left:6.59cm;width:4.37cm;height:0.67cm;border-top:none;border-right:none;">
<div style="position:absolute;top:0.03cm;left:0.05cm;font-size:6pt;">* REFERENCE No*</div>
<div style="position:absolute;top:0.31cm;left:0.05cm;font-size:6.5pt;">{{ $order_reference }}</div>
</div>
<div class="mc-box" style="top:4.97cm;left:6.60cm;width:4.37cm;height:1.31cm;border-top:none;">
<div style="position:absolute;top:0.03cm;left:0.08cm;font-size:6pt;">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
</div>
{{-- Right-hand signature / print-timestamp column, differs by copy type --}}
@if ($copyType === 'delivery')
<div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;">
<div style="position:absolute;top:0.05cm;left:0.08cm;font-size:6.5pt;">{{ $antik_1 ?? '' }}</div>
</div>
<div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;">ΓΙΑ ΤΗΝ ΠΑΡΑΛΑΒΗ</div>
<div style="position:absolute;top:0.38cm;left:0.08cm;font-size:6pt;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ</div>
<div style="position:absolute;top:1.73cm;left:0.11cm;font-size:6pt;">{{ $date }} {{ $time }}</div>
</div>
@else
<div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;">
<div style="position:absolute;top:0.05cm;left:0.08cm;font-size:6.5pt;">{{ $antik_1 ?? '' }}</div>
</div>
<div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ</div>
<div style="position:absolute;top:1.73cm;left:0.11cm;font-size:6pt;">Ημερομηνία - Ωρα Εκτύπωσης: {{ $date }} {{ $time }}</div>
</div>
@endif
@@ -0,0 +1,17 @@
{{--
Sender/recipient box, matching SydetaLabelE.rdlc's rectangle10/
rectangle11 (ΑΠΟΣΤΟΛΕΑΣ/ΠΑΡΑΛΗΠΤΗΣ) layout: a bold title line, then
the RDLC's sender_1..5 / rec_1..5 fields as separate plain text
lines (ELTA's own client pre-wraps the address into these fixed-
width lines server-side, so we render each line separately rather
than reflowing the address ourselves, to match the real line
breaks).
Expects: $title, $lines (array of up to 5 strings).
--}}
<div class="pb-title">{{ $title }}</div>
@foreach ($lines as $line)
@if (trim((string) $line) !== '')
<div class="pb-line">{{ $line }}</div>
@endif
@endforeach
@@ -0,0 +1,120 @@
{{--
Copy 3: the ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ/ΜΕΤΑΒΙΒΑΣΗ payment-receipt stub,
geometry transcribed from sydetaE.rdlc's third band (source Top
values there run ~19.22cm-27.0cm on the full A4 page; offsets below
are relative to this partial's own container, i.e. the RDLC's Top
minus 19.22cm). This band is narrower than copies 1/2 (starts at
x=3.01cm instead of x=0.27cm) — the RDLC leaves its left margin for
the perforated tear line + rotated "3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ"
text, reproduced by the caller outside this partial.
Expects all of EltaLabelRenderer's $data.
--}}
<div class="mc-box" style="top:0.00cm;left:6.44cm;width:6.64cm;height:1.15cm;">
<div class="mc-barcode" style="top:0.10cm;left:0.22cm;width:6.27cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
<div class="mc-voucher" style="top:0.75cm;left:0.24cm;width:6.14cm;">{{ $voucher_no }}</div>
</div>
<div class="mc-copylabel" style="position:absolute;top:0.10cm;left:0.00cm;width:3.92cm;font-size:8pt;font-weight:bold;">{{ $voucher_no }}</div>
{{-- 4-cell deposit/destination/weight/pieces row: same doubled-border
issue and same fix as _main-copy.blade.php's 5-cell row — a real
<table border-collapse:collapse> in place of 4 independently-bordered
absolute divs, positioned at the row's own RDLC top/left/width. --}}
<table class="mc-cell-row" style="top:1.60cm;left:0.02cm;width:5.61cm;height:0.82cm;">
<colgroup>
<col style="width:1.51cm;"><col style="width:1.67cm;"><col style="width:1.35cm;"><col style="width:1.08cm;">
</colgroup>
{{-- border-bottom:none on every cell: the ΑΠΟΣΤΟΛΕΑΣ party box
immediately below (top:2.44cm, this row ends at 2.42cm) already
draws that shared line with its own border-top. --}}
<tr>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Γρ.Κατάθεσης</div>
<div class="mc-cell-val">{{ $station_apo }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Γρ.Προορισμού</div>
<div class="mc-cell-val">{{ $station_pros }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Βάρος</div>
<div class="mc-cell-val">{{ $weight }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Τεμάχια</div>
<div class="mc-cell-val">{{ $package_label }}</div>
</td>
</tr>
</table>
<div class="mc-box" style="top:1.60cm;left:5.90cm;width:4.87cm;height:0.40cm;">
<div style="position:absolute;top:0.05cm;left:0.13cm;font-size:6pt;">{{ $service_code }}</div>
<div style="position:absolute;top:0.03cm;left:0.85cm;width:3.89cm;font-size:6pt;">{{ $service_name }}</div>
</div>
{{-- Below: several panel pairs sit a hair apart (or, for the
Συν.Χρέωσης/ΕΠΙΒΑΡΥΝΣΕΙΣ pair, even overlap slightly) per the RDLC's
own geometry — left untouched — close enough that two independent
borders read as one doubled/thick line. border-top/border-bottom is
removed from one side of each pair so only the remaining box's
border draws that shared line. --}}
<div class="mc-box mc-charge" style="top:2.02cm;left:5.90cm;width:4.87cm;height:0.37cm;font-size:5.4pt;padding-top:4px;white-space:nowrap;overflow:hidden;border-top:none;">{{ str_replace(' ΠΙΣΤΩΣΗ', ' ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ', $xreosi) }}</div>
<div class="mc-box mc-partybox" style="top:2.44cm;left:0.00cm;width:5.98cm;height:0.91cm;overflow:hidden;">
<div class="mc-party-title" style="top:0.03cm;left:0.10cm;font-size:6.5pt;">ΑΠΟΣΤΟΛΕΑΣ</div>
@foreach ($sender_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.30 + $i * 0.19 }}cm;left:0.06cm;width:5.85cm;font-size:5.6pt;">{{ $line }}</div>
@endif
@endforeach
</div>
<div class="mc-box" style="top:2.45cm;left:6.06cm;width:4.71cm;height:1.17cm;border-bottom:none;">
<div style="position:absolute;top:0.02cm;left:0.16cm;font-size:6pt;">Συν.Χρέωσης (€):</div>
<div style="position:absolute;top:0.30cm;left:0.16cm;font-size:6pt;">Πρόσθετες Υπηρεσίες</div>
</div>
<div class="mc-box mc-partybox" style="top:3.35cm;left:0.00cm;width:5.97cm;height:1.63cm;overflow:hidden;border-top:none;">
<div class="mc-party-title" style="top:0.06cm;left:0.10cm;font-size:6.5pt;">ΠΑΡΑΛΗΠΤΗΣ</div>
@foreach ($recipient_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.34 + $i * 0.26 }}cm;left:0.10cm;width:5.79cm;font-size:5.6pt;">{{ $line }}</div>
@endif
@endforeach
</div>
<div class="mc-box" style="top:3.28cm;left:6.06cm;width:4.71cm;height:1.41cm;">
<div style="position:absolute;top:0.05cm;left:0.14cm;font-size:6pt;">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div>
</div>
<div style="position:absolute;top:4.95cm;left:0.05cm;width:8.75cm;font-size:4.6pt;line-height:1.25;white-space:nowrap;">
Έλαβα γνώση των όρων που αναγράφονται στο αντίγραφο 1 και 6 και τους αποδέχομαι ανεπιφύλακτα
</div>
{{-- rectangle29 --}}
<div class="mc-box" style="top:5.46cm;left:0.03cm;width:2.75cm;height:1.66cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ</div>
<div style="position:absolute;top:1.35cm;left:0.08cm;font-size:6pt;">{{ $date }}</div>
</div>
{{-- rectangle43 --}}
<div class="mc-box" style="top:5.47cm;left:2.89cm;width:3.76cm;height:1.66cm;">
<div style="position:absolute;top:0.09cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ ΠΑΡΑΛΗΠΤΗ</div>
<div style="position:absolute;top:1.35cm;left:0.08cm;font-size:6pt;">Ημ/νία: &nbsp;&nbsp;&nbsp; Ώρα:</div>
</div>
{{-- rectangle44 --}}
<div class="mc-box" style="top:5.48cm;left:6.76cm;width:3.99cm;height:1.68cm;text-align:center;">
<div style="position:absolute;top:0.04cm;left:0;width:100%;font-size:7pt;">Π Ο Σ Ο</div>
<div style="position:absolute;top:0.75cm;left:0;width:100%;font-size:13pt;font-weight:bold;">{{ number_format($cod_amount ?? 0, 2) }}</div>
</div>
{{-- rectangle45: small ELTA-branded legal box; the real RDLC fills it via
its own Image18 bitmap (baked "ΠΟΡΤΑ-ΠΟΡΤΑ ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ..."
legal text + logo graphic) rather than text fields — approximated
here with our own extracted logo + the same legal text as plain
HTML, since we can't extract that embedded bitmap. --}}
<div class="mc-box ps-brandbox-small" style="top:5.55cm;left:10.84cm;width:2.72cm;height:1.61cm;">
<div class="ps-brand-logo-sm"><img src="{{ $eltaLogo }}" alt=""></div>
<div class="ps-legal-sm">
<strong>ΠΟΡΤΑ-ΠΟΡΤΑ</strong><br>
ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.<br>
ΕΕΤΤ ΑΜ: 99-150
</div>
</div>
+13 -1
View File
@@ -11,7 +11,7 @@ class Staff extends ModelsStaff
'last_name', 'last_name',
'admin', 'admin',
'email', 'email',
'otp_code', 'otp_code_hash',
'otp_expires_at', 'otp_expires_at',
]; ];
@@ -19,6 +19,18 @@ class Staff extends ModelsStaff
'admin' => 'bool', 'admin' => 'bool',
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'otp_code_hash' => 'hashed',
'otp_expires_at' => 'datetime', 'otp_expires_at' => 'datetime',
]; ];
// Overrides (doesn't merge with) Lunar\Admin\Models\Staff's own
// $hidden — repeats its password/remember_token here so this class
// doesn't silently drop that protection while adding otp_code_hash/
// otp_expires_at, which the base model has no reason to know about.
protected $hidden = [
'password',
'remember_token',
'otp_code_hash',
'otp_expires_at',
];
} }
+11 -3
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Auth\Services; namespace Modules\Core\Auth\Services;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Modules\Core\Auth\Mail\OtpMail; use Modules\Core\Auth\Mail\OtpMail;
use Modules\Core\Auth\Models\Staff; use Modules\Core\Auth\Models\Staff;
@@ -27,7 +28,10 @@ class OtpService
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT); $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
$staff->otp_code = $code; // otp_code_hash's 'hashed' cast (see Staff's own $casts) hashes
// this automatically on assignment, same as password — never
// stored or compared in plaintext.
$staff->otp_code_hash = $code;
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); $staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$staff->save(); $staff->save();
@@ -44,11 +48,15 @@ class OtpService
return null; return null;
} }
if (! $staff->otp_expires_at || $staff->otp_code != $code || now()->isAfter($staff->otp_expires_at)) { if (! $staff->otp_code_hash || ! $staff->otp_expires_at || now()->isAfter($staff->otp_expires_at)) {
return null; return null;
} }
$staff->otp_code = null; if (! Hash::check($code, $staff->otp_code_hash)) {
return null;
}
$staff->otp_code_hash = null;
$staff->otp_expires_at = null; $staff->otp_expires_at = null;
$staff->save(); $staff->save();
+17 -9
View File
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserAuthenticated; use Modules\Core\Auth\Events\UserAuthenticated;
@@ -40,8 +41,11 @@ use Modules\Core\Auth\Mail\UserOtpMail;
* at all — firstOrCreate() and UserCreated only fire from validate(), and * at all — firstOrCreate() and UserCreated only fire from validate(), and
* only once the code has actually been proven correct. An email that * only once the code has actually been proven correct. An email that
* already has a User row is unaffected: its OTP state still lives on that * already has a User row is unaffected: its OTP state still lives on that
* row's own otp_code/otp_expires_at/otp_attempts columns exactly as * row's own otp_code_hash/otp_expires_at/otp_attempts columns exactly as
* before, so a returning shopper's login is unchanged. * before, so a returning shopper's login is unchanged. otp_code_hash
* holds a bcrypt hash of the code (the 'otp_code_hash' => 'hashed' cast
* on App\Models\User hashes it automatically on assignment, same as
* password), not the code itself — compared via Hash::check().
* *
* Two independent throttles, both configured under core.auth.otp — see * Two independent throttles, both configured under core.auth.otp — see
* config/core.php's own comment for why they're separate: max_attempts * config/core.php's own comment for why they're separate: max_attempts
@@ -87,7 +91,7 @@ class UserOtpService
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT); $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
if ($user) { if ($user) {
$user->otp_code = $code; $user->otp_code_hash = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); $user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->otp_attempts = 0; $user->otp_attempts = 0;
$user->save(); $user->save();
@@ -96,8 +100,12 @@ class UserOtpService
// class's own docblock for why: creating one on every // class's own docblock for why: creating one on every
// generateAndSend() call let anyone mint real User/Customer // generateAndSend() call let anyone mint real User/Customer
// rows for an email nobody proved they owned. // rows for an email nobody proved they owned.
//
// Hashed even in the cache (not just on the DB-backed path)
// — a code sitting in Cache::get()-able storage is the same
// exposure as a plaintext DB column if anything can read it.
Cache::put($this->pendingKey($email), [ Cache::put($this->pendingKey($email), [
'code' => $code, 'code_hash' => Hash::make($code),
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp, 'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
'attempts' => 0, 'attempts' => 0,
], now()->addMinutes(self::EXPIRY_MINUTES)); ], now()->addMinutes(self::EXPIRY_MINUTES));
@@ -154,15 +162,15 @@ class UserOtpService
return DB::transaction(function () use ($model, $email, $code) { return DB::transaction(function () use ($model, $email, $code) {
$user = $model::where('email', $email)->lockForUpdate()->first(); $user = $model::where('email', $email)->lockForUpdate()->first();
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) { if (! $user || ! $user->otp_code_hash || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
return null; return null;
} }
if (! hash_equals((string) $user->otp_code, $code)) { if (! Hash::check($code, $user->otp_code_hash)) {
$user->otp_attempts++; $user->otp_attempts++;
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) { if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
$user->otp_code = null; $user->otp_code_hash = null;
$user->otp_expires_at = null; $user->otp_expires_at = null;
$user->otp_attempts = 0; $user->otp_attempts = 0;
} }
@@ -172,7 +180,7 @@ class UserOtpService
return null; return null;
} }
$user->otp_code = null; $user->otp_code_hash = null;
$user->otp_expires_at = null; $user->otp_expires_at = null;
$user->otp_attempts = 0; $user->otp_attempts = 0;
$user->save(); $user->save();
@@ -200,7 +208,7 @@ class UserOtpService
return null; return null;
} }
if (! hash_equals((string) $pending['code'], $code)) { if (! Hash::check($code, $pending['code_hash'])) {
$pending['attempts']++; $pending['attempts']++;
if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) { if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) {
@@ -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;
}
}
+2 -1
View File
@@ -65,6 +65,7 @@ class CorePlugin implements Plugin
->brandName('Boboko') ->brandName('Boboko')
->brandLogo(asset('static/logos/core/boboko-logo.svg')) ->brandLogo(asset('static/logos/core/boboko-logo.svg'))
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg')) ->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
->favicon(asset('static/logos/core/favicon.svg'))
->login(Login::class) ->login(Login::class)
->resources([ ->resources([
LanguageLineResource::class, LanguageLineResource::class,
@@ -150,7 +151,7 @@ class CorePlugin implements Plugin
}); });
LunarStaff::addActivitylogExcept([ LunarStaff::addActivitylogExcept([
'otp_code', 'otp_code_hash',
'otp_expires_at', 'otp_expires_at',
'password', 'password',
'remember_token', 'remember_token',
@@ -115,7 +115,7 @@ class CustomerDataProvider implements PersonalDataProvider
// secret tied to an identity that no longer exists here — clear // secret tied to an identity that no longer exists here — clear
// it alongside name/email rather than leaving it to expire on // it alongside name/email rather than leaving it to expire on
// its own 10-minute window. // its own 10-minute window.
'otp_code' => null, 'otp_code_hash' => null,
'otp_expires_at' => null, 'otp_expires_at' => null,
'otp_attempts' => 0, 'otp_attempts' => 0,
]); ]);
@@ -23,7 +23,7 @@ use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
* hash of the code, expiry, wrong-guess count) lives on the user's own * hash of the code, expiry, wrong-guess count) lives on the user's own
* row (see the migration adding pending_email/pending_email_code_hash/ * row (see the migration adding pending_email/pending_email_code_hash/
* pending_email_expires_at/pending_email_attempts) — the same convention * pending_email_expires_at/pending_email_attempts) — the same convention
* Auth\Services\UserOtpService's otp_code/otp_expires_at/otp_attempts * Auth\Services\UserOtpService's otp_code_hash/otp_expires_at/otp_attempts
* already use — rather than the session, since a code arrives by email * already use — rather than the session, since a code arrives by email
* and is often opened on a different device/session than the one that * and is often opened on a different device/session than the one that
* requested it; a session-scoped pending change couldn't be confirmed * requested it; a session-scoped pending change couldn't be confirmed
@@ -0,0 +1,322 @@
<?php
namespace Modules\Core\Localization\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
/**
* Default `storefront` translation lines — nav, cart, product, shop, auth,
* account, orders, contact, reviews, wishlist (see the storefront views
* under resources/views for where each is used).
*
* Additive and idempotent: a group/key that already exists is left untouched,
* so anything edited in the Filament Language Lines UI wins on a re-run. Runs
* explicitly — `php artisan db:seed --class="Modules\Core\Localization\
* Database\Seeders\StorefrontTranslationsSeeder"` — it is not wired into any
* app's own DatabaseSeeder.
*
* Greek copy uses an informal register (εσύ/σου) — a consuming app with a
* different house style overrides individual lines from the Filament
* Language Lines UI same as any other translation, rather than forking
* this class.
*/
class StorefrontTranslationsSeeder extends Seeder
{
public function run(): void
{
$translations = app(TranslationService::class);
foreach ($this->lines() as $key => [$en, $el]) {
$exists = LanguageLine::query()
->where('group', 'storefront')
->where('key', $key)
->exists();
if ($exists) {
$this->command?->warn("storefront.{$key} already exists — skipped");
continue;
}
$translations->create('storefront', $key, ['en' => $en, 'el' => $el]);
$this->command?->info("storefront.{$key} added");
}
}
/**
* key => [English, Greek].
*
* @return array<string, array{0: string, 1: string}>
*/
private function lines(): array
{
return [
// ── Navigation ──────────────────────────────────────────────
'nav.home' => ['Home', 'Αρχική'],
'nav.products' => ['Products', 'Προϊόντα'],
'nav.cart' => ['Cart', 'Καλάθι'],
'nav.account' => ['Account', 'Λογαριασμός'],
'nav.back' => ['Back', 'Πίσω'],
'nav.contact' => ['Contact', 'Επικοινωνία'],
'nav.close' => ['Close', 'Κλείσιμο'],
// ── Cart ────────────────────────────────────────────────────
'cart.empty' => ['Your cart is empty', 'Το καλάθι σας είναι άδειο'],
'cart.checkout' => ['Checkout', 'Ολοκλήρωση Παραγγελίας'],
'cart.total' => ['Total', 'Σύνολο'],
'cart.remove' => ['Remove', 'Αφαίρεση'],
// ── Product ─────────────────────────────────────────────────
'product.add_to_cart' => ['Add to Cart', 'Προσθήκη στο Καλάθι'],
'product.out_of_stock' => ['Out of Stock', 'Εξαντλήθηκε'],
'product.sold' => ['Sold', 'Εξαντλήθηκε'],
'product.price' => ['Price', 'Τιμή'],
'product.description' => ['Description', 'Περιγραφή'],
'product.no_image' => ['No image', 'Χωρίς εικόνα'],
'product.read_more' => ['Read more', 'Περισσότερα'],
'product.reviews' => ['Reviews', 'Αξιολογήσεις'],
'product.related' => ['You may also like', 'Μπορεί επίσης να σου αρέσει'],
// ── Auth (login link) ───────────────────────────────────────
'auth.login' => ['Log In', 'Σύνδεση'],
'auth.logout' => ['Log Out', 'Αποσύνδεση'],
// ── Search ──────────────────────────────────────────────────
'search.placeholder' => ['Search products…', 'Αναζήτηση προϊόντων…'],
'search.results_for' => ['Search results for ', 'Αποτελέσματα αναζήτησης για '],
'customer_reviews' => [
'{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
'{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
],
// ── Pagination ──────────────────────────────────────────────
'pagination.nav_label' => ['Pagination', 'Σελιδοποίηση'],
'pagination.next' => ['Next page', 'Επόμενη σελίδα'],
'pagination.previous' => ['Previous page', 'Προηγούμενη σελίδα'],
'pagination.page' => ['Page :page', 'Σελίδα :page'],
// ── Error pages ─────────────────────────────────────────────
'errors.404_title' => ['Page not found', 'Η σελίδα δεν βρέθηκε'],
'errors.404_text' => [
'The page you are looking for does not exist or has been moved.',
'Η σελίδα που αναζητάς δεν υπάρχει ή έχει μετακινηθεί.',
],
'errors.back_home' => ['Back to home', 'Επιστροφή στην αρχική'],
// ── Reviews ─────────────────────────────────────────────────
'review.rating' => ['Rating', 'Βαθμολογία'],
'review.write_label' => ['Write a review', 'Γράψε μια αξιολόγηση'],
'review.name' => ['Name', 'Όνομα'],
'review.name_optional' => ['Optional', 'Προαιρετικό'],
'review.email' => ['Email', 'Email'],
'review.email_not_published' => ['Will not be published', 'Δεν θα δημοσιευτεί'],
'review.save_info' => [
'Save my name and email for the next time I comment.',
'Αποθήκευσε το όνομα και το email μου για την επόμενη φορά που θα σχολιάσω.',
],
'review.submit' => ['Submit', 'Υποβολή'],
'review.stars_count' => ['{1} :count star|[2,*] :count stars', '{1} :count αστέρι|[2,*] :count αστέρια'],
'review.no_reviews_yet' => ['No reviews yet.', 'Δεν υπάρχουν αξιολογήσεις ακόμα.'],
'review.write_first' => ['Write the first review', 'Γράψε την πρώτη'],
'review.write_new' => ['Add a review', 'Πρόσθεσε μια'],
'review.for_product' => ['review for ":name"', 'αξιολόγηση για το «:name»'],
'review.rating_required' => ['Please select a rating.', 'Παρακαλούμε επίλεξε βαθμολογία.'],
'review.reply' => ['Reply', 'Απάντηση'],
'review.thank_you' => ['Thanks for your review!', 'Ευχαριστούμε για την αξιολόγησή σου!'],
// ── Shop / listing page ─────────────────────────────────────
'shop.showing_results' => [
'{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
'{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
],
'shop.all_products' => ['All Products', 'Όλα τα Προϊόντα'],
'shop.sort_label' => ['Sort products', 'Ταξινόμηση προϊόντων'],
'shop.sort_default' => ['Default sorting', 'Προεπιλεγμένη ταξινόμηση'],
'shop.sort_popularity' => ['Popularity', 'Δημοφιλή'],
'shop.sort_price_asc' => ['Price: Low to High', 'Τιμή: Αύξουσα'],
'shop.sort_price_desc' => ['Price: High to Low', 'Τιμή: Φθίνουσα'],
'shop.sort_newest' => ['Newest', 'Νεότερα'],
'shop.no_products' => ['No products found in this category.', 'Δεν βρέθηκαν προϊόντα σε αυτή την κατηγορία.'],
'shop.search_label' => ['Search products', 'Αναζήτηση προϊόντων'],
'shop.search_placeholder' => ['Search products…', 'Αναζήτησε προϊόντα…'],
'shop.filter_price' => ['Filter by price', 'Φίλτρο τιμής'],
'shop.price_min' => ['Min price', 'Ελάχιστη τιμή'],
'shop.price_max' => ['Max price', 'Μέγιστη τιμή'],
'shop.reset' => ['Reset', 'Επαναφορά'],
'shop.apply' => ['Apply', 'Εφαρμογή'],
'shop.availability' => ['Availability', 'Διαθεσιμότητα'],
'shop.in_stock_only' => ['In-stock products only', 'Μόνο διαθέσιμα προϊόντα'],
// ── Passwordless login (Auth\Services\UserOtpService) ───────
'auth.login_intro' => [
'Enter your email and we\'ll send you a code to sign in — no password needed.',
'Γράψε το email σου και θα σου στείλουμε έναν κωδικό σύνδεσης — δεν χρειάζεται κωδικός πρόσβασης.',
],
'auth.email' => ['Email', 'Email'],
'auth.terms_notice' => [
'By continuing, you accept the <a href=":terms">Terms of Use</a> and have read the <a href=":privacy">Privacy Policy</a>.',
'Συνεχίζοντας, αποδέχεσαι τους <a href=":terms">Όρους Χρήσης</a> και έχεις διαβάσει την <a href=":privacy">Πολιτική Απορρήτου</a>.',
],
'auth.send_code' => ['Send code', 'Αποστολή κωδικού'],
'auth.enter_code' => ['Enter the code', 'Εισάγετε τον κωδικό'],
'auth.code_sent_to' => ['We sent a code to', 'Στείλαμε έναν κωδικό στο'],
'auth.code' => ['Code', 'Κωδικός'],
'auth.resend_code' => ['Resend code', 'Επαναποστολή κωδικού'],
'auth.code_resent' => ['A new code was sent.', 'Στάλθηκε νέος κωδικός.'],
'auth.change_email' => ['Use a different email', 'Χρήση διαφορετικού email'],
'auth.invalid_code' => ['That code is invalid or has expired.', 'Ο κωδικός δεν είναι έγκυρος ή έχει λήξει.'],
'auth.too_many_codes' => [
'Too many attempts. Please wait a few minutes and try again.',
'Πολλές προσπάθειες. Περίμενε λίγα λεπτά και ξαναδοκίμασε.',
],
'auth.captcha_failed' => [
'Please complete the captcha and try again.',
'Παρακαλούμε ολοκλήρωσε το captcha και ξαναδοκίμασε.',
],
// ── Account profile page (account/show.blade.php) ───────────
'account.nav_profile' => ['Profile', 'Προφίλ'],
'account.nav_orders' => ['Orders', 'Παραγγελίες'],
'account.nav_wishlist' => ['Wishlist', 'Λίστα επιθυμιών'],
'account.email_heading' => ['Login email', 'Email σύνδεσης'],
'account.email_change' => ['Change email', 'Αλλαγή email'],
'account.details_heading' => ['Your details', 'Τα στοιχεία σου'],
'account.first_name' => ['First name', 'Όνομα'],
'account.last_name' => ['Last name', 'Επώνυμο'],
'account.invoice' => ['I need an invoice', 'Χρειάζομαι τιμολόγιο'],
'account.company_name' => ['Company name', 'Επωνυμία εταιρείας'],
'account.tax_identifier' => ['Tax ID (VAT)', 'ΑΦΜ'],
'account.address_heading' => ['Address', 'Διεύθυνση'],
'account.line_one' => ['Address', 'Διεύθυνση'],
'account.city' => ['City', 'Πόλη'],
'account.postcode' => ['Postcode', 'Ταχυδρομικός κώδικας'],
'account.state' => ['Region', 'Περιοχή'],
'account.state_placeholder' => ['Select a region', 'Επίλεξε περιοχή'],
'account.phone' => ['Phone', 'Τηλέφωνο'],
'account.emails_heading' => ['Emails', 'Ειδοποιήσεις email'],
'account.recovery_consent' => [
'Email me a reminder if I don\'t finish my order',
'Στείλε μου υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
],
'account.save' => ['Save changes', 'Αποθήκευση'],
'account.saved' => ['Your details were saved.', 'Τα στοιχεία σου αποθηκεύτηκαν.'],
'account.delete_heading' => ['Delete account', 'Διαγραφή λογαριασμού'],
'account.delete_text' => [
'This permanently deletes your account and personal data. This cannot be undone.',
'Αυτό διαγράφει οριστικά τον λογαριασμό και τα προσωπικά σου δεδομένα. Δεν μπορεί να αναιρεθεί.',
],
'account.delete' => ['Delete my account', 'Διαγραφή λογαριασμού'],
'account.delete_confirm_heading' => ['Are you sure?', 'Είσαι σίγουρος/η;'],
'account.delete_confirm_text' => [
'This cannot be undone. Your account and personal data will be permanently deleted.',
'Αυτό δεν μπορεί να αναιρεθεί. Ο λογαριασμός και τα προσωπικά σου δεδομένα θα διαγραφούν οριστικά.',
],
'account.delete_confirm' => ['Yes, delete my account', 'Ναι, διαγραφή λογαριασμού'],
'account.delete_cancel' => ['Cancel', 'Ακύρωση'],
'account.deletion_requested' => [
'Your account deletion has been requested.',
'Ζητήθηκε η διαγραφή του λογαριασμού σου.',
],
// ── Account email-change flow (account/email.blade.php, account/email-code.blade.php) ──
'account.email_change_heading' => ['Change your email', 'Αλλαγή email'],
'account.email_current' => ['Your current email is', 'Το τρέχον email σου είναι'],
'account.email_new' => ['New email', 'Νέο email'],
'account.email_new_hint' => [
'We\'ll send a code to this address to confirm it\'s yours.',
'Θα στείλουμε έναν κωδικό σε αυτή τη διεύθυνση για να επιβεβαιώσουμε ότι είναι δική σου.',
],
'account.email_confirm' => ['Confirm', 'Επιβεβαίωση'],
'account.email_same' => ['That\'s already your current email.', 'Αυτό είναι ήδη το τρέχον email σου.'],
'account.email_taken' => [
'That email address is already in use.',
'Αυτή η διεύθυνση email χρησιμοποιείται ήδη.',
],
'account.email_changed' => ['Your email was changed.', 'Το email σου άλλαξε.'],
// ── Order history (account/orders/index.blade.php, account/orders/show.blade.php) ──
'orders.empty' => ['You have no orders yet.', 'Δεν έχεις παραγγελίες ακόμα.'],
'orders.shop_now' => ['Shop now', 'Αγόρασε τώρα'],
'orders.date' => ['Date', 'Ημερομηνία'],
'orders.number' => ['Order', 'Παραγγελία'],
'orders.status' => ['Status', 'Κατάσταση'],
'orders.total' => ['Total', 'Σύνολο'],
'orders.view' => ['View', 'Προβολή'],
'orders.view_order' => ['View order :number', 'Προβολή παραγγελίας :number'],
'orders.order_title' => ['Order :number', 'Παραγγελία :number'],
'orders.back' => ['Back to orders', 'Πίσω στις παραγγελίες'],
'orders.payment' => ['Payment method', 'Τρόπος πληρωμής'],
'orders.shipping_method' => ['Shipping method', 'Τρόπος αποστολής'],
'orders.tracking' => ['Tracking', 'Παρακολούθηση αποστολής'],
'orders.items' => ['Items', 'Προϊόντα'],
'orders.subtotal' => ['Subtotal', 'Μερικό σύνολο'],
'orders.discount' => ['Discount', 'Έκπτωση'],
'orders.shipping' => ['Shipping', 'Μεταφορικά'],
'orders.tax' => ['Tax', 'ΦΠΑ'],
'orders.shipping_to' => ['Shipping to', 'Αποστολή σε'],
'orders.billing' => ['Billing details', 'Στοιχεία τιμολόγησης'],
// ── Contact details (components/contact-details.blade.php) ──
'contact.address' => ['Address', 'Διεύθυνση'],
'contact.phone' => ['Phone', 'Τηλέφωνο'],
// ── Contact form (contact.blade.php, ContactController, emails.contact-confirmation) ──
'contact.sent' => [
'Your message was sent — we\'ll get back to you soon.',
'Το μήνυμά σου στάλθηκε — θα σου απαντήσουμε σύντομα.',
],
'contact.send_failed' => [
'Something went wrong sending your message. Please try again.',
'Κάτι πήγε στραβά κατά την αποστολή. Παρακαλούμε δοκίμασε ξανά.',
],
'contact.too_many' => [
'Too many messages sent. Please wait a while before trying again.',
'Στάλθηκαν πολλά μηνύματα. Περίμενε λίγο πριν ξαναδοκιμάσεις.',
],
'contact.confirmation_subject' => ['We received your message', 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_preheader' => [
'Thanks for reaching out — here\'s a copy of your message.',
'Ευχαριστούμε για την επικοινωνία — εδώ είναι ένα αντίγραφο του μηνύματός σου.',
],
'contact.confirmation_heading' => ['We received your message', 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_body' => [
'Thanks for getting in touch. We\'ll reply as soon as we can.',
'Ευχαριστούμε που επικοινώνησες μαζί μας. Θα απαντήσουμε το συντομότερο δυνατό.',
],
'contact.confirmation_footer' => [
'This is a copy of the message you sent us.',
'Αυτό είναι ένα αντίγραφο του μηνύματος που μας έστειλες.',
],
// ── Product page — custom fields, add-to-cart failure (product/show.blade.php,
// components/product-custom-fields.blade.php) ─────────────
'product.personalize' => ['Personalize', 'Εξατομίκευση'],
'product.custom_field_photo_hint' => ['Max file size: :size MB.', 'Μέγιστο μέγεθος αρχείου: :size MB.'],
'product.custom_field_uploading' => ['Uploading…', 'Μεταφόρτωση…'],
'product.custom_field_upload_failed' => [
'Upload failed. Please try again.',
'Η μεταφόρτωση απέτυχε. Παρακαλούμε δοκίμασε ξανά.',
],
'product.add_to_cart_failed' => [
'{0} Sorry, that\'s out of stock|{1} Only :count left in stock|[2,*] Only :count left in stock',
'{0} Λυπούμαστε, εξαντλήθηκε|{1} Απομένει μόνο :count κομμάτι|[2,*] Απομένουν μόνο :count κομμάτια',
],
// ── Wishlist (components/wishlist-button.blade.php, wishlist/guest.blade.php, wishlist/list.blade.php) ──
'wishlist.add' => ['Add to wishlist', 'Προσθήκη στη λίστα επιθυμιών'],
'wishlist.remove' => ['Remove from wishlist', 'Αφαίρεση από τη λίστα επιθυμιών'],
'wishlist.added' => ['Added to wishlist', 'Προστέθηκε στη λίστα επιθυμιών'],
'wishlist.removed' => ['Removed from wishlist', 'Αφαιρέθηκε από τη λίστα επιθυμιών'],
'wishlist.empty' => ['Your wishlist is empty.', 'Η λίστα επιθυμιών σου είναι άδεια.'],
'wishlist.guest_hint' => [
'Log in to keep your wishlist across devices.',
'Συνδέσου για να κρατήσεις τη λίστα επιθυμιών σου σε όλες τις συσκευές.',
],
'wishlist.remove_named' => ['Remove :name from wishlist', 'Αφαίρεση :name από τη λίστα επιθυμιών'],
'wishlist.remove_short' => ['Remove', 'Αφαίρεση'],
];
}
}
@@ -0,0 +1,114 @@
<?php
namespace Modules\Core\Localization\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
/**
* Default `validation` translation lines — Laravel's own error-message group
* (`validation.required`, `validation.email`, `validation.attributes.*`, …),
* resolved by every `Validator::make()`/`$request->validate()` call in a
* consuming app (checkout addresses, cart, product reviews, stock checks).
*
* Spatie's DB loader (spatie/laravel-translation-loader, wired in
* LocalizationServiceProvider) merges this group over Laravel's file-based
* validation.php — without this, Greek requests fall back to Laravel's
* untranslated English defaults. Only the rule keys and field attributes
* actually in use are seeded; add more as new rules/fields show up.
*
* Additive and idempotent: a key that already exists is left untouched, so
* anything edited in the Filament Language Lines UI wins on a re-run. Runs
* explicitly — `php artisan db:seed --class="Modules\Core\Localization\
* Database\Seeders\ValidationTranslationsSeeder"` — it is not wired into any
* app's own DatabaseSeeder.
*
* Greek copy uses the project's informal register (εσύ/σου).
*/
class ValidationTranslationsSeeder extends Seeder
{
public function run(): void
{
$translations = app(TranslationService::class);
foreach ($this->lines() as $key => [$en, $el]) {
$exists = LanguageLine::query()
->where('group', 'validation')
->where('key', $key)
->exists();
if ($exists) {
$this->command?->warn("validation.{$key} already exists — skipped");
continue;
}
$translations->create('validation', $key, ['en' => $en, 'el' => $el]);
$this->command?->info("validation.{$key} added");
}
}
/**
* key => [English, Greek].
*
* @return array<string, array{0: string, 1: string}>
*/
private function lines(): array
{
return [
// ── Rule messages ─────────────────────────────────────────────
'required' => ['The :attribute field is required.', 'Το πεδίο :attribute είναι υποχρεωτικό.'],
'email' => ['The :attribute field must be a valid email address.', 'Το πεδίο :attribute πρέπει να είναι έγκυρη διεύθυνση email.'],
'string' => ['The :attribute field must be a string.', 'Το πεδίο :attribute πρέπει να είναι κείμενο.'],
'integer' => ['The :attribute field must be an integer.', 'Το πεδίο :attribute πρέπει να είναι ακέραιος αριθμός.'],
'boolean' => ['The :attribute field must be true or false.', 'Το πεδίο :attribute πρέπει να είναι true ή false.'],
'min.numeric' => ['The :attribute field must be at least :min.', 'Το πεδίο :attribute πρέπει να είναι τουλάχιστον :min.'],
'max.string' => ['The :attribute field must not be greater than :max characters.', 'Το πεδίο :attribute δεν πρέπει να ξεπερνά τους :max χαρακτήρες.'],
'between.numeric' => ['The :attribute field must be between :min and :max.', 'Το πεδίο :attribute πρέπει να είναι μεταξύ :min και :max.'],
'exists' => ['The selected :attribute is invalid.', 'Η επιλεγμένη τιμή για το πεδίο :attribute δεν είναι έγκυρη.'],
// Product custom-field photo uploads (CustomFieldUploadController, CartController).
'file' => ['The :attribute field must be a file.', 'Το πεδίο :attribute πρέπει να είναι αρχείο.'],
'mimes' => ['The :attribute field must be a file of type: :values.', 'Το πεδίο :attribute πρέπει να είναι αρχείο τύπου: :values.'],
'max.file' => ['The :attribute field must not be greater than :max kilobytes.', 'Το αρχείο στο πεδίο :attribute δεν πρέπει να ξεπερνά τα :max kilobytes.'],
'uploaded' => ['The :attribute failed to upload.', 'Η μεταφόρτωση στο πεδίο :attribute απέτυχε.'],
// ── Field names (checkout: billing/shipping address) ──────────
'attributes.contact_email' => ['email', 'email'],
'attributes.billing_first_name' => ['first name', 'όνομα'],
'attributes.billing_last_name' => ['last name', 'επώνυμο'],
'attributes.billing_company_name' => ['company name', 'επωνυμία εταιρείας'],
'attributes.billing_tax_identifier' => ['tax ID', 'ΑΦΜ'],
'attributes.billing_line_one' => ['address', 'διεύθυνση'],
'attributes.billing_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'],
'attributes.billing_city' => ['city', 'πόλη'],
'attributes.billing_state' => ['region', 'νομό / περιοχή'],
'attributes.billing_postcode' => ['postcode', 'ταχυδρομικό κώδικα'],
'attributes.billing_country_id' => ['country', 'χώρα'],
'attributes.billing_contact_phone' => ['phone', 'τηλέφωνο'],
'attributes.shipping_first_name' => ['first name', 'όνομα'],
'attributes.shipping_last_name' => ['last name', 'επώνυμο'],
'attributes.shipping_company_name' => ['company name', 'επωνυμία εταιρείας'],
'attributes.shipping_line_one' => ['address', 'διεύθυνση'],
'attributes.shipping_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'],
'attributes.shipping_city' => ['city', 'πόλη'],
'attributes.shipping_state' => ['region', 'νομό / περιοχή'],
'attributes.shipping_postcode' => ['postcode', 'ταχυδρομικό κώδικα'],
'attributes.shipping_country_id' => ['country', 'χώρα'],
'attributes.shipping_contact_phone' => ['phone', 'τηλέφωνο'],
'attributes.shipping_delivery_instructions' => ['delivery notes', 'σχόλια για την παράδοση'],
// ── Field names (cart) ─────────────────────────────────────────
'attributes.purchasable_id' => ['product', 'προϊόν'],
'attributes.quantity' => ['quantity', 'ποσότητα'],
'attributes.code' => ['coupon code', 'κωδικό κουπονιού'],
// ── Field names (product reviews / stock check) ────────────────
'attributes.variant' => ['variant', 'παραλλαγή'],
'attributes.rating' => ['rating', 'βαθμολογία'],
'attributes.content' => ['review text', 'κείμενο κριτικής'],
'attributes.name' => ['name', 'όνομα'],
'attributes.email' => ['email', 'email'],
];
}
}
@@ -1,264 +0,0 @@
<?php
namespace Modules\Core\Localization\Services;
/**
* Default storefront UI label translations (group `storefront`), seeded by
* Modules\Core\Command\InstallLunarCommand. Kept as its own class, separate from
* the seeding logic, so the actual label list can be scanned/diffed without wading
* through the upsert mechanics — see InstallLunarCommand::seedStorefrontLabels()
* for how (and how safely) these get written.
*/
class StorefrontLabels
{
/**
* @return array<string, array<string, string>> keyed by `group.key` dot-notation,
* each value a locale => text map (`en`/`el`).
*/
public static function all(): array
{
return [
'nav.home' => ['en' => 'Home', 'el' => 'Αρχική'],
'nav.products' => ['en' => 'Products', 'el' => 'Προϊόντα'],
'nav.cart' => ['en' => 'Cart', 'el' => 'Καλάθι'],
'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'],
'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'],
'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'],
'nav.close' => ['en' => 'Close', 'el' => 'Κλείσιμο'],
'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'],
'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'],
'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
'cart.remove' => ['en' => 'Remove', 'el' => 'Αφαίρεση'],
'product.add_to_cart' => ['en' => 'Add to Cart', 'el' => 'Προσθήκη στο Καλάθι'],
'product.out_of_stock' => ['en' => 'Out of Stock', 'el' => 'Εξαντλήθηκε'],
'product.price' => ['en' => 'Price', 'el' => 'Τιμή'],
'product.description' => ['en' => 'Description', 'el' => 'Περιγραφή'],
'product.no_image' => ['en' => 'No image', 'el' => 'Χωρίς εικόνα'],
'product.read_more' => ['en' => 'Read more', 'el' => 'Περισσότερα'],
'product.reviews' => ['en' => 'Reviews', 'el' => 'Αξιολογήσεις'],
'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'],
'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'],
'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'],
'search.results_for' => ['en' => 'Search results for ', 'el' => 'Αποτελέσματα αναζήτησης για '],
'customer_reviews' => [
'en' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
'el' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
],
'pagination.nav_label' => ['en' => 'Pagination', 'el' => 'Σελιδοποίηση'],
'pagination.next' => ['en' => 'Next page', 'el' => 'Επόμενη σελίδα'],
'pagination.previous' => ['en' => 'Previous page', 'el' => 'Προηγούμενη σελίδα'],
'pagination.page' => ['en' => 'Page :page', 'el' => 'Σελίδα :page'],
'review.rating' => ['en' => 'Rating', 'el' => 'Βαθμολογία'],
'review.write_label' => ['en' => 'Write a review', 'el' => 'Γράψε μια αξιολόγηση'],
'review.name' => ['en' => 'Name', 'el' => 'Όνομα'],
'review.name_optional' => ['en' => 'Optional', 'el' => 'Προαιρετικό'],
'review.email' => ['en' => 'Email', 'el' => 'Email'],
'review.email_not_published' => ['en' => 'Will not be published', 'el' => 'Δεν θα δημοσιευτεί'],
'review.save_info' => [
'en' => 'Save my name and email for the next time I comment.',
'el' => 'Αποθήκευσε το όνομα και το email μου για την επόμενη φορά που θα σχολιάσω.',
],
'review.submit' => ['en' => 'Submit', 'el' => 'Υποβολή'],
'review.stars_count' => ['en' => '{1} :count star|[2,*] :count stars', 'el' => '{1} :count αστέρι|[2,*] :count αστέρια'],
'review.no_reviews_yet' => ['en' => 'No reviews yet.', 'el' => 'Δεν υπάρχουν αξιολογήσεις ακόμα.'],
'review.write_first' => ['en' => 'Write the first review', 'el' => 'Γράψε την πρώτη'],
'review.write_new' => ['en' => 'Add a review', 'el' => 'Πρόσθεσε μια'],
'review.for_product' => ['en' => 'review for ":name"', 'el' => 'αξιολόγηση για το «:name»'],
'shop.showing_results' => [
'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
],
'shop.all_products' => ['en' => 'All Products', 'el' => 'Όλα τα Προϊόντα'],
'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'],
'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'],
'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'],
'shop.sort_price_asc' => ['en' => 'Price: Low to High', 'el' => 'Τιμή: Αύξουσα'],
'shop.sort_price_desc' => ['en' => 'Price: High to Low', 'el' => 'Τιμή: Φθίνουσα'],
'shop.sort_newest' => ['en' => 'Newest', 'el' => 'Νεότερα'],
'shop.no_products' => ['en' => 'No products found in this category.', 'el' => 'Δεν βρέθηκαν προϊόντα σε αυτή την κατηγορία.'],
'shop.search_label' => ['en' => 'Search products', 'el' => 'Αναζήτηση προϊόντων'],
'shop.search_placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτησε προϊόντα…'],
'shop.filter_price' => ['en' => 'Filter by price', 'el' => 'Φίλτρο τιμής'],
'shop.price_min' => ['en' => 'Min price', 'el' => 'Ελάχιστη τιμή'],
'shop.price_max' => ['en' => 'Max price', 'el' => 'Μέγιστη τιμή'],
'shop.reset' => ['en' => 'Reset', 'el' => 'Επαναφορά'],
'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'],
'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'],
'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'],
// Passwordless login (Auth\Services\UserOtpService)
'auth.login_intro' => [
'en' => 'Enter your email and we\'ll send you a code to sign in — no password needed.',
'el' => 'Γράψε το email σου και θα σου στείλουμε έναν κωδικό σύνδεσης — δεν χρειάζεται κωδικός πρόσβασης.',
],
'auth.email' => ['en' => 'Email', 'el' => 'Email'],
'auth.terms_notice' => [
'en' => 'By continuing, you accept the <a href=":terms">Terms of Use</a> and have read the <a href=":privacy">Privacy Policy</a>.',
'el' => 'Συνεχίζοντας, αποδέχεσαι τους <a href=":terms">Όρους Χρήσης</a> και έχεις διαβάσει την <a href=":privacy">Πολιτική Απορρήτου</a>.',
],
'auth.send_code' => ['en' => 'Send code', 'el' => 'Αποστολή κωδικού'],
'auth.enter_code' => ['en' => 'Enter the code', 'el' => 'Εισάγετε τον κωδικό'],
'auth.code_sent_to' => ['en' => 'We sent a code to', 'el' => 'Στείλαμε έναν κωδικό στο'],
'auth.code' => ['en' => 'Code', 'el' => 'Κωδικός'],
'auth.resend_code' => ['en' => 'Resend code', 'el' => 'Επαναποστολή κωδικού'],
'auth.code_resent' => ['en' => 'A new code was sent.', 'el' => 'Στάλθηκε νέος κωδικός.'],
'auth.change_email' => ['en' => 'Use a different email', 'el' => 'Χρήση διαφορετικού email'],
'auth.invalid_code' => ['en' => 'That code is invalid or has expired.', 'el' => 'Ο κωδικός δεν είναι έγκυρος ή έχει λήξει.'],
'auth.too_many_codes' => [
'en' => 'Too many attempts. Please wait a few minutes and try again.',
'el' => 'Πολλές προσπάθειες. Περίμενε λίγα λεπτά και ξαναδοκίμασε.',
],
// Account profile page (account/show.blade.php)
'account.nav_profile' => ['en' => 'Profile', 'el' => 'Προφίλ'],
'account.nav_orders' => ['en' => 'Orders', 'el' => 'Παραγγελίες'],
'account.nav_wishlist' => ['en' => 'Wishlist', 'el' => 'Λίστα επιθυμιών'],
'account.email_heading' => ['en' => 'Login email', 'el' => 'Email σύνδεσης'],
'account.email_change' => ['en' => 'Change email', 'el' => 'Αλλαγή email'],
'account.details_heading' => ['en' => 'Your details', 'el' => 'Τα στοιχεία σου'],
'account.first_name' => ['en' => 'First name', 'el' => 'Όνομα'],
'account.last_name' => ['en' => 'Last name', 'el' => 'Επώνυμο'],
'account.invoice' => ['en' => 'I need an invoice', 'el' => 'Χρειάζομαι τιμολόγιο'],
'account.company_name' => ['en' => 'Company name', 'el' => 'Επωνυμία εταιρείας'],
'account.tax_identifier' => ['en' => 'Tax ID (VAT)', 'el' => 'ΑΦΜ'],
'account.address_heading' => ['en' => 'Address', 'el' => 'Διεύθυνση'],
'account.line_one' => ['en' => 'Address', 'el' => 'Διεύθυνση'],
'account.city' => ['en' => 'City', 'el' => 'Πόλη'],
'account.postcode' => ['en' => 'Postcode', 'el' => 'Ταχυδρομικός κώδικας'],
'account.state' => ['en' => 'Region', 'el' => 'Περιοχή'],
'account.state_placeholder' => ['en' => 'Select a region', 'el' => 'Επίλεξε περιοχή'],
'account.phone' => ['en' => 'Phone', 'el' => 'Τηλέφωνο'],
'account.emails_heading' => ['en' => 'Emails', 'el' => 'Ειδοποιήσεις email'],
'account.recovery_consent' => [
'en' => 'Email me a reminder if I don\'t finish my order',
'el' => 'Στείλε μου υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
],
'account.save' => ['en' => 'Save changes', 'el' => 'Αποθήκευση'],
'account.saved' => ['en' => 'Your details were saved.', 'el' => 'Τα στοιχεία σου αποθηκεύτηκαν.'],
'account.delete_heading' => ['en' => 'Delete account', 'el' => 'Διαγραφή λογαριασμού'],
'account.delete_text' => [
'en' => 'This permanently deletes your account and personal data. This cannot be undone.',
'el' => 'Αυτό διαγράφει οριστικά τον λογαριασμό και τα προσωπικά σου δεδομένα. Δεν μπορεί να αναιρεθεί.',
],
'account.delete' => ['en' => 'Delete my account', 'el' => 'Διαγραφή λογαριασμού'],
'account.delete_confirm_heading' => ['en' => 'Are you sure?', 'el' => 'Είσαι σίγουρος/η;'],
'account.delete_confirm_text' => [
'en' => 'This cannot be undone. Your account and personal data will be permanently deleted.',
'el' => 'Αυτό δεν μπορεί να αναιρεθεί. Ο λογαριασμός και τα προσωπικά σου δεδομένα θα διαγραφούν οριστικά.',
],
'account.delete_confirm' => ['en' => 'Yes, delete my account', 'el' => 'Ναι, διαγραφή λογαριασμού'],
'account.delete_cancel' => ['en' => 'Cancel', 'el' => 'Ακύρωση'],
'account.deletion_requested' => [
'en' => 'Your account deletion has been requested.',
'el' => 'Ζητήθηκε η διαγραφή του λογαριασμού σου.',
],
// Account email-change flow (account/email.blade.php, account/email-code.blade.php)
'account.email_change_heading' => ['en' => 'Change your email', 'el' => 'Αλλαγή email'],
'account.email_current' => ['en' => 'Your current email is', 'el' => 'Το τρέχον email σου είναι'],
'account.email_new' => ['en' => 'New email', 'el' => 'Νέο email'],
'account.email_new_hint' => [
'en' => 'We\'ll send a code to this address to confirm it\'s yours.',
'el' => 'Θα στείλουμε έναν κωδικό σε αυτή τη διεύθυνση για να επιβεβαιώσουμε ότι είναι δική σου.',
],
'account.email_confirm' => ['en' => 'Confirm', 'el' => 'Επιβεβαίωση'],
'account.email_same' => [
'en' => 'That\'s already your current email.',
'el' => 'Αυτό είναι ήδη το τρέχον email σου.',
],
'account.email_taken' => [
'en' => 'That email address is already in use.',
'el' => 'Αυτή η διεύθυνση email χρησιμοποιείται ήδη.',
],
'account.email_changed' => ['en' => 'Your email was changed.', 'el' => 'Το email σου άλλαξε.'],
// Order history (account/orders/index.blade.php, account/orders/show.blade.php)
'orders.empty' => ['en' => 'You have no orders yet.', 'el' => 'Δεν έχεις παραγγελίες ακόμα.'],
'orders.shop_now' => ['en' => 'Shop now', 'el' => 'Αγόρασε τώρα'],
'orders.date' => ['en' => 'Date', 'el' => 'Ημερομηνία'],
'orders.number' => ['en' => 'Order', 'el' => 'Παραγγελία'],
'orders.status' => ['en' => 'Status', 'el' => 'Κατάσταση'],
'orders.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
'orders.view' => ['en' => 'View', 'el' => 'Προβολή'],
'orders.view_order' => ['en' => 'View order :number', 'el' => 'Προβολή παραγγελίας :number'],
'orders.order_title' => ['en' => 'Order :number', 'el' => 'Παραγγελία :number'],
'orders.back' => ['en' => 'Back to orders', 'el' => 'Πίσω στις παραγγελίες'],
'orders.payment' => ['en' => 'Payment method', 'el' => 'Τρόπος πληρωμής'],
'orders.shipping_method' => ['en' => 'Shipping method', 'el' => 'Τρόπος αποστολής'],
'orders.tracking' => ['en' => 'Tracking', 'el' => 'Παρακολούθηση αποστολής'],
'orders.items' => ['en' => 'Items', 'el' => 'Προϊόντα'],
'orders.subtotal' => ['en' => 'Subtotal', 'el' => 'Μερικό σύνολο'],
'orders.discount' => ['en' => 'Discount', 'el' => 'Έκπτωση'],
'orders.shipping' => ['en' => 'Shipping', 'el' => 'Μεταφορικά'],
'orders.tax' => ['en' => 'Tax', 'el' => 'ΦΠΑ'],
'orders.shipping_to' => ['en' => 'Shipping to', 'el' => 'Αποστολή σε'],
'orders.billing' => ['en' => 'Billing details', 'el' => 'Στοιχεία τιμολόγησης'],
// Contact form (contact.blade.php, ContactController, emails.contact-confirmation)
'contact.sent' => [
'en' => 'Your message was sent — we\'ll get back to you soon.',
'el' => 'Το μήνυμά σου στάλθηκε — θα σου απαντήσουμε σύντομα.',
],
'contact.send_failed' => [
'en' => 'Something went wrong sending your message. Please try again.',
'el' => 'Κάτι πήγε στραβά κατά την αποστολή. Παρακαλούμε δοκίμασε ξανά.',
],
'contact.too_many' => [
'en' => 'Too many messages sent. Please wait a while before trying again.',
'el' => 'Στάλθηκαν πολλά μηνύματα. Περίμενε λίγο πριν ξαναδοκιμάσεις.',
],
'contact.confirmation_subject' => ['en' => 'We received your message', 'el' => 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_preheader' => [
'en' => 'Thanks for reaching out — here\'s a copy of your message.',
'el' => 'Ευχαριστούμε για την επικοινωνία — εδώ είναι ένα αντίγραφο του μηνύματός σου.',
],
'contact.confirmation_heading' => ['en' => 'We received your message', 'el' => 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_body' => [
'en' => 'Thanks for getting in touch. We\'ll reply as soon as we can.',
'el' => 'Ευχαριστούμε που επικοινώνησες μαζί μας. Θα απαντήσουμε το συντομότερο δυνατό.',
],
'contact.confirmation_footer' => [
'en' => 'This is a copy of the message you sent us.',
'el' => 'Αυτό είναι ένα αντίγραφο του μηνύματος που μας έστειλες.',
],
// Product page — custom fields, add-to-cart failure (product/show.blade.php,
// components/product-custom-fields.blade.php)
'product.personalize' => ['en' => 'Personalize', 'el' => 'Εξατομίκευση'],
'product.custom_field_photo_hint' => [
'en' => 'Max file size: :size MB.',
'el' => 'Μέγιστο μέγεθος αρχείου: :size MB.',
],
'product.custom_field_uploading' => ['en' => 'Uploading…', 'el' => 'Μεταφόρτωση…'],
'product.custom_field_upload_failed' => [
'en' => 'Upload failed. Please try again.',
'el' => 'Η μεταφόρτωση απέτυχε. Παρακαλούμε δοκίμασε ξανά.',
],
'product.add_to_cart_failed' => [
'en' => '{0} Sorry, that\'s out of stock|{1} Only :count left in stock|[2,*] Only :count left in stock',
'el' => '{0} Λυπούμαστε, εξαντλήθηκε|{1} Απομένει μόνο :count κομμάτι|[2,*] Απομένουν μόνο :count κομμάτια',
],
// Reviews (components/review-form.blade.php, review-card.blade.php, product/show.blade.php)
'review.rating_required' => ['en' => 'Please select a rating.', 'el' => 'Παρακαλούμε επίλεξε βαθμολογία.'],
'review.reply' => ['en' => 'Reply', 'el' => 'Απάντηση'],
'review.thank_you' => [
'en' => 'Thanks for your review!',
'el' => 'Ευχαριστούμε για την αξιολόγησή σου!',
],
// Wishlist (components/wishlist-button.blade.php, wishlist/guest.blade.php, wishlist/list.blade.php)
'wishlist.add' => ['en' => 'Add to wishlist', 'el' => 'Προσθήκη στη λίστα επιθυμιών'],
'wishlist.remove' => ['en' => 'Remove from wishlist', 'el' => 'Αφαίρεση από τη λίστα επιθυμιών'],
'wishlist.added' => ['en' => 'Added to wishlist', 'el' => 'Προστέθηκε στη λίστα επιθυμιών'],
'wishlist.removed' => ['en' => 'Removed from wishlist', 'el' => 'Αφαιρέθηκε από τη λίστα επιθυμιών'],
'wishlist.empty' => ['en' => 'Your wishlist is empty.', 'el' => 'Η λίστα επιθυμιών σου είναι άδεια.'],
'wishlist.guest_hint' => [
'en' => 'Log in to keep your wishlist across devices.',
'el' => 'Συνδέσου για να κρατήσεις τη λίστα επιθυμιών σου σε όλες τις συσκευές.',
],
'wishlist.remove_named' => ['en' => 'Remove :name from wishlist', 'el' => 'Αφαίρεση :name από τη λίστα επιθυμιών'],
'wishlist.remove_short' => ['en' => 'Remove', 'el' => 'Αφαίρεση'],
];
}
}
+11
View File
@@ -44,6 +44,17 @@ class NotificationRegistry
if (isset($event->delaySeconds) && $event->delaySeconds > 0) { if (isset($event->delaySeconds) && $event->delaySeconds > 0) {
$notification->delay($event->delaySeconds); $notification->delay($event->delaySeconds);
} }
// Every order-notification event carries ->order, whose
// ->meta['locale'] was saved at checkout (Checkout\
// Services\CheckoutService::initiatePayment()) — the
// request that actually sends this may have no locale of
// its own (a payment webhook, a queued job, a staff
// action from Filament), so this is the only reliable
// source. Falls back to the app default for an order
// placed before this existed.
if (isset($event->order) && $locale = $event->order->meta['locale'] ?? null) {
$notification->locale($locale);
}
$notification->notifiable()->notify($notification); $notification->notifiable()->notify($notification);
} catch (Throwable $e) { } catch (Throwable $e) {
report($e); report($e);
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Event;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Services\OrderPaymentResolutionService; use Modules\Core\Order\Services\OrderPaymentResolutionService;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Payment\Events\PaymentDeferred; use Modules\Core\Payment\Events\PaymentDeferred;
/** /**
@@ -36,11 +37,18 @@ use Modules\Core\Payment\Events\PaymentDeferred;
* visibility and stock decrement, but nothing ever moved `status` off its * visibility and stock decrement, but nothing ever moved `status` off its
* initial value, since resolveCaptureOrAuthorization() only does that for * initial value, since resolveCaptureOrAuthorization() only does that for
* an actual capture. Caught and fixed after the fact. * an actual capture. Caught and fixed after the fact.
*
* The status advance is skipped for a bank transfer order
* (OrderStatusFlow::isBankTransfer()) — unlike COD, it genuinely has
* something to await at 'awaiting_payment': the wire itself. Only
* OrderFulfillmentService::markPaid() ever advances it past that point
* (see BankTransferPaymentDriver's own docblock).
*/ */
class MarkOrderPlacedOnDeferredPayment class MarkOrderPlacedOnDeferredPayment
{ {
public function __construct( public function __construct(
private readonly OrderPaymentResolutionService $resolution, private readonly OrderPaymentResolutionService $resolution,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(PaymentDeferred $event): void public function handle(PaymentDeferred $event): void
@@ -53,7 +61,9 @@ class MarkOrderPlacedOnDeferredPayment
$order = Order::findOrFail($orderId); $order = Order::findOrFail($orderId);
$this->resolution->resolveDeferredPayment($order, self::class); if (! $this->flow->isBankTransfer($order)) {
$this->resolution->resolveDeferredPayment($order, self::class);
}
if (! blank($order->placed_at)) { if (! blank($order->placed_at)) {
return; return;
@@ -7,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,
@@ -9,6 +9,7 @@ use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\Contracts\SupportsRefunds; use Modules\Core\Payment\Contracts\SupportsRefunds;
use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus; use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentDeferred;
use Modules\Core\Payment\Events\PaymentRefunded; use Modules\Core\Payment\Events\PaymentRefunded;
/** /**
@@ -27,20 +28,24 @@ use Modules\Core\Payment\Events\PaymentRefunded;
* payment requires the money to arrive BEFORE the order can be * payment requires the money to arrive BEFORE the order can be
* considered paid (unlike cash-on-delivery, where payment happens on * considered paid (unlike cash-on-delivery, where payment happens on
* delivery — see CashOnDeliveryPaymentDriver's own docblock for that * delivery — see CashOnDeliveryPaymentDriver's own docblock for that
* driver's mirror-image reasoning). So pay() returns Pending, dispatching * driver's mirror-image reasoning). So pay() returns Pending and DOES
* no event at all — no PaymentCaptured (nothing has been paid yet), and * dispatch PaymentDeferred, same as COD — without it, nothing ever sets
* deliberately NOT PaymentDeferred either (unlike COD, whose * Order::placed_at or fires OrderPlaced, leaving the order invisible in
* MarkOrderPlacedOnDeferredPayment listener immediately advances the * customer order history, un-decremented in stock, and the checkout
* order past 'awaiting_payment' since a COD order has nothing to await at * confirmation page unable to find it (see PaymentDeferred's and
* checkout). A bank transfer order genuinely DOES have something to * CashOnDeliveryPaymentDriver's own docblocks for that failure mode).
* await: it stays at 'awaiting_payment' with Order::paid false until * Unlike COD, though, a bank transfer order genuinely DOES have something
* staff confirm the wire arrived via OrderFulfillmentService::markPaid(), * to await: MarkOrderPlacedOnDeferredPayment skips
* which — unlike its COD path — also advances the order's status, since * OrderPaymentResolutionService::resolveDeferredPayment() for a bank
* nothing else ever will (see that method's own docblock). * transfer order (via OrderStatusFlow::isBankTransfer()), so it stays at
* CheckoutController::placeOrder() already treats a Pending result with * 'awaiting_payment' with Order::paid false until staff confirm the wire
* no continuation as a fully placed order (see its own docblock), so the * arrived via OrderFulfillmentService::markPaid(), which — unlike its COD
* order is still created and visible to the shopper immediately; only its * path — also advances the order's status, since nothing else ever will
* payment/status is what's left outstanding. * (see that method's own docblock). CheckoutController::placeOrder()
* already treats a Pending result with no continuation as a fully placed
* order (see its own docblock), so the order is still created and visible
* to the shopper immediately; only its payment/status is what's left
* outstanding.
* *
* $reference is generated here for the same reason as OfflinePaymentDriver's * $reference is generated here for the same reason as OfflinePaymentDriver's
* pay(): there is no gateway to hand one back. refund()'s 'notes' (in * pay(): there is no gateway to hand one back. refund()'s 'notes' (in
@@ -63,11 +68,15 @@ class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRe
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{ {
return new PaymentResult( $result = new PaymentResult(
status: PaymentResultStatus::Pending, status: PaymentResultStatus::Pending,
reference: 'bank-transfer-'.Str::uuid(), reference: 'bank-transfer-'.Str::uuid(),
amount: $amount, amount: $amount,
); );
PaymentDeferred::dispatch($type, $result, $context);
return $result;
} }
public function refund(string $reference, Price $amount, array $context = []): PaymentResult public function refund(string $reference, Price $amount, array $context = []): PaymentResult
@@ -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]);
}
} }
} }
+27 -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;
@@ -23,11 +24,16 @@ use Modules\Core\Shipping\Carriers\Acs\Jobs\WarmAcsAreaCacheJob;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService; 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\Carriers\Elta\EltaClient;
use Modules\Core\Shipping\Carriers\Elta\EltaFulfillmentService;
use Modules\Core\Shipping\Carriers\Elta\EltaRateDriver;
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
@@ -36,14 +42,17 @@ class ShippingServiceProvider extends ServiceProvider
{ {
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs'); $this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs');
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow'); $this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow');
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/elta.php', 'elta');
$this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs'))); $this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs')));
$this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow'))); $this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow')));
$this->app->singleton(EltaClient::class, fn () => new EltaClient(config('elta')));
$this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) { $this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) {
return match ($params['carrier'] ?? null) { return match ($params['carrier'] ?? null) {
'acs' => $app->make(AcsFulfillmentService::class), 'acs' => $app->make(AcsFulfillmentService::class),
'box-now' => $app->make(BoxNowFulfillmentService::class), 'box-now' => $app->make(BoxNowFulfillmentService::class),
'elta' => $app->make(EltaFulfillmentService::class),
default => null, default => null,
}; };
}); });
@@ -64,6 +73,7 @@ class ShippingServiceProvider extends ServiceProvider
$this->publishes([ $this->publishes([
__DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'), __DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'), __DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
__DIR__ . '/../../config/shippingCarriers/elta.php' => config_path('shippingCarriers/elta.php'),
], 'core-config'); ], 'core-config');
// Signed-URL auth only, same model as Lunar's own vendor // Signed-URL auth only, same model as Lunar's own vendor
@@ -81,10 +91,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 +128,23 @@ 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));
Shipping::extend('elta', fn ($app) => $app->make(EltaRateDriver::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));
} }
} }
+2 -1
View File
@@ -3,6 +3,7 @@
namespace Modules\Core\Providers; namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Modules\Core\Auth\Events\UserAuthenticated; use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Wishlist\Listeners\MergeGuestWishlistOnLogin; use Modules\Core\Wishlist\Listeners\MergeGuestWishlistOnLogin;
@@ -19,7 +20,7 @@ class WishlistServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
$this->loadRoutesFrom(__DIR__.'/../Wishlist/routes/web.php'); Route::middleware('web')->group(__DIR__.'/../Wishlist/routes/web.php');
Event::listen(UserAuthenticated::class, MergeGuestWishlistOnLogin::class); Event::listen(UserAuthenticated::class, MergeGuestWishlistOnLogin::class);
} }
+13 -1
View File
@@ -9,16 +9,19 @@ use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing; use Modules\Core\Shipping\Concerns\CachesLivePricing;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName; use Modules\Core\Shipping\Support\ShippingMethodName;
use Modules\Core\Shipping\Support\WeightCalculator; use Modules\Core\Shipping\Support\WeightCalculator;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType, SupportsCashCollection
{ {
use ResolvesFixedPricing; use ResolvesFixedPricing;
use CachesLivePricing; use CachesLivePricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate; public ShippingRate $shippingRate;
@@ -37,6 +40,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
return 'carrier'; return 'carrier';
} }
public function collectsCash(): bool
{
return true;
}
public function description(): string public function description(): string
{ {
return 'Live rate quote from ACS Courier.'; return 'Live rate quote from ACS Courier.';
@@ -48,6 +56,10 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
$shippingMethod = $shippingRate->shippingMethod; $shippingMethod = $shippingRate->shippingMethod;
$cart = $shippingOptionRequest->cart; $cart = $shippingOptionRequest->cart;
if ($this->cartHasExcludedProducts($shippingRate, $cart)) {
return null;
}
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') { if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart); return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
} }
@@ -6,6 +6,7 @@ use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
@@ -18,6 +19,7 @@ use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
{ {
use ResolvesFixedPricing; use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate; public ShippingRate $shippingRate;
@@ -38,6 +40,10 @@ class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{ {
if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) {
return null;
}
return $this->resolveFixedPrice( return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate, $shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod, $shippingOptionRequest->shippingRate->shippingMethod,
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Illuminate\Support\Facades\Cache;
/**
* Resolves a Greek postcode to its ELTA station code/name via PELTKNEW
* (the *NEW-family replacement for the old, differently-authenticated
* PELSTATION — see EltaClient's docblock). Unlike Acs\AreaResolver,
* there's no daily bulk-warm job — PELTKNEW only accepts a single pel_tk
* (postcode), with no fetch-all variant to warm a whole-country cache
* from, so this caches per-postcode on first lookup instead.
*/
class AreaResolver
{
public function __construct(private readonly EltaClient $client) {}
public function resolve(string $postcode): EltaStation
{
return Cache::remember(
"elta.station.{$postcode}",
now()->addDays(30),
function () use ($postcode) {
$response = $this->client->getStation([
'pel_tk' => $postcode,
'sender_station' => '',
])->throwIfError();
$fields = $response->data['pel_fields'] ?? [];
return new EltaStation(
code: $fields['rec_station'] ?? '',
name: $fields['rec_station_t'] ?? '',
);
},
);
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use DOMDocument;
use DOMElement;
use DOMXPath;
use Illuminate\Support\Facades\Http;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
/**
* SOAP transport for ELTA Courier's web services.
*
* ELTA runs two parallel operation families at the same endpoint
* (212.205.47.226:9003). The original CREATEAWB/PELB64VG/PELTT01/PELSTATION
* set (sourced from the unofficial chinchillabrains/eltaws reference client)
* rejects this account's credentials with st_flag=3 ("invalid password").
* Decompiling ELTA's own official desktop client (ELTA_PEL.exe, from their
* public .msi installer) showed it never calls CREATEAWB at all — it
* exclusively uses a newer "*NEW" family (PELVGNEW, PELLOGINNEW,
* PELTTNEW01, PELTKNEW, PELVGDEL, PELPARALVGNEW1). PELVGNEW was
* live-verified directly against the production endpoint with this same
* account and succeeded (st_flag=0, real voucher number returned) — it
* requires no password at all, just the account/user code pair.
*
* No .wsdl files exist for the *NEW family (only reconstructed from the
* decompiled C# proxy classes' method signatures). PHP's SoapClient in
* non-WSDL mode refuses to serialize these calls at all (SoapFault "Error
* in client request message" regardless of style/use/SoapVar wrapping —
* this legacy Micro Focus/CICS gateway's document/literal shape doesn't
* match what ext-soap's non-WSDL client is willing to build), so *NEW
* operations are sent as hand-built SOAP 1.1 envelopes over plain HTTP —
* proven directly against the live endpoint — with the response parsed
* back via DOMDocument/DOMXPath.
*
* The old family's label operation (PELB64VG) is deliberately NOT
* exposed here — it fails with the same st_flag=3 as CREATEAWB (same
* legacy family), and has no *NEW replacement since ELTA's own client
* renders labels locally instead of fetching them (see
* EltaLabelRenderer).
*/
class EltaClient
{
public function __construct(private readonly array $config) {}
public function login(array $params): EltaResponse
{
return $this->call('PELLOGINNEW', $params);
}
public function createVoucher(array $params): EltaResponse
{
return $this->call('PELVGNEW', $params);
}
public function listVouchers(array $params): EltaResponse
{
return $this->call('PELPARALVGNEW1', $params);
}
public function deleteVoucher(array $params): EltaResponse
{
return $this->call('PELVGDEL', $params);
}
public function getTracking(array $params): EltaResponse
{
return $this->call('PELTTNEW01', $params);
}
public function getStation(array $params): EltaResponse
{
return $this->call('PELTKNEW', $params);
}
private function call(string $operation, array $params): EltaResponse
{
$namespace = '/'.$operation;
$body = '';
foreach ($params as $key => $value) {
$body .= '<cre:'.$key.'>'.htmlspecialchars((string) $value, ENT_XML1).'</cre:'.$key.'>';
}
$envelope = '<?xml version="1.0" encoding="UTF-8"?>'
.'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cre="'.$namespace.'">'
.'<soapenv:Header/><soapenv:Body><cre:READ>'.$body.'</cre:READ></soapenv:Body></soapenv:Envelope>';
$response = Http::withHeaders([
'SOAPAction' => 'Get',
'Content-Type' => 'text/xml',
])
->timeout($this->config['timeout'])
->withBody($envelope, 'text/xml')
->post($this->config['service_location']);
if ($response->failed()) {
throw new EltaApiException('ELTA request failed: HTTP '.$response->status());
}
return EltaResponse::fromSoapResult($this->parseEnvelope($response->body()));
}
private function parseEnvelope(string $xml): array
{
$dom = new DOMDocument();
if (! $dom->loadXML($xml)) {
throw new EltaApiException('ELTA returned a response that could not be parsed as XML.');
}
$xpath = new DOMXPath($dom);
$xpath->registerNamespace('soapenv', 'http://schemas.xmlsoap.org/soap/envelope/');
$readResponse = $xpath->query('//soapenv:Body/*')->item(0);
if (! $readResponse instanceof DOMElement) {
throw new EltaApiException('ELTA response did not contain a SOAP body.');
}
return $this->nodeToArray($readResponse);
}
private function nodeToArray(DOMElement $element): array
{
$data = [];
foreach ($element->childNodes as $child) {
if (! $child instanceof DOMElement) {
continue;
}
$name = $child->localName;
$hasElementChildren = false;
foreach ($child->childNodes as $grandchild) {
if ($grandchild instanceof DOMElement) {
$hasElementChildren = true;
break;
}
}
$value = $hasElementChildren ? $this->nodeToArray($child) : trim($child->textContent);
if (array_key_exists($name, $data)) {
if (! is_array($data[$name]) || ! array_is_list($data[$name])) {
$data[$name] = [$data[$name]];
}
$data[$name][] = $value;
} else {
$data[$name] = $value;
}
}
return $data;
}
}
@@ -0,0 +1,249 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
/**
* Not SupportsManifestBatching — no manifest/pickup-list operation was
* found in either ELTA operation family, unlike ACS's
* ACS_Issue_Pickup_List. Same shape as Box Now, which has no manifest
* step either.
*/
class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
{
public function __construct(
private readonly EltaClient $client,
private readonly EltaLabelRenderer $labelRenderer,
) {}
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$address = $order->shippingAddress;
$weight = $request->weight ?? 0.5;
$params = [
'pel_apost_code' => config('elta.apost_code'),
'pel_paral_name' => trim("{$address->first_name} {$address->last_name}"),
'pel_paral_address' => $address->line_one,
'pel_paral_area' => $address->city,
'pel_paral_tk' => $address->postcode,
'pel_paral_thl_1' => $address->contact_phone,
'pel_paral_thl_2' => '',
'pel_service' => '',
'pel_baros' => number_format($weight, 3, '.', ''),
'pel_baros_xyz' => '',
'pel_x' => '',
'pel_y' => '',
'pel_z' => '',
'pel_temaxia' => (string) $request->packageCount,
'pel_paral_sxolia' => '',
'pel_sur_1' => '',
'pel_sur_2' => '',
'pel_sur_3' => '',
'pel_ant_poso' => '',
'pel_ant_poso1' => '',
'pel_ant_poso2' => '',
'pel_ant_poso3' => '',
'pel_ant_poso4' => '',
'pel_ant_date1' => '',
'pel_ant_date2' => '',
'pel_ant_date3' => '',
'pel_ant_date4' => '',
'pel_asf_poso' => '',
'pel_user' => config('elta.user_code'),
'pel_ref_no' => (string) $order->id,
'pel_insert_flag' => '',
'pel_paral_code' => '',
'pel_retur_code' => '',
];
$codAmount = null;
if ($request->paymentMode === 'cod') {
$codAmount = $request->amountToCollect ?? $order->total->decimal;
$params['pel_ant_poso'] = number_format($codAmount, 2, '.', '');
}
$response = $this->client->createVoucher($params)->throwIfError();
$voucherNo = (string) $response->data['vg_code'];
$shipment = Shipment::create([
'order_id' => $order->id,
'carrier' => 'elta',
'tracking_reference' => $voucherNo,
'meta' => [
'weight' => $weight,
'ocr_line' => $response->data['ocr_line'] ?? null,
'date_time' => $response->data['date_time'] ?? null,
'package_count' => $request->packageCount,
'cod_amount' => $codAmount,
],
]);
// PELVGNEW's own response already carries every child voucher
// number for a multi-piece shipment in vg_child_no — no second
// request needed, same as the old CREATEAWB/vg_child shape.
foreach (array_filter((array) ($response->data['vg_child_no'] ?? [])) as $childVoucherNo) {
Shipment::create([
'order_id' => $order->id,
'carrier' => 'elta',
'tracking_reference' => $childVoucherNo,
'parent_reference' => $voucherNo,
'meta' => $shipment->meta?->toArray() ?? [],
]);
}
return $shipment;
}
/**
* PELB64VG (the old, deprecated operation this used to call) fails
* with the same st_flag=3 as CREATEAWB — it's the same legacy family.
* There's no *NEW replacement: ELTA's own official client renders
* labels entirely locally from a bundled report template, never
* fetching one from the server at all (see EltaLabelRenderer's
* docblock). This reproduces that local rendering instead.
*/
public function printLabel(Shipment $shipment): string
{
$pdf = $this->labelRenderer->render($shipment);
$shipment->update(['label_printed_at' => now()]);
return $pdf;
}
/**
* Unlike the old, deprecated operation set (which genuinely has no
* delete operation), the *NEW family does support cancellation via
* PELVGDEL — but it identifies a voucher by its internal pel_vg_id,
* not the AWB/tracking number PELVGNEW returns (vg_code). Getting
* pel_vg_id requires first listing the account's vouchers via
* PELPARALVGNEW1 and matching the row whose pel_paral_vg equals our
* tracking_reference.
*/
public function cancelShipment(Shipment $shipment): void
{
$voucherId = $this->findVoucherId($shipment->tracking_reference);
if ($voucherId === null) {
throw new EltaApiException(
'Could not find voucher '.$shipment->tracking_reference.' in ELTA\'s voucher list — it may already be too old or cancelled to look up.',
);
}
$this->client->deleteVoucher(['pel_vg_id' => $voucherId])->throwIfError();
$shipment->update(['cancelled_at' => now()]);
}
private function findVoucherId(string $trackingReference): ?string
{
$inId = '0';
// PELPARALVGNEW1 paginates via in_id, same cursor pattern the
// official client uses — walk pages until the voucher is found
// or a page comes back empty. flag_1/flag_2 map to the real
// client's "Show All"/"All Users" checkboxes — live-confirmed
// both must be '1', otherwise even a voucher created moments ago
// is filtered out of the (default, unfiltered-looking) list.
for ($page = 0; $page < 50; $page++) {
$response = $this->client->listVouchers([
'pel_code' => config('elta.apost_code'),
'pel_user_code' => config('elta.user_code'),
'flag_1' => '1',
'flag_2' => '1',
'in_id' => $inId,
])->throwIfError();
$rows = array_filter(
(array) ($response->data['vg_rec'] ?? []),
fn (array $row) => filled($row['pel_vg_id'] ?? null),
);
if ($rows === []) {
return null;
}
$match = collect($rows)->first(
fn (array $row) => ($row['pel_paral_vg'] ?? null) === $trackingReference,
);
if ($match !== null) {
return $match['pel_vg_id'];
}
$inId = (string) end($rows)['pel_vg_id'];
}
return null;
}
public function trackShipment(Shipment $shipment): Collection
{
$response = $this->client->getTracking([
'web_vg' => $shipment->tracking_reference,
'pel_code' => config('elta.apost_code'),
])->throwIfError();
$rows = array_filter(
(array) ($response->data['web_status'] ?? []),
fn (array $row) => filled($row['web_date_time'] ?? null),
);
// No explicit delivered-confirmation field like the old
// PELTT01's pod_name — pel_rec.a_rec_date_time is blank until
// delivery per the real client's own rendering, used as the
// delivered signal here; unconfirmed against a genuinely
// delivered parcel yet.
$isDelivered = filled(trim($response->data['pel_rec']['a_rec_date_time'] ?? '', ' /:'));
return collect(array_values($rows))->map(function (array $row, int $index) use ($rows, $isDelivered) {
$isLast = $index === count($rows) - 1;
$dateTime = $row['web_date_time'] ?? '';
return new TrackingCheckpoint(
status: $isLast && $isDelivered
? TrackingStatus::Delivered
: $this->guessStatusFromTitle($row['web_status_name'] ?? ''),
carrierStatus: $row['web_status_name'] ?? null,
message: $row['web_sxolia'] ?: ($row['web_status_name'] ?? null),
location: $row['web_station'] ?? null,
occurredAt: Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12)),
meta: $row,
);
});
}
/**
* web_status_name is free text with no structured status code — same
* limitation Acs\AcsFulfillmentService::guessStatusFromAction() has.
* Live-confirmed text seen so far: "ΔΗΜΙΟΥΡΓΙΑ ΣΥ.ΔΕ.ΤΑ. ΑΠΟ ΠΕΛΑΤΗ"
* (voucher created) — matched via a Pending-ish fallback below since
* it isn't yet in transit. Other statuses are best-guess substring
* matches to refine as more real tracking text is observed.
*/
private function guessStatusFromTitle(string $title): TrackingStatus
{
$title = mb_strtolower($title);
return match (true) {
str_contains($title, 'παραδόθηκε') => TrackingStatus::Delivered,
str_contains($title, 'διανομή') => TrackingStatus::OutForDelivery,
str_contains($title, 'παραλαβή') => TrackingStatus::CollectedFromSender,
str_contains($title, 'μεταφορά') || str_contains($title, 'διαμετακόμιση') => TrackingStatus::InTransit,
default => TrackingStatus::Pending,
};
}
}
@@ -0,0 +1,184 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Carbon;
use Modules\Core\Shipping\Models\Shipment;
use Picqer\Barcode\BarcodeGeneratorPNG;
/**
* Renders an ELTA shipping label as a PDF ourselves — there is no *NEW
* operation for fetching one (see EltaFulfillmentService::printLabel()'s
* docblock), so this reproduces ELTA's own official label design instead
* of a server-fetched file.
*
* Field layout/text is traced directly from ELTA's own 2024 Customer
* Client Installation Manual, which includes real sample images of both
* the A4 voucher (3 stacked copies: delivery copy, sender's copy, a
* ΤΑΧΥΠΛΗΡΩΜΗ payment-receipt stub) and the A6 thermal label (single
* copy, with a return-reason checkbox list the A4 version doesn't have)
* — not from decompiled client code, which an earlier version of this
* class was built from and turned out to look nothing like a real
* label. The barcode is Code 128 (single barcode per label, matching
* both real samples) encoding the voucher number itself — an earlier
* version guessed Code 39 with a second "OCR" barcode from misreading
* decompiled code; neither survived comparison against the real
* samples.
*/
class EltaLabelRenderer
{
public function __construct(private readonly AreaResolver $areaResolver) {}
public function render(Shipment $shipment): string
{
$order = $shipment->order;
$address = $order->shippingAddress;
$meta = $shipment->meta?->toArray() ?? [];
$destinationStation = $this->areaResolver->resolve($address->postcode);
$dateTime = (string) ($meta['date_time'] ?? '');
$occurredAt = strlen($dateTime) >= 12
? Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12))
: now();
$voucherNo = $shipment->tracking_reference;
$ocrLine = (string) ($meta['ocr_line'] ?? '');
$packageCount = (int) ($meta['package_count'] ?? 1);
$generator = new BarcodeGeneratorPNG();
$data = [
'sender_name' => config('elta.sender_name'),
'sender_address' => config('elta.sender_address'),
'sender_postcode' => config('elta.sender_postcode'),
'sender_area' => config('elta.sender_area'),
'sender_phone' => config('elta.sender_phone'),
'apost_code' => config('elta.apost_code'),
'recipient_name' => trim("{$address->first_name} {$address->last_name}"),
'recipient_address' => $address->line_one,
'recipient_postcode' => $address->postcode,
'recipient_area' => $address->city,
'recipient_phone' => $address->contact_phone,
// Mirrors Sydeta.cs::print_vg()'s own dataRow["sender_1..5"] /
// dataRow["rec_1..5"] construction: name on its own line, then
// address, then "TK:<postcode> <area> ΤΗΛ:<phone>" — each of
// the RDLC's rectangle8/rectangle9 boxes renders these as 3
// separate textbox lines (not 5; sender_2/sender_3 and
// rec_2/rec_3 only appear when a line's text exceeds the
// report's 40-char wrap width, which our own data never hits
// in practice for a name/address/contact triple).
'sender_lines' => [
config('elta.sender_name'),
config('elta.sender_address'),
'TK:'.config('elta.sender_postcode').' '.config('elta.sender_area').' ΤΗΛ:'.config('elta.sender_phone'),
],
'recipient_lines' => [
trim("{$address->first_name} {$address->last_name}"),
$address->line_one,
'TK:'.$address->postcode.' '.$address->city,
'ΤΗΛ: '.$address->contact_phone,
],
'date' => $occurredAt->format('d/m/Y'),
'time' => $occurredAt->format('H:i'),
'voucher_no' => $voucherNo,
'barcode_voucher' => $this->barcodeDataUri($generator, $voucherNo),
'weight' => $meta['weight'] ?? null,
'package_count' => $packageCount,
'package_label' => $packageCount > 1
? sprintf('001/%03d', $packageCount)
: '001',
'multiPiece' => $packageCount > 1,
'station_apo' => config('elta.origin_station_code'),
'station_pros' => $destinationStation->code,
'station_pros_title' => $destinationStation->name,
'service_code' => '214',
'service_name' => 'ΠΟΛΗ ΠΟΛΗ -ΠΘ- ΝΗΣΙ',
'xreosi' => 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ',
'siimvasi' => '131775-9',
'cod_amount' => $meta['cod_amount'] ?? null,
'ocr_line' => $ocrLine,
// The A6 layout's 3-cell row shows a 13-digit reference derived
// from the OCR line, not the barcode's own voucher text —
// confirmed against the real sample: ">14260009814363<..."
// yields reference "1426000981436" (13 chars after the '>').
'ocr_reference' => strlen($ocrLine) >= 14 ? substr($ocrLine, 1, 13) : '',
'order_reference' => (string) $order->id,
// sydetaE.rdlc's "Πρόσθετες Υπηρεσίες" (sur_1..4) and the
// ΑΝΤΙΚΑΤΑΒΟΛΗ banner (antik_1) are only populated by ELTA's
// own client when the shipper ticks special-service checkboxes
// (Sydeta.cs::print_vg()'s checkBox2-5) or a COD amount is set
// — neither is tracked on our Shipment model today, so these
// render as empty boxes, matching what a plain "credit" send
// with no extras or COD looks like on a real label.
'sur_1' => null,
'sur_2' => null,
'sur_3' => null,
'sur_4' => null,
'antik_1' => $meta['cod_amount'] ?? null ? '* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *' : null,
'volumetric_weight' => $meta['volumetric_weight'] ?? null,
'eltaLogo' => $this->logoDataUri(),
];
$paperSize = config('elta.label_paper_size', 'a4');
$view = $paperSize === 'a6'
? 'core::shipping.carriers.elta.label-a6'
: 'core::shipping.carriers.elta.label-a4';
$pdf = Pdf::loadView($view, $data);
// A6 = 104mm x 148mm, per RDLCPrinter.cs's own DeviceInfo
// override for printer_size==2 (1mm ≈ 2.8346pt) — note this is
// ELTA's own thermal-label size, not the ISO A6 (105x148mm) the
// old comment here named. A 4-value paper array plus an
// orientation string together confuse dompdf into doubling the
// canvas and silently overflowing to a second blank page
// (reproduced with a bare empty page); the array alone is
// already portrait (height > width), so the orientation
// argument is both redundant and the actual bug.
$paperSize === 'a6'
? $pdf->setPaper([0, 0, 294.80, 419.53])
: $pdf->setPaper('a4');
return $pdf->output();
}
/**
* PNG over SVG — dompdf's inline <svg> support doesn't reliably
* render the barcode generator's <rect>-based bars (confirmed: the
* SVG output rendered as literal text in a live test), while a
* base64 PNG <img> is unambiguous.
*/
private function barcodeDataUri(BarcodeGeneratorPNG $generator, string $payload): string
{
$png = $generator->getBarcode($payload, BarcodeGeneratorPNG::TYPE_CODE_128);
return 'data:image/png;base64,'.base64_encode($png);
}
/**
* Embedded as a base64 data URI rather than referenced by URL —
* resources/logos/ is only copied into a consuming app's public/ on
* vendor:publish, which isn't guaranteed to have run, and dompdf
* needs either a real filesystem path or a working absolute URL.
*/
private function logoDataUri(): string
{
$path = __DIR__.'/../../../../resources/logos/elta-courier-logo.png';
return 'data:image/png;base64,'.base64_encode(file_get_contents($path));
}
}
@@ -0,0 +1,70 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
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;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
/**
* None of ELTA's four SOAP operations (create/label/track/station) expose
* a price-quote/calculation call, so — like Box Now — this always
* resolves the method's own charge_by + price-break configuration, never
* live pricing. Does not implement SupportsLivePricing.
*
* Implements SupportsCashCollection: an ELTA courier hands a parcel to
* the recipient in person, the same fact as AcsRateDriver, unlike Box
* Now's unattended lockers.
*/
class EltaRateDriver implements ShippingRateInterface, DeclaresFulfillmentType, SupportsCashCollection
{
use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate;
public function name(): string
{
return 'ELTA Courier';
}
public function fulfillmentType(): string
{
return 'carrier';
}
public function collectsCash(): bool
{
return true;
}
public function description(): string
{
return 'Delivery via ELTA Courier.';
}
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,43 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
/**
* Normalizes a SoapClient::READ() stdClass result — every ELTA operation's
* response carries the same st_flag (0=success, 1-99=error)/st_title
* shape, live-verified against the real production endpoint (see
* EltaClient's own docblock). Mirrors Acs\AcsResponse's hasError/data
* shape so the rest of the integration (rate driver, fulfillment service)
* reads identically regardless of transport (SOAP here vs. ACS's JSON).
*/
class EltaResponse
{
private function __construct(
public readonly bool $hasError,
public readonly ?string $errorMessage,
public readonly array $data,
) {}
public static function fromSoapResult(mixed $result): self
{
$data = json_decode(json_encode($result), true) ?? [];
$flag = (int) ($data['st_flag'] ?? 0);
return new self(
hasError: $flag !== 0,
errorMessage: $flag !== 0 ? ($data['st_title'] ?? 'Unknown ELTA error') : null,
data: $data,
);
}
public function throwIfError(): self
{
if ($this->hasError) {
throw new EltaApiException($this->errorMessage ?? 'Unknown ELTA API error', $this->data);
}
return $this;
}
}
@@ -0,0 +1,11 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
class EltaStation
{
public function __construct(
public readonly string $code,
public readonly string $name,
) {}
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta\Exceptions;
use RuntimeException;
use Throwable;
class EltaApiException extends RuntimeException
{
public function __construct(string $message, public readonly array $data = [], ?Throwable $previous = null)
{
parent::__construct($message, previous: $previous);
}
}
@@ -0,0 +1,75 @@
<?php
namespace Modules\Core\Shipping\Carriers\StorePickup;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
/**
* First-party replacement for lunarphp/table-rate-shipping's own Collection
* driver — same shape as AcsRateDriver/BoxNowRateDriver, unlike the vendor
* generic drivers (flat-rate/ship-by/free-shipping/collection), which this
* module no longer registers (see ShippingServiceProvider). Two reasons
* this exists rather than keeping the vendor driver:
*
* 1. Collection::resolve() reads $shippingMethod->name directly instead of
* through Modules\Core\Shipping\Support\ShippingMethodName::resolve() —
* ShippingMethod has no cast for that locale-keyed JSON column (see
* ShippingMethodName's own docblock), so the storefront showed the raw
* JSON blob as the option's name instead of the translated string.
* 2. Unambiguously store pickup, like ACS/Box Now are unambiguously
* carrier — implementing DeclaresFulfillmentType means this is a
* hardcoded fact about the driver, never a merchant configuration
* choice (see that contract's own docblock).
*
* No live pricing — there's no API for in-person pickup, just the method's
* own charge_by + price-break configuration (usually free), the same
* mechanism Box Now uses.
*/
class StorePickupRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
{
use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate;
public function name(): string
{
return 'Store Pickup';
}
public function fulfillmentType(): string
{
return 'store_pickup';
}
public function description(): string
{
return 'Collect your order in store.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) {
return null;
}
return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod,
$shippingOptionRequest->cart,
);
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Shipping\Concerns;
use Lunar\Models\Cart;
use Lunar\Models\Product;
use Lunar\Shipping\Models\ShippingRate;
/**
* A shipping zone can list specific products that can't go through it (see
* the Filament "Shipping Exclusions" relation manager on ShippingZoneResource)
* — a merchant's way of saying "this item physically can't be handled by
* this carrier/method," independent of price. lunarphp/table-rate-shipping's
* own generic drivers (FlatRate, ShipBy, FreeShipping, Collection) each
* check this before returning an option; every first-party driver in this
* module (ACS, Box Now, store pickup) shares it from here instead, so a
* merchant-configured exclusion is honored no matter which driver a
* shipping method uses — not just the vendor's generic ones. Call this
* FIRST in resolve(), before any pricing lookup (live or fixed): an
* excluded product should make the option disappear entirely, the same
* "return null" a driver already uses for "no rate matched."
*/
trait ExcludesRestrictedProducts
{
private function cartHasExcludedProducts(ShippingRate $shippingRate, Cart $cart): bool
{
$productIds = $cart->lines->load('purchasable')->pluck('purchasable.product_id');
return $shippingRate->shippingZone->shippingExclusions()
->whereHas('exclusions', function ($query) use ($productIds) {
$query->wherePurchasableType(Product::morphName())
->whereIn('purchasable_id', $productIds);
})->exists();
}
}
@@ -3,18 +3,14 @@
namespace Modules\Core\Shipping\Contracts; namespace Modules\Core\Shipping\Contracts;
/** /**
* Optional contract a shipping rate driver implements to declare whether * Every shipping rate driver this module registers implements this to
* it fulfils via carrier delivery or in-store pickup — e.g. * declare whether it fulfils via carrier delivery or in-store pickup —
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver and BoxNowRateDriver * Modules\Core\Shipping\Carriers\Acs\AcsRateDriver and BoxNowRateDriver
* are unambiguously carrier-only, so this is a hardcoded fact about the * are unambiguously carrier-only, Modules\Core\Shipping\Carriers\
* driver, not something a merchant should have to configure per row. * StorePickup\StorePickupRateDriver unambiguously store-pickup, so this
* * is a hardcoded fact about each driver, never something a merchant
* table-rate-shipping's own generic drivers (flat-rate, ship-by, * configures per row. See Modules\Core\Shipping\Support\FulfillmentType::
* free-shipping) don't implement this — they're genuinely ambiguous (a * resolve(), the single source of truth this feeds.
* merchant could configure one for either carrier delivery or store
* pickup), so Modules\Core\Shipping\Support\FulfillmentType::resolve()
* falls back to ShippingMethod.data['fulfillment_type'] (still merchant-
* overridable) only for drivers that don't implement this contract.
*/ */
interface DeclaresFulfillmentType interface DeclaresFulfillmentType
{ {
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Shipping\Contracts;
/**
* A shipping rate driver implements this to say a person is physically
* present at handoff to collect cash — true for a courier like
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver, false for an
* unattended parcel locker network like Modules\Core\Shipping\Carriers\
* BoxNow\BoxNowRateDriver, where no one is there to take payment. Checked
* by Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()
* for cash-on-delivery specifically (Modules\Core\Payment\Drivers\
* CashOnDeliveryPaymentDriver) — separate from Modules\Core\Shipping\
* Contracts\DeclaresFulfillmentType, which only distinguishes carrier vs.
* store_pickup and can't tell ACS and Box Now apart (both 'carrier').
*
* Not implemented at all means "no" — a driver with no opinion here
* (any of table-rate-shipping's own generic drivers, if one were ever
* re-added) is treated as not supporting cash collection, the safer
* default for a driver this module knows nothing about.
*/
interface SupportsCashCollection
{
public function collectsCash(): bool;
}
@@ -48,6 +48,6 @@ class ShippingMethodListExtension extends BaseExtension
->label('Type') ->label('Type')
->options(fn () => collect(Shipping::getSupportedDrivers()) ->options(fn () => collect(Shipping::getSupportedDrivers())
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()])) ->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
->default('flat-rate'); ->default('acs');
} }
} }
@@ -13,7 +13,6 @@ use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
use Lunar\Admin\Support\Forms\Components\TranslatedText; use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName; use Modules\Core\Shipping\Support\ShippingMethodName;
@@ -22,11 +21,9 @@ class ShippingMethodResourceExtension extends ResourceExtension
public function extendForm(Schema $schema): Schema public function extendForm(Schema $schema): Schema
{ {
return $schema->components( return $schema->components(
$this->replaceFulfillmentTypeField( $this->replaceChargeByField(
$this->replaceChargeByField( $this->replaceNameField(
$this->replaceNameField( $this->replaceDriverField($schema->getComponents())
$this->replaceDriverField($schema->getComponents())
)
) )
) )
); );
@@ -98,64 +95,6 @@ class ShippingMethodResourceExtension extends ResourceExtension
return $field; return $field;
} }
/**
* Inserts the `fulfillment_type` Select right after `charge_by`, in
* the SAME Group (vendor's own `Group::make([getChargeByFormComponent()])
* ->columns(2)`) — formerly appended at the very end of the whole
* form, disconnected from `driver`/`charge_by`, the decisions it
* actually relates to. Only rendered at all for a driver that DOESN'T
* already declare its own fulfillment type (see Modules\Core\Shipping\
* Contracts\DeclaresFulfillmentType, Modules\Core\Shipping\Support\
* FulfillmentType) — acs/box-now are unambiguously carrier-only, so
* asking a merchant to also pick "Carrier delivery" for every ACS/Box
* Now method was redundant, error-prone config with no real decision
* behind it. Still offered for table-rate-shipping's generic drivers
* (flat-rate, ship-by, free-shipping), which are genuinely ambiguous.
*/
private function replaceFulfillmentTypeField(array $components): array
{
$result = [];
foreach ($components as $component) {
$result[] = $component;
if (method_exists($component, 'getName') && $component->getName() === 'charge_by') {
$result[] = $this->fulfillmentTypeSelect();
} elseif (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema($this->replaceFulfillmentTypeField($component->getChildComponents()));
}
}
return $result;
}
private function fulfillmentTypeSelect(): Select
{
return Select::make('data.fulfillment_type')
->label('Fulfillment type')
->options([
'carrier' => 'Carrier delivery',
'store_pickup' => 'Collect in store',
])
->default('carrier')
->required()
->visible(fn (Get $get) => $this->driverIsFulfillmentAmbiguous($get('../driver')))
->helperText('Whether an order using this method is handed to a carrier, or collected by the customer in person.');
}
private function driverIsFulfillmentAmbiguous(?string $driver): bool
{
if (! $driver) {
return true;
}
try {
return ! Shipping::driver($driver) instanceof DeclaresFulfillmentType;
} catch (InvalidArgumentException) {
return true;
}
}
/** /**
* Extend the vendor's cart_total/weight charge_by Select with a third * Extend the vendor's cart_total/weight charge_by Select with a third
* "live" option — only offered when the currently selected driver * "live" option — only offered when the currently selected driver
@@ -297,7 +236,7 @@ class ShippingMethodResourceExtension extends ResourceExtension
->label('Type') ->label('Type')
->options(fn () => collect(Shipping::getSupportedDrivers()) ->options(fn () => collect(Shipping::getSupportedDrivers())
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()])) ->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
->default('flat-rate') ->default('acs')
->live(); ->live();
} }
} }
+11 -36
View File
@@ -8,20 +8,16 @@ use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
/** /**
* The single source of truth for "is this ShippingMethod a carrier * The single source of truth for "is this ShippingMethod a carrier
* delivery or an in-store pickup" — replaces a merchant-facing * delivery or an in-store pickup" — every driver this module registers
* data['fulfillment_type'] Select that used to exist for every method * (ACS, Box Now, Modules\Core\Shipping\Carriers\StorePickup\
* regardless of driver. Modules\Core\Shipping\Carriers\Acs\AcsRateDriver * StorePickupRateDriver) implements DeclaresFulfillmentType, so this is
* and BoxNowRateDriver are unambiguously carrier-only (see * now a hardcoded fact about the driver, never a merchant choice. There
* Modules\Core\Shipping\Contracts\DeclaresFulfillmentType's own * used to be a merchant-facing data['fulfillment_type'] Select as a
* docblock), so asking a merchant to also pick "Carrier delivery" for * fallback for table-rate-shipping's own generic drivers (flat-rate,
* every ACS/Box Now method was redundant, error-prone config with no * ship-by, free-shipping, collection), which were genuinely ambiguous
* real decision behind it. * (a merchant could configure one for either purpose) — those drivers
* * are no longer offered at all (see Modules\Core\Shipping\Support\
* table-rate-shipping's own generic drivers (flat-rate, ship-by, * ShippingManager), so the fallback and the field it read from are gone.
* free-shipping) don't implement DeclaresFulfillmentType — a merchant
* could genuinely configure one for either purpose (e.g. "Flat Rate —
* Athens Store Pickup") — so those still fall back to the merchant-set
* data['fulfillment_type'], defaulting to 'carrier' when unset.
*/ */
class FulfillmentType class FulfillmentType
{ {
@@ -29,32 +25,11 @@ class FulfillmentType
{ {
$driver = collect(Shipping::getSupportedDrivers())->get($method->driver); $driver = collect(Shipping::getSupportedDrivers())->get($method->driver);
if ($driver instanceof DeclaresFulfillmentType) { return $driver instanceof DeclaresFulfillmentType ? $driver->fulfillmentType() : 'carrier';
return $driver->fulfillmentType();
}
return $method->data['fulfillment_type'] ?? 'carrier';
} }
public static function isStorePickup(ShippingMethod $method): bool public static function isStorePickup(ShippingMethod $method): bool
{ {
return static::resolve($method) === 'store_pickup'; return static::resolve($method) === 'store_pickup';
} }
/**
* Whether the merchant-facing "Fulfillment type" Select should be
* shown at all for a given driver — hidden entirely for a driver that
* already declares its own fulfillment type, since there is no real
* decision left for the merchant to make.
*/
public static function isConfigurableFor(?string $driverKey): bool
{
if ($driverKey === null) {
return true;
}
$driver = collect(Shipping::getSupportedDrivers())->get($driverKey);
return ! $driver instanceof DeclaresFulfillmentType;
}
} }
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Modules\Core\Shipping\Support;
use Lunar\Shipping\Managers\ShippingManager as VendorShippingManager;
/**
* Drops lunarphp/table-rate-shipping's own generic drivers (free-shipping,
* flat-rate, ship-by, collection) from getSupportedDrivers() — every
* shipping method in this app now uses a first-party driver (ACS, Box
* Now, or Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver,
* registered via Shipping::extend() in ShippingServiceProvider), each of
* which declares its own fulfillment type and correctly decodes
* ShippingMethod's translatable `name` column (see StorePickupRateDriver's
* own docblock for why the vendor `collection` driver specifically had to
* go — it read $shippingMethod->name raw, showing the storefront the raw
* JSON blob instead of a translated string). The generic drivers'
* create{X}Driver() methods still exist on the parent (harmless — nothing
* calls them once their key is gone from getSupportedDrivers()), so this
* only needs to override the one method that lists what's offered.
*
* Bound over the vendor's own ShippingMethodManagerInterface binding in
* ShippingServiceProvider, from inside its own $this->app->booted(...)
* callback — a plain register()-time bind() here runs BEFORE the
* vendor's own (composer discovers boboko/core before lunarphp/
* table-rate-shipping alphabetically), so the vendor's later bind()
* would silently win instead. See that provider's own comment.
*/
class ShippingManager extends VendorShippingManager
{
public function getSupportedDrivers(): \Illuminate\Support\Collection
{
return collect($this->customCreators)
->mapWithKeys(function ($creator, $key) {
return [$key => $this->callCustomCreator($key)];
});
}
}
@@ -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(),
]); ]);
} }