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;
|
|
|
|
|
use Filament\Tables\Columns\TextColumn;
|
|
|
|
|
use Filament\Tables\Table;
|
|
|
|
|
use Lunar\Admin\Support\Extending\ResourceExtension;
|
|
|
|
|
use Lunar\Shipping\Facades\Shipping;
|
2026-07-19 02:24:10 +03:00
|
|
|
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
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-10 22:50:40 +03:00
|
|
|
return $schema->components([
|
|
|
|
|
...$this->replaceChargeByField(
|
2026-08-31 13:16:13 +03:00
|
|
|
$this->replaceDriverField($schema->getComponents())
|
2026-09-10 22:50:40 +03:00
|
|
|
),
|
|
|
|
|
$this->fulfillmentTypeSelect(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
|
|
|
|
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()
|
|
|
|
|
->helperText('Whether an order using this method is handed to a carrier, or collected by the customer in person.');
|
2026-07-19 00:52:01 +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)) {
|
|
|
|
|
$component->schema(
|
|
|
|
|
$this->replaceChargeByField($component->getChildComponents())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $column;
|
|
|
|
|
}, $table->getColumns())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function driverColumn(): TextColumn
|
|
|
|
|
{
|
|
|
|
|
return TextColumn::make('driver')
|
|
|
|
|
->label('Type')
|
|
|
|
|
->formatStateUsing(fn ($state) => $this->driverLabel($state));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
$this->replaceDriverField($component->getChildComponents())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-07-19 02:24:10 +03:00
|
|
|
->default('flat-rate')
|
|
|
|
|
->live();
|
2026-07-19 00:52:01 +03:00
|
|
|
}
|
|
|
|
|
}
|