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
+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\Concerns\CachesLivePricing;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName;
use Modules\Core\Shipping\Support\WeightCalculator;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType
{
use ResolvesFixedPricing;
use CachesLivePricing;
@@ -30,6 +32,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
return 'ACS Courier';
}
public function fulfillmentType(): string
{
return 'carrier';
}
public function description(): string
{
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);
return new ShippingOption(
name: $shippingMethod->name ?: $this->name(),
name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(),
description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(),
price: new Price($amount, $cart->currency, 1),
@@ -7,6 +7,7 @@ use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
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
@@ -14,7 +15,7 @@ use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
* there is no live option to offer.
*/
class BoxNowRateDriver implements ShippingRateInterface
class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
{
use ResolvesFixedPricing;
@@ -25,6 +26,11 @@ class BoxNowRateDriver implements ShippingRateInterface
return 'Box Now Locker Delivery';
}
public function fulfillmentType(): string
{
return 'carrier';
}
public function description(): string
{
return 'Deliver to a Box Now parcel locker.';
@@ -6,6 +6,8 @@ use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\Pricing;
use Lunar\Shipping\Models\ShippingMethod;
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
@@ -31,12 +33,13 @@ trait ResolvesFixedPricing
}
return new ShippingOption(
name: $shippingMethod->name ?: $this->name(),
name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(),
description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(),
price: $pricing->matched->price,
taxClass: $shippingRate->getTaxClass(),
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\Table;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName;
class ShippingMethodResourceExtension extends ResourceExtension
{
public function extendForm(Schema $schema): Schema
{
return $schema->components([
...$this->replaceChargeByField(
$this->replaceDriverField($schema->getComponents())
),
$this->fulfillmentTypeSelect(),
]);
return $schema->components(
$this->replaceFulfillmentTypeField(
$this->replaceChargeByField(
$this->replaceNameField(
$this->replaceDriverField($schema->getComponents())
)
)
)
);
}
/**
* ShippingMethod.data['fulfillment_type'] — 'carrier' (default) or
* 'store_pickup'. Same free-form-`data`-column pattern as charge_by
* above, not a migrated column: ShippingMethod is a vendor
* (lunarphp/table-rate-shipping) table, and this codebase avoids
* forking vendor migrations for a merchant-configurable extra (see
* 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.
* Replaces the vendor's plain-string `name` TextInput with
* Lunar's own TranslatedText — `name` is now a locale-keyed JSON
* column (see database/migrations/..._make_shipping_methods_name_translatable.php),
* same shape/resolution as PaymentMethod.name and Product/Collection
* names (Lunar\Base\Traits\HasTranslations).
*/
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
{
return Select::make('data.fulfillment_type')
@@ -52,9 +125,23 @@ class ShippingMethodResourceExtension extends ResourceExtension
])
->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
* "live" option — only offered when the currently selected driver
@@ -127,6 +214,10 @@ class ShippingMethodResourceExtension extends ResourceExtension
return $this->driverColumn();
}
if (method_exists($column, 'getName') && $column->getName() === 'name') {
return $this->nameColumn();
}
return $column;
}, $table->getColumns())
);
@@ -139,6 +230,21 @@ class ShippingMethodResourceExtension extends ResourceExtension
->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
{
$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);
}
}