Feat: Adding Translations to Payment and Shipping Methods, removing unecessary shipping method fulfillment type

This commit is contained in:
2026-09-15 23:51:10 +03:00
parent ea73cc3562
commit 97004234f0
14 changed files with 447 additions and 36 deletions
@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Language;
/**
* PaymentMethod.name becomes a locale-keyed JSON array (e.g.
* {"en": "Cash On Delivery", "el": "Αντικαταβολή"}), rendered in Filament
* via Lunar's own Lunar\Admin\Support\Forms\Components\TranslatedText —
* the same reusable component/data-shape Product/Collection names already
* use (Lunar\Base\Traits\HasTranslations), just applied directly to a
* plain column here rather than through attribute_data, since
* PaymentMethod is a merchant-configured settings row, not a translatable
* catalog attribute.
*
* Existing plain-string rows are preserved under the store's default
* Language code (falls back to 'en' if no Language row exists yet — this
* migration can run before lunar:install seeds one) rather than dropped,
* so an already-configured payment method's name isn't blanked out.
*
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
* (which requires doctrine/dbal — not installed in this project) —
* Postgres-specific (this project runs on `pgsql`, per its own docker
* setup), with an explicit USING clause since json isn't implicitly
* castable from varchar.
*/
return new class extends Migration
{
public function up(): void
{
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
$existing = DB::table('payment_methods')->pluck('name', 'id');
DB::statement('ALTER TABLE payment_methods ALTER COLUMN name DROP DEFAULT');
DB::statement("ALTER TABLE payment_methods ALTER COLUMN name TYPE json USING NULL");
foreach ($existing as $id => $name) {
if ($name === null) {
continue;
}
DB::table('payment_methods')
->where('id', $id)
->update(['name' => json_encode([$defaultLocale => $name])]);
}
}
public function down(): void
{
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
$existing = DB::table('payment_methods')->pluck('name', 'id');
DB::statement('ALTER TABLE payment_methods ALTER COLUMN name TYPE varchar(255) USING NULL');
foreach ($existing as $id => $name) {
$decoded = json_decode((string) $name, true);
$flat = is_array($decoded) ? ($decoded[$defaultLocale] ?? reset($decoded) ?: null) : $name;
DB::table('payment_methods')->where('id', $id)->update(['name' => $flat]);
}
}
};
@@ -0,0 +1,74 @@
<?php
use Illuminate\Support\Facades\DB;
use Lunar\Base\Migration;
use Lunar\Models\Language;
/**
* ShippingMethod.name becomes a locale-keyed JSON array (e.g.
* {"en": "Standard Delivery", "el": "Κανονική Παράδοση"}), rendered in
* Filament via Lunar's own Lunar\Admin\Support\Forms\Components\
* TranslatedText (Modules\Core\Shipping\Extensions\
* ShippingMethodResourceExtension::replaceNameField()) — same shape/
* resolution as PaymentMethod.name (see its own migration,
* 2026_09_15_000001_make_payment_methods_name_translatable.php) and
* Product/Collection names (Lunar\Base\Traits\HasTranslations).
*
* ShippingMethod is a vendor (lunarphp/table-rate-shipping) table, but
* converting a vendor column's type via a migration is no different from
* any other schema change this project already makes against a vendor
* table (see database/migrations/2026_08_31_000001_create_payment_methods_table.php's
* sibling migrations for the same pattern against PaymentMethod) — there
* was no good reason to route this through `data.name` instead, unlike
* `data.fulfillment_type` which is a genuinely NEW field the vendor table
* never had at all.
*
* Existing plain-string rows are preserved under the store's default
* Language code (falls back to 'en' if no Language row exists yet)
* rather than dropped.
*
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
* (requires doctrine/dbal — not installed in this project) — Postgres-
* specific (this project runs on `pgsql`), with an explicit USING clause
* since json isn't implicitly castable from varchar.
*/
return new class extends Migration
{
public function up(): void
{
$table = $this->prefix.'shipping_methods';
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
// The column is NOT NULL (vendor migration never marked it
// nullable) — converting via `USING NULL` first, then
// backfilling with a second UPDATE, violates that constraint
// before the backfill ever runs. json_build_object() converts
// each existing string in place, in the same statement, so the
// column is never transiently NULL. $defaultLocale is inlined
// (not bound) — parameter binding inside an ALTER TABLE ... USING
// expression isn't reliable across drivers; it's a Language::code
// value we control, not user input, so quote_literal-safe
// interpolation here is fine.
$quotedLocale = DB::getPdo()->quote($defaultLocale);
DB::statement("ALTER TABLE {$table} ALTER COLUMN name TYPE json USING json_build_object({$quotedLocale}, name)");
}
public function down(): void
{
$table = $this->prefix.'shipping_methods';
$defaultLocale = Language::where('default', true)->value('code') ?? 'en';
// Same NOT NULL constraint applies going back — ->>'{locale}'
// extracts the default locale's text value directly in the
// USING clause, falling back to the first key present via
// COALESCE for any row missing that locale (e.g. one only ever
// filled in via a non-default language).
$quotedLocale = DB::getPdo()->quote($defaultLocale);
DB::statement(
"ALTER TABLE {$table} ALTER COLUMN name TYPE varchar(255) ".
"USING COALESCE(name->>{$quotedLocale}, (SELECT value FROM json_each_text(name) LIMIT 1))"
);
}
};
+14 -1
View File
@@ -66,6 +66,16 @@ class InstallLunarCommand extends Command
]); ]);
} }
if (! Language::where('code', 'el')->exists()) {
$this->components->info('Adding Greek language');
Language::create([
'code' => 'el',
'name' => 'Greek',
'default' => false,
]);
}
if (! Currency::whereDefault(true)->exists()) { if (! Currency::whereDefault(true)->exists()) {
$this->components->info('Adding a default currency (USD)'); $this->components->info('Adding a default currency (USD)');
@@ -310,7 +320,10 @@ class InstallLunarCommand extends Command
PaymentMethod::create([ PaymentMethod::create([
'type' => 'cash-on-delivery', 'type' => 'cash-on-delivery',
'name' => 'Cash on Delivery', 'name' => [
'en' => 'Cash on Delivery',
'el' => 'Αντικαταβολή',
],
'driver' => 'cash-on-delivery', 'driver' => 'cash-on-delivery',
'capture_mode' => 'pay', 'capture_mode' => 'pay',
'position' => 0, 'position' => 0,
@@ -42,6 +42,8 @@ class OrderPaymentMethodSummaryExtension extends ViewPageExtension
return null; return null;
} }
return PaymentMethod::where('type', $type)->value('name') ?? $type; $method = PaymentMethod::where('type', $type)->first();
return $method?->translate('name') ?? $type;
} }
} }
@@ -12,6 +12,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Events\PaymentMethodsReordered; use Modules\Core\Payment\Events\PaymentMethodsReordered;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
@@ -76,7 +77,7 @@ class PaymentMethodResource extends Resource
->sortable(), ->sortable(),
TextColumn::make('name') TextColumn::make('name')
->label('Name') ->label('Name')
->searchable(), ->state(fn (PaymentMethod $record) => $record->translate('name')),
TextColumn::make('type') TextColumn::make('type')
->label('Type'), ->label('Type'),
TextColumn::make('driver') TextColumn::make('driver')
@@ -124,10 +125,9 @@ class PaymentMethodResource extends Resource
public static function getFormComponents(): array public static function getFormComponents(): array
{ {
return [ return [
TextInput::make('name') TranslatedText::make('name')
->label('Name') ->label('Name')
->required() ->required(),
->maxLength(255),
TextInput::make('type') TextInput::make('type')
->label('Type') ->label('Type')
->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.') ->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.')
+14 -1
View File
@@ -4,6 +4,7 @@ namespace Modules\Core\Payment\Models;
use Illuminate\Database\Eloquent\Casts\AsArrayObject; use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Lunar\Base\Traits\HasTranslations;
/** /**
* A merchant-configured payment method — the DB-instance layer, admin * A merchant-configured payment method — the DB-instance layer, admin
@@ -11,7 +12,16 @@ use Illuminate\Database\Eloquent\Model;
* shipping_methods table already has (see docs/payments.md): * shipping_methods table already has (see docs/payments.md):
* - type: unique, machine-facing slug (Cart::meta['payment_method'], * - type: unique, machine-facing slug (Cart::meta['payment_method'],
* ApplyPaymentMethodFee's lookup key, every Payment event's $type). * ApplyPaymentMethodFee's lookup key, every Payment event's $type).
* - name: admin-facing label. * - name: admin-facing label, locale-keyed JSON (e.g.
* {"en": "Cash On Delivery", "el": "Αντικαταβολή"}) — same shape/
* resolution as Product/Collection names (Lunar\Base\Traits\
* HasTranslations), just applied directly to this column rather than
* through attribute_data, since this is a merchant settings row, not
* a catalog attribute. Rendered in Filament via Lunar's own
* Lunar\Admin\Support\Forms\Components\TranslatedText — one input per
* configured Language row, no bespoke translation UI. Resolve a
* display string with $method->translate('name') (locale defaults to
* app()->getLocale(), falling back to the store's default language).
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key * - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
* — NOT the same as `type`, and not unique (two rows can share one * — NOT the same as `type`, and not unique (two rows can share one
* driver, e.g. two differently-named offline-style methods). * driver, e.g. two differently-named offline-style methods).
@@ -28,12 +38,15 @@ use Illuminate\Database\Eloquent\Model;
*/ */
class PaymentMethod extends Model class PaymentMethod extends Model
{ {
use HasTranslations;
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
'enabled' => 'boolean', 'enabled' => 'boolean',
'position' => 'integer', 'position' => 'integer',
'driver_missing_at' => 'datetime', 'driver_missing_at' => 'datetime',
'name' => 'array',
'data' => AsArrayObject::class, 'data' => AsArrayObject::class,
]; ];
} }
+7 -5
View File
@@ -27,6 +27,7 @@ use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
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\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
class ShippingServiceProvider extends ServiceProvider class ShippingServiceProvider extends ServiceProvider
@@ -80,8 +81,10 @@ 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).
// //
// Reads ShippingMethod.data['fulfillment_type'] directly rather // Resolves via Modules\Core\Shipping\Support\FulfillmentType (driver-
// than through a ShippingMethod::macro('isStorePickup', ...) — // declared for acs/box-now, merchant-configured data['fulfillment_type']
// fallback for table-rate-shipping's generic drivers) rather than
// through a ShippingMethod::macro('isStorePickup', ...) —
// 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()
@@ -91,8 +94,7 @@ class ShippingServiceProvider extends ServiceProvider
// false. (Lunar\Models\Order is unaffected because it declares // false. (Lunar\Models\Order is unaffected because it declares
// its own macro() method directly, bypassing __callStatic // its own macro() method directly, bypassing __callStatic
// entirely — that's why Order::macro('isStorePickupOrder', ...) // entirely — that's why Order::macro('isStorePickupOrder', ...)
// below still works.) Defaults to 'carrier' (false) for any row // below still works.)
// saved before this field existed.
Order::macro('isStorePickupOrder', function () { Order::macro('isStorePickupOrder', function () {
/** @var Order $this */ /** @var Order $this */
$code = $this->shippingAddress?->shipping_option; $code = $this->shippingAddress?->shipping_option;
@@ -108,7 +110,7 @@ class ShippingServiceProvider extends ServiceProvider
// attribute avoids that entirely. // attribute avoids that entirely.
$method = ShippingMethod::where('code', $code)->first(); $method = ShippingMethod::where('code', $code)->first();
return ($method?->data['fulfillment_type'] ?? 'carrier') === 'store_pickup'; return $method && FulfillmentType::isStorePickup($method);
}); });
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) { foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
+9 -2
View File
@@ -10,10 +10,12 @@ 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\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
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\WeightCalculator; use Modules\Core\Shipping\Support\WeightCalculator;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType
{ {
use ResolvesFixedPricing; use ResolvesFixedPricing;
use CachesLivePricing; use CachesLivePricing;
@@ -30,6 +32,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
return 'ACS Courier'; return 'ACS Courier';
} }
public function fulfillmentType(): string
{
return 'carrier';
}
public function description(): string public function description(): string
{ {
return 'Live rate quote from ACS Courier.'; return 'Live rate quote from ACS Courier.';
@@ -84,7 +91,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
$amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100); $amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
return new ShippingOption( return new ShippingOption(
name: $shippingMethod->name ?: $this->name(), name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(),
description: $shippingMethod->description ?: $this->description(), description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(), identifier: $shippingRate->getIdentifier(),
price: new Price($amount, $cart->currency, 1), price: new Price($amount, $cart->currency, 1),
@@ -7,6 +7,7 @@ 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\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
/** /**
* Box Now has no pricing API, so this always resolves the method's normal * Box Now has no pricing API, so this always resolves the method's normal
@@ -14,7 +15,7 @@ use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing: * flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
* there is no live option to offer. * there is no live option to offer.
*/ */
class BoxNowRateDriver implements ShippingRateInterface class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
{ {
use ResolvesFixedPricing; use ResolvesFixedPricing;
@@ -25,6 +26,11 @@ class BoxNowRateDriver implements ShippingRateInterface
return 'Box Now Locker Delivery'; return 'Box Now Locker Delivery';
} }
public function fulfillmentType(): string
{
return 'carrier';
}
public function description(): string public function description(): string
{ {
return 'Deliver to a Box Now parcel locker.'; return 'Deliver to a Box Now parcel locker.';
@@ -6,6 +6,8 @@ use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\Pricing; use Lunar\Facades\Pricing;
use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Support\FulfillmentType;
use Modules\Core\Shipping\Support\ShippingMethodName;
/** /**
* Shared by any carrier driver that also supports Lunar's own price-break * Shared by any carrier driver that also supports Lunar's own price-break
@@ -31,12 +33,13 @@ trait ResolvesFixedPricing
} }
return new ShippingOption( return new ShippingOption(
name: $shippingMethod->name ?: $this->name(), name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(),
description: $shippingMethod->description ?: $this->description(), description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(), identifier: $shippingRate->getIdentifier(),
price: $pricing->matched->price, price: $pricing->matched->price,
taxClass: $shippingRate->getTaxClass(), taxClass: $shippingRate->getTaxClass(),
taxReference: $shippingRate->getTaxReference(), taxReference: $shippingRate->getTaxReference(),
collect: FulfillmentType::isStorePickup($shippingMethod),
); );
} }
} }
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Shipping\Contracts;
/**
* Optional contract a shipping rate driver implements to declare whether
* it fulfils via carrier delivery or in-store pickup — e.g.
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver and BoxNowRateDriver
* are unambiguously carrier-only, so this is a hardcoded fact about the
* driver, not something a merchant should have to configure per row.
*
* table-rate-shipping's own generic drivers (flat-rate, ship-by,
* free-shipping) don't implement this — they're genuinely ambiguous (a
* merchant could configure one for either carrier delivery or store
* pickup), so Modules\Core\Shipping\Support\FulfillmentType::resolve()
* falls back to ShippingMethod.data['fulfillment_type'] (still merchant-
* overridable) only for drivers that don't implement this contract.
*/
interface DeclaresFulfillmentType
{
/**
* @return 'carrier'|'store_pickup'
*/
public function fulfillmentType(): string;
}
@@ -11,37 +11,110 @@ use Filament\Forms\Components\Select;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; 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\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;
class ShippingMethodResourceExtension extends ResourceExtension class ShippingMethodResourceExtension extends ResourceExtension
{ {
public function extendForm(Schema $schema): Schema public function extendForm(Schema $schema): Schema
{ {
return $schema->components([ return $schema->components(
...$this->replaceChargeByField( $this->replaceFulfillmentTypeField(
$this->replaceDriverField($schema->getComponents()) $this->replaceChargeByField(
), $this->replaceNameField(
$this->fulfillmentTypeSelect(), $this->replaceDriverField($schema->getComponents())
]); )
)
)
);
} }
/** /**
* ShippingMethod.data['fulfillment_type'] — 'carrier' (default) or * Replaces the vendor's plain-string `name` TextInput with
* 'store_pickup'. Same free-form-`data`-column pattern as charge_by * Lunar's own TranslatedText — `name` is now a locale-keyed JSON
* above, not a migrated column: ShippingMethod is a vendor * column (see database/migrations/..._make_shipping_methods_name_translatable.php),
* (lunarphp/table-rate-shipping) table, and this codebase avoids * same shape/resolution as PaymentMethod.name and Product/Collection
* forking vendor migrations for a merchant-configurable extra (see * names (Lunar\Base\Traits\HasTranslations).
* PaymentMethod.data.fee for the same convention on a different
* vendor-adjacent model).
*
* What this actually gates: Modules\Core\Shipping\Extensions\
* OrderViewExtension's "Create Shipment" action only makes sense for
* a 'carrier' method (it books a real carrier voucher) — a
* 'store_pickup' order instead moves through Order.status
* 'ready-for-pickup' -> a staff "Mark Picked Up" action, no shipment
* ever created. See docs/checkout.md for the full status-flow design.
*/ */
private function replaceNameField(array $components): array
{
return array_map(function (Component $component) {
if (method_exists($component, 'getName') && $component->getName() === 'name') {
return $this->translatedNameField();
}
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema($this->replaceNameField($component->getChildComponents()));
}
return $component;
}, $components);
}
/**
* ShippingMethod.name is a locale-keyed JSON column (see database/
* migrations/..._make_shipping_methods_name_translatable.php), but
* ShippingMethod is a vendor Eloquent model with no cast declared for
* it — Lunar\Shipping\Models\ShippingMethod only casts `data`, and
* there's no ModelManifest contract wired up to swap in a first-party
* subclass that adds one (Contracts\ShippingMethod exists but is
* never bound — see this class's own git history/nameColumn() for
* the same gap on the read side). TranslatedText itself round-trips
* plain array state, so afterStateHydrated()/dehydrateStateUsing()
* decode/encode the raw JSON string at the field boundary instead —
* the model attribute is a string on the way in and out, only ever
* an array while Filament's schema state holds it.
*/
private function translatedNameField(): TranslatedText
{
$field = TranslatedText::make('name')
->label('Name')
->required()
->afterStateHydrated(function (TranslatedText $component, $state) {
$decoded = json_decode((string) $state, true);
$component->state(is_array($decoded) ? $decoded : []);
})
->dehydrateStateUsing(fn ($state) => json_encode(is_array($state) ? $state : []));
$field->expanded = true;
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 private function fulfillmentTypeSelect(): Select
{ {
return Select::make('data.fulfillment_type') return Select::make('data.fulfillment_type')
@@ -52,9 +125,23 @@ class ShippingMethodResourceExtension extends ResourceExtension
]) ])
->default('carrier') ->default('carrier')
->required() ->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.'); ->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
@@ -127,6 +214,10 @@ class ShippingMethodResourceExtension extends ResourceExtension
return $this->driverColumn(); return $this->driverColumn();
} }
if (method_exists($column, 'getName') && $column->getName() === 'name') {
return $this->nameColumn();
}
return $column; return $column;
}, $table->getColumns()) }, $table->getColumns())
); );
@@ -139,6 +230,21 @@ class ShippingMethodResourceExtension extends ResourceExtension
->formatStateUsing(fn ($state) => $this->driverLabel($state)); ->formatStateUsing(fn ($state) => $this->driverLabel($state));
} }
/**
* `name` is a locale-keyed JSON column (see Modules\Core\Shipping\
* Support\ShippingMethodName's own docblock for why it needs manual
* decoding rather than a model cast). Uses ->state() rather than
* ->formatStateUsing(), which would otherwise have Filament iterate a
* would-be array state as a multi-value list (one formatted cell per
* locale) instead of a single string.
*/
private function nameColumn(): TextColumn
{
return TextColumn::make('name')
->label('Name')
->state(fn ($record) => ShippingMethodName::resolve($record));
}
private function driverLabel(string $key): string private function driverLabel(string $key): string
{ {
$driver = collect(Shipping::getSupportedDrivers())->get($key); $driver = collect(Shipping::getSupportedDrivers())->get($key);
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Modules\Core\Shipping\Support;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
/**
* The single source of truth for "is this ShippingMethod a carrier
* delivery or an in-store pickup" — replaces a merchant-facing
* data['fulfillment_type'] Select that used to exist for every method
* regardless of driver. Modules\Core\Shipping\Carriers\Acs\AcsRateDriver
* and BoxNowRateDriver are unambiguously carrier-only (see
* Modules\Core\Shipping\Contracts\DeclaresFulfillmentType's own
* docblock), so asking a merchant to also pick "Carrier delivery" for
* every ACS/Box Now method was redundant, error-prone config with no
* real decision behind it.
*
* table-rate-shipping's own generic drivers (flat-rate, ship-by,
* free-shipping) don't implement DeclaresFulfillmentType — a merchant
* could genuinely configure one for either purpose (e.g. "Flat Rate —
* Athens Store Pickup") — so those still fall back to the merchant-set
* data['fulfillment_type'], defaulting to 'carrier' when unset.
*/
class FulfillmentType
{
public static function resolve(ShippingMethod $method): string
{
$driver = collect(Shipping::getSupportedDrivers())->get($method->driver);
if ($driver instanceof DeclaresFulfillmentType) {
return $driver->fulfillmentType();
}
return $method->data['fulfillment_type'] ?? 'carrier';
}
public static function isStorePickup(ShippingMethod $method): bool
{
return static::resolve($method) === 'store_pickup';
}
/**
* Whether the merchant-facing "Fulfillment type" Select should be
* shown at all for a given driver — hidden entirely for a driver that
* already declares its own fulfillment type, since there is no real
* decision left for the merchant to make.
*/
public static function isConfigurableFor(?string $driverKey): bool
{
if ($driverKey === null) {
return true;
}
$driver = collect(Shipping::getSupportedDrivers())->get($driverKey);
return ! $driver instanceof DeclaresFulfillmentType;
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Shipping\Support;
use Illuminate\Support\Arr;
use Lunar\Shipping\Models\ShippingMethod;
/**
* ShippingMethod.name is a locale-keyed JSON column (see database/
* migrations/..._make_shipping_methods_name_translatable.php, and
* Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension for
* the Filament form/table side), but ShippingMethod is a vendor Eloquent
* model with no cast declared for it — Lunar\Shipping\Models\
* ShippingMethod only casts `data`, and there's no ModelManifest contract
* wired up by the package to swap in a first-party subclass that adds
* one (Contracts\ShippingMethod exists but is never bound). So `$method->
* name` returns the raw JSON string, not a decoded array — this resolves
* it the same way Lunar\Base\Traits\HasTranslations::translate() would,
* shared by every rate driver that builds a Lunar\DataTypes\ShippingOption
* (Modules\Core\Shipping\Concerns\ResolvesFixedPricing, Modules\Core\
* Shipping\Carriers\Acs\AcsRateDriver) plus the Filament table column.
*/
class ShippingMethodName
{
public static function resolve(ShippingMethod $method, ?string $locale = null): ?string
{
$decoded = json_decode((string) $method->getRawOriginal('name'), true);
if (! is_array($decoded)) {
return $method->getRawOriginal('name');
}
return Arr::get($decoded, $locale ?: app()->getLocale()) ?: Arr::first($decoded);
}
}