Compare commits

...
11 Commits
36 changed files with 966 additions and 447 deletions
+120
View File
@@ -4,6 +4,126 @@ 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.27.0] - 2026-09-29
### Added
- `Modules\Core\Shipping\Contracts\SupportsCashCollection` — a shipping driver
implements this to say a person is physically present at handoff to collect
cash. `AcsRateDriver` implements it (a courier can); `BoxNowRateDriver` and
the new `StorePickupRateDriver` don't (an unattended locker or a store
clerk deciding to accept cash isn't the same fact as a courier collecting
it on delivery). `Modules\Core\Checkout\Services\CheckoutService::
getPaymentMethods()` now checks this for cash-on-delivery specifically, so
COD is no longer offered alongside Box Now — `RequiresFulfillmentType`
alone couldn't distinguish "an unattended carrier" from "an attended one,"
both being `carrier`.
- `Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver` — a
first-party replacement for `lunarphp/table-rate-shipping`'s own
`Collection` driver, following the same shape as `AcsRateDriver`/
`BoxNowRateDriver`. Fixes two bugs the vendor driver had: it read
`ShippingMethod->name` directly instead of decoding the translatable JSON
column, so the storefront showed the raw JSON blob instead of a
translated name; and it never checked product exclusions the same way
the vendor's own generic drivers did.
- `Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts` — shared
product-exclusion check (a shipping zone's configured "this product can't
ship via this method" list), now applied consistently by every
first-party driver (ACS, Box Now, Store Pickup). Previously only Lunar's
own generic drivers honored exclusions at all — ACS and Box Now silently
ignored them.
- `Modules\Core\Localization\Database\Seeders\StorefrontTranslationsSeeder`
— the `storefront` translation group is now seeded the same way
`checkout`/`validation` already are (a dedicated, idempotent seeder run
from the entrypoint), rather than as a private method inside
`InstallLunarCommand`. `Modules\Core\Localization\Services\
StorefrontLabels` is removed; its ~160 lines moved into the new seeder.
- `StoreDetailsService::bankTransferInstructionsFor()` — sanitized HTML for
a bank transfer order's confirmation email, gated on
`OrderStatusFlow::isBankTransfer()`.
- `StoreServiceProvider::applyMailFromName()` — listens for
`Illuminate\Mail\Events\MessageSending` and renames the From header's
display name to Store Details' `mail_from_name`, when set, for every
outgoing email app-wide (order notifications, OTP codes, contact form
confirmations, etc.), no per-mailable changes needed. Only touches a
message whose From address still matches `config('mail.from.address')`
— the sending address itself is untouched (a deliverability/domain-
authentication concern, not a merchant-editable text field), and a
mailable/notification that already set its own explicit sender is left
alone entirely.
- `Order.meta['locale']` — the shopper's locale at checkout, saved by
`CheckoutService::initiatePayment()` so every order notification renders
in the locale they actually checked out in (via `Notification::locale()`
in `NotificationRegistry::register()`), independent of whatever locale
happens to be active when the notification is actually sent (a payment
webhook, a queued job, a staff action).
- `Order.meta['coupon_code']` — copied from the cart at checkout, so the
confirmation email always shows the code the order was actually placed
with, rather than reading the cart row (which may since have been
cleared/reused).
- Contact details on the storefront and product confirmation emails now
accept `$order` directly (all 8 order notification views), removing two
workarounds: `placed.blade.php` no longer reaches the order through
`$lines->first()->order`, and `status-updated.blade.php` no longer
reverse-searches a status key from its English label.
### Changed
- The vendor's own generic shipping drivers (`free-shipping`, `flat-rate`,
`ship-by`, `collection`) are no longer offered at all — every shipping
method now uses a first-party driver (ACS, Box Now, or Store Pickup),
each of which declares its own fulfillment type. The `data.fulfillment_type`
Select on the shipping method form is removed, along with the
merchant-facing fallback it existed for — every registered driver now
hardcodes this fact about itself rather than a merchant configuring it
per row.
- `OrderCapturedNotification` ("payment captured") no longer sends for any
payment method except bank transfer. For every other method, placing the
order and paying for it are the same moment from the shopper's
perspective — `OrderPlacedNotification` already covers it. Bank transfer
is the one case where payment confirmation is a genuine, later, separate
event (staff marking the order paid once the wire arrives).
- `OrderStatusUpdatedNotification` no longer sends for an order's first
transition off `awaiting_payment`, except for a bank transfer order —
for every other payment method that transition happens in the same
request as checkout itself, so it's not new information the shopper
hasn't already been told.
- `OrderStatusUpdatedNotification`'s subject line now names the order's
actual current status ("Your order :reference is now :status") instead
of a generic "has been updated."
- The checkout page's payment methods now refresh live when the shipping
option changes, instead of requiring a full page reload — switching to
store pickup correctly drops cash-on-delivery and offers "pay in store"
immediately.
### Fixed
- `StoreDetailsService::bankTransferInstructionsFor()` returned the raw
Tiptap JSON array `bank_transfer_instructions` is stored as, instead of
rendering it to HTML — the order confirmation email for a bank transfer
order failed outright (`TypeError`, queued job marked `FAIL`) rather than
showing the store's payment instructions. Now converts via Filament's own
`RichContentRenderer`, the same converter the admin panel itself uses to
render a `RichEditor` field's content read-only.
## [0.26.5] - 2026-09-28
### Added
- Three new fields on the "Store Details" settings page
(`Modules\Core\Store\Filament\Pages\ManageStoreDetails`):
- **Contact email** (`contact_email`) — used both as the receiver address for the
contact form and shown to customers as the store's contact email.
- **Mail from name** (`mail_from_name`) — the sender name on outgoing emails
(`MAIL_FROM_NAME`).
- **Legal name** (`legal_name`) — the registered company's legal name, distinct
from the storefront-facing `name`. Locale-keyed JSON, like `name`/`address`/
`bank_transfer_instructions`, since a registered name can legitimately differ
per locale.
`contact_email`/`mail_from_name` are plain nullable strings, matching
`phone`/`tax_identifier`. The migration backfills all four translatable
`store_details` columns with an empty per-locale array on any row where they're
still `NULL`, and `StoreDetailsService::firstOrCreate()` now seeds `legal_name`
the same way it already seeds `name`/`address`/`bank_transfer_instructions` —
both needed so `TranslatedText` doesn't silently drop keystrokes on a
NULL-starting translatable field (see that service's own docblock).
## [0.26.4] - 2026-09-28 ## [0.26.4] - 2026-09-28
### Fixed ### Fixed
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.26.4", "version": "0.27.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Lunar\Models\Language;
/**
* contact_email/mail_from_name join tax_identifier/registration_number/
* phone as plain, non-locale-dependent strings on the store_details
* singleton (see that table's own migration docblock).
*
* legal_name is locale-keyed JSON instead — same shape/resolution as
* name/address/bank_transfer_instructions (HasTranslations, see
* StoreDetails's own docblock) — since a registered company name can
* legitimately differ per locale (e.g. a transliterated/translated legal
* form). Distinct from the storefront-facing brand name in `name`.
*
* Backfills every translatable column (name/address/
* bank_transfer_instructions/legal_name) with an empty per-locale array
* on any row that's still genuinely NULL there — StoreDetailsService::
* firstOrCreate() only seeds columns on INSERT, so an existing singleton
* row (or one created before a Language row existed, leaving
* emptyPerLocale() an empty array at the time) could otherwise keep a
* translatable column NULL forever. That's the exact condition
* TranslatedText breaks on (see StoreDetailsService's own docblock: a
* NULL-starting translatable field silently drops every keystroke and
* never persists) — backfilled here for all four columns, not just the
* new one, so the same fix covers however the existing row got there.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('store_details', function (Blueprint $table) {
$table->string('contact_email')->nullable()->after('phone');
$table->string('mail_from_name')->nullable()->after('contact_email');
$table->json('legal_name')->nullable()->after('registration_number');
});
$emptyPerLocale = json_encode(
Language::query()->pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all()
);
foreach (['name', 'address', 'bank_transfer_instructions', 'legal_name'] as $column) {
DB::table('store_details')->whereNull($column)->update([$column => $emptyPerLocale]);
}
}
public function down(): void
{
Schema::table('store_details', function (Blueprint $table) {
$table->dropColumn(['contact_email', 'mail_from_name', 'legal_name']);
});
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@boboko/core", "name": "@boboko/core",
"version": "0.26.4", "version": "0.27.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.",
@@ -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
@@ -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,
@@ -646,6 +646,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.
* *
@@ -0,0 +1,314 @@
<?php
namespace Modules\Core\Localization\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
/**
* Default `storefront` translation lines — nav, cart, product, shop, auth,
* account, orders, contact, reviews, wishlist (see the storefront views
* under resources/views for where each is used).
*
* Additive and idempotent: a group/key that already exists is left untouched,
* so anything edited in the Filament Language Lines UI wins on a re-run. Runs
* explicitly — `php artisan db:seed --class="Modules\Core\Localization\
* Database\Seeders\StorefrontTranslationsSeeder"` — it is not wired into any
* app's own DatabaseSeeder.
*
* Greek copy uses an informal register (εσύ/σου) — a consuming app with a
* different house style overrides individual lines from the Filament
* Language Lines UI same as any other translation, rather than forking
* this class.
*/
class StorefrontTranslationsSeeder extends Seeder
{
public function run(): void
{
$translations = app(TranslationService::class);
foreach ($this->lines() as $key => [$en, $el]) {
$exists = LanguageLine::query()
->where('group', 'storefront')
->where('key', $key)
->exists();
if ($exists) {
$this->command?->warn("storefront.{$key} already exists — skipped");
continue;
}
$translations->create('storefront', $key, ['en' => $en, 'el' => $el]);
$this->command?->info("storefront.{$key} added");
}
}
/**
* key => [English, Greek].
*
* @return array<string, array{0: string, 1: string}>
*/
private function lines(): array
{
return [
// ── Navigation ──────────────────────────────────────────────
'nav.home' => ['Home', 'Αρχική'],
'nav.products' => ['Products', 'Προϊόντα'],
'nav.cart' => ['Cart', 'Καλάθι'],
'nav.account' => ['Account', 'Λογαριασμός'],
'nav.back' => ['Back', 'Πίσω'],
'nav.contact' => ['Contact', 'Επικοινωνία'],
'nav.close' => ['Close', 'Κλείσιμο'],
// ── Cart ────────────────────────────────────────────────────
'cart.empty' => ['Your cart is empty', 'Το καλάθι σας είναι άδειο'],
'cart.checkout' => ['Checkout', 'Ολοκλήρωση Παραγγελίας'],
'cart.total' => ['Total', 'Σύνολο'],
'cart.remove' => ['Remove', 'Αφαίρεση'],
// ── Product ─────────────────────────────────────────────────
'product.add_to_cart' => ['Add to Cart', 'Προσθήκη στο Καλάθι'],
'product.out_of_stock' => ['Out of Stock', 'Εξαντλήθηκε'],
'product.sold' => ['Sold', 'Εξαντλήθηκε'],
'product.price' => ['Price', 'Τιμή'],
'product.description' => ['Description', 'Περιγραφή'],
'product.no_image' => ['No image', 'Χωρίς εικόνα'],
'product.read_more' => ['Read more', 'Περισσότερα'],
'product.reviews' => ['Reviews', 'Αξιολογήσεις'],
'product.related' => ['You may also like', 'Μπορεί επίσης να σου αρέσει'],
// ── Auth (login link) ───────────────────────────────────────
'auth.login' => ['Log In', 'Σύνδεση'],
'auth.logout' => ['Log Out', 'Αποσύνδεση'],
// ── Search ──────────────────────────────────────────────────
'search.placeholder' => ['Search products…', 'Αναζήτηση προϊόντων…'],
'search.results_for' => ['Search results for ', 'Αποτελέσματα αναζήτησης για '],
'customer_reviews' => [
'{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
'{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
],
// ── Pagination ──────────────────────────────────────────────
'pagination.nav_label' => ['Pagination', 'Σελιδοποίηση'],
'pagination.next' => ['Next page', 'Επόμενη σελίδα'],
'pagination.previous' => ['Previous page', 'Προηγούμενη σελίδα'],
'pagination.page' => ['Page :page', 'Σελίδα :page'],
// ── Reviews ─────────────────────────────────────────────────
'review.rating' => ['Rating', 'Βαθμολογία'],
'review.write_label' => ['Write a review', 'Γράψε μια αξιολόγηση'],
'review.name' => ['Name', 'Όνομα'],
'review.name_optional' => ['Optional', 'Προαιρετικό'],
'review.email' => ['Email', 'Email'],
'review.email_not_published' => ['Will not be published', 'Δεν θα δημοσιευτεί'],
'review.save_info' => [
'Save my name and email for the next time I comment.',
'Αποθήκευσε το όνομα και το email μου για την επόμενη φορά που θα σχολιάσω.',
],
'review.submit' => ['Submit', 'Υποβολή'],
'review.stars_count' => ['{1} :count star|[2,*] :count stars', '{1} :count αστέρι|[2,*] :count αστέρια'],
'review.no_reviews_yet' => ['No reviews yet.', 'Δεν υπάρχουν αξιολογήσεις ακόμα.'],
'review.write_first' => ['Write the first review', 'Γράψε την πρώτη'],
'review.write_new' => ['Add a review', 'Πρόσθεσε μια'],
'review.for_product' => ['review for ":name"', 'αξιολόγηση για το «:name»'],
'review.rating_required' => ['Please select a rating.', 'Παρακαλούμε επίλεξε βαθμολογία.'],
'review.reply' => ['Reply', 'Απάντηση'],
'review.thank_you' => ['Thanks for your review!', 'Ευχαριστούμε για την αξιολόγησή σου!'],
// ── Shop / listing page ─────────────────────────────────────
'shop.showing_results' => [
'{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
'{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
],
'shop.all_products' => ['All Products', 'Όλα τα Προϊόντα'],
'shop.sort_label' => ['Sort products', 'Ταξινόμηση προϊόντων'],
'shop.sort_default' => ['Default sorting', 'Προεπιλεγμένη ταξινόμηση'],
'shop.sort_popularity' => ['Popularity', 'Δημοφιλή'],
'shop.sort_price_asc' => ['Price: Low to High', 'Τιμή: Αύξουσα'],
'shop.sort_price_desc' => ['Price: High to Low', 'Τιμή: Φθίνουσα'],
'shop.sort_newest' => ['Newest', 'Νεότερα'],
'shop.no_products' => ['No products found in this category.', 'Δεν βρέθηκαν προϊόντα σε αυτή την κατηγορία.'],
'shop.search_label' => ['Search products', 'Αναζήτηση προϊόντων'],
'shop.search_placeholder' => ['Search products…', 'Αναζήτησε προϊόντα…'],
'shop.filter_price' => ['Filter by price', 'Φίλτρο τιμής'],
'shop.price_min' => ['Min price', 'Ελάχιστη τιμή'],
'shop.price_max' => ['Max price', 'Μέγιστη τιμή'],
'shop.reset' => ['Reset', 'Επαναφορά'],
'shop.apply' => ['Apply', 'Εφαρμογή'],
'shop.availability' => ['Availability', 'Διαθεσιμότητα'],
'shop.in_stock_only' => ['In-stock products only', 'Μόνο διαθέσιμα προϊόντα'],
// ── Passwordless login (Auth\Services\UserOtpService) ───────
'auth.login_intro' => [
'Enter your email and we\'ll send you a code to sign in — no password needed.',
'Γράψε το email σου και θα σου στείλουμε έναν κωδικό σύνδεσης — δεν χρειάζεται κωδικός πρόσβασης.',
],
'auth.email' => ['Email', 'Email'],
'auth.terms_notice' => [
'By continuing, you accept the <a href=":terms">Terms of Use</a> and have read the <a href=":privacy">Privacy Policy</a>.',
'Συνεχίζοντας, αποδέχεσαι τους <a href=":terms">Όρους Χρήσης</a> και έχεις διαβάσει την <a href=":privacy">Πολιτική Απορρήτου</a>.',
],
'auth.send_code' => ['Send code', 'Αποστολή κωδικού'],
'auth.enter_code' => ['Enter the code', 'Εισάγετε τον κωδικό'],
'auth.code_sent_to' => ['We sent a code to', 'Στείλαμε έναν κωδικό στο'],
'auth.code' => ['Code', 'Κωδικός'],
'auth.resend_code' => ['Resend code', 'Επαναποστολή κωδικού'],
'auth.code_resent' => ['A new code was sent.', 'Στάλθηκε νέος κωδικός.'],
'auth.change_email' => ['Use a different email', 'Χρήση διαφορετικού email'],
'auth.invalid_code' => ['That code is invalid or has expired.', 'Ο κωδικός δεν είναι έγκυρος ή έχει λήξει.'],
'auth.too_many_codes' => [
'Too many attempts. Please wait a few minutes and try again.',
'Πολλές προσπάθειες. Περίμενε λίγα λεπτά και ξαναδοκίμασε.',
],
'auth.captcha_failed' => [
'Please complete the captcha and try again.',
'Παρακαλούμε ολοκλήρωσε το captcha και ξαναδοκίμασε.',
],
// ── Account profile page (account/show.blade.php) ───────────
'account.nav_profile' => ['Profile', 'Προφίλ'],
'account.nav_orders' => ['Orders', 'Παραγγελίες'],
'account.nav_wishlist' => ['Wishlist', 'Λίστα επιθυμιών'],
'account.email_heading' => ['Login email', 'Email σύνδεσης'],
'account.email_change' => ['Change email', 'Αλλαγή email'],
'account.details_heading' => ['Your details', 'Τα στοιχεία σου'],
'account.first_name' => ['First name', 'Όνομα'],
'account.last_name' => ['Last name', 'Επώνυμο'],
'account.invoice' => ['I need an invoice', 'Χρειάζομαι τιμολόγιο'],
'account.company_name' => ['Company name', 'Επωνυμία εταιρείας'],
'account.tax_identifier' => ['Tax ID (VAT)', 'ΑΦΜ'],
'account.address_heading' => ['Address', 'Διεύθυνση'],
'account.line_one' => ['Address', 'Διεύθυνση'],
'account.city' => ['City', 'Πόλη'],
'account.postcode' => ['Postcode', 'Ταχυδρομικός κώδικας'],
'account.state' => ['Region', 'Περιοχή'],
'account.state_placeholder' => ['Select a region', 'Επίλεξε περιοχή'],
'account.phone' => ['Phone', 'Τηλέφωνο'],
'account.emails_heading' => ['Emails', 'Ειδοποιήσεις email'],
'account.recovery_consent' => [
'Email me a reminder if I don\'t finish my order',
'Στείλε μου υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
],
'account.save' => ['Save changes', 'Αποθήκευση'],
'account.saved' => ['Your details were saved.', 'Τα στοιχεία σου αποθηκεύτηκαν.'],
'account.delete_heading' => ['Delete account', 'Διαγραφή λογαριασμού'],
'account.delete_text' => [
'This permanently deletes your account and personal data. This cannot be undone.',
'Αυτό διαγράφει οριστικά τον λογαριασμό και τα προσωπικά σου δεδομένα. Δεν μπορεί να αναιρεθεί.',
],
'account.delete' => ['Delete my account', 'Διαγραφή λογαριασμού'],
'account.delete_confirm_heading' => ['Are you sure?', 'Είσαι σίγουρος/η;'],
'account.delete_confirm_text' => [
'This cannot be undone. Your account and personal data will be permanently deleted.',
'Αυτό δεν μπορεί να αναιρεθεί. Ο λογαριασμός και τα προσωπικά σου δεδομένα θα διαγραφούν οριστικά.',
],
'account.delete_confirm' => ['Yes, delete my account', 'Ναι, διαγραφή λογαριασμού'],
'account.delete_cancel' => ['Cancel', 'Ακύρωση'],
'account.deletion_requested' => [
'Your account deletion has been requested.',
'Ζητήθηκε η διαγραφή του λογαριασμού σου.',
],
// ── Account email-change flow (account/email.blade.php, account/email-code.blade.php) ──
'account.email_change_heading' => ['Change your email', 'Αλλαγή email'],
'account.email_current' => ['Your current email is', 'Το τρέχον email σου είναι'],
'account.email_new' => ['New email', 'Νέο email'],
'account.email_new_hint' => [
'We\'ll send a code to this address to confirm it\'s yours.',
'Θα στείλουμε έναν κωδικό σε αυτή τη διεύθυνση για να επιβεβαιώσουμε ότι είναι δική σου.',
],
'account.email_confirm' => ['Confirm', 'Επιβεβαίωση'],
'account.email_same' => ['That\'s already your current email.', 'Αυτό είναι ήδη το τρέχον email σου.'],
'account.email_taken' => [
'That email address is already in use.',
'Αυτή η διεύθυνση email χρησιμοποιείται ήδη.',
],
'account.email_changed' => ['Your email was changed.', 'Το email σου άλλαξε.'],
// ── Order history (account/orders/index.blade.php, account/orders/show.blade.php) ──
'orders.empty' => ['You have no orders yet.', 'Δεν έχεις παραγγελίες ακόμα.'],
'orders.shop_now' => ['Shop now', 'Αγόρασε τώρα'],
'orders.date' => ['Date', 'Ημερομηνία'],
'orders.number' => ['Order', 'Παραγγελία'],
'orders.status' => ['Status', 'Κατάσταση'],
'orders.total' => ['Total', 'Σύνολο'],
'orders.view' => ['View', 'Προβολή'],
'orders.view_order' => ['View order :number', 'Προβολή παραγγελίας :number'],
'orders.order_title' => ['Order :number', 'Παραγγελία :number'],
'orders.back' => ['Back to orders', 'Πίσω στις παραγγελίες'],
'orders.payment' => ['Payment method', 'Τρόπος πληρωμής'],
'orders.shipping_method' => ['Shipping method', 'Τρόπος αποστολής'],
'orders.tracking' => ['Tracking', 'Παρακολούθηση αποστολής'],
'orders.items' => ['Items', 'Προϊόντα'],
'orders.subtotal' => ['Subtotal', 'Μερικό σύνολο'],
'orders.discount' => ['Discount', 'Έκπτωση'],
'orders.shipping' => ['Shipping', 'Μεταφορικά'],
'orders.tax' => ['Tax', 'ΦΠΑ'],
'orders.shipping_to' => ['Shipping to', 'Αποστολή σε'],
'orders.billing' => ['Billing details', 'Στοιχεία τιμολόγησης'],
// ── Contact details (components/contact-details.blade.php) ──
'contact.address' => ['Address', 'Διεύθυνση'],
'contact.phone' => ['Phone', 'Τηλέφωνο'],
// ── Contact form (contact.blade.php, ContactController, emails.contact-confirmation) ──
'contact.sent' => [
'Your message was sent — we\'ll get back to you soon.',
'Το μήνυμά σου στάλθηκε — θα σου απαντήσουμε σύντομα.',
],
'contact.send_failed' => [
'Something went wrong sending your message. Please try again.',
'Κάτι πήγε στραβά κατά την αποστολή. Παρακαλούμε δοκίμασε ξανά.',
],
'contact.too_many' => [
'Too many messages sent. Please wait a while before trying again.',
'Στάλθηκαν πολλά μηνύματα. Περίμενε λίγο πριν ξαναδοκιμάσεις.',
],
'contact.confirmation_subject' => ['We received your message', 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_preheader' => [
'Thanks for reaching out — here\'s a copy of your message.',
'Ευχαριστούμε για την επικοινωνία — εδώ είναι ένα αντίγραφο του μηνύματός σου.',
],
'contact.confirmation_heading' => ['We received your message', 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_body' => [
'Thanks for getting in touch. We\'ll reply as soon as we can.',
'Ευχαριστούμε που επικοινώνησες μαζί μας. Θα απαντήσουμε το συντομότερο δυνατό.',
],
'contact.confirmation_footer' => [
'This is a copy of the message you sent us.',
'Αυτό είναι ένα αντίγραφο του μηνύματος που μας έστειλες.',
],
// ── Product page — custom fields, add-to-cart failure (product/show.blade.php,
// components/product-custom-fields.blade.php) ─────────────
'product.personalize' => ['Personalize', 'Εξατομίκευση'],
'product.custom_field_photo_hint' => ['Max file size: :size MB.', 'Μέγιστο μέγεθος αρχείου: :size MB.'],
'product.custom_field_uploading' => ['Uploading…', 'Μεταφόρτωση…'],
'product.custom_field_upload_failed' => [
'Upload failed. Please try again.',
'Η μεταφόρτωση απέτυχε. Παρακαλούμε δοκίμασε ξανά.',
],
'product.add_to_cart_failed' => [
'{0} Sorry, that\'s out of stock|{1} Only :count left in stock|[2,*] Only :count left in stock',
'{0} Λυπούμαστε, εξαντλήθηκε|{1} Απομένει μόνο :count κομμάτι|[2,*] Απομένουν μόνο :count κομμάτια',
],
// ── Wishlist (components/wishlist-button.blade.php, wishlist/guest.blade.php, wishlist/list.blade.php) ──
'wishlist.add' => ['Add to wishlist', 'Προσθήκη στη λίστα επιθυμιών'],
'wishlist.remove' => ['Remove from wishlist', 'Αφαίρεση από τη λίστα επιθυμιών'],
'wishlist.added' => ['Added to wishlist', 'Προστέθηκε στη λίστα επιθυμιών'],
'wishlist.removed' => ['Removed from wishlist', 'Αφαιρέθηκε από τη λίστα επιθυμιών'],
'wishlist.empty' => ['Your wishlist is empty.', 'Η λίστα επιθυμιών σου είναι άδεια.'],
'wishlist.guest_hint' => [
'Log in to keep your wishlist across devices.',
'Συνδέσου για να κρατήσεις τη λίστα επιθυμιών σου σε όλες τις συσκευές.',
],
'wishlist.remove_named' => ['Remove :name from wishlist', 'Αφαίρεση :name από τη λίστα επιθυμιών'],
'wishlist.remove_short' => ['Remove', 'Αφαίρεση'],
];
}
}
@@ -1,268 +0,0 @@
<?php
namespace Modules\Core\Localization\Services;
/**
* Default storefront UI label translations (group `storefront`), seeded by
* Modules\Core\Command\InstallLunarCommand. Kept as its own class, separate from
* the seeding logic, so the actual label list can be scanned/diffed without wading
* through the upsert mechanics — see InstallLunarCommand::seedStorefrontLabels()
* for how (and how safely) these get written.
*/
class StorefrontLabels
{
/**
* @return array<string, array<string, string>> keyed by `group.key` dot-notation,
* each value a locale => text map (`en`/`el`).
*/
public static function all(): array
{
return [
'nav.home' => ['en' => 'Home', 'el' => 'Αρχική'],
'nav.products' => ['en' => 'Products', 'el' => 'Προϊόντα'],
'nav.cart' => ['en' => 'Cart', 'el' => 'Καλάθι'],
'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'],
'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'],
'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'],
'nav.close' => ['en' => 'Close', 'el' => 'Κλείσιμο'],
'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'],
'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'],
'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
'cart.remove' => ['en' => 'Remove', 'el' => 'Αφαίρεση'],
'product.add_to_cart' => ['en' => 'Add to Cart', 'el' => 'Προσθήκη στο Καλάθι'],
'product.out_of_stock' => ['en' => 'Out of Stock', 'el' => 'Εξαντλήθηκε'],
'product.price' => ['en' => 'Price', 'el' => 'Τιμή'],
'product.description' => ['en' => 'Description', 'el' => 'Περιγραφή'],
'product.no_image' => ['en' => 'No image', 'el' => 'Χωρίς εικόνα'],
'product.read_more' => ['en' => 'Read more', 'el' => 'Περισσότερα'],
'product.reviews' => ['en' => 'Reviews', 'el' => 'Αξιολογήσεις'],
'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'],
'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'],
'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'],
'search.results_for' => ['en' => 'Search results for ', 'el' => 'Αποτελέσματα αναζήτησης για '],
'customer_reviews' => [
'en' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
'el' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
],
'pagination.nav_label' => ['en' => 'Pagination', 'el' => 'Σελιδοποίηση'],
'pagination.next' => ['en' => 'Next page', 'el' => 'Επόμενη σελίδα'],
'pagination.previous' => ['en' => 'Previous page', 'el' => 'Προηγούμενη σελίδα'],
'pagination.page' => ['en' => 'Page :page', 'el' => 'Σελίδα :page'],
'review.rating' => ['en' => 'Rating', 'el' => 'Βαθμολογία'],
'review.write_label' => ['en' => 'Write a review', 'el' => 'Γράψε μια αξιολόγηση'],
'review.name' => ['en' => 'Name', 'el' => 'Όνομα'],
'review.name_optional' => ['en' => 'Optional', 'el' => 'Προαιρετικό'],
'review.email' => ['en' => 'Email', 'el' => 'Email'],
'review.email_not_published' => ['en' => 'Will not be published', 'el' => 'Δεν θα δημοσιευτεί'],
'review.save_info' => [
'en' => 'Save my name and email for the next time I comment.',
'el' => 'Αποθήκευσε το όνομα και το email μου για την επόμενη φορά που θα σχολιάσω.',
],
'review.submit' => ['en' => 'Submit', 'el' => 'Υποβολή'],
'review.stars_count' => ['en' => '{1} :count star|[2,*] :count stars', 'el' => '{1} :count αστέρι|[2,*] :count αστέρια'],
'review.no_reviews_yet' => ['en' => 'No reviews yet.', 'el' => 'Δεν υπάρχουν αξιολογήσεις ακόμα.'],
'review.write_first' => ['en' => 'Write the first review', 'el' => 'Γράψε την πρώτη'],
'review.write_new' => ['en' => 'Add a review', 'el' => 'Πρόσθεσε μια'],
'review.for_product' => ['en' => 'review for ":name"', 'el' => 'αξιολόγηση για το «:name»'],
'shop.showing_results' => [
'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
],
'shop.all_products' => ['en' => 'All Products', 'el' => 'Όλα τα Προϊόντα'],
'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'],
'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'],
'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'],
'shop.sort_price_asc' => ['en' => 'Price: Low to High', 'el' => 'Τιμή: Αύξουσα'],
'shop.sort_price_desc' => ['en' => 'Price: High to Low', 'el' => 'Τιμή: Φθίνουσα'],
'shop.sort_newest' => ['en' => 'Newest', 'el' => 'Νεότερα'],
'shop.no_products' => ['en' => 'No products found in this category.', 'el' => 'Δεν βρέθηκαν προϊόντα σε αυτή την κατηγορία.'],
'shop.search_label' => ['en' => 'Search products', 'el' => 'Αναζήτηση προϊόντων'],
'shop.search_placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτησε προϊόντα…'],
'shop.filter_price' => ['en' => 'Filter by price', 'el' => 'Φίλτρο τιμής'],
'shop.price_min' => ['en' => 'Min price', 'el' => 'Ελάχιστη τιμή'],
'shop.price_max' => ['en' => 'Max price', 'el' => 'Μέγιστη τιμή'],
'shop.reset' => ['en' => 'Reset', 'el' => 'Επαναφορά'],
'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'],
'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'],
'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'],
// Passwordless login (Auth\Services\UserOtpService)
'auth.login_intro' => [
'en' => 'Enter your email and we\'ll send you a code to sign in — no password needed.',
'el' => 'Γράψε το email σου και θα σου στείλουμε έναν κωδικό σύνδεσης — δεν χρειάζεται κωδικός πρόσβασης.',
],
'auth.email' => ['en' => 'Email', 'el' => 'Email'],
'auth.terms_notice' => [
'en' => 'By continuing, you accept the <a href=":terms">Terms of Use</a> and have read the <a href=":privacy">Privacy Policy</a>.',
'el' => 'Συνεχίζοντας, αποδέχεσαι τους <a href=":terms">Όρους Χρήσης</a> και έχεις διαβάσει την <a href=":privacy">Πολιτική Απορρήτου</a>.',
],
'auth.send_code' => ['en' => 'Send code', 'el' => 'Αποστολή κωδικού'],
'auth.enter_code' => ['en' => 'Enter the code', 'el' => 'Εισάγετε τον κωδικό'],
'auth.code_sent_to' => ['en' => 'We sent a code to', 'el' => 'Στείλαμε έναν κωδικό στο'],
'auth.code' => ['en' => 'Code', 'el' => 'Κωδικός'],
'auth.resend_code' => ['en' => 'Resend code', 'el' => 'Επαναποστολή κωδικού'],
'auth.code_resent' => ['en' => 'A new code was sent.', 'el' => 'Στάλθηκε νέος κωδικός.'],
'auth.change_email' => ['en' => 'Use a different email', 'el' => 'Χρήση διαφορετικού email'],
'auth.invalid_code' => ['en' => 'That code is invalid or has expired.', 'el' => 'Ο κωδικός δεν είναι έγκυρος ή έχει λήξει.'],
'auth.too_many_codes' => [
'en' => 'Too many attempts. Please wait a few minutes and try again.',
'el' => 'Πολλές προσπάθειες. Περίμενε λίγα λεπτά και ξαναδοκίμασε.',
],
'auth.captcha_failed' => [
'en' => 'Please complete the captcha and try again.',
'el' => 'Παρακαλούμε ολοκλήρωσε το captcha και ξαναδοκίμασε.',
],
// Account profile page (account/show.blade.php)
'account.nav_profile' => ['en' => 'Profile', 'el' => 'Προφίλ'],
'account.nav_orders' => ['en' => 'Orders', 'el' => 'Παραγγελίες'],
'account.nav_wishlist' => ['en' => 'Wishlist', 'el' => 'Λίστα επιθυμιών'],
'account.email_heading' => ['en' => 'Login email', 'el' => 'Email σύνδεσης'],
'account.email_change' => ['en' => 'Change email', 'el' => 'Αλλαγή email'],
'account.details_heading' => ['en' => 'Your details', 'el' => 'Τα στοιχεία σου'],
'account.first_name' => ['en' => 'First name', 'el' => 'Όνομα'],
'account.last_name' => ['en' => 'Last name', 'el' => 'Επώνυμο'],
'account.invoice' => ['en' => 'I need an invoice', 'el' => 'Χρειάζομαι τιμολόγιο'],
'account.company_name' => ['en' => 'Company name', 'el' => 'Επωνυμία εταιρείας'],
'account.tax_identifier' => ['en' => 'Tax ID (VAT)', 'el' => 'ΑΦΜ'],
'account.address_heading' => ['en' => 'Address', 'el' => 'Διεύθυνση'],
'account.line_one' => ['en' => 'Address', 'el' => 'Διεύθυνση'],
'account.city' => ['en' => 'City', 'el' => 'Πόλη'],
'account.postcode' => ['en' => 'Postcode', 'el' => 'Ταχυδρομικός κώδικας'],
'account.state' => ['en' => 'Region', 'el' => 'Περιοχή'],
'account.state_placeholder' => ['en' => 'Select a region', 'el' => 'Επίλεξε περιοχή'],
'account.phone' => ['en' => 'Phone', 'el' => 'Τηλέφωνο'],
'account.emails_heading' => ['en' => 'Emails', 'el' => 'Ειδοποιήσεις email'],
'account.recovery_consent' => [
'en' => 'Email me a reminder if I don\'t finish my order',
'el' => 'Στείλε μου υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
],
'account.save' => ['en' => 'Save changes', 'el' => 'Αποθήκευση'],
'account.saved' => ['en' => 'Your details were saved.', 'el' => 'Τα στοιχεία σου αποθηκεύτηκαν.'],
'account.delete_heading' => ['en' => 'Delete account', 'el' => 'Διαγραφή λογαριασμού'],
'account.delete_text' => [
'en' => 'This permanently deletes your account and personal data. This cannot be undone.',
'el' => 'Αυτό διαγράφει οριστικά τον λογαριασμό και τα προσωπικά σου δεδομένα. Δεν μπορεί να αναιρεθεί.',
],
'account.delete' => ['en' => 'Delete my account', 'el' => 'Διαγραφή λογαριασμού'],
'account.delete_confirm_heading' => ['en' => 'Are you sure?', 'el' => 'Είσαι σίγουρος/η;'],
'account.delete_confirm_text' => [
'en' => 'This cannot be undone. Your account and personal data will be permanently deleted.',
'el' => 'Αυτό δεν μπορεί να αναιρεθεί. Ο λογαριασμός και τα προσωπικά σου δεδομένα θα διαγραφούν οριστικά.',
],
'account.delete_confirm' => ['en' => 'Yes, delete my account', 'el' => 'Ναι, διαγραφή λογαριασμού'],
'account.delete_cancel' => ['en' => 'Cancel', 'el' => 'Ακύρωση'],
'account.deletion_requested' => [
'en' => 'Your account deletion has been requested.',
'el' => 'Ζητήθηκε η διαγραφή του λογαριασμού σου.',
],
// Account email-change flow (account/email.blade.php, account/email-code.blade.php)
'account.email_change_heading' => ['en' => 'Change your email', 'el' => 'Αλλαγή email'],
'account.email_current' => ['en' => 'Your current email is', 'el' => 'Το τρέχον email σου είναι'],
'account.email_new' => ['en' => 'New email', 'el' => 'Νέο email'],
'account.email_new_hint' => [
'en' => 'We\'ll send a code to this address to confirm it\'s yours.',
'el' => 'Θα στείλουμε έναν κωδικό σε αυτή τη διεύθυνση για να επιβεβαιώσουμε ότι είναι δική σου.',
],
'account.email_confirm' => ['en' => 'Confirm', 'el' => 'Επιβεβαίωση'],
'account.email_same' => [
'en' => 'That\'s already your current email.',
'el' => 'Αυτό είναι ήδη το τρέχον email σου.',
],
'account.email_taken' => [
'en' => 'That email address is already in use.',
'el' => 'Αυτή η διεύθυνση email χρησιμοποιείται ήδη.',
],
'account.email_changed' => ['en' => 'Your email was changed.', 'el' => 'Το email σου άλλαξε.'],
// Order history (account/orders/index.blade.php, account/orders/show.blade.php)
'orders.empty' => ['en' => 'You have no orders yet.', 'el' => 'Δεν έχεις παραγγελίες ακόμα.'],
'orders.shop_now' => ['en' => 'Shop now', 'el' => 'Αγόρασε τώρα'],
'orders.date' => ['en' => 'Date', 'el' => 'Ημερομηνία'],
'orders.number' => ['en' => 'Order', 'el' => 'Παραγγελία'],
'orders.status' => ['en' => 'Status', 'el' => 'Κατάσταση'],
'orders.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
'orders.view' => ['en' => 'View', 'el' => 'Προβολή'],
'orders.view_order' => ['en' => 'View order :number', 'el' => 'Προβολή παραγγελίας :number'],
'orders.order_title' => ['en' => 'Order :number', 'el' => 'Παραγγελία :number'],
'orders.back' => ['en' => 'Back to orders', 'el' => 'Πίσω στις παραγγελίες'],
'orders.payment' => ['en' => 'Payment method', 'el' => 'Τρόπος πληρωμής'],
'orders.shipping_method' => ['en' => 'Shipping method', 'el' => 'Τρόπος αποστολής'],
'orders.tracking' => ['en' => 'Tracking', 'el' => 'Παρακολούθηση αποστολής'],
'orders.items' => ['en' => 'Items', 'el' => 'Προϊόντα'],
'orders.subtotal' => ['en' => 'Subtotal', 'el' => 'Μερικό σύνολο'],
'orders.discount' => ['en' => 'Discount', 'el' => 'Έκπτωση'],
'orders.shipping' => ['en' => 'Shipping', 'el' => 'Μεταφορικά'],
'orders.tax' => ['en' => 'Tax', 'el' => 'ΦΠΑ'],
'orders.shipping_to' => ['en' => 'Shipping to', 'el' => 'Αποστολή σε'],
'orders.billing' => ['en' => 'Billing details', 'el' => 'Στοιχεία τιμολόγησης'],
// Contact form (contact.blade.php, ContactController, emails.contact-confirmation)
'contact.sent' => [
'en' => 'Your message was sent — we\'ll get back to you soon.',
'el' => 'Το μήνυμά σου στάλθηκε — θα σου απαντήσουμε σύντομα.',
],
'contact.send_failed' => [
'en' => 'Something went wrong sending your message. Please try again.',
'el' => 'Κάτι πήγε στραβά κατά την αποστολή. Παρακαλούμε δοκίμασε ξανά.',
],
'contact.too_many' => [
'en' => 'Too many messages sent. Please wait a while before trying again.',
'el' => 'Στάλθηκαν πολλά μηνύματα. Περίμενε λίγο πριν ξαναδοκιμάσεις.',
],
'contact.confirmation_subject' => ['en' => 'We received your message', 'el' => 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_preheader' => [
'en' => 'Thanks for reaching out — here\'s a copy of your message.',
'el' => 'Ευχαριστούμε για την επικοινωνία — εδώ είναι ένα αντίγραφο του μηνύματός σου.',
],
'contact.confirmation_heading' => ['en' => 'We received your message', 'el' => 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_body' => [
'en' => 'Thanks for getting in touch. We\'ll reply as soon as we can.',
'el' => 'Ευχαριστούμε που επικοινώνησες μαζί μας. Θα απαντήσουμε το συντομότερο δυνατό.',
],
'contact.confirmation_footer' => [
'en' => 'This is a copy of the message you sent us.',
'el' => 'Αυτό είναι ένα αντίγραφο του μηνύματος που μας έστειλες.',
],
// Product page — custom fields, add-to-cart failure (product/show.blade.php,
// components/product-custom-fields.blade.php)
'product.personalize' => ['en' => 'Personalize', 'el' => 'Εξατομίκευση'],
'product.custom_field_photo_hint' => [
'en' => 'Max file size: :size MB.',
'el' => 'Μέγιστο μέγεθος αρχείου: :size MB.',
],
'product.custom_field_uploading' => ['en' => 'Uploading…', 'el' => 'Μεταφόρτωση…'],
'product.custom_field_upload_failed' => [
'en' => 'Upload failed. Please try again.',
'el' => 'Η μεταφόρτωση απέτυχε. Παρακαλούμε δοκίμασε ξανά.',
],
'product.add_to_cart_failed' => [
'en' => '{0} Sorry, that\'s out of stock|{1} Only :count left in stock|[2,*] Only :count left in stock',
'el' => '{0} Λυπούμαστε, εξαντλήθηκε|{1} Απομένει μόνο :count κομμάτι|[2,*] Απομένουν μόνο :count κομμάτια',
],
// Reviews (components/review-form.blade.php, review-card.blade.php, product/show.blade.php)
'review.rating_required' => ['en' => 'Please select a rating.', 'el' => 'Παρακαλούμε επίλεξε βαθμολογία.'],
'review.reply' => ['en' => 'Reply', 'el' => 'Απάντηση'],
'review.thank_you' => [
'en' => 'Thanks for your review!',
'el' => 'Ευχαριστούμε για την αξιολόγησή σου!',
],
// Wishlist (components/wishlist-button.blade.php, wishlist/guest.blade.php, wishlist/list.blade.php)
'wishlist.add' => ['en' => 'Add to wishlist', 'el' => 'Προσθήκη στη λίστα επιθυμιών'],
'wishlist.remove' => ['en' => 'Remove from wishlist', 'el' => 'Αφαίρεση από τη λίστα επιθυμιών'],
'wishlist.added' => ['en' => 'Added to wishlist', 'el' => 'Προστέθηκε στη λίστα επιθυμιών'],
'wishlist.removed' => ['en' => 'Removed from wishlist', 'el' => 'Αφαιρέθηκε από τη λίστα επιθυμιών'],
'wishlist.empty' => ['en' => 'Your wishlist is empty.', 'el' => 'Η λίστα επιθυμιών σου είναι άδεια.'],
'wishlist.guest_hint' => [
'en' => 'Log in to keep your wishlist across devices.',
'el' => 'Συνδέσου για να κρατήσεις τη λίστα επιθυμιών σου σε όλες τις συσκευές.',
],
'wishlist.remove_named' => ['en' => 'Remove :name from wishlist', 'el' => 'Αφαίρεση :name από τη λίστα επιθυμιών'],
'wishlist.remove_short' => ['en' => 'Remove', 'el' => 'Αφαίρεση'],
];
}
}
+11
View File
@@ -44,6 +44,17 @@ class NotificationRegistry
if (isset($event->delaySeconds) && $event->delaySeconds > 0) { if (isset($event->delaySeconds) && $event->delaySeconds > 0) {
$notification->delay($event->delaySeconds); $notification->delay($event->delaySeconds);
} }
// Every order-notification event carries ->order, whose
// ->meta['locale'] was saved at checkout (Checkout\
// Services\CheckoutService::initiatePayment()) — the
// request that actually sends this may have no locale of
// its own (a payment webhook, a queued job, a staff
// action from Filament), so this is the only reliable
// source. Falls back to the app default for an order
// placed before this existed.
if (isset($event->order) && $locale = $event->order->meta['locale'] ?? null) {
$notification->locale($locale);
}
$notification->notifiable()->notify($notification); $notification->notifiable()->notify($notification);
} catch (Throwable $e) { } catch (Throwable $e) {
report($e); report($e);
@@ -7,8 +7,23 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade; use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderCaptured; use Modules\Core\Order\Events\OrderCaptured;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Support\OrderReferenceDisplay; use Modules\Core\Order\Support\OrderReferenceDisplay;
/**
* "Payment captured" is only a meaningful, distinct update for a bank
* transfer order — placing that order and paying for it are genuinely two
* separate moments (Order::paid stays false at checkout; a shopper is
* told their order is confirmed and awaiting a wire, then separately told
* once staff mark it paid). For every other payment method (card,
* authorize-then-capture, COD), placing the order already means the
* shopper did everything they need to on their end — a card payment is
* captured in the very same request that places the order, so a second
* "payment captured" email would just repeat what OrderPlacedNotification
* already said. See OrderStatusFlow's own docblock for why payment method
* never affects the order's status SEQUENCE, only this kind of business
* decision about which events actually matter to the shopper.
*/
class OrderCapturedNotification extends BaseNotification class OrderCapturedNotification extends BaseNotification
{ {
public function __construct(private readonly OrderCaptured $event) {} public function __construct(private readonly OrderCaptured $event) {}
@@ -25,6 +40,10 @@ class OrderCapturedNotification extends BaseNotification
public function via(object $notifiable): array public function via(object $notifiable): array
{ {
if (! app(OrderStatusFlow::class)->isBankTransfer($this->event->order)) {
return [];
}
return ['mail']; return ['mail'];
} }
@@ -46,6 +65,7 @@ class OrderCapturedNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('Payment captured for your order :reference', ['reference' => $reference])) ->subject(__('Payment captured for your order :reference', ['reference' => $reference]))
->view('core::order.notifications.captured', [ ->view('core::order.notifications.captured', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
'amount' => $this->event->transaction->amount->formatted, 'amount' => $this->event->transaction->amount->formatted,
]); ]);
@@ -46,6 +46,7 @@ class OrderCompletedNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('Your order :reference is complete', ['reference' => $reference])) ->subject(__('Your order :reference is complete', ['reference' => $reference]))
->view('core::order.notifications.completed', [ ->view('core::order.notifications.completed', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
]); ]);
} }
@@ -46,6 +46,7 @@ class OrderDeliveredNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('Your order :reference has been delivered', ['reference' => $reference])) ->subject(__('Your order :reference has been delivered', ['reference' => $reference]))
->view('core::order.notifications.delivered', [ ->view('core::order.notifications.delivered', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
]); ]);
} }
@@ -51,6 +51,7 @@ class OrderDispatchedNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('Your order :reference is on its way', ['reference' => $reference])) ->subject(__('Your order :reference is on its way', ['reference' => $reference]))
->view('core::order.notifications.dispatched', [ ->view('core::order.notifications.dispatched', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
]); ]);
} }
@@ -56,6 +56,7 @@ class OrderPickupReadyNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('Your order :reference is ready for pickup', ['reference' => $reference])) ->subject(__('Your order :reference is ready for pickup', ['reference' => $reference]))
->view('core::order.notifications.pickup-ready', [ ->view('core::order.notifications.pickup-ready', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
]); ]);
} }
@@ -57,6 +57,7 @@ class OrderPlacedNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('Your order :reference is confirmed', ['reference' => $reference])) ->subject(__('Your order :reference is confirmed', ['reference' => $reference]))
->view('core::order.notifications.placed', [ ->view('core::order.notifications.placed', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
'total' => $order->total->formatted, 'total' => $order->total->formatted,
'lines' => $order->lines, 'lines' => $order->lines,
@@ -46,6 +46,7 @@ class OrderRefundedNotification extends BaseNotification
return (new MailMessage) return (new MailMessage)
->subject(__('A refund has been issued for your order :reference', ['reference' => $reference])) ->subject(__('A refund has been issued for your order :reference', ['reference' => $reference]))
->view('core::order.notifications.refunded', [ ->view('core::order.notifications.refunded', [
'order' => $order,
'reference' => $reference, 'reference' => $reference,
'amount' => $this->event->transaction->amount->formatted, 'amount' => $this->event->transaction->amount->formatted,
]); ]);
@@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade; use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderStatusUpdated; use Modules\Core\Order\Events\OrderStatusUpdated;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Support\OrderReferenceDisplay; use Modules\Core\Order\Support\OrderReferenceDisplay;
class OrderStatusUpdatedNotification extends BaseNotification class OrderStatusUpdatedNotification extends BaseNotification
@@ -30,6 +31,22 @@ class OrderStatusUpdatedNotification extends BaseNotification
* NotificationRegistry, so without this the customer would get two * NotificationRegistry, so without this the customer would get two
* emails for that one transition. Returning no channels is the * emails for that one transition. Returning no channels is the
* standard Laravel way to suppress a notification outright. * standard Laravel way to suppress a notification outright.
*
* Also suppressed for the order's very first transition off
* 'awaiting_payment' — for every payment method except bank transfer,
* that transition happens in the SAME request as checkout itself
* (Modules\Core\Order\Services\OrderPaymentResolutionService::
* resolveCaptureOrAuthorization()/resolveDeferredPayment(), both
* called synchronously alongside Checkout\Events\OrderPlaced), so it's
* not new information — the shopper was already told their order is
* confirmed by Modules\Core\Order\Notifications\
* OrderPlacedNotification moments earlier. A bank transfer order is
* the one genuine exception: it sits at 'awaiting_payment' until
* staff separately mark it paid (Modules\Core\Order\Services\
* OrderFulfillmentService::markPaid()), a real, later event the
* shopper does need to hear about — see OrderStatusFlow's own
* docblock for why bank transfer is the only payment method where
* placement and payment aren't the same moment.
*/ */
public function via(object $notifiable): array public function via(object $notifiable): array
{ {
@@ -37,6 +54,11 @@ class OrderStatusUpdatedNotification extends BaseNotification
return []; return [];
} }
if ($this->event->previousStatus === 'awaiting_payment'
&& ! app(OrderStatusFlow::class)->isBankTransfer($this->event->order)) {
return [];
}
return ['mail']; return ['mail'];
} }
@@ -54,12 +76,17 @@ class OrderStatusUpdatedNotification extends BaseNotification
$order = $this->event->order; $order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order); $reference = OrderReferenceDisplay::resolve($order);
$statusLabel = config("lunar.orders.statuses.{$order->status}.label", $order->status);
return (new MailMessage) return (new MailMessage)
->subject(__('Your order :reference has been updated', ['reference' => $reference])) ->subject(__('Your order :reference is now :status', [
->view('core::order.notifications.status-updated', [
'reference' => $reference, 'reference' => $reference,
'statusLabel' => config("lunar.orders.statuses.{$order->status}.label", $order->status), 'status' => $statusLabel,
]))
->view('core::order.notifications.status-updated', [
'order' => $order,
'reference' => $reference,
'statusLabel' => $statusLabel,
]); ]);
} }
} }
+19 -5
View File
@@ -10,6 +10,7 @@ use Livewire\Mechanisms\ComponentRegistry;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates; use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
use Lunar\Shipping\Interfaces\ShippingMethodManagerInterface;
use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Cart\Events\CartCleared; use Modules\Core\Cart\Events\CartCleared;
use Modules\Core\Cart\Events\CartLineAdded; use Modules\Core\Cart\Events\CartLineAdded;
@@ -25,9 +26,11 @@ use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
use Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver;
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions; use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
use Modules\Core\Shipping\Support\FulfillmentType; use Modules\Core\Shipping\Support\FulfillmentType;
use Modules\Core\Shipping\Support\ShippingManager;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
class ShippingServiceProvider extends ServiceProvider class ShippingServiceProvider extends ServiceProvider
@@ -81,10 +84,9 @@ class ShippingServiceProvider extends ServiceProvider
// resolveCarrier() for the same lookup pattern already used to // resolveCarrier() for the same lookup pattern already used to
// resolve a carrier driver from it). // resolve a carrier driver from it).
// //
// Resolves via Modules\Core\Shipping\Support\FulfillmentType (driver- // Resolves via Modules\Core\Shipping\Support\FulfillmentType
// declared for acs/box-now, merchant-configured data['fulfillment_type'] // (hardcoded per driver — see that class's own docblock) rather
// fallback for table-rate-shipping's generic drivers) rather than // than through a ShippingMethod::macro('isStorePickup', ...) —
// through a ShippingMethod::macro('isStorePickup', ...) —
// Lunar\Base\Traits\HasModelExtending::__callStatic() (used by // Lunar\Base\Traits\HasModelExtending::__callStatic() (used by
// Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel) // Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel)
// intercepts EVERY unmatched static call, including macro() // intercepts EVERY unmatched static call, including macro()
@@ -119,10 +121,22 @@ class ShippingServiceProvider extends ServiceProvider
// Deferred: the Shipping facade resolves a binding registered in // Deferred: the Shipping facade resolves a binding registered in
// lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(), // lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(),
// and provider boot order between packages isn't guaranteed. // and provider boot order between packages isn't guaranteed — in
// fact composer's package discovery registers boboko/core BEFORE
// lunarphp/table-rate-shipping (alphabetical), so a bind() in this
// provider's own register()/boot() runs first and gets silently
// overwritten by the vendor's own later bind() of the same
// abstract. booted() is the first point every provider's
// register()/boot() has definitely already run, so this is also
// where the ShippingMethodManagerInterface override belongs (must
// run before the Shipping::extend() calls below, which resolve —
// and the facade then CACHES — whatever's bound at that moment).
$this->app->booted(function () { $this->app->booted(function () {
$this->app->bind(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class));
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class)); Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class)); Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
Shipping::extend('store-pickup', fn ($app) => $app->make(StorePickupRateDriver::class));
$this->app->make(ConsoleSchedule::class) $this->app->make(ConsoleSchedule::class)
->job(new WarmAcsAreaCacheJob) ->job(new WarmAcsAreaCacheJob)
+33
View File
@@ -2,15 +2,48 @@
namespace Modules\Core\Providers; namespace Modules\Core\Providers;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Modules\Core\Store\Events\StoreDetailsUpdated; use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Listeners\FlushStoreDetailsCache; use Modules\Core\Store\Listeners\FlushStoreDetailsCache;
use Modules\Core\Store\Services\StoreDetailsService;
use Symfony\Component\Mime\Address;
class StoreServiceProvider extends ServiceProvider class StoreServiceProvider extends ServiceProvider
{ {
public function boot(): void public function boot(): void
{ {
Event::listen(StoreDetailsUpdated::class, FlushStoreDetailsCache::class); Event::listen(StoreDetailsUpdated::class, FlushStoreDetailsCache::class);
Event::listen(MessageSending::class, [$this, 'applyMailFromName']);
}
/**
* Renames the From header's display name to the store's own Store
* Details setting, when filled in — leaves the address itself alone
* (a deliverability/domain-authentication concern, not something a
* merchant should freely edit via a text field) and leaves an
* EXPLICIT sender alone too: anything whose From address isn't
* config('mail.from.address') deliberately chose a different sender
* (e.g. a mailable/notification that calls ->from() itself), which
* this provider has no business second-guessing.
* StoreDetailsService::current() is forever-cached (see its own
* docblock), so this costs nothing extra per send.
*/
public function applyMailFromName(MessageSending $event): void
{
$name = app(StoreDetailsService::class)->current()->mail_from_name;
if (blank($name)) {
return;
}
$from = $event->message->getFrom()[0] ?? null;
if ($from === null || $from->getAddress() !== config('mail.from.address')) {
return;
}
$event->message->from(new Address($from->getAddress(), $name));
} }
} }
+13 -1
View File
@@ -9,16 +9,19 @@ use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing; use Modules\Core\Shipping\Concerns\CachesLivePricing;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName; use Modules\Core\Shipping\Support\ShippingMethodName;
use Modules\Core\Shipping\Support\WeightCalculator; use Modules\Core\Shipping\Support\WeightCalculator;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType, SupportsCashCollection
{ {
use ResolvesFixedPricing; use ResolvesFixedPricing;
use CachesLivePricing; use CachesLivePricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate; public ShippingRate $shippingRate;
@@ -37,6 +40,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
return 'carrier'; return 'carrier';
} }
public function collectsCash(): bool
{
return true;
}
public function description(): string public function description(): string
{ {
return 'Live rate quote from ACS Courier.'; return 'Live rate quote from ACS Courier.';
@@ -48,6 +56,10 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
$shippingMethod = $shippingRate->shippingMethod; $shippingMethod = $shippingRate->shippingMethod;
$cart = $shippingOptionRequest->cart; $cart = $shippingOptionRequest->cart;
if ($this->cartHasExcludedProducts($shippingRate, $cart)) {
return null;
}
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') { if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart); return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
} }
@@ -6,6 +6,7 @@ use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
@@ -18,6 +19,7 @@ use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
{ {
use ResolvesFixedPricing; use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate; public ShippingRate $shippingRate;
@@ -38,6 +40,10 @@ class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{ {
if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) {
return null;
}
return $this->resolveFixedPrice( return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate, $shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod, $shippingOptionRequest->shippingRate->shippingMethod,
@@ -0,0 +1,75 @@
<?php
namespace Modules\Core\Shipping\Carriers\StorePickup;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
/**
* First-party replacement for lunarphp/table-rate-shipping's own Collection
* driver — same shape as AcsRateDriver/BoxNowRateDriver, unlike the vendor
* generic drivers (flat-rate/ship-by/free-shipping/collection), which this
* module no longer registers (see ShippingServiceProvider). Two reasons
* this exists rather than keeping the vendor driver:
*
* 1. Collection::resolve() reads $shippingMethod->name directly instead of
* through Modules\Core\Shipping\Support\ShippingMethodName::resolve() —
* ShippingMethod has no cast for that locale-keyed JSON column (see
* ShippingMethodName's own docblock), so the storefront showed the raw
* JSON blob as the option's name instead of the translated string.
* 2. Unambiguously store pickup, like ACS/Box Now are unambiguously
* carrier — implementing DeclaresFulfillmentType means this is a
* hardcoded fact about the driver, never a merchant configuration
* choice (see that contract's own docblock).
*
* No live pricing — there's no API for in-person pickup, just the method's
* own charge_by + price-break configuration (usually free), the same
* mechanism Box Now uses.
*/
class StorePickupRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
{
use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate;
public function name(): string
{
return 'Store Pickup';
}
public function fulfillmentType(): string
{
return 'store_pickup';
}
public function description(): string
{
return 'Collect your order in store.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) {
return null;
}
return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod,
$shippingOptionRequest->cart,
);
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Shipping\Concerns;
use Lunar\Models\Cart;
use Lunar\Models\Product;
use Lunar\Shipping\Models\ShippingRate;
/**
* A shipping zone can list specific products that can't go through it (see
* the Filament "Shipping Exclusions" relation manager on ShippingZoneResource)
* — a merchant's way of saying "this item physically can't be handled by
* this carrier/method," independent of price. lunarphp/table-rate-shipping's
* own generic drivers (FlatRate, ShipBy, FreeShipping, Collection) each
* check this before returning an option; every first-party driver in this
* module (ACS, Box Now, store pickup) shares it from here instead, so a
* merchant-configured exclusion is honored no matter which driver a
* shipping method uses — not just the vendor's generic ones. Call this
* FIRST in resolve(), before any pricing lookup (live or fixed): an
* excluded product should make the option disappear entirely, the same
* "return null" a driver already uses for "no rate matched."
*/
trait ExcludesRestrictedProducts
{
private function cartHasExcludedProducts(ShippingRate $shippingRate, Cart $cart): bool
{
$productIds = $cart->lines->load('purchasable')->pluck('purchasable.product_id');
return $shippingRate->shippingZone->shippingExclusions()
->whereHas('exclusions', function ($query) use ($productIds) {
$query->wherePurchasableType(Product::morphName())
->whereIn('purchasable_id', $productIds);
})->exists();
}
}
@@ -3,18 +3,14 @@
namespace Modules\Core\Shipping\Contracts; namespace Modules\Core\Shipping\Contracts;
/** /**
* Optional contract a shipping rate driver implements to declare whether * Every shipping rate driver this module registers implements this to
* it fulfils via carrier delivery or in-store pickup — e.g. * declare whether it fulfils via carrier delivery or in-store pickup —
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver and BoxNowRateDriver * Modules\Core\Shipping\Carriers\Acs\AcsRateDriver and BoxNowRateDriver
* are unambiguously carrier-only, so this is a hardcoded fact about the * are unambiguously carrier-only, Modules\Core\Shipping\Carriers\
* driver, not something a merchant should have to configure per row. * StorePickup\StorePickupRateDriver unambiguously store-pickup, so this
* * is a hardcoded fact about each driver, never something a merchant
* table-rate-shipping's own generic drivers (flat-rate, ship-by, * configures per row. See Modules\Core\Shipping\Support\FulfillmentType::
* free-shipping) don't implement this — they're genuinely ambiguous (a * resolve(), the single source of truth this feeds.
* merchant could configure one for either carrier delivery or store
* pickup), so Modules\Core\Shipping\Support\FulfillmentType::resolve()
* falls back to ShippingMethod.data['fulfillment_type'] (still merchant-
* overridable) only for drivers that don't implement this contract.
*/ */
interface DeclaresFulfillmentType interface DeclaresFulfillmentType
{ {
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Shipping\Contracts;
/**
* A shipping rate driver implements this to say a person is physically
* present at handoff to collect cash — true for a courier like
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver, false for an
* unattended parcel locker network like Modules\Core\Shipping\Carriers\
* BoxNow\BoxNowRateDriver, where no one is there to take payment. Checked
* by Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()
* for cash-on-delivery specifically (Modules\Core\Payment\Drivers\
* CashOnDeliveryPaymentDriver) — separate from Modules\Core\Shipping\
* Contracts\DeclaresFulfillmentType, which only distinguishes carrier vs.
* store_pickup and can't tell ACS and Box Now apart (both 'carrier').
*
* Not implemented at all means "no" — a driver with no opinion here
* (any of table-rate-shipping's own generic drivers, if one were ever
* re-added) is treated as not supporting cash collection, the safer
* default for a driver this module knows nothing about.
*/
interface SupportsCashCollection
{
public function collectsCash(): bool;
}
@@ -48,6 +48,6 @@ class ShippingMethodListExtension extends BaseExtension
->label('Type') ->label('Type')
->options(fn () => collect(Shipping::getSupportedDrivers()) ->options(fn () => collect(Shipping::getSupportedDrivers())
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()])) ->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
->default('flat-rate'); ->default('acs');
} }
} }
@@ -13,7 +13,6 @@ use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
use Lunar\Admin\Support\Forms\Components\TranslatedText; use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName; use Modules\Core\Shipping\Support\ShippingMethodName;
@@ -22,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,12 @@
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\Store\Events\StoreDetailsUpdated; use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Models\StoreDetails; use Modules\Core\Store\Models\StoreDetails;
@@ -35,6 +38,37 @@ class StoreDetailsService
); );
} }
/**
* Null for any non-bank-transfer order — the confirmation email only
* shows this block when there's actually a wire to send (see
* BankTransferPaymentDriver's own docblock for why a bank transfer
* order stays at 'awaiting_payment' until staff confirm the wire
* arrived). Null also when the store hasn't filled the field in for
* $locale, so the caller's own @if($bankTransferInstructions) guard
* covers both cases identically.
*
* bank_transfer_instructions is a TranslatedRichEditor field (see
* ManageStoreDetails), so translate() returns Filament's Tiptap JSON
* document structure for that locale, not a plain string —
* RichContentRenderer::make() is Filament's own converter from that
* structure to sanitized HTML (the same one the admin panel itself
* uses to render a RichEditor's content read-only).
*/
public function bankTransferInstructionsFor(Order $order, string $locale): ?string
{
if (! app(OrderStatusFlow::class)->isBankTransfer($order)) {
return null;
}
$content = $this->current()->translate('bank_transfer_instructions', $locale);
if (blank($content)) {
return null;
}
return RichContentRenderer::make($content)->toHtml();
}
public function update(array $attributes): StoreDetails public function update(array $attributes): StoreDetails
{ {
$storeDetails = $this->firstOrCreate(); $storeDetails = $this->firstOrCreate();
@@ -67,6 +101,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(),
]); ]);
} }