Merge branch 'master' into Payment-Methods
This commit is contained in:
@@ -35,6 +35,12 @@ class Login extends SimplePage
|
||||
}
|
||||
}
|
||||
|
||||
public function back(): void
|
||||
{
|
||||
$this->otpSent = false;
|
||||
$this->otp = '';
|
||||
}
|
||||
|
||||
public function requestOtp(): void
|
||||
{
|
||||
$this->validate(['email' => 'required|email']);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Lunar\Models\Cart;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
use Modules\Core\Recovery\Events\CartAbandoned;
|
||||
use Modules\Core\Recovery\Events\CheckoutAbandoned;
|
||||
|
||||
/**
|
||||
* "Abandoned" is a derived state (Cart::updated_at older than
|
||||
* config('core.cart.abandoned_after')) — nothing transitions a cart into it
|
||||
* via a normal Eloquent write, so there's no model-event hook to dispatch
|
||||
* CartAbandoned/CheckoutAbandoned from directly. This command is the only
|
||||
* place that moment gets detected; run it on a schedule (see docs/cart.md).
|
||||
*
|
||||
* Splits Cart::scopeActive()'s two branches into their own events —
|
||||
* see CartAbandoned/CheckoutAbandoned's docblocks for why they're distinct,
|
||||
* not one combined "abandoned" state: a cart with no order at all is a much
|
||||
* weaker purchase-intent signal than one with a draft order that was never
|
||||
* placed.
|
||||
*
|
||||
* Deliberately does NOT write anything to Cart/Order — dispatch only. An
|
||||
* earlier version recorded an "already notified" marker on Cart::meta/
|
||||
* Order::meta, but that write bumped updated_at as an Eloquent side effect,
|
||||
* which un-staled the very cart being marked abandoned (the same field
|
||||
* abandonment staleness is computed from) — see docs/cart.md's former
|
||||
* "Known bug" note. Cart/Checkout must have no way of writing abandonment
|
||||
* state at all; every cart still matching the query below refires its event
|
||||
* on every run until Recovery (not yet built — see
|
||||
* docs/recovery-strategies.md) owns its own dedup/tracking table.
|
||||
*/
|
||||
class DetectAbandonedCarts extends Command
|
||||
{
|
||||
protected $signature = 'boboko:cart:detect-abandoned';
|
||||
|
||||
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$cutoff = CartResource::abandonedCutoff();
|
||||
|
||||
$cartsAbandoned = 0;
|
||||
$checkoutsAbandoned = 0;
|
||||
|
||||
Cart::query()
|
||||
->whereDoesntHave('orders')
|
||||
->where('updated_at', '<=', $cutoff)
|
||||
->with('lines')
|
||||
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
|
||||
foreach ($carts as $cart) {
|
||||
if ($cart->lines->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Event::dispatch(new CartAbandoned($cart));
|
||||
|
||||
$cartsAbandoned++;
|
||||
}
|
||||
});
|
||||
|
||||
Cart::query()
|
||||
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
|
||||
->where('updated_at', '<=', $cutoff)
|
||||
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
|
||||
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
|
||||
foreach ($carts as $cart) {
|
||||
$order = $cart->orders->first();
|
||||
|
||||
if ($order === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Event::dispatch(new CheckoutAbandoned($cart, $order));
|
||||
|
||||
$checkoutsAbandoned++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->components->info("Dispatched CartAbandoned for {$cartsAbandoned} cart(s), CheckoutAbandoned for {$checkoutsAbandoned} checkout(s).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
class CartCleared
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}> $lines
|
||||
* Snapshot of every line that was in the cart before clearing — Cart::clear()
|
||||
* deletes all rows directly, so nothing here can be fresh CartLine instances.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly array $lines,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
class CartCouponApplied
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly string $code,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
class CartCouponRemoved
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly string $code,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
|
||||
class CartLineAdded
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly CartLine $line,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
|
||||
/**
|
||||
* The reverse of CartLineSaved — a previously saved-for-later line moved back
|
||||
* into the purchasable cart (now counted in totals again).
|
||||
*/
|
||||
class CartLineMovedToCart
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly CartLine $line,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
class CartLineRemoved
|
||||
{
|
||||
/**
|
||||
* @param array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array} $line
|
||||
* Snapshot of the removed line — the row is already deleted by the time this
|
||||
* event dispatches, so nothing here can be a fresh CartLine model instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly array $line,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
|
||||
/**
|
||||
* A line was moved OUT of the purchasable cart and into "saved for later" —
|
||||
* not a removal (the row still exists), but distinct from CartLineUpdated
|
||||
* since it's a state transition worth its own hook (e.g. abandoned-cart
|
||||
* recovery treating a saved line very differently from a deleted one).
|
||||
*/
|
||||
class CartLineSaved
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly CartLine $line,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
|
||||
class CartLineUpdated
|
||||
{
|
||||
/**
|
||||
* @param array{quantity: int, meta: array} $old Snapshot before the update.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly CartLine $line,
|
||||
public readonly array $old,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown by CartService::applyCoupon() when the given code doesn't match any
|
||||
* currently-active, non-exhausted Discount — Lunar's own
|
||||
* Discounts::validateCoupon() only returns a bool, it has no matching
|
||||
* exception type of its own to reuse here.
|
||||
*/
|
||||
class InvalidCouponException extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly string $code)
|
||||
{
|
||||
parent::__construct("The coupon code \"{$code}\" is not valid.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Filament\Resources;
|
||||
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Lunar\Admin\Filament\Resources\CustomerResource;
|
||||
use Lunar\Models\Cart;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource\Pages;
|
||||
|
||||
/**
|
||||
* Read-only — a cart is managed entirely through the storefront (add/update/remove
|
||||
* line, checkout), never hand-edited by staff. Scoped to carts with a known
|
||||
* `user_id`/`customer_id` only: an anonymous guest's session cart carries no
|
||||
* identity a staff member could act on (no name, no email, nothing to follow up
|
||||
* with), so listing every such row would be noise, not a real admin capability —
|
||||
* see docs/cart.md for the reasoning (Lunar itself ships no cart admin view at all
|
||||
* to follow a precedent from).
|
||||
*/
|
||||
class CartResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Cart::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';
|
||||
|
||||
protected static ?string $navigationGroup = 'Sales';
|
||||
|
||||
protected static ?string $modelLabel = 'Cart';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Carts';
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->where(fn (Builder $query) => $query->whereNotNull('user_id')->orWhereNotNull('customer_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count only, not a fetch — no rows are loaded. Combines BOTH abandoned
|
||||
* states (`active()` already covers "no order at all" and "draft order,
|
||||
* never placed" together — see ListCarts::getTabs()'s "Abandoned Cart" /
|
||||
* "Abandoned Checkout" tabs for where they're split apart), not "Ongoing"
|
||||
* — the badge is meant to answer "how many carts might need following up
|
||||
* on," not the total including ones someone is actively shopping in right
|
||||
* now.
|
||||
*/
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very
|
||||
* different things together: a cart someone is actively shopping in right now,
|
||||
* and one that's genuinely been left behind. Lunar tracks no time-based
|
||||
* staleness signal of its own — `Cart::updated_at` plus a configurable
|
||||
* threshold (`config('core.cart.abandoned_after')`, default 1 hour) is what
|
||||
* this resource uses to tell them apart. A cart with no recent activity is
|
||||
* "Abandoned"; anything more recent is "Ongoing".
|
||||
*/
|
||||
public static function abandonedCutoff(): Carbon
|
||||
{
|
||||
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('Cart')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('customer.full_name')
|
||||
->label('Customer')
|
||||
->placeholder('—')
|
||||
->searchable()
|
||||
->url(fn (Cart $record) => $record->customer_id !== null
|
||||
? CustomerResource::getUrl('view', ['record' => $record->customer_id])
|
||||
: null),
|
||||
Tables\Columns\TextColumn::make('user.email')
|
||||
->label('User')
|
||||
->placeholder('—')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('lines_count')
|
||||
->label('Lines')
|
||||
->counts('lines')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('lines_sum_quantity')
|
||||
->label('Items')
|
||||
->sum('lines', 'quantity')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('currency.code')
|
||||
->label('Currency'),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label('Last activity')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
])
|
||||
->defaultSort('updated_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListCarts::route('/'),
|
||||
'view' => Pages\ViewCart::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
|
||||
|
||||
use Filament\Resources\Components\Tab;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
|
||||
class ListCarts extends ListRecords
|
||||
{
|
||||
protected static string $resource = CartResource::class;
|
||||
|
||||
/**
|
||||
* `Cart::completed_at` is declared/cast on the model but never actually written
|
||||
* anywhere in Lunar core — it's dead, not a real "did this convert" signal.
|
||||
* "Completed" instead means the cart has an order with `placed_at` set (a
|
||||
* placed, not just drafted, order).
|
||||
*
|
||||
* `Cart::scopeActive()` (not yet converted to an order) actually mixes two
|
||||
* distinct states: no order started at all, vs. a draft order exists
|
||||
* (`placed_at IS NULL`) but was never placed — checkout was started, not
|
||||
* finished. That's a real difference in purchase intent (a cart with a
|
||||
* draft order is a much stronger signal than one with no order at all) and
|
||||
* in reachability (checkout usually captures an email even for a guest),
|
||||
* so they get separate tabs rather than one combined "no order yet"
|
||||
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
|
||||
* Modules\Core\Recovery\Events\CheckoutAbandoned draw.
|
||||
*
|
||||
* "Ongoing" vs the two abandoned tabs all split on `updated_at` against
|
||||
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness
|
||||
* signal of its own, so recent activity is the only thing distinguishing a
|
||||
* cart someone is shopping in right now from one genuinely left behind.
|
||||
*/
|
||||
public function getTabs(): array
|
||||
{
|
||||
return [
|
||||
'abandoned_cart' => Tab::make('Abandoned Cart')
|
||||
->modifyQueryUsing(fn(Builder $query) => $query
|
||||
->whereDoesntHave('orders')
|
||||
->where('updated_at', '<=', CartResource::abandonedCutoff())),
|
||||
'abandoned_checkout' => Tab::make('Abandoned Checkout')
|
||||
->modifyQueryUsing(fn(Builder $query) => $query
|
||||
->whereHas('orders', fn(Builder $query) => $query->whereNull('placed_at'))
|
||||
->where('updated_at', '<=', CartResource::abandonedCutoff())),
|
||||
|
||||
'ongoing' => Tab::make('Ongoing')
|
||||
->modifyQueryUsing(fn(Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())),
|
||||
'completed' => Tab::make('Completed')
|
||||
->modifyQueryUsing(fn(Builder $query) => $query->whereHas(
|
||||
'orders',
|
||||
fn(Builder $query) => $query->whereNotNull('placed_at'),
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\Section;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Infolist;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Lunar\Admin\Filament\Resources\CustomerResource;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
|
||||
class ViewCart extends ViewRecord
|
||||
{
|
||||
protected static string $resource = CartResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('viewCustomer')
|
||||
->label('View Customer')
|
||||
->icon('heroicon-o-user')
|
||||
->url(fn (Cart $record) => CustomerResource::getUrl('view', ['record' => $record->customer_id]))
|
||||
->visible(fn (Cart $record) => $record->customer_id !== null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cart's computed properties (subTotal/total/etc.) are plain public properties
|
||||
* populated as a side effect of the pipeline calculate() runs — never persisted,
|
||||
* so they don't exist on a plain Eloquent-fetched record. Calculated once here
|
||||
* (a single view page load), not per-row in the list table, since running the
|
||||
* full pipeline for every row of a paginated table would be expensive for no
|
||||
* real benefit — see docs/lunar.md's Cart gotchas.
|
||||
*/
|
||||
protected function resolveRecord(int|string $key): Cart
|
||||
{
|
||||
/** @var Cart $cart */
|
||||
$cart = parent::resolveRecord($key);
|
||||
|
||||
return $cart->calculate();
|
||||
}
|
||||
|
||||
public function infolist(Infolist $infolist): Infolist
|
||||
{
|
||||
return $infolist
|
||||
->schema([
|
||||
Section::make('Cart')
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('id'),
|
||||
TextEntry::make('customer.full_name')
|
||||
->label('Customer')
|
||||
->placeholder('—')
|
||||
->url(fn (Cart $record) => $record->customer_id !== null
|
||||
? CustomerResource::getUrl('view', ['record' => $record->customer_id])
|
||||
: null),
|
||||
TextEntry::make('user.email')
|
||||
->label('User')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('currency.code')
|
||||
->label('Currency'),
|
||||
TextEntry::make('completedOrderPlacedAt')
|
||||
->label('Ordered at')
|
||||
->state(fn (Cart $record) => $record->orders()->whereNotNull('placed_at')->value('placed_at'))
|
||||
->dateTime()
|
||||
->placeholder('Not ordered'),
|
||||
TextEntry::make('updated_at')
|
||||
->label('Last activity')
|
||||
->dateTime(),
|
||||
]),
|
||||
Section::make('Lines')
|
||||
->schema([
|
||||
RepeatableEntry::make('lines')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('purchasable.sku')
|
||||
->label('SKU')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('quantity'),
|
||||
TextEntry::make('unitPrice')
|
||||
->label('Unit price')
|
||||
->formatStateUsing(fn (CartLine $record) => $record->unitPrice?->formatted() ?? '—'),
|
||||
TextEntry::make('total')
|
||||
->label('Line total')
|
||||
->formatStateUsing(fn (CartLine $record) => $record->total?->formatted() ?? '—'),
|
||||
])
|
||||
->columns(4),
|
||||
]),
|
||||
Section::make('Totals')
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('subTotal')
|
||||
->label('Subtotal')
|
||||
->formatStateUsing(fn (Cart $record) => $record->subTotal?->formatted() ?? '—'),
|
||||
TextEntry::make('discountTotal')
|
||||
->label('Discount')
|
||||
->formatStateUsing(fn (Cart $record) => $record->discountTotal?->formatted() ?? '—'),
|
||||
TextEntry::make('taxTotal')
|
||||
->label('Tax')
|
||||
->formatStateUsing(fn (Cart $record) => $record->taxTotal?->formatted() ?? '—'),
|
||||
TextEntry::make('total')
|
||||
->label('Total')
|
||||
->formatStateUsing(fn (Cart $record) => $record->total?->formatted() ?? '—')
|
||||
->weight('bold'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Pipelines;
|
||||
|
||||
use Closure;
|
||||
use Lunar\DataTypes\Price;
|
||||
use Lunar\Models\Contracts\CartLine as CartLineContract;
|
||||
|
||||
/**
|
||||
* Runs in config('lunar.cart.pipelines.cart_lines'), after GetUnitPrice —
|
||||
* zeroes out unitPrice/unitPriceInclTax for any line flagged
|
||||
* meta.saved_for_later, BEFORE Lunar's own CalculateLines pipeline step reads
|
||||
* unitPrice to compute subTotal/total. A saved-for-later item is deliberately
|
||||
* parked, not pending purchase, so it shouldn't inflate Cart::total — and
|
||||
* since CalculateLines sums every CartLine unconditionally with no meta-based
|
||||
* exclusion of its own, zeroing the price here (rather than patching subTotal
|
||||
* after the fact) is what makes every downstream total naturally correct
|
||||
* without a second pass.
|
||||
*/
|
||||
class ZeroSavedForLaterPrice
|
||||
{
|
||||
public function handle(CartLineContract $cartLine, Closure $next): mixed
|
||||
{
|
||||
if ($cartLine->meta['saved_for_later'] ?? false) {
|
||||
$currency = $cartLine->cart->currency;
|
||||
|
||||
$cartLine->unitPrice = new Price(0, $currency, 1);
|
||||
$cartLine->unitPriceInclTax = new Price(0, $currency, 1);
|
||||
}
|
||||
|
||||
return $next($cartLine);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Lunar\Actions\Carts\GetExistingCartLine;
|
||||
use Lunar\Base\Purchasable;
|
||||
use Lunar\Facades\CartSession;
|
||||
use Lunar\Facades\Discounts;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
use Modules\Core\Cart\Events\CartCleared;
|
||||
use Modules\Core\Cart\Events\CartCouponApplied;
|
||||
use Modules\Core\Cart\Events\CartCouponRemoved;
|
||||
use Modules\Core\Cart\Events\CartLineAdded;
|
||||
use Modules\Core\Cart\Events\CartLineMovedToCart;
|
||||
use Modules\Core\Cart\Events\CartLineRemoved;
|
||||
use Modules\Core\Cart\Events\CartLineSaved;
|
||||
use Modules\Core\Cart\Events\CartLineUpdated;
|
||||
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
||||
|
||||
/**
|
||||
* Storefront-facing cart operations, mirroring Modules\Core\Catalog\Services\
|
||||
* ProductService/CollectionService's shape — one boboko-owned API a storefront
|
||||
* calls, so Lunar's own CartSession/Cart stay an implementation detail rather
|
||||
* than something a consuming app depends on directly.
|
||||
*
|
||||
* Every mutating method dispatches a matching domain event
|
||||
* (Modules\Core\Cart\Events\*) after the underlying Lunar operation completes —
|
||||
* Lunar itself dispatches zero cart events (see docs/lunar.md's Cart gotchas),
|
||||
* so without this, nothing in a consuming app has anything to react to when a
|
||||
* cart actually changes (reindexing, notifications, analytics, etc.).
|
||||
*
|
||||
* All mutating methods return the recalculated Cart — matching Lunar's own
|
||||
* Cart::add()/updateLine()/etc., which already return $this after
|
||||
* refresh()->recalculate() — so a caller gets fresh totals in the same call,
|
||||
* no second fetch needed.
|
||||
*/
|
||||
class CartService
|
||||
{
|
||||
/**
|
||||
* The current session's cart, or null if none exists yet. Does NOT
|
||||
* auto-create one — see currentOrCreate() for that.
|
||||
*/
|
||||
public function current(): ?Cart
|
||||
{
|
||||
return CartSession::current();
|
||||
}
|
||||
|
||||
/**
|
||||
* The current session's cart, creating one if none exists yet — the right
|
||||
* call for "add to cart" style flows where a cart must exist by the time
|
||||
* the method returns.
|
||||
*/
|
||||
public function currentOrCreate(): Cart
|
||||
{
|
||||
return CartSession::manager();
|
||||
}
|
||||
|
||||
public function addLine(Purchasable $purchasable, int $quantity = 1, array $meta = []): Cart
|
||||
{
|
||||
$cart = $this->currentOrCreate()->add($purchasable, $quantity, $meta);
|
||||
|
||||
$line = app(config('lunar.cart.actions.get_existing_cart_line', GetExistingCartLine::class))
|
||||
->execute($cart, $purchasable, $meta);
|
||||
|
||||
if ($line !== null) {
|
||||
Event::dispatch(new CartLineAdded($cart, $line));
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function updateLine(int $cartLineId, int $quantity, ?array $meta = null): Cart
|
||||
{
|
||||
$before = CartLine::findOrFail($cartLineId);
|
||||
$old = ['quantity' => $before->quantity, 'meta' => $before->meta->toArray()];
|
||||
|
||||
$cart = $this->currentOrCreate()->updateLine($cartLineId, $quantity, $meta);
|
||||
|
||||
$line = $cart->lines->firstWhere('id', $cartLineId);
|
||||
|
||||
if ($line !== null) {
|
||||
Event::dispatch(new CartLineUpdated($cart, $line, $old));
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function removeLine(int $cartLineId): Cart
|
||||
{
|
||||
$line = CartLine::findOrFail($cartLineId);
|
||||
$snapshot = $this->snapshotLine($line);
|
||||
|
||||
$cart = $this->currentOrCreate()->remove($cartLineId);
|
||||
|
||||
Event::dispatch(new CartLineRemoved($cart, $snapshot));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function clear(): Cart
|
||||
{
|
||||
$cart = $this->currentOrCreate();
|
||||
$snapshots = $cart->lines->map($this->snapshotLine(...))->all();
|
||||
|
||||
$cart = $cart->clear();
|
||||
|
||||
Event::dispatch(new CartCleared($cart, $snapshots));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cart's coupon code, which the ApplyDiscounts pipeline step picks
|
||||
* up on the next calculate() — there's no dedicated Lunar action for this
|
||||
* (unlike add/update/remove, coupon_code is a plain cast attribute), so
|
||||
* this is the closest thing to one for a consuming app to call.
|
||||
*
|
||||
* Validated via Discounts::validateCoupon() (does a matching, currently
|
||||
* active, non-exhausted Discount exist?) before it's set — CouponString's
|
||||
* cast only normalizes casing, it doesn't validate anything, so setting
|
||||
* coupon_code directly would silently accept a bogus code and just not
|
||||
* discount anything once calculated.
|
||||
*
|
||||
* @throws InvalidCouponException if the code doesn't match a valid, active,
|
||||
* non-exhausted Discount
|
||||
*/
|
||||
public function applyCoupon(string $code): Cart
|
||||
{
|
||||
if (! Discounts::validateCoupon($code)) {
|
||||
throw new InvalidCouponException($code);
|
||||
}
|
||||
|
||||
$cart = $this->currentOrCreate();
|
||||
$cart->coupon_code = $code;
|
||||
$cart->save();
|
||||
$cart = $cart->recalculate();
|
||||
|
||||
Event::dispatch(new CartCouponApplied($cart, $cart->coupon_code));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function removeCoupon(): Cart
|
||||
{
|
||||
$cart = $this->currentOrCreate();
|
||||
$code = $cart->coupon_code;
|
||||
|
||||
if ($code === null) {
|
||||
return $cart;
|
||||
}
|
||||
|
||||
$cart->coupon_code = null;
|
||||
$cart->save();
|
||||
$cart = $cart->recalculate();
|
||||
|
||||
Event::dispatch(new CartCouponRemoved($cart, $code));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines currently counted toward the cart's totals — everything except
|
||||
* ones flagged meta.saved_for_later (see savedLines()). This is the set a
|
||||
* cart page's main list / checkout would iterate, since a saved line
|
||||
* isn't pending purchase.
|
||||
*
|
||||
* @return Collection<int, CartLine>
|
||||
*/
|
||||
public function activeLines(?Cart $cart = null): Collection
|
||||
{
|
||||
$cart ??= $this->currentOrCreate();
|
||||
|
||||
return $cart->lines->reject(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines a shopper has deliberately parked rather than deleted — excluded
|
||||
* from Cart totals (see Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice)
|
||||
* and from activeLines(). A cart page's "Saved for later" section iterates
|
||||
* this set.
|
||||
*
|
||||
* @return Collection<int, CartLine>
|
||||
*/
|
||||
public function savedLines(?Cart $cart = null): Collection
|
||||
{
|
||||
$cart ??= $this->currentOrCreate();
|
||||
|
||||
return $cart->lines->filter(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a line OUT of the purchasable cart without deleting it — it stays
|
||||
* on the cart (still visible, still re-addable) but is excluded from
|
||||
* totals via meta.saved_for_later, zeroed by ZeroSavedForLaterPrice before
|
||||
* Lunar's own CalculateLines sums the cart (which has no meta-based
|
||||
* exclusion of its own).
|
||||
*/
|
||||
public function saveForLater(int $cartLineId): Cart
|
||||
{
|
||||
$line = CartLine::findOrFail($cartLineId);
|
||||
$meta = [...$line->meta->toArray(), 'saved_for_later' => true];
|
||||
|
||||
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
|
||||
|
||||
$line = $cart->lines->firstWhere('id', $cartLineId);
|
||||
|
||||
if ($line !== null) {
|
||||
Event::dispatch(new CartLineSaved($cart, $line));
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reverse of saveForLater() — moves a line back into the purchasable
|
||||
* cart, counted in totals again.
|
||||
*/
|
||||
public function moveToCart(int $cartLineId): Cart
|
||||
{
|
||||
$line = CartLine::findOrFail($cartLineId);
|
||||
$meta = [...$line->meta->toArray(), 'saved_for_later' => false];
|
||||
|
||||
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
|
||||
|
||||
$line = $cart->lines->firstWhere('id', $cartLineId);
|
||||
|
||||
if ($line !== null) {
|
||||
Event::dispatch(new CartLineMovedToCart($cart, $line));
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}
|
||||
*/
|
||||
private function snapshotLine(CartLine $line): array
|
||||
{
|
||||
return [
|
||||
'id' => $line->id,
|
||||
'purchasable_type' => $line->purchasable_type,
|
||||
'purchasable_id' => $line->purchasable_id,
|
||||
'quantity' => $line->quantity,
|
||||
'meta' => $line->meta->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Contracts;
|
||||
|
||||
use Filament\Forms\Components\Component;
|
||||
|
||||
/**
|
||||
* A Product Option Type describes how a category of Lunar `ProductOption` (e.g.
|
||||
* "Color", "Size", "Material") behaves — namely, what structured data its values
|
||||
* carry in their free-form `meta` jsonb column, and how an admin edits that data.
|
||||
*
|
||||
* `ProductOption`/`ProductOptionValue` themselves stay exactly as Lunar defines
|
||||
* them — this is not a new model. `ProductOptionTypeManager` maps a
|
||||
* `ProductOption::handle` to the type describing it (via `config('core.product_option_types')`,
|
||||
* typed explicitly by the admin), so adding a new kind of option is a single new
|
||||
* class, not scattered per-option special-casing across the admin UI or storefront.
|
||||
*/
|
||||
interface ProductOptionTypeInterface
|
||||
{
|
||||
/**
|
||||
* Matches the ProductOption::handle this type describes (e.g. 'color', 'size').
|
||||
*/
|
||||
public static function getKey(): string;
|
||||
|
||||
/**
|
||||
* Filament form components for editing a ProductOptionValue's `meta` under this
|
||||
* option type — e.g. Color returns a color picker for `meta.hex`, Size returns a
|
||||
* numeric input for `meta.sort_value`. Field names should be dot-notation under
|
||||
* `meta` (e.g. `meta.hex`), matching where ValuesRelationManagerExtension saves them.
|
||||
*
|
||||
* @return array<Component>
|
||||
*/
|
||||
public function getMetaForm(): array;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\DTOs;
|
||||
|
||||
/**
|
||||
* Filter input for CollectionService::list(). All fields are optional — omitted
|
||||
* filters are simply not added to the Meilisearch query. Values are matched
|
||||
* against Modules\Core\Catalog\Services\CollectionIndexer's document fields, so
|
||||
* filtering only works on stores where that indexer is registered and the index
|
||||
* has been re-synced (see docs/product-listing.md).
|
||||
*/
|
||||
class CollectionFilters
|
||||
{
|
||||
/**
|
||||
* @param $parentId children of this specific parent collection.
|
||||
* @param $rootOnly top-level collections only (`parent_id IS NULL`) — mutually
|
||||
* exclusive with $parentId; if both are set, $parentId wins.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?int $parentId = null,
|
||||
public readonly ?int $groupId = null,
|
||||
public readonly bool $rootOnly = false,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\DTOs;
|
||||
|
||||
/**
|
||||
* Filter input for ProductService::list(). All fields are optional — omitted
|
||||
* filters are simply not added to the Meilisearch query. Values are matched
|
||||
* against Modules\Core\Catalog\Services\ProductIndexer's document fields, so
|
||||
* filtering only works on stores where that indexer is registered and the index
|
||||
* has been re-synced (see docs/product-listing.md).
|
||||
*/
|
||||
class ProductFilters
|
||||
{
|
||||
/**
|
||||
* @param $collectionId matches a product in this collection OR any of its
|
||||
* descendant collections (filtered against ProductIndexer's `collection_ids`,
|
||||
* not a direct-assignment-only match) — the right semantics for "products on
|
||||
* this category page", since products are typically attached only to leaf
|
||||
* collections.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?int $collectionId = null,
|
||||
public readonly ?string $brand = null,
|
||||
public readonly ?float $minPrice = null,
|
||||
public readonly ?float $maxPrice = null,
|
||||
public readonly bool $inStockOnly = false,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Enums;
|
||||
|
||||
/**
|
||||
* Sort options for CollectionService::list(), each mapped to a Meilisearch `sort`
|
||||
* clause against a field indexed as sortable by Modules\Core\Catalog\Services\
|
||||
* CollectionIndexer (see its getSortableFields()).
|
||||
*/
|
||||
enum CollectionSort: string
|
||||
{
|
||||
case Position = 'position';
|
||||
case Name = 'name';
|
||||
case Newest = 'newest';
|
||||
|
||||
public function toMeilisearchSort(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Position => '_lft:asc',
|
||||
self::Name => 'name:asc',
|
||||
self::Newest => 'created_at:desc',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Enums;
|
||||
|
||||
/**
|
||||
* Sort options for ProductService::list(), each mapped to a Meilisearch `sort`
|
||||
* clause against a field indexed as sortable by Modules\Core\Catalog\Services\
|
||||
* ProductIndexer (see its getSortableFields()). Adding a case here requires the
|
||||
* matching field to also be sortable in the index, re-synced via
|
||||
* `php artisan lunar:meilisearch:setup`.
|
||||
*/
|
||||
enum ProductSort: string
|
||||
{
|
||||
case PriceAsc = 'price_asc';
|
||||
case PriceDesc = 'price_desc';
|
||||
case Newest = 'newest';
|
||||
|
||||
public function toMeilisearchSort(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PriceAsc => 'price:asc',
|
||||
self::PriceDesc => 'price:desc',
|
||||
self::Newest => 'created_at:desc',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Filament\Extensions;
|
||||
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Form;
|
||||
use Illuminate\Support\Str;
|
||||
use Lunar\Admin\Support\Extending\ResourceExtension;
|
||||
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
|
||||
|
||||
/**
|
||||
* Adds an "Option Type" dropdown to Lunar's own ProductOptionResource form, letting
|
||||
* an admin pick which registered `ProductOptionTypeInterface` (if any) describes this
|
||||
* option's values — e.g. "Color" — independent of the option's own `handle`. The
|
||||
* selection is saved to `ProductOption::meta['option_type']`.
|
||||
*/
|
||||
class ProductOptionResourceExtension extends ResourceExtension
|
||||
{
|
||||
public function extendForm(Form $form): Form
|
||||
{
|
||||
$options = collect(ProductOptionTypeManager::get()->all())
|
||||
->keys()
|
||||
->mapWithKeys(fn (string $key) => [$key => Str::headline($key)])
|
||||
->all();
|
||||
|
||||
if ($options === []) {
|
||||
return $form;
|
||||
}
|
||||
|
||||
return $form->schema([
|
||||
...$form->getComponents(),
|
||||
Select::make('meta.option_type')
|
||||
->label('Option Type')
|
||||
->options($options)
|
||||
->helperText('Controls which meta fields appear when editing this option\'s values.')
|
||||
->native(false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Filament\Extensions;
|
||||
|
||||
use Filament\Forms\Form;
|
||||
use Lunar\Admin\Support\Extending\RelationManagerExtension;
|
||||
use Lunar\Models\ProductOption;
|
||||
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
|
||||
|
||||
/**
|
||||
* Appends the owning `ProductOption`'s registered `ProductOptionTypeInterface` meta
|
||||
* form (if any) to Lunar's own ValuesRelationManager form, so e.g. a "color" option
|
||||
* gets a hex-color picker for each value alongside the stock name field — without
|
||||
* forking Lunar's relation manager.
|
||||
*/
|
||||
class ValuesRelationManagerExtension extends RelationManagerExtension
|
||||
{
|
||||
public function extendForm(Form $form): Form
|
||||
{
|
||||
/** @var ProductOption $option */
|
||||
$option = $this->caller->getOwnerRecord();
|
||||
|
||||
$type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null);
|
||||
|
||||
if ($type === null) {
|
||||
return $form;
|
||||
}
|
||||
|
||||
return $form->schema([
|
||||
...$form->getComponents(),
|
||||
...$type->getMetaForm(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Observers;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Lunar\Models\Product;
|
||||
use Lunar\Models\ProductOption;
|
||||
use Lunar\Models\ProductOptionValue;
|
||||
use Lunar\Models\ProductVariant;
|
||||
|
||||
/**
|
||||
* Keeps every product using a ProductOption/ProductOptionValue in sync with
|
||||
* Meilisearch. ProductIndexer::mapVariant() embeds each option value's `meta`
|
||||
* (e.g. a color's hex) directly into the product's indexed document — but saving
|
||||
* the option or one of its values never fires the *product's* own save/update
|
||||
* events, so without this, a changed option_type or a changed hex would only
|
||||
* reach the index on that product's next unrelated reindex.
|
||||
*/
|
||||
class ProductOptionReindexObserver
|
||||
{
|
||||
public function optionSaved(ProductOption $option): void
|
||||
{
|
||||
$this->reindexProductsForOption($option->id);
|
||||
}
|
||||
|
||||
public function optionDeleted(ProductOption $option): void
|
||||
{
|
||||
$this->reindexProductsForOption($option->id);
|
||||
}
|
||||
|
||||
public function valueSaved(ProductOptionValue $value): void
|
||||
{
|
||||
$this->reindexProductsForValues([$value->id]);
|
||||
}
|
||||
|
||||
public function valueDeleted(ProductOptionValue $value): void
|
||||
{
|
||||
$this->reindexProductsForValues([$value->id]);
|
||||
}
|
||||
|
||||
private function reindexProductsForOption(int $optionId): void
|
||||
{
|
||||
$valueIds = ProductOptionValue::where('product_option_id', $optionId)->pluck('id');
|
||||
|
||||
$this->reindexProductsForValues($valueIds->all());
|
||||
}
|
||||
|
||||
private function reindexProductsForValues(array $valueIds): void
|
||||
{
|
||||
if ($valueIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = config('lunar.database.table_prefix');
|
||||
|
||||
$variantIds = DB::table("{$prefix}product_option_value_product_variant")
|
||||
->whereIn('value_id', $valueIds)
|
||||
->pluck('variant_id');
|
||||
|
||||
if ($variantIds->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$productIds = ProductVariant::whereIn('id', $variantIds)->pluck('product_id')->unique();
|
||||
|
||||
Product::whereIn('id', $productIds)->get()->each->searchable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\OptionTypes;
|
||||
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
use Modules\Core\Catalog\Contracts\ProductOptionTypeInterface;
|
||||
|
||||
/**
|
||||
* Describes a 'color' ProductOption's values as carrying a hex code in
|
||||
* `meta.hex`, editable via a Filament color picker. Registered automatically by
|
||||
* `Modules\Core\Providers\CatalogServiceProvider` — a shop's admin still has to
|
||||
* pick "Color" from the Option Type dropdown per-ProductOption for it to apply.
|
||||
*/
|
||||
class ColorOptionType implements ProductOptionTypeInterface
|
||||
{
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'color';
|
||||
}
|
||||
|
||||
public function getMetaForm(): array
|
||||
{
|
||||
return [
|
||||
ColorPicker::make('meta.hex')
|
||||
->label('Color')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Lunar\Models\Collection;
|
||||
use Lunar\Models\Product;
|
||||
use Lunar\Search\CollectionIndexer as BaseCollectionIndexer;
|
||||
|
||||
/**
|
||||
* Extends Lunar's own indexer so Modules\Core\Catalog\Services\CollectionService can
|
||||
* serve category browsing/nav AND single-collection lookups from Meilisearch alone,
|
||||
* the same reasoning as Modules\Core\Catalog\Services\ProductIndexer. Lunar's base
|
||||
* indexer only carries `id`/`name`/`created_at` — nowhere near enough for a storefront
|
||||
* category page or a nav tree. Adds:
|
||||
* - parent_id, _lft, _rgt (filterable/sortable) — the nested-set tree position, so
|
||||
* CollectionService can resolve "children of X" or build a full tree without a
|
||||
* database read
|
||||
* - collection_group_id (filterable) — mirrors Collection::scopeInGroup()
|
||||
* - slugs (filterable) — every locale's Url::slug, so getBySlug() resolves from the
|
||||
* index directly, no database read
|
||||
* - thumbnail (display) — the collection's thumbnail image URL
|
||||
* - ancestors (display) — [{id, name}, ...] ordered root-first, so a breadcrumb can
|
||||
* render directly from a single indexed document with zero extra queries
|
||||
* - product_count (display) — how many products are in this collection or any of
|
||||
* its descendants, read from the *product* Meilisearch index at collection-index
|
||||
* time (via `collection_ids`, see Modules\Core\Catalog\Services\ProductIndexer) —
|
||||
* matches what ProductService::list(ProductFilters(collectionId: ...)) would
|
||||
* return, not just direct assignment. Reflects the product index's state as of
|
||||
* the last collection reindex, so re-run `lunar:search:index --refresh` after a
|
||||
* product reindex if this needs to be current.
|
||||
*
|
||||
* New fields aren't filterable/sortable in Meilisearch until `php artisan
|
||||
* lunar:meilisearch:setup` re-syncs index settings, and existing documents need
|
||||
* `lunar:search:index --refresh` to pick up the new shape.
|
||||
*/
|
||||
class CollectionIndexer extends BaseCollectionIndexer
|
||||
{
|
||||
public function getFilterableFields(): array
|
||||
{
|
||||
return [
|
||||
...parent::getFilterableFields(),
|
||||
'id',
|
||||
'parent_id',
|
||||
'_lft',
|
||||
'collection_group_id',
|
||||
'slugs',
|
||||
];
|
||||
}
|
||||
|
||||
public function getSortableFields(): array
|
||||
{
|
||||
return [
|
||||
...parent::getSortableFields(),
|
||||
'_lft',
|
||||
];
|
||||
}
|
||||
|
||||
public function makeAllSearchableUsing(Builder $query): Builder
|
||||
{
|
||||
return parent::makeAllSearchableUsing($query)->with(['urls', 'media', 'ancestors']);
|
||||
}
|
||||
|
||||
public function toSearchableArray(Model $model): array
|
||||
{
|
||||
/** @var Collection $model */
|
||||
$data = parent::toSearchableArray($model);
|
||||
|
||||
$data['parent_id'] = $model->parent_id;
|
||||
$data['_lft'] = $model->_lft;
|
||||
$data['_rgt'] = $model->_rgt;
|
||||
$data['collection_group_id'] = $model->collection_group_id;
|
||||
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
||||
$data['thumbnail'] = $model->getThumbnailImage() ?: null;
|
||||
$data['ancestors'] = $model->ancestors
|
||||
->sortBy('_lft')
|
||||
->map(fn ($ancestor) => [
|
||||
'id' => $ancestor->id,
|
||||
'name' => $ancestor->translateAttribute('name'),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
$data['product_count'] = Product::search('')
|
||||
->options(['filter' => "collection_ids = \"{$model->id}\""])
|
||||
->paginateRaw(perPage: 1, page: 1)
|
||||
->total();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Lunar\Base\AttributeManifest;
|
||||
use Lunar\FieldTypes\TranslatedText;
|
||||
use Lunar\Models\Collection as CollectionModel;
|
||||
use Modules\Core\Catalog\DTOs\CollectionFilters;
|
||||
use Modules\Core\Catalog\Enums\CollectionSort;
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
|
||||
/**
|
||||
* Category browsing (tree/nav) AND single-collection lookup, all reading directly
|
||||
* from the Meilisearch index (Modules\Core\Catalog\Services\CollectionIndexer) — same
|
||||
* shape and reasoning as Modules\Core\Catalog\Services\ProductService. Callers get
|
||||
* plain arrays of the indexed document, not Eloquent models.
|
||||
*/
|
||||
class CollectionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LanguageCache $languages,
|
||||
private readonly AttributeManifest $attributes,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result — see
|
||||
* ProductService's "Meilisearch driver quirk" note) so a controller/view gets
|
||||
* normal pagination behaviour without ever touching the raw Meilisearch response.
|
||||
*/
|
||||
public function list(?CollectionFilters $filters = null, int $perPage = 24, int $page = 1, ?CollectionSort $sort = null): LengthAwarePaginator
|
||||
{
|
||||
$options = ['filter' => $this->buildFilter($filters)];
|
||||
|
||||
if ($sort !== null) {
|
||||
$options['sort'] = [$sort->toMeilisearchSort()];
|
||||
}
|
||||
|
||||
$paginator = CollectionModel::search('')
|
||||
->options($options)
|
||||
->paginateRaw(perPage: $perPage, page: $page);
|
||||
|
||||
$data = collect($this->hitsFrom($paginator))
|
||||
->map(fn (array $collection) => $this->withLocalizedFields($collection))
|
||||
->all();
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
items: $data,
|
||||
total: $paginator->total(),
|
||||
perPage: $paginator->perPage(),
|
||||
currentPage: $paginator->currentPage(),
|
||||
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a single collection by its URL slug (any locale). Returns the full
|
||||
* indexed collection document, or null if no collection has that slug.
|
||||
*/
|
||||
public function getBySlug(string $slug): ?array
|
||||
{
|
||||
return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a single collection by its primary key. Returns the full indexed
|
||||
* collection document, or null if no collection has that id.
|
||||
*/
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
return $this->findOneWhere("id = \"{$id}\"");
|
||||
}
|
||||
|
||||
private function findOneWhere(string $filter): ?array
|
||||
{
|
||||
$paginator = CollectionModel::search('')
|
||||
->options(['filter' => $filter])
|
||||
->paginateRaw(perPage: 1, page: 1);
|
||||
|
||||
$collection = $this->hitsFrom($paginator)[0] ?? null;
|
||||
|
||||
return $collection !== null ? $this->withLocalizedFields($collection) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every translated Collection attribute's current-locale value — same
|
||||
* logic as ProductService::withLocalizedFields(), see there for the full
|
||||
* reasoning (AttributeManifest-driven, store-default-locale fallback, raw
|
||||
* per-locale keys stripped after resolving).
|
||||
*/
|
||||
private function withLocalizedFields(array $collection): array
|
||||
{
|
||||
$locale = App::getLocale();
|
||||
$fallbackLocale = $this->languages->defaultLocale();
|
||||
$availableLocales = $this->languages->availableLocales();
|
||||
|
||||
foreach ($this->translatedAttributeHandles() as $handle) {
|
||||
$collection[$handle] = $collection[$handle.'_'.$locale] ?? $collection[$handle.'_'.$fallbackLocale] ?? null;
|
||||
|
||||
foreach ($availableLocales as $availableLocale) {
|
||||
unset($collection[$handle.'_'.$availableLocale]);
|
||||
}
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function translatedAttributeHandles(): array
|
||||
{
|
||||
return $this->attributes->getSearchableAttributes((new CollectionModel)->getMorphClass())
|
||||
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
|
||||
->pluck('handle')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
|
||||
* in items(), not a plain list of hits — see ProductService's identical note.
|
||||
*/
|
||||
private function hitsFrom(LengthAwarePaginatorContract $paginator): array
|
||||
{
|
||||
$rawResponse = $paginator->items();
|
||||
|
||||
return collect($rawResponse['hits'] ?? [])->values()->all();
|
||||
}
|
||||
|
||||
private function buildFilter(?CollectionFilters $filters): ?string
|
||||
{
|
||||
if ($filters === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clauses = Collection::make([
|
||||
$filters->parentId !== null ? "parent_id = \"{$filters->parentId}\""
|
||||
: ($filters->rootOnly ? 'parent_id IS NULL' : null),
|
||||
$filters->groupId !== null ? "collection_group_id = \"{$filters->groupId}\"" : null,
|
||||
])->filter();
|
||||
|
||||
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Lunar\Models\Currency;
|
||||
use Lunar\Models\Price;
|
||||
use Lunar\Models\Product;
|
||||
use Lunar\Models\ProductVariant;
|
||||
use Lunar\Search\ProductIndexer as BaseProductIndexer;
|
||||
use Modules\Core\Review\Models\ProductReview;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
/**
|
||||
* Extends Lunar's own indexer so Modules\Core\Catalog\Services\ProductService can
|
||||
* serve both listing/filtering AND single-product lookups from Meilisearch alone —
|
||||
* one data source, no separate database read path for a product detail page. Adds:
|
||||
* - collections: [{id, name}, ...] — directly assigned collections only, for
|
||||
* display (breadcrumbs, "also in"). Not filterable — see collection_ids below.
|
||||
* - collection_ids (filterable): flat array of every directly-assigned collection's
|
||||
* id UNIONED with all of its ancestors' ids. Products are typically attached only
|
||||
* to leaf collections in a Shopify-imported tree, so a plain `collections.id`
|
||||
* filter would never match a parent/root category page — ProductService::list()
|
||||
* filters `collectionId` against this field instead, so "products in category X"
|
||||
* also picks up every product attached only to one of X's subcategories.
|
||||
* - slugs (every locale's Url::slug for the product, filterable) — lets
|
||||
* ProductService::getBySlug() resolve a product from the index directly, with
|
||||
* no database read at all
|
||||
* - price (cheapest variant, filterable) and full per-variant pricing
|
||||
* - variants: sku, stock, purchasable, option values, prices, media
|
||||
* - the full media gallery (not just the single thumbnail Lunar's base indexer sends)
|
||||
* - tags
|
||||
* - reviews: {items: [...], count, average_rating} — items are public-safe fields
|
||||
* only (see mapReview() — reviewer_email is deliberately excluded, it's PII with
|
||||
* no storefront use), including staff replies
|
||||
* - channel_ids (filterable) — Lunar's base indexer only indexes "status" as
|
||||
* filterable, not channel assignment, so search results can't otherwise be
|
||||
* scoped to products actually assigned+enabled on the current sales channel
|
||||
* - in_stock (filterable) — true if ANY variant can currently be purchased at
|
||||
* quantity 1, via ProductVariant::canBeFulfilledAtQuantity() (Lunar's own
|
||||
* purchasability rule: `purchasable === 'always'` is always true regardless of
|
||||
* stock, `in_stock` checks stock alone, anything else checks stock+backorder).
|
||||
* Reflects stock as of the last reindex only — nothing currently reindexes a
|
||||
* product when an order decrements its stock (see docs/product-listing.md).
|
||||
*
|
||||
* A review is created/edited independently of its product (Modules\Core\Providers\
|
||||
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
|
||||
* this data doesn't go stale between full reindexes.
|
||||
*
|
||||
* New fields aren't filterable in Meilisearch until `php artisan lunar:meilisearch:setup`
|
||||
* re-syncs index settings, and existing documents need `lunar:search:index --refresh` to
|
||||
* pick up the new shape — see docs/product-listing.md. If SCOUT_QUEUE is enabled, the
|
||||
* queue worker also needs restarting after deploying changes to this class (see
|
||||
* docs/lunar.md "Gotchas" — a running worker keeps stale indexer code in memory).
|
||||
*/
|
||||
class ProductIndexer extends BaseProductIndexer
|
||||
{
|
||||
public function getFilterableFields(): array
|
||||
{
|
||||
return [
|
||||
...parent::getFilterableFields(),
|
||||
'id',
|
||||
'brand',
|
||||
'collection_ids',
|
||||
'price',
|
||||
'slugs',
|
||||
'channel_ids',
|
||||
'in_stock',
|
||||
];
|
||||
}
|
||||
|
||||
public function getSortableFields(): array
|
||||
{
|
||||
return [
|
||||
...parent::getSortableFields(),
|
||||
'price',
|
||||
];
|
||||
}
|
||||
|
||||
public function makeAllSearchableUsing(Builder $query): Builder
|
||||
{
|
||||
return parent::makeAllSearchableUsing($query)->with([
|
||||
'collections',
|
||||
'collections.ancestors',
|
||||
'media',
|
||||
'tags',
|
||||
'urls',
|
||||
'variants.images',
|
||||
'variants.prices',
|
||||
'variants.values.option',
|
||||
]);
|
||||
}
|
||||
|
||||
public function toSearchableArray(Model $model): array
|
||||
{
|
||||
/** @var Product $model */
|
||||
$data = parent::toSearchableArray($model);
|
||||
|
||||
$currency = Currency::getDefault();
|
||||
$reviews = ProductReview::where('product_id', $model->id)->with('media')->get();
|
||||
|
||||
$data['collections'] = $model->collections->map(fn ($collection) => [
|
||||
'id' => $collection->id,
|
||||
'name' => $collection->translateAttribute('name'),
|
||||
])->all();
|
||||
$data['collection_ids'] = $model->collections
|
||||
->flatMap(fn ($collection) => [$collection->id, ...$collection->ancestors->pluck('id')])
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
||||
$data['tags'] = $model->tags->pluck('value')->all();
|
||||
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
|
||||
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
|
||||
$data['price'] = $this->cheapestPrice($model, $currency);
|
||||
$data['reviews'] = [
|
||||
'items' => $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(),
|
||||
'count' => $reviews->count(),
|
||||
'average_rating' => $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1),
|
||||
];
|
||||
$data['channel_ids'] = $model->channels()
|
||||
->wherePivot('enabled', true)
|
||||
->pluck('lunar_channels.id')
|
||||
->toArray();
|
||||
$data['in_stock'] = $model->variants->contains(
|
||||
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
|
||||
);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function mapVariant(ProductVariant $variant, Currency $currency): array
|
||||
{
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
'sku' => $variant->sku,
|
||||
'stock' => $variant->stock,
|
||||
'purchasable' => $variant->purchasable,
|
||||
'options' => $variant->values->map(fn ($value) => [
|
||||
'option' => $this->translatedName($value->option->name),
|
||||
'handle' => $value->option->handle,
|
||||
'value' => $this->translatedName($value->name),
|
||||
'meta' => $value->meta,
|
||||
])->all(),
|
||||
'prices' => $variant->prices->map(fn (Price $price) => [
|
||||
'currency_id' => $price->currency_id,
|
||||
'customer_group_id' => $price->customer_group_id,
|
||||
'price' => $price->price->decimal(),
|
||||
'compare_price' => $price->compare_price?->decimal(),
|
||||
'min_quantity' => $price->min_quantity,
|
||||
])->all(),
|
||||
'media' => $variant->images->map(fn (Media $media) => $this->mapMedia($media))->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Public-safe fields only — reviewer_email is PII with no storefront use and is
|
||||
* deliberately excluded, unlike every other column on the review. reply/replied_at
|
||||
* (the staff response) are included since they're meant to be shown alongside the
|
||||
* review on the storefront.
|
||||
*/
|
||||
private function mapReview(ProductReview $review): array
|
||||
{
|
||||
return [
|
||||
'id' => $review->id,
|
||||
'title' => $review->title,
|
||||
'body' => $review->body,
|
||||
'rating' => $review->rating,
|
||||
'reviewed_at' => $review->reviewed_at?->timestamp,
|
||||
'reviewer_name' => $review->reviewer_name,
|
||||
'reply' => $review->reply,
|
||||
'replied_at' => $review->replied_at?->timestamp,
|
||||
'location' => $review->location,
|
||||
'media' => $review->media->map(fn (Media $media) => $this->mapMedia($media))->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ProductOption/ProductOptionValue's `name` is a plain locale-keyed array cast
|
||||
* (AsArrayObject) directly on the column — unlike Product/Collection/Brand, it is
|
||||
* not stored in attribute_data. Lunar's translateAttribute() only reads
|
||||
* attribute_data, so it silently returns null for these two models; this reads
|
||||
* the array directly instead. Falls back to the first available locale if the
|
||||
* current one is missing. Not a general replacement for translateAttribute() —
|
||||
* every other translated field in this indexer (product/collection name and
|
||||
* description) genuinely is attribute_data-backed and translateAttribute() is
|
||||
* correct for those.
|
||||
*/
|
||||
private function translatedName(mixed $name): ?string
|
||||
{
|
||||
$names = is_array($name) ? $name : (array) $name;
|
||||
|
||||
return $names[app()->getLocale()] ?? reset($names) ?: null;
|
||||
}
|
||||
|
||||
private function mapMedia(Media $media): array
|
||||
{
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'url' => $media->getUrl(),
|
||||
'thumb' => $media->getUrl('small'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The cheapest variant's base price (no customer group) in the default currency,
|
||||
* as a float in major units — e.g. 19.99, not 1999. Null if the product has no
|
||||
* variant with a price in that currency yet, so it's excluded from price filters
|
||||
* rather than sorting to the bottom as if it were free.
|
||||
*/
|
||||
private function cheapestPrice(Product $model, Currency $currency): ?float
|
||||
{
|
||||
$price = $model->variants
|
||||
->flatMap(fn ($variant) => $variant->prices)
|
||||
->filter(fn ($price) => $price->currency_id === $currency->id && $price->customer_group_id === null)
|
||||
->min(fn ($price) => $price->price->value);
|
||||
|
||||
return $price !== null ? $price / (10 ** $currency->decimal_places) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Modules\Core\Catalog\Contracts\ProductOptionTypeInterface;
|
||||
|
||||
/**
|
||||
* Resolves an admin-selected option type key to the `ProductOptionTypeInterface`
|
||||
* describing it. The selection (which key a given `Lunar\Models\ProductOption` uses)
|
||||
* is stored per-option in `ProductOption::meta['option_type']` — deliberately not
|
||||
* tied to the option's `handle`, since a shop's own handle naming (e.g. transliterated
|
||||
* Greek, legacy imports) shouldn't have to match a type's key.
|
||||
*
|
||||
* A singleton registry, same shape as `Modules\Core\Notification\NotificationRegistry`
|
||||
* — a consuming app calls `ProductOptionTypeManager::get()->register([...])` from its
|
||||
* own service provider `boot()`, rather than listing classes in a published config
|
||||
* file.
|
||||
*/
|
||||
class ProductOptionTypeManager
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
|
||||
/** @var array<string, class-string<ProductOptionTypeInterface>> */
|
||||
private array $types = [];
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function get(): static
|
||||
{
|
||||
if (static::$instance === null) {
|
||||
static::$instance = new static();
|
||||
}
|
||||
|
||||
return static::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<class-string<ProductOptionTypeInterface>> $types
|
||||
*/
|
||||
public function register(array $types): void
|
||||
{
|
||||
foreach ($types as $class) {
|
||||
$this->types[$class::getKey()] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
public function unregister(string $key): void
|
||||
{
|
||||
unset($this->types[$key]);
|
||||
}
|
||||
|
||||
public function resolve(?string $key): ?ProductOptionTypeInterface
|
||||
{
|
||||
if ($key === null || ! isset($this->types[$key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app($this->types[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, class-string<ProductOptionTypeInterface>>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->types;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Lunar\Facades\AttributeManifest;
|
||||
use Lunar\Models\Language;
|
||||
use Lunar\Models\Product;
|
||||
|
||||
/**
|
||||
* Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed
|
||||
* fields on a single document (name_en, name_el, description_en, description_el —
|
||||
* see Lunar\Search\ScoutIndexer::mapSearchableAttributes()), not separate indexes
|
||||
* or a filterable locale field. Locale-aware search means choosing which fields
|
||||
* to search on, not filtering results by locale.
|
||||
*/
|
||||
class ProductSearchService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, Product>
|
||||
*/
|
||||
public function search(string $query, ?string $locale = null): Collection
|
||||
{
|
||||
$locale ??= App::getLocale();
|
||||
$defaultLocale = Language::getDefault()->code;
|
||||
|
||||
return Product::search($query)
|
||||
->options([
|
||||
'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale),
|
||||
])
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Target the resolved locale's fields plus the default locale's fields, so a
|
||||
* product that's only ever been translated into the default language still
|
||||
* surfaces when searched in another locale, instead of becoming invisible
|
||||
* until every product is fully translated.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function searchableFields(string $locale, string $defaultLocale): array
|
||||
{
|
||||
$handles = AttributeManifest::getSearchableAttributes(Product::morphName())
|
||||
->pluck('handle');
|
||||
|
||||
$locales = array_unique([$locale, $defaultLocale]);
|
||||
|
||||
return $handles
|
||||
->crossJoin($locales)
|
||||
->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}")
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Lunar\Base\AttributeManifest;
|
||||
use Lunar\FieldTypes\TranslatedText;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
|
||||
/**
|
||||
* Storefront product listing/filtering AND single-product lookup, all reading directly
|
||||
* from the Meilisearch index (Modules\Core\Catalog\Services\ProductIndexer) - one data
|
||||
* source, no ->get() model hydration anywhere in this service. Callers get plain arrays
|
||||
* of the indexed document, not Eloquent models.
|
||||
*
|
||||
* Full-text query search lives separately in Modules\Core\Catalog\Services\
|
||||
* ProductSearchService; this service is for browsing/filtering without a search term.
|
||||
*/
|
||||
class ProductService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LanguageCache $languages,
|
||||
private readonly AttributeManifest $attributes,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result -
|
||||
* see "Meilisearch driver quirk" below) so a controller/view gets normal
|
||||
* pagination behaviour ($products->links(), JSON serialization, etc.)
|
||||
* without ever touching the raw Meilisearch response directly.
|
||||
*/
|
||||
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator
|
||||
{
|
||||
$options = ['filter' => $this->buildFilter($filters)];
|
||||
|
||||
if ($sort !== null) {
|
||||
$options['sort'] = [$sort->toMeilisearchSort()];
|
||||
}
|
||||
|
||||
$paginator = Product::search('')
|
||||
->options($options)
|
||||
->paginateRaw(perPage: $perPage, page: $page);
|
||||
|
||||
$data = collect($this->hitsFrom($paginator))
|
||||
->map(fn (array $product) => $this->withLocalizedFields($product))
|
||||
->all();
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
items: $data,
|
||||
total: $paginator->total(),
|
||||
perPage: $paginator->perPage(),
|
||||
currentPage: $paginator->currentPage(),
|
||||
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Facet value counts for the given filter/field, scoped to the SAME filters
|
||||
* `list()` would apply. Note this does NOT exclude `$field` itself from
|
||||
* `$filters` — e.g. `facets('brand', new ProductFilters(brand: 'Acme'))` would
|
||||
* scope the counts to only "Acme" already, collapsing every other brand's count
|
||||
* to whatever remains under that filter. For a standard "faceted sidebar" (every
|
||||
* brand's count reflecting collection/price/stock filters but NOT the brand
|
||||
* filter itself), build a `$filters` that omits the field being faceted on and
|
||||
* apply that field's own filter separately in the UI/query layer.
|
||||
*
|
||||
* `$field` must be one of ProductIndexer's filterable fields; only discrete-value
|
||||
* fields make sense here (`brand`, `in_stock`) — a numeric field like `price`
|
||||
* would return one "facet" per exact price, not a usable range bucket. Use
|
||||
* `priceRange()` for `price` instead.
|
||||
*
|
||||
* @return array<string, int> facet value => matching product count
|
||||
*/
|
||||
public function facets(string $field, ?ProductFilters $filters = null): array
|
||||
{
|
||||
return $this->rawFacets($field, $this->buildFilter($filters))['facetDistribution'][$field] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The min/max `price` across products matching the given filters (minus
|
||||
* `minPrice`/`maxPrice` themselves, same "scoped but not self-collapsing"
|
||||
* reasoning as `facets()` — a price slider's own bounds shouldn't shrink to
|
||||
* whatever range is currently selected). Backed by Meilisearch's `facetStats`,
|
||||
* not `facetDistribution` — the right feature for a numeric field's range,
|
||||
* where `facets('price')` would otherwise return one entry per exact price.
|
||||
*
|
||||
* @return array{min: ?float, max: ?float} null/null if no product matches
|
||||
*/
|
||||
public function priceRange(?ProductFilters $filters = null): array
|
||||
{
|
||||
$filter = $this->buildFilter($filters, exclude: ['price']);
|
||||
$stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null;
|
||||
|
||||
return [
|
||||
'min' => $stats['min'] ?? null,
|
||||
'max' => $stats['max'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function rawFacets(string $field, ?string $filter): array
|
||||
{
|
||||
return Product::search('')
|
||||
->options([
|
||||
'filter' => $filter,
|
||||
'facets' => [$field],
|
||||
'hitsPerPage' => 0,
|
||||
])
|
||||
->raw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a single product by its URL slug (any locale - slugs are indexed across
|
||||
* all languages, see Modules\Core\Catalog\Services\ProductIndexer). Returns the full
|
||||
* indexed product document, or null if no product has that slug.
|
||||
*/
|
||||
public function getBySlug(string $slug): ?array
|
||||
{
|
||||
return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a single product by its primary key. Returns the full indexed product
|
||||
* document, or null if no product has that id.
|
||||
*/
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
return $this->findOneWhere("id = \"{$id}\"");
|
||||
}
|
||||
|
||||
private function findOneWhere(string $filter): ?array
|
||||
{
|
||||
$paginator = Product::search('')
|
||||
->options(['filter' => $filter])
|
||||
->paginateRaw(perPage: 1, page: 1);
|
||||
|
||||
$product = $this->hitsFrom($paginator)[0] ?? null;
|
||||
|
||||
return $product !== null ? $this->withLocalizedFields($product) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every translated Product attribute's current-locale value from the
|
||||
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
|
||||
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
|
||||
* default language (LanguageCache::defaultLocale()) when the current locale
|
||||
* has no translation - e.g. a product with no English copy yet still shows its
|
||||
* Greek name on /en/ rather than rendering blank.
|
||||
*
|
||||
* Which handles are translated is read from AttributeManifest - the same
|
||||
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
|
||||
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
|
||||
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
|
||||
* picked up automatically with no change here. The raw per-locale keys are
|
||||
* then stripped, since once resolved, callers only ever need the one that
|
||||
* matched the current locale.
|
||||
*
|
||||
* Deliberately not config('app.locale') - App::setLocale() overwrites that
|
||||
* config value on every request, so by request time it's just whatever the
|
||||
* current locale already is, not a stable fallback.
|
||||
*/
|
||||
private function withLocalizedFields(array $product): array
|
||||
{
|
||||
$locale = App::getLocale();
|
||||
$fallbackLocale = $this->languages->defaultLocale();
|
||||
$availableLocales = $this->languages->availableLocales();
|
||||
|
||||
foreach ($this->translatedAttributeHandles() as $handle) {
|
||||
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
|
||||
|
||||
foreach ($availableLocales as $availableLocale) {
|
||||
unset($product[$handle.'_'.$availableLocale]);
|
||||
}
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function translatedAttributeHandles(): array
|
||||
{
|
||||
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
|
||||
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
|
||||
->pluck('handle')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
|
||||
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
|
||||
* actual documents are under the 'hits' key.
|
||||
*/
|
||||
private function hitsFrom(LengthAwarePaginatorContract $paginator): array
|
||||
{
|
||||
$rawResponse = $paginator->items();
|
||||
|
||||
return collect($rawResponse['hits'] ?? [])->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, 'collectionId'|'brand'|'price'|'inStockOnly'> $exclude filter
|
||||
* fields to leave out even if set on $filters — e.g. priceRange() excludes
|
||||
* 'price' so a price slider's own bounds don't shrink to whatever range is
|
||||
* already selected on it.
|
||||
*/
|
||||
private function buildFilter(?ProductFilters $filters, array $exclude = []): ?string
|
||||
{
|
||||
if ($filters === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clauses = Collection::make([
|
||||
'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
|
||||
'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
|
||||
'price' => Collection::make([
|
||||
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
|
||||
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
|
||||
])->filter()->join(' AND ') ?: null,
|
||||
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
|
||||
])->except($exclude)->filter();
|
||||
|
||||
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Checkout\Events;
|
||||
|
||||
use Lunar\Base\Addressable;
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
/**
|
||||
* Dispatched by CheckoutService::setBillingAddress() — see
|
||||
* ShippingAddressSet's docblock for the full reasoning (Lunar dispatches no
|
||||
* checkout-lifecycle events; this feeds funnel-stage tracking, not built
|
||||
* yet).
|
||||
*/
|
||||
class BillingAddressSet
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly array|Addressable $address,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Checkout\Events;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by CheckoutService::placeOrder() the moment an Order exists —
|
||||
* the handoff point between Checkout and Order (see docs/checkout.md's
|
||||
* "Three-stage lifecycle"). Checkout has no opinion about what happens
|
||||
* after this fires; Order's own listeners (not built yet — Order is a
|
||||
* named-but-unscoped concern, same status Recovery had before it existed)
|
||||
* would be what reacts to it — e.g. a confirmation email, initializing
|
||||
* order status tracking.
|
||||
*/
|
||||
class OrderPlaced
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Checkout\Events;
|
||||
|
||||
use Lunar\Base\Addressable;
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
/**
|
||||
* Dispatched by CheckoutService::setShippingAddress() — Lunar itself
|
||||
* dispatches no checkout-lifecycle events at all (same gap CartService's
|
||||
* events fill for cart mutations; see docs/cart.md). Feeds
|
||||
* abandoned-checkout stage tracking / conversion-funnel analytics (neither
|
||||
* built yet — see docs/checkout.md), which is why $address is carried
|
||||
* directly rather than requiring a listener to re-read it off the cart.
|
||||
*/
|
||||
class ShippingAddressSet
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly array|Addressable $address,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Checkout\Events;
|
||||
|
||||
use Lunar\DataTypes\ShippingOption;
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
/**
|
||||
* Dispatched by CheckoutService::selectShippingOption() — carries the fully
|
||||
* resolved ShippingOption (name, price, carrier identifier), not just the
|
||||
* string identifier the caller passed in. Deliberate divergence from
|
||||
* CartService's events, which carry a plain Cart/CartLine model reference —
|
||||
* a live-priced carrier quote (see docs/checkout.md's note on
|
||||
* ShippingManifest::getOptions() already being backed by the merged
|
||||
* Shipping-Carriers ACS/Box Now live-rate drivers) is meaningfully more
|
||||
* expensive for a listener to re-derive later than a CartLine reference is.
|
||||
*/
|
||||
class ShippingOptionSelected
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly ShippingOption $option,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Checkout\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown by CheckoutService::selectShippingOption() when the given
|
||||
* identifier doesn't resolve to a real, currently-available ShippingOption
|
||||
* for the cart — Lunar's own ShippingManifest::getOption() just returns
|
||||
* null, it has no matching exception type of its own to reuse here (same
|
||||
* reasoning as Modules\Core\Cart\Exceptions\InvalidCouponException for
|
||||
* Discounts::validateCoupon()).
|
||||
*/
|
||||
class InvalidShippingOptionException extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly string $identifier)
|
||||
{
|
||||
parent::__construct("The shipping option \"{$identifier}\" is not available for this cart.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Checkout\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Lunar\Base\Addressable;
|
||||
use Lunar\DataTypes\ShippingOption;
|
||||
use Lunar\Facades\ShippingManifest;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\Checkout\Events\BillingAddressSet;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Checkout\Events\ShippingAddressSet;
|
||||
use Modules\Core\Checkout\Events\ShippingOptionSelected;
|
||||
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
||||
|
||||
/**
|
||||
* Storefront-facing checkout operations, mirroring
|
||||
* Modules\Core\Cart\Services\CartService's shape — one boboko-owned API a
|
||||
* storefront calls, keeping Lunar's own Cart/ShippingManifest primitives an
|
||||
* implementation detail. See docs/checkout.md for the full design —
|
||||
* Checkout is the middle of a three-stage lifecycle (Cart → Checkout →
|
||||
* Order): it owns the placement moment itself (address, shipping selection,
|
||||
* placeOrder()) and ends the instant an Order exists. What happens to that
|
||||
* Order afterward (status transitions, fulfillment) is deliberately out of
|
||||
* scope here — see OrderPlaced's docblock.
|
||||
*
|
||||
* Depends on CartService for cart access rather than reaching into
|
||||
* Lunar\Facades\CartSession directly a second time, so Checkout stays
|
||||
* layered on top of Cart's own service boundary instead of duplicating it.
|
||||
*/
|
||||
class CheckoutService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CartService $cart,
|
||||
) {}
|
||||
|
||||
public function setShippingAddress(array|Addressable $address): Cart
|
||||
{
|
||||
$cart = $this->cart->currentOrCreate()->setShippingAddress($address);
|
||||
|
||||
Event::dispatch(new ShippingAddressSet($cart, $address));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function setBillingAddress(array|Addressable $address): Cart
|
||||
{
|
||||
$cart = $this->cart->currentOrCreate()->setBillingAddress($address);
|
||||
|
||||
Event::dispatch(new BillingAddressSet($cart, $address));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every shipping option currently available for the cart — already
|
||||
* fully backed by the merged Shipping-Carriers work: this runs every
|
||||
* registered Lunar\Shipping\Interfaces\ShippingRateInterface driver
|
||||
* (ACS/Box Now live-rate quoting alongside table-rate-shipping's own
|
||||
* flat-rate/free-shipping/collection drivers) through
|
||||
* ShippingManifest's pipeline. No rate-resolution logic lives here —
|
||||
* this is a thin pass-through.
|
||||
*
|
||||
* @return Collection<int, ShippingOption>
|
||||
*/
|
||||
public function getShippingOptions(): Collection
|
||||
{
|
||||
return ShippingManifest::getOptions($this->cart->currentOrCreate());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidShippingOptionException if $identifier doesn't resolve
|
||||
* to a real, currently-available option for the cart
|
||||
*/
|
||||
public function selectShippingOption(string $identifier): Cart
|
||||
{
|
||||
$cartBefore = $this->cart->currentOrCreate();
|
||||
$option = ShippingManifest::getOption($cartBefore, $identifier);
|
||||
|
||||
if ($option === null) {
|
||||
throw new InvalidShippingOptionException($identifier);
|
||||
}
|
||||
|
||||
$cart = $cartBefore->setShippingOption($option);
|
||||
|
||||
Event::dispatch(new ShippingOptionSelected($cart, $option));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* $fingerprint is mandatory, not optional — the caller must prove the
|
||||
* cart total the shopper last saw (Cart::fingerprint()) still matches
|
||||
* before an order is placed. Cart::checkFingerprint() throws Lunar's own
|
||||
* FingerprintMismatchException on a mismatch (a line's price changed,
|
||||
* stock adjusted the total, another tab modified the cart) rather than
|
||||
* silently placing an order at a different total than what was shown.
|
||||
*
|
||||
* No exception wrapping: Lunar\Validation\Cart\ValidateCartForOrderCreation
|
||||
* (run inside Cart::createOrder()) already throws
|
||||
* Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag
|
||||
* ($exception->errors()) for address/shipping-option validation and the
|
||||
* duplicate-order guard — already the right shape for a storefront to
|
||||
* render as form errors directly. FingerprintMismatchException
|
||||
* propagates the same way, for the same reason.
|
||||
*
|
||||
* @throws \Lunar\Exceptions\FingerprintMismatchException
|
||||
* @throws \Lunar\Exceptions\Carts\CartException
|
||||
*/
|
||||
public function placeOrder(string $fingerprint): Order
|
||||
{
|
||||
$cart = $this->cart->currentOrCreate();
|
||||
$cart->checkFingerprint($fingerprint);
|
||||
|
||||
$order = $cart->createOrder();
|
||||
|
||||
Event::dispatch(new OrderPlaced($order));
|
||||
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ use Lunar\Models\Product;
|
||||
use Lunar\Models\ProductType;
|
||||
use Lunar\Models\TaxClass;
|
||||
use Lunar\Models\TaxZone;
|
||||
use Modules\Core\Localization\Models\LanguageLine;
|
||||
use Modules\Core\Localization\Services\StorefrontLabels;
|
||||
use Modules\Core\Localization\Services\TranslationService;
|
||||
|
||||
/**
|
||||
* Overrides Lunar's own lunar:install to skip the interactive prompts (migrate
|
||||
@@ -31,7 +34,7 @@ class InstallLunarCommand extends Command
|
||||
|
||||
protected $description = 'Seed the default Lunar store data (countries, channel, currency, tax zone, attributes, product type)';
|
||||
|
||||
public function handle(): void
|
||||
public function handle(TranslationService $translations): void
|
||||
{
|
||||
$this->components->info('Seeding default Lunar store data...');
|
||||
|
||||
@@ -241,9 +244,38 @@ class InstallLunarCommand extends Command
|
||||
}
|
||||
});
|
||||
|
||||
$this->components->info('Seeding storefront label translations');
|
||||
$this->seedStorefrontLabels($translations);
|
||||
|
||||
$this->components->info('Publishing Filament assets');
|
||||
$this->call('filament:assets');
|
||||
|
||||
$this->components->info('Lunar default data seeded.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-key upsert, not an all-or-nothing "only seed if the group is empty" guard —
|
||||
* a key already present in the database (including one an admin has since edited
|
||||
* via the Filament Languages resource) is left untouched; only keys missing
|
||||
* entirely are created. This is what makes it safe to add new keys to
|
||||
* StorefrontLabels later and re-run this on an already-installed store without
|
||||
* either skipping the new keys (the old all-or-nothing guard) or reverting an
|
||||
* admin's edits back to the hardcoded default (a naive updateOrCreate would).
|
||||
*/
|
||||
private function seedStorefrontLabels(TranslationService $translations): void
|
||||
{
|
||||
$labels = StorefrontLabels::all();
|
||||
|
||||
$existingKeys = LanguageLine::where('group', 'storefront')
|
||||
->whereIn('key', array_keys($labels))
|
||||
->pluck('key');
|
||||
|
||||
foreach ($labels as $key => $text) {
|
||||
if ($existingKeys->contains($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$translations->create('storefront', $key, $text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-2
@@ -6,17 +6,30 @@ use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Lunar\Admin\Filament\Resources\ProductOptionResource;
|
||||
use Lunar\Admin\Filament\Resources\ProductOptionResource\RelationManagers\ValuesRelationManager;
|
||||
use Lunar\Admin\Filament\Resources\OrderResource;
|
||||
use Lunar\Admin\Filament\Resources\ProductResource;
|
||||
use Lunar\Admin\Filament\Resources\StaffResource;
|
||||
use Lunar\Admin\Models\Staff as LunarStaff;
|
||||
use Lunar\Admin\Support\Facades\LunarPanel;
|
||||
use Lunar\Models\Product;
|
||||
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
|
||||
use Lunar\Shipping\Filament\Resources\ShippingMethodResource\Pages\ListShippingMethod;
|
||||
use Lunar\Shipping\ShippingPlugin;
|
||||
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
||||
use Modules\Core\Auth\Filament\Pages\Login;
|
||||
use Modules\Core\Auth\Mail\InviteMail;
|
||||
use Modules\Core\Review\Extensions\ProductResourceExtension;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
|
||||
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
|
||||
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
|
||||
use Modules\Core\Review\Models\ProductReview;
|
||||
use Modules\Core\Shipping\Extensions\OrderViewExtension;
|
||||
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
|
||||
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
|
||||
use Modules\Core\Shipping\Filament\Pages\ManagePickupManifests;
|
||||
|
||||
class CorePlugin implements Plugin
|
||||
{
|
||||
@@ -32,11 +45,21 @@ class CorePlugin implements Plugin
|
||||
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
|
||||
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
|
||||
->login(Login::class)
|
||||
->plugin(ShippingPlugin::make());
|
||||
->resources([
|
||||
LanguageLineResource::class,
|
||||
CartResource::class,
|
||||
])
|
||||
->plugin(ShippingPlugin::make())
|
||||
->pages([ManagePickupManifests::class]);
|
||||
|
||||
LunarPanel::extensions([
|
||||
StaffResource::class => StaffResourceExtension::class,
|
||||
ProductResource::class => ProductResourceExtension::class,
|
||||
ProductOptionResource::class => ProductOptionResourceExtension::class,
|
||||
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
|
||||
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
|
||||
ListShippingMethod::class => ShippingMethodListExtension::class,
|
||||
OrderResource\Pages\ManageOrder::class => OrderViewExtension::class,
|
||||
]);
|
||||
|
||||
Product::macro('reviews', function (): HasMany {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Events;
|
||||
|
||||
use Lunar\Models\Language;
|
||||
|
||||
class LanguageCreated
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Language $language,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Events;
|
||||
|
||||
use Lunar\Models\Language;
|
||||
|
||||
class LanguageDeleted
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Language $language,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Events;
|
||||
|
||||
use Lunar\Models\Language;
|
||||
|
||||
class LanguageUpdated
|
||||
{
|
||||
/**
|
||||
* @param array{code: string} $old Snapshot of watched attributes before the update.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Language $language,
|
||||
public readonly array $old,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Events;
|
||||
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
class TranslationCreated
|
||||
{
|
||||
public function __construct(
|
||||
public readonly LanguageLine $languageLine,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Events;
|
||||
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
class TranslationDeleted
|
||||
{
|
||||
public function __construct(
|
||||
public readonly LanguageLine $languageLine,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Events;
|
||||
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
class TranslationUpdated
|
||||
{
|
||||
/**
|
||||
* @param array{group: string, key: string, text: array} $old Snapshot before the update.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly LanguageLine $languageLine,
|
||||
public readonly array $old,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Filament\Resources;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Lunar\Models\Language;
|
||||
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
class LanguageLineResource extends Resource
|
||||
{
|
||||
protected static ?string $model = LanguageLine::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-language';
|
||||
|
||||
protected static ?string $navigationGroup = 'Settings';
|
||||
|
||||
protected static ?string $modelLabel = 'Translation';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Translations';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form->schema([
|
||||
Forms\Components\TextInput::make('group')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->default('storefront')
|
||||
->helperText('Namespace for this label, e.g. "storefront" for e-shop UI text.'),
|
||||
|
||||
Forms\Components\TextInput::make('key')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Dot-notation key, e.g. "nav.cart".'),
|
||||
|
||||
Forms\Components\Fieldset::make('Translations')
|
||||
->schema(static::localeInputs()),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('group')
|
||||
->badge()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('key')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
...static::localeColumns(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('group')
|
||||
->options(fn () => LanguageLine::query()->distinct()->pluck('group', 'group')),
|
||||
])
|
||||
->defaultSort('key');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListLanguageLines::route('/'),
|
||||
'create' => Pages\CreateLanguageLine::route('/create'),
|
||||
'edit' => Pages\EditLanguageLine::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Forms\Components\Textarea>
|
||||
*/
|
||||
private static function localeInputs(): array
|
||||
{
|
||||
return static::localeCodes()
|
||||
->map(fn (string $code) => Forms\Components\Textarea::make("text.{$code}")
|
||||
->label(strtoupper($code))
|
||||
->rows(2))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Tables\Columns\TextColumn>
|
||||
*/
|
||||
private static function localeColumns(): array
|
||||
{
|
||||
return static::localeCodes()
|
||||
->map(fn (string $code) => Tables\Columns\TextColumn::make("text.{$code}")
|
||||
->label(strtoupper($code))
|
||||
->limit(40)
|
||||
->toggleable())
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function localeCodes(): \Illuminate\Support\Collection
|
||||
{
|
||||
return Language::query()->pluck('code');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||
use Modules\Core\Localization\Services\TranslationService;
|
||||
|
||||
class CreateLanguageLine extends CreateRecord
|
||||
{
|
||||
protected static string $resource = LanguageLineResource::class;
|
||||
|
||||
protected function handleRecordCreation(array $data): Model
|
||||
{
|
||||
return app(TranslationService::class)->create(
|
||||
$data['group'],
|
||||
$data['key'],
|
||||
$data['text'] ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
|
||||
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||
use Modules\Core\Localization\Services\TranslationService;
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
class EditLanguageLine extends EditRecord
|
||||
{
|
||||
protected static string $resource = LanguageLineResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->action(function (LanguageLine $record) {
|
||||
app(TranslationService::class)->delete($record);
|
||||
|
||||
$this->redirect($this->getResource()::getUrl('index'));
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filament's default Cancel button uses window.history.back(), which
|
||||
* restores the browser's cached previous page instead of re-fetching —
|
||||
* so an edit made just before clicking Cancel doesn't show up in the
|
||||
* list until a manual refresh. Redirect through Livewire instead, which
|
||||
* always re-queries.
|
||||
*/
|
||||
protected function getCancelFormAction(): Action
|
||||
{
|
||||
return Action::make('cancel')
|
||||
->label(__('filament-panels::resources/pages/edit-record.form.actions.cancel.label'))
|
||||
->url(static::getResource()::getUrl('index'))
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
protected function handleRecordUpdate(Model $record, array $data): Model
|
||||
{
|
||||
return app(TranslationService::class)->update(
|
||||
$record,
|
||||
$data['group'],
|
||||
$data['key'],
|
||||
$data['text'] ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
|
||||
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||
|
||||
class ListLanguageLines extends ListRecords
|
||||
{
|
||||
protected static string $resource = LanguageLineResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Listeners;
|
||||
|
||||
use Modules\Core\Localization\Events\LanguageCreated;
|
||||
use Modules\Core\Localization\Events\LanguageDeleted;
|
||||
use Modules\Core\Localization\Events\LanguageUpdated;
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
|
||||
class FlushLanguageCache
|
||||
{
|
||||
public function __construct(private readonly LanguageCache $languages) {}
|
||||
|
||||
public function handle(LanguageCreated|LanguageUpdated|LanguageDeleted $event): void
|
||||
{
|
||||
$this->languages->forget();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Listeners;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\Core\Localization\Events\TranslationCreated;
|
||||
use Modules\Core\Localization\Events\TranslationDeleted;
|
||||
use Modules\Core\Localization\Events\TranslationUpdated;
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
/**
|
||||
* LanguageLine::boot() already flushes the cache for the current group's locales
|
||||
* present after a save, but misses two cases on update: locales removed from
|
||||
* `text` (e.g. dropping the "el" key leaves `{group}.el` stale), and a changed
|
||||
* `group`/`key` (the old group's cached array never gets told a row left it).
|
||||
* This listener flushes every group+locale combination touched by either the
|
||||
* old or new state so nothing can remain stale.
|
||||
*/
|
||||
class FlushTranslationCache
|
||||
{
|
||||
public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void
|
||||
{
|
||||
$this->flush($event->languageLine->group, array_keys($event->languageLine->text ?? []));
|
||||
|
||||
if ($event instanceof TranslationUpdated) {
|
||||
$this->flush($event->old['group'], array_keys($event->old['text'] ?? []));
|
||||
}
|
||||
}
|
||||
|
||||
private function flush(string $group, array $locales): void
|
||||
{
|
||||
foreach ($locales as $locale) {
|
||||
Cache::forget(LanguageLine::getCacheKey($group, $locale));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Listeners;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Modules\Core\Localization\Events\TranslationCreated;
|
||||
use Modules\Core\Localization\Events\TranslationDeleted;
|
||||
use Modules\Core\Localization\Events\TranslationUpdated;
|
||||
use Modules\Core\Logging\ActivityLogService;
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
class LogTranslationActivity
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogService $activityLog,
|
||||
) {}
|
||||
|
||||
public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void
|
||||
{
|
||||
$languageLine = $event->languageLine;
|
||||
|
||||
match (true) {
|
||||
$event instanceof TranslationCreated => $this->activityLog->created(
|
||||
$languageLine,
|
||||
$this->flatten($languageLine),
|
||||
),
|
||||
$event instanceof TranslationUpdated => $this->activityLog->updated(
|
||||
$languageLine,
|
||||
Arr::dot($event->old),
|
||||
$this->flatten($languageLine),
|
||||
),
|
||||
$event instanceof TranslationDeleted => $this->activityLog->deleted(
|
||||
$languageLine,
|
||||
$this->flatten($languageLine),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filament's Activity resource renders `properties` with a flat KeyValue
|
||||
* field, which can't display a nested value like `text: {en, el}` — it
|
||||
* shows as "[object Object]". Flatten to dot-notation ("text.en",
|
||||
* "text.el") so every property is a plain string, viewable as-is.
|
||||
*/
|
||||
private function flatten(LanguageLine $languageLine): array
|
||||
{
|
||||
return Arr::dot([
|
||||
'group' => $languageLine->group,
|
||||
'key' => $languageLine->key,
|
||||
'text' => $languageLine->text,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Listeners;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\Core\Localization\Events\LanguageUpdated;
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
/**
|
||||
* A renamed Language::code (e.g. "el" -> "gr") would otherwise strand every
|
||||
* LanguageLine's translated text under the old, now-unroutable key —
|
||||
* getTranslationsForGroup($newCode, ...) would silently return nothing for
|
||||
* that locale even though the translated content still exists. Move the
|
||||
* text.{oldCode} key to text.{newCode} on every affected row instead.
|
||||
*/
|
||||
class MigrateTranslationsForRenamedLanguage
|
||||
{
|
||||
public function handle(LanguageUpdated $event): void
|
||||
{
|
||||
$oldCode = $event->old['code'];
|
||||
$newCode = $event->language->code;
|
||||
|
||||
if ($oldCode === $newCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
$affectedGroups = [];
|
||||
|
||||
LanguageLine::query()
|
||||
->whereJsonContainsKey('text->'.$oldCode)
|
||||
->each(function (LanguageLine $languageLine) use ($oldCode, $newCode, &$affectedGroups) {
|
||||
$text = $languageLine->text;
|
||||
$text[$newCode] = $text[$oldCode];
|
||||
unset($text[$oldCode]);
|
||||
|
||||
$languageLine->update(['text' => $text]);
|
||||
|
||||
$affectedGroups[$languageLine->group] = true;
|
||||
});
|
||||
|
||||
foreach (array_keys($affectedGroups) as $group) {
|
||||
Cache::forget(LanguageLine::getCacheKey($group, $oldCode));
|
||||
Cache::forget(LanguageLine::getCacheKey($group, $newCode));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Lunar\Models\Language;
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LocaleMiddleware
|
||||
{
|
||||
public function __construct(private readonly LanguageCache $languages) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$languages = $this->languages->all();
|
||||
|
||||
if ($languages->isEmpty()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$segment = (string) $request->segment(1);
|
||||
$language = $languages->firstWhere('code', $segment);
|
||||
|
||||
if (! $language) {
|
||||
return $this->redirectToLocalizedUrl($request, $languages);
|
||||
}
|
||||
|
||||
App::setLocale($language->code);
|
||||
$request->attributes->set('locale', $language->code);
|
||||
$request->attributes->set('language', $language);
|
||||
|
||||
// Lets route() calls omit {locale} anywhere in the request lifecycle
|
||||
// (controllers, views) — without this, every route() call would need
|
||||
// locale passed explicitly every time.
|
||||
URL::defaults(['locale' => $language->code]);
|
||||
|
||||
$this->shareLocaleViewData($request, $language, $languages);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares the current locale and every OTHER available locale (each with its
|
||||
* own URL for the current page) with all views, so the header language
|
||||
* switcher and layout hreflang tags don't have to recompute it.
|
||||
*
|
||||
* `altLocales` is a collection, not a single value — firstWhere('code', '!=',
|
||||
* ...) would only ever surface one alternate, which happens to look correct
|
||||
* with exactly 2 configured languages (there's only one "other" to find) but
|
||||
* silently drops every locale past the first for a 3+ language store, with no
|
||||
* error, just fewer switcher options than actually configured. A view iterates
|
||||
* `$altLocales` to render as many links/dropdown entries as there are
|
||||
* alternates, whether that's 1 or 10.
|
||||
*/
|
||||
private function shareLocaleViewData(Request $request, Language $language, Collection $languages): void
|
||||
{
|
||||
$route = $request->route();
|
||||
$routeName = $route?->getName();
|
||||
|
||||
$altLocales = $languages
|
||||
->reject(fn (Language $other) => $other->code === $language->code)
|
||||
->map(fn (Language $other) => [
|
||||
'code' => $other->code,
|
||||
'name' => $other->name,
|
||||
'url' => $routeName
|
||||
? route($routeName, array_merge($route->parameters(), ['locale' => $other->code]))
|
||||
: url('/'.$other->code),
|
||||
])
|
||||
->values();
|
||||
|
||||
View::share('currentLocale', $language->code);
|
||||
View::share('altLocales', $altLocales);
|
||||
}
|
||||
|
||||
private function redirectToLocalizedUrl(Request $request, Collection $languages): Response
|
||||
{
|
||||
$locale = $this->negotiateLocale($request, $languages);
|
||||
|
||||
$path = trim($request->getPathInfo(), '/');
|
||||
$target = '/'.$locale.($path !== '' ? '/'.$path : '');
|
||||
|
||||
$query = $request->getQueryString();
|
||||
if ($query) {
|
||||
$target .= '?'.$query;
|
||||
}
|
||||
|
||||
return redirect($target);
|
||||
}
|
||||
|
||||
private function negotiateLocale(Request $request, Collection $languages): string
|
||||
{
|
||||
$preferred = $request->getPreferredLanguage($languages->pluck('code')->all());
|
||||
|
||||
if ($preferred) {
|
||||
return $preferred;
|
||||
}
|
||||
|
||||
return $languages->firstWhere('default', true)?->code
|
||||
?? $languages->first()->code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Models;
|
||||
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
use Spatie\TranslationLoader\LanguageLine as BaseLanguageLine;
|
||||
|
||||
/**
|
||||
* Overrides the base package's locale fallback (config('app.fallback_locale'), a
|
||||
* static .env value) with the store's actual default language — Lunar's
|
||||
* `languages.default` flag, the same source LocaleMiddleware/LanguageCache already
|
||||
* treat as the single source of truth for "this store's default language".
|
||||
*
|
||||
* Without this, changing the default language via the Filament Languages resource
|
||||
* has no effect on which locale an untranslated storefront label falls back to —
|
||||
* two disconnected "default locale" concepts silently drifting apart. Swapped in
|
||||
* via config('translation-loader.model') (see LocalizationServiceProvider), the
|
||||
* package's own documented extension point for this.
|
||||
*/
|
||||
class LanguageLine extends BaseLanguageLine
|
||||
{
|
||||
public function getTranslation(string $locale): ?string
|
||||
{
|
||||
if (isset($this->text[$locale])) {
|
||||
return $this->text[$locale];
|
||||
}
|
||||
|
||||
$fallback = app(LanguageCache::class)->defaultLocale();
|
||||
|
||||
return $fallback !== null ? ($this->text[$fallback] ?? null) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Observers;
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Lunar\Models\Language;
|
||||
use Modules\Core\Localization\Events\LanguageCreated;
|
||||
use Modules\Core\Localization\Events\LanguageDeleted;
|
||||
use Modules\Core\Localization\Events\LanguageUpdated;
|
||||
|
||||
class LanguageCacheObserver
|
||||
{
|
||||
public function created(Language $language): void
|
||||
{
|
||||
Event::dispatch(new LanguageCreated($language));
|
||||
}
|
||||
|
||||
public function updated(Language $language): void
|
||||
{
|
||||
Event::dispatch(new LanguageUpdated($language, [
|
||||
'code' => $language->getOriginal('code'),
|
||||
]));
|
||||
}
|
||||
|
||||
public function deleted(Language $language): void
|
||||
{
|
||||
Event::dispatch(new LanguageDeleted($language));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Lunar\Models\Language;
|
||||
|
||||
/**
|
||||
* Cached read layer over Lunar's `languages` table — the single source both
|
||||
* Modules\Core\Localization\Middleware\LocaleMiddleware (request-time locale resolution) and
|
||||
* any other locale-aware code (e.g. Modules\Core\Catalog\Services\ProductService) read
|
||||
* from, so the language list is fetched once per cache lifetime rather than once
|
||||
* per caller. Cached forever, invalidated via forget() by
|
||||
* Modules\Core\Localization\Listeners\FlushLanguageCache on
|
||||
* LanguageCreated/LanguageUpdated/LanguageDeleted.
|
||||
*/
|
||||
class LanguageCache
|
||||
{
|
||||
private const CACHE_KEY = 'core.localization.languages';
|
||||
|
||||
public function all(): Collection
|
||||
{
|
||||
return Cache::rememberForever(
|
||||
self::CACHE_KEY,
|
||||
fn () => Language::query()->get(['id', 'code', 'name', 'default']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The store's default language code (e.g. 'el') - the fixed fallback other
|
||||
* locale-aware code should use, as opposed to config('app.locale') which
|
||||
* App::setLocale() mutates per request and so can't serve as a stable
|
||||
* fallback.
|
||||
*/
|
||||
public function defaultLocale(): ?string
|
||||
{
|
||||
return $this->all()->firstWhere('default', true)?->code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every configured store locale code (e.g. ['el', 'en']) - for code that needs
|
||||
* to enumerate all locales a TranslatedText attribute was indexed under (see
|
||||
* Modules\Core\Catalog\Services\ProductService::withLocalizedFields()), rather than
|
||||
* hardcoding locale codes.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function availableLocales(): array
|
||||
{
|
||||
return $this->all()->pluck('code')->all();
|
||||
}
|
||||
|
||||
public function forget(): void
|
||||
{
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Services;
|
||||
|
||||
/**
|
||||
* Default storefront UI label translations (group `storefront`), seeded by
|
||||
* Modules\Core\Command\InstallLunarCommand. Kept as its own class, separate from
|
||||
* the seeding logic, so the actual label list can be scanned/diffed without wading
|
||||
* through the upsert mechanics — see InstallLunarCommand::seedStorefrontLabels()
|
||||
* for how (and how safely) these get written.
|
||||
*/
|
||||
class StorefrontLabels
|
||||
{
|
||||
/**
|
||||
* @return array<string, array<string, string>> keyed by `group.key` dot-notation,
|
||||
* each value a locale => text map (`en`/`el`).
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
'nav.home' => ['en' => 'Home', 'el' => 'Αρχική'],
|
||||
'nav.products' => ['en' => 'Products', 'el' => 'Προϊόντα'],
|
||||
'nav.cart' => ['en' => 'Cart', 'el' => 'Καλάθι'],
|
||||
'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'],
|
||||
'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'],
|
||||
'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'],
|
||||
'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'],
|
||||
'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'],
|
||||
'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
|
||||
'cart.remove' => ['en' => 'Remove', 'el' => 'Αφαίρεση'],
|
||||
'product.add_to_cart' => ['en' => 'Add to Cart', 'el' => 'Προσθήκη στο Καλάθι'],
|
||||
'product.out_of_stock' => ['en' => 'Out of Stock', 'el' => 'Εξαντλήθηκε'],
|
||||
'product.price' => ['en' => 'Price', 'el' => 'Τιμή'],
|
||||
'product.description' => ['en' => 'Description', 'el' => 'Περιγραφή'],
|
||||
'product.no_image' => ['en' => 'No image', 'el' => 'Χωρίς εικόνα'],
|
||||
'product.read_more' => ['en' => 'Read more', 'el' => 'Περισσότερα'],
|
||||
'product.reviews' => ['en' => 'Reviews', 'el' => 'Αξιολογήσεις'],
|
||||
'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'],
|
||||
'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'],
|
||||
'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'],
|
||||
'customer_reviews' => [
|
||||
'en' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
|
||||
'el' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
|
||||
],
|
||||
'pagination.nav_label' => ['en' => 'Pagination', 'el' => 'Σελιδοποίηση'],
|
||||
'pagination.next' => ['en' => 'Next page', 'el' => 'Επόμενη σελίδα'],
|
||||
'pagination.previous' => ['en' => 'Previous page', 'el' => 'Προηγούμενη σελίδα'],
|
||||
'pagination.page' => ['en' => 'Page :page', 'el' => 'Σελίδα :page'],
|
||||
'review.rating' => ['en' => 'Rating', 'el' => 'Βαθμολογία'],
|
||||
'review.write_label' => ['en' => 'Write a review', 'el' => 'Γράψε μια αξιολόγηση'],
|
||||
'review.name' => ['en' => 'Name', 'el' => 'Όνομα'],
|
||||
'review.name_optional' => ['en' => 'Optional', 'el' => 'Προαιρετικό'],
|
||||
'review.email' => ['en' => 'Email', 'el' => 'Email'],
|
||||
'review.email_not_published' => ['en' => 'Will not be published', 'el' => 'Δεν θα δημοσιευτεί'],
|
||||
'review.save_info' => [
|
||||
'en' => 'Save my name and email for the next time I comment.',
|
||||
'el' => 'Αποθήκευσε το όνομα και το email μου για την επόμενη φορά που θα σχολιάσω.',
|
||||
],
|
||||
'review.submit' => ['en' => 'Submit', 'el' => 'Υποβολή'],
|
||||
'review.stars_count' => ['en' => '{1} :count star|[2,*] :count stars', 'el' => '{1} :count αστέρι|[2,*] :count αστέρια'],
|
||||
'review.no_reviews_yet' => ['en' => 'No reviews yet.', 'el' => 'Δεν υπάρχουν αξιολογήσεις ακόμα.'],
|
||||
'review.write_first' => ['en' => 'Write the first review', 'el' => 'Γράψε την πρώτη'],
|
||||
'review.write_new' => ['en' => 'Add a review', 'el' => 'Πρόσθεσε μια'],
|
||||
'review.for_product' => ['en' => 'review for ":name"', 'el' => 'αξιολόγηση για το «:name»'],
|
||||
'shop.showing_results' => [
|
||||
'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
|
||||
'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
|
||||
],
|
||||
'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'],
|
||||
'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'],
|
||||
'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'],
|
||||
'shop.sort_price_asc' => ['en' => 'Price: Low to High', 'el' => 'Τιμή: Αύξουσα'],
|
||||
'shop.sort_price_desc' => ['en' => 'Price: High to Low', 'el' => 'Τιμή: Φθίνουσα'],
|
||||
'shop.sort_newest' => ['en' => 'Newest', 'el' => 'Νεότερα'],
|
||||
'shop.no_products' => ['en' => 'No products found in this category.', 'el' => 'Δεν βρέθηκαν προϊόντα σε αυτή την κατηγορία.'],
|
||||
'shop.search_label' => ['en' => 'Search products', 'el' => 'Αναζήτηση προϊόντων'],
|
||||
'shop.search_placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτησε προϊόντα…'],
|
||||
'shop.filter_price' => ['en' => 'Filter by price', 'el' => 'Φίλτρο τιμής'],
|
||||
'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'],
|
||||
'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'],
|
||||
'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Services;
|
||||
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
class TranslationReader
|
||||
{
|
||||
private const DEFAULT_GROUP = 'storefront';
|
||||
|
||||
/**
|
||||
* All labels in a group for the given (or current) locale, keyed by their
|
||||
* dot-notation key — e.g. ['nav.cart' => 'Cart', 'nav.home' => 'Home'].
|
||||
* Backed by LanguageLine's own forever-cache, so this is a cache hit after
|
||||
* the first call for a given group+locale.
|
||||
*/
|
||||
public function group(string $group = self::DEFAULT_GROUP, ?string $locale = null): array
|
||||
{
|
||||
return LanguageLine::getTranslationsForGroup($locale ?? App::getLocale(), $group);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Localization\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Modules\Core\Localization\Events\TranslationCreated;
|
||||
use Modules\Core\Localization\Events\TranslationDeleted;
|
||||
use Modules\Core\Localization\Events\TranslationUpdated;
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
class TranslationService
|
||||
{
|
||||
public function create(string $group, string $key, array $text): LanguageLine
|
||||
{
|
||||
$languageLine = LanguageLine::create([
|
||||
'group' => $group,
|
||||
'key' => $key,
|
||||
'text' => $text,
|
||||
]);
|
||||
|
||||
Event::dispatch(new TranslationCreated($languageLine));
|
||||
|
||||
return $languageLine;
|
||||
}
|
||||
|
||||
public function update(LanguageLine $languageLine, string $group, string $key, array $text): LanguageLine
|
||||
{
|
||||
// Callers (e.g. Filament's EditRecord) may hand us a model instance
|
||||
// already filled with the new form values in memory — refresh from the
|
||||
// database first so $old reflects what's actually persisted, not what's
|
||||
// about to be written.
|
||||
$persisted = $languageLine->fresh();
|
||||
|
||||
$old = [
|
||||
'group' => $persisted->group,
|
||||
'key' => $persisted->key,
|
||||
'text' => $persisted->text,
|
||||
];
|
||||
|
||||
$languageLine->update([
|
||||
'group' => $group,
|
||||
'key' => $key,
|
||||
'text' => $text,
|
||||
]);
|
||||
|
||||
Event::dispatch(new TranslationUpdated($languageLine, $old));
|
||||
|
||||
return $languageLine;
|
||||
}
|
||||
|
||||
public function delete(LanguageLine $languageLine): void
|
||||
{
|
||||
$languageLine->delete();
|
||||
|
||||
Event::dispatch(new TranslationDeleted($languageLine));
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,23 @@ use Lunar\Models\Url;
|
||||
|
||||
class ProductResolver
|
||||
{
|
||||
/**
|
||||
* A slug can have more than one `lunar_urls` row pointing at it across import
|
||||
* batches — e.g. a product soft-deleted and re-imported leaves its old URL row
|
||||
* behind, still matching the same slug. Picking "whichever Url row matches
|
||||
* first" (as a plain Url::where('slug', ...)->first() would) can resolve to a
|
||||
* soft-deleted product, silently failing every downstream write for that
|
||||
* product (e.g. JudgeMeExportImporter logging "no product found" for a handle
|
||||
* that, in isolation, clearly exists). Join against `lunar_products` directly
|
||||
* so only a URL pointing at a live (non-deleted) product resolves.
|
||||
*/
|
||||
public function resolve(string $handle): ?Product
|
||||
{
|
||||
$url = Url::query()
|
||||
->where('slug', $handle)
|
||||
->where('element_type', (new Product)->getMorphClass())
|
||||
return Product::query()
|
||||
->join('lunar_urls', 'lunar_urls.element_id', '=', 'lunar_products.id')
|
||||
->where('lunar_urls.slug', $handle)
|
||||
->where('lunar_urls.element_type', (new Product)->getMorphClass())
|
||||
->select('lunar_products.*')
|
||||
->first();
|
||||
|
||||
return $url?->element;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Core\Cart\Commands\DetectAbandonedCarts;
|
||||
|
||||
class CartServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([DetectAbandonedCarts::class]);
|
||||
}
|
||||
|
||||
$this->app->booted(function () {
|
||||
$this->app->make(Schedule::class)
|
||||
->command(DetectAbandonedCarts::class)
|
||||
->hourly();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Lunar\Models\ProductOption;
|
||||
use Lunar\Models\ProductOptionValue;
|
||||
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
|
||||
use Modules\Core\Catalog\OptionTypes\ColorOptionType;
|
||||
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
|
||||
|
||||
class CatalogServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
ProductOptionTypeManager::get()->register([
|
||||
ColorOptionType::class,
|
||||
]);
|
||||
|
||||
$observer = new ProductOptionReindexObserver;
|
||||
|
||||
ProductOption::saved(fn (ProductOption $option) => $observer->optionSaved($option));
|
||||
ProductOption::deleted(fn (ProductOption $option) => $observer->optionDeleted($option));
|
||||
|
||||
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
|
||||
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Lunar\Models\Language;
|
||||
use Modules\Core\Localization\Events\LanguageCreated;
|
||||
use Modules\Core\Localization\Events\LanguageDeleted;
|
||||
use Modules\Core\Localization\Events\LanguageUpdated;
|
||||
use Modules\Core\Localization\Events\TranslationCreated;
|
||||
use Modules\Core\Localization\Events\TranslationDeleted;
|
||||
use Modules\Core\Localization\Events\TranslationUpdated;
|
||||
use Modules\Core\Localization\Listeners\FlushLanguageCache;
|
||||
use Modules\Core\Localization\Listeners\FlushTranslationCache;
|
||||
use Modules\Core\Localization\Listeners\LogTranslationActivity;
|
||||
use Modules\Core\Localization\Listeners\MigrateTranslationsForRenamedLanguage;
|
||||
use Modules\Core\Localization\Middleware\LocaleMiddleware;
|
||||
use Modules\Core\Localization\Models\LanguageLine;
|
||||
use Modules\Core\Localization\Observers\LanguageCacheObserver;
|
||||
|
||||
class LocalizationServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
// Must run before Spatie\TranslationLoader\TranslationServiceProvider's
|
||||
// register() merges its own config defaults - mergeConfigFrom() only fills
|
||||
// in keys not already set, so setting this here (regardless of provider
|
||||
// boot order) makes it win over the package's default
|
||||
// Spatie\TranslationLoader\LanguageLine::class.
|
||||
config(['translation-loader.model' => LanguageLine::class]);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class);
|
||||
Language::observe(LanguageCacheObserver::class);
|
||||
|
||||
foreach ([TranslationCreated::class, TranslationUpdated::class, TranslationDeleted::class] as $event) {
|
||||
Event::listen($event, FlushTranslationCache::class);
|
||||
Event::listen($event, LogTranslationActivity::class);
|
||||
}
|
||||
|
||||
foreach ([LanguageCreated::class, LanguageUpdated::class, LanguageDeleted::class] as $event) {
|
||||
Event::listen($event, FlushLanguageCache::class);
|
||||
}
|
||||
|
||||
Event::listen(LanguageUpdated::class, MigrateTranslationsForRenamedLanguage::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Core\Review\Models\ProductReview;
|
||||
|
||||
/**
|
||||
* Keeps a product's Meilisearch document in sync with its reviews. A review is
|
||||
* created/edited independently of its product (customer submission, staff reply),
|
||||
* so the product's own save/update events never fire for it — without this listener,
|
||||
* Modules\Core\Catalog\Services\ProductIndexer's review data would only refresh on
|
||||
* the next full product reindex.
|
||||
*/
|
||||
class ReviewServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
ProductReview::created(fn (ProductReview $review) => $review->product?->searchable());
|
||||
ProductReview::updated(fn (ProductReview $review) => $review->product?->searchable());
|
||||
ProductReview::deleted(fn (ProductReview $review) => $review->product?->searchable());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
use Livewire\Mechanisms\ComponentRegistry;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Modules\Core\Cart\Events\CartCleared;
|
||||
use Modules\Core\Cart\Events\CartLineAdded;
|
||||
use Modules\Core\Cart\Events\CartLineRemoved;
|
||||
use Modules\Core\Cart\Events\CartLineUpdated;
|
||||
use Modules\Core\Checkout\Events\ShippingAddressSet;
|
||||
use Modules\Core\Shipping\Carriers\Acs\AcsClient;
|
||||
use Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService;
|
||||
use Modules\Core\Shipping\Carriers\Acs\AcsRateDriver;
|
||||
use Modules\Core\Shipping\Carriers\Acs\Jobs\WarmAcsAreaCacheJob;
|
||||
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
|
||||
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
|
||||
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
|
||||
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
|
||||
use Modules\Core\Shipping\Listeners\FlushLivePricingCache;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
class ShippingServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs');
|
||||
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow');
|
||||
|
||||
$this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs')));
|
||||
$this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow')));
|
||||
|
||||
$this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) {
|
||||
return match ($params['carrier'] ?? null) {
|
||||
'acs' => $app->make(AcsFulfillmentService::class),
|
||||
'box-now' => $app->make(BoxNowFulfillmentService::class),
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
|
||||
// The vendor Rates page has no extension hook, so we swap it for
|
||||
// our subclass everywhere. Route::get($path, VendorClass::class)
|
||||
// instantiates the vendor class directly via the container for the
|
||||
// initial full-page load (bypassing Livewire's component registry
|
||||
// entirely), so this container bind is required in addition to the
|
||||
// Livewire::component() re-registration below — the bind covers
|
||||
// first load, the Livewire registration covers every AJAX
|
||||
// round-trip (form submits, table interactions) afterwards.
|
||||
$this->app->bind(VendorManageShippingRates::class, ManageShippingRates::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->publishes([
|
||||
__DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
|
||||
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
|
||||
], 'core-config');
|
||||
|
||||
Order::resolveRelationUsing('shipments', function ($order) {
|
||||
return $order->hasMany(Shipment::class);
|
||||
});
|
||||
|
||||
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
|
||||
Event::listen($event, [FlushLivePricingCache::class, 'handle']);
|
||||
}
|
||||
|
||||
// Deferred: the Shipping facade resolves a binding registered in
|
||||
// lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(),
|
||||
// and provider boot order between packages isn't guaranteed.
|
||||
$this->app->booted(function () {
|
||||
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
|
||||
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
|
||||
|
||||
$this->app->make(ConsoleSchedule::class)
|
||||
->job(new WarmAcsAreaCacheJob)
|
||||
->dailyAt('06:00')
|
||||
->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
|
||||
|
||||
$this->app->make(ConsoleSchedule::class)
|
||||
->job(new PollShipmentTrackingJob)
|
||||
->everyThirtyMinutes();
|
||||
|
||||
$this->overrideRatesPageLivewireComponent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The vendor Rates page has no extension hook, so we swap it for our
|
||||
* subclass (see Shipping/Filament/Pages/ManageShippingRates). Filament
|
||||
* already registered the vendor class as a Livewire component under a
|
||||
* name derived from its class string (see
|
||||
* Panel::registerLivewireComponents()); Livewire's own registry is a
|
||||
* simple last-write-wins name => class map, so re-registering the same
|
||||
* derived name against our subclass here overrides it — keeping the
|
||||
* route, sub-navigation, and every Livewire round-trip (including form
|
||||
* submissions) pointed at one consistent component identity.
|
||||
*/
|
||||
private function overrideRatesPageLivewireComponent(): void
|
||||
{
|
||||
$name = $this->app->make(ComponentRegistry::class)->getName(VendorManageShippingRates::class);
|
||||
|
||||
Livewire::component($name, ManageShippingRates::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Recovery\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
/**
|
||||
* A cart has gone stale (no activity for config('core.cart.abandoned_after'))
|
||||
* with NO order ever started — the shopper added items and never began
|
||||
* checkout. Weak purchase-intent signal: usually a browsing/price-check
|
||||
* action, not a near-purchase. Distinct from CheckoutAbandoned, which fires
|
||||
* for a cart that DID reach checkout (a draft Order exists) but never placed
|
||||
* it — a much stronger intent signal, and reachable via the email/address
|
||||
* checkout itself usually captures even for a guest.
|
||||
*
|
||||
* Lives under Recovery, not Cart — abandonment detection/tracking is
|
||||
* deliberately kept out of the Cart module entirely, including its event
|
||||
* definitions, so Cart has no abandonment-related code at all. See
|
||||
* docs/cart.md and docs/recovery-strategies.md.
|
||||
*
|
||||
* "Abandoned" is a derived state (stale updated_at), not something that
|
||||
* transitions via a normal Eloquent write, so there's no natural model-event
|
||||
* hook to dispatch this from directly — detection is Recovery's own concern
|
||||
* (not yet built; design notes in docs/recovery-strategies.md).
|
||||
*/
|
||||
class CartAbandoned
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Recovery\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* A cart's checkout has gone stale (no activity for
|
||||
* config('core.cart.abandoned_after')) with a draft Order already created
|
||||
* (Order::isDraft() — placed_at IS NULL) but never placed. Strong
|
||||
* purchase-intent signal — the shopper committed to checking out, something
|
||||
* blocked completion. Distinct from CartAbandoned, which fires for a cart
|
||||
* with no order at all (weak intent, usually unreachable). Checkout
|
||||
* typically captures an email/address even for a guest, so this state is
|
||||
* normally reachable regardless of login status.
|
||||
*
|
||||
* Lives under Recovery, not Checkout/Cart — abandonment detection/tracking
|
||||
* is deliberately kept out of both modules entirely, including its event
|
||||
* definitions. See docs/cart.md and docs/recovery-strategies.md.
|
||||
*
|
||||
* "Abandoned" is a derived state (stale updated_at, no placed_at), not
|
||||
* something that transitions via a normal Eloquent write — detection is
|
||||
* Recovery's own concern (not yet built; design notes in
|
||||
* docs/recovery-strategies.md).
|
||||
*/
|
||||
class CheckoutAbandoned
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly Order $order,
|
||||
) {}
|
||||
}
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Review\Extensions;
|
||||
namespace Modules\Core\Review\Filament\Extensions;
|
||||
|
||||
use Lunar\Admin\Support\Extending\ResourceExtension;
|
||||
use Modules\Core\Review\Pages\ManageProductReviews;
|
||||
use Modules\Core\Review\Filament\Pages\ManageProductReviews;
|
||||
|
||||
class ProductResourceExtension extends ResourceExtension
|
||||
{
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Review\Pages;
|
||||
namespace Modules\Core\Review\Filament\Pages;
|
||||
|
||||
use Filament\Forms\Components\Group;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
@@ -5,8 +5,11 @@ namespace Modules\Core\Review\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Lunar\Models\Product;
|
||||
use Spatie\Image\Enums\BorderType;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
class ProductReview extends Model implements HasMedia
|
||||
{
|
||||
@@ -30,4 +33,23 @@ class ProductReview extends Model implements HasMedia
|
||||
{
|
||||
$this->addMediaCollection(self::IMAGES_COLLECTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlike Product/ProductVariant, this model sits outside Lunar's own
|
||||
* MediaDefinitionsInterface (Lunar\Base\StandardMediaDefinitions), which is
|
||||
* what registers the 'small' conversion those models get automatically. Without
|
||||
* this, Modules\Core\Catalog\Services\ProductIndexer::mapMedia() — shared across
|
||||
* product, variant, and review media — throws Spatie\MediaLibrary\MediaCollections\
|
||||
* Exceptions\InvalidConversion the first time a review has an image, since
|
||||
* $media->getUrl('small') has no matching conversion to resolve.
|
||||
*/
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('small')
|
||||
->fit(Fit::Fill, 300, 300)
|
||||
->border(0, BorderType::Overlay, color: '#FFF')
|
||||
->background('#FFF')
|
||||
->sharpen(10)
|
||||
->keepOriginalImageFormat();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Search;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Lunar\Search\ProductIndexer as BaseProductIndexer;
|
||||
|
||||
/**
|
||||
* Lunar's own indexer puts raw attribute HTML (e.g. name_en, description_en) into
|
||||
* the search index, which pollutes relevance ranking and highlighting with markup.
|
||||
* Strip tags from string fields before they reach Meilisearch.
|
||||
*/
|
||||
class ProductIndexer extends BaseProductIndexer
|
||||
{
|
||||
public function toSearchableArray(Model $model): array
|
||||
{
|
||||
$data = parent::toSearchableArray($model);
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$data[$key] = trim(strip_tags($value));
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||
|
||||
class AcsArea
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $stationId,
|
||||
public readonly int $branchId,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class AcsClient
|
||||
{
|
||||
public function __construct(private readonly array $config) {}
|
||||
|
||||
public function call(string $alias, array $parameters = []): AcsResponse
|
||||
{
|
||||
$response = Http::withHeaders([
|
||||
'AcsApiKey' => $this->config['api_key'],
|
||||
])
|
||||
->timeout($this->config['timeout'])
|
||||
->post($this->config['base_url'], [
|
||||
'ACSAlias' => $alias,
|
||||
'ACSInputParameters' => array_merge($this->credentialParams(), $parameters),
|
||||
]);
|
||||
|
||||
return AcsResponse::fromHttpResponse($response);
|
||||
}
|
||||
|
||||
private function credentialParams(): array
|
||||
{
|
||||
return [
|
||||
'Company_ID' => $this->config['company_id'],
|
||||
'Company_Password' => $this->config['company_password'],
|
||||
'User_ID' => $this->config['user_id'],
|
||||
'User_Password' => $this->config['user_password'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||
use Modules\Core\Shipping\Contracts\SupportsTracking;
|
||||
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
|
||||
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
|
||||
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AcsClient $client,
|
||||
private readonly AreaResolver $areaResolver,
|
||||
) {}
|
||||
|
||||
public function createShipment(Order $order, ShipmentRequest $request): Shipment
|
||||
{
|
||||
$address = $order->shippingAddress;
|
||||
$destination = $this->areaResolver->resolve($address->postcode);
|
||||
$weight = $request->weight ?? 0.5;
|
||||
|
||||
$params = [
|
||||
'Pickup_Date' => now()->toDateString(),
|
||||
'Sender' => config('acs.sender.name'),
|
||||
'Recipient_Name' => trim("{$address->first_name} {$address->last_name}"),
|
||||
'Recipient_Address' => $address->line_one,
|
||||
'Recipient_Zipcode' => $address->postcode,
|
||||
'Recipient_Region' => $address->city,
|
||||
'Recipient_Phone' => $address->contact_phone,
|
||||
'Recipient_Country' => 'GR',
|
||||
'Acs_Station_Branch_Destination' => $destination->branchId,
|
||||
'Billing_Code' => config('acs.billing_code'),
|
||||
'Charge_Type' => 2,
|
||||
'Item_Quantity' => $request->packageCount,
|
||||
'Weight' => $weight,
|
||||
];
|
||||
|
||||
if ($request->paymentMode === 'cod') {
|
||||
$params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal;
|
||||
$params['Cod_Payment_Way'] = 0; // cash
|
||||
$params['Acs_Delivery_Products'] = 'COD';
|
||||
}
|
||||
|
||||
$response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError();
|
||||
|
||||
$voucherNo = (string) $response->valueOutput['Voucher_No'];
|
||||
|
||||
$shipment = Shipment::create([
|
||||
'order_id' => $order->id,
|
||||
'carrier' => 'acs',
|
||||
'tracking_reference' => $voucherNo,
|
||||
'meta' => [
|
||||
'station_destination' => $destination->stationId,
|
||||
'weight' => $weight,
|
||||
'pickup_date' => now()->toDateString(),
|
||||
],
|
||||
]);
|
||||
|
||||
if ($request->packageCount > 1) {
|
||||
$this->persistMultipartVouchers($shipment);
|
||||
}
|
||||
|
||||
return $shipment;
|
||||
}
|
||||
|
||||
public function printLabel(Shipment $shipment): string
|
||||
{
|
||||
$response = $this->client->call('ACS_Print_Voucher', [
|
||||
'Voucher_No' => $shipment->tracking_reference,
|
||||
'Print_Type' => 2,
|
||||
'Start_Position' => 1,
|
||||
])->throwIfError();
|
||||
|
||||
$shipment->update(['label_printed_at' => now()]);
|
||||
|
||||
return $response->valueOutput[$shipment->tracking_reference] ?? '';
|
||||
}
|
||||
|
||||
public function cancelShipment(Shipment $shipment): void
|
||||
{
|
||||
if ($shipment->manifest_reference) {
|
||||
throw new \RuntimeException('Cannot cancel a shipment already included in an issued manifest.');
|
||||
}
|
||||
|
||||
$this->client->call('ACS_Delete_Voucher', [
|
||||
'Voucher_No' => $shipment->tracking_reference,
|
||||
])->throwIfError();
|
||||
|
||||
$shipment->update(['cancelled_at' => now()]);
|
||||
}
|
||||
|
||||
public function pendingForManifest(): Collection
|
||||
{
|
||||
return Shipment::query()
|
||||
->where('carrier', 'acs')
|
||||
->whereNull('manifest_reference')
|
||||
->whereNull('cancelled_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function issueManifest(Collection $shipments): ManifestResult
|
||||
{
|
||||
$unprinted = $shipments->whereNull('label_printed_at');
|
||||
|
||||
if ($unprinted->isNotEmpty()) {
|
||||
return ManifestResult::blocked($unprinted, 'unprinted');
|
||||
}
|
||||
|
||||
$response = $this->client->call('ACS_Issue_Pickup_List', [
|
||||
'Pickup_Date' => now()->toDateString(),
|
||||
'MyData' => null,
|
||||
])->throwIfError();
|
||||
|
||||
$pickupListNo = (string) $response->valueOutput['PickupList_No'];
|
||||
|
||||
$shipments->each(fn (Shipment $shipment) => $shipment->update([
|
||||
'manifest_reference' => $pickupListNo,
|
||||
]));
|
||||
|
||||
return ManifestResult::success($pickupListNo, $shipments);
|
||||
}
|
||||
|
||||
public function trackShipment(Shipment $shipment): Collection
|
||||
{
|
||||
$response = $this->client->call('ACS_TrackingDetails', [
|
||||
'Voucher_No' => $shipment->tracking_reference,
|
||||
])->throwIfError();
|
||||
|
||||
$rows = $response->tableOutput['Table_Data'] ?? [];
|
||||
|
||||
// ACS's per-checkpoint data (checkpoint_action) is free text with no
|
||||
// status code, so the final checkpoint's status is corroborated
|
||||
// against the structured summary call rather than guessed from text.
|
||||
$isDelivered = $this->isDelivered($shipment);
|
||||
|
||||
return collect($rows)->values()->map(function (array $row, int $index) use ($rows, $isDelivered) {
|
||||
$isLast = $index === count($rows) - 1;
|
||||
|
||||
return new TrackingCheckpoint(
|
||||
status: $isLast && $isDelivered
|
||||
? TrackingStatus::Delivered
|
||||
: $this->guessStatusFromAction($row['checkpoint_action'] ?? ''),
|
||||
carrierStatus: $row['checkpoint_action'] ?? null,
|
||||
message: $row['checkpoint_action'] ?? null,
|
||||
location: $row['checkpoint_location'] ?? null,
|
||||
occurredAt: Carbon::parse($row['checkpoint_date_time']),
|
||||
meta: $row,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private function isDelivered(Shipment $shipment): bool
|
||||
{
|
||||
try {
|
||||
$response = $this->client->call('ACS_Trackingsummary', [
|
||||
'Voucher_No' => $shipment->tracking_reference,
|
||||
])->throwIfError();
|
||||
} catch (AcsApiException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) ($response->valueOutput['shipment_status'] ?? 0) === 4;
|
||||
}
|
||||
|
||||
private function guessStatusFromAction(string $action): TrackingStatus
|
||||
{
|
||||
$action = strtolower($action);
|
||||
|
||||
return match (true) {
|
||||
str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered,
|
||||
str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery,
|
||||
str_contains($action, 'arrival') || str_contains($action, 'departure') => TrackingStatus::InTransit,
|
||||
default => TrackingStatus::Pending,
|
||||
};
|
||||
}
|
||||
|
||||
private function persistMultipartVouchers(Shipment $mainShipment): void
|
||||
{
|
||||
$response = $this->client->call('ACS_Get_Multipart_Vouchers', [
|
||||
'Main_Voucher_No' => $mainShipment->tracking_reference,
|
||||
])->throwIfError();
|
||||
|
||||
foreach ($response->tableOutput['Table_Data'] ?? [] as $row) {
|
||||
Shipment::create([
|
||||
'order_id' => $mainShipment->order_id,
|
||||
'carrier' => 'acs',
|
||||
'tracking_reference' => $row['MultiPart_Voucher_No'],
|
||||
'parent_reference' => $mainShipment->tracking_reference,
|
||||
'meta' => $mainShipment->meta?->toArray() ?? [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||
|
||||
use Lunar\DataTypes\Price;
|
||||
use Lunar\DataTypes\ShippingOption;
|
||||
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
||||
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
||||
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\SupportsLivePricing;
|
||||
|
||||
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
||||
{
|
||||
use ResolvesFixedPricing;
|
||||
use CachesLivePricing;
|
||||
|
||||
public ShippingRate $shippingRate;
|
||||
|
||||
public function __construct(
|
||||
private readonly AcsClient $client,
|
||||
private readonly AreaResolver $areaResolver,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'ACS Courier';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Live rate quote from ACS Courier.';
|
||||
}
|
||||
|
||||
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
|
||||
{
|
||||
$shippingRate = $shippingOptionRequest->shippingRate;
|
||||
$shippingMethod = $shippingRate->shippingMethod;
|
||||
$cart = $shippingOptionRequest->cart;
|
||||
|
||||
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
|
||||
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
|
||||
}
|
||||
|
||||
$postcode = $cart->shippingAddress?->postcode;
|
||||
|
||||
if (! $postcode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapped in CachesLivePricing's cache so a live-pricing outage within
|
||||
* the cache window still serves the last successful quote instead of
|
||||
* immediately falling back. A cold cache during an outage falls back
|
||||
* to the rate's own configured static price (resolveFixedPrice()) —
|
||||
* see ManageShippingRates, which now allows a static price to be
|
||||
* configured on a "live" rate specifically for this fallback.
|
||||
*/
|
||||
private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption
|
||||
{
|
||||
return $this->cached($shippingRate, $cart, function () use ($shippingRate, $shippingMethod, $cart, $postcode) {
|
||||
try {
|
||||
$destination = $this->areaResolver->resolve($postcode);
|
||||
|
||||
$response = $this->client->call('ACS_Price_Calculation', [
|
||||
'Billing_Code' => config('acs.billing_code'),
|
||||
'Acs_Station_Destination' => $destination->stationId,
|
||||
'Weight' => $this->totalWeightInKg($cart),
|
||||
'Pickup_Date' => now()->toDateString(),
|
||||
'Charge_Type' => 2,
|
||||
])->throwIfError();
|
||||
} catch (AcsApiException $e) {
|
||||
report($e);
|
||||
|
||||
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
|
||||
}
|
||||
|
||||
$amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
|
||||
|
||||
return new ShippingOption(
|
||||
name: $shippingMethod->name ?: $this->name(),
|
||||
description: $shippingMethod->description ?: $this->description(),
|
||||
identifier: $shippingRate->getIdentifier(),
|
||||
price: new Price($amount, $cart->currency, 1),
|
||||
taxClass: $shippingRate->getTaxClass(),
|
||||
taxReference: $shippingRate->getTaxReference(),
|
||||
meta: ['acs_station_destination' => $destination->stationId],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function on(ShippingRate $shippingRate): self
|
||||
{
|
||||
$this->shippingRate = $shippingRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function totalWeightInKg($cart): float
|
||||
{
|
||||
$weight = 0.0;
|
||||
|
||||
foreach ($cart->lines->load('purchasable') as $line) {
|
||||
$variant = $line->purchasable;
|
||||
|
||||
if (! $variant || ! $variant->weight_value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unit = $variant->weight_unit ?? 'kg';
|
||||
$value = (float) $variant->weight_value;
|
||||
|
||||
$weight += match ($unit) {
|
||||
'g' => $value / 1000,
|
||||
'lb' => $value * 0.45359237,
|
||||
'oz' => $value * 0.0283495231,
|
||||
default => $value, // kg
|
||||
} * $line->quantity;
|
||||
}
|
||||
|
||||
return max($weight, 0.5); // ACS minimum billable weight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||
|
||||
class AcsResponse
|
||||
{
|
||||
private function __construct(
|
||||
public readonly bool $hasError,
|
||||
public readonly ?string $errorMessage,
|
||||
public readonly array $valueOutput,
|
||||
public readonly array $tableOutput,
|
||||
) {}
|
||||
|
||||
public static function fromHttpResponse(Response $response): self
|
||||
{
|
||||
$body = $response->json() ?? [];
|
||||
|
||||
// ACS's own JSON key is misspelled ("Responce") — preserved here verbatim.
|
||||
$output = $body['ACSOutputResponce'] ?? [];
|
||||
|
||||
return new self(
|
||||
hasError: (bool) ($body['ACSExecution_HasError'] ?? ! $response->successful()),
|
||||
errorMessage: $body['ACSExecutionErrorMessage'] ?? null,
|
||||
valueOutput: $output['ACSValueOutput'][0] ?? [],
|
||||
tableOutput: $output['ACSTableOutput'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
public function throwIfError(): self
|
||||
{
|
||||
if ($this->hasError) {
|
||||
throw new AcsApiException(
|
||||
$this->errorMessage ?? ($this->valueOutput['Error_Message'] ?? 'Unknown ACS API error'),
|
||||
$this->tableOutput,
|
||||
);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||
|
||||
class AreaResolver
|
||||
{
|
||||
public const CACHE_KEY = 'acs.areas';
|
||||
|
||||
public function __construct(private readonly AcsClient $client) {}
|
||||
|
||||
/**
|
||||
* Resolve a Greek postcode to its ACS station/branch codes.
|
||||
*
|
||||
* Reads from the table warmed daily by WarmAcsAreaCacheJob. Falls
|
||||
* back to a live lookup for that single postcode if the warmed cache is
|
||||
* missing (e.g. the daily job never ran or failed) or doesn't contain it.
|
||||
*/
|
||||
public function resolve(string $postcode): AcsArea
|
||||
{
|
||||
$areas = Cache::get(self::CACHE_KEY);
|
||||
|
||||
if ($areas !== null && isset($areas[$postcode])) {
|
||||
return $this->toArea($areas[$postcode]);
|
||||
}
|
||||
|
||||
return $this->toArea($this->fetch($postcode));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache the full country's postcode-to-station map in one call.
|
||||
*/
|
||||
public function warmAll(): void
|
||||
{
|
||||
$areas = [];
|
||||
|
||||
foreach ($this->fetchAll() as $row) {
|
||||
$areas[$row['Zip_Code']] = $row;
|
||||
}
|
||||
|
||||
Cache::forever(self::CACHE_KEY, $areas);
|
||||
}
|
||||
|
||||
private function fetch(string $postcode): array
|
||||
{
|
||||
$response = $this->client->call('ACS_Area_Find_By_Zip_Code', [
|
||||
'Zip_Code' => $postcode,
|
||||
'Show_Only_Inaccessible_Areas' => 0,
|
||||
'Country' => 'GR',
|
||||
])->throwIfError();
|
||||
|
||||
$area = $response->tableOutput['Table_Data'][0] ?? null;
|
||||
|
||||
if (! $area) {
|
||||
throw new AcsApiException("No ACS area found for postcode {$postcode}");
|
||||
}
|
||||
|
||||
return $area;
|
||||
}
|
||||
|
||||
private function fetchAll(): array
|
||||
{
|
||||
$response = $this->client->call('ACS_Area_Find_By_Zip_Code', [
|
||||
'Zip_Code' => null,
|
||||
'Show_Only_Inaccessible_Areas' => 0,
|
||||
'Country' => 'GR',
|
||||
])->throwIfError();
|
||||
|
||||
return $response->tableOutput['Table_Data'] ?? [];
|
||||
}
|
||||
|
||||
private function toArea(array $row): AcsArea
|
||||
{
|
||||
return new AcsArea(
|
||||
stationId: $row['Station_ID'],
|
||||
branchId: (int) $row['Branch_ID'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\Acs\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class AcsApiException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, public readonly array $tableOutput = [])
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\Acs\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Modules\Core\Shipping\Carriers\Acs\AreaResolver;
|
||||
|
||||
class WarmAcsAreaCacheJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public function handle(AreaResolver $areaResolver): void
|
||||
{
|
||||
$areaResolver->warmAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
|
||||
|
||||
class BoxNowClient
|
||||
{
|
||||
private const TOKEN_CACHE_KEY = 'boxnow.access_token';
|
||||
|
||||
public function __construct(private readonly array $config) {}
|
||||
|
||||
/**
|
||||
* GET/POST against the main delivery/parcel API, authenticated with a
|
||||
* cached Bearer token.
|
||||
*/
|
||||
public function request(string $method, string $path, array $payload = []): array
|
||||
{
|
||||
$response = Http::withToken($this->token())
|
||||
->timeout($this->config['timeout'])
|
||||
->{$method}("{$this->config['base_url']}{$path}", $payload);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
// Token expired early / was revoked — refresh once and retry.
|
||||
Cache::forget(self::TOKEN_CACHE_KEY);
|
||||
|
||||
$response = Http::withToken($this->token())
|
||||
->timeout($this->config['timeout'])
|
||||
->{$method}("{$this->config['base_url']}{$path}", $payload);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new BoxNowApiException(
|
||||
"Box Now API error ({$response->status()}) on {$method} {$path}",
|
||||
$response->json() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The origins/destinations lookups are served faster from a separate
|
||||
* location API host, per Box Now's own documentation.
|
||||
*/
|
||||
public function locationRequest(string $path, array $query = []): array
|
||||
{
|
||||
$response = Http::withToken($this->token())
|
||||
->timeout($this->config['timeout'])
|
||||
->get("{$this->config['location_api_url']}{$path}", $query);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new BoxNowApiException(
|
||||
"Box Now location API error ({$response->status()}) on GET {$path}",
|
||||
$response->json() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw bytes (e.g. a PDF label) rather than JSON.
|
||||
*/
|
||||
public function requestRaw(string $path): string
|
||||
{
|
||||
$response = Http::withToken($this->token())
|
||||
->timeout($this->config['timeout'])
|
||||
->get("{$this->config['base_url']}{$path}");
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new BoxNowApiException("Box Now API error ({$response->status()}) on GET {$path}");
|
||||
}
|
||||
|
||||
return $response->body();
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(55), function () {
|
||||
$response = Http::timeout($this->config['timeout'])
|
||||
->post("{$this->config['base_url']}/auth-sessions", [
|
||||
'grant_type' => 'client_credentials',
|
||||
'client_id' => $this->config['client_id'],
|
||||
'client_secret' => $this->config['client_secret'],
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new BoxNowApiException('Box Now authentication failed', $response->json() ?? []);
|
||||
}
|
||||
|
||||
return $response->json('access_token');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Contracts\SupportsTracking;
|
||||
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
|
||||
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
/**
|
||||
* Unlike ACS, Box Now has no manifest/pickup-list step — creating a
|
||||
* delivery request also books the courier pickup, so this only implements
|
||||
* CarrierFulfillmentInterface (not SupportsManifestBatching).
|
||||
*
|
||||
* Box Now delivers to lockers, not addresses. The storefront locker-picker
|
||||
* is out of scope for this pass — createShipment() requires the chosen
|
||||
* locker's Box Now locationId via ShipmentRequest::$destinationLocationId
|
||||
* (e.g. set manually by admin staff until checkout UI exists).
|
||||
*/
|
||||
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
|
||||
{
|
||||
public function __construct(private readonly BoxNowClient $client) {}
|
||||
|
||||
public function createShipment(Order $order, ShipmentRequest $request): Shipment
|
||||
{
|
||||
$address = $order->shippingAddress;
|
||||
$destinationLocationId = $request->destinationLocationId;
|
||||
|
||||
if (! $destinationLocationId) {
|
||||
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
|
||||
}
|
||||
|
||||
$isCod = $request->paymentMode === 'cod';
|
||||
|
||||
$response = $this->client->request('post', '/delivery-requests', [
|
||||
'orderNumber' => $order->reference.'-'.$order->id,
|
||||
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
|
||||
'paymentMode' => $isCod ? 'cod' : 'prepaid',
|
||||
'amountToBeCollected' => $isCod
|
||||
? number_format($request->amountToCollect ?? $order->total->decimal, 2, '.', '')
|
||||
: '0.00',
|
||||
'origin' => [
|
||||
'contactNumber' => config('boxnow.sender.phone'),
|
||||
'contactEmail' => config('boxnow.sender.email'),
|
||||
'contactName' => config('boxnow.sender.name'),
|
||||
'locationId' => config('boxnow.origin_location_id'),
|
||||
],
|
||||
'destination' => [
|
||||
'contactNumber' => $address->contact_phone,
|
||||
'contactEmail' => $address->contact_email,
|
||||
'contactName' => trim("{$address->first_name} {$address->last_name}"),
|
||||
'locationId' => $destinationLocationId,
|
||||
],
|
||||
'items' => [
|
||||
[
|
||||
'id' => (string) $order->id,
|
||||
'name' => 'Order '.$order->reference,
|
||||
'value' => '0.00',
|
||||
'compartmentSize' => 1,
|
||||
'weight' => $request->weight ?? 0,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException(
|
||||
'Box Now delivery request succeeded but returned no parcel id.',
|
||||
$response,
|
||||
));
|
||||
|
||||
return Shipment::create([
|
||||
'order_id' => $order->id,
|
||||
'carrier' => 'box-now',
|
||||
'tracking_reference' => $parcelId,
|
||||
'meta' => [
|
||||
'delivery_request_id' => $response['id'] ?? null,
|
||||
'locker_id' => $destinationLocationId,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function printLabel(Shipment $shipment): string
|
||||
{
|
||||
$bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf");
|
||||
|
||||
$shipment->update(['label_printed_at' => now()]);
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
public function cancelShipment(Shipment $shipment): void
|
||||
{
|
||||
$this->client->request('post', "/parcels/{$shipment->tracking_reference}:cancel");
|
||||
|
||||
$shipment->update(['cancelled_at' => now()]);
|
||||
}
|
||||
|
||||
public function trackShipment(Shipment $shipment): Collection
|
||||
{
|
||||
$response = $this->client->request('get', '/parcels', [
|
||||
'parcelId' => $shipment->tracking_reference,
|
||||
]);
|
||||
|
||||
$parcel = $response['data'][0] ?? null;
|
||||
|
||||
if (! $parcel) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$events = $parcel['events'] ?? [];
|
||||
|
||||
// Fall back to a single checkpoint from the parcel's current state
|
||||
// if Box Now didn't return a detailed events history.
|
||||
if (empty($events)) {
|
||||
$events = [[
|
||||
'type' => $parcel['state'] ?? 'new',
|
||||
'locationDisplayName' => null,
|
||||
'createTime' => $parcel['updateTime'] ?? $parcel['createTime'] ?? now()->toIso8601String(),
|
||||
]];
|
||||
}
|
||||
|
||||
return collect($events)->map(fn (array $event) => new TrackingCheckpoint(
|
||||
status: $this->mapState($event['type'] ?? $parcel['state'] ?? 'new'),
|
||||
carrierStatus: $event['type'] ?? $parcel['state'] ?? null,
|
||||
message: null,
|
||||
location: $event['locationDisplayName'] ?? null,
|
||||
occurredAt: Carbon::parse($event['createTime']),
|
||||
meta: $event,
|
||||
));
|
||||
}
|
||||
|
||||
private function mapState(string $state): TrackingStatus
|
||||
{
|
||||
return match ($state) {
|
||||
'new' => TrackingStatus::Pending,
|
||||
'in-transit', 'in-depot' => TrackingStatus::InTransit,
|
||||
'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery,
|
||||
'delivered' => TrackingStatus::Delivered,
|
||||
'returned', 'accepted-for-return' => TrackingStatus::Returned,
|
||||
'cancelled' => TrackingStatus::Cancelled,
|
||||
'expired-return', 'missing' => TrackingStatus::Failed,
|
||||
default => TrackingStatus::Unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
||||
|
||||
use Lunar\DataTypes\ShippingOption;
|
||||
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
||||
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
||||
use Lunar\Shipping\Models\ShippingRate;
|
||||
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||
|
||||
/**
|
||||
* Box Now has no pricing API, so this always resolves the method's normal
|
||||
* charge_by + price-break configuration — the same mechanism the built-in
|
||||
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
|
||||
* there is no live option to offer.
|
||||
*/
|
||||
class BoxNowRateDriver implements ShippingRateInterface
|
||||
{
|
||||
use ResolvesFixedPricing;
|
||||
|
||||
public ShippingRate $shippingRate;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Box Now Locker Delivery';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Deliver to a Box Now parcel locker.';
|
||||
}
|
||||
|
||||
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
|
||||
{
|
||||
return $this->resolveFixedPrice(
|
||||
$shippingOptionRequest->shippingRate,
|
||||
$shippingOptionRequest->shippingRate->shippingMethod,
|
||||
$shippingOptionRequest->cart,
|
||||
);
|
||||
}
|
||||
|
||||
public function on(ShippingRate $shippingRate): self
|
||||
{
|
||||
$this->shippingRate = $shippingRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\BoxNow\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class BoxNowApiException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, public readonly array $body = [])
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Lunar\DataTypes\ShippingOption;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Shipping\Models\ShippingRate;
|
||||
|
||||
/**
|
||||
* Shared by any Modules\Core\Shipping\Contracts\SupportsLivePricing driver —
|
||||
* a live carrier price quote is a real, billed API call, but stable from
|
||||
* one fetch to the next within a single checkout attempt. Keyed by rate id
|
||||
* + cart id, so two different live-priced rates (e.g. ACS and a future
|
||||
* carrier) never collide — each is its own ShippingRate row. Not shared
|
||||
* across carts: the quote depends on cart-specific weight/quantity/
|
||||
* destination (see docs/checkout.md).
|
||||
*
|
||||
* Invalidated by Modules\Core\Shipping\Listeners\FlushLivePricingCache on
|
||||
* the only two things that can change what this cart's quote should be: a
|
||||
* cart line changing (add/update/remove/clear) or the shipping address
|
||||
* changing. Deliberately NOT invalidated on order placement — the price
|
||||
* the shopper was quoted must still be there if anything re-reads it after
|
||||
* the order exists; Cart::createOrder() persists the resolved price onto
|
||||
* the order anyway, so nothing should be re-querying the live driver for
|
||||
* that cart again regardless of cache state.
|
||||
*/
|
||||
trait CachesLivePricing
|
||||
{
|
||||
private function cached(ShippingRate $shippingRate, Cart $cart, \Closure $resolve): ?ShippingOption
|
||||
{
|
||||
return Cache::remember(
|
||||
"shipping.live_price.{$shippingRate->id}.{$cart->id}",
|
||||
now()->addMinutes(30),
|
||||
$resolve,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Concerns;
|
||||
|
||||
use Lunar\DataTypes\ShippingOption;
|
||||
use Lunar\Facades\Pricing;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Lunar\Shipping\Models\ShippingRate;
|
||||
|
||||
/**
|
||||
* Shared by any carrier driver that also supports Lunar's own price-break
|
||||
* pricing (charge_by = cart_total | weight) as a fallback to, or standalone
|
||||
* alternative for, live API pricing. Mirrors the vendor ShipBy driver's
|
||||
* charge_by handling exactly, so behavior is consistent with the rest of
|
||||
* Lunar's shipping system rather than inventing a separate mechanism.
|
||||
*/
|
||||
trait ResolvesFixedPricing
|
||||
{
|
||||
private function resolveFixedPrice(ShippingRate $shippingRate, ShippingMethod $shippingMethod, $cart): ?ShippingOption
|
||||
{
|
||||
$chargeBy = $shippingMethod->data['charge_by'] ?? 'cart_total';
|
||||
|
||||
$tier = $chargeBy === 'weight'
|
||||
? $cart->lines->load('purchasable')->sum(fn ($line) => ($line->purchasable->weight_value ?? 0) * $line->quantity)
|
||||
: $cart->lines->sum('subTotal.value');
|
||||
|
||||
$pricing = Pricing::for($shippingRate)->qty($tier)->get();
|
||||
|
||||
if (! $pricing->matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ShippingOption(
|
||||
name: $shippingMethod->name ?: $this->name(),
|
||||
description: $shippingMethod->description ?: $this->description(),
|
||||
identifier: $shippingRate->getIdentifier(),
|
||||
price: $pricing->matched->price,
|
||||
taxClass: $shippingRate->getTaxClass(),
|
||||
taxReference: $shippingRate->getTaxReference(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Contracts;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
interface CarrierFulfillmentInterface
|
||||
{
|
||||
/**
|
||||
* Create a shipment with the carrier for the given order.
|
||||
*/
|
||||
public function createShipment(Order $order, ShipmentRequest $request): Shipment;
|
||||
|
||||
/**
|
||||
* Fetch the printable label for a shipment (raw file bytes).
|
||||
*/
|
||||
public function printLabel(Shipment $shipment): string;
|
||||
|
||||
/**
|
||||
* Cancel a shipment with the carrier.
|
||||
*/
|
||||
public function cancelShipment(Shipment $shipment): void;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Contracts;
|
||||
|
||||
/**
|
||||
* Marker for a Lunar\Shipping\Interfaces\ShippingRateInterface driver that
|
||||
* calculates its price from a live carrier API rather than the manually
|
||||
* configured ShippingRate price/price-breaks. Admin UI uses this to hide
|
||||
* the manual pricing fields for such drivers — carriers without a pricing
|
||||
* API simply don't implement it, and manual pricing remains required.
|
||||
*/
|
||||
interface SupportsLivePricing {}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Contracts;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
|
||||
|
||||
/**
|
||||
* Optional capability for carriers that batch shipments into a manifest
|
||||
* before courier pickup (e.g. ACS's end-of-day pickup list). Carriers
|
||||
* without this concept simply don't implement it.
|
||||
*/
|
||||
interface SupportsManifestBatching
|
||||
{
|
||||
/**
|
||||
* Shipments created but not yet included in an issued manifest.
|
||||
*/
|
||||
public function pendingForManifest(): Collection;
|
||||
|
||||
/**
|
||||
* Finalize the given shipments into a manifest with the carrier.
|
||||
*/
|
||||
public function issueManifest(Collection $shipments): ManifestResult;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Contracts;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
/**
|
||||
* Optional capability for carriers that expose shipment tracking. Carriers
|
||||
* without a tracking API simply don't implement it.
|
||||
*/
|
||||
interface SupportsTracking
|
||||
{
|
||||
/**
|
||||
* Return the shipment's known checkpoints from the carrier — as many
|
||||
* as the carrier's API returns in one call, not just the latest one.
|
||||
* Deduplication against what's already stored happens elsewhere.
|
||||
*
|
||||
* @return Collection<int, TrackingCheckpoint>
|
||||
*/
|
||||
public function trackShipment(Shipment $shipment): Collection;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\DataTransferObjects;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ManifestResult
|
||||
{
|
||||
private function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly ?string $reference,
|
||||
public readonly Collection $includedShipments,
|
||||
public readonly Collection $blockedShipments,
|
||||
public readonly ?string $reason,
|
||||
) {}
|
||||
|
||||
public static function success(string $reference, Collection $includedShipments): self
|
||||
{
|
||||
return new self(true, $reference, $includedShipments, collect(), null);
|
||||
}
|
||||
|
||||
public static function blocked(Collection $blockedShipments, string $reason): self
|
||||
{
|
||||
return new self(false, null, collect(), $blockedShipments, $reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\DataTransferObjects;
|
||||
|
||||
/**
|
||||
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
|
||||
* Every field is optional — a carrier reads only what it needs and ignores
|
||||
* the rest (e.g. destinationLocationId only matters to locker-delivery
|
||||
* carriers like Box Now; ACS has no use for it).
|
||||
*/
|
||||
class ShipmentRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ?float $weight = null,
|
||||
public readonly int $packageCount = 1,
|
||||
public readonly ?string $destinationLocationId = null,
|
||||
public readonly ?string $paymentMode = null,
|
||||
public readonly ?float $amountToCollect = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\DataTransferObjects;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
|
||||
/**
|
||||
* One normalized checkpoint in a shipment's carrier-reported history.
|
||||
* Returned (potentially several at once) by SupportsTracking::trackShipment().
|
||||
*/
|
||||
class TrackingCheckpoint
|
||||
{
|
||||
public function __construct(
|
||||
public readonly TrackingStatus $status,
|
||||
public readonly ?string $carrierStatus,
|
||||
public readonly ?string $message,
|
||||
public readonly ?string $location,
|
||||
public readonly CarbonInterface $occurredAt,
|
||||
public readonly array $meta = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Enums;
|
||||
|
||||
/**
|
||||
* Normalized shipment status, mapped from each carrier's own status codes
|
||||
* (e.g. ACS's shipment_status/non_delivery_reason_code, Box Now's parcel
|
||||
* state) so the rest of the system never needs to know carrier-specific
|
||||
* vocabulary.
|
||||
*/
|
||||
enum TrackingStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case InTransit = 'in_transit';
|
||||
case OutForDelivery = 'out_for_delivery';
|
||||
case Delivered = 'delivered';
|
||||
case Failed = 'failed';
|
||||
case Returned = 'returned';
|
||||
case Cancelled = 'cancelled';
|
||||
case Unknown = 'unknown';
|
||||
|
||||
public function isTerminal(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Delivered, self::Returned, self::Cancelled => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Modules\Core\Shipping\Models\ShipmentInfo;
|
||||
|
||||
/**
|
||||
* Fired once per newly-recorded ShipmentInfo checkpoint. Listeners (e.g.
|
||||
* customer notifications, order status sync) are added separately.
|
||||
*/
|
||||
class ShipmentStatusUpdatedByCarrier
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(public readonly ShipmentInfo $shipmentInfo) {}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Extensions;
|
||||
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Lunar\Admin\Support\Extending\ViewPageExtension;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
|
||||
|
||||
class OrderViewExtension extends ViewPageExtension
|
||||
{
|
||||
public function headerActions(array $actions): array
|
||||
{
|
||||
$actions[] = $this->createShipmentAction();
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function createShipmentAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('create_shipment')
|
||||
->label('Create Shipment')
|
||||
->icon('heroicon-o-truck')
|
||||
->modalSubmitActionLabel('Create Shipment')
|
||||
->form([
|
||||
Forms\Components\TextInput::make('weight')
|
||||
->label('Package weight (kg)')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->helperText('Leave blank to use the carrier\'s default.'),
|
||||
Forms\Components\TextInput::make('destination_location_id')
|
||||
->label('Box Now locker ID')
|
||||
->helperText('Only required for Box Now shipments.')
|
||||
->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null),
|
||||
Forms\Components\Toggle::make('confirm')
|
||||
->label('Confirm')
|
||||
->helperText('This will create a real shipment with the carrier.')
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if ($value !== true) {
|
||||
$fail('Please confirm before creating the shipment.');
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
])
|
||||
->action(function (Order $record, array $data, Actions\Action $action) {
|
||||
$service = $this->resolveFulfillmentService($record);
|
||||
|
||||
if (! $service) {
|
||||
Notification::make()
|
||||
->title('No carrier fulfillment integration is configured for this order.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
$action->halt();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$request = new ShipmentRequest(
|
||||
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
|
||||
destinationLocationId: $data['destination_location_id'] ?? null,
|
||||
);
|
||||
|
||||
try {
|
||||
$service->createShipment($record, $request);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make()
|
||||
->title('Failed to create shipment: '.$e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
$action->halt();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Shipment created.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Order $record) => $record->shipments()->exists() === false
|
||||
&& $this->resolveFulfillmentService($record) !== null);
|
||||
}
|
||||
|
||||
private function resolveCarrier(Order $record): ?string
|
||||
{
|
||||
$code = $record->shippingAddress?->shipping_option;
|
||||
|
||||
if (! $code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ShippingMethod::where('code', $code)->value('driver');
|
||||
}
|
||||
|
||||
private function resolveFulfillmentService(Order $record): ?CarrierFulfillmentInterface
|
||||
{
|
||||
$carrier = $this->resolveCarrier($record);
|
||||
|
||||
if (! $carrier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Extensions;
|
||||
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Group;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Lunar\Admin\Support\Extending\BaseExtension;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
|
||||
|
||||
/**
|
||||
* ListShippingMethod::getDefaultHeaderActions() builds its CreateAction's
|
||||
* form inline (calling ShippingMethodResource::getDriverFormComponent()
|
||||
* directly, a hardcoded 2-option Select) rather than through the resource's
|
||||
* own extendForm() pipeline, so ShippingMethodResourceExtension's driver
|
||||
* fix never reaches it. Re-declares the same create-action form with a
|
||||
* dynamic driver Select instead.
|
||||
*/
|
||||
class ShippingMethodListExtension extends BaseExtension
|
||||
{
|
||||
public function headerActions(array $actions): array
|
||||
{
|
||||
foreach ($actions as $action) {
|
||||
if ($action instanceof Actions\CreateAction) {
|
||||
$action->form([
|
||||
ShippingMethodResource::getNameFormComponent(),
|
||||
Group::make([
|
||||
ShippingMethodResource::getCodeFormComponent(),
|
||||
$this->driverSelect(),
|
||||
])->columns(2),
|
||||
ShippingMethodResource::getDescriptionFormComponent(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function driverSelect(): Select
|
||||
{
|
||||
return Select::make('driver')
|
||||
->label('Type')
|
||||
->options(fn () => collect(Shipping::getSupportedDrivers())
|
||||
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
|
||||
->default('flat-rate');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Extensions;
|
||||
|
||||
use Filament\Forms\Components\Component;
|
||||
use Filament\Forms\Components\Concerns\HasChildComponents;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Lunar\Admin\Support\Extending\ResourceExtension;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||
|
||||
class ShippingMethodResourceExtension extends ResourceExtension
|
||||
{
|
||||
public function extendForm(Form $form): Form
|
||||
{
|
||||
return $form->schema(
|
||||
$this->replaceChargeByField(
|
||||
$this->replaceDriverField($form->getComponents())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
} 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();
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @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->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()]))
|
||||
->default('flat-rate')
|
||||
->live();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Pages;
|
||||
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\BulkAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
class ManagePickupManifests extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-truck';
|
||||
|
||||
protected static ?string $navigationLabel = 'Pickup Manifests';
|
||||
|
||||
/**
|
||||
* Without an explicit group, this page had no navigation group at all —
|
||||
* Filament's Panel::getUrl() falls back to "first item in the first
|
||||
* navigation group" when no homeUrl is set (neither Lunar nor CorePlugin
|
||||
* sets one), and an ungrouped page sorted ahead of every one of Lunar's
|
||||
* own grouped resources (Sales, Catalog, etc.), making this page the
|
||||
* panel's de facto home instead of the real Dashboard. Grouping it under
|
||||
* Sales — alongside CartResource, OrderResource — fixes that by letting
|
||||
* a legitimate item sort first again. Sorted last within the group
|
||||
* deliberately (a high explicit navigationSort — Lunar's own
|
||||
* OrderResource uses 1) so this page never competes to be first even as
|
||||
* more Sales-group items are added later.
|
||||
*/
|
||||
protected static ?string $navigationGroup = 'Sales';
|
||||
|
||||
protected static ?int $navigationSort = 100;
|
||||
|
||||
protected static string $view = 'core::shipping.filament.pages.manage-pickup-manifests';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query($this->pendingQuery())
|
||||
->columns([
|
||||
TextColumn::make('carrier')->badge(),
|
||||
TextColumn::make('tracking_reference')->label('Tracking #'),
|
||||
TextColumn::make('order.reference')->label('Order'),
|
||||
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
|
||||
])
|
||||
->actions([
|
||||
Action::make('print')
|
||||
->label('Print')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Shipment $record) => $this->printShipment($record)),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkAction::make('print_selected')
|
||||
->label('Print selected')
|
||||
->icon('heroicon-o-printer')
|
||||
->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => $this->printShipment($shipment))),
|
||||
BulkAction::make('issue_manifest')
|
||||
->label('Issue Manifest')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->action(fn (Collection $records) => $this->issueManifest($records)),
|
||||
]);
|
||||
}
|
||||
|
||||
private function pendingQuery(): Builder
|
||||
{
|
||||
$carriers = collect(Shipping::getSupportedDrivers())->keys()->filter(
|
||||
fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsManifestBatching
|
||||
);
|
||||
|
||||
return Shipment::query()
|
||||
->whereIn('carrier', $carriers)
|
||||
->whereNull('manifest_reference')
|
||||
->whereNull('cancelled_at');
|
||||
}
|
||||
|
||||
private function printShipment(Shipment $shipment): void
|
||||
{
|
||||
$this->fulfillmentService($shipment->carrier)?->printLabel($shipment);
|
||||
}
|
||||
|
||||
private function issueManifest(Collection $shipments): void
|
||||
{
|
||||
$shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) {
|
||||
$service = $this->fulfillmentService($carrier);
|
||||
|
||||
if (! $service instanceof SupportsManifestBatching) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $service->issueManifest($group);
|
||||
|
||||
if (! $result->success) {
|
||||
Notification::make()
|
||||
->title("Manifest blocked for {$carrier}: {$result->reason}")
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title("Manifest issued for {$carrier}: {$result->reference}")
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
|
||||
{
|
||||
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Filament\Pages;
|
||||
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Lunar\Shipping\Models\ShippingRate;
|
||||
|
||||
/**
|
||||
* Bound in place of the vendor ManageShippingRates page via the container
|
||||
* (see ShippingServiceProvider), since that page has no extension hook of
|
||||
* its own. Every reference to the vendor class name — routes, sub-nav,
|
||||
* ShippingZoneResource::getPages() — is untouched; the container simply
|
||||
* hands back this subclass whenever the vendor class is resolved.
|
||||
*
|
||||
* Relabels the price / price-break fields for a rate whose method has
|
||||
* charge_by = "live" (see ShippingMethodResourceExtension, which adds that
|
||||
* option to methods whose driver supports live pricing) — they stay
|
||||
* visible and editable, but as the fallback price used when the live API
|
||||
* call fails (see AcsRateDriver::resolveLivePrice()), not the primary
|
||||
* price. Pricing strategy (cart_total / weight / live) stays entirely on
|
||||
* the Shipping Method, matching Lunar's own existing charge_by convention;
|
||||
* nothing new is stored on the rate itself.
|
||||
*
|
||||
* Also re-binds the vendor price field's afterStateHydrated(): the vendor
|
||||
* callback reads $record->basePrices->first()->price->decimal with no
|
||||
* null-guard, which crashes on any rate with no basePrices row — routine
|
||||
* for a live rate that has never had a fallback price configured. Same
|
||||
* logic, just null-safe.
|
||||
*/
|
||||
class ManageShippingRates extends BaseManageShippingRates
|
||||
{
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
$form = parent::form($form);
|
||||
|
||||
return $form->schema(
|
||||
$this->labelPriceFieldsAsFallbackWhenLive($form->getComponents())
|
||||
);
|
||||
}
|
||||
|
||||
private function labelPriceFieldsAsFallbackWhenLive(array $components): array
|
||||
{
|
||||
$isLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) === 'live';
|
||||
|
||||
foreach ($components as $component) {
|
||||
if (! method_exists($component, 'getName')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($component->getName() === 'price') {
|
||||
$component->required(fn (Get $get) => ! $isLive($get))
|
||||
->helperText(fn (Get $get) => $isLive($get)
|
||||
? 'Used only if the live API call fails.'
|
||||
: null)
|
||||
->afterStateHydrated(static function (TextInput $component, ?Model $record = null): void {
|
||||
if (! $record) {
|
||||
return;
|
||||
}
|
||||
|
||||
$basePrice = $record->basePrices->first();
|
||||
|
||||
$component->state($basePrice?->price->decimal);
|
||||
});
|
||||
}
|
||||
|
||||
if ($component->getName() === 'prices') {
|
||||
$component->helperText(fn (Get $get) => $isLive($get)
|
||||
? 'Used only if the live API call fails.'
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
return $components;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$table = parent::table($table);
|
||||
|
||||
return $table->columns(
|
||||
array_map(function ($column) {
|
||||
if (method_exists($column, 'getName') && $column->getName() === 'basePrices.0') {
|
||||
return TextColumn::make('basePrices.0')
|
||||
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label'))
|
||||
->formatStateUsing(function ($state, ShippingRate $record) {
|
||||
if (static::methodChargeBy($record->shipping_method_id) === 'live') {
|
||||
return $state === null
|
||||
? 'Live API pricing, no fallback set'
|
||||
: $state->price->formatted.' (fallback)';
|
||||
}
|
||||
|
||||
return $state?->price->formatted;
|
||||
});
|
||||
}
|
||||
|
||||
return $column;
|
||||
}, $table->getColumns())
|
||||
);
|
||||
}
|
||||
|
||||
protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string
|
||||
{
|
||||
if (blank($method)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $method instanceof ShippingMethod) {
|
||||
$method = ShippingMethod::find($method);
|
||||
}
|
||||
|
||||
return $method?->data['charge_by'] ?? null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user