243 lines
9.0 KiB
PHP
243 lines
9.0 KiB
PHP
<?php
|
|
|
|
namespace Modules\Core\Shipping\Extensions;
|
|
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Schemas\Components\Component;
|
|
use Filament\Schemas\Components\Concerns\HasChildComponents;
|
|
use Filament\Schemas\Components\Utilities\Get;
|
|
use InvalidArgumentException;
|
|
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\SupportsLivePricing;
|
|
use Modules\Core\Shipping\Support\ShippingMethodName;
|
|
|
|
class ShippingMethodResourceExtension extends ResourceExtension
|
|
{
|
|
public function extendForm(Schema $schema): Schema
|
|
{
|
|
return $schema->components(
|
|
$this->replaceChargeByField(
|
|
$this->replaceNameField(
|
|
$this->replaceDriverField($schema->getComponents())
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 self::translatedNameField();
|
|
}
|
|
|
|
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
|
|
$component->schema($this->replaceNameField($component->getDefaultChildComponents()));
|
|
}
|
|
|
|
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.
|
|
*/
|
|
/**
|
|
* 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
|
|
{
|
|
$field = TranslatedText::make('name')
|
|
->label('Name')
|
|
->required()
|
|
->afterStateHydrated(function (TranslatedText $component, $state) {
|
|
// 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 : []);
|
|
})
|
|
->dehydrateStateUsing(fn ($state) => json_encode(is_array($state) ? $state : []));
|
|
|
|
$field->expanded = true;
|
|
|
|
return $field;
|
|
}
|
|
|
|
/**
|
|
* 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)) {
|
|
$component->schema(
|
|
$this->replaceChargeByField($component->getDefaultChildComponents())
|
|
);
|
|
}
|
|
|
|
return $component;
|
|
}, $components);
|
|
}
|
|
|
|
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;
|
|
} catch (InvalidArgumentException) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function extendTable(Table $table): Table
|
|
{
|
|
return $table->columns(
|
|
array_map(function ($column) {
|
|
if (method_exists($column, 'getName') && $column->getName() === 'driver') {
|
|
return $this->driverColumn();
|
|
}
|
|
|
|
if (method_exists($column, 'getName') && $column->getName() === 'name') {
|
|
return $this->nameColumn();
|
|
}
|
|
|
|
return $column;
|
|
}, $table->getColumns())
|
|
);
|
|
}
|
|
|
|
private function driverColumn(): TextColumn
|
|
{
|
|
return TextColumn::make('driver')
|
|
->label('Type')
|
|
->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);
|
|
|
|
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.
|
|
*
|
|
* @param array<Component> $components
|
|
* @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(
|
|
$this->replaceDriverField($component->getDefaultChildComponents())
|
|
);
|
|
}
|
|
|
|
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()]))
|
|
->default('acs')
|
|
->live();
|
|
}
|
|
}
|