2026-07-19 00:52:01 +03:00
<? php
namespace Modules\Core\Shipping\Extensions ;
2026-08-31 13:16:13 +03:00
use Filament\Schemas\Schema ;
use Filament\Schemas\Components\Component ;
use Filament\Schemas\Components\Concerns\HasChildComponents ;
use Filament\Schemas\Components\Utilities\Get ;
use InvalidArgumentException ;
2026-07-19 00:52:01 +03:00
use Filament\Forms\Components\Select ;
2026-09-30 17:28:47 +03:00
use Filament\Forms\Components\TextInput ;
use Filament\Forms\Components\Toggle ;
2026-07-19 00:52:01 +03:00
use Filament\Tables\Columns\TextColumn ;
use Filament\Tables\Table ;
use Lunar\Admin\Support\Extending\ResourceExtension ;
2026-09-15 23:51:10 +03:00
use Lunar\Admin\Support\Forms\Components\TranslatedText ;
2026-07-19 00:52:01 +03:00
use Lunar\Shipping\Facades\Shipping ;
2026-07-19 02:24:10 +03:00
use Modules\Core\Shipping\Contracts\SupportsLivePricing ;
2026-09-15 23:51:10 +03:00
use Modules\Core\Shipping\Support\ShippingMethodName ;
2026-07-19 00:52:01 +03:00
class ShippingMethodResourceExtension extends ResourceExtension
{
2026-08-31 13:16:13 +03:00
public function extendForm ( Schema $schema ) : Schema
2026-07-19 00:52:01 +03:00
{
2026-09-15 23:51:10 +03:00
return $schema -> components (
2026-09-29 00:43:48 +03:00
$this -> replaceChargeByField (
$this -> replaceNameField (
$this -> replaceDriverField ( $schema -> getComponents ())
2026-09-15 23:51:10 +03:00
)
)
);
}
/**
* 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' ) {
2026-09-18 00:52:38 +03:00
return self :: translatedNameField ();
2026-09-15 23:51:10 +03:00
}
if ( in_array ( HasChildComponents :: class , class_uses_recursive ( $component ), true )) {
2026-09-16 00:24:18 +03:00
$component -> schema ( $this -> replaceNameField ( $component -> getDefaultChildComponents ()));
2026-09-15 23:51:10 +03:00
}
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.
*/
2026-09-18 00:52:38 +03:00
/**
* Also called directly by Modules\Core\Shipping\Extensions\
* ShippingMethodListExtension — the create action's form is built
* inline by the vendor's ListShippingMethod page rather than through
* this class's own extendForm() pipeline, so it needs the same
* translated field wired in separately.
*/
public static function translatedNameField () : TranslatedText
2026-09-15 23:51:10 +03:00
{
$field = TranslatedText :: make ( 'name' )
-> label ( 'Name' )
-> required ()
-> afterStateHydrated ( function ( TranslatedText $component , $state ) {
2026-09-18 00:52:38 +03:00
// On create there is no record yet, so Filament hydrates
// this from the field's own default/current state — already
// an array (or null), never the raw JSON string edit gets
// from the model attribute. Only decode when it's a string.
if ( is_string ( $state )) {
$state = json_decode ( $state , true );
}
$component -> state ( is_array ( $state ) ? $state : []);
2026-09-15 23:51:10 +03:00
})
-> dehydrateStateUsing ( fn ( $state ) => json_encode ( is_array ( $state ) ? $state : []));
$field -> expanded = true ;
return $field ;
2026-09-10 22:50:40 +03:00
}
2026-07-19 02:24:10 +03:00
/**
* Extend the vendor's cart_total/weight charge_by Select with a third
* "live" option — only offered when the currently selected driver
* supports live pricing (see SupportsLivePricing). Picking it is what
* tells the driver to call its carrier API instead of resolving a
* price break.
*/
private function replaceChargeByField ( array $components ) : array
{
return array_map ( function ( Component $component ) {
if ( method_exists ( $component , 'getName' ) && $component -> getName () === 'charge_by' ) {
return $this -> chargeBySelect ();
}
if ( in_array ( HasChildComponents :: class , class_uses_recursive ( $component ), true )) {
2026-09-30 17:28:47 +03:00
$children = $this -> replaceChargeByField ( $component -> getDefaultChildComponents ());
// The manual carrier's settings sit next to charge_by, in
// the same `data` group.
$hasChargeBy = collect ( $children ) -> contains (
fn ( Component $child ) => method_exists ( $child , 'getName' ) && $child -> getName () === 'charge_by'
2026-07-19 02:24:10 +03:00
);
2026-09-30 17:28:47 +03:00
$component -> schema ( $hasChargeBy ? [ ... $children , ... $this -> manualCarrierFields ()] : $children );
2026-07-19 02:24:10 +03:00
}
return $component ;
}, $components );
}
2026-09-30 17:28:47 +03:00
/**
* Settings for a carrier without an integration (ManualRateDriver),
* stored in the method's `data`. Shown only for that driver.
*
* @return array<Component>
*/
private function manualCarrierFields () : array
{
$isManual = fn ( Get $get ) => $get ( '../driver' ) === 'manual' ;
return [
TextInput :: make ( 'carrier_name' )
-> label ( 'Carrier name' )
-> helperText ( 'Shown to staff and customers on this carrier\'s shipments, e.g. "Geniki Taxydromiki".' )
-> visible ( $isManual )
-> required ( $isManual ),
TextInput :: make ( 'tracking_url' )
-> label ( 'Tracking URL' )
2026-09-30 21:30:13 +03:00
-> helperText ( 'Optional. The carrier\'s tracking page, with {number} where the voucher number goes, e.g. https://example.com/track?number={number}. For an integrated carrier it\'s only used on vouchers typed in by hand.' )
-> rule ( 'starts_with:http://,https://' ),
2026-09-30 17:28:47 +03:00
Toggle :: make ( 'collects_cash' )
-> label ( 'Courier collects cash on delivery' )
-> helperText ( 'Offers cash on delivery at checkout for this shipping method.' )
-> visible ( $isManual ),
];
}
2026-07-19 02:24:10 +03:00
private function chargeBySelect () : Select
{
return Select :: make ( 'charge_by' )
-> label ( 'Charge by' )
-> options ( function ( Get $get ) {
$options = [
'cart_total' => 'Cart Total' ,
'weight' => 'Weight' ,
];
// "charge_by" is nested inside a Group with
// ->statePath('data'), while "driver" sits one level up, at
// the form root. Note: an *absolute* path here would need to
// additionally account for the page's own form wrapper
// (EditRecord::getFormStatePath() === 'data'), which relative
// paths never cross — so "../driver" (relative) is the
// correct, page-independent way to reach it, not an
// absolute 'driver' string.
if ( $this -> driverSupportsLivePricing ( $get ( '../driver' ))) {
$options [ 'live' ] = 'Live API pricing' ;
}
return $options ;
})
-> live ();
}
private function driverSupportsLivePricing ( ? string $driver ) : bool
{
if ( ! $driver ) {
return false ;
}
try {
return Shipping :: driver ( $driver ) instanceof SupportsLivePricing ;
2026-08-31 13:16:13 +03:00
} catch ( InvalidArgumentException ) {
2026-07-19 02:24:10 +03:00
return false ;
}
}
2026-07-19 00:52:01 +03:00
public function extendTable ( Table $table ) : Table
{
return $table -> columns (
array_map ( function ( $column ) {
if ( method_exists ( $column , 'getName' ) && $column -> getName () === 'driver' ) {
return $this -> driverColumn ();
}
2026-09-15 23:51:10 +03:00
if ( method_exists ( $column , 'getName' ) && $column -> getName () === 'name' ) {
return $this -> nameColumn ();
}
2026-07-19 00:52:01 +03:00
return $column ;
}, $table -> getColumns ())
);
}
private function driverColumn () : TextColumn
{
return TextColumn :: make ( 'driver' )
-> label ( 'Type' )
-> formatStateUsing ( fn ( $state ) => $this -> driverLabel ( $state ));
}
2026-09-15 23:51:10 +03:00
/**
* `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 ));
}
2026-07-19 00:52:01 +03:00
private function driverLabel ( string $key ) : string
{
$driver = collect ( Shipping :: getSupportedDrivers ()) -> get ( $key );
return $driver ?-> name () ?? $key ;
}
/**
* Recursively walk the form tree and replace the hardcoded driver
* Select (nested inside Section > Group) with one listing every
* registered driver, built-in or custom.
*
2026-08-31 13:16:13 +03:00
* @param array<Component> $components
2026-07-19 00:52:01 +03:00
* @return array<Component>
*/
private function replaceDriverField ( array $components ) : array
{
return array_map ( function ( Component $component ) {
if ( method_exists ( $component , 'getName' ) && $component -> getName () === 'driver' ) {
return $this -> driverSelect ();
}
if ( in_array ( HasChildComponents :: class , class_uses_recursive ( $component ), true )) {
$component -> schema (
2026-09-16 00:24:18 +03:00
$this -> replaceDriverField ( $component -> getDefaultChildComponents ())
2026-07-19 00:52:01 +03:00
);
}
return $component ;
}, $components );
}
private function driverSelect () : Select
{
return Select :: make ( 'driver' )
-> label ( 'Type' )
-> options ( fn () => collect ( Shipping :: getSupportedDrivers ())
-> mapWithKeys ( fn ( $driver , $key ) => [ $key => $driver -> name ()]))
2026-09-29 00:43:48 +03:00
-> default ( 'acs' )
2026-07-19 02:24:10 +03:00
-> live ();
2026-07-19 00:52:01 +03:00
}
}