Merge branch 'master' into Privacy

This commit is contained in:
2026-09-16 00:13:41 +03:00
322 changed files with 21329 additions and 658 deletions
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Auth\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Auth\Services\UserOtpService::generateAndSend()
* when an email has requested too many codes too quickly — caps both
* mail-bombing one inbox and the "just request a fresh code to reset my
* guess count" loophole a per-code attempt cap alone doesn't close.
*/
class OtpThrottledException extends RuntimeException
{
public function __construct(
public readonly int $availableInSeconds,
) {
parent::__construct("Too many code requests. Try again in {$availableInSeconds} second(s).");
}
}
@@ -2,18 +2,18 @@
namespace Modules\Core\Auth\Extensions;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Lunar\Admin\Support\Extending\ResourceExtension;
class StaffResourceExtension extends ResourceExtension
{
public function extendForm(Form $form): Form
public function extendForm(Schema $form): Schema
{
$schema = collect($form->getComponents())
->reject(fn ($component) => method_exists($component, 'getName') && $component->getName() == 'password')
->values()
->all();
return $form->schema($schema);
return $form->components($schema);
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ class Login extends SimplePage
{
use WithRateLimiting;
protected static string $view = 'core::auth.filament.pages.login';
protected string $view = 'core::auth.filament.pages.login';
public ?string $email = '';
public ?string $otp = '';
@@ -78,7 +78,7 @@ class Login extends SimplePage
]);
}
if ($staff instanceof FilamentUser && !$staff->canAccessPanel(Filament::getCurrentPanel())) {
if ($staff instanceof FilamentUser && !$staff->canAccessPanel(Filament::getCurrentOrDefaultPanel())) {
throw ValidationException::withMessages([
'email' => 'You do not have access to this panel.',
]);
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Auth\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Core\Auth\Services\UserSessionService;
use Symfony\Component\HttpFoundation\Response;
/**
* The enforcement half of the session registry — see
* Modules\Core\Auth\Services\UserSessionService's own docblock. Not
* auto-registered anywhere (no routes/kernel wiring exist in this
* package — see Modules\Core\Customer\Services\CustomerAccountService's
* own docblock for why this branch stops at services); a consuming app
* adds this to its `web` middleware group (after `auth`) to actually get
* "logout everywhere" enforcement.
*
* A request with no recorded UserSession at all (see
* UserSessionService::currentSession()'s own docblock) is let through —
* only an EXPLICITLY revoked session is rejected.
*/
class EnsureSessionNotRevoked
{
public function __construct(
private readonly UserSessionService $sessions,
) {}
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return $next($request);
}
$session = $this->sessions->currentSession();
if ($session && $session->isRevoked()) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
abort(401, 'Your session has been revoked. Please log in again.');
}
$session?->update(['last_used_at' => now()]);
return $next($request);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Auth\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One row per login (see Modules\Core\Auth\Services\UserOtpService::
* validate()) — see that table's own migration docblock for why this
* exists independent of the actual session-store driver.
*/
class UserSession extends Model
{
protected $guarded = [];
protected $casts = [
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function user(): BelongsTo
{
$model = config('auth.providers.users.model');
return $this->belongsTo($model);
}
public function isRevoked(): bool
{
return $this->revoked_at !== null;
}
}
+115 -11
View File
@@ -2,18 +2,76 @@
namespace Modules\Core\Auth\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Auth\Mail\UserOtpMail;
/**
* The storefront's passwordless login — a shopper supplies only an email
* (Shopify-style), gets a 6-digit code, and validate() authenticates the
* `web` guard via Auth::login().
*
* That alone is enough to merge/associate any active guest cart into the
* now-known customer — Auth::login() fires Illuminate\Auth\Events\Login,
* which Lunar's own Lunar\Listeners\CartSessionAuthListener (registered
* unconditionally in LunarServiceProvider::boot(), no opt-in needed)
* already listens to, calling CartSession::associate() with
* config('lunar.cart.auth_policy') — 'merge' by default, 'override' if a
* consumer changes that config. Deliberately no cart-association call
* here: doing our own on top would run a SECOND merge attempt with a
* hardcoded policy that ignores whatever the consumer configured.
*
* generateAndSend()'s find-or-create already triggers the full
* Customer/User pairing cascade for a genuinely new email — see
* Modules\Core\Auth\Events\UserCreated's own docblock and
* Modules\Core\Customer\Listeners\CreateCustomerForUser.
*
* Two independent throttles, both configured under core.auth.otp — see
* config/core.php's own comment for why they're separate: max_attempts
* caps wrong guesses against ONE code; generation_limit caps how often a
* NEW code can be requested for the same email at all (closes both the
* "regenerate to reset my guess count" loophole and mail-bombing one
* inbox).
*
* validate() also records a UserSessionService entry for the new login —
* see that class's own docblock for the "logout everywhere" registry
* this feeds (Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked
* is the enforcement half; a consuming app must add it to its own
* middleware stack). $request is optional purely so this service stays
* callable from a context with no HTTP request at all (a console
* command, a test) — user-agent/ip are simply not recorded when omitted.
*/
class UserOtpService
{
private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6;
public function __construct(
private readonly UserSessionService $sessions,
) {}
/**
* @throws OtpThrottledException if this email has requested too many
* codes within core.auth.otp.generation_decay_minutes
*/
public function generateAndSend(string $email): bool
{
$limiterKey = $this->generationLimiterKey($email);
$maxGenerations = (int) config('core.auth.otp.generation_limit', 3);
if (RateLimiter::tooManyAttempts($limiterKey, $maxGenerations)) {
throw new OtpThrottledException(RateLimiter::availableIn($limiterKey));
}
RateLimiter::hit($limiterKey, (int) config('core.auth.otp.generation_decay_minutes', 10) * 60);
$model = config('auth.providers.users.model');
$user = $model::firstOrCreate(['email' => $email]);
@@ -21,6 +79,7 @@ class UserOtpService
$user->otp_code = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->otp_attempts = 0;
$user->save();
Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code));
@@ -28,25 +87,70 @@ class UserOtpService
return true;
}
public function validate(string $email, string $code)
/**
* A wrong code counts against core.auth.otp.max_attempts and, once
* reached, invalidates the code entirely — the shopper must request
* a fresh one via generateAndSend() (itself throttled independently
* — see this class's own docblock) rather than being able to keep
* guessing against a still-live code for the rest of its 10-minute
* expiry window.
*/
public function validate(string $email, string $code, ?Request $request = null): ?Authenticatable
{
$model = config('auth.providers.users.model');
$user = $model::where('email', $email)->first();
if (! $user) {
// lockForUpdate() + a transaction make the read-check-increment-save
// below atomic across concurrent requests for the same user — without
// it, two guesses fired in parallel can each read the same
// pre-increment otp_attempts value and both save past
// max_attempts, letting an attacker exceed the lockout by
// parallelizing requests instead of sending them serially.
$result = DB::transaction(function () use ($model, $email, $code) {
$user = $model::where('email', $email)->lockForUpdate()->first();
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
return null;
}
if (! hash_equals((string) $user->otp_code, $code)) {
$user->otp_attempts++;
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
$user->otp_code = null;
$user->otp_expires_at = null;
$user->otp_attempts = 0;
}
$user->save();
return null;
}
$user->otp_code = null;
$user->otp_expires_at = null;
$user->otp_attempts = 0;
$user->save();
return $user;
});
if (! $result) {
return null;
}
if (! $user->otp_expires_at || $user->otp_code != $code || now()->isAfter($user->otp_expires_at)) {
return null;
}
RateLimiter::clear($this->generationLimiterKey($email));
$user->otp_code = null;
$user->otp_expires_at = null;
$user->save();
Auth::login($result);
Event::dispatch(new UserAuthenticated($user));
$this->sessions->record($result, $request);
return $user;
Event::dispatch(new UserAuthenticated($result));
return $result;
}
private function generationLimiterKey(string $email): string
{
return 'otp-generate:'.strtolower($email);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace Modules\Core\Auth\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Modules\Core\Auth\Models\UserSession;
/**
* The record/revoke half of the session registry — see
* database/migrations/2026_09_15_000001_create_user_sessions_table.php's
* own docblock for why this exists (SESSION_DRIVER=redis in this app has
* no "sessions" table to purge by user_id). The enforcement half is
* Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked, which reads
* the token this class stamps into the session payload.
*/
class UserSessionService
{
private const SESSION_TOKEN_KEY = 'user_session_token';
/**
* Called once, right after Auth::login() succeeds (see
* UserOtpService::validate()) — generates a fresh token, records it,
* and stamps it into the CURRENT session payload so
* EnsureSessionNotRevoked can look it up on later requests.
*/
public function record(Authenticatable $user, ?Request $request = null): UserSession
{
$token = Str::random(64);
$session = UserSession::create([
'user_id' => $user->getAuthIdentifier(),
'token' => $token,
'user_agent' => $request?->userAgent(),
'ip_address' => $request?->ip(),
'last_used_at' => now(),
]);
session([self::SESSION_TOKEN_KEY => $token]);
return $session;
}
/**
* Revokes every OTHER active session for $user — the current one
* (matched by the token in the CURRENT session payload) is left
* alone, matching Laravel's own logoutOtherDevices() semantics
* (there just isn't a password to re-verify against here — this is a
* passwordless account, so revocation is simply "every row that
* isn't the one making this request").
*
* Known, deliberately accepted gap: this requires only a currently
* valid session, not a freshly-completed login — so anyone holding
* an already-authenticated session (e.g. someone who sits down at an
* account left logged in on a shared/public PC) can use this to
* evict the real owner's OTHER sessions just as easily as the real
* owner could use it to evict an intruder's. A stricter version would
* require a fresh OTP re-verification (e.g. within the last few
* minutes) before allowing this call. Left as-is for now — revisit if
* this turns out to matter in practice, rather than building
* abuse-resistance against a threat model nobody's confirmed is real
* for this storefront.
*/
public function revokeOtherSessions(Authenticatable $user): int
{
$currentToken = session(self::SESSION_TOKEN_KEY);
return UserSession::query()
->where('user_id', $user->getAuthIdentifier())
->whereNull('revoked_at')
->when($currentToken, fn ($query) => $query->where('token', '!=', $currentToken))
->update(['revoked_at' => now()]);
}
/**
* Revokes EVERY session for $user, current one included — for a
* "this account may be compromised" response, not a routine logout.
*/
public function revokeAllSessions(Authenticatable $user): int
{
return UserSession::query()
->where('user_id', $user->getAuthIdentifier())
->whereNull('revoked_at')
->update(['revoked_at' => now()]);
}
/**
* @return UserSession|null null if the CURRENT session has no
* recorded token at all (e.g. a session predating this feature, or
* one Auth::login() established outside UserOtpService) — treated
* as valid by EnsureSessionNotRevoked rather than rejected, since
* there's nothing to have been revoked.
*/
public function currentSession(): ?UserSession
{
$token = session(self::SESSION_TOKEN_KEY);
if (! $token) {
return null;
}
return UserSession::where('token', $token)->first();
}
}
@@ -0,0 +1,89 @@
<?php
namespace Modules\Core\Cart\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Cart;
use Modules\Core\Cart\Services\CartLifecycleService;
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.
*
* Both queries require meta->recovery_consent = true — CartAbandoned/
* CheckoutAbandoned exist specifically to drive future recovery-email
* sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where
* that consent is actually recorded), and a non-consenting cart's
* abandonment must never be dispatched at all, not merely filtered later
* at send time — see docs referenced above for the legal reasoning. This
* consent filter stays here rather than on Modules\Core\Cart\Services\
* CartLifecycleService, whose two "abandoned" queries this command builds
* on — dispatch eligibility is this command's own concern, not part of
* what "abandoned" means to a staff member browsing the admin panel. (The
* non-empty-lines requirement, by contrast, IS part of what "abandoned"
* means either way, so it lives on CartLifecycleService::abandonedCarts()
* itself, not here.)
*/
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(CartLifecycleService $lifecycle): void
{
$cartsAbandoned = 0;
$checkoutsAbandoned = 0;
$lifecycle->abandonedCarts(Cart::query())
->where('meta->recovery_consent', true)
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
foreach ($carts as $cart) {
Event::dispatch(new CartAbandoned($cart));
$cartsAbandoned++;
}
});
$lifecycle->abandonedCheckouts(Cart::query())
->where('meta->recovery_consent', true)
->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).");
}
}
+18
View File
@@ -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,
) {}
}
+13
View File
@@ -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,
) {}
}
+13
View File
@@ -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,
) {}
}
+14
View File
@@ -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,
) {}
}
+18
View File
@@ -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,
) {}
}
+18
View File
@@ -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,
) {}
}
+20
View File
@@ -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,
) {}
}
+18
View File
@@ -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 $couponCode)
{
parent::__construct("The coupon code \"{$couponCode}\" is not valid.");
}
}
@@ -0,0 +1,125 @@
<?php
namespace Modules\Core\Cart\Filament\Resources;
use Filament\Tables\Columns\TextColumn;
use Filament\Actions\ViewAction;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ListCarts;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Carbon;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Modules\Core\Cart\Services\CartLifecycleService;
/**
* Read-only — a cart is managed entirely through the storefront (add/update/remove
* line, checkout), never hand-edited by staff. Lists every cart, guest carts
* included — see docs/cart.md ("Scope: every cart, identified or not"). An
* anonymous cart's Customer/User columns just render "—" (see table() below)
* rather than the row being hidden outright: most real traffic never reaches
* an identified user/customer, and "how many carts are ongoing/abandoned
* right now" is a real reporting need regardless of identity — excluding
* anonymous carts would silently undercount it. 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 | \BackedEnum | null $navigationIcon = 'heroicon-o-shopping-cart';
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
protected static ?string $modelLabel = 'Cart';
protected static ?string $pluralModelLabel = 'Carts';
/**
* 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();
}
public static function lifecycle(): CartLifecycleService
{
return app(CartLifecycleService::class);
}
/**
* `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 static::lifecycle()->abandonedCutoff();
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')
->label('Cart')
->sortable(),
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),
TextColumn::make('user.email')
->label('User')
->placeholder('—')
->searchable(),
TextColumn::make('lines_count')
->label('Lines')
->counts('lines')
->sortable(),
TextColumn::make('lines_sum_quantity')
->label('Items')
->sum('lines', 'quantity')
->sortable(),
TextColumn::make('currency.code')
->label('Currency'),
TextColumn::make('updated_at')
->label('Last activity')
->dateTime()
->sortable(),
])
->recordActions([
ViewAction::make(),
])
->defaultSort('updated_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => ListCarts::route('/'),
'view' => ViewCart::route('/{record}'),
];
}
public static function canCreate(): bool
{
return false;
}
}
@@ -0,0 +1,51 @@
<?php
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Cart\Services\CartLifecycleService;
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.
*
* The four query shapes below live on Modules\Core\Cart\Services\
* CartLifecycleService, shared with Modules\Core\Cart\Commands\
* DetectAbandonedCarts — see that service's docblock for why duplicating
* them independently in both places was worth centralizing.
*/
public function getTabs(): array
{
$lifecycle = app(CartLifecycleService::class);
return [
'abandoned_cart' => Tab::make('Abandoned Cart')
->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCarts($query)),
'abandoned_checkout' => Tab::make('Abandoned Checkout')
->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCheckouts($query)),
'ongoing' => Tab::make('Ongoing')
->modifyQueryUsing(fn (Builder $query) => $lifecycle->ongoing($query)),
'completed' => Tab::make('Completed')
->modifyQueryUsing(fn (Builder $query) => $lifecycle->completed($query)),
];
}
}
@@ -0,0 +1,207 @@
<?php
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Section;
use Filament\Actions\Action;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Colors\Color;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\Blade;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
use Lunar\Models\ProductVariant;
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.
*
* Eager-loads what the Lines section (below) reads off each line's
* purchasable — name, thumbnail, options — the same relations Lunar's
* own OrderItemsTable loads for an order's line items (`with(['purchasable'])`,
* see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so
* rendering the product grid doesn't N+1 per line.
*/
protected function resolveRecord(int|string $key): Cart
{
/** @var Cart $cart */
$cart = parent::resolveRecord($key);
$cart->load('lines.purchasable', 'shippingAddress.country');
EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant))
->loadMissing(['product.thumbnail', 'images', 'values']);
return $cart->calculate();
}
public function infolist(Schema $schema): Schema
{
return $schema
->components([
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([
ImageEntry::make('image')
->hiddenLabel()
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? $record->purchasable->getThumbnail()?->getUrl('small')
: null)
->defaultImageUrl(fn () => 'data:image/svg+xml;base64,'.base64_encode(
Blade::render('<x-filament::icon icon="heroicon-o-photo" style="color:rgb('.Color::Gray[400].');"/>')
))
->imageSize(48),
TextEntry::make('description')
->label('Product')
// ProductVariant::getDescription()/getOption() are typed
// string but internally read translateAttribute()/
// translate(), which return null for a product/option
// with no attribute data set for the active locale —
// reading the underlying relations directly here avoids
// that TypeError rather than calling through them.
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? ($record->purchasable->product?->translateAttribute('name') ?? '—')
: '—')
->url(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? EditProduct::getUrl(['record' => $record->purchasable->product_id])
: null)
->weight('bold'),
TextEntry::make('options')
->label('Options')
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
? ($record->purchasable->values->map(fn ($value) => $value->translate('name'))->filter()->join(', ') ?: null)
: null)
->placeholder('—')
->badge(),
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('Shipping')
->columns(3)
->schema([
TextEntry::make('shippingAddress.shipping_option')
->label('Shipping method')
// The raw identifier (e.g. "acs") is all a
// CartAddress row stores — the human-readable
// name only exists on the resolved
// Lunar\DataTypes\ShippingOption, which is what
// shippingBreakdown's items are keyed/named
// from below, so fall back to that name rather
// than showing the bare identifier.
->formatStateUsing(fn (Cart $record, ?string $state) => $state
? ($record->shippingBreakdown?->items->get($state)?->name ?? $state)
: null)
->placeholder('Not selected'),
TextEntry::make('shippingAddress.country.name')
->label('Shipping to')
->placeholder('—'),
TextEntry::make('shippingTotal')
->label('Shipping total')
->formatStateUsing(fn (Cart $record) => $record->shippingTotal?->formatted() ?? '—')
->weight('bold'),
RepeatableEntry::make('shippingBreakdownItems')
->label('Breakdown')
->columnSpanFull()
// shippingBreakdown->items is a plain (non-Eloquent)
// Collection of Lunar\Base\ValueObjects\Cart\
// ShippingBreakdownItem — e.g. the carrier rate and,
// separately, Modules\Core\Payment\Pipelines\Cart\
// ApplyPaymentMethodFee's own line item when the
// selected payment method carries a fee (see
// CHANGELOG 0.16.3) — both show up here individually
// rather than only as the summed shippingTotal above.
->state(fn (Cart $record) => $record->shippingBreakdown?->items->values() ?? [])
->schema([
TextEntry::make('name')
->hiddenLabel(),
TextEntry::make('price')
->hiddenLabel()
->formatStateUsing(fn ($state) => $state?->formatted() ?? '—')
->alignEnd(),
])
->columns(2)
->visible(fn (Cart $record) => (bool) $record->shippingBreakdown?->items->isNotEmpty()),
])
->visible(fn (Cart $record) => $record->shippingAddress !== null),
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,99 @@
<?php
namespace Modules\Core\Cart\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Lunar\Models\Cart;
/**
* The single source of truth for the four cart lifecycle states documented in
* docs/cart.md ("Four states, not two — and not Cart::completed_at"). Both
* Modules\Core\Cart\Filament\Resources\CartResource/ListCarts (staff-facing
* browsing/tabs) and Modules\Core\Cart\Commands\DetectAbandonedCarts
* (abandonment-event dispatch) build on these same four query shapes — before
* this existed, each reimplemented them independently, which is exactly the
* kind of drift that lets the admin panel and the recovery-email pipeline
* quietly disagree about what "abandoned" means.
*
* `Cart::completed_at` is declared/cast on the model but never actually
* written anywhere in Lunar core — not a real signal, not used here.
* `Cart::scopeActive()` (Lunar's own "not yet converted to an order" scope)
* mixes two distinct states together (no order at all vs. a draft order that
* was never placed) — see docs/cart.md for why they're kept apart as
* different purchase-intent/reachability signals rather than folded into one
* "not converted" bucket.
*
* Query shape only: consent (`meta->recovery_consent`) and non-empty-lines
* filtering stay in DetectAbandonedCarts, not here — those are specific to
* whether a recovery event should fire, not to what "abandoned" means. Staff
* browsing the admin panel should see every abandoned cart, consenting or
* not.
*
* `unrecoverableCutoff()` is a second, older threshold
* (`core.cart.unrecoverable_after`, default 90 days) applied as a lower
* bound on both abandoned*() methods below: a cart past it is too old to be
* a realistic recovery target (pricing/stock/tax have likely moved on), so
* it drops out of "Abandoned Cart"/"Abandoned Checkout" entirely rather than
* staying flagged as an actionable abandonment forever. It does not appear
* in `ongoing()`/`completed()` either — this is about the abandoned-cart
* pipeline specifically, not a retention/deletion policy (no rows are
* touched here).
*/
class CartLifecycleService
{
public function abandonedCutoff(): Carbon
{
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
}
public function unrecoverableCutoff(): Carbon
{
return now()->sub(config('core.cart.unrecoverable_after', '90 days'));
}
/**
* Not yet converted to an order (scopeActive()), with recent activity —
* someone plausibly shopping right now, not (yet) left behind.
*/
public function ongoing(Builder $query): Builder
{
return $query->active()->where('updated_at', '>', $this->abandonedCutoff());
}
/**
* No order started at all, stale, not yet past the unrecoverable cap, and
* actually has something in it — the weaker of the two abandoned states
* (see docs/cart.md's "Abandoned Cart vs Abandoned Checkout"). An empty
* cart (created but nothing ever added — e.g. a bot, or a session that
* never shopped) was never really "abandoned"; there's nothing to
* recover, so it's excluded rather than counted as a false positive.
*/
public function abandonedCarts(Builder $query): Builder
{
return $query->whereDoesntHave('orders')
->whereHas('lines')
->where('updated_at', '<=', $this->abandonedCutoff())
->where('updated_at', '>', $this->unrecoverableCutoff());
}
/**
* A draft order exists (checkout was started) but was never placed,
* stale, and not yet past the unrecoverable cap — the stronger of the
* two abandoned states.
*/
public function abandonedCheckouts(Builder $query): Builder
{
return $query->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', $this->abandonedCutoff())
->where('updated_at', '>', $this->unrecoverableCutoff());
}
/**
* Has an order that was actually placed, not just drafted.
*/
public function completed(Builder $query): Builder
{
return $query->whereHas('orders', fn (Builder $query) => $query->whereNotNull('placed_at'));
}
}
+250
View File
@@ -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\Schemas\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,39 @@
<?php
namespace Modules\Core\Catalog\Contracts;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
/**
* One strategy for producing recommended products for a given product —
* e.g. same category, same tag, best sellers, random. Modules\Core\Catalog\
* Services\RecommendationService runs rules registered in
* config('catalog.recommendation_rules') in order, topping up from each
* successive rule until $limit distinct products are collected or every
* rule is exhausted (e.g. 3 from SameCategoryRule + 1 from RandomRule) —
* nothing here decides that accumulation itself; a store composes its own
* chain by ordering rules in config (e.g. [SameCategoryRule::class,
* RandomRule::class]).
*
* Returns raw Product models, not ProductService::list()'s locale-resolved
* array output — this runs at index time (ProductIndexer::toSearchableArray()),
* where "the current locale" isn't a meaningful concept the way it is for a
* storefront request. ProductIndexer resolves translated fields itself via
* translateAttribute(), same as it already does for the embedded `collections`
* field — same known index-time-locale tradeoff, not a new one.
*/
interface RecommendationRule
{
/**
* $exclude carries $product's own id plus every id already picked by an
* earlier rule this call — RecommendationService never shows the same
* product twice even when two rules would both suggest it, and a rule
* shouldn't spend its $limit budget re-returning something already
* collected.
*
* @param array<int> $exclude
* @return Collection<int, Product> at most $limit products
*/
public function recommend(Product $product, int $limit, array $exclude): Collection;
}
+24
View File
@@ -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,
) {}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Catalog\DTOs;
/**
* A price range slider's bounds and whether it's currently narrowed —
* built by ProductService::priceSliderBounds(), which owns the floor/ceil
* rounding and "is this actually a meaningful filter" comparison, so a
* controller (CategoryController, SearchController, ...) doesn't have to
* reimplement that rule itself.
*/
class PriceSliderBounds
{
public function __construct(
public readonly ?int $floor,
public readonly ?int $ceil,
public readonly bool $filtered,
) {}
}
+30
View File
@@ -0,0 +1,30 @@
<?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.
* @param $tag exactly one tag — no multi-select yet.
*/
public function __construct(
public readonly ?int $collectionId = null,
public readonly ?string $brand = null,
public readonly ?string $tag = null,
public readonly ?float $minPrice = null,
public readonly ?float $maxPrice = null,
public readonly bool $inStockOnly = false,
) {}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Catalog\DTOs;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Everything a listing page needs from one ProductService::list() call —
* the product page itself, the price slider's bounds, and the set of tags
* actually present on matching products (for a tag filter sidebar) — so a
* controller makes one service call instead of orchestrating list(),
* priceSliderBounds(), and facets('tags', ...) separately. list() still
* issues multiple Meilisearch requests internally (the product search, the
* price facet stats, the tag facet distribution — see priceSliderBounds()'s
* own docblock for why the price ones can't be merged into one without
* changing the slider's UX), but that's this DTO's job to hide, not the
* controller's to know about.
*/
class ProductListingResult
{
/**
* @param array<int, string> $availableTags every distinct tag value
* present on at least one product matching the listing's OTHER
* filters (collection/price/stock — never the tag filter itself, so
* selecting a tag doesn't collapse the list down to just that tag).
* Sorted alphabetically. Empty if no product in scope has any tag.
*/
public function __construct(
public readonly LengthAwarePaginator $products,
public readonly PriceSliderBounds $priceBounds,
public readonly array $availableTags = [],
) {}
}
+24
View File
@@ -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',
};
}
}
+26
View File
@@ -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',
};
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Catalog\Events;
/**
* Dispatched whenever a Product is deleted (see CatalogServiceProvider,
* which wires this to the model's own deleted() hook — fires for both a
* soft delete and a force delete, same as Laravel Scout's own
* ModelObserver::deleted() that triggers unsearchable() for the product
* itself). Same purpose as Modules\Core\Catalog\Events\ProductSaved: lets
* Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct find
* and re-index every OTHER product that currently embeds this one in its
* `recommendations` field, so a deleted product doesn't linger as a dead
* reference elsewhere. Carries only the id, not the Product model — by the
* time this fires the model may already be gone (force delete), and the
* reverse lookup only ever needs the id to filter on.
*/
class ProductDeleted
{
public function __construct(
public readonly int $productId,
) {}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Catalog\Events;
use Lunar\Models\Product;
/**
* Dispatched whenever a Product is saved (see CatalogServiceProvider,
* which wires this to the model's own saved() hook) — exists specifically
* so Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct can
* find and re-index every OTHER product that currently embeds this one in
* its own `recommendations` field (see ProductIndexer). Those products
* have no direct database relationship to this one — a recommendation is
* computed and stored only inside Meilisearch (Modules\Core\Catalog\
* Services\RecommendationService) — so nothing about their own save
* lifecycle would otherwise pick up this product's changed name/price/
* image.
*/
class ProductSaved
{
public function __construct(
public readonly Product $product,
) {}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Schemas\Schema;
use Filament\Forms\Components\Select;
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(Schema $schema): Schema
{
$options = collect(ProductOptionTypeManager::get()->all())
->keys()
->mapWithKeys(fn (string $key) => [$key => Str::headline($key)])
->all();
if ($options === []) {
return $schema;
}
return $schema->components([
...$schema->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\Schemas\Schema;
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(Schema $schema): Schema
{
/** @var ProductOption $option */
$option = $this->caller->getOwnerRecord();
$type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null);
if ($type === null) {
return $schema;
}
return $schema->components([
...$schema->getComponents(),
...$type->getMetaForm(),
]);
}
}
@@ -0,0 +1,64 @@
<?php
namespace Modules\Core\Catalog\Listeners;
use Lunar\Models\Product;
use Modules\Core\Catalog\Events\ProductDeleted;
use Modules\Core\Catalog\Events\ProductSaved;
/**
* Keeps every product's embedded `recommendations` field (see
* ProductIndexer) in sync when a product they recommend changes or is
* removed. Unlike Modules\Core\Catalog\Observers\ProductOptionReindexObserver's
* equivalent ("who references this option value"), there is no Postgres
* table to query here — a recommendation only exists inside Meilisearch,
* computed by RecommendationService at index time — so the reverse lookup
* is a Meilisearch filter query against `recommendations.id`, not a
* database join.
*
* Handles both ProductSaved (name/price/image changed — referencing
* products' embedded copy is stale) and ProductDeleted (the recommended
* product no longer exists at all — referencing products need to drop it
* and, since RecommendationService tops up to its limit, naturally pick up
* a replacement on reindex). Same reverse lookup either way, just a
* different source for the id being searched for.
*
* Re-indexing via ->searchable() dispatches Scout's own (queued, if
* SCOUT_QUEUE is configured) reindex job per matched product — this
* listener itself does no synchronous Meilisearch writing.
*/
class ReindexProductsRecommendingProduct
{
public function handleSaved(ProductSaved $event): void
{
$this->reindexReferencingProducts($event->product->id);
}
public function handleDeleted(ProductDeleted $event): void
{
$this->reindexReferencingProducts($event->productId);
}
private function reindexReferencingProducts(int $productId): void
{
$hits = Product::search('')
->options([
'filter' => "recommendations.id = \"{$productId}\"",
'attributesToRetrieve' => ['id'],
// Meilisearch's own hitsPerPage default (20) would silently
// drop referencing products past that count — this is a
// reverse lookup, not a paginated storefront result, so it
// needs every match, up to Meilisearch's hard limit.
'hitsPerPage' => 1000,
])
->raw()['hits'] ?? [];
$ids = collect($hits)->pluck('id')->unique()->values();
if ($ids->isEmpty()) {
return;
}
Product::whereIn('id', $ids)->get()->each->searchable();
}
}
@@ -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(),
];
}
}
-20
View File
@@ -1,20 +0,0 @@
<?php
namespace Modules\Core\Catalog;
/**
* 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\Search\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
{
public function __construct(
public readonly ?int $collectionId = null,
public readonly ?string $brand = null,
public readonly ?float $minPrice = null,
public readonly ?float $maxPrice = null,
) {}
}
-126
View File
@@ -1,126 +0,0 @@
<?php
namespace Modules\Core\Catalog;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Lunar\Models\Product;
use Modules\Core\Localization\LocaleMiddleware;
/**
* Storefront product listing/filtering AND single-product lookup, all reading directly
* from the Meilisearch index (Modules\Core\Search\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\Search\ProductSearchService;
* this service is for browsing/filtering without a search term.
*/
class ProductService
{
/**
* @return array{data: array<int, array>, meta: array}
*/
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1): array
{
$paginator = Product::search('')
->options([
'filter' => $this->buildFilter($filters),
])
->paginateRaw(perPage: $perPage, page: $page);
return [
'data' => collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
->all(),
'meta' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
],
];
}
/**
* Look up a single product by its URL slug (any locale - slugs are indexed across
* all languages, see Modules\Core\Search\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 the current-locale `name`/`description` from the indexer's
* per-locale `name_{locale}`/`description_{locale}` fields, falling back to
* the store's default language (Language::default, see
* LocaleMiddleware::defaultLocale()) when the current locale has no
* translation - e.g. a product with no English copy yet still shows its
* Greek name/description on /en/ rather than rendering blank.
*
* 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 = LocaleMiddleware::defaultLocale();
$product['name'] = $product['name_'.$locale] ?? $product['name_'.$fallbackLocale] ?? null;
$product['description'] = $product['description_'.$locale] ?? $product['description_'.$fallbackLocale] ?? null;
return $product;
}
/**
* 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(LengthAwarePaginator $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
private function buildFilter(?ProductFilters $filters): ?string
{
if ($filters === null) {
return null;
}
$clauses = Collection::make([
$filters->collectionId !== null ? "collections = \"{$filters->collectionId}\"" : null,
$filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
])->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Catalog\Recommendations;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* The universal fallback — always returns something as long as the store
* has more than one product, since it has no eligibility condition of its
* own to come up empty on. Meant to be placed last in
* config('catalog.recommendation_rules'), not first: every store using
* the default chain gets a real fallback, but one that only kicks in once
* more specific rules (same category, same tag, ...) have had a chance.
*/
class RandomRule implements RecommendationRule
{
public function recommend(Product $product, int $limit, array $exclude): Collection
{
return Product::query()
->whereKeyNot($exclude)
->inRandomOrder()
->limit($limit)
->get();
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Catalog\Recommendations;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* Recommends other products sharing at least one of $product's directly-
* assigned collections — takes $product's first collection (a product
* usually has one primary category; if it has several, the first is as
* good a choice as any without a "primary collection" concept to prefer).
* Returns nothing if $product has no collection at all, letting the next
* rule in the chain (see RecommendationRule's docblock) take over.
*
* Queries Eloquent directly rather than going through Modules\Core\Catalog\
* Services\ProductService — this runs at index time (see
* RecommendationRule's docblock), where Meilisearch may be mid-reindex for
* this very product and ProductService::list()'s locale-resolution has no
* meaningful "current locale" to resolve against anyway.
*/
class SameCategoryRule implements RecommendationRule
{
public function recommend(Product $product, int $limit, array $exclude): Collection
{
$collection = $product->collections->first();
if ($collection === null) {
return collect();
}
return $collection->products()
->whereKeyNot($exclude)
->inRandomOrder()
->limit($limit)
->get();
}
}
@@ -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;
}
}
+147
View File
@@ -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 ');
}
}
+274
View File
@@ -0,0 +1,274 @@
<?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
* - skus (every variant's sku, deduplicated, filterable) — same "resolve from the
* index alone" reasoning as slugs, for a future SKU-based lookup/filter
* - price (cheapest variant, filterable) and full per-variant pricing
* - variants: sku, gtin, mpn, ean, stock, backorder, unit_quantity, purchasable,
* shippable, tax_ref, dimensions (length/width/height/weight/volume, each
* {value, unit}), option values, prices, media — the variant's own images
* (ProductVariant::images(), separate from the product's gallery below), not
* the product's own media repeated per variant
* - 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).
* Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced reindexes a product
* the moment an order placed against it decrements its stock — see that
* class's own docblock for why only `purchasable === 'in_stock'`
* variants are ever touched. Any other stock edit (a manual admin
* change, a future inventory-sync integration) still only reflects here
* as of the next reindex (see docs/product-listing.md).
*
* - recommendations (recommendations.id filterable): [{id, name, price, image}, ...]
* up to 4 other products to show alongside this one (a "related products"
* section), sourced from Modules\Core\Catalog\Services\RecommendationService's
* configured rule chain (config('catalog.recommendation_rules')). Embedded
* card data, not just ids, same reasoning as `collections`: renders
* directly with zero extra Meilisearch calls. `name` is resolved via
* translateAttribute() at index time (not through ProductService's
* per-request locale resolution, since indexing has no "current locale"
* the way a storefront request does) — same known index-time-locale
* tradeoff `collections` already has. `recommendations.id` is filterable
* specifically so Modules\Core\Catalog\Listeners\
* ReindexProductsRecommendingProduct can find every product currently
* recommending a given one, when that one changes — there's no Postgres
* relation for this, a recommendation only exists inside the index.
*
* 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',
'recommendations.id',
'skus',
'tags',
];
}
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['skus'] = $model->variants->pluck('sku')->filter()->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)
);
$data['recommendations'] = app(RecommendationService::class)
->recommend($model)
->load(['media', 'variants.prices'])
->map(fn (Product $recommendation) => [
'id' => $recommendation->id,
'name' => $recommendation->translateAttribute('name'),
'price' => $this->cheapestPrice($recommendation, $currency),
'image' => $recommendation->media->first() ? $this->mapMedia($recommendation->media->first())['thumb'] : null,
])
->all();
return $data;
}
private function mapVariant(ProductVariant $variant, Currency $currency): array
{
return [
'id' => $variant->id,
'sku' => $variant->sku,
'gtin' => $variant->gtin,
'mpn' => $variant->mpn,
'ean' => $variant->ean,
'stock' => $variant->stock,
'backorder' => $variant->backorder,
'unit_quantity' => $variant->unit_quantity,
'purchasable' => $variant->purchasable,
'shippable' => $variant->shippable,
'tax_ref' => $variant->tax_ref,
'dimensions' => [
'length' => ['value' => $variant->length_value, 'unit' => $variant->length_unit],
'width' => ['value' => $variant->width_value, 'unit' => $variant->width_unit],
'height' => ['value' => $variant->height_value, 'unit' => $variant->height_unit],
'weight' => ['value' => $variant->weight_value, 'unit' => $variant->weight_unit],
'volume' => ['value' => $variant->volume_value, 'unit' => $variant->volume_unit],
],
'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,124 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Pagination\LengthAwarePaginator;
use Lunar\Facades\AttributeManifest;
use Lunar\Models\Language;
use Lunar\Models\Product;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\DTOs\ProductListingResult;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductDocumentLocalizer;
use Modules\Core\Catalog\Support\ProductFilterBuilder;
/**
* 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
{
public function __construct(
private readonly ProductFilterBuilder $filterBuilder,
private readonly ProductDocumentLocalizer $localizer,
private readonly ProductService $products,
) {}
/**
* Returns the exact same Modules\Core\Catalog\DTOs\ProductListingResult
* ProductService::list() does — a search results page and a category
* listing page consume identically shaped data, one call each. The
* paginator itself carries plain, localized indexed-document arrays
* (not hydrated Product models), same as list().
*
* priceBounds/availableTags are delegated to ProductService's own
* priceSliderBounds()/availableTags() rather than reimplemented here —
* both already accept a $query param for exactly this reason (a search
* page's slider/tag sidebar should reflect only the products search
* actually matched, not the whole catalog).
*
* $filters/$sort apply the exact same semantics ProductService::list()
* uses for collection browsing (same ProductFilterBuilder, same
* ProductSort::toMeilisearchSort()) — a shopper narrowing a text search
* by price/brand/stock gets identical filter behavior to narrowing a
* category listing, since both go through the same Meilisearch `filter`
* clause underneath.
*/
public function search(
string $query,
?ProductFilters $filters = null,
?ProductSort $sort = null,
int $perPage = 24,
int $page = 1,
): ProductListingResult {
$options = [
'attributesToSearchOn' => $this->searchableFields(),
'filter' => $this->filterBuilder->build($filters),
];
if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()];
}
$paginator = Product::search($query)
->options($options)
->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
$products = new LengthAwarePaginator(
items: $data,
total: $paginator->total(),
perPage: $paginator->perPage(),
currentPage: $paginator->currentPage(),
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
);
$priceBounds = $this->products->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice, $query);
$availableTags = $this->products->availableTags($filters, $query);
return new ProductListingResult($products, $priceBounds, $availableTags);
}
/**
* Targets every configured store language's fields, not just the current
* request locale plus the store default — a shopper browsing in Greek
* typing an English word (or vice versa) should still match a product
* whose only translation for that text happens to be in a third
* language. There's no per-request "current locale" concept in this
* method any more: which fields exist to search on is a property of the
* store's configured languages, not of who's asking.
*
* Also targets variants.options.value directly — a variant's option
* value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is how ProductIndexer
* already indexes it (see mapVariant()), but it isn't one of Lunar's own
* attributes, so it can't come from AttributeManifest the way name/
* description do; it's a structural field of the document, added here
* directly instead. Not locale-suffixed like the attribute-manifest
* fields — option values are stored as one already-resolved string per
* variant (see ProductIndexer::translatedName()), not per-locale.
*
* @return array<int, string>
*/
private function searchableFields(): array
{
$handles = AttributeManifest::getSearchableAttributes(Product::morphName())
->pluck('handle');
$locales = Language::all()->pluck('code');
$attributeFields = $handles
->crossJoin($locales)
->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}");
return $attributeFields
->push('variants.options.value')
->values()
->all();
}
}
+297
View File
@@ -0,0 +1,297 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Pagination\LengthAwarePaginator;
use Lunar\Models\Product;
use Modules\Core\Catalog\DTOs\PriceSliderBounds;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\DTOs\ProductListingResult;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductDocumentLocalizer;
use Modules\Core\Catalog\Support\ProductFilterBuilder;
/**
* 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 ProductDocumentLocalizer $localizer,
private readonly ProductFilterBuilder $filterBuilder,
) {}
/**
* One call for everything a listing page needs: the product page AND
* the price slider's bounds — a controller used to have to call this
* plus priceSliderBounds() separately and glue the results together
* itself; that orchestration now happens in here instead. Still issues
* two Meilisearch requests under the hood (the product search, and a
* separate price-facet-stats query — see priceSliderBounds()'s
* docblock for why they can't be merged into one without changing the
* slider's own UX), but the caller only ever makes one call.
*
* $filters->minPrice/$filters->maxPrice double as both the applied
* product filter AND the "is the slider actually narrowed" comparison
* in priceSliderBounds() — the same values, used two ways, so nothing
* new needs to be threaded through separately.
*
* The paginator itself is 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): ProductListingResult
{
$options = ['filter' => $this->filterBuilder->build($filters)];
if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()];
}
$paginator = Product::search('')
->options($options)
->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
$products = new LengthAwarePaginator(
items: $data,
total: $paginator->total(),
perPage: $paginator->perPage(),
currentPage: $paginator->currentPage(),
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
);
$priceBounds = $this->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice);
$availableTags = $this->availableTags($filters);
return new ProductListingResult($products, $priceBounds, $availableTags);
}
/**
* Every distinct `tags` value present on a product matching $filters,
* excluding $filters->tag itself — same "scoped but not self-collapsing"
* reasoning as priceRange() excluding `price` — so selecting a tag
* doesn't shrink the sidebar down to just that one tag. Sorted
* alphabetically; Meilisearch's facetDistribution has no defined order
* of its own.
*
* $query defaults to '' (every product, same as list()'s own default
* text query) — same reasoning as priceRange()'s own $query: pass the
* shopper's search text here too so a search page's own tag sidebar
* reflects only the products search actually matched. Public (not
* private, unlike the rest of this listing-only orchestration) so
* ProductSearchService::search() can reuse it directly rather than
* reimplementing the same facet call a second time.
*
* @return array<int, string>
*/
public function availableTags(?ProductFilters $filters, string $query = ''): array
{
$filter = $this->filterBuilder->build($filters, exclude: ['tag']);
$tags = $this->rawFacets('tags', $filter, $query)['facetDistribution']['tags'] ?? [];
return collect($tags)->keys()->sort()->values()->all();
}
/**
* 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`, `tags`, `in_stock`) — a numeric field like
* `price` would return one "facet" per exact price, not a usable range bucket.
* Use `priceRange()` for `price` instead. `facets('tags', $filters)` is how a
* category page gets "which tags actually appear on products in this category" —
* pass a $filters that omits `tag` (see `build()`'s $exclude) so the tag list
* itself doesn't collapse to whichever tag is already selected.
*
* @return array<string, int> facet value => matching product count
*/
public function facets(string $field, ?ProductFilters $filters = null): array
{
return $this->rawFacets($field, $this->filterBuilder->build($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.
*
* $query defaults to '' (every product, same as list()'s own default text
* query) — pass the shopper's search text here too so a search page's own
* price slider spans only the products that search actually matched,
* rather than the whole catalog's price range.
*
* @return array{min: ?float, max: ?float} null/null if no product matches
*/
public function priceRange(?ProductFilters $filters = null, string $query = ''): array
{
$filter = $this->filterBuilder->build($filters, exclude: ['price']);
$stats = $this->rawFacets('price', $filter, $query)['facetStats']['price'] ?? null;
return [
'min' => $stats['min'] ?? null,
'max' => $stats['max'] ?? null,
];
}
/**
* priceRange() rounded to whole euros (floor/ceil, so the slider's ends
* are never tighter than what's actually in range) plus whether
* $selectedMinPrice/$selectedMaxPrice actually narrow it — the same
* "floor/ceil + is this a real filter" rule CategoryController and
* SearchController each used to duplicate inline. $selectedMinPrice/
* $selectedMaxPrice are the currently-applied filter values (e.g.
* CategoryListing::$minPrice), not part of $filters itself, since
* $filters here must already exclude price the way priceRange() expects.
*/
public function priceSliderBounds(
?ProductFilters $filters,
?float $selectedMinPrice,
?float $selectedMaxPrice,
string $query = '',
): PriceSliderBounds {
$priceRange = $this->priceRange($filters, $query);
$floor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null;
$ceil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null;
$filtered = ($selectedMinPrice !== null && $selectedMinPrice > ($floor ?? PHP_INT_MIN))
|| ($selectedMaxPrice !== null && $selectedMaxPrice < ($ceil ?? PHP_INT_MAX));
return new PriceSliderBounds($floor, $ceil, $filtered);
}
private function rawFacets(string $field, ?string $filter, string $query = ''): array
{
return Product::search($query)
->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}\"");
}
/**
* The id/price/image of every variant on a product document (from
* getById()/getBySlug()'s own 'variants' array) — the base price and
* thumbnail a variant picker/swatch list needs, without a caller
* reaching into $product['variants'][n]['prices'][0]/['media'][0]
* itself. Domain shaping (which price/image represents a variant),
* not presentation — a card's href/layout stays a storefront concern
* (e.g. App\Catalog\ProductCard in 3dealer), but "the variant's price
* is its first price row" is a rule about the data, true regardless of
* which app renders it.
*
* @param array $product A document from getById()/getBySlug().
* @return array<int, array{id: int, price: ?float, image: ?string}>
*/
public function variantSummaries(array $product): array
{
return collect($product['variants'] ?? [])
->map(fn (array $variant) => [
'id' => $variant['id'],
'price' => $variant['prices'][0]['price'] ?? null,
'image' => $variant['media'][0]['url'] ?? null,
])
->values()
->all();
}
/**
* $limit random products, still scoped to the index's own default
* visibility (channel/status), unlike Eloquent's Product::inRandomOrder()
* which has no notion of that filtering at all — a random pick can never
* surface a hidden/unpublished product this way. Meilisearch itself has
* no ORDER BY RANDOM() equivalent, so this pulls every matching id only
* (attributesToRetrieve: ['id'], the lightest possible request — no
* name/media/variants/etc. for documents that will mostly be discarded),
* shuffles in PHP, then fetches the full localized documents for just
* the $limit ids actually picked.
*
* @return array<int, array>
*/
public function random(int $limit): array
{
$raw = Product::search('')
->options(['attributesToRetrieve' => ['id']])
->raw();
$ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values();
if ($ids->isEmpty()) {
return [];
}
// Meilisearch's `id IN [...]` doesn't preserve the given order — it's
// an unordered set filter, not a list to iterate — so the shuffle
// above would otherwise be silently undone by whatever order the
// re-fetch comes back in. Re-sort the fetched documents back into
// $ids's already-shuffled order instead of trusting the response's.
$products = collect($this->findAllWhere('id IN ['.$ids->implode(', ').']'))
->keyBy('id');
return $ids->map(fn ($id) => $products->get($id))->filter()->values()->all();
}
private function findOneWhere(string $filter): ?array
{
$products = $this->findAllWhere($filter, limit: 1);
return $products[0] ?? null;
}
/**
* @return array<int, array>
*/
private function findAllWhere(string $filter, int $limit = 1000): array
{
$paginator = Product::search('')
->options(['filter' => $filter])
->paginateRaw(perPage: $limit, page: 1);
return collect($this->localizer->hitsFrom($paginator))
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
->all();
}
}
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* Runs each rule in config('catalog.recommendation_rules'), in order,
* topping up from each successive rule until $limit distinct products are
* collected or every rule is exhausted — e.g. 3 from SameCategoryRule
* (the product's category only has 3 other products) + 1 from RandomRule.
* No rule is special-cased as "the fallback" here; a store gets fallback
* behaviour purely by how it orders its own config (e.g. SameCategoryRule
* before RandomRule). Never returns the same product twice even if two
* rules would both suggest it (see RecommendationRule's $exclude), and
* never returns fewer than $limit unless the store genuinely doesn't have
* that many other products at all.
*/
class RecommendationService
{
/**
* @return Collection<int, Product>
*/
public function recommend(Product $product, int $limit = 4): Collection
{
$recommendations = new Collection();
foreach (config('catalog.recommendation_rules', []) as $ruleClass) {
if ($recommendations->count() >= $limit) {
break;
}
$exclude = [$product->id, ...$recommendations->pluck('id')];
$remaining = $limit - $recommendations->count();
/** @var RecommendationRule $rule */
$rule = app($ruleClass);
$recommendations = $recommendations->merge(
$rule->recommend($product, $remaining, $exclude)
);
}
return $recommendations->take($limit)->values();
}
}
@@ -0,0 +1,86 @@
<?php
namespace Modules\Core\Catalog\Support;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Support\Facades\App;
use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache;
/**
* Shared between Modules\Core\Catalog\Services\ProductService and
* ProductSearchService — both read the same kind of Meilisearch document
* (Modules\Core\Catalog\Services\ProductIndexer's shape) and need the
* exact same per-locale field resolution and raw-response unwrapping.
* Extracted rather than duplicated so a future fix to the localization-
* fallback logic only needs to be made once.
*/
class ProductDocumentLocalizer
{
public function __construct(
private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes,
) {}
/**
* 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.
*/
public 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;
}
/**
* 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.
*/
public function hitsFrom(LengthAwarePaginatorContract $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
/**
* @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();
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Core\Catalog\Support;
use Illuminate\Support\Collection;
use Modules\Core\Catalog\DTOs\ProductFilters;
/**
* Builds a Meilisearch `filter` clause from a ProductFilters DTO — extracted
* out of ProductService (where it originated, scoped to browsing/filtering
* without a search term) so ProductSearchService can apply the exact same
* filter semantics to a text query too, rather than reimplementing it.
*/
class ProductFilterBuilder
{
/**
* @param array<int, 'collectionId'|'brand'|'tag'|'price'|'inStockOnly'> $exclude
* filter fields to leave out even if set on $filters — e.g.
* ProductService::priceRange() excludes 'price' so a price slider's own
* bounds don't shrink to whatever range is already selected on it.
*/
public function build(?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,
'tag' => $filters->tag !== null ? 'tags = "'.addcslashes($filters->tag, '"\\').'"' : 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 ');
}
}
+20
View File
@@ -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,
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Models\Order;
/**
* Dispatched once an Order's placed_at is set — the handoff point between
* Checkout/Payment and Order (see docs/checkout.md's "Three-stage
* lifecycle"). Fired by Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus once it resolves a PaymentCaptured/
* PaymentAuthorized event into an actual order status change, not by
* CheckoutService directly — a draft Order can exist (via
* CheckoutService::initiatePayment()) well before this fires, if payment
* resolves asynchronously (e.g. a redirect-based gateway). Checkout has no
* opinion about what happens after this fires; Order's own listeners are
* what react to it — e.g. a confirmation email, initializing order status
* tracking.
*/
class OrderPlaced
{
public function __construct(
public readonly Order $order,
) {}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Models\Cart;
/**
* Dispatched by CheckoutService::selectPaymentMethod() — carries the plain
* type key (e.g. 'cash-on-delivery', 'stripe'), same convention as
* CartService's events (a plain reference the listener resolves further
* itself, rather than an already-resolved object) since a payment type key
* has nothing further to eagerly resolve the way a ShippingOption does.
*/
class PaymentMethodSelected
{
public function __construct(
public readonly Cart $cart,
public readonly string $type,
) {}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Models\Cart;
/**
* Dispatched by CheckoutService::setRecoveryConsent() every time the
* shopper's promotional/abandoned-cart-recovery opt-in changes — including
* an explicit opt-OUT (a later submit with the checkbox unticked), not
* just an opt-in. $consent is the new value, already written to
* Cart::meta by the time this fires.
*/
class RecoveryConsentSet
{
public function __construct(
public readonly Cart $cart,
public readonly bool $consent,
) {}
}
@@ -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,19 @@
<?php
namespace Modules\Core\Checkout\Exceptions;
use RuntimeException;
/**
* Thrown by CheckoutService::initiatePayment() when $termsAccepted is
* false — an Order is a consumer contract, and its acceptance must be
* refused rather than created-then-flagged. No Lunar exception type
* covers this, same reasoning as UnknownPaymentTypeException.
*/
class TermsNotAcceptedException extends RuntimeException
{
public function __construct()
{
parent::__construct('The order cannot be placed until the terms have been accepted.');
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Checkout\Exceptions;
use RuntimeException;
/**
* Thrown by CheckoutService::selectPaymentMethod()/confirmPayment() when
* $type doesn't resolve to a registered Modules\Core\Checkout\Contracts\
* PaymentDriver (config('payment.drivers')) — same reasoning as
* InvalidShippingOptionException: nothing here has a matching Lunar
* exception type to reuse, so this is the boboko-owned signal instead of a
* silent no-op or an opaque container-resolution error.
*/
class UnknownPaymentTypeException extends RuntimeException
{
public function __construct(public readonly string $type)
{
parent::__construct("The payment type \"{$type}\" is not registered.");
}
}
+314
View File
@@ -0,0 +1,314 @@
<?php
namespace Modules\Core\Checkout\Services;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
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 Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Events\BillingAddressSet;
use Modules\Core\Checkout\Events\PaymentMethodSelected;
use Modules\Core\Checkout\Events\RecoveryConsentSet;
use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Checkout\Events\ShippingOptionSelected;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
/**
* 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,
* ensuring a draft Order exists) and hands off to Payment the instant that
* draft exists — see initiatePayment(). What happens to that Order
* afterward (status transitions, fulfillment) is deliberately out of
* scope here — see docs/payments.md and Checkout\Events\OrderPlaced's
* docblock for where that now lives.
*
* 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,
private readonly PaymentDriverRegistry $paymentDrivers,
private readonly PaymentMethodCache $paymentMethods,
) {}
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;
}
/**
* The shopper's promotional/abandoned-cart-recovery opt-in — a
* cart-level decision, deliberately independent of setShippingAddress()/
* setBillingAddress(): consent is given once, and must NOT be reset or
* re-asked just because the shopper later changes which address is on
* the cart (a different Addressable being set is not a withdrawal of
* consent). Only an explicit call to THIS method — the checkbox itself
* being submitted, checked or unchecked — ever changes it; calling it
* again with false is exactly how a later opt-out is recorded.
*
* Stored on Cart::meta (interim, per the legal design this implements —
* a real column/consent record is the eventual target) as
* recovery_consent (bool), recovery_consent_at (ISO 8601 timestamp,
* null when $consent is false), and recovery_consent_policy_version
* (config('legal.privacy_policy_version') at the moment of consent —
* so a later dispute is answered from what was actually agreed to,
* not whatever the policy says today). Separate from any future
* newsletter opt-in — recovery consent is its own scope, never merged
* with marketing-newsletter consent.
*
* Deliberately does not merge with the meta-writing pattern
* selectPaymentMethod() uses (read-merge-save in two separate
* statements) — this writes both meta keys in one save, since there's
* no dependency between recovery_consent and anything else needing to
* be persisted first.
*/
public function setRecoveryConsent(bool $consent): Cart
{
$cart = $this->cart->currentOrCreate();
$cart->meta = [
...($cart->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$cart->save();
Event::dispatch(new RecoveryConsentSet($cart, $consent));
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;
}
/**
* Every payment method currently offered to the storefront, ordered by
* Modules\Core\Payment\Models\PaymentMethod::position — a row is
* offered only when ALL three checks pass, each meaning something
* different to an admin diagnosing why a method isn't showing up (see
* docs/payments.md):
* 1. `enabled` — an admin turned it on.
* 2. its `driver` still resolves via PaymentDriverRegistry — the
* driver class hasn't been removed (see the `payment:sync-drivers`
* command, which sets `driver_missing_at` when this fails; a row
* with that set is excluded here regardless of `enabled`, so a
* vanished driver can never silently look "available").
* 3. the resolved driver reports Configurable::isConfigured() — its
* own runtime requirements (e.g. an API key) are met.
*
* @return Collection<int, PaymentMethod>
*/
public function getPaymentMethods(): Collection
{
return $this->paymentMethods->all()
->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null)
->filter(fn (PaymentMethod $method) => $this->paymentDrivers->resolve($method->driver)?->isConfigured() ?? false)
->values();
}
/**
* Records which payment type the shopper picked (Cart::meta
* ['payment_method']) — read by Modules\Core\Payment\Pipelines\
* Cart\ApplyPaymentMethodFee to add that method's own `data.fee` (if
* any) before recalculation.
*
* Also snapshots Cart::fingerprint() into meta, *after* saving the
* chosen type — the fingerprint has to reflect the final total
* including any payment-method-specific fee, which only exists once
* payment_method is set and the cart recalculates. Captured here,
* server-side, rather than asked of the storefront: this is the last
* moment before initiatePayment() that the shopper's reviewed total is
* known, and initiatePayment() reads it back internally instead of
* taking a fingerprint parameter — a storefront should never need to
* know Cart::fingerprint() exists.
*
* Does not itself call a payment driver — selecting a method and
* initiating payment against it are deliberately separate steps, same
* as selecting a shipping option happens before placing the order.
*
* @throws UnknownPaymentTypeException if $type isn't currently offered
* — see getPaymentMethods() for what that means
*/
public function selectPaymentMethod(string $type): Cart
{
if (! $this->getPaymentMethods()->contains('type', $type)) {
throw new UnknownPaymentTypeException($type);
}
$cart = $this->cart->currentOrCreate();
$cart->meta = [...($cart->meta?->toArray() ?? []), 'payment_method' => $type];
$cart->save();
// Cart::calculate() no-ops if this cart instance was already
// calculated earlier in the request (Cart::isCalculated()) — which
// it will have been if the shopper switches payment method after
// the checkout page's first render already calculated it. Without
// recalculate() forcing a fresh run, the just-saved payment_method
// (and any fee tied to it, see ApplyPaymentMethodFee) would never
// be reflected — the summary would keep showing whichever method
// was calculated first.
$cart = $cart->recalculate();
$cart->meta = [...($cart->meta?->toArray() ?? []), 'checkout_fingerprint' => $cart->fingerprint()];
$cart->save();
Event::dispatch(new PaymentMethodSelected($cart, $type));
return $cart;
}
/**
* The one storefront-facing "place this order and pay for it" call —
* the point where Checkout hands off to Payment. Ensures a draft
* Order exists (Cart::createOrder() — confirmed idempotent against a
* cart's own pre-existing, not-yet-placed-at draft; see
* vendor/lunarphp/core/src/Actions/Carts/CreateOrder.php), then
* resolves the payment method selected by selectPaymentMethod() and
* calls pay() or authorize() on its driver, per that method's own
* `capture_mode` column.
*
* Returns the driver's own PaymentResult UNCHANGED — this method does
* not wait for or resolve anything past what pay()/authorize() itself
* returns synchronously. A Pending result (an async gateway like
* Stripe requiring 3-D Secure/a redirect) is a normal, expected
* outcome, not an error — the caller (a storefront controller) is
* responsible for whatever the gateway needs next.
*
* The draft order's own $order->total (not the Cart's) is what gets
* passed as $amount — Order::$total is Lunar's own Price-cast
* attribute, already resolving the correct Currency via the order's
* own currency_code, and is the authoritative total once the draft
* row exists.
*
* $context passed to the driver is {cart_id, order_id} — the exact
* keys Modules\Core\Payment\Drivers\StripePaymentDriver::
* rememberIntent() already reads.
*
* Same fingerprint precondition the old placeOrder() had: mandatory,
* not optional, checked before the draft is created.
*
* $termsAccepted is likewise mandatory, not optional data a caller
* might omit — an Order is a consumer contract, and its acceptance
* must be refused (TermsNotAcceptedException, before createOrder() is
* ever called — the order is never created-then-flagged) rather than
* assumed. $policyVersion is recorded alongside it on the created
* Order's own meta (terms_accepted, terms_accepted_at,
* terms_accepted_policy_version) — the order-level equivalent of
* setRecoveryConsent()'s cart-level record, and the durable audit
* trail for a later "what did the shopper actually agree to"
* dispute. Written directly here (not via a separate event/listener)
* since the Order row this attaches to doesn't exist before
* createOrder() runs, and nothing else needs to react to this
* specific write independently of the order simply existing.
*
* @param array<string, mixed> $data passed through untouched to
* the driver's pay()/authorize() — e.g. Stripe's payment_method
* token.
*
* @throws UnknownPaymentTypeException if the cart's selected
* payment_method (from selectPaymentMethod()) is no longer offered
* — re-checked here, not just at selection time, since a method
* could be disabled (or its driver removed) in between
* @throws TermsNotAcceptedException if $termsAccepted is false
* @throws FingerprintMismatchException
* @throws CartException
*/
public function initiatePayment(string $fingerprint, bool $termsAccepted, string $policyVersion, array $data = []): PaymentResult
{
if (! $termsAccepted) {
throw new TermsNotAcceptedException;
}
$cart = $this->cart->currentOrCreate();
$cart->checkFingerprint($fingerprint);
$type = $cart->meta['payment_method'] ?? null;
$method = $type !== null ? $this->getPaymentMethods()->firstWhere('type', $type) : null;
if ($method === null) {
throw new UnknownPaymentTypeException((string) $type);
}
$order = $cart->createOrder();
$order->meta = [
...($order->meta?->toArray() ?? []),
'payment_method' => $type,
'terms_accepted' => true,
'terms_accepted_at' => now()->toIso8601String(),
'terms_accepted_policy_version' => $policyVersion,
];
$order->save();
$driver = $this->paymentDrivers->resolve($method->driver);
$context = ['cart_id' => $cart->id, 'order_id' => $order->id];
return $method->capture_mode === 'authorize'
? $driver->authorize($type, $order->total, $data, $context)
: $driver->pay($type, $order->total, $data, $context);
}
}
@@ -0,0 +1,60 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Lunar\Models\ProductVariant;
/**
* One-off backfill for variants the Shopify import left with a blank SKU —
* not an importer bug, the source CSV rows genuinely had no `Variant SKU`
* value (see Modules\MigrateImport\Shopify\ShopifyExportImporter) — so
* this synthesizes one instead of re-running the import. Format is
* "SKU-P{product_id}-V{variant_id}": deterministic and guaranteed unique
* without a uniqueness check, since product_id/variant_id already are.
* Only variants with a null `sku` are touched.
*/
class BackfillMissingSkusCommand extends Command
{
protected $signature = 'boboko:catalog:backfill-skus {--dry-run : List what would change without writing}';
protected $description = 'Generate a SKU for every product variant that is missing one';
public function handle(): void
{
$dryRun = (bool) $this->option('dry-run');
$query = ProductVariant::query()->whereNull('sku');
$total = $query->count();
if ($total === 0) {
$this->info('No variants are missing a SKU.');
return;
}
$this->info(($dryRun ? '[dry-run] ' : '') . "Backfilling SKUs for {$total} variant(s)...");
$bar = $this->output->createProgressBar($total);
$bar->start();
$query->chunkById(500, function ($variants) use ($dryRun, $bar) {
foreach ($variants as $variant) {
$sku = "SKU-P{$variant->product_id}-V{$variant->id}";
if ($dryRun) {
$this->newLine();
$this->line("Variant {$variant->id}: sku => {$sku}");
} else {
$variant->update(['sku' => $sku]);
}
$bar->advance();
}
});
$bar->finish();
$this->newLine();
$this->info($dryRun ? 'Dry run complete — no changes were written.' : 'Done.');
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Command;
use Lunar\Admin\Models\Staff;
use Lunar\Admin\Console\Commands\MakeLunarAdminCommand;
use function Laravel\Prompts\text;
@@ -31,7 +32,7 @@ class CreateAdminCommand extends MakeLunarAdminCommand
required: true,
validate: fn (string $email): ?string => match (true) {
! filter_var($email, FILTER_VALIDATE_EMAIL) => 'The email address must be valid.',
\Lunar\Admin\Models\Staff::where('email', $email)->exists() => 'A user with this email address already exists',
Staff::where('email', $email)->exists() => 'A user with this email address already exists',
default => null,
},
),
+6 -3
View File
@@ -2,6 +2,9 @@
namespace Modules\Core\Command;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
use FilesystemIterator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Modules\Core\ResultType\Error;
@@ -88,10 +91,10 @@ class ExportCommand extends Command
$zip->addFile($sqlFile, basename($sqlFile));
if (is_dir($filesDir)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$filesDir,
\FilesystemIterator::SKIP_DOTS,
FilesystemIterator::SKIP_DOTS,
),
);
foreach ($iterator as $file) {
+79 -29
View File
@@ -18,7 +18,10 @@ use Lunar\Models\Product;
use Lunar\Models\ProductType;
use Lunar\Models\TaxClass;
use Lunar\Models\TaxZone;
use Spatie\TranslationLoader\LanguageLine;
use Modules\Core\Localization\Models\LanguageLine;
use Modules\Core\Localization\Services\StorefrontLabels;
use Modules\Core\Localization\Services\TranslationService;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Overrides Lunar's own lunar:install to skip the interactive prompts (migrate
@@ -32,7 +35,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...');
@@ -63,6 +66,16 @@ class InstallLunarCommand extends Command
]);
}
if (! Language::where('code', 'el')->exists()) {
$this->components->info('Adding Greek language');
Language::create([
'code' => 'el',
'name' => 'Greek',
'default' => false,
]);
}
if (! Currency::whereDefault(true)->exists()) {
$this->components->info('Adding a default currency (USD)');
@@ -242,10 +255,11 @@ class InstallLunarCommand extends Command
}
});
if (! LanguageLine::where('group', 'storefront')->exists()) {
$this->components->info('Seeding storefront label translations');
$this->seedStorefrontLabels();
}
$this->components->info('Seeding storefront label translations');
$this->seedStorefrontLabels($translations);
$this->components->info('Seeding payment method settings');
$this->seedPaymentMethods();
$this->components->info('Publishing Filament assets');
$this->call('filament:assets');
@@ -253,32 +267,68 @@ class InstallLunarCommand extends Command
$this->components->info('Lunar default data seeded.');
}
private function seedStorefrontLabels(): void
/**
* 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 = [
'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' => 'Πίσω'],
'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' => 'Τιμή'],
'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'],
'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'],
'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'],
];
$labels = StorefrontLabels::all();
$existingKeys = LanguageLine::where('group', 'storefront')
->whereIn('key', array_keys($labels))
->pluck('key');
foreach ($labels as $key => $text) {
LanguageLine::create([
'group' => 'storefront',
'key' => $key,
'text' => $text,
]);
if ($existingKeys->contains($key)) {
continue;
}
$translations->create('storefront', $key, $text);
}
}
/**
* A single, deliberately opinionated starter row on fresh install —
* `PaymentMethod` is now fully admin-creatable/deletable (see
* docs/payments.md), so this is no longer "seed every config-defined
* type," it's "give a fresh store one reasonable payment method to
* start from instead of zero." Every value here is a plain literal in
* THIS command, not sourced from config or PaymentDriverRegistry — a
* driver has no business carrying opinions about what its captured
* order status should be called; that's a merchant decision.
*
* Skip-if-exists on `type`, same idempotent convention as
* seedStorefrontLabels() — an admin who has since edited or deleted
* this row (via the Filament Payment Methods resource) is left alone;
* re-running lunar:install never recreates a deleted starter row.
*
* Seeded disabled — shouldn't go live for shoppers before staff have
* actually reviewed it and turned it on via the Payment Methods
* resource. See CheckoutService::getPaymentMethods().
*/
private function seedPaymentMethods(): void
{
if (PaymentMethod::where('type', 'cash-on-delivery')->exists()) {
return;
}
PaymentMethod::create([
'type' => 'cash-on-delivery',
'name' => [
'en' => 'Cash on Delivery',
'el' => 'Αντικαταβολή',
],
'driver' => 'cash-on-delivery',
'capture_mode' => 'pay',
'position' => 0,
'enabled' => false,
'data' => [],
]);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
/**
* Reconciles every Modules\Core\Payment\Models\PaymentMethod row's `driver`
* column against PaymentDriverRegistry — the registry only knows "which
* driver classes exist THIS deploy," and only at the moment something
* calls resolve(); nothing else notices a driver disappearing (a package
* removed, a custom Registry::register() call deleted) on its own. Meant
* to run unconditionally on every container start/deploy (alongside
* `migrate`), not on a schedule — "did the set of registered drivers
* change" is a deploy-time event, cheap enough to check every single time
* regardless of whether anything actually changed. See docs/payments.md.
*
* Sets/clears `driver_missing_at` — deliberately NOT the `enabled` column,
* so an admin's own manual toggle is never confused with "the driver
* vanished," and a driver that comes back in a later deploy auto-clears
* this with no admin action needed.
*/
class SyncPaymentDriversCommand extends Command
{
protected $signature = 'boboko:payment:sync-drivers';
protected $description = 'Flag PaymentMethod rows whose driver no longer resolves via the registry, and clear the flag for ones that do again';
public function handle(PaymentDriverRegistry $registry): int
{
$missing = 0;
$restored = 0;
PaymentMethod::query()->each(function (PaymentMethod $method) use ($registry, &$missing, &$restored) {
$resolves = $method->driver !== null && $registry->resolve($method->driver) !== null;
if (! $resolves && $method->driver_missing_at === null) {
$method->update(['driver_missing_at' => now()]);
$missing++;
} elseif ($resolves && $method->driver_missing_at !== null) {
$method->update(['driver_missing_at' => null]);
$restored++;
}
});
$this->components->info("Payment driver sync complete: {$missing} newly flagged, {$restored} restored.");
return self::SUCCESS;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Laravel\Scout\EngineManager;
use Laravel\Scout\Engines\MeilisearchEngine;
use Lunar\Models\Product;
/**
* lunarphp/meilisearch's own `lunar:meilisearch:setup` only pushes
* filterableAttributes/sortableAttributes (see MeilisearchSetup::handle())
* — it has no notion of typo tolerance or prefix search, and Meilisearch's
* defaults for both are loose enough to produce bad matches on short Greek
* words. Confirmed via showMatchesPosition that a query for "Κάπτεν" was
* matching "κανένας" purely through prefixSearch's default 'indexingTime'
* behavior (their edit distance is far past anything typo tolerance would
* bridge) — fixed by disabling prefix search below, verified afterward with
* "Super"/"Superheroes"-style prefix probes returning no results for a
* partial word. minWordSizeForTypos is tightened defensively alongside it
* so short words in general get less typo-tolerant fuzzing, even though a
* separate short-word collision case ("Κάπτεν" vs "κάποτε", high letter
* overlap despite real edit distance) persisted after both settings were
* confirmed live and wasn't fully root-caused — treated as a known,
* narrow edge case rather than a blocker. Run this after
* `lunar:meilisearch:setup`, whenever Product's index needs
* (re)provisioning.
*
* Disabling prefix search here is a deliberate tradeoff: it also turns off
* legitimate partial-word matching (typing "car" matching "cart" before
* you finish the word) — useful for a future autocomplete/search-as-you-
* type UI. If that's built later, re-enable prefixSearch deliberately then,
* informed by real UX needs, rather than leaving it on by accident today.
*/
class TuneProductSearchCommand extends Command
{
protected $signature = 'lunar:meilisearch:tune-product-search';
protected $description = 'Tighten typo-tolerance and disable prefix search on the product search index';
public function handle(EngineManager $engineManager): void
{
/** @var MeilisearchEngine $engine */
$engine = $engineManager->createMeilisearchDriver();
$index = $engine->getIndex((new Product)->searchableAs());
$this->components->info('Updating typo tolerance for product search...');
$task = $index->updateTypoTolerance([
'minWordSizeForTypos' => [
'oneTypo' => 8,
'twoTypos' => 12,
],
]);
$engine->waitForTask($task['taskUid']);
$this->components->info('Disabling prefix search for product search...');
$task = $index->updatePrefixSearch('disabled');
$engine->waitForTask($task['taskUid']);
$this->components->info('Product search index tuned.');
}
}
+34 -4
View File
@@ -2,6 +2,8 @@
namespace Modules\Core;
use Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder;
use Lunar\Admin\Filament\Resources\OrderResource\Pages\Components\OrderItemsTable;
use Filament\Contracts\Plugin;
use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -10,25 +12,44 @@ use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Admin\Filament\Resources\CustomerResource\Pages\EditCustomer;
use Lunar\Admin\Filament\Resources\CustomerResource\Pages\ViewCustomer;
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\Customer;
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\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\Order\Filament\Extensions\OrderItemsTableExtension;
use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension;
use Modules\Core\Order\Filament\Extensions\OrderActionsExtension;
use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Privacy\Filament\Extensions\CustomerErasureActionsExtension;
use Modules\Core\Privacy\Filament\Extensions\CustomerErasureRelationsExtension;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource;
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest;
use Modules\Core\Review\Extensions\ProductResourceExtension;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Shipping\Extensions\OrderShipmentsExtension;
use Modules\Core\Shipping\Extensions\OrderViewExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
class CorePlugin implements Plugin
{
@@ -48,12 +69,22 @@ class CorePlugin implements Plugin
LanguageLineResource::class,
DataErasureRequestResource::class,
DataExportRequestResource::class,
CartResource::class,
PaymentMethodResource::class,
ShipmentResource::class,
ManifestResource::class,
])
->plugin(ShippingPlugin::make());
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,
ManageOrder::class => [OrderViewExtension::class, OrderActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class],
OrderItemsTable::class => OrderItemsTableExtension::class,
// headerActions() is resolved per PAGE class, not per resource class —
// unlike extendForm()/extendTable(), which really are resource-keyed
// (called statically from the Resource class itself). Registering this
@@ -110,9 +141,8 @@ class CorePlugin implements Plugin
'password',
'remember_token',
'email_verified_at',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
'app_authentication_secret',
'app_authentication_recovery_codes',
]);
LunarStaff::created(function (LunarStaff $staff) {
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Models\Address;
/**
* Dispatched by Modules\Core\Customer\Services\CustomerAccountService::
* createAddress(). $causer is carried explicitly (unlike e.g.
* Modules\Core\Payment\Events\PaymentMethodCreated, which is always
* staff-caused implicitly) because this write happens on the `web`
* guard, not `staff` — a listener logging this needs to know who to
* attribute it to without guessing a guard.
*/
class CustomerAddressCreated
{
public function __construct(
public readonly Address $address,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
class CustomerAddressDeleted
{
/**
* @param array<string, mixed> $address Snapshot of the deleted
* row — already gone from the database by dispatch time.
*/
public function __construct(
public readonly array $address,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Models\Address;
class CustomerAddressUpdated
{
/**
* @param array<string, mixed> $old Snapshot of the changed
* attributes before the update.
*/
public function __construct(
public readonly Address $address,
public readonly array $old,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Modules\Core\Customer\Models\Customer;
class CustomerProfileUpdated
{
/**
* @param array<string, mixed> $old Snapshot of the changed
* attributes before the update.
*/
public function __construct(
public readonly Customer $customer,
public readonly array $old,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Customer\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Customer\Services\CustomerAccountService when an
* address id doesn't belong to the customer making the request — never
* a plain 404/ModelNotFoundException, so a storefront can't probe for
* another customer's address ids by trying sequential ones and reading
* the response shape.
*/
class AddressNotFoundException extends RuntimeException
{
public function __construct()
{
parent::__construct('Address not found.');
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Customer\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Customer\Services\CustomerAccountService when an
* order id doesn't belong to the customer making the request (or isn't
* placed yet) — never a plain 404/ModelNotFoundException, so a
* storefront can't probe for another customer's order ids.
*/
class OrderNotFoundException extends RuntimeException
{
public function __construct()
{
parent::__construct('Order not found.');
}
}
@@ -0,0 +1,61 @@
<?php
namespace Modules\Core\Customer\Listeners;
use Lunar\Models\Address;
use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted;
use Modules\Core\Customer\Events\CustomerAddressUpdated;
use Modules\Core\Customer\Events\CustomerProfileUpdated;
use Modules\Core\Logging\ActivityLogService;
/**
* Same pattern as Payment\Listeners\LogPaymentMethodActivity — routes
* Modules\Core\Customer\Services\CustomerAccountService's own events
* through the shared Logging\ActivityLogService, giving every
* shopper-initiated address/profile change an audit trail (previously
* none existed at all for account self-service writes). $causer is
* passed through explicitly on every call, since these events are
* `web`-guard-caused, not `staff`-guard — see ActivityLogService's own
* docblock for why that parameter exists.
*/
class LogCustomerAccountActivity
{
public function __construct(
private readonly ActivityLogService $activityLog,
) {}
public function handleAddressCreated(CustomerAddressCreated $event): void
{
$this->activityLog->created($event->address, $event->address->getAttributes(), $event->causer);
}
public function handleAddressUpdated(CustomerAddressUpdated $event): void
{
$this->activityLog->updated(
$event->address,
$event->old,
$event->address->only(array_keys($event->old)),
$event->causer,
);
}
public function handleAddressDeleted(CustomerAddressDeleted $event): void
{
$subject = (new Address)->forceFill($event->address);
$subject->exists = true;
$subject->id = $event->address['id'];
$this->activityLog->deleted($subject, $event->address, $event->causer);
}
public function handleProfileUpdated(CustomerProfileUpdated $event): void
{
$this->activityLog->updated(
$event->customer,
$event->old,
$event->customer->only(array_keys($event->old)),
$event->causer,
);
}
}
@@ -2,12 +2,12 @@
namespace Modules\Core\Customer\RelationManagers;
use Filament\Forms\Components\Group;
use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Schemas\Components\Group;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
@@ -38,9 +38,9 @@ class AddressRelationManager extends BaseAddressRelationManager
),
])
->headerActions([
CreateAction::make()->form($this->addressForm()),
CreateAction::make()->schema($this->addressForm()),
])
->actions([
->recordActions([
EditAction::make('editAddress')
->fillForm(fn (AddressContract $record): array => [
'line_one' => $record->line_one,
@@ -51,7 +51,7 @@ class AddressRelationManager extends BaseAddressRelationManager
'contact_email' => $record->contact_email,
'contact_phone' => $record->contact_phone,
])
->form($this->addressForm()),
->schema($this->addressForm()),
DeleteAction::make('deleteAddress'),
]);
}
@@ -2,6 +2,8 @@
namespace Modules\Core\Customer\RelationManagers;
use Filament\Tables\Columns\TextColumn;
use Filament\Actions\EditAction;
use Filament\Forms\Components\TextInput;
use Filament\Tables;
use Filament\Tables\Table;
@@ -14,16 +16,16 @@ class UserRelationManager extends BaseUserRelationManager
public function getDefaultTable(Table $table): Table
{
return $table->columns([
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->label(__('lunarpanel::user.table.name.label')),
Tables\Columns\TextColumn::make('email')
TextColumn::make('email')
->label(__('lunarpanel::user.table.email.label')),
])->actions([
Tables\Actions\EditAction::make('edit')
])->recordActions([
EditAction::make('edit')
->after(
fn (Model $record) => CustomerUserEdited::dispatch($record)
)
->form([
->schema([
TextInput::make('email')
->label(__('lunarpanel::user.form.email.label'))
->required()
@@ -0,0 +1,255 @@
<?php
namespace Modules\Core\Customer\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Address;
use Lunar\Models\Order;
use LogicException;
use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted;
use Modules\Core\Customer\Events\CustomerAddressUpdated;
use Modules\Core\Customer\Events\CustomerProfileUpdated;
use Modules\Core\Customer\Exceptions\AddressNotFoundException;
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
use Modules\Core\Customer\Models\Customer;
/**
* The storefront-facing "My Account" API — mirrors Modules\Core\Cart\
* Services\CartService's shape, one boboko-owned service a storefront
* calls, so Lunar's own Customer/Order/Address models stay an
* implementation detail. Every method is scoped to the given
* Authenticatable's own Customer::latestCustomer() (see docs/modules.md
* "Customer/User Pairing") — there is no method here that accepts a bare
* order/address id without also requiring the owning user, precisely so
* a controller built on top of this can't accidentally leak one
* customer's data to another by trusting a client-supplied id alone.
*
* $user->latestCustomer() can be null for a User that has no paired
* Customer yet (shouldn't happen via the normal OTP-login cascade — see
* Modules\Core\Auth\Events\UserCreated — but is defended against anyway,
* since nothing stops a User row existing without one, e.g. seeded data)
* — every method returns an empty/null result rather than throwing in
* that case, since "no customer paired yet" isn't a not-found error, it's
* a legitimately empty account.
*
* Address/profile writes go through an explicit column allowlist
* (WRITABLE_ADDRESS_FIELDS/WRITABLE_PROFILE_FIELDS) rather than trusting
* Lunar\Models\Address/Customer's own $guarded = [] — that flag makes
* every column mass-assignable at the model layer, including
* customer_id on addresses, so a caller passing through an unfiltered
* request array (a real risk for a storefront controller built directly
* against this service) could otherwise reassign an address to a
* different customer entirely, or overwrite created_at/id. Arr::only()
* silently drops anything not on the allowlist rather than erroring —
* this is a safety boundary, not form validation (a storefront still
* validates its own request shape before calling this).
*
* Authorization here IS the ownership scoping itself, not a separate
* layer bolted on top — there is deliberately no Laravel Policy/Gate
* class for Order/Address, since a policy is meaningless without a
* controller calling authorize() against it, and this branch is scoped
* to backend services only (no routes/controllers — see the branch's own
* commit history). Every public method below takes Authenticatable $user
* as a required first argument and resolves everything else (Order,
* Address, Customer) strictly through that user's own
* latestCustomer() — there is no method that looks anything up by a bare
* id alone. A future storefront controller cannot "forget" the
* authorization check the way it could with a separate policy class,
* because the check IS how every lookup happens; skipping it isn't an
* option the method signatures allow.
*/
class CustomerAccountService
{
private const WRITABLE_ADDRESS_FIELDS = [
'title', 'first_name', 'last_name', 'company_name',
'line_one', 'line_two', 'line_three', 'city', 'state', 'postcode',
'delivery_instructions', 'contact_email', 'contact_phone',
'country_id', 'shipping_default', 'billing_default',
];
private const WRITABLE_PROFILE_FIELDS = [
'title', 'first_name', 'last_name', 'company_name', 'vat_no',
];
public function customer(Authenticatable $user): ?Customer
{
/** @var Customer|null */
return $user->latestCustomer();
}
/**
* Placed orders only (placed_at IS NOT NULL) — a draft/abandoned
* order with no placed_at is checkout-in-progress state, not
* something that belongs in order history.
*/
public function orders(Authenticatable $user, int $perPage = 15): LengthAwarePaginator
{
$customer = $this->customer($user);
if (! $customer) {
return new LengthAwarePaginator([], 0, $perPage);
}
return $customer->orders()
->whereNotNull('placed_at')
->latest('placed_at')
->paginate($perPage);
}
/**
* @throws OrderNotFoundException if $orderId doesn't belong to this
* customer, or belongs to a draft (never placed) order
*/
public function order(Authenticatable $user, int $orderId): Order
{
$customer = $this->customer($user);
$order = $customer
?->orders()
->whereNotNull('placed_at')
->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments'])
->find($orderId);
if (! $order) {
throw new OrderNotFoundException;
}
return $order;
}
public function addresses(Authenticatable $user): iterable
{
$customer = $this->customer($user);
return $customer?->addresses ?? collect();
}
/**
* @param array<string, mixed> $data Any key not in
* WRITABLE_ADDRESS_FIELDS is silently dropped — see this class's
* own docblock.
*/
public function createAddress(Authenticatable $user, array $data): Address
{
$customer = $this->customerOrFail($user);
$address = $customer->addresses()->create(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS));
$this->enforceSingleDefault($customer, $address);
$address->refresh();
Event::dispatch(new CustomerAddressCreated($address, $user));
return $address;
}
/**
* @throws AddressNotFoundException if $addressId doesn't belong to
* this customer
*/
public function updateAddress(Authenticatable $user, int $addressId, array $data): Address
{
$address = $this->ownedAddress($user, $addressId);
$old = $address->only(array_keys(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS)));
$address->update(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS));
$this->enforceSingleDefault($address->customer, $address);
$address->refresh();
Event::dispatch(new CustomerAddressUpdated($address, $old, $user));
return $address;
}
/**
* @throws AddressNotFoundException if $addressId doesn't belong to
* this customer
*/
public function deleteAddress(Authenticatable $user, int $addressId): void
{
$address = $this->ownedAddress($user, $addressId);
$snapshot = $address->getAttributes();
$address->delete();
Event::dispatch(new CustomerAddressDeleted($snapshot, $user));
}
/**
* Lunar has no built-in action enforcing "at most one shipping
* default / one billing default per customer" — a raw update() could
* otherwise leave two addresses both flagged shipping_default. Runs
* after every create/update, unconditionally (cheap — at most two
* single-row UPDATEs, only fired when the just-written address
* itself is a default), clearing the flag on every OTHER address of
* the same customer.
*/
private function enforceSingleDefault(Customer $customer, Address $address): void
{
if ($address->shipping_default) {
$customer->addresses()->where('id', '!=', $address->id)->update(['shipping_default' => false]);
}
if ($address->billing_default) {
$customer->addresses()->where('id', '!=', $address->id)->update(['billing_default' => false]);
}
}
/**
* @throws AddressNotFoundException if $addressId doesn't belong to
* this customer
*/
private function ownedAddress(Authenticatable $user, int $addressId): Address
{
$customer = $this->customer($user);
$address = $customer?->addresses()->find($addressId);
if (! $address) {
throw new AddressNotFoundException;
}
return $address;
}
/**
* @param array<string, mixed> $data Any key not in
* WRITABLE_PROFILE_FIELDS is silently dropped — see this class's
* own docblock.
*/
public function updateProfile(Authenticatable $user, array $data): Customer
{
$customer = $this->customerOrFail($user);
$old = $customer->only(array_keys(Arr::only($data, self::WRITABLE_PROFILE_FIELDS)));
$customer->update(Arr::only($data, self::WRITABLE_PROFILE_FIELDS));
$customer->refresh();
Event::dispatch(new CustomerProfileUpdated($customer, $old, $user));
return $customer;
}
/**
* @throws LogicException if $user has no paired Customer at all —
* distinct from AddressNotFoundException/OrderNotFoundException
* (which mean "this id isn't yours"), this means the account
* itself is in an invariant-violating state the normal OTP-login
* cascade should never produce.
*/
private function customerOrFail(Authenticatable $user): Customer
{
$customer = $this->customer($user);
if (! $customer) {
throw new LogicException('This user has no paired Customer record.');
}
return $customer;
}
}
@@ -2,8 +2,17 @@
namespace Modules\Core\Localization\Filament\Resources;
use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Fieldset;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages\ListLanguageLines;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages\CreateLanguageLine;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages\EditLanguageLine;
use Filament\Forms\Components\Textarea;
use Illuminate\Support\Collection;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
@@ -15,29 +24,29 @@ class LanguageLineResource extends Resource
{
protected static ?string $model = LanguageLine::class;
protected static ?string $navigationIcon = 'heroicon-o-language';
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-language';
protected static ?string $navigationGroup = 'Settings';
protected static string | \UnitEnum | null $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Translation';
protected static ?string $pluralModelLabel = 'Translations';
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form->schema([
Forms\Components\TextInput::make('group')
return $schema->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')
TextInput::make('key')
->required()
->maxLength(255)
->helperText('Dot-notation key, e.g. "nav.cart".'),
Forms\Components\Fieldset::make('Translations')
Fieldset::make('Translations')
->schema(static::localeInputs()),
]);
}
@@ -46,16 +55,16 @@ class LanguageLineResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('group')
TextColumn::make('group')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('key')
TextColumn::make('key')
->searchable()
->sortable(),
...static::localeColumns(),
])
->filters([
Tables\Filters\SelectFilter::make('group')
SelectFilter::make('group')
->options(fn () => LanguageLine::query()->distinct()->pluck('group', 'group')),
])
->defaultSort('key');
@@ -69,38 +78,38 @@ class LanguageLineResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListLanguageLines::route('/'),
'create' => Pages\CreateLanguageLine::route('/create'),
'edit' => Pages\EditLanguageLine::route('/{record}/edit'),
'index' => ListLanguageLines::route('/'),
'create' => CreateLanguageLine::route('/create'),
'edit' => EditLanguageLine::route('/{record}/edit'),
];
}
/**
* @return array<Forms\Components\Textarea>
* @return array<Textarea>
*/
private static function localeInputs(): array
{
return static::localeCodes()
->map(fn (string $code) => Forms\Components\Textarea::make("text.{$code}")
->map(fn (string $code) => Textarea::make("text.{$code}")
->label(strtoupper($code))
->rows(2))
->all();
}
/**
* @return array<Tables\Columns\TextColumn>
* @return array<TextColumn>
*/
private static function localeColumns(): array
{
return static::localeCodes()
->map(fn (string $code) => Tables\Columns\TextColumn::make("text.{$code}")
->map(fn (string $code) => TextColumn::make("text.{$code}")
->label(strtoupper($code))
->limit(40)
->toggleable())
->all();
}
private static function localeCodes(): \Illuminate\Support\Collection
private static function localeCodes(): Collection
{
return Language::query()->pluck('code');
}
@@ -5,7 +5,7 @@ namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Page
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Localization\TranslationService;
use Modules\Core\Localization\Services\TranslationService;
class CreateLanguageLine extends CreateRecord
{
@@ -2,12 +2,13 @@
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Actions\DeleteAction;
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\TranslationService;
use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
class EditLanguageLine extends EditRecord
@@ -17,7 +18,7 @@ class EditLanguageLine extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->action(function (LanguageLine $record) {
app(TranslationService::class)->delete($record);
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
@@ -13,7 +14,7 @@ class ListLanguageLines extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
CreateAction::make(),
];
}
}
@@ -5,12 +5,14 @@ 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\LocaleMiddleware;
use Modules\Core\Localization\Services\LanguageCache;
class FlushLanguageCache
{
public function __construct(private readonly LanguageCache $languages) {}
public function handle(LanguageCreated|LanguageUpdated|LanguageDeleted $event): void
{
LocaleMiddleware::forgetLanguagesCache();
$this->languages->forget();
}
}
@@ -1,24 +1,24 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache;
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
{
private const CACHE_KEY = 'core.localization.languages';
public function __construct(private readonly LanguageCache $languages) {}
public function handle(Request $request, Closure $next): Response
{
$languages = $this->availableLanguages();
$languages = $this->languages->all();
if ($languages->isEmpty()) {
return $next($request);
@@ -45,41 +45,37 @@ class LocaleMiddleware
return $next($request);
}
public static function forgetLanguagesCache(): void
{
Cache::forget(self::CACHE_KEY);
}
/**
* The store's default language code (e.g. 'el') - the fixed fallback other
* locale-aware code (Modules\Core\Catalog\ProductService) should use, as
* opposed to config('app.locale') which App::setLocale() mutates per
* request and so can't serve as a stable fallback.
*/
public static function defaultLocale(): ?string
{
return (new self)->availableLanguages()->firstWhere('default', true)?->code;
}
/**
* Shares the current/alternate locale (and the alternate's URL) with all
* views, so the header language switcher and layout hreflang tags don't
* have to recompute it.
* 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
{
$altLanguage = $languages->firstWhere('code', '!=', $language->code);
$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('altLocale', $altLanguage?->code);
View::share(
'altLocaleUrl',
$altLanguage && $routeName
? route($routeName, array_merge($route->parameters(), ['locale' => $altLanguage->code]))
: ($altLanguage ? url('/'.$altLanguage->code) : null),
);
View::share('altLocales', $altLocales);
}
private function redirectToLocalizedUrl(Request $request, Collection $languages): Response
@@ -108,12 +104,4 @@ class LocaleMiddleware
return $languages->firstWhere('default', true)?->code
?? $languages->first()->code;
}
private function availableLanguages(): Collection
{
return Cache::rememberForever(
self::CACHE_KEY,
fn () => Language::query()->get(['id', 'code', 'name', 'default']),
);
}
}
+32
View File
@@ -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;
}
}
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Observers;
use Illuminate\Support\Facades\Event;
use Lunar\Models\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,90 @@
<?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' => 'Επικοινωνία'],
'nav.close' => ['en' => 'Close', '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' => 'Αναζήτηση προϊόντων…'],
'search.results_for' => ['en' => 'Search results for ', '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.all_products' => ['en' => 'All Products', 'el' => 'Όλα τα Προϊόντα'],
'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.price_min' => ['en' => 'Min price', 'el' => 'Ελάχιστη τιμή'],
'shop.price_max' => ['en' => 'Max price', 'el' => 'Μέγιστη τιμή'],
'shop.reset' => ['en' => 'Reset', 'el' => 'Επαναφορά'],
'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'],
'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'],
'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'],
];
}
}
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Services;
use Illuminate\Support\Facades\App;
use Spatie\TranslationLoader\LanguageLine;
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Services;
use Illuminate\Support\Facades\Event;
use Modules\Core\Localization\Events\TranslationCreated;
+16 -10
View File
@@ -2,25 +2,31 @@
namespace Modules\Core\Logging;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
/**
* Thin wrapper around Spatie Activity Log that standardises the log channel,
* actor (authenticated staff member), and property shape for all domain events.
* actor, and property shape for all domain events.
*
* All logs are written to the 'lunar' channel. The subject is always an
* Eloquent model, and the actor is resolved from the 'staff' guard at call time.
* Eloquent model. $causer defaults to the 'staff' guard's current user —
* every existing caller of this class is admin-side — but can be passed
* explicitly for a non-staff actor (e.g. a customer editing their own
* address on the `web` guard — see Modules\Core\Customer\Services\
* CustomerAccountService, which passes the acting User rather than
* relying on this default resolving to null for a web-guard session).
*/
class ActivityLogService
{
/**
* Log a creation event. $attributes describes the initial state.
*/
public function created(Model $subject, array $attributes): void
public function created(Model $subject, array $attributes, ?Authenticatable $causer = null): void
{
activity('lunar')
->performedOn($subject)
->causedBy(auth('staff')->user())
->causedBy($causer ?? auth('staff')->user())
->withProperties(['attributes' => $attributes])
->log('created');
}
@@ -28,11 +34,11 @@ class ActivityLogService
/**
* Log an update event. $old holds the previous values, $attributes the new ones.
*/
public function updated(Model $subject, array $old, array $attributes): void
public function updated(Model $subject, array $old, array $attributes, ?Authenticatable $causer = null): void
{
activity('lunar')
->performedOn($subject)
->causedBy(auth('staff')->user())
->causedBy($causer ?? auth('staff')->user())
->withProperties(['old' => $old, 'attributes' => $attributes])
->log('updated');
}
@@ -40,11 +46,11 @@ class ActivityLogService
/**
* Log a failed operation. $attributes provides context (e.g. error message, service).
*/
public function failed(Model $subject, array $attributes): void
public function failed(Model $subject, array $attributes, ?Authenticatable $causer = null): void
{
activity('lunar')
->performedOn($subject)
->causedBy(auth('staff')->user())
->causedBy($causer ?? auth('staff')->user())
->withProperties(['attributes' => $attributes])
->log('failed');
}
@@ -52,11 +58,11 @@ class ActivityLogService
/**
* Log a deletion event. $attributes provides context (e.g. reason, name).
*/
public function deleted(Model $subject, array $attributes): void
public function deleted(Model $subject, array $attributes, ?Authenticatable $causer = null): void
{
activity('lunar')
->performedOn($subject)
->causedBy(auth('staff')->user())
->causedBy($causer ?? auth('staff')->user())
->withProperties(['attributes' => $attributes])
->log('deleted');
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\MigrateImport;
use InvalidArgumentException;
use Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter;
use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter;
@@ -15,7 +16,7 @@ class ImporterFactory
['judgeme', 'export'] => new JudgeMeExportImporter,
// ["woocommerce", "export"] => new WooCommerceExportImporter(),
// ["woocommerce", "api"] => new WooCommerceApiImporter(),
default => throw new \InvalidArgumentException(
default => throw new InvalidArgumentException(
"No importer available for source \"{$spec->source}\" with type \"{$spec->type}\".",
),
};
@@ -2,6 +2,8 @@
namespace Modules\Core\MigrateImport\JudgeMe;
use RuntimeException;
class JudgeMeCsvReader
{
/**
@@ -12,7 +14,7 @@ class JudgeMeCsvReader
$handle = fopen($csvPath, 'r');
if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}");
throw new RuntimeException("Could not open CSV file: {$csvPath}");
}
$headers = fgetcsv($handle);
@@ -2,6 +2,7 @@
namespace Modules\Core\MigrateImport\JudgeMe;
use Throwable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Modules\Core\MigrateImport\Importer;
@@ -75,7 +76,7 @@ class JudgeMeExportImporter implements Importer
foreach ($urls as $url) {
try {
$review->addMediaFromUrl($url)->toMediaCollection(ProductReview::IMAGES_COLLECTION);
} catch (\Throwable $e) {
} catch (Throwable $e) {
Log::warning('JudgeMe import: failed to download review image', [
'review_id' => $review->id,
'url' => $url,
@@ -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;
}
}
@@ -16,10 +16,16 @@ class ProductOptionResolver
// the same option instead of creating a near-duplicate.
$handle = Str::slug($name) ?: 'option';
// 'label' must be set even though nothing here reads it back — a null
// label crashes Lunar's own ProductOptionIndexer::toSearchableArray()
// (foreach (null as ...)) the moment this option gets reindexed, since
// it assumes every ProductOption always has one. Same value as 'name'
// is a reasonable default; Shopify's CSV has no separate "label" concept.
return ProductOption::query()->firstOrCreate(
['handle' => $handle],
[
'name' => [DefaultLocale::code() => $name],
'label' => [DefaultLocale::code() => $name],
'shared' => true,
],
);
@@ -2,6 +2,8 @@
namespace Modules\Core\MigrateImport\Shopify;
use RuntimeException;
class ShopifyCsvReader
{
/**
@@ -12,7 +14,7 @@ class ShopifyCsvReader
$handle = fopen($csvPath, 'r');
if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}");
throw new RuntimeException("Could not open CSV file: {$csvPath}");
}
$headers = fgetcsv($handle);
@@ -2,6 +2,8 @@
namespace Modules\Core\MigrateImport\Shopify;
use Lunar\Models\TaxClass;
use Lunar\Models\ProductOption;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup;
@@ -23,6 +25,7 @@ use Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\TagResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\TaxClassResolver;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ShopifyExportImporter implements Importer
{
@@ -111,7 +114,7 @@ class ShopifyExportImporter implements Importer
$options = $this->attachOptions($product, $row);
foreach ($group->variantRows as $index => $variantRow) {
$this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options);
$this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options, $imagesPath);
}
foreach ($group->imageRows as $index => $imageRow) {
@@ -120,7 +123,7 @@ class ShopifyExportImporter implements Importer
}
/**
* @return array<int, \Lunar\Models\ProductOption>
* @return array<int, ProductOption>
*/
private function attachOptions(Product $product, array $row): array
{
@@ -146,9 +149,10 @@ class ShopifyExportImporter implements Importer
string $handle,
int $index,
array $row,
\Lunar\Models\TaxClass $taxClass,
TaxClass $taxClass,
Currency $currency,
array $options,
string $imagesPath,
): void {
$externalId = "{$handle}#{$index}";
$existing = ImportMapping::resolve(self::SOURCE, 'variant', $externalId);
@@ -180,6 +184,26 @@ class ShopifyExportImporter implements Importer
: null;
$this->priceResolver->resolve($variant, $currency, $price, $comparePrice);
// Shopify's own "Variant Image" column — the one image a variant picker
// actually swaps to when that variant is selected — distinct from the
// product's full gallery (imageRows below). Often the same file as one
// of the product's own image rows, sometimes not yet imported at all
// (e.g. a variant-only image never listed as its own image row) — either
// way resolveOrImportImage() handles both via the same Image Src dedup
// key, so whichever of importVariant()/importImage() runs first for a
// given src does the actual import.
$variantImageSrc = trim((string) ($row['Variant Image'] ?? ''));
if ($variantImageSrc !== '') {
$media = $this->resolveOrImportImage($product, $handle, $variantImageSrc, 1, $imagesPath);
if ($media) {
$variant->images()->syncWithoutDetaching([
$media->id => ['primary' => true, 'position' => 1],
]);
}
}
}
private function importImage(
@@ -189,29 +213,54 @@ class ShopifyExportImporter implements Importer
array $row,
string $imagesPath,
): void {
$externalId = $row['Image Src'] ?: "{$handle}#image-{$index}";
$position = (int) ($row['Image Position'] ?? $index + 1);
if (ImportMapping::resolve(self::SOURCE, 'image', $externalId)) {
return;
$this->resolveOrImportImage($product, $handle, $row['Image Src'], $position, $imagesPath);
}
/**
* Resolves the Media already imported for $imageSrc (recorded under
* source_type 'image', keyed by Image Src — the same URL Shopify repeats
* across a product's own image rows and any variant's "Variant Image"
* column), importing it via AssetResolver if this is the first time this
* src has been seen. Shared by importImage() (product gallery) and
* importVariant() (variant-specific image) so the same physical file is
* never uploaded to Spatie MediaLibrary twice just because Shopify's flat
* CSV format repeats the URL on multiple rows.
*/
private function resolveOrImportImage(
Product $product,
string $handle,
string $imageSrc,
int $position,
string $imagesPath,
): ?Media {
$externalId = $imageSrc ?: "{$handle}#image-{$position}";
$existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId);
if ($existing instanceof Media) {
return $existing;
}
$localFile = $this->findLocalFile($imagesPath, $row['Image Src']);
$localFile = $this->findLocalFile($imagesPath, $imageSrc);
if ($localFile === null) {
Log::warning('Shopify import: image file not found', [
'handle' => $handle,
'image_src' => $row['Image Src'],
'image_src' => $imageSrc,
]);
return;
return null;
}
$media = $this->assetResolver->resolve($product, $localFile, $position);
if ($media) {
ImportMapping::record(self::SOURCE, 'image', $externalId, $product);
ImportMapping::record(self::SOURCE, 'image', $externalId, $media);
}
return $media;
}
private function findLocalFile(string $imagesPath, string $imageSrc): ?string
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Notification;
use Throwable;
use Illuminate\Support\Facades\Event;
class NotificationRegistry
@@ -44,7 +45,7 @@ class NotificationRegistry
$notification->delay($event->delaySeconds);
}
$notification->notifiable()->notify($notification);
} catch (\Throwable $e) {
} catch (Throwable $e) {
report($e);
}
});
+6 -3
View File
@@ -2,6 +2,9 @@
namespace Modules\Core\Option;
use InvalidArgumentException;
use Exception;
use RuntimeException;
use Traversable;
/**
@@ -39,7 +42,7 @@ final class LazyOption extends Option
public function __construct($callback, array $arguments = [])
{
if (!is_callable($callback)) {
throw new \InvalidArgumentException("Invalid callback given");
throw new InvalidArgumentException("Invalid callback given");
}
$this->callback = $callback;
@@ -71,7 +74,7 @@ final class LazyOption extends Option
return $this->option()->getOrCall($callable);
}
public function getOrThrow(\Exception $ex)
public function getOrThrow(Exception $ex)
{
return $this->option()->getOrThrow($ex);
}
@@ -146,7 +149,7 @@ final class LazyOption extends Option
if ($option instanceof Option) {
$this->option = $option;
} else {
throw new \RuntimeException(
throw new RuntimeException(
sprintf("Expected instance of %s", Option::class),
);
}

Some files were not shown because too many files have changed in this diff Show More