From e18b2fa44c1468cfe7735f0c6d64dd7741454b33 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 16 Jul 2026 17:14:23 +0300 Subject: [PATCH 001/110] Feature: Creating Shipment migration and model --- ...26_07_16_000001_create_shipments_table.php | 29 +++++++++++++++++++ src/Shipping/Models/Shipment.php | 24 +++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 database/migrations/2026_07_16_000001_create_shipments_table.php create mode 100644 src/Shipping/Models/Shipment.php diff --git a/database/migrations/2026_07_16_000001_create_shipments_table.php b/database/migrations/2026_07_16_000001_create_shipments_table.php new file mode 100644 index 0000000..ffccb42 --- /dev/null +++ b/database/migrations/2026_07_16_000001_create_shipments_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('order_id')->constrained(config('lunar.database.table_prefix').'orders'); + $table->string('carrier'); + $table->string('tracking_reference')->unique(); + $table->string('parent_reference')->nullable(); + $table->timestamp('label_printed_at')->nullable(); + $table->string('manifest_reference')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipments'); + } +}; diff --git a/src/Shipping/Models/Shipment.php b/src/Shipping/Models/Shipment.php new file mode 100644 index 0000000..368a110 --- /dev/null +++ b/src/Shipping/Models/Shipment.php @@ -0,0 +1,24 @@ + AsArrayObject::class, + 'label_printed_at' => 'datetime', + 'cancelled_at' => 'datetime', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} From 08135c4c8f8debef368327fa7386bd3b792a8956 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 16 Jul 2026 17:14:41 +0300 Subject: [PATCH 002/110] Feature: Creating Shipping Carrier Contracts --- .../Contracts/CarrierFulfillmentInterface.php | 24 +++++++++++++++++++ .../Contracts/SupportsLivePricing.php | 12 ++++++++++ .../Contracts/SupportsManifestBatching.php | 24 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 src/Shipping/Contracts/CarrierFulfillmentInterface.php create mode 100644 src/Shipping/Contracts/SupportsLivePricing.php create mode 100644 src/Shipping/Contracts/SupportsManifestBatching.php diff --git a/src/Shipping/Contracts/CarrierFulfillmentInterface.php b/src/Shipping/Contracts/CarrierFulfillmentInterface.php new file mode 100644 index 0000000..68f1b30 --- /dev/null +++ b/src/Shipping/Contracts/CarrierFulfillmentInterface.php @@ -0,0 +1,24 @@ + Date: Sun, 19 Jul 2026 00:42:13 +0300 Subject: [PATCH 003/110] Feature: Creating Migration For Adding Pricing Mode to Shipping Rates --- ...d_pricing_mode_to_shipping_rates_table.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php diff --git a/database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php b/database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php new file mode 100644 index 0000000..5442c1b --- /dev/null +++ b/database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php @@ -0,0 +1,26 @@ +string('pricing_mode')->default('live')->after('enabled'); + }); + } + + public function down(): void + { + $prefix = config('lunar.database.table_prefix'); + + Schema::table("{$prefix}shipping_rates", function (Blueprint $table) { + $table->dropColumn('pricing_mode'); + }); + } +}; From 3599329b57caab7e8ff6d3aba0412bde778cd26d Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 00:50:15 +0300 Subject: [PATCH 004/110] Feature: Add ManifestResult DTO for carrier manifest batching Completes the carrier-agnostic shipping abstraction (CarrierFulfillmentInterface, SupportsManifestBatching, Shipment model) with the result type SupportsManifestBatching::issueManifest() returns. --- .../DataTransferObjects/ManifestResult.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/Shipping/DataTransferObjects/ManifestResult.php diff --git a/src/Shipping/DataTransferObjects/ManifestResult.php b/src/Shipping/DataTransferObjects/ManifestResult.php new file mode 100644 index 0000000..a855421 --- /dev/null +++ b/src/Shipping/DataTransferObjects/ManifestResult.php @@ -0,0 +1,26 @@ + Date: Sun, 19 Jul 2026 00:50:51 +0300 Subject: [PATCH 005/110] Feature: Add ACS courier integration ACS rate driver (live price quotes via ACS_Price_Calculation, cached postcode-to-station lookups) and fulfillment service (voucher creation, label printing, end-of-day pickup manifest). Adds a per-rate pricing_mode column so admins can choose live API pricing vs. a fixed price on ACS-driven shipping rates, surfaced via a custom Rates page. --- config/shippingCarriers/acs.php | 50 ++++++ src/Shipping/Carriers/Acs/AcsArea.php | 11 ++ src/Shipping/Carriers/Acs/AcsClient.php | 34 ++++ .../Carriers/Acs/AcsFulfillmentService.php | 133 ++++++++++++++ src/Shipping/Carriers/Acs/AcsRateDriver.php | 134 ++++++++++++++ src/Shipping/Carriers/Acs/AcsResponse.php | 43 +++++ src/Shipping/Carriers/Acs/AreaResolver.php | 81 +++++++++ .../Acs/Exceptions/AcsApiException.php | 13 ++ .../Carriers/Acs/Jobs/WarmAcsAreaCacheJob.php | 27 +++ .../Filament/Pages/ManageShippingRates.php | 165 ++++++++++++++++++ 10 files changed, 691 insertions(+) create mode 100644 config/shippingCarriers/acs.php create mode 100644 src/Shipping/Carriers/Acs/AcsArea.php create mode 100644 src/Shipping/Carriers/Acs/AcsClient.php create mode 100644 src/Shipping/Carriers/Acs/AcsFulfillmentService.php create mode 100644 src/Shipping/Carriers/Acs/AcsRateDriver.php create mode 100644 src/Shipping/Carriers/Acs/AcsResponse.php create mode 100644 src/Shipping/Carriers/Acs/AreaResolver.php create mode 100644 src/Shipping/Carriers/Acs/Exceptions/AcsApiException.php create mode 100644 src/Shipping/Carriers/Acs/Jobs/WarmAcsAreaCacheJob.php create mode 100644 src/Shipping/Filament/Pages/ManageShippingRates.php diff --git a/config/shippingCarriers/acs.php b/config/shippingCarriers/acs.php new file mode 100644 index 0000000..cea2928 --- /dev/null +++ b/config/shippingCarriers/acs.php @@ -0,0 +1,50 @@ + env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'), + + 'api_key' => env('ACS_API_KEY'), + + 'company_id' => env('ACS_COMPANY_ID'), + 'company_password' => env('ACS_COMPANY_PASSWORD'), + 'user_id' => env('ACS_USER_ID'), + 'user_password' => env('ACS_USER_PASSWORD'), + + 'billing_code' => env('ACS_BILLING_CODE'), + + 'sender' => [ + 'name' => env('ACS_SENDER_NAME'), + 'address' => env('ACS_SENDER_ADDRESS'), + 'zip_code' => env('ACS_SENDER_ZIP'), + 'phone' => env('ACS_SENDER_PHONE'), + ], + + 'timeout' => env('ACS_HTTP_TIMEOUT', 10), + +]; diff --git a/src/Shipping/Carriers/Acs/AcsArea.php b/src/Shipping/Carriers/Acs/AcsArea.php new file mode 100644 index 0000000..a4fe1b5 --- /dev/null +++ b/src/Shipping/Carriers/Acs/AcsArea.php @@ -0,0 +1,11 @@ + $this->config['api_key'], + ]) + ->timeout($this->config['timeout']) + ->post($this->config['base_url'], [ + 'ACSAlias' => $alias, + 'ACSInputParameters' => array_merge($this->credentialParams(), $parameters), + ]); + + return AcsResponse::fromHttpResponse($response); + } + + private function credentialParams(): array + { + return [ + 'Company_ID' => $this->config['company_id'], + 'Company_Password' => $this->config['company_password'], + 'User_ID' => $this->config['user_id'], + 'User_Password' => $this->config['user_password'], + ]; + } +} diff --git a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php new file mode 100644 index 0000000..1635408 --- /dev/null +++ b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php @@ -0,0 +1,133 @@ +shippingAddress; + $destination = $this->areaResolver->resolve($address->postcode); + + $response = $this->client->call('ACS_Create_Voucher', array_merge([ + 'Pickup_Date' => now()->toDateString(), + 'Sender' => config('acs.sender.name'), + 'Recipient_Name' => trim("{$address->first_name} {$address->last_name}"), + 'Recipient_Address' => $address->line_one, + 'Recipient_Zipcode' => $address->postcode, + 'Recipient_Region' => $address->city, + 'Recipient_Phone' => $address->contact_phone, + 'Recipient_Country' => 'GR', + 'Acs_Station_Branch_Destination' => $destination->branchId, + 'Billing_Code' => config('acs.billing_code'), + 'Charge_Type' => 2, + 'Item_Quantity' => 1, + 'Weight' => 0.5, + ], $overrides))->throwIfError(); + + $voucherNo = (string) $response->valueOutput['Voucher_No']; + + $shipment = Shipment::create([ + 'order_id' => $order->id, + 'carrier' => 'acs', + 'tracking_reference' => $voucherNo, + 'meta' => [ + 'station_destination' => $destination->stationId, + 'weight' => $overrides['Weight'] ?? 0.5, + 'pickup_date' => now()->toDateString(), + ], + ]); + + if (($overrides['Item_Quantity'] ?? 1) > 1) { + $this->persistMultipartVouchers($shipment); + } + + return $shipment; + } + + public function printLabel(Shipment $shipment): string + { + $response = $this->client->call('ACS_Print_Voucher', [ + 'Voucher_No' => $shipment->tracking_reference, + 'Print_Type' => 2, + 'Start_Position' => 1, + ])->throwIfError(); + + $shipment->update(['label_printed_at' => now()]); + + return $response->valueOutput[$shipment->tracking_reference] ?? ''; + } + + public function cancelShipment(Shipment $shipment): void + { + if ($shipment->manifest_reference) { + throw new \RuntimeException('Cannot cancel a shipment already included in an issued manifest.'); + } + + $this->client->call('ACS_Delete_Voucher', [ + 'Voucher_No' => $shipment->tracking_reference, + ])->throwIfError(); + + $shipment->update(['cancelled_at' => now()]); + } + + public function pendingForManifest(): Collection + { + return Shipment::query() + ->where('carrier', 'acs') + ->whereNull('manifest_reference') + ->whereNull('cancelled_at') + ->get(); + } + + public function issueManifest(Collection $shipments): ManifestResult + { + $unprinted = $shipments->whereNull('label_printed_at'); + + if ($unprinted->isNotEmpty()) { + return ManifestResult::blocked($unprinted, 'unprinted'); + } + + $response = $this->client->call('ACS_Issue_Pickup_List', [ + 'Pickup_Date' => now()->toDateString(), + 'MyData' => null, + ])->throwIfError(); + + $pickupListNo = (string) $response->valueOutput['PickupList_No']; + + $shipments->each(fn (Shipment $shipment) => $shipment->update([ + 'manifest_reference' => $pickupListNo, + ])); + + return ManifestResult::success($pickupListNo, $shipments); + } + + private function persistMultipartVouchers(Shipment $mainShipment): void + { + $response = $this->client->call('ACS_Get_Multipart_Vouchers', [ + 'Main_Voucher_No' => $mainShipment->tracking_reference, + ])->throwIfError(); + + foreach ($response->tableOutput['Table_Data'] ?? [] as $row) { + Shipment::create([ + 'order_id' => $mainShipment->order_id, + 'carrier' => 'acs', + 'tracking_reference' => $row['MultiPart_Voucher_No'], + 'parent_reference' => $mainShipment->tracking_reference, + 'meta' => $mainShipment->meta?->toArray() ?? [], + ]); + } + } +} diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php new file mode 100644 index 0000000..0d92a48 --- /dev/null +++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php @@ -0,0 +1,134 @@ +shippingRate; + $shippingMethod = $shippingRate->shippingMethod; + $cart = $shippingOptionRequest->cart; + + $postcode = $cart->shippingAddress?->postcode; + + if (! $postcode) { + return null; + } + + if (($shippingRate->pricing_mode ?? 'live') === 'fixed') { + return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart); + } + + return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode); + } + + private function resolveFixedPrice(ShippingRate $shippingRate, $shippingMethod, $cart): ?ShippingOption + { + $subTotal = $cart->lines->sum('subTotal.value'); + + $pricing = Pricing::for($shippingRate)->qty($subTotal)->get(); + + if (! $pricing->matched) { + return null; + } + + return new ShippingOption( + name: $shippingMethod->name ?: $this->name(), + description: $shippingMethod->description ?: $this->description(), + identifier: $shippingRate->getIdentifier(), + price: $pricing->matched->price, + taxClass: $shippingRate->getTaxClass(), + taxReference: $shippingRate->getTaxReference(), + ); + } + + private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption + { + try { + $destination = $this->areaResolver->resolve($postcode); + + $response = $this->client->call('ACS_Price_Calculation', [ + 'Billing_Code' => config('acs.billing_code'), + 'Acs_Station_Destination' => $destination->stationId, + 'Weight' => $this->totalWeightInKg($cart), + 'Pickup_Date' => now()->toDateString(), + 'Charge_Type' => 2, + ])->throwIfError(); + } catch (AcsApiException $e) { + report($e); + + return null; + } + + $amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100); + + return new ShippingOption( + name: $shippingMethod->name ?: $this->name(), + description: $shippingMethod->description ?: $this->description(), + identifier: $shippingRate->getIdentifier(), + price: new Price($amount, $cart->currency, 1), + taxClass: $shippingRate->getTaxClass(), + taxReference: $shippingRate->getTaxReference(), + meta: ['acs_station_destination' => $destination->stationId], + ); + } + + public function on(ShippingRate $shippingRate): self + { + $this->shippingRate = $shippingRate; + + return $this; + } + + private function totalWeightInKg($cart): float + { + $weight = 0.0; + + foreach ($cart->lines->load('purchasable') as $line) { + $variant = $line->purchasable; + + if (! $variant || ! $variant->weight_value) { + continue; + } + + $unit = $variant->weight_unit ?? 'kg'; + $value = (float) $variant->weight_value; + + $weight += match ($unit) { + 'g' => $value / 1000, + 'lb' => $value * 0.45359237, + 'oz' => $value * 0.0283495231, + default => $value, // kg + } * $line->quantity; + } + + return max($weight, 0.5); // ACS minimum billable weight + } +} diff --git a/src/Shipping/Carriers/Acs/AcsResponse.php b/src/Shipping/Carriers/Acs/AcsResponse.php new file mode 100644 index 0000000..3e1255d --- /dev/null +++ b/src/Shipping/Carriers/Acs/AcsResponse.php @@ -0,0 +1,43 @@ +json() ?? []; + + // ACS's own JSON key is misspelled ("Responce") — preserved here verbatim. + $output = $body['ACSOutputResponce'] ?? []; + + return new self( + hasError: (bool) ($body['ACSExecution_HasError'] ?? ! $response->successful()), + errorMessage: $body['ACSExecutionErrorMessage'] ?? null, + valueOutput: $output['ACSValueOutput'][0] ?? [], + tableOutput: $output['ACSTableOutput'] ?? [], + ); + } + + public function throwIfError(): self + { + if ($this->hasError) { + throw new AcsApiException( + $this->errorMessage ?? ($this->valueOutput['Error_Message'] ?? 'Unknown ACS API error'), + $this->tableOutput, + ); + } + + return $this; + } +} diff --git a/src/Shipping/Carriers/Acs/AreaResolver.php b/src/Shipping/Carriers/Acs/AreaResolver.php new file mode 100644 index 0000000..a87398f --- /dev/null +++ b/src/Shipping/Carriers/Acs/AreaResolver.php @@ -0,0 +1,81 @@ +toArea($areas[$postcode]); + } + + return $this->toArea($this->fetch($postcode)); + } + + /** + * Fetch and cache the full country's postcode-to-station map in one call. + */ + public function warmAll(): void + { + $areas = []; + + foreach ($this->fetchAll() as $row) { + $areas[$row['Zip_Code']] = $row; + } + + Cache::forever(self::CACHE_KEY, $areas); + } + + private function fetch(string $postcode): array + { + $response = $this->client->call('ACS_Area_Find_By_Zip_Code', [ + 'Zip_Code' => $postcode, + 'Show_Only_Inaccessible_Areas' => 0, + 'Country' => 'GR', + ])->throwIfError(); + + $area = $response->tableOutput['Table_Data'][0] ?? null; + + if (! $area) { + throw new AcsApiException("No ACS area found for postcode {$postcode}"); + } + + return $area; + } + + private function fetchAll(): array + { + $response = $this->client->call('ACS_Area_Find_By_Zip_Code', [ + 'Zip_Code' => null, + 'Show_Only_Inaccessible_Areas' => 0, + 'Country' => 'GR', + ])->throwIfError(); + + return $response->tableOutput['Table_Data'] ?? []; + } + + private function toArea(array $row): AcsArea + { + return new AcsArea( + stationId: $row['Station_ID'], + branchId: (int) $row['Branch_ID'], + ); + } +} diff --git a/src/Shipping/Carriers/Acs/Exceptions/AcsApiException.php b/src/Shipping/Carriers/Acs/Exceptions/AcsApiException.php new file mode 100644 index 0000000..75ed50c --- /dev/null +++ b/src/Shipping/Carriers/Acs/Exceptions/AcsApiException.php @@ -0,0 +1,13 @@ +warmAll(); + } +} diff --git a/src/Shipping/Filament/Pages/ManageShippingRates.php b/src/Shipping/Filament/Pages/ManageShippingRates.php new file mode 100644 index 0000000..d91b287 --- /dev/null +++ b/src/Shipping/Filament/Pages/ManageShippingRates.php @@ -0,0 +1,165 @@ +schema( + $this->insertPricingModeFieldAfterShippingMethod( + $this->hidePriceFieldsWhenLive($form->getComponents()) + ) + ); + } + + /** + * Insert the "Pricing" field immediately after "shipping_method_id" so + * it reads as a pair, rather than appending it elsewhere in the form. + */ + private function insertPricingModeFieldAfterShippingMethod(array $components): array + { + $result = []; + + foreach ($components as $component) { + $result[] = $component; + + if (method_exists($component, 'getName') && $component->getName() === 'shipping_method_id') { + $result[] = $this->pricingModeField(); + } + } + + return $result; + } + + private function pricingModeField(): Select + { + return Select::make('pricing_mode') + ->label('Pricing') + ->options([ + 'live' => 'Use live API pricing', + 'fixed' => 'Use a fixed price', + ]) + ->default('live') + ->live() + ->visible(fn (Get $get) => static::methodHasLivePricing($get('shipping_method_id'))) + ->columnSpan(2); + } + + /** + * Hide the vendor's price / price-break fields whenever this rate is + * set to live pricing — they'd otherwise be dead configuration, + * silently ignored by the driver. Only the base "price" field was ever + * required by the vendor form; the "prices" repeater (price breaks) is + * always optional, so its required() state is left untouched. + */ + private function hidePriceFieldsWhenLive(array $components): array + { + $isFixedOrNotLiveCapable = fn (Get $get) => ! static::methodHasLivePricing($get('shipping_method_id')) + || $get('pricing_mode') === 'fixed'; + + foreach ($components as $component) { + if (! method_exists($component, 'getName')) { + continue; + } + + if ($component->getName() === 'price') { + $component->visible($isFixedOrNotLiveCapable) + ->required($isFixedOrNotLiveCapable) + ->dehydrated(true); + } + + if ($component->getName() === 'prices') { + $component->visible($isFixedOrNotLiveCapable) + ->dehydrated(true); + } + } + + return $components; + } + + public function table(Table $table): Table + { + $table = parent::table($table); + + return $table->columns( + array_map(function ($column) { + if (method_exists($column, 'getName') && $column->getName() === 'basePrices.0') { + return TextColumn::make('basePrices.0') + ->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label')) + ->formatStateUsing(function ($state, ShippingRate $record) { + if (static::methodHasLivePricing($record->shipping_method_id) && $record->pricing_mode === 'live') { + return 'Live API pricing'; + } + + return $state?->price->formatted; + }); + } + + return $column; + }, $table->getColumns()) + ); + } + + protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void + { + $isLive = static::methodHasLivePricing($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) + && ($data['pricing_mode'] ?? 'live') === 'live'; + + $shippingRate->pricing_mode = $isLive ? 'live' : 'fixed'; + $shippingRate->save(); + + if ($isLive) { + return; + } + + parent::saveShippingRate($shippingRate, $data); + } + + protected static function methodHasLivePricing(ShippingMethod|int|string|null $method): bool + { + if (blank($method)) { + return false; + } + + if (! $method instanceof ShippingMethod) { + $method = ShippingMethod::find($method); + } + + if (! $method) { + return false; + } + + try { + return Shipping::driver($method->driver) instanceof SupportsLivePricing; + } catch (\InvalidArgumentException) { + return false; + } + } +} From 37c2e1194c965ac92092702e4eeee39a09e883ed Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 00:51:23 +0300 Subject: [PATCH 006/110] Feature: Add Box Now locker delivery integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Box Now rate driver (fixed pricing only — no live pricing API) and fulfillment service (delivery request creation, label printing, cancellation). Unlike ACS, Box Now books courier pickup automatically on delivery request creation, so no manifest/pickup-list step is implemented. Storefront locker selection is not yet built; createShipment() expects the chosen locker's locationId to be supplied by the caller. --- config/shippingCarriers/boxnow.php | 47 +++++++++ src/Shipping/Carriers/BoxNow/BoxNowClient.php | 97 +++++++++++++++++++ .../BoxNow/BoxNowFulfillmentService.php | 92 ++++++++++++++++++ .../Carriers/BoxNow/BoxNowRateDriver.php | 61 ++++++++++++ .../BoxNow/Exceptions/BoxNowApiException.php | 13 +++ 5 files changed, 310 insertions(+) create mode 100644 config/shippingCarriers/boxnow.php create mode 100644 src/Shipping/Carriers/BoxNow/BoxNowClient.php create mode 100644 src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php create mode 100644 src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php create mode 100644 src/Shipping/Carriers/BoxNow/Exceptions/BoxNowApiException.php diff --git a/config/shippingCarriers/boxnow.php b/config/shippingCarriers/boxnow.php new file mode 100644 index 0000000..672b95f --- /dev/null +++ b/config/shippingCarriers/boxnow.php @@ -0,0 +1,47 @@ + env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'), + 'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'), + + 'client_id' => env('BOXNOW_CLIENT_ID'), + 'client_secret' => env('BOXNOW_CLIENT_SECRET'), + + 'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'), + + 'sender' => [ + 'name' => env('BOXNOW_SENDER_NAME'), + 'email' => env('BOXNOW_SENDER_EMAIL'), + 'phone' => env('BOXNOW_SENDER_PHONE'), + ], + + 'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10), + +]; diff --git a/src/Shipping/Carriers/BoxNow/BoxNowClient.php b/src/Shipping/Carriers/BoxNow/BoxNowClient.php new file mode 100644 index 0000000..dd8bbea --- /dev/null +++ b/src/Shipping/Carriers/BoxNow/BoxNowClient.php @@ -0,0 +1,97 @@ +token()) + ->timeout($this->config['timeout']) + ->{$method}("{$this->config['base_url']}{$path}", $payload); + + if ($response->status() === 401) { + // Token expired early / was revoked — refresh once and retry. + Cache::forget(self::TOKEN_CACHE_KEY); + + $response = Http::withToken($this->token()) + ->timeout($this->config['timeout']) + ->{$method}("{$this->config['base_url']}{$path}", $payload); + } + + if ($response->failed()) { + throw new BoxNowApiException( + "Box Now API error ({$response->status()}) on {$method} {$path}", + $response->json() ?? [], + ); + } + + return $response->json() ?? []; + } + + /** + * The origins/destinations lookups are served faster from a separate + * location API host, per Box Now's own documentation. + */ + public function locationRequest(string $path, array $query = []): array + { + $response = Http::withToken($this->token()) + ->timeout($this->config['timeout']) + ->get("{$this->config['location_api_url']}{$path}", $query); + + if ($response->failed()) { + throw new BoxNowApiException( + "Box Now location API error ({$response->status()}) on GET {$path}", + $response->json() ?? [], + ); + } + + return $response->json() ?? []; + } + + /** + * Fetch raw bytes (e.g. a PDF label) rather than JSON. + */ + public function requestRaw(string $path): string + { + $response = Http::withToken($this->token()) + ->timeout($this->config['timeout']) + ->get("{$this->config['base_url']}{$path}"); + + if ($response->failed()) { + throw new BoxNowApiException("Box Now API error ({$response->status()}) on GET {$path}"); + } + + return $response->body(); + } + + private function token(): string + { + return Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(55), function () { + $response = Http::timeout($this->config['timeout']) + ->post("{$this->config['base_url']}/auth-sessions", [ + 'grant_type' => 'client_credentials', + 'client_id' => $this->config['client_id'], + 'client_secret' => $this->config['client_secret'], + ]); + + if ($response->failed()) { + throw new BoxNowApiException('Box Now authentication failed', $response->json() ?? []); + } + + return $response->json('access_token'); + }); + } +} diff --git a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php new file mode 100644 index 0000000..05266c5 --- /dev/null +++ b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php @@ -0,0 +1,92 @@ +shippingAddress; + $destinationLocationId = $overrides['locationId'] ?? null; + + if (! $destinationLocationId) { + throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.'); + } + + $response = $this->client->request('post', '/delivery-requests', [ + 'orderNumber' => $order->reference.'-'.$order->id, + 'invoiceValue' => number_format($order->total->decimal, 2, '.', ''), + 'paymentMode' => 'prepaid', + 'amountToBeCollected' => '0.00', + 'origin' => [ + 'contactNumber' => config('boxnow.sender.phone'), + 'contactEmail' => config('boxnow.sender.email'), + 'contactName' => config('boxnow.sender.name'), + 'locationId' => config('boxnow.origin_location_id'), + ], + 'destination' => [ + 'contactNumber' => $address->contact_phone, + 'contactEmail' => $address->contact_email, + 'contactName' => trim("{$address->first_name} {$address->last_name}"), + 'locationId' => $destinationLocationId, + ], + 'items' => [ + [ + 'id' => (string) $order->id, + 'name' => 'Order '.$order->reference, + 'value' => '0.00', + 'compartmentSize' => $overrides['compartmentSize'] ?? 1, + 'weight' => $overrides['weight'] ?? 0, + ], + ], + ]); + + $parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException( + 'Box Now delivery request succeeded but returned no parcel id.', + $response, + )); + + return Shipment::create([ + 'order_id' => $order->id, + 'carrier' => 'box-now', + 'tracking_reference' => $parcelId, + 'meta' => [ + 'delivery_request_id' => $response['id'] ?? null, + 'locker_id' => $destinationLocationId, + ], + ]); + } + + public function printLabel(Shipment $shipment): string + { + $bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf"); + + $shipment->update(['label_printed_at' => now()]); + + return $bytes; + } + + public function cancelShipment(Shipment $shipment): void + { + $this->client->request('post', "/parcels/{$shipment->tracking_reference}:cancel"); + + $shipment->update(['cancelled_at' => now()]); + } +} diff --git a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php new file mode 100644 index 0000000..4897657 --- /dev/null +++ b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php @@ -0,0 +1,61 @@ +shippingRate; + $shippingMethod = $shippingRate->shippingMethod; + $cart = $shippingOptionRequest->cart; + + $subTotal = $cart->lines->sum('subTotal.value'); + + $pricing = Pricing::for($shippingRate)->qty($subTotal)->get(); + + if (! $pricing->matched) { + return null; + } + + return new ShippingOption( + name: $shippingMethod->name ?: $this->name(), + description: $shippingMethod->description ?: $this->description(), + identifier: $shippingRate->getIdentifier(), + price: $pricing->matched->price, + taxClass: $shippingRate->getTaxClass(), + taxReference: $shippingRate->getTaxReference(), + ); + } + + public function on(ShippingRate $shippingRate): self + { + $this->shippingRate = $shippingRate; + + return $this; + } +} diff --git a/src/Shipping/Carriers/BoxNow/Exceptions/BoxNowApiException.php b/src/Shipping/Carriers/BoxNow/Exceptions/BoxNowApiException.php new file mode 100644 index 0000000..59bc0c9 --- /dev/null +++ b/src/Shipping/Carriers/BoxNow/Exceptions/BoxNowApiException.php @@ -0,0 +1,13 @@ + Date: Sun, 19 Jul 2026 00:52:01 +0300 Subject: [PATCH 007/110] Feature: Wire up carrier-agnostic shipping admin UI Registers ShippingServiceProvider (carrier config, driver/fulfillment bindings, Rates page override) and wires the shipping admin surface into CorePlugin: dynamic carrier dropdown on Shipping Method create/edit, a "Create Shipment" order action resolved generically by carrier, and a Pickup Manifests page for carriers that support manifest batching. --- composer.json | 3 +- .../pages/manage-pickup-manifests.blade.php | 3 + src/CorePlugin.php | 13 ++- src/Providers/ShippingServiceProvider.php | 97 ++++++++++++++++ .../Extensions/OrderViewExtension.php | 101 +++++++++++++++++ .../ShippingMethodListExtension.php | 48 ++++++++ .../ShippingMethodResourceExtension.php | 83 ++++++++++++++ .../Filament/Pages/ManagePickupManifests.php | 106 ++++++++++++++++++ 8 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php create mode 100644 src/Providers/ShippingServiceProvider.php create mode 100644 src/Shipping/Extensions/OrderViewExtension.php create mode 100644 src/Shipping/Extensions/ShippingMethodListExtension.php create mode 100644 src/Shipping/Extensions/ShippingMethodResourceExtension.php create mode 100644 src/Shipping/Filament/Pages/ManagePickupManifests.php diff --git a/composer.json b/composer.json index 84768af..cac7ef2 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "providers": [ "Modules\\Core\\Providers\\CoreServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider", - "Modules\\Core\\Providers\\CustomerServiceProvider" + "Modules\\Core\\Providers\\CustomerServiceProvider", + "Modules\\Core\\Providers\\ShippingServiceProvider" ] } }, diff --git a/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php b/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php new file mode 100644 index 0000000..ce096a2 --- /dev/null +++ b/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php @@ -0,0 +1,3 @@ + + {{ $this->table }} + diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 38a356f..a9ef2b4 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -6,17 +6,24 @@ use Filament\Contracts\Plugin; use Filament\Panel; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Mail; +use Lunar\Admin\Filament\Resources\OrderResource; use Lunar\Admin\Filament\Resources\ProductResource; use Lunar\Admin\Filament\Resources\StaffResource; use Lunar\Admin\Models\Staff as LunarStaff; use Lunar\Admin\Support\Facades\LunarPanel; use Lunar\Models\Product; +use Lunar\Shipping\Filament\Resources\ShippingMethodResource; +use Lunar\Shipping\Filament\Resources\ShippingMethodResource\Pages\ListShippingMethod; use Lunar\Shipping\ShippingPlugin; use Modules\Core\Auth\Extensions\StaffResourceExtension; use Modules\Core\Auth\Filament\Pages\Login; use Modules\Core\Auth\Mail\InviteMail; use Modules\Core\Review\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; +use Modules\Core\Shipping\Extensions\OrderViewExtension; +use Modules\Core\Shipping\Extensions\ShippingMethodListExtension; +use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension; +use Modules\Core\Shipping\Filament\Pages\ManagePickupManifests; class CorePlugin implements Plugin { @@ -32,11 +39,15 @@ class CorePlugin implements Plugin ->brandLogo(asset('static/logos/core/boboko-logo.svg')) ->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg')) ->login(Login::class) - ->plugin(ShippingPlugin::make()); + ->plugin(ShippingPlugin::make()) + ->pages([ManagePickupManifests::class]); LunarPanel::extensions([ StaffResource::class => StaffResourceExtension::class, ProductResource::class => ProductResourceExtension::class, + ShippingMethodResource::class => ShippingMethodResourceExtension::class, + ListShippingMethod::class => ShippingMethodListExtension::class, + OrderResource\Pages\ManageOrder::class => OrderViewExtension::class, ]); Product::macro('reviews', function (): HasMany { diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php new file mode 100644 index 0000000..0adecbb --- /dev/null +++ b/src/Providers/ShippingServiceProvider.php @@ -0,0 +1,97 @@ +mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs'); + $this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow'); + + $this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs'))); + $this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow'))); + + $this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) { + return match ($params['carrier'] ?? null) { + 'acs' => $app->make(AcsFulfillmentService::class), + 'box-now' => $app->make(BoxNowFulfillmentService::class), + default => null, + }; + }); + + // The vendor Rates page has no extension hook, so we swap it for + // our subclass everywhere. Route::get($path, VendorClass::class) + // instantiates the vendor class directly via the container for the + // initial full-page load (bypassing Livewire's component registry + // entirely), so this container bind is required in addition to the + // Livewire::component() re-registration below — the bind covers + // first load, the Livewire registration covers every AJAX + // round-trip (form submits, table interactions) afterwards. + $this->app->bind(VendorManageShippingRates::class, ManageShippingRates::class); + } + + public function boot(): void + { + $this->publishes([ + __DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'), + __DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'), + ], 'core-config'); + + Order::resolveRelationUsing('shipments', function ($order) { + return $order->hasMany(Shipment::class); + }); + + // Deferred: the Shipping facade resolves a binding registered in + // lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(), + // and provider boot order between packages isn't guaranteed. + $this->app->booted(function () { + Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class)); + Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class)); + + $this->app->make(ConsoleSchedule::class) + ->job(new WarmAcsAreaCacheJob) + ->dailyAt('06:00') + ->when(fn () => ShippingMethod::where('driver', 'acs')->exists()); + + $this->overrideRatesPageLivewireComponent(); + }); + } + + /** + * The vendor Rates page has no extension hook, so we swap it for our + * subclass (see Shipping/Filament/Pages/ManageShippingRates). Filament + * already registered the vendor class as a Livewire component under a + * name derived from its class string (see + * Panel::registerLivewireComponents()); Livewire's own registry is a + * simple last-write-wins name => class map, so re-registering the same + * derived name against our subclass here overrides it — keeping the + * route, sub-navigation, and every Livewire round-trip (including form + * submissions) pointed at one consistent component identity. + */ + private function overrideRatesPageLivewireComponent(): void + { + $name = $this->app->make(ComponentRegistry::class)->getName(VendorManageShippingRates::class); + + Livewire::component($name, ManageShippingRates::class); + } +} diff --git a/src/Shipping/Extensions/OrderViewExtension.php b/src/Shipping/Extensions/OrderViewExtension.php new file mode 100644 index 0000000..93a4722 --- /dev/null +++ b/src/Shipping/Extensions/OrderViewExtension.php @@ -0,0 +1,101 @@ +createShipmentAction(); + + return $actions; + } + + private function createShipmentAction(): Actions\Action + { + return Actions\Action::make('create_shipment') + ->label('Create Shipment') + ->icon('heroicon-o-truck') + ->modalSubmitActionLabel('Create Shipment') + ->form([ + Forms\Components\Toggle::make('confirm') + ->label('Confirm') + ->helperText('This will create a real shipment with the carrier.') + ->rules([ + function () { + return function (string $attribute, $value, \Closure $fail) { + if ($value !== true) { + $fail('Please confirm before creating the shipment.'); + } + }; + }, + ]), + ]) + ->action(function (Order $record, Actions\Action $action) { + $service = $this->resolveFulfillmentService($record); + + if (! $service) { + Notification::make() + ->title('No carrier fulfillment integration is configured for this order.') + ->danger() + ->send(); + + $action->halt(); + + return; + } + + try { + $service->createShipment($record); + } catch (\Throwable $e) { + report($e); + + Notification::make() + ->title('Failed to create shipment: '.$e->getMessage()) + ->danger() + ->send(); + + $action->halt(); + + return; + } + + Notification::make() + ->title('Shipment created.') + ->success() + ->send(); + }) + ->visible(fn (Order $record) => $record->shipments()->exists() === false + && $this->resolveFulfillmentService($record) !== null); + } + + private function resolveCarrier(Order $record): ?string + { + $code = $record->shippingAddress?->shipping_option; + + if (! $code) { + return null; + } + + return ShippingMethod::where('code', $code)->value('driver'); + } + + private function resolveFulfillmentService(Order $record): ?CarrierFulfillmentInterface + { + $carrier = $this->resolveCarrier($record); + + if (! $carrier) { + return null; + } + + return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); + } +} diff --git a/src/Shipping/Extensions/ShippingMethodListExtension.php b/src/Shipping/Extensions/ShippingMethodListExtension.php new file mode 100644 index 0000000..f9d9b0c --- /dev/null +++ b/src/Shipping/Extensions/ShippingMethodListExtension.php @@ -0,0 +1,48 @@ +form([ + ShippingMethodResource::getNameFormComponent(), + Group::make([ + ShippingMethodResource::getCodeFormComponent(), + $this->driverSelect(), + ])->columns(2), + ShippingMethodResource::getDescriptionFormComponent(), + ]); + } + } + + return $actions; + } + + private function driverSelect(): Select + { + return Select::make('driver') + ->label('Type') + ->options(fn () => collect(Shipping::getSupportedDrivers()) + ->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()])) + ->default('flat-rate'); + } +} diff --git a/src/Shipping/Extensions/ShippingMethodResourceExtension.php b/src/Shipping/Extensions/ShippingMethodResourceExtension.php new file mode 100644 index 0000000..0aa8cd6 --- /dev/null +++ b/src/Shipping/Extensions/ShippingMethodResourceExtension.php @@ -0,0 +1,83 @@ +schema( + $this->replaceDriverField($form->getComponents()) + ); + } + + public function extendTable(Table $table): Table + { + return $table->columns( + array_map(function ($column) { + if (method_exists($column, 'getName') && $column->getName() === 'driver') { + return $this->driverColumn(); + } + + return $column; + }, $table->getColumns()) + ); + } + + private function driverColumn(): TextColumn + { + return TextColumn::make('driver') + ->label('Type') + ->formatStateUsing(fn ($state) => $this->driverLabel($state)); + } + + private function driverLabel(string $key): string + { + $driver = collect(Shipping::getSupportedDrivers())->get($key); + + return $driver?->name() ?? $key; + } + + /** + * Recursively walk the form tree and replace the hardcoded driver + * Select (nested inside Section > Group) with one listing every + * registered driver, built-in or custom. + * + * @param array $components + * @return array + */ + private function replaceDriverField(array $components): array + { + return array_map(function (Component $component) { + if (method_exists($component, 'getName') && $component->getName() === 'driver') { + return $this->driverSelect(); + } + + if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { + $component->schema( + $this->replaceDriverField($component->getChildComponents()) + ); + } + + return $component; + }, $components); + } + + private function driverSelect(): Select + { + return Select::make('driver') + ->label('Type') + ->options(fn () => collect(Shipping::getSupportedDrivers()) + ->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()])) + ->default('flat-rate'); + } +} diff --git a/src/Shipping/Filament/Pages/ManagePickupManifests.php b/src/Shipping/Filament/Pages/ManagePickupManifests.php new file mode 100644 index 0000000..993c513 --- /dev/null +++ b/src/Shipping/Filament/Pages/ManagePickupManifests.php @@ -0,0 +1,106 @@ +query($this->pendingQuery()) + ->columns([ + TextColumn::make('carrier')->badge(), + TextColumn::make('tracking_reference')->label('Tracking #'), + TextColumn::make('order.reference')->label('Order'), + TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'), + ]) + ->actions([ + Action::make('print') + ->label('Print') + ->icon('heroicon-o-printer') + ->action(fn (Shipment $record) => $this->printShipment($record)), + ]) + ->bulkActions([ + BulkAction::make('print_selected') + ->label('Print selected') + ->icon('heroicon-o-printer') + ->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => $this->printShipment($shipment))), + BulkAction::make('issue_manifest') + ->label('Issue Manifest') + ->icon('heroicon-o-check-circle') + ->action(fn (Collection $records) => $this->issueManifest($records)), + ]); + } + + private function pendingQuery(): Builder + { + $carriers = collect(Shipping::getSupportedDrivers())->keys()->filter( + fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsManifestBatching + ); + + return Shipment::query() + ->whereIn('carrier', $carriers) + ->whereNull('manifest_reference') + ->whereNull('cancelled_at'); + } + + private function printShipment(Shipment $shipment): void + { + $this->fulfillmentService($shipment->carrier)?->printLabel($shipment); + } + + private function issueManifest(Collection $shipments): void + { + $shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) { + $service = $this->fulfillmentService($carrier); + + if (! $service instanceof SupportsManifestBatching) { + return; + } + + $result = $service->issueManifest($group); + + if (! $result->success) { + Notification::make() + ->title("Manifest blocked for {$carrier}: {$result->reason}") + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title("Manifest issued for {$carrier}: {$result->reference}") + ->success() + ->send(); + }); + } + + private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface + { + return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); + } +} From d34e4505267dd3ae9da2460fb418ae09a2677ef1 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 02:24:10 +0300 Subject: [PATCH 008/110] Fix: Move carrier live-pricing choice onto ShippingMethod.charge_by Reverts the earlier per-rate pricing_mode column in favor of extending Lunar's existing charge_by field (cart_total/weight) with a third "live" option, gated by a SupportsLivePricing capability check on the driver. Adds a shared ResolvesFixedPricing trait so any carrier driver can fall back to Lunar's normal price-break resolution, matching the vendor ShipBy driver's own charge_by handling instead of introducing a separate mechanism. Also fixes an incorrect Get() path in the admin form that silently hid the new "live" option. --- ...d_pricing_mode_to_shipping_rates_table.php | 26 ----- src/Shipping/Carriers/Acs/AcsRateDriver.php | 32 ++----- .../Carriers/BoxNow/BoxNowRateDriver.php | 35 +++---- .../Concerns/ResolvesFixedPricing.php | 42 ++++++++ .../ShippingMethodResourceExtension.php | 73 +++++++++++++- .../Filament/Pages/ManageShippingRates.php | 95 ++++--------------- 6 files changed, 148 insertions(+), 155 deletions(-) delete mode 100644 database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php create mode 100644 src/Shipping/Concerns/ResolvesFixedPricing.php diff --git a/database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php b/database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php deleted file mode 100644 index 5442c1b..0000000 --- a/database/migrations/2026_07_17_000001_add_pricing_mode_to_shipping_rates_table.php +++ /dev/null @@ -1,26 +0,0 @@ -string('pricing_mode')->default('live')->after('enabled'); - }); - } - - public function down(): void - { - $prefix = config('lunar.database.table_prefix'); - - Schema::table("{$prefix}shipping_rates", function (Blueprint $table) { - $table->dropColumn('pricing_mode'); - }); - } -}; diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php index 0d92a48..6c8af72 100644 --- a/src/Shipping/Carriers/Acs/AcsRateDriver.php +++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php @@ -4,15 +4,17 @@ namespace Modules\Core\Shipping\Carriers\Acs; use Lunar\DataTypes\Price; use Lunar\DataTypes\ShippingOption; -use Lunar\Facades\Pricing; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Models\ShippingRate; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; +use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing; class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing { + use ResolvesFixedPricing; + public ShippingRate $shippingRate; public function __construct( @@ -36,39 +38,19 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing $shippingMethod = $shippingRate->shippingMethod; $cart = $shippingOptionRequest->cart; + if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') { + return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart); + } + $postcode = $cart->shippingAddress?->postcode; if (! $postcode) { return null; } - if (($shippingRate->pricing_mode ?? 'live') === 'fixed') { - return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart); - } - return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode); } - private function resolveFixedPrice(ShippingRate $shippingRate, $shippingMethod, $cart): ?ShippingOption - { - $subTotal = $cart->lines->sum('subTotal.value'); - - $pricing = Pricing::for($shippingRate)->qty($subTotal)->get(); - - if (! $pricing->matched) { - return null; - } - - return new ShippingOption( - name: $shippingMethod->name ?: $this->name(), - description: $shippingMethod->description ?: $this->description(), - identifier: $shippingRate->getIdentifier(), - price: $pricing->matched->price, - taxClass: $shippingRate->getTaxClass(), - taxReference: $shippingRate->getTaxReference(), - ); - } - private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption { try { diff --git a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php index 4897657..46fe211 100644 --- a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php +++ b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php @@ -3,19 +3,21 @@ namespace Modules\Core\Shipping\Carriers\BoxNow; use Lunar\DataTypes\ShippingOption; -use Lunar\Facades\Pricing; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Models\ShippingRate; +use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; /** - * Box Now has no pricing API, so this always resolves the admin-configured - * price/price-breaks on the ShippingRate — the same mechanism the built-in - * flat-rate driver uses. Unlike AcsRateDriver, this does not implement - * SupportsLivePricing: there is nothing to toggle between. + * Box Now has no pricing API, so this always resolves the method's normal + * charge_by + price-break configuration — the same mechanism the built-in + * flat-rate/ship-by drivers use. Does not implement SupportsLivePricing: + * there is no live option to offer. */ class BoxNowRateDriver implements ShippingRateInterface { + use ResolvesFixedPricing; + public ShippingRate $shippingRate; public function name(): string @@ -30,25 +32,10 @@ class BoxNowRateDriver implements ShippingRateInterface public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption { - $shippingRate = $shippingOptionRequest->shippingRate; - $shippingMethod = $shippingRate->shippingMethod; - $cart = $shippingOptionRequest->cart; - - $subTotal = $cart->lines->sum('subTotal.value'); - - $pricing = Pricing::for($shippingRate)->qty($subTotal)->get(); - - if (! $pricing->matched) { - return null; - } - - return new ShippingOption( - name: $shippingMethod->name ?: $this->name(), - description: $shippingMethod->description ?: $this->description(), - identifier: $shippingRate->getIdentifier(), - price: $pricing->matched->price, - taxClass: $shippingRate->getTaxClass(), - taxReference: $shippingRate->getTaxReference(), + return $this->resolveFixedPrice( + $shippingOptionRequest->shippingRate, + $shippingOptionRequest->shippingRate->shippingMethod, + $shippingOptionRequest->cart, ); } diff --git a/src/Shipping/Concerns/ResolvesFixedPricing.php b/src/Shipping/Concerns/ResolvesFixedPricing.php new file mode 100644 index 0000000..d0a31bd --- /dev/null +++ b/src/Shipping/Concerns/ResolvesFixedPricing.php @@ -0,0 +1,42 @@ +data['charge_by'] ?? 'cart_total'; + + $tier = $chargeBy === 'weight' + ? $cart->lines->load('purchasable')->sum(fn ($line) => ($line->purchasable->weight_value ?? 0) * $line->quantity) + : $cart->lines->sum('subTotal.value'); + + $pricing = Pricing::for($shippingRate)->qty($tier)->get(); + + if (! $pricing->matched) { + return null; + } + + return new ShippingOption( + name: $shippingMethod->name ?: $this->name(), + description: $shippingMethod->description ?: $this->description(), + identifier: $shippingRate->getIdentifier(), + price: $pricing->matched->price, + taxClass: $shippingRate->getTaxClass(), + taxReference: $shippingRate->getTaxReference(), + ); + } +} diff --git a/src/Shipping/Extensions/ShippingMethodResourceExtension.php b/src/Shipping/Extensions/ShippingMethodResourceExtension.php index 0aa8cd6..5cfadc7 100644 --- a/src/Shipping/Extensions/ShippingMethodResourceExtension.php +++ b/src/Shipping/Extensions/ShippingMethodResourceExtension.php @@ -6,20 +6,88 @@ use Filament\Forms\Components\Component; use Filament\Forms\Components\Concerns\HasChildComponents; use Filament\Forms\Components\Select; use Filament\Forms\Form; +use Filament\Forms\Get; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Shipping\Facades\Shipping; +use Modules\Core\Shipping\Contracts\SupportsLivePricing; class ShippingMethodResourceExtension extends ResourceExtension { public function extendForm(Form $form): Form { return $form->schema( - $this->replaceDriverField($form->getComponents()) + $this->replaceChargeByField( + $this->replaceDriverField($form->getComponents()) + ) ); } + /** + * Extend the vendor's cart_total/weight charge_by Select with a third + * "live" option — only offered when the currently selected driver + * supports live pricing (see SupportsLivePricing). Picking it is what + * tells the driver to call its carrier API instead of resolving a + * price break. + */ + private function replaceChargeByField(array $components): array + { + return array_map(function (Component $component) { + if (method_exists($component, 'getName') && $component->getName() === 'charge_by') { + return $this->chargeBySelect(); + } + + if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { + $component->schema( + $this->replaceChargeByField($component->getChildComponents()) + ); + } + + return $component; + }, $components); + } + + private function chargeBySelect(): Select + { + return Select::make('charge_by') + ->label('Charge by') + ->options(function (Get $get) { + $options = [ + 'cart_total' => 'Cart Total', + 'weight' => 'Weight', + ]; + + // "charge_by" is nested inside a Group with + // ->statePath('data'), while "driver" sits one level up, at + // the form root. Note: an *absolute* path here would need to + // additionally account for the page's own form wrapper + // (EditRecord::getFormStatePath() === 'data'), which relative + // paths never cross — so "../driver" (relative) is the + // correct, page-independent way to reach it, not an + // absolute 'driver' string. + if ($this->driverSupportsLivePricing($get('../driver'))) { + $options['live'] = 'Live API pricing'; + } + + return $options; + }) + ->live(); + } + + private function driverSupportsLivePricing(?string $driver): bool + { + if (! $driver) { + return false; + } + + try { + return Shipping::driver($driver) instanceof SupportsLivePricing; + } catch (\InvalidArgumentException) { + return false; + } + } + public function extendTable(Table $table): Table { return $table->columns( @@ -78,6 +146,7 @@ class ShippingMethodResourceExtension extends ResourceExtension ->label('Type') ->options(fn () => collect(Shipping::getSupportedDrivers()) ->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()])) - ->default('flat-rate'); + ->default('flat-rate') + ->live(); } } diff --git a/src/Shipping/Filament/Pages/ManageShippingRates.php b/src/Shipping/Filament/Pages/ManageShippingRates.php index d91b287..bf6cdb3 100644 --- a/src/Shipping/Filament/Pages/ManageShippingRates.php +++ b/src/Shipping/Filament/Pages/ManageShippingRates.php @@ -2,16 +2,13 @@ namespace Modules\Core\Shipping\Filament\Pages; -use Filament\Forms\Components\Select; use Filament\Forms\Form; use Filament\Forms\Get; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Lunar\Shipping\Facades\Shipping; +use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates; use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingRate; -use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates; -use Modules\Core\Shipping\Contracts\SupportsLivePricing; /** * Bound in place of the vendor ManageShippingRates page via the container @@ -20,11 +17,13 @@ use Modules\Core\Shipping\Contracts\SupportsLivePricing; * ShippingZoneResource::getPages() — is untouched; the container simply * hands back this subclass whenever the vendor class is resolved. * - * Adds a per-rate "Pricing" toggle (live API vs. fixed price) for methods - * whose driver supports live pricing (see SupportsLivePricing). Rates on - * methods without live pricing behave exactly as the vendor page always did - * — no toggle shown, price fields always required, vendor save logic used - * as-is. + * Hides the price / price-break fields for a rate whose method has + * charge_by = "live" (see ShippingMethodResourceExtension, which adds that + * option to methods whose driver supports live pricing) — those fields + * would otherwise be dead configuration the driver never reads. Pricing + * strategy (cart_total / weight / live) stays entirely on the Shipping + * Method, matching Lunar's own existing charge_by convention; nothing new + * is stored on the rate itself. */ class ManageShippingRates extends BaseManageShippingRates { @@ -33,56 +32,13 @@ class ManageShippingRates extends BaseManageShippingRates $form = parent::form($form); return $form->schema( - $this->insertPricingModeFieldAfterShippingMethod( - $this->hidePriceFieldsWhenLive($form->getComponents()) - ) + $this->hidePriceFieldsWhenLive($form->getComponents()) ); } - /** - * Insert the "Pricing" field immediately after "shipping_method_id" so - * it reads as a pair, rather than appending it elsewhere in the form. - */ - private function insertPricingModeFieldAfterShippingMethod(array $components): array - { - $result = []; - - foreach ($components as $component) { - $result[] = $component; - - if (method_exists($component, 'getName') && $component->getName() === 'shipping_method_id') { - $result[] = $this->pricingModeField(); - } - } - - return $result; - } - - private function pricingModeField(): Select - { - return Select::make('pricing_mode') - ->label('Pricing') - ->options([ - 'live' => 'Use live API pricing', - 'fixed' => 'Use a fixed price', - ]) - ->default('live') - ->live() - ->visible(fn (Get $get) => static::methodHasLivePricing($get('shipping_method_id'))) - ->columnSpan(2); - } - - /** - * Hide the vendor's price / price-break fields whenever this rate is - * set to live pricing — they'd otherwise be dead configuration, - * silently ignored by the driver. Only the base "price" field was ever - * required by the vendor form; the "prices" repeater (price breaks) is - * always optional, so its required() state is left untouched. - */ private function hidePriceFieldsWhenLive(array $components): array { - $isFixedOrNotLiveCapable = fn (Get $get) => ! static::methodHasLivePricing($get('shipping_method_id')) - || $get('pricing_mode') === 'fixed'; + $isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live'; foreach ($components as $component) { if (! method_exists($component, 'getName')) { @@ -90,14 +46,11 @@ class ManageShippingRates extends BaseManageShippingRates } if ($component->getName() === 'price') { - $component->visible($isFixedOrNotLiveCapable) - ->required($isFixedOrNotLiveCapable) - ->dehydrated(true); + $component->visible($isNotLive)->required($isNotLive)->dehydrated(true); } if ($component->getName() === 'prices') { - $component->visible($isFixedOrNotLiveCapable) - ->dehydrated(true); + $component->visible($isNotLive)->dehydrated(true); } } @@ -114,7 +67,7 @@ class ManageShippingRates extends BaseManageShippingRates return TextColumn::make('basePrices.0') ->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label')) ->formatStateUsing(function ($state, ShippingRate $record) { - if (static::methodHasLivePricing($record->shipping_method_id) && $record->pricing_mode === 'live') { + if (static::methodChargeBy($record->shipping_method_id) === 'live') { return 'Live API pricing'; } @@ -129,37 +82,23 @@ class ManageShippingRates extends BaseManageShippingRates protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void { - $isLive = static::methodHasLivePricing($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) - && ($data['pricing_mode'] ?? 'live') === 'live'; - - $shippingRate->pricing_mode = $isLive ? 'live' : 'fixed'; - $shippingRate->save(); - - if ($isLive) { + if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') { return; } parent::saveShippingRate($shippingRate, $data); } - protected static function methodHasLivePricing(ShippingMethod|int|string|null $method): bool + protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string { if (blank($method)) { - return false; + return null; } if (! $method instanceof ShippingMethod) { $method = ShippingMethod::find($method); } - if (! $method) { - return false; - } - - try { - return Shipping::driver($method->driver) instanceof SupportsLivePricing; - } catch (\InvalidArgumentException) { - return false; - } + return $method?->data['charge_by'] ?? null; } } From 808c7695950dadfdd36cc662ac2d0662bae1d147 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 03:35:16 +0300 Subject: [PATCH 009/110] Add cash-on-delivery payment type with cart fee pipeline --- composer.json | 6 ++- config/payment.php | 37 ++++++++++++++++ .../Pipelines/Cart/ApplyCashOnDeliveryFee.php | 30 +++++++++++++ src/Providers/PaymentServiceProvider.php | 42 +++++++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 config/payment.php create mode 100644 src/Payment/Pipelines/Cart/ApplyCashOnDeliveryFee.php create mode 100644 src/Providers/PaymentServiceProvider.php diff --git a/composer.json b/composer.json index 84768af..edf23ff 100644 --- a/composer.json +++ b/composer.json @@ -33,13 +33,15 @@ "providers": [ "Modules\\Core\\Providers\\CoreServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider", - "Modules\\Core\\Providers\\CustomerServiceProvider" + "Modules\\Core\\Providers\\CustomerServiceProvider", + "Modules\\Core\\Providers\\PaymentServiceProvider" ] } }, "config": { "allow-plugins": { - "pestphp/pest-plugin": true + "pestphp/pest-plugin": true, + "php-http/discovery": true } } } diff --git a/config/payment.php b/config/payment.php new file mode 100644 index 0000000..c82088f --- /dev/null +++ b/config/payment.php @@ -0,0 +1,37 @@ + [ + 'cash-on-delivery' => [ + 'driver' => 'offline', + 'authorized' => 'awaiting-payment', + 'fee' => 0, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Lunar cart pipeline additions + |-------------------------------------------------------------------------- + | + | Appended to config('lunar.cart.pipelines.cart') after ApplyShipping so + | the cash-on-delivery fee is added to the shipping total before the + | final Calculate step sums everything up. + | + */ + 'cart_pipeline' => [ + ApplyCashOnDeliveryFee::class, + ], +]; diff --git a/src/Payment/Pipelines/Cart/ApplyCashOnDeliveryFee.php b/src/Payment/Pipelines/Cart/ApplyCashOnDeliveryFee.php new file mode 100644 index 0000000..573b161 --- /dev/null +++ b/src/Payment/Pipelines/Cart/ApplyCashOnDeliveryFee.php @@ -0,0 +1,30 @@ +meta['payment_method'] ?? null) === 'cash-on-delivery') { + $fee = (int) config('lunar.payments.types.cash-on-delivery.fee', 0); + + $cart->shippingTotal = new Price( + ($cart->shippingTotal?->value ?? 0) + $fee, + $cart->currency, + 1 + ); + } + + return $next($cart); + } +} diff --git a/src/Providers/PaymentServiceProvider.php b/src/Providers/PaymentServiceProvider.php new file mode 100644 index 0000000..9e9e6fe --- /dev/null +++ b/src/Providers/PaymentServiceProvider.php @@ -0,0 +1,42 @@ +mergeConfigFrom(__DIR__ . '/../../config/payment.php', 'payment'); + } + + public function boot(): void + { + config([ + 'lunar.payments.types' => array_merge( + config('lunar.payments.types', []), + config('payment.types', []) + ), + ]); + + $cartPipeline = config('lunar.cart.pipelines.cart', []); + $insertAfter = array_search(ApplyShipping::class, $cartPipeline, true); + + foreach (config('payment.cart_pipeline', []) as $pipe) { + if (in_array($pipe, $cartPipeline, true)) { + continue; + } + + if ($insertAfter === false) { + $cartPipeline[] = $pipe; + } else { + array_splice($cartPipeline, $insertAfter + 1, 0, [$pipe]); + $insertAfter++; + } + } + + config(['lunar.cart.pipelines.cart' => $cartPipeline]); + } +} From 435a4dd290891fc497facdfccbadf51c7959c480 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 17:41:09 +0300 Subject: [PATCH 010/110] Feat: Adding Shippment Tracking --- ...7_21_000001_create_shipment_info_table.php | 28 +++++ src/Providers/ShippingServiceProvider.php | 5 + src/Shipping/Contracts/SupportsTracking.php | 23 ++++ .../TrackingCheckpoint.php | 22 ++++ src/Shipping/Enums/TrackingStatus.php | 29 +++++ .../Events/ShipmentStatusUpdatedByCarrier.php | 17 +++ src/Shipping/Jobs/PollShipmentTrackingJob.php | 107 ++++++++++++++++++ src/Shipping/Models/Shipment.php | 11 ++ src/Shipping/Models/ShipmentInfo.php | 31 +++++ 9 files changed, 273 insertions(+) create mode 100644 database/migrations/2026_07_21_000001_create_shipment_info_table.php create mode 100644 src/Shipping/Contracts/SupportsTracking.php create mode 100644 src/Shipping/DataTransferObjects/TrackingCheckpoint.php create mode 100644 src/Shipping/Enums/TrackingStatus.php create mode 100644 src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php create mode 100644 src/Shipping/Jobs/PollShipmentTrackingJob.php create mode 100644 src/Shipping/Models/ShipmentInfo.php diff --git a/database/migrations/2026_07_21_000001_create_shipment_info_table.php b/database/migrations/2026_07_21_000001_create_shipment_info_table.php new file mode 100644 index 0000000..fbfe887 --- /dev/null +++ b/database/migrations/2026_07_21_000001_create_shipment_info_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('shipment_id')->constrained('shipments')->cascadeOnDelete(); + $table->string('status'); + $table->string('carrier_status')->nullable(); + $table->text('message')->nullable(); + $table->string('location')->nullable(); + $table->timestamp('occurred_at'); + $table->json('meta')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipment_info'); + } +}; diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php index 0adecbb..c93b899 100644 --- a/src/Providers/ShippingServiceProvider.php +++ b/src/Providers/ShippingServiceProvider.php @@ -19,6 +19,7 @@ use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; +use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Models\Shipment; class ShippingServiceProvider extends ServiceProvider @@ -73,6 +74,10 @@ class ShippingServiceProvider extends ServiceProvider ->dailyAt('06:00') ->when(fn () => ShippingMethod::where('driver', 'acs')->exists()); + $this->app->make(ConsoleSchedule::class) + ->job(new PollShipmentTrackingJob) + ->everyThirtyMinutes(); + $this->overrideRatesPageLivewireComponent(); }); } diff --git a/src/Shipping/Contracts/SupportsTracking.php b/src/Shipping/Contracts/SupportsTracking.php new file mode 100644 index 0000000..4ec4457 --- /dev/null +++ b/src/Shipping/Contracts/SupportsTracking.php @@ -0,0 +1,23 @@ + + */ + public function trackShipment(Shipment $shipment): Collection; +} diff --git a/src/Shipping/DataTransferObjects/TrackingCheckpoint.php b/src/Shipping/DataTransferObjects/TrackingCheckpoint.php new file mode 100644 index 0000000..3ce5e94 --- /dev/null +++ b/src/Shipping/DataTransferObjects/TrackingCheckpoint.php @@ -0,0 +1,22 @@ + true, + default => false, + }; + } +} diff --git a/src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php b/src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php new file mode 100644 index 0000000..590dfd9 --- /dev/null +++ b/src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php @@ -0,0 +1,17 @@ +keys()->filter( + fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsTracking + ); + + if ($trackableCarriers->isEmpty()) { + return; + } + + Shipment::query() + ->whereIn('carrier', $trackableCarriers) + ->whereNull('cancelled_at') + ->whereDoesntHave('shipmentInfo', function ($query) { + $query->whereIn('status', [ + TrackingStatus::Delivered->value, + TrackingStatus::Returned->value, + TrackingStatus::Cancelled->value, + ]); + }) + ->chunkById(50, function ($shipments) { + $shipments->groupBy('carrier')->each( + fn ($group, $carrier) => $this->pollCarrierShipments($carrier, $group) + ); + }); + } + + private function pollCarrierShipments(string $carrier, $shipments): void + { + $service = $this->fulfillmentService($carrier); + + if (! $service instanceof SupportsTracking) { + return; + } + + foreach ($shipments as $shipment) { + $this->recordNewCheckpoints($shipment, $service->trackShipment($shipment)); + } + } + + private function recordNewCheckpoints(Shipment $shipment, $checkpoints): void + { + $existing = $shipment->shipmentInfo() + ->get(['status', 'occurred_at']) + ->map(fn ($info) => $info->status->value.'|'.$info->occurred_at->toIso8601String()) + ->flip(); + + foreach ($checkpoints as $checkpoint) { + $fingerprint = $checkpoint->status->value.'|'.$checkpoint->occurredAt->toIso8601String(); + + if ($existing->has($fingerprint)) { + continue; + } + + $info = ShipmentInfo::create([ + 'shipment_id' => $shipment->id, + 'status' => $checkpoint->status, + 'carrier_status' => $checkpoint->carrierStatus, + 'message' => $checkpoint->message, + 'location' => $checkpoint->location, + 'occurred_at' => $checkpoint->occurredAt, + 'meta' => $checkpoint->meta, + ]); + + ShipmentStatusUpdatedByCarrier::dispatch($info); + } + } + + private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface + { + return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); + } +} diff --git a/src/Shipping/Models/Shipment.php b/src/Shipping/Models/Shipment.php index 368a110..c278f4c 100644 --- a/src/Shipping/Models/Shipment.php +++ b/src/Shipping/Models/Shipment.php @@ -5,6 +5,7 @@ namespace Modules\Core\Shipping\Models; use Illuminate\Database\Eloquent\Casts\AsArrayObject; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Lunar\Models\Order; class Shipment extends Model @@ -21,4 +22,14 @@ class Shipment extends Model { return $this->belongsTo(Order::class); } + + public function shipmentInfo(): HasMany + { + return $this->hasMany(ShipmentInfo::class); + } + + public function latestShipmentInfo(): ?ShipmentInfo + { + return $this->shipmentInfo()->latest('occurred_at')->first(); + } } diff --git a/src/Shipping/Models/ShipmentInfo.php b/src/Shipping/Models/ShipmentInfo.php new file mode 100644 index 0000000..b2a401d --- /dev/null +++ b/src/Shipping/Models/ShipmentInfo.php @@ -0,0 +1,31 @@ + TrackingStatus::class, + 'occurred_at' => 'datetime', + 'meta' => AsArrayObject::class, + ]; + + public function shipment(): BelongsTo + { + return $this->belongsTo(Shipment::class); + } +} From 9f30a7324e594849d396874328e41489a68a2b8a Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 18:27:24 +0300 Subject: [PATCH 011/110] Feature: Shipment Updates to handle COD --- .../Carriers/Acs/AcsFulfillmentService.php | 85 +++++++++++++++++-- .../BoxNow/BoxNowFulfillmentService.php | 78 ++++++++++++++--- .../Contracts/CarrierFulfillmentInterface.php | 3 +- .../DataTransferObjects/ShipmentRequest.php | 20 +++++ .../Extensions/OrderViewExtension.php | 19 ++++- 5 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 src/Shipping/DataTransferObjects/ShipmentRequest.php diff --git a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php index 1635408..a40b9fb 100644 --- a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php +++ b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php @@ -2,26 +2,33 @@ namespace Modules\Core\Shipping\Carriers\Acs; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Lunar\Models\Order; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\SupportsManifestBatching; +use Modules\Core\Shipping\Contracts\SupportsTracking; +use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\DataTransferObjects\ManifestResult; +use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest; +use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint; +use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Models\Shipment; -class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching +class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking { public function __construct( private readonly AcsClient $client, private readonly AreaResolver $areaResolver, ) {} - public function createShipment(Order $order, array $overrides = []): Shipment + public function createShipment(Order $order, ShipmentRequest $request): Shipment { $address = $order->shippingAddress; $destination = $this->areaResolver->resolve($address->postcode); + $weight = $request->weight ?? 0.5; - $response = $this->client->call('ACS_Create_Voucher', array_merge([ + $params = [ 'Pickup_Date' => now()->toDateString(), 'Sender' => config('acs.sender.name'), 'Recipient_Name' => trim("{$address->first_name} {$address->last_name}"), @@ -33,9 +40,17 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani 'Acs_Station_Branch_Destination' => $destination->branchId, 'Billing_Code' => config('acs.billing_code'), 'Charge_Type' => 2, - 'Item_Quantity' => 1, - 'Weight' => 0.5, - ], $overrides))->throwIfError(); + 'Item_Quantity' => $request->packageCount, + 'Weight' => $weight, + ]; + + if ($request->paymentMode === 'cod') { + $params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal; + $params['Cod_Payment_Way'] = 0; // cash + $params['Acs_Delivery_Products'] = 'COD'; + } + + $response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError(); $voucherNo = (string) $response->valueOutput['Voucher_No']; @@ -45,12 +60,12 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani 'tracking_reference' => $voucherNo, 'meta' => [ 'station_destination' => $destination->stationId, - 'weight' => $overrides['Weight'] ?? 0.5, + 'weight' => $weight, 'pickup_date' => now()->toDateString(), ], ]); - if (($overrides['Item_Quantity'] ?? 1) > 1) { + if ($request->packageCount > 1) { $this->persistMultipartVouchers($shipment); } @@ -114,6 +129,60 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani return ManifestResult::success($pickupListNo, $shipments); } + public function trackShipment(Shipment $shipment): Collection + { + $response = $this->client->call('ACS_TrackingDetails', [ + 'Voucher_No' => $shipment->tracking_reference, + ])->throwIfError(); + + $rows = $response->tableOutput['Table_Data'] ?? []; + + // ACS's per-checkpoint data (checkpoint_action) is free text with no + // status code, so the final checkpoint's status is corroborated + // against the structured summary call rather than guessed from text. + $isDelivered = $this->isDelivered($shipment); + + return collect($rows)->values()->map(function (array $row, int $index) use ($rows, $isDelivered) { + $isLast = $index === count($rows) - 1; + + return new TrackingCheckpoint( + status: $isLast && $isDelivered + ? TrackingStatus::Delivered + : $this->guessStatusFromAction($row['checkpoint_action'] ?? ''), + carrierStatus: $row['checkpoint_action'] ?? null, + message: $row['checkpoint_action'] ?? null, + location: $row['checkpoint_location'] ?? null, + occurredAt: Carbon::parse($row['checkpoint_date_time']), + meta: $row, + ); + }); + } + + private function isDelivered(Shipment $shipment): bool + { + try { + $response = $this->client->call('ACS_Trackingsummary', [ + 'Voucher_No' => $shipment->tracking_reference, + ])->throwIfError(); + } catch (AcsApiException) { + return false; + } + + return (int) ($response->valueOutput['shipment_status'] ?? 0) === 4; + } + + private function guessStatusFromAction(string $action): TrackingStatus + { + $action = strtolower($action); + + return match (true) { + str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered, + str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery, + str_contains($action, 'arrival') || str_contains($action, 'departure') => TrackingStatus::InTransit, + default => TrackingStatus::Pending, + }; + } + private function persistMultipartVouchers(Shipment $mainShipment): void { $response = $this->client->call('ACS_Get_Multipart_Vouchers', [ diff --git a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php index 05266c5..ea32ea7 100644 --- a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php +++ b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php @@ -2,9 +2,15 @@ namespace Modules\Core\Shipping\Carriers\BoxNow; +use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Lunar\Models\Order; use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; +use Modules\Core\Shipping\Contracts\SupportsTracking; +use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest; +use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint; +use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Models\Shipment; /** @@ -13,28 +19,32 @@ use Modules\Core\Shipping\Models\Shipment; * CarrierFulfillmentInterface (not SupportsManifestBatching). * * Box Now delivers to lockers, not addresses. The storefront locker-picker - * is out of scope for this pass — createShipment() expects the chosen - * locker's Box Now locationId via $overrides['locationId'] (e.g. set - * manually by admin staff until checkout UI exists). + * is out of scope for this pass — createShipment() requires the chosen + * locker's Box Now locationId via ShipmentRequest::$destinationLocationId + * (e.g. set manually by admin staff until checkout UI exists). */ -class BoxNowFulfillmentService implements CarrierFulfillmentInterface +class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking { public function __construct(private readonly BoxNowClient $client) {} - public function createShipment(Order $order, array $overrides = []): Shipment + public function createShipment(Order $order, ShipmentRequest $request): Shipment { $address = $order->shippingAddress; - $destinationLocationId = $overrides['locationId'] ?? null; + $destinationLocationId = $request->destinationLocationId; if (! $destinationLocationId) { throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.'); } + $isCod = $request->paymentMode === 'cod'; + $response = $this->client->request('post', '/delivery-requests', [ 'orderNumber' => $order->reference.'-'.$order->id, 'invoiceValue' => number_format($order->total->decimal, 2, '.', ''), - 'paymentMode' => 'prepaid', - 'amountToBeCollected' => '0.00', + 'paymentMode' => $isCod ? 'cod' : 'prepaid', + 'amountToBeCollected' => $isCod + ? number_format($request->amountToCollect ?? $order->total->decimal, 2, '.', '') + : '0.00', 'origin' => [ 'contactNumber' => config('boxnow.sender.phone'), 'contactEmail' => config('boxnow.sender.email'), @@ -52,8 +62,8 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface 'id' => (string) $order->id, 'name' => 'Order '.$order->reference, 'value' => '0.00', - 'compartmentSize' => $overrides['compartmentSize'] ?? 1, - 'weight' => $overrides['weight'] ?? 0, + 'compartmentSize' => 1, + 'weight' => $request->weight ?? 0, ], ], ]); @@ -89,4 +99,52 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface $shipment->update(['cancelled_at' => now()]); } + + public function trackShipment(Shipment $shipment): Collection + { + $response = $this->client->request('get', '/parcels', [ + 'parcelId' => $shipment->tracking_reference, + ]); + + $parcel = $response['data'][0] ?? null; + + if (! $parcel) { + return collect(); + } + + $events = $parcel['events'] ?? []; + + // Fall back to a single checkpoint from the parcel's current state + // if Box Now didn't return a detailed events history. + if (empty($events)) { + $events = [[ + 'type' => $parcel['state'] ?? 'new', + 'locationDisplayName' => null, + 'createTime' => $parcel['updateTime'] ?? $parcel['createTime'] ?? now()->toIso8601String(), + ]]; + } + + return collect($events)->map(fn (array $event) => new TrackingCheckpoint( + status: $this->mapState($event['type'] ?? $parcel['state'] ?? 'new'), + carrierStatus: $event['type'] ?? $parcel['state'] ?? null, + message: null, + location: $event['locationDisplayName'] ?? null, + occurredAt: Carbon::parse($event['createTime']), + meta: $event, + )); + } + + private function mapState(string $state): TrackingStatus + { + return match ($state) { + 'new' => TrackingStatus::Pending, + 'in-transit', 'in-depot' => TrackingStatus::InTransit, + 'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery, + 'delivered' => TrackingStatus::Delivered, + 'returned', 'accepted-for-return' => TrackingStatus::Returned, + 'cancelled' => TrackingStatus::Cancelled, + 'expired-return', 'missing' => TrackingStatus::Failed, + default => TrackingStatus::Unknown, + }; + } } diff --git a/src/Shipping/Contracts/CarrierFulfillmentInterface.php b/src/Shipping/Contracts/CarrierFulfillmentInterface.php index 68f1b30..d76d8d4 100644 --- a/src/Shipping/Contracts/CarrierFulfillmentInterface.php +++ b/src/Shipping/Contracts/CarrierFulfillmentInterface.php @@ -3,6 +3,7 @@ namespace Modules\Core\Shipping\Contracts; use Lunar\Models\Order; +use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest; use Modules\Core\Shipping\Models\Shipment; interface CarrierFulfillmentInterface @@ -10,7 +11,7 @@ interface CarrierFulfillmentInterface /** * Create a shipment with the carrier for the given order. */ - public function createShipment(Order $order, array $overrides = []): Shipment; + public function createShipment(Order $order, ShipmentRequest $request): Shipment; /** * Fetch the printable label for a shipment (raw file bytes). diff --git a/src/Shipping/DataTransferObjects/ShipmentRequest.php b/src/Shipping/DataTransferObjects/ShipmentRequest.php new file mode 100644 index 0000000..f63b4a0 --- /dev/null +++ b/src/Shipping/DataTransferObjects/ShipmentRequest.php @@ -0,0 +1,20 @@ +icon('heroicon-o-truck') ->modalSubmitActionLabel('Create Shipment') ->form([ + Forms\Components\TextInput::make('weight') + ->label('Package weight (kg)') + ->numeric() + ->minValue(0) + ->helperText('Leave blank to use the carrier\'s default.'), + Forms\Components\TextInput::make('destination_location_id') + ->label('Box Now locker ID') + ->helperText('Only required for Box Now shipments.') + ->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null), Forms\Components\Toggle::make('confirm') ->label('Confirm') ->helperText('This will create a real shipment with the carrier.') @@ -39,7 +49,7 @@ class OrderViewExtension extends ViewPageExtension }, ]), ]) - ->action(function (Order $record, Actions\Action $action) { + ->action(function (Order $record, array $data, Actions\Action $action) { $service = $this->resolveFulfillmentService($record); if (! $service) { @@ -53,8 +63,13 @@ class OrderViewExtension extends ViewPageExtension return; } + $request = new ShipmentRequest( + weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, + destinationLocationId: $data['destination_location_id'] ?? null, + ); + try { - $service->createShipment($record); + $service->createShipment($record, $request); } catch (\Throwable $e) { report($e); From fa137c9a79054306b6a15ecafb46e688a84f21ea Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 26 Aug 2026 13:14:32 +0300 Subject: [PATCH 012/110] Feature: Adding back ChannelIds on Product Indexer --- src/Search/ProductIndexer.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Search/ProductIndexer.php b/src/Search/ProductIndexer.php index cd6fc3d..ad3d4ce 100644 --- a/src/Search/ProductIndexer.php +++ b/src/Search/ProductIndexer.php @@ -27,6 +27,9 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media; * - reviews: public-safe fields only (see mapReview() — reviewer_email is deliberately * excluded, it's PII with no storefront use), including staff replies, plus an * average rating + * - 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 * * A review is created/edited independently of its product (Modules\Core\Providers\ * ReviewServiceProvider re-indexes the product on review create/update/delete), so @@ -49,6 +52,7 @@ class ProductIndexer extends BaseProductIndexer 'collections', 'price', 'slugs', + 'channel_ids', ]; } @@ -83,6 +87,10 @@ class ProductIndexer extends BaseProductIndexer $data['reviews'] = $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(); $data['review_count'] = $reviews->count(); $data['average_rating'] = $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1); + $data['channel_ids'] = $model->channels() + ->wherePivot('enabled', true) + ->pluck('id') + ->toArray(); return $data; } From 356fbd73c5d608e7b8b702859f44a3aa8f22390c Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 26 Aug 2026 13:16:07 +0300 Subject: [PATCH 013/110] Bump Version to 0.5.1 --- CHANGELOG.md | 5 +++++ composer.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd79e2..944b9a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.5.1] - 2026-08-25 + +### Added +- `Modules\Core\Search\ProductIndexer` now indexes `channel_ids` (filterable) — Lunar's base indexer only marks `status` as filterable, not channel assignment, so storefront search couldn't otherwise scope results to products actually assigned and enabled on the current sales channel. Computed from `$product->channels()->wherePivot('enabled', true)`. Ported from an older `Products` branch whose remote had been deleted; the branch's other, now-superseded `ProductIndexer` changes were dropped in favor of the richer indexer already on `master` (collections, price, variants, reviews — see `0.5.0`). + ## [0.5.0] - 2026-08-24 ### Added diff --git a/composer.json b/composer.json index 29cc4b6..a34ac8d 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.5.0", + "version": "0.5.1", "autoload": { "psr-4": { "Modules\\Core\\": "src/" From c2eb9bd66a2ce722c784218c0020f96615df63bc Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 26 Aug 2026 13:26:29 +0300 Subject: [PATCH 014/110] Hotfix: Updating LocaleMiddleware to handle more than 2 languages --- docs/localization.md | 27 +++++++++++++++++++++ src/Localization/LocaleMiddleware.php | 34 ++++++++++++++++++--------- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/docs/localization.md b/docs/localization.md index 18fe74d..9a3bff9 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -105,6 +105,33 @@ $language = $request->attributes->get('language'); // Lunar\Models\Language in Use `$language->id` when querying Lunar's translatable content (e.g. `Url::where('language_id', ...)`). +### Shared view data — language switcher and `hreflang` tags + +The middleware also shares two variables with every view, via `View::share()`, so a layout's +language switcher or `hreflang` tags don't have to recompute the language list themselves: + +```blade +{{-- current locale --}} +{{ $currentLocale }} {{-- e.g. "el" --}} + +{{-- every OTHER configured language, each with its own URL for the current page --}} +@foreach ($altLocales as $altLocale) + {{ $altLocale['name'] }} +@endforeach +``` + +`$altLocales` is a **collection**, not a single value — deliberately, so it scales to any number +of configured languages rather than assuming exactly two. Each entry is a plain array: + +| Key | Description | +|---|---| +| `code` | The language's `Lunar\Models\Language::code` (e.g. `en`) | +| `name` | The language's display name | +| `url` | The **current route**, re-generated with that language's code — via `route($routeName, [...])` when the current request matched a named route, or a bare `/{code}` fallback otherwise | + +A 3+ language store gets one `$altLocales` entry per additional language automatically — nothing +about this shape assumes or special-cases a two-language store. + --- ## Single-language shops diff --git a/src/Localization/LocaleMiddleware.php b/src/Localization/LocaleMiddleware.php index 5bd1f51..5cd694d 100644 --- a/src/Localization/LocaleMiddleware.php +++ b/src/Localization/LocaleMiddleware.php @@ -62,24 +62,36 @@ class LocaleMiddleware } /** - * 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 From 235fdda4a72da9c09bce9e00f0611e18fe410879 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 26 Aug 2026 13:28:08 +0300 Subject: [PATCH 015/110] Bump Version to 0.5.2 --- CHANGELOG.md | 5 +++++ composer.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 944b9a6..c19b7da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.5.2] - 2026-08-26 + +### Fixed +- `Modules\Core\Localization\LocaleMiddleware`'s shared view data only ever surfaced a single alternate locale (`altLocale`/`altLocaleUrl`, found via `firstWhere('code', '!=', $current)`) — correct by coincidence for a 2-language store, but silently dropped every locale past the first "other" one found for a 3+ language store, with no error. Replaced with `altLocales`, a collection of every other configured language (`code`, `name`, `url` for the current route each), so a language switcher or `hreflang` tags scale to any number of locales. Documented in `docs/localization.md` ("Shared view data — language switcher and `hreflang` tags"). + ## [0.5.1] - 2026-08-25 ### Added diff --git a/composer.json b/composer.json index a34ac8d..a9248e1 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.5.1", + "version": "0.5.2", "autoload": { "psr-4": { "Modules\\Core\\": "src/" From 885923380d197d62aed1c99b417c7ddfbf3c8a69 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 26 Aug 2026 14:29:08 +0300 Subject: [PATCH 016/110] Hotfix: Selcting correct table name for channel Id --- src/Search/ProductIndexer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Search/ProductIndexer.php b/src/Search/ProductIndexer.php index ad3d4ce..2e06194 100644 --- a/src/Search/ProductIndexer.php +++ b/src/Search/ProductIndexer.php @@ -89,7 +89,7 @@ class ProductIndexer extends BaseProductIndexer $data['average_rating'] = $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1); $data['channel_ids'] = $model->channels() ->wherePivot('enabled', true) - ->pluck('id') + ->pluck('lunar_channels.id') ->toArray(); return $data; From 09844ec2b5e26792722031b9dc2faa49205f6778 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 26 Aug 2026 14:29:29 +0300 Subject: [PATCH 017/110] Bump Version to 0.5.3 --- CHANGELOG.md | 5 +++++ composer.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c19b7da..587c43e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.5.3] - 2026-08-26 + +### Fixed +- `Modules\Core\Search\ProductIndexer::toSearchableArray()` threw `column reference "id" is ambiguous` on Postgres when computing `channel_ids` — `$model->channels()->wherePivot('enabled', true)->pluck('id')` joins `lunar_channels` and `lunar_channelables`, both of which have an `id` column, and the unqualified `pluck('id')` left Postgres unable to resolve which table's column to select (SQLite/MySQL tolerated the ambiguity). Qualified as `pluck('lunar_channels.id')`. + ## [0.5.2] - 2026-08-26 ### Fixed diff --git a/composer.json b/composer.json index a9248e1..0e8ed22 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.5.2", + "version": "0.5.3", "autoload": { "psr-4": { "Modules\\Core\\": "src/" From 751aff59392a4615bda43a01b9b8da73b5a34203 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 26 Aug 2026 18:05:15 +0300 Subject: [PATCH 018/110] Feature: Adding Sorting To Product List --- docs/product-listing.md | 20 ++++++++++++++++++++ src/Catalog/ProductService.php | 12 ++++++++---- src/Catalog/ProductSort.php | 25 +++++++++++++++++++++++++ src/Search/ProductIndexer.php | 8 ++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 src/Catalog/ProductSort.php diff --git a/docs/product-listing.md b/docs/product-listing.md index 7d2397d..c417bdc 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -24,6 +24,7 @@ carry that full shape. ```php use Modules\Core\Catalog\ProductFilters; use Modules\Core\Catalog\ProductService; +use Modules\Core\Catalog\ProductSort; $service = app(ProductService::class); @@ -37,6 +38,10 @@ $result = $service->list( page: 1, ); +// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default +// relevance ordering (irrelevant here since the query is always empty). +$result = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); + $result['data']; // array of Meilisearch documents (plain arrays, not models) $result['meta']['total']; $result['meta']['per_page']; @@ -113,6 +118,21 @@ variants don't. --- +## Sorting + +`ProductSort` (`Modules\Core\Catalog\ProductSort`) is a fixed enum of supported sort orders — +`PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field +`Modules\Core\Search\ProductIndexer::getSortableFields()` marks sortable (`price`, plus +`created_at`/`updated_at`/`skus`/`status` inherited from Lunar's base indexer). Adding a new +`ProductSort` case requires adding the matching field to `getSortableFields()` and re-syncing (see +below) — sortable attributes are index settings, not computed per-query, same as filterable ones. + +Omitting `sort` leaves Meilisearch's default ordering, which is meaningless here since `list()` +always searches with an empty query string (`Product::search('')`) — there's no relevance score to +rank by, so results come back in whatever order the index returns them absent an explicit sort. + +--- + ## Registering the indexer Not automatic — an app opts in via its own `config/lunar/search.php`: diff --git a/src/Catalog/ProductService.php b/src/Catalog/ProductService.php index 5b1489e..6c7631b 100644 --- a/src/Catalog/ProductService.php +++ b/src/Catalog/ProductService.php @@ -22,12 +22,16 @@ class ProductService /** * @return array{data: array, meta: array} */ - public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1): array + public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): array { + $options = ['filter' => $this->buildFilter($filters)]; + + if ($sort !== null) { + $options['sort'] = [$sort->toMeilisearchSort()]; + } + $paginator = Product::search('') - ->options([ - 'filter' => $this->buildFilter($filters), - ]) + ->options($options) ->paginateRaw(perPage: $perPage, page: $page); return [ diff --git a/src/Catalog/ProductSort.php b/src/Catalog/ProductSort.php new file mode 100644 index 0000000..a36aea4 --- /dev/null +++ b/src/Catalog/ProductSort.php @@ -0,0 +1,25 @@ + 'price:asc', + self::PriceDesc => 'price:desc', + self::Newest => 'created_at:desc', + }; + } +} diff --git a/src/Search/ProductIndexer.php b/src/Search/ProductIndexer.php index 2e06194..de73393 100644 --- a/src/Search/ProductIndexer.php +++ b/src/Search/ProductIndexer.php @@ -56,6 +56,14 @@ class ProductIndexer extends BaseProductIndexer ]; } + public function getSortableFields(): array + { + return [ + ...parent::getSortableFields(), + 'price', + ]; + } + public function makeAllSearchableUsing(Builder $query): Builder { return parent::makeAllSearchableUsing($query)->with([ From ef9e9daab0aed2f65d5db40a838d9047ba02dd68 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 26 Aug 2026 18:34:26 +0300 Subject: [PATCH 019/110] Bump version to 0.5.4 --- CHANGELOG.md | 5 +++++ composer.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 587c43e..2dc5bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.5.4] - 2026-08-26 + +### Added +- `Modules\Core\Catalog\ProductService::list()` accepts a `sort` parameter (new `ProductSort` enum: `PriceAsc`, `PriceDesc`, `Newest`), translated into a Meilisearch `sort` clause — `list()` previously had no way to order results, since it always searches with an empty query string and so has no relevance score to fall back on. `Modules\Core\Search\ProductIndexer::getSortableFields()` now also marks `price` sortable (Lunar's base indexer only marks `created_at`/`updated_at`/`skus`/`status`). Requires re-syncing index settings (`php artisan lunar:meilisearch:setup`) on existing stores. Documented in `docs/product-listing.md` ("Sorting"). + ## [0.5.3] - 2026-08-26 ### Fixed diff --git a/composer.json b/composer.json index 0e8ed22..9dc2fcf 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.5.3", + "version": "0.5.4", "autoload": { "psr-4": { "Modules\\Core\\": "src/" From 0edf7b156d76d32371d8dbe9d3d67b15b6e2d9db Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 26 Aug 2026 23:52:33 +0300 Subject: [PATCH 020/110] Feature: Updating Product Service to handle multiple locales, fetching the correct locale, or, the default locale as fallback --- src/Catalog/ProductOptionTypeInterface.php | 34 +++++++++++ src/Catalog/ProductService.php | 22 +++++-- .../Listeners/FlushLanguageCache.php | 6 +- src/Localization/LocaleMiddleware.php | 30 +--------- src/Localization/Services/LanguageCache.php | 58 +++++++++++++++++++ 5 files changed, 115 insertions(+), 35 deletions(-) create mode 100644 src/Catalog/ProductOptionTypeInterface.php create mode 100644 src/Localization/Services/LanguageCache.php diff --git a/src/Catalog/ProductOptionTypeInterface.php b/src/Catalog/ProductOptionTypeInterface.php new file mode 100644 index 0000000..248459d --- /dev/null +++ b/src/Catalog/ProductOptionTypeInterface.php @@ -0,0 +1,34 @@ + + */ + public function getMetaForm(): array; +} diff --git a/src/Catalog/ProductService.php b/src/Catalog/ProductService.php index 6c7631b..f464d31 100644 --- a/src/Catalog/ProductService.php +++ b/src/Catalog/ProductService.php @@ -6,7 +6,7 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\App; use Lunar\Models\Product; -use Modules\Core\Localization\LocaleMiddleware; +use Modules\Core\Localization\Services\LanguageCache; /** * Storefront product listing/filtering AND single-product lookup, all reading directly @@ -19,6 +19,8 @@ use Modules\Core\Localization\LocaleMiddleware; */ class ProductService { + public function __construct(private readonly LanguageCache $languages) {} + /** * @return array{data: array, meta: array} */ @@ -80,10 +82,14 @@ class ProductService /** * 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. + * 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/description on /en/ rather than rendering + * blank. The raw per-locale keys are then stripped - every configured + * locale's translation is indexed in Meilisearch (Lunar's base indexer + * explodes every TranslatedText attribute into name_{locale}/ + * description_{locale} per store language), but once resolved into `name`/ + * `description`, callers only ever need the one that matched. * * Deliberately not config('app.locale') - App::setLocale() overwrites that * config value on every request, so by request time it's just whatever the @@ -92,11 +98,15 @@ class ProductService private function withLocalizedFields(array $product): array { $locale = App::getLocale(); - $fallbackLocale = LocaleMiddleware::defaultLocale(); + $fallbackLocale = $this->languages->defaultLocale(); $product['name'] = $product['name_'.$locale] ?? $product['name_'.$fallbackLocale] ?? null; $product['description'] = $product['description_'.$locale] ?? $product['description_'.$fallbackLocale] ?? null; + foreach ($this->languages->availableLocales() as $availableLocale) { + unset($product['name_'.$availableLocale], $product['description_'.$availableLocale]); + } + return $product; } diff --git a/src/Localization/Listeners/FlushLanguageCache.php b/src/Localization/Listeners/FlushLanguageCache.php index 575811c..e79016c 100644 --- a/src/Localization/Listeners/FlushLanguageCache.php +++ b/src/Localization/Listeners/FlushLanguageCache.php @@ -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(); } } diff --git a/src/Localization/LocaleMiddleware.php b/src/Localization/LocaleMiddleware.php index 5cd694d..edb1d58 100644 --- a/src/Localization/LocaleMiddleware.php +++ b/src/Localization/LocaleMiddleware.php @@ -6,19 +6,19 @@ 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,22 +45,6 @@ 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 locale and every OTHER available locale (each with its * own URL for the current page) with all views, so the header language @@ -120,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']), - ); - } } diff --git a/src/Localization/Services/LanguageCache.php b/src/Localization/Services/LanguageCache.php new file mode 100644 index 0000000..62f9b3e --- /dev/null +++ b/src/Localization/Services/LanguageCache.php @@ -0,0 +1,58 @@ + 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\ProductService::withLocalizedFields()), rather than + * hardcoding locale codes. + * + * @return array + */ + public function availableLocales(): array + { + return $this->all()->pluck('code')->all(); + } + + public function forget(): void + { + Cache::forget(self::CACHE_KEY); + } +} From b86fe788521b4582926532ddea84193c3e978ae8 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 00:34:39 +0300 Subject: [PATCH 021/110] Hotfix: Adding a correct media conversions to handle small review images --- src/Review/Models/ProductReview.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Review/Models/ProductReview.php b/src/Review/Models/ProductReview.php index 978c17e..1b94424 100644 --- a/src/Review/Models/ProductReview.php +++ b/src/Review/Models/ProductReview.php @@ -5,8 +5,11 @@ namespace Modules\Core\Review\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Lunar\Models\Product; +use Spatie\Image\Enums\BorderType; +use Spatie\Image\Enums\Fit; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; +use Spatie\MediaLibrary\MediaCollections\Models\Media; class ProductReview extends Model implements HasMedia { @@ -30,4 +33,23 @@ class ProductReview extends Model implements HasMedia { $this->addMediaCollection(self::IMAGES_COLLECTION); } + + /** + * Unlike Product/ProductVariant, this model sits outside Lunar's own + * MediaDefinitionsInterface (Lunar\Base\StandardMediaDefinitions), which is + * what registers the 'small' conversion those models get automatically. Without + * this, Modules\Core\Search\ProductIndexer::mapMedia() — shared across product, + * variant, and review media — throws Spatie\MediaLibrary\MediaCollections\ + * Exceptions\InvalidConversion the first time a review has an image, since + * $media->getUrl('small') has no matching conversion to resolve. + */ + public function registerMediaConversions(?Media $media = null): void + { + $this->addMediaConversion('small') + ->fit(Fit::Fill, 300, 300) + ->border(0, BorderType::Overlay, color: '#FFF') + ->background('#FFF') + ->sharpen(10) + ->keepOriginalImageFormat(); + } } From d01d27f7ea1b3ddc669747dcbdca44bd3b14b1fb Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 00:46:43 +0300 Subject: [PATCH 022/110] Hotfix: Correcting ProductResolver when importing Products --- .../JudgeMe/Resolvers/ProductResolver.php | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/MigrateImport/JudgeMe/Resolvers/ProductResolver.php b/src/MigrateImport/JudgeMe/Resolvers/ProductResolver.php index ad68d91..d1cf3b9 100644 --- a/src/MigrateImport/JudgeMe/Resolvers/ProductResolver.php +++ b/src/MigrateImport/JudgeMe/Resolvers/ProductResolver.php @@ -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; } } From b3b5ca740d52e980a685c8c7ff317deb39da75fe Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 00:48:38 +0300 Subject: [PATCH 023/110] Feature: Resolving translated fields based on current locale, while also resturning a correct length aware paginator for the results --- docs/product-listing.md | 53 ++++++++++++++++----- src/Catalog/ProductService.php | 86 ++++++++++++++++++++++------------ 2 files changed, 97 insertions(+), 42 deletions(-) diff --git a/docs/product-listing.md b/docs/product-listing.md index c417bdc..d52287a 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -28,11 +28,13 @@ use Modules\Core\Catalog\ProductSort; $service = app(ProductService::class); -// List everything, paginated -$result = $service->list(perPage: 24, page: 1); +// List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator, +// built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see +// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator. +$products = $service->list(perPage: 24, page: 1); // Filter by collection, brand, and/or price range -$result = $service->list( +$products = $service->list( filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0), perPage: 24, page: 1, @@ -40,13 +42,14 @@ $result = $service->list( // Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default // relevance ordering (irrelevant here since the query is always empty). -$result = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); +$products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); -$result['data']; // array of Meilisearch documents (plain arrays, not models) -$result['meta']['total']; -$result['meta']['per_page']; -$result['meta']['current_page']; -$result['meta']['last_page']; +$products->items(); // array of Meilisearch documents (plain arrays, not models) +$products->total(); +$products->perPage(); +$products->currentPage(); +$products->lastPage(); +$products->links(); // in a Blade view — renders pagination links as usual // Single product, by primary key $product = $service->getById(367); // array, or null if not found @@ -79,9 +82,8 @@ needs, listing and detail alike: | `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). | | `reviews`, `review_count`, `average_rating` | `Modules\Core\Review\Models\ProductReview` | See "Reviews" below. | -`description` and other translated attributes are indexed as-is, including any HTML markup -(e.g. from a Shopify `Body (HTML)` import) — **not stripped**. Any view rendering a description -sourced from `ProductService`'s results must treat it as trusted HTML. +`name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see +"Locale resolution" below for how `ProductService` resolves them down to one value per request. **`ProductOption`/`ProductOptionValue` names need a different translation accessor.** Unlike `Product`/`Collection`/`Brand`, their `name` is a plain locale-keyed array cast, not @@ -90,6 +92,33 @@ indexer's `translatedName()` reads the array directly instead. See `docs/lunar.m --- +## Locale resolution: `name`, `description`, and any other translated attribute + +Lunar's base `ScoutIndexer` explodes every `TranslatedText` attribute into one `{handle}_{locale}` +field per store language at index time (`name_el`, `name_en`, `description_el`, ... — and the same +for any custom translated attribute a store adds, e.g. `seo_title`/`seo_description`). Every raw +document in Meilisearch carries all of them side by side, since a document is written once but +read across many different-locale requests. + +`ProductService` resolves these back down to a single value per request. For every result it +returns (`list()`'s items, `getById()`, `getBySlug()`), it: + +1. Reads which `Product` attributes are `TranslatedText` from `Lunar\Base\AttributeManifest` — the + same source Lunar's own indexer reads — rather than a hardcoded `['name', 'description']` list, + so a store's own custom translated attributes are picked up automatically with no change here. +2. For each one, resolves `{handle}_{currentLocale}`, falling back to `{handle}_{storeDefaultLocale}` + (`LanguageCache::defaultLocale()`) if 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. +3. Assigns the result to a plain `{handle}` key and **strips every raw `{handle}_{locale}` key** — + callers only ever see `$product['name']`/`$product['seo_title']`/etc., never the per-locale + fields the index actually stores. + +`description` and other translated attributes are otherwise indexed as-is, including any HTML +markup (e.g. from a Shopify `Body (HTML)` import) — **not stripped**. Any view rendering a +description sourced from `ProductService`'s results must treat it as trusted HTML. + +--- + ## Reviews `Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product as diff --git a/src/Catalog/ProductService.php b/src/Catalog/ProductService.php index f464d31..802d4b1 100644 --- a/src/Catalog/ProductService.php +++ b/src/Catalog/ProductService.php @@ -2,9 +2,12 @@ namespace Modules\Core\Catalog; -use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\App; +use Lunar\Base\AttributeManifest; +use Lunar\FieldTypes\TranslatedText; use Lunar\Models\Product; use Modules\Core\Localization\Services\LanguageCache; @@ -19,12 +22,18 @@ use Modules\Core\Localization\Services\LanguageCache; */ class ProductService { - public function __construct(private readonly LanguageCache $languages) {} + public function __construct( + private readonly LanguageCache $languages, + private readonly AttributeManifest $attributes, + ) {} /** - * @return array{data: array, meta: array} + * Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result - + * see "Meilisearch driver quirk" below) so a controller/view gets normal + * pagination behaviour ($products->links(), JSON serialization, etc.) + * without ever touching the raw Meilisearch response directly. */ - public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): array + public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator { $options = ['filter' => $this->buildFilter($filters)]; @@ -36,17 +45,17 @@ class ProductService ->options($options) ->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(), - ], - ]; + $data = collect($this->hitsFrom($paginator)) + ->map(fn (array $product) => $this->withLocalizedFields($product)) + ->all(); + + return new LengthAwarePaginator( + items: $data, + total: $paginator->total(), + perPage: $paginator->perPage(), + currentPage: $paginator->currentPage(), + options: ['path' => LengthAwarePaginator::resolveCurrentPath()], + ); } /** @@ -80,16 +89,20 @@ class ProductService } /** - * 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 (LanguageCache::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. The raw per-locale keys are then stripped - every configured - * locale's translation is indexed in Meilisearch (Lunar's base indexer - * explodes every TranslatedText attribute into name_{locale}/ - * description_{locale} per store language), but once resolved into `name`/ - * `description`, callers only ever need the one that matched. + * 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 @@ -99,23 +112,36 @@ class ProductService { $locale = App::getLocale(); $fallbackLocale = $this->languages->defaultLocale(); + $availableLocales = $this->languages->availableLocales(); - $product['name'] = $product['name_'.$locale] ?? $product['name_'.$fallbackLocale] ?? null; - $product['description'] = $product['description_'.$locale] ?? $product['description_'.$fallbackLocale] ?? null; + foreach ($this->translatedAttributeHandles() as $handle) { + $product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null; - foreach ($this->languages->availableLocales() as $availableLocale) { - unset($product['name_'.$availableLocale], $product['description_'.$availableLocale]); + foreach ($availableLocales as $availableLocale) { + unset($product[$handle.'_'.$availableLocale]); + } } return $product; } + /** + * @return array + */ + private function translatedAttributeHandles(): array + { + return $this->attributes->getSearchableAttributes((new Product)->getMorphClass()) + ->filter(fn ($attribute) => $attribute->type === TranslatedText::class) + ->pluck('handle') + ->all(); + } + /** * For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response * (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the * actual documents are under the 'hits' key. */ - private function hitsFrom(LengthAwarePaginator $paginator): array + private function hitsFrom(LengthAwarePaginatorContract $paginator): array { $rawResponse = $paginator->items(); From ef356a639701cb1e940e1857232c7790def8a5e7 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 01:05:04 +0300 Subject: [PATCH 024/110] Feature: Products Restructuring to follow a strict rule --- docs/lunar.md | 4 ++-- docs/product-listing.md | 22 +++++++++---------- docs/product-search.md | 4 ++-- src/Localization/Services/LanguageCache.php | 4 ++-- .../DTOs}/ProductFilters.php | 8 +++---- .../Enums}/ProductSort.php | 9 ++++---- .../Services}/ProductIndexer.php | 8 +++---- .../Services}/ProductSearchService.php | 2 +- .../Services}/ProductService.php | 18 ++++++++------- src/Providers/ReviewServiceProvider.php | 4 ++-- src/Review/Models/ProductReview.php | 4 ++-- 11 files changed, 45 insertions(+), 42 deletions(-) rename src/{Catalog => Product/DTOs}/ProductFilters.php (63%) rename src/{Catalog => Product/Enums}/ProductSort.php (59%) rename src/{Search => Product/Services}/ProductIndexer.php (96%) rename src/{Search => Product/Services}/ProductSearchService.php (97%) rename src/{Catalog => Product/Services}/ProductService.php (89%) diff --git a/docs/lunar.md b/docs/lunar.md index 9e97e70..24a84ec 100644 --- a/docs/lunar.md +++ b/docs/lunar.md @@ -1206,6 +1206,6 @@ Real bugs/traps hit while building against Lunar in this package — not obvious - **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness. - **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead. - **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken. -- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Catalog\ProductService` / `docs/product-listing.md`. -- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Search\ProductIndexer::translatedName()`. +- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Product\Services\ProductService` / `docs/product-listing.md`. +- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Product\Services\ProductIndexer::translatedName()`. - **A running `queue:work` process does not pick up an edited/newly-added Scout indexer class.** It loads PHP classes once at boot and keeps them for the process's lifetime. Symptoms: reindexing commands succeed with no errors, calling `toSearchableArray()` directly (e.g. via `artisan tinker`, which always boots fresh) returns the new fields correctly, but documents written via `$model->searchable()` through the live queue are still missing them. Restart the queue worker after deploying an indexer change — no code fix needed. diff --git a/docs/product-listing.md b/docs/product-listing.md index d52287a..8a331ed 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -1,10 +1,10 @@ # Product Listing -`Modules\Core\Catalog\ProductService` provides catalog browsing/filtering AND single-product +`Modules\Core\Product\Services\ProductService` provides catalog browsing/filtering AND single-product lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the Meilisearch index rather than the database. One data source for everything this service does. -This is separate from `Modules\Core\Search\ProductSearchService` (see `product-search.md`), which +This is separate from `Modules\Core\Product\Services\ProductSearchService` (see `product-search.md`), which handles free-text query search. `ProductService` is for browsing/lookup without a search term. --- @@ -14,7 +14,7 @@ handles free-text query search. `ProductService` is for browsing/lookup without Every method here reads Meilisearch documents directly and returns plain arrays — never Scout's `->get()`, which would re-hydrate Eloquent models from the database. This means the index has to carry everything a detail page needs (variants, prices, options, media, reviews — see below), not -just the trimmed fields a listing page needs. `Modules\Core\Search\ProductIndexer` is built to +just the trimmed fields a listing page needs. `Modules\Core\Product\Services\ProductIndexer` is built to carry that full shape. --- @@ -22,9 +22,9 @@ carry that full shape. ## Usage ```php -use Modules\Core\Catalog\ProductFilters; -use Modules\Core\Catalog\ProductService; -use Modules\Core\Catalog\ProductSort; +use Modules\Core\Product\DTOs\ProductFilters; +use Modules\Core\Product\Services\ProductService; +use Modules\Core\Product\Enums\ProductSort; $service = app(ProductService::class); @@ -62,11 +62,11 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei --- -## Fields this depends on: `Modules\Core\Search\ProductIndexer` +## Fields this depends on: `Modules\Core\Product\Services\ProductIndexer` Lunar's own `Lunar\Search\ProductIndexer` only carries listing-grade fields (name, description, status, brand, a single thumbnail, skus) and marks just `__soft_deleted`, `skus`, `status` as -filterable. `Modules\Core\Search\ProductIndexer` extends it to add everything `ProductService` +filterable. `Modules\Core\Product\Services\ProductIndexer` extends it to add everything `ProductService` needs, listing and detail alike: | Field | Source | Notes | @@ -149,9 +149,9 @@ variants don't. ## Sorting -`ProductSort` (`Modules\Core\Catalog\ProductSort`) is a fixed enum of supported sort orders — +`ProductSort` (`Modules\Core\Product\Enums\ProductSort`) is a fixed enum of supported sort orders — `PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field -`Modules\Core\Search\ProductIndexer::getSortableFields()` marks sortable (`price`, plus +`Modules\Core\Product\Services\ProductIndexer::getSortableFields()` marks sortable (`price`, plus `created_at`/`updated_at`/`skus`/`status` inherited from Lunar's base indexer). Adding a new `ProductSort` case requires adding the matching field to `getSortableFields()` and re-syncing (see below) — sortable attributes are index settings, not computed per-query, same as filterable ones. @@ -168,7 +168,7 @@ Not automatic — an app opts in via its own `config/lunar/search.php`: ```php 'indexers' => [ - Lunar\Models\Product::class => Modules\Core\Search\ProductIndexer::class, + Lunar\Models\Product::class => Modules\Core\Product\Services\ProductIndexer::class, // ...other model indexers unchanged ], ``` diff --git a/docs/product-search.md b/docs/product-search.md index 8f3a98b..564c5a4 100644 --- a/docs/product-search.md +++ b/docs/product-search.md @@ -1,6 +1,6 @@ # Product Search -`Modules\Core\Search\ProductSearchService` provides locale-aware full-text product search on +`Modules\Core\Product\Services\ProductSearchService` provides locale-aware full-text product search on top of Laravel Scout + Meilisearch. --- @@ -24,7 +24,7 @@ merges `$builder->options` directly into the search request). ## Usage ```php -use Modules\Core\Search\ProductSearchService; +use Modules\Core\Product\Services\ProductSearchService; $results = app(ProductSearchService::class)->search('running shoes'); // or an explicit locale, bypassing App::getLocale(): diff --git a/src/Localization/Services/LanguageCache.php b/src/Localization/Services/LanguageCache.php index 62f9b3e..1e03b60 100644 --- a/src/Localization/Services/LanguageCache.php +++ b/src/Localization/Services/LanguageCache.php @@ -9,7 +9,7 @@ use Lunar\Models\Language; /** * Cached read layer over Lunar's `languages` table — the single source both * Modules\Core\Localization\LocaleMiddleware (request-time locale resolution) and - * any other locale-aware code (e.g. Modules\Core\Catalog\ProductService) read + * any other locale-aware code (e.g. Modules\Core\Product\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 @@ -41,7 +41,7 @@ class LanguageCache /** * 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\ProductService::withLocalizedFields()), rather than + * Modules\Core\Product\Services\ProductService::withLocalizedFields()), rather than * hardcoding locale codes. * * @return array diff --git a/src/Catalog/ProductFilters.php b/src/Product/DTOs/ProductFilters.php similarity index 63% rename from src/Catalog/ProductFilters.php rename to src/Product/DTOs/ProductFilters.php index 5701d01..e1257d8 100644 --- a/src/Catalog/ProductFilters.php +++ b/src/Product/DTOs/ProductFilters.php @@ -1,13 +1,13 @@ get() model hydration anywhere in this service. Callers get plain arrays of the - * indexed document, not Eloquent models. + * from the Meilisearch index (Modules\Core\Product\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\Search\ProductSearchService; - * this service is for browsing/filtering without a search term. + * Full-text query search lives separately in Modules\Core\Product\Services\ + * ProductSearchService; this service is for browsing/filtering without a search term. */ class ProductService { @@ -60,8 +62,8 @@ class ProductService /** * 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. + * all languages, see Modules\Core\Product\Services\ProductIndexer). Returns the full + * indexed product document, or null if no product has that slug. */ public function getBySlug(string $slug): ?array { diff --git a/src/Providers/ReviewServiceProvider.php b/src/Providers/ReviewServiceProvider.php index 0651a6c..c5a32e1 100644 --- a/src/Providers/ReviewServiceProvider.php +++ b/src/Providers/ReviewServiceProvider.php @@ -9,8 +9,8 @@ use Modules\Core\Review\Models\ProductReview; * Keeps a product's Meilisearch document in sync with its reviews. A review is * created/edited independently of its product (customer submission, staff reply), * so the product's own save/update events never fire for it — without this listener, - * Modules\Core\Search\ProductIndexer's review data would only refresh on the next - * full product reindex. + * Modules\Core\Product\Services\ProductIndexer's review data would only refresh on + * the next full product reindex. */ class ReviewServiceProvider extends ServiceProvider { diff --git a/src/Review/Models/ProductReview.php b/src/Review/Models/ProductReview.php index 1b94424..d2ab244 100644 --- a/src/Review/Models/ProductReview.php +++ b/src/Review/Models/ProductReview.php @@ -38,8 +38,8 @@ class ProductReview extends Model implements HasMedia * Unlike Product/ProductVariant, this model sits outside Lunar's own * MediaDefinitionsInterface (Lunar\Base\StandardMediaDefinitions), which is * what registers the 'small' conversion those models get automatically. Without - * this, Modules\Core\Search\ProductIndexer::mapMedia() — shared across product, - * variant, and review media — throws Spatie\MediaLibrary\MediaCollections\ + * this, Modules\Core\Product\Services\ProductIndexer::mapMedia() — shared across + * product, variant, and review media — throws Spatie\MediaLibrary\MediaCollections\ * Exceptions\InvalidConversion the first time a review has an image, since * $media->getUrl('small') has no matching conversion to resolve. */ From 594fa41527cbfca94175aac8ee1f32e41895a0d0 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 01:08:34 +0300 Subject: [PATCH 025/110] Feature: Localization Restructuring to follow a stricter pattern --- docs/localization.md | 8 ++++---- docs/product-search.md | 2 +- .../LanguageLineResource/Pages/CreateLanguageLine.php | 2 +- .../LanguageLineResource/Pages/EditLanguageLine.php | 2 +- src/Localization/{ => Middleware}/LocaleMiddleware.php | 2 +- .../{ => Observers}/LanguageCacheObserver.php | 2 +- src/Localization/Services/LanguageCache.php | 2 +- src/Localization/{ => Services}/TranslationReader.php | 2 +- src/Localization/{ => Services}/TranslationService.php | 2 +- src/Providers/LocalizationServiceProvider.php | 4 ++-- 10 files changed, 14 insertions(+), 14 deletions(-) rename src/Localization/{ => Middleware}/LocaleMiddleware.php (98%) rename src/Localization/{ => Observers}/LanguageCacheObserver.php (93%) rename src/Localization/{ => Services}/TranslationReader.php (93%) rename src/Localization/{ => Services}/TranslationService.php (97%) diff --git a/docs/localization.md b/docs/localization.md index 9a3bff9..4544a5c 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -50,7 +50,7 @@ fine — just keep admin/Livewire/webhook routes registered outside of it (as th ## Behavior -`Modules\Core\Localization\LocaleMiddleware`: +`Modules\Core\Localization\Middleware\LocaleMiddleware`: 1. Reads the first path segment (`request()->segment(1)`). 2. Matches it against `Lunar\Models\Language::code`. @@ -68,7 +68,7 @@ and invalidated automatically. Adding, editing, or removing a language via the F ### How invalidation is wired (event-driven, not the observer itself) -`Modules\Core\Localization\LanguageCacheObserver` observes `Lunar\Models\Language`'s +`Modules\Core\Localization\Observers\LanguageCacheObserver` observes `Lunar\Models\Language`'s `created`/`updated`/`deleted` Eloquent events, but it's a thin trigger only — it doesn't do any invalidation work itself. It dispatches one of three events from `Modules\Core\Localization\Events` (`LanguageCreated`, `LanguageUpdated` — carrying the old @@ -195,13 +195,13 @@ a third language automatically adds a third input, no resource changes needed. ### `TranslationService` — writes go through here, not the model directly -`Modules\Core\Localization\TranslationService` wraps create/update/delete on `LanguageLine` and +`Modules\Core\Localization\Services\TranslationService` wraps create/update/delete on `LanguageLine` and dispatches a domain event after each write, following this project's standard event-driven pattern (see `modules.md`'s "Splitting Service Providers" / event-listener convention — the same shape as `Modules\Core\Auth\Events\UserCreated`): ```php -use Modules\Core\Localization\TranslationService; +use Modules\Core\Localization\Services\TranslationService; app(TranslationService::class)->create('storefront', 'nav.wishlist', [ 'en' => 'Wishlist', diff --git a/docs/product-search.md b/docs/product-search.md index 564c5a4..5cb05a1 100644 --- a/docs/product-search.md +++ b/docs/product-search.md @@ -36,7 +36,7 @@ Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product` (`variants`, `brand`, `media`, etc.) are available on the results as normal. `$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by -`Modules\Core\Localization\LocaleMiddleware` (see `localization.md`), so callers in controllers +`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers don't need to pass it explicitly. --- diff --git a/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php b/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php index 7870fe2..8705623 100644 --- a/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php +++ b/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php @@ -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 { diff --git a/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php b/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php index 80c864a..02b9740 100644 --- a/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php +++ b/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php @@ -7,7 +7,7 @@ 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 diff --git a/src/Localization/LocaleMiddleware.php b/src/Localization/Middleware/LocaleMiddleware.php similarity index 98% rename from src/Localization/LocaleMiddleware.php rename to src/Localization/Middleware/LocaleMiddleware.php index edb1d58..da1352c 100644 --- a/src/Localization/LocaleMiddleware.php +++ b/src/Localization/Middleware/LocaleMiddleware.php @@ -1,6 +1,6 @@ Date: Thu, 27 Aug 2026 01:28:37 +0300 Subject: [PATCH 026/110] Fix: Correcting Language Line fallback resolver --- docs/localization.md | 18 +++++++++++ src/Localization/Models/LanguageLine.php | 32 +++++++++++++++++++ src/Providers/LocalizationServiceProvider.php | 11 +++++++ 3 files changed, 61 insertions(+) create mode 100644 src/Localization/Models/LanguageLine.php diff --git a/docs/localization.md b/docs/localization.md index 4544a5c..1a1fd5a 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -178,6 +178,24 @@ namespaced groups so nothing collides. `__()` resolves the translation for whate `App::getLocale()` currently is, which `LocaleMiddleware` already sets per-request (see "Behavior" above) — no extra wiring needed between the two systems. +### Fallback locale follows the store's default language, not `config('app.fallback_locale')` + +`spatie/laravel-translation-loader`'s stock `LanguageLine::getTranslation()` falls back to +`config('app.fallback_locale')` — a static `.env` value — when a key has no text for the current +locale. That's a second, disconnected "default language" concept: an admin changing the default +language via the Filament **Languages** resource has no effect on it, so an untranslated label +could silently fall back to the wrong language. + +`Modules\Core\Localization\Models\LanguageLine` overrides `getTranslation()` to fall back to +`LanguageCache::defaultLocale()` instead — the same `languages.default` flag `LocaleMiddleware` +already treats as the single source of truth. It's swapped in via +`config('translation-loader.model')` (the package's own documented extension point for +"any model that extends `LanguageLine`"), set in `LocalizationServiceProvider::register()` so it +wins regardless of provider boot order (Laravel's `mergeConfigFrom()` only fills in config keys +not already set, so an explicit `register()`-time set always beats the package's own default). +No consuming app configuration needed — this is automatic once `LocalizationServiceProvider` is +registered. + ### Seeding A starter set of common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`, diff --git a/src/Localization/Models/LanguageLine.php b/src/Localization/Models/LanguageLine.php new file mode 100644 index 0000000..17f4766 --- /dev/null +++ b/src/Localization/Models/LanguageLine.php @@ -0,0 +1,32 @@ +text[$locale])) { + return $this->text[$locale]; + } + + $fallback = app(LanguageCache::class)->defaultLocale(); + + return $fallback !== null ? ($this->text[$fallback] ?? null) : null; + } +} diff --git a/src/Providers/LocalizationServiceProvider.php b/src/Providers/LocalizationServiceProvider.php index c78bd96..112d481 100644 --- a/src/Providers/LocalizationServiceProvider.php +++ b/src/Providers/LocalizationServiceProvider.php @@ -16,10 +16,21 @@ use Modules\Core\Localization\Listeners\FlushTranslationCache; use Modules\Core\Localization\Listeners\LogTranslationActivity; use Modules\Core\Localization\Listeners\MigrateTranslationsForRenamedLanguage; use Modules\Core\Localization\Middleware\LocaleMiddleware; +use Modules\Core\Localization\Models\LanguageLine; use Modules\Core\Localization\Observers\LanguageCacheObserver; class LocalizationServiceProvider extends ServiceProvider { + public function register(): void + { + // Must run before Spatie\TranslationLoader\TranslationServiceProvider's + // register() merges its own config defaults - mergeConfigFrom() only fills + // in keys not already set, so setting this here (regardless of provider + // boot order) makes it win over the package's default + // Spatie\TranslationLoader\LanguageLine::class. + config(['translation-loader.model' => LanguageLine::class]); + } + public function boot(): void { $this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class); From eef473dbd23dda060976b45121b4aefc6b2a1509 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 01:55:58 +0300 Subject: [PATCH 027/110] Feature: Product option types to support multiple cases --- config/core.php | 19 ++++ docs/product-options.md | 105 ++++++++++++++++++ src/CorePlugin.php | 6 + .../Contracts}/ProductOptionTypeInterface.php | 12 +- .../ProductOptionResourceExtension.php | 39 +++++++ .../ValuesRelationManagerExtension.php | 34 ++++++ .../ProductOptionReindexObserver.php | 68 ++++++++++++ src/Product/OptionTypes/ColorOptionType.php | 28 +++++ .../Services/ProductOptionTypeManager.php | 40 +++++++ src/Providers/ProductServiceProvider.php | 22 ++++ 10 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 docs/product-options.md rename src/{Catalog => Product/Contracts}/ProductOptionTypeInterface.php (71%) create mode 100644 src/Product/Filament/Extensions/ProductOptionResourceExtension.php create mode 100644 src/Product/Filament/Extensions/ValuesRelationManagerExtension.php create mode 100644 src/Product/Observers/ProductOptionReindexObserver.php create mode 100644 src/Product/OptionTypes/ColorOptionType.php create mode 100644 src/Product/Services/ProductOptionTypeManager.php create mode 100644 src/Providers/ProductServiceProvider.php diff --git a/config/core.php b/config/core.php index 5e0f027..bcb16f1 100644 --- a/config/core.php +++ b/config/core.php @@ -16,4 +16,23 @@ return [ 'auto_create_customer_for_user' => true, + /* + |-------------------------------------------------------------------------- + | Product Option Types + |-------------------------------------------------------------------------- + | + | Enabled `Modules\Core\Product\Contracts\ProductOptionTypeInterface` + | implementations, describing what structured data a ProductOption's + | values carry in their `meta` jsonb column, and how an admin edits it. + | An admin picks one per ProductOption from a dropdown built from this + | list (stored in ProductOption::meta, not tied to the option's handle) — + | a ProductOption with none selected has no described meta behavior, + | plain name/position only. + | + | \App\ProductOptions\ColorOptionType::class, + | + */ + + 'product_option_types' => [], + ]; diff --git a/docs/product-options.md b/docs/product-options.md new file mode 100644 index 0000000..f1043fb --- /dev/null +++ b/docs/product-options.md @@ -0,0 +1,105 @@ +# Product Option Types + +Lunar's `ProductOption`/`ProductOptionValue` are generic by design — a "Color" option +and a "Size" option are both just a handle, a translated name, and a list of values. +Each `ProductOptionValue` carries a free-form `meta` jsonb column, but nothing in +Lunar's own admin UI exposes it — there's no way for an admin to, say, attach a hex +code to a "Red" value without editing the database directly. + +`Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category +of option behaves — what structured data its values carry in `meta`, and how an +admin edits that data — without introducing a new model. `ProductOption`/ +`ProductOptionValue` stay exactly as Lunar defines them. + +--- + +## Registering a type + +A shop enables a type class in `config/core.php`: + +```php +// config/core.php +'product_option_types' => [ + \App\ProductOptions\ColorOptionType::class, +], +``` + +This is a plain list, **not** keyed by `ProductOption::handle` — a shop's own handle +naming (transliterated Greek, legacy import slugs, whatever an admin happened to type +when creating the option) shouldn't have to match a type's key. Instead, an admin +picks a type per-option from a dropdown on the `ProductOption` edit form itself (see +below); the choice is stored in `ProductOption::meta['option_type']`, not inferred +from anything else. + +A `ProductOption` with no type selected behaves exactly as stock Lunar does — plain +name/position, no extra meta form. + +--- + +## Writing a type + +```php +namespace App\ProductOptions; + +use Filament\Forms\Components\ColorPicker; +use Modules\Core\Product\Contracts\ProductOptionTypeInterface; + +class ColorOptionType implements ProductOptionTypeInterface +{ + public static function getKey(): string + { + return 'color'; + } + + public function getMetaForm(): array + { + return [ + ColorPicker::make('meta.hex') + ->label('Color') + ->required(), + ]; + } +} +``` + +`getMetaForm()` returns Filament form components, keyed under `meta.*` dot notation +— the path they save to on `ProductOptionValue::meta` (cast as `AsArrayObject`, a +plain jsonb column). `getKey()` is the identifier used in the admin's "Option Type" +dropdown and in `ProductOption::meta['option_type']` — it has no relationship to the +`ProductOption::handle`. + +A reference implementation ships at `Modules\Core\Product\OptionTypes\ColorOptionType` +— not auto-registered, since registration is always an explicit shop decision. + +--- + +## How it's wired into the admin UI + +`Modules\Core\Product\Services\ProductOptionTypeManager`: +- `all(): Collection` — every enabled type, + keyed by `getKey()`. +- `resolve(?string $key): ?ProductOptionTypeInterface` — looks up one by key (or + `null` if no key / not found). + +Two extensions hook into Lunar's admin via its extension system +(`LunarPanel::extensions([...])`, registered in `CorePlugin`) — no forking of Lunar's +classes needed: + +- `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension` extends + `Lunar\Admin\Filament\Resources\ProductOptionResource`'s own form with a `Select` + (`meta.option_type`) listing every enabled type's key. Shown only when at least one + type is enabled. +- `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` extends + the "Values" tab's form. Its `extendForm()` reads + `$option->meta['option_type']` off the owning `ProductOption`, resolves it via + `ProductOptionTypeManager`, and appends `getMetaForm()`'s fields to the stock name + field. A `ProductOption` with no type selected gets the stock form unchanged. + +--- + +## Reading the value back + +Storefront code reads `ProductOptionValue::meta` like any other jsonb column — e.g. +`$value->meta['hex']` for a color swatch. `ProductOptionTypeManager` is an admin-side +concern only (describing *how to edit* the meta); nothing requires the storefront to +go through it to *read* the meta. diff --git a/src/CorePlugin.php b/src/CorePlugin.php index ef0bd4a..00582c2 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -6,6 +6,8 @@ use Filament\Contracts\Plugin; use Filament\Panel; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Mail; +use Lunar\Admin\Filament\Resources\ProductOptionResource; +use Lunar\Admin\Filament\Resources\ProductOptionResource\RelationManagers\ValuesRelationManager; use Lunar\Admin\Filament\Resources\ProductResource; use Lunar\Admin\Filament\Resources\StaffResource; use Lunar\Admin\Models\Staff as LunarStaff; @@ -16,6 +18,8 @@ use Modules\Core\Auth\Extensions\StaffResourceExtension; use Modules\Core\Auth\Filament\Pages\Login; use Modules\Core\Auth\Mail\InviteMail; use Modules\Core\Localization\Filament\Resources\LanguageLineResource; +use Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension; +use Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Review\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; @@ -41,6 +45,8 @@ class CorePlugin implements Plugin LunarPanel::extensions([ StaffResource::class => StaffResourceExtension::class, ProductResource::class => ProductResourceExtension::class, + ProductOptionResource::class => ProductOptionResourceExtension::class, + ValuesRelationManager::class => ValuesRelationManagerExtension::class, ]); Product::macro('reviews', function (): HasMany { diff --git a/src/Catalog/ProductOptionTypeInterface.php b/src/Product/Contracts/ProductOptionTypeInterface.php similarity index 71% rename from src/Catalog/ProductOptionTypeInterface.php rename to src/Product/Contracts/ProductOptionTypeInterface.php index 248459d..e14d1e0 100644 --- a/src/Catalog/ProductOptionTypeInterface.php +++ b/src/Product/Contracts/ProductOptionTypeInterface.php @@ -1,6 +1,6 @@ */ diff --git a/src/Product/Filament/Extensions/ProductOptionResourceExtension.php b/src/Product/Filament/Extensions/ProductOptionResourceExtension.php new file mode 100644 index 0000000..0c9635f --- /dev/null +++ b/src/Product/Filament/Extensions/ProductOptionResourceExtension.php @@ -0,0 +1,39 @@ +all() + ->keys() + ->mapWithKeys(fn (string $key) => [$key => Str::headline($key)]) + ->all(); + + if ($options === []) { + return $form; + } + + return $form->schema([ + ...$form->getComponents(), + Select::make('meta.option_type') + ->label('Option Type') + ->options($options) + ->helperText('Controls which meta fields appear when editing this option\'s values.') + ->native(false), + ]); + } +} diff --git a/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php b/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php new file mode 100644 index 0000000..17833a4 --- /dev/null +++ b/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php @@ -0,0 +1,34 @@ +caller->getOwnerRecord(); + + $type = app(ProductOptionTypeManager::class)->resolve($option->meta['option_type'] ?? null); + + if ($type === null) { + return $form; + } + + return $form->schema([ + ...$form->getComponents(), + ...$type->getMetaForm(), + ]); + } +} diff --git a/src/Product/Observers/ProductOptionReindexObserver.php b/src/Product/Observers/ProductOptionReindexObserver.php new file mode 100644 index 0000000..e34b1ca --- /dev/null +++ b/src/Product/Observers/ProductOptionReindexObserver.php @@ -0,0 +1,68 @@ +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(); + } +} diff --git a/src/Product/OptionTypes/ColorOptionType.php b/src/Product/OptionTypes/ColorOptionType.php new file mode 100644 index 0000000..e4c6fec --- /dev/null +++ b/src/Product/OptionTypes/ColorOptionType.php @@ -0,0 +1,28 @@ +label('Color') + ->required(), + ]; + } +} diff --git a/src/Product/Services/ProductOptionTypeManager.php b/src/Product/Services/ProductOptionTypeManager.php new file mode 100644 index 0000000..258a900 --- /dev/null +++ b/src/Product/Services/ProductOptionTypeManager.php @@ -0,0 +1,40 @@ + keyed by getKey() + */ + public function all(): Collection + { + return collect(config('core.product_option_types', [])) + ->map(fn (string $class) => app($class)) + ->keyBy(fn (ProductOptionTypeInterface $type) => $type::getKey()); + } + + public function resolve(?string $key): ?ProductOptionTypeInterface + { + if ($key === null) { + return null; + } + + return $this->all()->get($key); + } +} diff --git a/src/Providers/ProductServiceProvider.php b/src/Providers/ProductServiceProvider.php new file mode 100644 index 0000000..b45cd56 --- /dev/null +++ b/src/Providers/ProductServiceProvider.php @@ -0,0 +1,22 @@ + $observer->optionSaved($option)); + ProductOption::deleted(fn (ProductOption $option) => $observer->optionDeleted($option)); + + ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value)); + ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value)); + } +} From cb3fe095d9a781418b3e9c6694fd743b455d70c4 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 01:57:43 +0300 Subject: [PATCH 028/110] Bump Version to 0.6.0 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ composer.json | 3 ++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc5bfc..261da9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.6.0] - 2026-08-27 + +### Added +- `Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category of `Lunar\Models\ProductOption` (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form `meta` jsonb column, and how an admin edits it via Filament — without introducing a new model. Enabled per-shop as a plain list in `config('core.product_option_types')`; an admin then picks one per `ProductOption` from a "Option Type" dropdown on the option's own edit form (added by `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension`), stored in `ProductOption::meta['option_type']` — deliberately not tied to the option's `handle`, since a shop's own handle naming shouldn't have to match a type's key. `Modules\Core\Product\Services\ProductOptionTypeManager` resolves the selected key to its type (`all()`/`resolve()`). `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` hooks Lunar's own `ValuesRelationManager` (both extensions via `LunarPanel::extensions()`, registered in `CorePlugin`) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, `Modules\Core\Product\OptionTypes\ColorOptionType` (not auto-registered). Documented in `docs/product-options.md`. +- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the *product's* own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each. +- `Modules\Core\Localization\Models\LanguageLine` extends `spatie/laravel-translation-loader`'s `LanguageLine` to fall back to the store's actual default language (`LanguageCache::defaultLocale()`, backed by Lunar's `languages.default` flag) instead of the package's stock behavior of falling back to the static `config('app.fallback_locale')` — the two were previously disconnected, so changing the default language via the Filament **Languages** resource had no effect on which locale an untranslated storefront label silently fell back to. Swapped in automatically via `config('translation-loader.model')` in `LocalizationServiceProvider::register()`; no consuming app changes needed. Documented in `docs/localization.md` ("Fallback locale follows the store's default language"). + +### Changed +- **Breaking:** `Modules\Core\Catalog\ProductService::list()` now returns a real `Illuminate\Pagination\LengthAwarePaginator` (built from the localized Meilisearch hits) instead of a plain `array{data, meta}` — gives callers normal Laravel pagination behaviour (`$products->links()`, standard JSON serialization) without ever touching Scout's raw `paginateRaw()` response directly. `getById()`/`getBySlug()` are unaffected (still return `?array`). +- `ProductService::withLocalizedFields()` (used by `list()`, `getById()`, `getBySlug()`) no longer hardcodes `name`/`description` as the only translated fields — it now reads every `TranslatedText` attribute on `Product` from `Lunar\Base\AttributeManifest` (the same source Lunar's own indexer reads), so a store's own custom translated attributes (e.g. `seo_title`, `seo_description`) are resolved and locale-stripped automatically with no code change here. Raw `{handle}_{locale}` keys (e.g. `name_el`, `seo_title_en`) are now stripped from every returned product, not just `name_*`/`description_*`. +- Extracted `Modules\Core\Localization\Services\LanguageCache` (cached read layer over Lunar's `languages` table: `all()`, `defaultLocale()`, `availableLocales()`, `forget()`) out of `LocaleMiddleware`, which previously owned this as private/static methods despite not being middleware-specific behavior. `LocaleMiddleware` now takes `LanguageCache` via constructor injection. `LocaleMiddleware::defaultLocale()`/`forgetLanguagesCache()` (static) are removed — use `app(LanguageCache::class)` or inject `LanguageCache` directly. + +### Fixed +- `Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver::resolve()` picked whichever `lunar_urls` row matched a slug first, which can be a soft-deleted product left behind by an earlier import batch rather than the current live one — a store can easily end up with more than one `Product` row sharing the same slug across re-imports, since a soft-deleted product's URL row isn't cleaned up. This silently broke every downstream lookup for that handle (e.g. `Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter` logging "no product found for handle, skipping review" and dropping the row, even though a live product with that exact handle existed). Rewrote as a join against `lunar_products` — via `Product::query()`, so Eloquent's `SoftDeletes` global scope excludes trashed rows — so only a URL pointing at a live product resolves. +- `Modules\Core\Review\Models\ProductReview` had no `registerMediaConversions()` at all, unlike `Product`/`ProductVariant` which get one automatically from Lunar's own `Lunar\Base\StandardMediaDefinitions`. `Modules\Core\Search\ProductIndexer::mapMedia()` is shared across product, variant, and review media and always requests the `small` conversion — the first time a review had an attached image, indexing it threw `Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidConversion`, silently failing the product's `MakeSearchable` queue job (and everything queued after it, since Scout batches). Added a matching `small` conversion (300×300, same fit/border/background as Lunar's standard one) directly on `ProductReview`. + +### Breaking +- Merged `Modules\Core\Catalog` and `Modules\Core\Search` into a single `Modules\Core\Product` concern, since both existed purely to serve `Product` (browsing/filtering vs. indexing/full-text search — two services, one concern), following a stricter subfolder convention (`Contracts/`, `Enums/`, `Services/`, `DTOs/`, `Models/`, etc. per concern) going forward: + - `Modules\Core\Catalog\ProductService` → `Modules\Core\Product\Services\ProductService` + - `Modules\Core\Catalog\ProductFilters` → `Modules\Core\Product\DTOs\ProductFilters` + - `Modules\Core\Catalog\ProductSort` → `Modules\Core\Product\Enums\ProductSort` + - `Modules\Core\Search\ProductIndexer` → `Modules\Core\Product\Services\ProductIndexer` + - `Modules\Core\Search\ProductSearchService` → `Modules\Core\Product\Services\ProductSearchService` + + Consuming apps must update any direct references — notably `config/lunar/search.php`'s `'indexers'` map, which points at `ProductIndexer` by FQCN. `Modules\Core\Catalog\ProductOptionTypeInterface` (in-progress, not yet wired to anything) was deliberately left in place rather than moved. +- Reorganized `Modules\Core\Localization` under the same stricter per-concern subfolder convention — `Events/`, `Filament/`, `Listeners/` were already correctly categorized; four loose root files moved into typed buckets by structural role: + - `Modules\Core\Localization\LocaleMiddleware` → `Modules\Core\Localization\Middleware\LocaleMiddleware` + - `Modules\Core\Localization\LanguageCacheObserver` → `Modules\Core\Localization\Observers\LanguageCacheObserver` + - `Modules\Core\Localization\TranslationReader` → `Modules\Core\Localization\Services\TranslationReader` + - `Modules\Core\Localization\TranslationService` → `Modules\Core\Localization\Services\TranslationService` + + `Modules\Core\Localization\Services\LanguageCache` (added earlier in this same unreleased version) already lived at its correct final path — unaffected. The `'locale'` route-middleware alias (registered in `LocalizationServiceProvider`) is unaffected for consuming apps using it by string alias rather than FQCN. + ## [0.5.4] - 2026-08-26 ### Added diff --git a/composer.json b/composer.json index 9dc2fcf..8822380 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.5.4", + "version": "0.6.0", "autoload": { "psr-4": { "Modules\\Core\\": "src/" @@ -36,6 +36,7 @@ "Modules\\Core\\Providers\\AuthServiceProvider", "Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\LocalizationServiceProvider", + "Modules\\Core\\Providers\\ProductServiceProvider", "Modules\\Core\\Providers\\ReviewServiceProvider" ] } From e95ea4a43cac2bf4bde3233c9b13f4d1f9488bbc Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 11:14:56 +0300 Subject: [PATCH 029/110] Fix: Updating Product Options Registry and adding handle to the meilisearch index --- config/core.php | 19 ------- docs/product-options.md | 42 ++++++++------ .../ProductOptionResourceExtension.php | 2 +- .../ValuesRelationManagerExtension.php | 2 +- src/Product/OptionTypes/ColorOptionType.php | 7 ++- src/Product/Services/ProductIndexer.php | 1 + .../Services/ProductOptionTypeManager.php | 56 ++++++++++++++----- src/Providers/ProductServiceProvider.php | 6 ++ 8 files changed, 80 insertions(+), 55 deletions(-) diff --git a/config/core.php b/config/core.php index bcb16f1..5e0f027 100644 --- a/config/core.php +++ b/config/core.php @@ -16,23 +16,4 @@ return [ 'auto_create_customer_for_user' => true, - /* - |-------------------------------------------------------------------------- - | Product Option Types - |-------------------------------------------------------------------------- - | - | Enabled `Modules\Core\Product\Contracts\ProductOptionTypeInterface` - | implementations, describing what structured data a ProductOption's - | values carry in their `meta` jsonb column, and how an admin edits it. - | An admin picks one per ProductOption from a dropdown built from this - | list (stored in ProductOption::meta, not tied to the option's handle) — - | a ProductOption with none selected has no described meta behavior, - | plain name/position only. - | - | \App\ProductOptions\ColorOptionType::class, - | - */ - - 'product_option_types' => [], - ]; diff --git a/docs/product-options.md b/docs/product-options.md index f1043fb..9b9a436 100644 --- a/docs/product-options.md +++ b/docs/product-options.md @@ -15,21 +15,23 @@ admin edits that data — without introducing a new model. `ProductOption`/ ## Registering a type -A shop enables a type class in `config/core.php`: +A shop registers a type class from its own service provider's `boot()`, the same +shape as `Modules\Core\Notification\NotificationRegistry`: ```php -// config/core.php -'product_option_types' => [ +use Modules\Core\Product\Services\ProductOptionTypeManager; + +ProductOptionTypeManager::get()->register([ \App\ProductOptions\ColorOptionType::class, -], +]); ``` -This is a plain list, **not** keyed by `ProductOption::handle` — a shop's own handle -naming (transliterated Greek, legacy import slugs, whatever an admin happened to type -when creating the option) shouldn't have to match a type's key. Instead, an admin -picks a type per-option from a dropdown on the `ProductOption` edit form itself (see -below); the choice is stored in `ProductOption::meta['option_type']`, not inferred -from anything else. +Not a published config array — the mapping isn't per-`ProductOption`, so there's +nothing for a shop to *key* by. Instead, an admin picks a type per-option from a +dropdown on the `ProductOption` edit form itself (see below); the choice is stored +in `ProductOption::meta['option_type']`, deliberately **not** tied to the option's +`handle` (a shop's own handle naming — transliterated Greek, legacy import slugs — +shouldn't have to match a type's key). A `ProductOption` with no type selected behaves exactly as stock Lunar does — plain name/position, no extra meta form. @@ -68,18 +70,24 @@ plain jsonb column). `getKey()` is the identifier used in the admin's "Option Ty dropdown and in `ProductOption::meta['option_type']` — it has no relationship to the `ProductOption::handle`. -A reference implementation ships at `Modules\Core\Product\OptionTypes\ColorOptionType` -— not auto-registered, since registration is always an explicit shop decision. +A reference implementation ships at `Modules\Core\Product\OptionTypes\ColorOptionType`, +registered automatically by `Modules\Core\Providers\ProductServiceProvider` — no shop +setup needed for it to appear in the "Option Type" dropdown, though an admin still +has to pick it per-`ProductOption` for it to take effect. --- ## How it's wired into the admin UI -`Modules\Core\Product\Services\ProductOptionTypeManager`: -- `all(): Collection` — every enabled type, - keyed by `getKey()`. -- `resolve(?string $key): ?ProductOptionTypeInterface` — looks up one by key (or - `null` if no key / not found). +`Modules\Core\Product\Services\ProductOptionTypeManager` is a singleton registry: +- `get(): static` — the shared instance. +- `register(array $types): void` — registers one or more type classes, keyed + internally by `getKey()`. +- `unregister(string $key): void` +- `resolve(?string $key): ?ProductOptionTypeInterface` — looks up a registered type + by key (or `null` if no key / not found). +- `all(): array` — every registered type's class, keyed by + `getKey()`. Two extensions hook into Lunar's admin via its extension system (`LunarPanel::extensions([...])`, registered in `CorePlugin`) — no forking of Lunar's diff --git a/src/Product/Filament/Extensions/ProductOptionResourceExtension.php b/src/Product/Filament/Extensions/ProductOptionResourceExtension.php index 0c9635f..8b2f500 100644 --- a/src/Product/Filament/Extensions/ProductOptionResourceExtension.php +++ b/src/Product/Filament/Extensions/ProductOptionResourceExtension.php @@ -18,7 +18,7 @@ class ProductOptionResourceExtension extends ResourceExtension { public function extendForm(Form $form): Form { - $options = app(ProductOptionTypeManager::class)->all() + $options = collect(ProductOptionTypeManager::get()->all()) ->keys() ->mapWithKeys(fn (string $key) => [$key => Str::headline($key)]) ->all(); diff --git a/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php b/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php index 17833a4..6fdcaa5 100644 --- a/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php +++ b/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php @@ -20,7 +20,7 @@ class ValuesRelationManagerExtension extends RelationManagerExtension /** @var ProductOption $option */ $option = $this->caller->getOwnerRecord(); - $type = app(ProductOptionTypeManager::class)->resolve($option->meta['option_type'] ?? null); + $type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null); if ($type === null) { return $form; diff --git a/src/Product/OptionTypes/ColorOptionType.php b/src/Product/OptionTypes/ColorOptionType.php index e4c6fec..06b70d5 100644 --- a/src/Product/OptionTypes/ColorOptionType.php +++ b/src/Product/OptionTypes/ColorOptionType.php @@ -6,9 +6,10 @@ use Filament\Forms\Components\ColorPicker; use Modules\Core\Product\Contracts\ProductOptionTypeInterface; /** - * Reference implementation: describes a 'color' ProductOption's values as - * carrying a hex code in `meta.hex`, editable via a Filament color picker. - * Not auto-registered — a shop opts in via config('core.product_option_types'). + * 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\ProductServiceProvider` — a shop's admin still has to + * pick "Color" from the Option Type dropdown per-ProductOption for it to apply. */ class ColorOptionType implements ProductOptionTypeInterface { diff --git a/src/Product/Services/ProductIndexer.php b/src/Product/Services/ProductIndexer.php index 7928151..68a15f7 100644 --- a/src/Product/Services/ProductIndexer.php +++ b/src/Product/Services/ProductIndexer.php @@ -112,6 +112,7 @@ class ProductIndexer extends BaseProductIndexer 'purchasable' => $variant->purchasable, 'options' => $variant->values->map(fn ($value) => [ 'option' => $this->translatedName($value->option->name), + 'handle' => $value->option->handle, 'value' => $this->translatedName($value->name), 'meta' => $value->meta, ])->all(), diff --git a/src/Product/Services/ProductOptionTypeManager.php b/src/Product/Services/ProductOptionTypeManager.php index 258a900..1d73ee7 100644 --- a/src/Product/Services/ProductOptionTypeManager.php +++ b/src/Product/Services/ProductOptionTypeManager.php @@ -2,7 +2,6 @@ namespace Modules\Core\Product\Services; -use Illuminate\Support\Collection; use Modules\Core\Product\Contracts\ProductOptionTypeInterface; /** @@ -12,29 +11,58 @@ use Modules\Core\Product\Contracts\ProductOptionTypeInterface; * 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. * - * The available keys come from `config('core.product_option_types')` — a plain list, - * not a config array, because the mapping from option to type is an admin's per-option - * choice made in the UI (see ValuesRelationManagerExtension/ProductOptionResourceExtension), - * not something config alone can express. + * 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 { - /** - * @return Collection keyed by getKey() - */ - public function all(): Collection + private static ?self $instance = null; + + /** @var array> */ + private array $types = []; + + private function __construct() {} + + public static function get(): static { - return collect(config('core.product_option_types', [])) - ->map(fn (string $class) => app($class)) - ->keyBy(fn (ProductOptionTypeInterface $type) => $type::getKey()); + if (static::$instance === null) { + static::$instance = new static(); + } + + return static::$instance; + } + + /** + * @param array> $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) { + if ($key === null || ! isset($this->types[$key])) { return null; } - return $this->all()->get($key); + return app($this->types[$key]); + } + + /** + * @return array> + */ + public function all(): array + { + return $this->types; } } diff --git a/src/Providers/ProductServiceProvider.php b/src/Providers/ProductServiceProvider.php index b45cd56..712d9ec 100644 --- a/src/Providers/ProductServiceProvider.php +++ b/src/Providers/ProductServiceProvider.php @@ -6,11 +6,17 @@ use Illuminate\Support\ServiceProvider; use Lunar\Models\ProductOption; use Lunar\Models\ProductOptionValue; use Modules\Core\Product\Observers\ProductOptionReindexObserver; +use Modules\Core\Product\OptionTypes\ColorOptionType; +use Modules\Core\Product\Services\ProductOptionTypeManager; class ProductServiceProvider extends ServiceProvider { public function boot(): void { + ProductOptionTypeManager::get()->register([ + ColorOptionType::class, + ]); + $observer = new ProductOptionReindexObserver; ProductOption::saved(fn (ProductOption $option) => $observer->optionSaved($option)); From e4342da44af74e003007376835b570a3177d13dc Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 11:32:20 +0300 Subject: [PATCH 030/110] Fix: Correcting shape of indexed products --- docs/product-listing.md | 16 ++++++++-------- src/Product/Services/ProductIndexer.php | 24 ++++++++++++++---------- src/Product/Services/ProductService.php | 2 +- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/docs/product-listing.md b/docs/product-listing.md index 8a331ed..2da31b3 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -72,15 +72,14 @@ needs, listing and detail alike: | Field | Source | Notes | |---|---|---| | `id` | — | Newly marked **filterable** — needed for `getById()`'s `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. | -| `collections` | `$product->collections->pluck('id')` | Filterable. Array of collection IDs (as strings) — filtering matches by ID, not slug. | -| `collection_names` | `$product->collections` | Display only, not filterable — translated collection names. | +| `collections` | `$product->collections` | Array of `{id, name}` — `name` is the translated collection name. Filterable on the nested field `collections.id`, not `collections` itself. | | `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. | | `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. | | `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. | | `tags` | `$product->tags->pluck('value')` | Display only. | | `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. | | `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). | -| `reviews`, `review_count`, `average_rating` | `Modules\Core\Review\Models\ProductReview` | See "Reviews" below. | +| `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. | `name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see "Locale resolution" below for how `ProductService` resolves them down to one value per request. @@ -121,11 +120,12 @@ description sourced from `ProductService`'s results must treat it as trusted HTM ## Reviews -`Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product as -`reviews` (array), plus `review_count` and `average_rating` (rounded to 1 decimal, `null` if the -product has no reviews). Only public-safe fields are included — **`reviewer_email` is deliberately -excluded**, it's PII with no storefront use. `reply`/`replied_at` (the staff response) are -included, since they're meant to be shown alongside the review. +`Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product under +a single `reviews` key: `{items, count, average_rating}` — `items` is the array of reviews, +`average_rating` is rounded to 1 decimal (`null` if the product has no reviews). Only public-safe +fields are included on each item — **`reviewer_email` is deliberately excluded**, it's PII with no +storefront use. `reply`/`replied_at` (the staff response) are included, since they're meant to be +shown alongside the review. A review is created/edited independently of its product (a customer submission, a staff reply) — its own save doesn't touch the `Product` row, so the product's own model events never fire. diff --git a/src/Product/Services/ProductIndexer.php b/src/Product/Services/ProductIndexer.php index 68a15f7..7bb019c 100644 --- a/src/Product/Services/ProductIndexer.php +++ b/src/Product/Services/ProductIndexer.php @@ -16,7 +16,7 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media; * Extends Lunar's own indexer so Modules\Core\Product\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 (ids, filterable) and collection_names (display) + * - collections: [{id, name}, ...] — filterable via `collections.id` * - 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 @@ -24,9 +24,9 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media; * - variants: sku, stock, purchasable, option values, prices, media * - the full media gallery (not just the single thumbnail Lunar's base indexer sends) * - tags - * - reviews: public-safe fields only (see mapReview() — reviewer_email is deliberately - * excluded, it's PII with no storefront use), including staff replies, plus an - * average rating + * - 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 @@ -49,7 +49,7 @@ class ProductIndexer extends BaseProductIndexer ...parent::getFilterableFields(), 'id', 'brand', - 'collections', + 'collections.id', 'price', 'slugs', 'channel_ids', @@ -85,16 +85,20 @@ class ProductIndexer extends BaseProductIndexer $currency = Currency::getDefault(); $reviews = ProductReview::where('product_id', $model->id)->with('media')->get(); - $data['collections'] = $model->collections->pluck('id')->map(fn ($id) => (string) $id)->all(); - $data['collection_names'] = $model->collections->map(fn ($collection) => $collection->translateAttribute('name'))->all(); + $data['collections'] = $model->collections->map(fn ($collection) => [ + 'id' => $collection->id, + 'name' => $collection->translateAttribute('name'), + ])->all(); $data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); $data['tags'] = $model->tags->pluck('value')->all(); $data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all(); $data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all(); $data['price'] = $this->cheapestPrice($model, $currency); - $data['reviews'] = $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(); - $data['review_count'] = $reviews->count(); - $data['average_rating'] = $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1); + $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') diff --git a/src/Product/Services/ProductService.php b/src/Product/Services/ProductService.php index 5e1d8e1..dcb4440 100644 --- a/src/Product/Services/ProductService.php +++ b/src/Product/Services/ProductService.php @@ -157,7 +157,7 @@ class ProductService } $clauses = Collection::make([ - $filters->collectionId !== null ? "collections = \"{$filters->collectionId}\"" : null, + $filters->collectionId !== null ? "collections.id = \"{$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, From 63caaf55c7dbb8cfa878d2e1a602e8ed8edf7a92 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 11:39:43 +0300 Subject: [PATCH 031/110] Bump Version to 0.6.1 --- CHANGELOG.md | 13 +- composer.json | 4 +- .../Contracts/ProductOptionTypeInterface.php | 34 --- src/Product/DTOs/ProductFilters.php | 20 -- src/Product/Enums/ProductSort.php | 26 --- .../ProductOptionResourceExtension.php | 39 ---- .../ValuesRelationManagerExtension.php | 34 --- .../ProductOptionReindexObserver.php | 68 ------ src/Product/OptionTypes/ColorOptionType.php | 29 --- src/Product/Services/ProductIndexer.php | 198 ------------------ .../Services/ProductOptionTypeManager.php | 68 ------ src/Product/Services/ProductSearchService.php | 56 ----- src/Product/Services/ProductService.php | 168 --------------- src/Providers/ProductServiceProvider.php | 28 --- 14 files changed, 13 insertions(+), 772 deletions(-) delete mode 100644 src/Product/Contracts/ProductOptionTypeInterface.php delete mode 100644 src/Product/DTOs/ProductFilters.php delete mode 100644 src/Product/Enums/ProductSort.php delete mode 100644 src/Product/Filament/Extensions/ProductOptionResourceExtension.php delete mode 100644 src/Product/Filament/Extensions/ValuesRelationManagerExtension.php delete mode 100644 src/Product/Observers/ProductOptionReindexObserver.php delete mode 100644 src/Product/OptionTypes/ColorOptionType.php delete mode 100644 src/Product/Services/ProductIndexer.php delete mode 100644 src/Product/Services/ProductOptionTypeManager.php delete mode 100644 src/Product/Services/ProductSearchService.php delete mode 100644 src/Product/Services/ProductService.php delete mode 100644 src/Providers/ProductServiceProvider.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 261da9b..3950e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.6.1] - 2026-08-27 + +### Added +- `Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category of `Lunar\Models\ProductOption` (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form `meta` jsonb column, and how an admin edits it via Filament — without introducing a new model. Registered via `Modules\Core\Product\Services\ProductOptionTypeManager::get()->register([...])` (a singleton registry, same shape as `Modules\Core\Notification\NotificationRegistry`) from a service provider's `boot()`. An admin then picks one per `ProductOption` from an "Option Type" dropdown on the option's own edit form (added by `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension`), stored in `ProductOption::meta['option_type']` — deliberately not tied to the option's `handle`, since a shop's own handle naming shouldn't have to match a type's key. `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` hooks Lunar's own `ValuesRelationManager` (both extensions via `LunarPanel::extensions()`, registered in `CorePlugin`) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, `Modules\Core\Product\OptionTypes\ColorOptionType`, registered automatically by the new `Modules\Core\Providers\ProductServiceProvider`. Documented in `docs/product-options.md`. +- `Modules\Core\Product\Services\ProductIndexer::mapVariant()` now includes each option's `handle` (alongside its translated name) in a variant's indexed `options[]` — previously only the translated `option`/`value` names and `meta` were indexed, with no stable, locale-independent identifier for which option a value belongs to. +- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the *product's* own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each. + +### Changed +- **Breaking:** `Modules\Core\Product\Services\ProductIndexer`'s indexed `collections` field is now an array of `{id, name}` objects instead of two parallel arrays (`collections` as bare ID strings, `collection_names` as translated names joined only by array index). `collection_names` is removed. Filtering by collection now targets the nested field `collections.id` (Meilisearch supports filtering on nested object fields), not bare `collections` — `Modules\Core\Product\Services\ProductService::buildFilter()` updated accordingly; `ProductFilters(collectionId: ...)`'s public API is unchanged. Run `php artisan lunar:meilisearch:setup` then `lunar:search:index --refresh` after upgrading (see docs/product-listing.md "Gotchas"). +- **Breaking:** `ProductIndexer`'s indexed `review_count`/`average_rating` top-level keys are folded into the existing `reviews` key: `reviews` is now `{items, count, average_rating}` instead of a bare array with `review_count`/`average_rating` as separate sibling keys. `reviews` (the array of review items) moved to `reviews.items`. + ## [0.6.0] - 2026-08-27 ### Added -- `Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category of `Lunar\Models\ProductOption` (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form `meta` jsonb column, and how an admin edits it via Filament — without introducing a new model. Enabled per-shop as a plain list in `config('core.product_option_types')`; an admin then picks one per `ProductOption` from a "Option Type" dropdown on the option's own edit form (added by `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension`), stored in `ProductOption::meta['option_type']` — deliberately not tied to the option's `handle`, since a shop's own handle naming shouldn't have to match a type's key. `Modules\Core\Product\Services\ProductOptionTypeManager` resolves the selected key to its type (`all()`/`resolve()`). `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` hooks Lunar's own `ValuesRelationManager` (both extensions via `LunarPanel::extensions()`, registered in `CorePlugin`) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, `Modules\Core\Product\OptionTypes\ColorOptionType` (not auto-registered). Documented in `docs/product-options.md`. -- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the *product's* own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each. - `Modules\Core\Localization\Models\LanguageLine` extends `spatie/laravel-translation-loader`'s `LanguageLine` to fall back to the store's actual default language (`LanguageCache::defaultLocale()`, backed by Lunar's `languages.default` flag) instead of the package's stock behavior of falling back to the static `config('app.fallback_locale')` — the two were previously disconnected, so changing the default language via the Filament **Languages** resource had no effect on which locale an untranslated storefront label silently fell back to. Swapped in automatically via `config('translation-loader.model')` in `LocalizationServiceProvider::register()`; no consuming app changes needed. Documented in `docs/localization.md` ("Fallback locale follows the store's default language"). ### Changed diff --git a/composer.json b/composer.json index 8822380..24fd660 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.6.0", + "version": "0.6.1", "autoload": { "psr-4": { "Modules\\Core\\": "src/" @@ -36,7 +36,7 @@ "Modules\\Core\\Providers\\AuthServiceProvider", "Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\LocalizationServiceProvider", - "Modules\\Core\\Providers\\ProductServiceProvider", + "Modules\\Core\\Providers\\CatalogServiceProvider", "Modules\\Core\\Providers\\ReviewServiceProvider" ] } diff --git a/src/Product/Contracts/ProductOptionTypeInterface.php b/src/Product/Contracts/ProductOptionTypeInterface.php deleted file mode 100644 index e14d1e0..0000000 --- a/src/Product/Contracts/ProductOptionTypeInterface.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ - public function getMetaForm(): array; -} diff --git a/src/Product/DTOs/ProductFilters.php b/src/Product/DTOs/ProductFilters.php deleted file mode 100644 index e1257d8..0000000 --- a/src/Product/DTOs/ProductFilters.php +++ /dev/null @@ -1,20 +0,0 @@ - 'price:asc', - self::PriceDesc => 'price:desc', - self::Newest => 'created_at:desc', - }; - } -} diff --git a/src/Product/Filament/Extensions/ProductOptionResourceExtension.php b/src/Product/Filament/Extensions/ProductOptionResourceExtension.php deleted file mode 100644 index 8b2f500..0000000 --- a/src/Product/Filament/Extensions/ProductOptionResourceExtension.php +++ /dev/null @@ -1,39 +0,0 @@ -all()) - ->keys() - ->mapWithKeys(fn (string $key) => [$key => Str::headline($key)]) - ->all(); - - if ($options === []) { - return $form; - } - - return $form->schema([ - ...$form->getComponents(), - Select::make('meta.option_type') - ->label('Option Type') - ->options($options) - ->helperText('Controls which meta fields appear when editing this option\'s values.') - ->native(false), - ]); - } -} diff --git a/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php b/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php deleted file mode 100644 index 6fdcaa5..0000000 --- a/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php +++ /dev/null @@ -1,34 +0,0 @@ -caller->getOwnerRecord(); - - $type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null); - - if ($type === null) { - return $form; - } - - return $form->schema([ - ...$form->getComponents(), - ...$type->getMetaForm(), - ]); - } -} diff --git a/src/Product/Observers/ProductOptionReindexObserver.php b/src/Product/Observers/ProductOptionReindexObserver.php deleted file mode 100644 index e34b1ca..0000000 --- a/src/Product/Observers/ProductOptionReindexObserver.php +++ /dev/null @@ -1,68 +0,0 @@ -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(); - } -} diff --git a/src/Product/OptionTypes/ColorOptionType.php b/src/Product/OptionTypes/ColorOptionType.php deleted file mode 100644 index 06b70d5..0000000 --- a/src/Product/OptionTypes/ColorOptionType.php +++ /dev/null @@ -1,29 +0,0 @@ -label('Color') - ->required(), - ]; - } -} diff --git a/src/Product/Services/ProductIndexer.php b/src/Product/Services/ProductIndexer.php deleted file mode 100644 index 7bb019c..0000000 --- a/src/Product/Services/ProductIndexer.php +++ /dev/null @@ -1,198 +0,0 @@ -with([ - 'collections', - '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['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); - $data['tags'] = $model->tags->pluck('value')->all(); - $data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all(); - $data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all(); - $data['price'] = $this->cheapestPrice($model, $currency); - $data['reviews'] = [ - 'items' => $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(), - 'count' => $reviews->count(), - 'average_rating' => $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1), - ]; - $data['channel_ids'] = $model->channels() - ->wherePivot('enabled', true) - ->pluck('lunar_channels.id') - ->toArray(); - - return $data; - } - - private function mapVariant(ProductVariant $variant, Currency $currency): array - { - return [ - 'id' => $variant->id, - 'sku' => $variant->sku, - 'stock' => $variant->stock, - 'purchasable' => $variant->purchasable, - 'options' => $variant->values->map(fn ($value) => [ - 'option' => $this->translatedName($value->option->name), - 'handle' => $value->option->handle, - 'value' => $this->translatedName($value->name), - 'meta' => $value->meta, - ])->all(), - 'prices' => $variant->prices->map(fn (Price $price) => [ - 'currency_id' => $price->currency_id, - 'customer_group_id' => $price->customer_group_id, - 'price' => $price->price->decimal(), - 'compare_price' => $price->compare_price?->decimal(), - 'min_quantity' => $price->min_quantity, - ])->all(), - 'media' => $variant->images->map(fn (Media $media) => $this->mapMedia($media))->all(), - ]; - } - - /** - * Public-safe fields only — reviewer_email is PII with no storefront use and is - * deliberately excluded, unlike every other column on the review. reply/replied_at - * (the staff response) are included since they're meant to be shown alongside the - * review on the storefront. - */ - private function mapReview(ProductReview $review): array - { - return [ - 'id' => $review->id, - 'title' => $review->title, - 'body' => $review->body, - 'rating' => $review->rating, - 'reviewed_at' => $review->reviewed_at?->timestamp, - 'reviewer_name' => $review->reviewer_name, - 'reply' => $review->reply, - 'replied_at' => $review->replied_at?->timestamp, - 'location' => $review->location, - 'media' => $review->media->map(fn (Media $media) => $this->mapMedia($media))->all(), - ]; - } - - /** - * ProductOption/ProductOptionValue's `name` is a plain locale-keyed array cast - * (AsArrayObject) directly on the column — unlike Product/Collection/Brand, it is - * not stored in attribute_data. Lunar's translateAttribute() only reads - * attribute_data, so it silently returns null for these two models; this reads - * the array directly instead. Falls back to the first available locale if the - * current one is missing. Not a general replacement for translateAttribute() — - * every other translated field in this indexer (product/collection name and - * description) genuinely is attribute_data-backed and translateAttribute() is - * correct for those. - */ - private function translatedName(mixed $name): ?string - { - $names = is_array($name) ? $name : (array) $name; - - return $names[app()->getLocale()] ?? reset($names) ?: null; - } - - private function mapMedia(Media $media): array - { - return [ - 'id' => $media->id, - 'url' => $media->getUrl(), - 'thumb' => $media->getUrl('small'), - ]; - } - - /** - * The cheapest variant's base price (no customer group) in the default currency, - * as a float in major units — e.g. 19.99, not 1999. Null if the product has no - * variant with a price in that currency yet, so it's excluded from price filters - * rather than sorting to the bottom as if it were free. - */ - private function cheapestPrice(Product $model, Currency $currency): ?float - { - $price = $model->variants - ->flatMap(fn ($variant) => $variant->prices) - ->filter(fn ($price) => $price->currency_id === $currency->id && $price->customer_group_id === null) - ->min(fn ($price) => $price->price->value); - - return $price !== null ? $price / (10 ** $currency->decimal_places) : null; - } -} diff --git a/src/Product/Services/ProductOptionTypeManager.php b/src/Product/Services/ProductOptionTypeManager.php deleted file mode 100644 index 1d73ee7..0000000 --- a/src/Product/Services/ProductOptionTypeManager.php +++ /dev/null @@ -1,68 +0,0 @@ -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> */ - private array $types = []; - - private function __construct() {} - - public static function get(): static - { - if (static::$instance === null) { - static::$instance = new static(); - } - - return static::$instance; - } - - /** - * @param array> $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> - */ - public function all(): array - { - return $this->types; - } -} diff --git a/src/Product/Services/ProductSearchService.php b/src/Product/Services/ProductSearchService.php deleted file mode 100644 index 6419c4d..0000000 --- a/src/Product/Services/ProductSearchService.php +++ /dev/null @@ -1,56 +0,0 @@ - - */ - public function search(string $query, ?string $locale = null): Collection - { - $locale ??= App::getLocale(); - $defaultLocale = Language::getDefault()->code; - - return Product::search($query) - ->options([ - 'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale), - ]) - ->get(); - } - - /** - * Target the resolved locale's fields plus the default locale's fields, so a - * product that's only ever been translated into the default language still - * surfaces when searched in another locale, instead of becoming invisible - * until every product is fully translated. - * - * @return array - */ - private function searchableFields(string $locale, string $defaultLocale): array - { - $handles = AttributeManifest::getSearchableAttributes(Product::morphName()) - ->pluck('handle'); - - $locales = array_unique([$locale, $defaultLocale]); - - return $handles - ->crossJoin($locales) - ->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}") - ->values() - ->all(); - } -} diff --git a/src/Product/Services/ProductService.php b/src/Product/Services/ProductService.php deleted file mode 100644 index dcb4440..0000000 --- a/src/Product/Services/ProductService.php +++ /dev/null @@ -1,168 +0,0 @@ -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\Product\Services\ - * ProductSearchService; this service is for browsing/filtering without a search term. - */ -class ProductService -{ - public function __construct( - private readonly LanguageCache $languages, - private readonly AttributeManifest $attributes, - ) {} - - /** - * Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result - - * see "Meilisearch driver quirk" below) so a controller/view gets normal - * pagination behaviour ($products->links(), JSON serialization, etc.) - * without ever touching the raw Meilisearch response directly. - */ - public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator - { - $options = ['filter' => $this->buildFilter($filters)]; - - if ($sort !== null) { - $options['sort'] = [$sort->toMeilisearchSort()]; - } - - $paginator = Product::search('') - ->options($options) - ->paginateRaw(perPage: $perPage, page: $page); - - $data = collect($this->hitsFrom($paginator)) - ->map(fn (array $product) => $this->withLocalizedFields($product)) - ->all(); - - return new LengthAwarePaginator( - items: $data, - total: $paginator->total(), - perPage: $paginator->perPage(), - currentPage: $paginator->currentPage(), - options: ['path' => LengthAwarePaginator::resolveCurrentPath()], - ); - } - - /** - * Look up a single product by its URL slug (any locale - slugs are indexed across - * all languages, see Modules\Core\Product\Services\ProductIndexer). Returns the full - * indexed product document, or null if no product has that slug. - */ - public function getBySlug(string $slug): ?array - { - return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"'); - } - - /** - * Look up a single product by its primary key. Returns the full indexed product - * document, or null if no product has that id. - */ - public function getById(int $id): ?array - { - return $this->findOneWhere("id = \"{$id}\""); - } - - private function findOneWhere(string $filter): ?array - { - $paginator = Product::search('') - ->options(['filter' => $filter]) - ->paginateRaw(perPage: 1, page: 1); - - $product = $this->hitsFrom($paginator)[0] ?? null; - - return $product !== null ? $this->withLocalizedFields($product) : null; - } - - /** - * Resolves every translated Product attribute's current-locale value from the - * indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`, - * `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's - * default language (LanguageCache::defaultLocale()) when the current locale - * has no translation - e.g. a product with no English copy yet still shows its - * Greek name on /en/ rather than rendering blank. - * - * Which handles are translated is read from AttributeManifest - the same - * source Lunar's own ScoutIndexer reads when exploding a TranslatedText - * attribute into `{handle}_{locale}` keys at index time - rather than a fixed - * list, so a store's own custom translated attributes (e.g. `seo_title`) are - * picked up automatically with no change here. The raw per-locale keys are - * then stripped, since once resolved, callers only ever need the one that - * matched the current locale. - * - * Deliberately not config('app.locale') - App::setLocale() overwrites that - * config value on every request, so by request time it's just whatever the - * current locale already is, not a stable fallback. - */ - private function withLocalizedFields(array $product): array - { - $locale = App::getLocale(); - $fallbackLocale = $this->languages->defaultLocale(); - $availableLocales = $this->languages->availableLocales(); - - foreach ($this->translatedAttributeHandles() as $handle) { - $product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null; - - foreach ($availableLocales as $availableLocale) { - unset($product[$handle.'_'.$availableLocale]); - } - } - - return $product; - } - - /** - * @return array - */ - private function translatedAttributeHandles(): array - { - return $this->attributes->getSearchableAttributes((new Product)->getMorphClass()) - ->filter(fn ($attribute) => $attribute->type === TranslatedText::class) - ->pluck('handle') - ->all(); - } - - /** - * For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response - * (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the - * actual documents are under the 'hits' key. - */ - private function hitsFrom(LengthAwarePaginatorContract $paginator): array - { - $rawResponse = $paginator->items(); - - return collect($rawResponse['hits'] ?? [])->values()->all(); - } - - private function buildFilter(?ProductFilters $filters): ?string - { - if ($filters === null) { - return null; - } - - $clauses = Collection::make([ - $filters->collectionId !== null ? "collections.id = \"{$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 '); - } -} diff --git a/src/Providers/ProductServiceProvider.php b/src/Providers/ProductServiceProvider.php deleted file mode 100644 index 712d9ec..0000000 --- a/src/Providers/ProductServiceProvider.php +++ /dev/null @@ -1,28 +0,0 @@ -register([ - ColorOptionType::class, - ]); - - $observer = new ProductOptionReindexObserver; - - ProductOption::saved(fn (ProductOption $option) => $observer->optionSaved($option)); - ProductOption::deleted(fn (ProductOption $option) => $observer->optionDeleted($option)); - - ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value)); - ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value)); - } -} From ba5a9523c8030c84d80f5d78c069d61e0afffb98 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 11:42:13 +0300 Subject: [PATCH 032/110] Feat: Restructuring Products into the Concern Catalog, for future Collection services --- docs/lunar.md | 4 +- docs/product-listing.md | 22 +- docs/product-options.md | 16 +- docs/product-search.md | 4 +- .../Contracts/ProductOptionTypeInterface.php | 34 +++ src/Catalog/DTOs/ProductFilters.php | 20 ++ src/Catalog/Enums/ProductSort.php | 26 +++ .../ProductOptionResourceExtension.php | 39 ++++ .../ValuesRelationManagerExtension.php | 34 +++ .../ProductOptionReindexObserver.php | 68 ++++++ src/Catalog/OptionTypes/ColorOptionType.php | 29 +++ src/Catalog/Services/ProductIndexer.php | 198 ++++++++++++++++++ .../Services/ProductOptionTypeManager.php | 68 ++++++ src/Catalog/Services/ProductSearchService.php | 56 +++++ src/Catalog/Services/ProductService.php | 168 +++++++++++++++ src/CorePlugin.php | 4 +- src/Localization/Services/LanguageCache.php | 4 +- src/Providers/CatalogServiceProvider.php | 28 +++ src/Providers/ReviewServiceProvider.php | 2 +- src/Review/Models/ProductReview.php | 2 +- 20 files changed, 797 insertions(+), 29 deletions(-) create mode 100644 src/Catalog/Contracts/ProductOptionTypeInterface.php create mode 100644 src/Catalog/DTOs/ProductFilters.php create mode 100644 src/Catalog/Enums/ProductSort.php create mode 100644 src/Catalog/Filament/Extensions/ProductOptionResourceExtension.php create mode 100644 src/Catalog/Filament/Extensions/ValuesRelationManagerExtension.php create mode 100644 src/Catalog/Observers/ProductOptionReindexObserver.php create mode 100644 src/Catalog/OptionTypes/ColorOptionType.php create mode 100644 src/Catalog/Services/ProductIndexer.php create mode 100644 src/Catalog/Services/ProductOptionTypeManager.php create mode 100644 src/Catalog/Services/ProductSearchService.php create mode 100644 src/Catalog/Services/ProductService.php create mode 100644 src/Providers/CatalogServiceProvider.php diff --git a/docs/lunar.md b/docs/lunar.md index 24a84ec..a6a9e8d 100644 --- a/docs/lunar.md +++ b/docs/lunar.md @@ -1206,6 +1206,6 @@ Real bugs/traps hit while building against Lunar in this package — not obvious - **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness. - **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead. - **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken. -- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Product\Services\ProductService` / `docs/product-listing.md`. -- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Product\Services\ProductIndexer::translatedName()`. +- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Catalog\Services\ProductService` / `docs/product-listing.md`. +- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Catalog\Services\ProductIndexer::translatedName()`. - **A running `queue:work` process does not pick up an edited/newly-added Scout indexer class.** It loads PHP classes once at boot and keeps them for the process's lifetime. Symptoms: reindexing commands succeed with no errors, calling `toSearchableArray()` directly (e.g. via `artisan tinker`, which always boots fresh) returns the new fields correctly, but documents written via `$model->searchable()` through the live queue are still missing them. Restart the queue worker after deploying an indexer change — no code fix needed. diff --git a/docs/product-listing.md b/docs/product-listing.md index 2da31b3..5161635 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -1,10 +1,10 @@ # Product Listing -`Modules\Core\Product\Services\ProductService` provides catalog browsing/filtering AND single-product +`Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the Meilisearch index rather than the database. One data source for everything this service does. -This is separate from `Modules\Core\Product\Services\ProductSearchService` (see `product-search.md`), which +This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md`), which handles free-text query search. `ProductService` is for browsing/lookup without a search term. --- @@ -14,7 +14,7 @@ handles free-text query search. `ProductService` is for browsing/lookup without Every method here reads Meilisearch documents directly and returns plain arrays — never Scout's `->get()`, which would re-hydrate Eloquent models from the database. This means the index has to carry everything a detail page needs (variants, prices, options, media, reviews — see below), not -just the trimmed fields a listing page needs. `Modules\Core\Product\Services\ProductIndexer` is built to +just the trimmed fields a listing page needs. `Modules\Core\Catalog\Services\ProductIndexer` is built to carry that full shape. --- @@ -22,9 +22,9 @@ carry that full shape. ## Usage ```php -use Modules\Core\Product\DTOs\ProductFilters; -use Modules\Core\Product\Services\ProductService; -use Modules\Core\Product\Enums\ProductSort; +use Modules\Core\Catalog\DTOs\ProductFilters; +use Modules\Core\Catalog\Services\ProductService; +use Modules\Core\Catalog\Enums\ProductSort; $service = app(ProductService::class); @@ -62,11 +62,11 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei --- -## Fields this depends on: `Modules\Core\Product\Services\ProductIndexer` +## Fields this depends on: `Modules\Core\Catalog\Services\ProductIndexer` Lunar's own `Lunar\Search\ProductIndexer` only carries listing-grade fields (name, description, status, brand, a single thumbnail, skus) and marks just `__soft_deleted`, `skus`, `status` as -filterable. `Modules\Core\Product\Services\ProductIndexer` extends it to add everything `ProductService` +filterable. `Modules\Core\Catalog\Services\ProductIndexer` extends it to add everything `ProductService` needs, listing and detail alike: | Field | Source | Notes | @@ -149,9 +149,9 @@ variants don't. ## Sorting -`ProductSort` (`Modules\Core\Product\Enums\ProductSort`) is a fixed enum of supported sort orders — +`ProductSort` (`Modules\Core\Catalog\Enums\ProductSort`) is a fixed enum of supported sort orders — `PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field -`Modules\Core\Product\Services\ProductIndexer::getSortableFields()` marks sortable (`price`, plus +`Modules\Core\Catalog\Services\ProductIndexer::getSortableFields()` marks sortable (`price`, plus `created_at`/`updated_at`/`skus`/`status` inherited from Lunar's base indexer). Adding a new `ProductSort` case requires adding the matching field to `getSortableFields()` and re-syncing (see below) — sortable attributes are index settings, not computed per-query, same as filterable ones. @@ -168,7 +168,7 @@ Not automatic — an app opts in via its own `config/lunar/search.php`: ```php 'indexers' => [ - Lunar\Models\Product::class => Modules\Core\Product\Services\ProductIndexer::class, + Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class, // ...other model indexers unchanged ], ``` diff --git a/docs/product-options.md b/docs/product-options.md index 9b9a436..7ff203e 100644 --- a/docs/product-options.md +++ b/docs/product-options.md @@ -6,7 +6,7 @@ Each `ProductOptionValue` carries a free-form `meta` jsonb column, but nothing i Lunar's own admin UI exposes it — there's no way for an admin to, say, attach a hex code to a "Red" value without editing the database directly. -`Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category +`Modules\Core\Catalog\Contracts\ProductOptionTypeInterface` describes how a category of option behaves — what structured data its values carry in `meta`, and how an admin edits that data — without introducing a new model. `ProductOption`/ `ProductOptionValue` stay exactly as Lunar defines them. @@ -19,7 +19,7 @@ A shop registers a type class from its own service provider's `boot()`, the same shape as `Modules\Core\Notification\NotificationRegistry`: ```php -use Modules\Core\Product\Services\ProductOptionTypeManager; +use Modules\Core\Catalog\Services\ProductOptionTypeManager; ProductOptionTypeManager::get()->register([ \App\ProductOptions\ColorOptionType::class, @@ -44,7 +44,7 @@ name/position, no extra meta form. namespace App\ProductOptions; use Filament\Forms\Components\ColorPicker; -use Modules\Core\Product\Contracts\ProductOptionTypeInterface; +use Modules\Core\Catalog\Contracts\ProductOptionTypeInterface; class ColorOptionType implements ProductOptionTypeInterface { @@ -70,8 +70,8 @@ plain jsonb column). `getKey()` is the identifier used in the admin's "Option Ty dropdown and in `ProductOption::meta['option_type']` — it has no relationship to the `ProductOption::handle`. -A reference implementation ships at `Modules\Core\Product\OptionTypes\ColorOptionType`, -registered automatically by `Modules\Core\Providers\ProductServiceProvider` — no shop +A reference implementation ships at `Modules\Core\Catalog\OptionTypes\ColorOptionType`, +registered automatically by `Modules\Core\Providers\CatalogServiceProvider` — no shop setup needed for it to appear in the "Option Type" dropdown, though an admin still has to pick it per-`ProductOption` for it to take effect. @@ -79,7 +79,7 @@ has to pick it per-`ProductOption` for it to take effect. ## How it's wired into the admin UI -`Modules\Core\Product\Services\ProductOptionTypeManager` is a singleton registry: +`Modules\Core\Catalog\Services\ProductOptionTypeManager` is a singleton registry: - `get(): static` — the shared instance. - `register(array $types): void` — registers one or more type classes, keyed internally by `getKey()`. @@ -93,11 +93,11 @@ Two extensions hook into Lunar's admin via its extension system (`LunarPanel::extensions([...])`, registered in `CorePlugin`) — no forking of Lunar's classes needed: -- `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension` extends +- `Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension` extends `Lunar\Admin\Filament\Resources\ProductOptionResource`'s own form with a `Select` (`meta.option_type`) listing every enabled type's key. Shown only when at least one type is enabled. -- `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` extends +- `Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension` extends the "Values" tab's form. Its `extendForm()` reads `$option->meta['option_type']` off the owning `ProductOption`, resolves it via `ProductOptionTypeManager`, and appends `getMetaForm()`'s fields to the stock name diff --git a/docs/product-search.md b/docs/product-search.md index 5cb05a1..7445197 100644 --- a/docs/product-search.md +++ b/docs/product-search.md @@ -1,6 +1,6 @@ # Product Search -`Modules\Core\Product\Services\ProductSearchService` provides locale-aware full-text product search on +`Modules\Core\Catalog\Services\ProductSearchService` provides locale-aware full-text product search on top of Laravel Scout + Meilisearch. --- @@ -24,7 +24,7 @@ merges `$builder->options` directly into the search request). ## Usage ```php -use Modules\Core\Product\Services\ProductSearchService; +use Modules\Core\Catalog\Services\ProductSearchService; $results = app(ProductSearchService::class)->search('running shoes'); // or an explicit locale, bypassing App::getLocale(): diff --git a/src/Catalog/Contracts/ProductOptionTypeInterface.php b/src/Catalog/Contracts/ProductOptionTypeInterface.php new file mode 100644 index 0000000..49fb205 --- /dev/null +++ b/src/Catalog/Contracts/ProductOptionTypeInterface.php @@ -0,0 +1,34 @@ + + */ + public function getMetaForm(): array; +} diff --git a/src/Catalog/DTOs/ProductFilters.php b/src/Catalog/DTOs/ProductFilters.php new file mode 100644 index 0000000..7fc48b3 --- /dev/null +++ b/src/Catalog/DTOs/ProductFilters.php @@ -0,0 +1,20 @@ + 'price:asc', + self::PriceDesc => 'price:desc', + self::Newest => 'created_at:desc', + }; + } +} diff --git a/src/Catalog/Filament/Extensions/ProductOptionResourceExtension.php b/src/Catalog/Filament/Extensions/ProductOptionResourceExtension.php new file mode 100644 index 0000000..b816ce6 --- /dev/null +++ b/src/Catalog/Filament/Extensions/ProductOptionResourceExtension.php @@ -0,0 +1,39 @@ +all()) + ->keys() + ->mapWithKeys(fn (string $key) => [$key => Str::headline($key)]) + ->all(); + + if ($options === []) { + return $form; + } + + return $form->schema([ + ...$form->getComponents(), + Select::make('meta.option_type') + ->label('Option Type') + ->options($options) + ->helperText('Controls which meta fields appear when editing this option\'s values.') + ->native(false), + ]); + } +} diff --git a/src/Catalog/Filament/Extensions/ValuesRelationManagerExtension.php b/src/Catalog/Filament/Extensions/ValuesRelationManagerExtension.php new file mode 100644 index 0000000..1429d89 --- /dev/null +++ b/src/Catalog/Filament/Extensions/ValuesRelationManagerExtension.php @@ -0,0 +1,34 @@ +caller->getOwnerRecord(); + + $type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null); + + if ($type === null) { + return $form; + } + + return $form->schema([ + ...$form->getComponents(), + ...$type->getMetaForm(), + ]); + } +} diff --git a/src/Catalog/Observers/ProductOptionReindexObserver.php b/src/Catalog/Observers/ProductOptionReindexObserver.php new file mode 100644 index 0000000..5b47c45 --- /dev/null +++ b/src/Catalog/Observers/ProductOptionReindexObserver.php @@ -0,0 +1,68 @@ +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(); + } +} diff --git a/src/Catalog/OptionTypes/ColorOptionType.php b/src/Catalog/OptionTypes/ColorOptionType.php new file mode 100644 index 0000000..10600b2 --- /dev/null +++ b/src/Catalog/OptionTypes/ColorOptionType.php @@ -0,0 +1,29 @@ +label('Color') + ->required(), + ]; + } +} diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php new file mode 100644 index 0000000..3640f27 --- /dev/null +++ b/src/Catalog/Services/ProductIndexer.php @@ -0,0 +1,198 @@ +with([ + 'collections', + '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['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); + $data['tags'] = $model->tags->pluck('value')->all(); + $data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all(); + $data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all(); + $data['price'] = $this->cheapestPrice($model, $currency); + $data['reviews'] = [ + 'items' => $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(), + 'count' => $reviews->count(), + 'average_rating' => $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1), + ]; + $data['channel_ids'] = $model->channels() + ->wherePivot('enabled', true) + ->pluck('lunar_channels.id') + ->toArray(); + + return $data; + } + + private function mapVariant(ProductVariant $variant, Currency $currency): array + { + return [ + 'id' => $variant->id, + 'sku' => $variant->sku, + 'stock' => $variant->stock, + 'purchasable' => $variant->purchasable, + 'options' => $variant->values->map(fn ($value) => [ + 'option' => $this->translatedName($value->option->name), + 'handle' => $value->option->handle, + 'value' => $this->translatedName($value->name), + 'meta' => $value->meta, + ])->all(), + 'prices' => $variant->prices->map(fn (Price $price) => [ + 'currency_id' => $price->currency_id, + 'customer_group_id' => $price->customer_group_id, + 'price' => $price->price->decimal(), + 'compare_price' => $price->compare_price?->decimal(), + 'min_quantity' => $price->min_quantity, + ])->all(), + 'media' => $variant->images->map(fn (Media $media) => $this->mapMedia($media))->all(), + ]; + } + + /** + * Public-safe fields only — reviewer_email is PII with no storefront use and is + * deliberately excluded, unlike every other column on the review. reply/replied_at + * (the staff response) are included since they're meant to be shown alongside the + * review on the storefront. + */ + private function mapReview(ProductReview $review): array + { + return [ + 'id' => $review->id, + 'title' => $review->title, + 'body' => $review->body, + 'rating' => $review->rating, + 'reviewed_at' => $review->reviewed_at?->timestamp, + 'reviewer_name' => $review->reviewer_name, + 'reply' => $review->reply, + 'replied_at' => $review->replied_at?->timestamp, + 'location' => $review->location, + 'media' => $review->media->map(fn (Media $media) => $this->mapMedia($media))->all(), + ]; + } + + /** + * ProductOption/ProductOptionValue's `name` is a plain locale-keyed array cast + * (AsArrayObject) directly on the column — unlike Product/Collection/Brand, it is + * not stored in attribute_data. Lunar's translateAttribute() only reads + * attribute_data, so it silently returns null for these two models; this reads + * the array directly instead. Falls back to the first available locale if the + * current one is missing. Not a general replacement for translateAttribute() — + * every other translated field in this indexer (product/collection name and + * description) genuinely is attribute_data-backed and translateAttribute() is + * correct for those. + */ + private function translatedName(mixed $name): ?string + { + $names = is_array($name) ? $name : (array) $name; + + return $names[app()->getLocale()] ?? reset($names) ?: null; + } + + private function mapMedia(Media $media): array + { + return [ + 'id' => $media->id, + 'url' => $media->getUrl(), + 'thumb' => $media->getUrl('small'), + ]; + } + + /** + * The cheapest variant's base price (no customer group) in the default currency, + * as a float in major units — e.g. 19.99, not 1999. Null if the product has no + * variant with a price in that currency yet, so it's excluded from price filters + * rather than sorting to the bottom as if it were free. + */ + private function cheapestPrice(Product $model, Currency $currency): ?float + { + $price = $model->variants + ->flatMap(fn ($variant) => $variant->prices) + ->filter(fn ($price) => $price->currency_id === $currency->id && $price->customer_group_id === null) + ->min(fn ($price) => $price->price->value); + + return $price !== null ? $price / (10 ** $currency->decimal_places) : null; + } +} diff --git a/src/Catalog/Services/ProductOptionTypeManager.php b/src/Catalog/Services/ProductOptionTypeManager.php new file mode 100644 index 0000000..2b45683 --- /dev/null +++ b/src/Catalog/Services/ProductOptionTypeManager.php @@ -0,0 +1,68 @@ +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> */ + private array $types = []; + + private function __construct() {} + + public static function get(): static + { + if (static::$instance === null) { + static::$instance = new static(); + } + + return static::$instance; + } + + /** + * @param array> $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> + */ + public function all(): array + { + return $this->types; + } +} diff --git a/src/Catalog/Services/ProductSearchService.php b/src/Catalog/Services/ProductSearchService.php new file mode 100644 index 0000000..0ae8e9e --- /dev/null +++ b/src/Catalog/Services/ProductSearchService.php @@ -0,0 +1,56 @@ + + */ + public function search(string $query, ?string $locale = null): Collection + { + $locale ??= App::getLocale(); + $defaultLocale = Language::getDefault()->code; + + return Product::search($query) + ->options([ + 'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale), + ]) + ->get(); + } + + /** + * Target the resolved locale's fields plus the default locale's fields, so a + * product that's only ever been translated into the default language still + * surfaces when searched in another locale, instead of becoming invisible + * until every product is fully translated. + * + * @return array + */ + private function searchableFields(string $locale, string $defaultLocale): array + { + $handles = AttributeManifest::getSearchableAttributes(Product::morphName()) + ->pluck('handle'); + + $locales = array_unique([$locale, $defaultLocale]); + + return $handles + ->crossJoin($locales) + ->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}") + ->values() + ->all(); + } +} diff --git a/src/Catalog/Services/ProductService.php b/src/Catalog/Services/ProductService.php new file mode 100644 index 0000000..f881d6c --- /dev/null +++ b/src/Catalog/Services/ProductService.php @@ -0,0 +1,168 @@ +get() model hydration anywhere in this service. Callers get plain arrays + * of the indexed document, not Eloquent models. + * + * Full-text query search lives separately in Modules\Core\Catalog\Services\ + * ProductSearchService; this service is for browsing/filtering without a search term. + */ +class ProductService +{ + public function __construct( + private readonly LanguageCache $languages, + private readonly AttributeManifest $attributes, + ) {} + + /** + * Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result - + * see "Meilisearch driver quirk" below) so a controller/view gets normal + * pagination behaviour ($products->links(), JSON serialization, etc.) + * without ever touching the raw Meilisearch response directly. + */ + public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator + { + $options = ['filter' => $this->buildFilter($filters)]; + + if ($sort !== null) { + $options['sort'] = [$sort->toMeilisearchSort()]; + } + + $paginator = Product::search('') + ->options($options) + ->paginateRaw(perPage: $perPage, page: $page); + + $data = collect($this->hitsFrom($paginator)) + ->map(fn (array $product) => $this->withLocalizedFields($product)) + ->all(); + + return new LengthAwarePaginator( + items: $data, + total: $paginator->total(), + perPage: $paginator->perPage(), + currentPage: $paginator->currentPage(), + options: ['path' => LengthAwarePaginator::resolveCurrentPath()], + ); + } + + /** + * Look up a single product by its URL slug (any locale - slugs are indexed across + * all languages, see Modules\Core\Catalog\Services\ProductIndexer). Returns the full + * indexed product document, or null if no product has that slug. + */ + public function getBySlug(string $slug): ?array + { + return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"'); + } + + /** + * Look up a single product by its primary key. Returns the full indexed product + * document, or null if no product has that id. + */ + public function getById(int $id): ?array + { + return $this->findOneWhere("id = \"{$id}\""); + } + + private function findOneWhere(string $filter): ?array + { + $paginator = Product::search('') + ->options(['filter' => $filter]) + ->paginateRaw(perPage: 1, page: 1); + + $product = $this->hitsFrom($paginator)[0] ?? null; + + return $product !== null ? $this->withLocalizedFields($product) : null; + } + + /** + * Resolves every translated Product attribute's current-locale value from the + * indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`, + * `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's + * default language (LanguageCache::defaultLocale()) when the current locale + * has no translation - e.g. a product with no English copy yet still shows its + * Greek name on /en/ rather than rendering blank. + * + * Which handles are translated is read from AttributeManifest - the same + * source Lunar's own ScoutIndexer reads when exploding a TranslatedText + * attribute into `{handle}_{locale}` keys at index time - rather than a fixed + * list, so a store's own custom translated attributes (e.g. `seo_title`) are + * picked up automatically with no change here. The raw per-locale keys are + * then stripped, since once resolved, callers only ever need the one that + * matched the current locale. + * + * Deliberately not config('app.locale') - App::setLocale() overwrites that + * config value on every request, so by request time it's just whatever the + * current locale already is, not a stable fallback. + */ + private function withLocalizedFields(array $product): array + { + $locale = App::getLocale(); + $fallbackLocale = $this->languages->defaultLocale(); + $availableLocales = $this->languages->availableLocales(); + + foreach ($this->translatedAttributeHandles() as $handle) { + $product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null; + + foreach ($availableLocales as $availableLocale) { + unset($product[$handle.'_'.$availableLocale]); + } + } + + return $product; + } + + /** + * @return array + */ + private function translatedAttributeHandles(): array + { + return $this->attributes->getSearchableAttributes((new Product)->getMorphClass()) + ->filter(fn ($attribute) => $attribute->type === TranslatedText::class) + ->pluck('handle') + ->all(); + } + + /** + * For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response + * (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the + * actual documents are under the 'hits' key. + */ + private function hitsFrom(LengthAwarePaginatorContract $paginator): array + { + $rawResponse = $paginator->items(); + + return collect($rawResponse['hits'] ?? [])->values()->all(); + } + + private function buildFilter(?ProductFilters $filters): ?string + { + if ($filters === null) { + return null; + } + + $clauses = Collection::make([ + $filters->collectionId !== null ? "collections.id = \"{$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 '); + } +} diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 00582c2..355748a 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -17,9 +17,9 @@ 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\Catalog\Filament\Extensions\ProductOptionResourceExtension; +use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Localization\Filament\Resources\LanguageLineResource; -use Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension; -use Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Review\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; diff --git a/src/Localization/Services/LanguageCache.php b/src/Localization/Services/LanguageCache.php index 03446c1..21e7d96 100644 --- a/src/Localization/Services/LanguageCache.php +++ b/src/Localization/Services/LanguageCache.php @@ -9,7 +9,7 @@ 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\Product\Services\ProductService) read + * 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 @@ -41,7 +41,7 @@ class LanguageCache /** * 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\Product\Services\ProductService::withLocalizedFields()), rather than + * Modules\Core\Catalog\Services\ProductService::withLocalizedFields()), rather than * hardcoding locale codes. * * @return array diff --git a/src/Providers/CatalogServiceProvider.php b/src/Providers/CatalogServiceProvider.php new file mode 100644 index 0000000..7354e13 --- /dev/null +++ b/src/Providers/CatalogServiceProvider.php @@ -0,0 +1,28 @@ +register([ + ColorOptionType::class, + ]); + + $observer = new ProductOptionReindexObserver; + + ProductOption::saved(fn (ProductOption $option) => $observer->optionSaved($option)); + ProductOption::deleted(fn (ProductOption $option) => $observer->optionDeleted($option)); + + ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value)); + ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value)); + } +} diff --git a/src/Providers/ReviewServiceProvider.php b/src/Providers/ReviewServiceProvider.php index c5a32e1..87bea35 100644 --- a/src/Providers/ReviewServiceProvider.php +++ b/src/Providers/ReviewServiceProvider.php @@ -9,7 +9,7 @@ use Modules\Core\Review\Models\ProductReview; * Keeps a product's Meilisearch document in sync with its reviews. A review is * created/edited independently of its product (customer submission, staff reply), * so the product's own save/update events never fire for it — without this listener, - * Modules\Core\Product\Services\ProductIndexer's review data would only refresh on + * Modules\Core\Catalog\Services\ProductIndexer's review data would only refresh on * the next full product reindex. */ class ReviewServiceProvider extends ServiceProvider diff --git a/src/Review/Models/ProductReview.php b/src/Review/Models/ProductReview.php index d2ab244..32a257e 100644 --- a/src/Review/Models/ProductReview.php +++ b/src/Review/Models/ProductReview.php @@ -38,7 +38,7 @@ class ProductReview extends Model implements HasMedia * Unlike Product/ProductVariant, this model sits outside Lunar's own * MediaDefinitionsInterface (Lunar\Base\StandardMediaDefinitions), which is * what registers the 'small' conversion those models get automatically. Without - * this, Modules\Core\Product\Services\ProductIndexer::mapMedia() — shared across + * this, Modules\Core\Catalog\Services\ProductIndexer::mapMedia() — shared across * product, variant, and review media — throws Spatie\MediaLibrary\MediaCollections\ * Exceptions\InvalidConversion the first time a review has an image, since * $media->getUrl('small') has no matching conversion to resolve. From a2c3fd545768f0b81295e8e0d4a32154ec91f4ac Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 12:11:01 +0300 Subject: [PATCH 033/110] Feature: Reviews restructuring and Creating Collection Indexer and Services --- docs/collections.md | 101 ++++++++++++ docs/localization.md | 17 +- docs/product-listing.md | 3 +- src/Catalog/DTOs/CollectionFilters.php | 24 +++ src/Catalog/DTOs/ProductFilters.php | 7 + src/Catalog/Enums/CollectionSort.php | 24 +++ src/Catalog/Services/CollectionIndexer.php | 69 ++++++++ src/Catalog/Services/CollectionService.php | 147 ++++++++++++++++++ src/Catalog/Services/ProductIndexer.php | 17 +- src/Catalog/Services/ProductService.php | 2 +- src/Command/InstallLunarCommand.php | 55 ++++--- src/CorePlugin.php | 2 +- .../Services/StorefrontLabels.php | 84 ++++++++++ .../Extensions/ProductResourceExtension.php | 4 +- .../Pages/ManageProductReviews.php | 2 +- 15 files changed, 518 insertions(+), 40 deletions(-) create mode 100644 docs/collections.md create mode 100644 src/Catalog/DTOs/CollectionFilters.php create mode 100644 src/Catalog/Enums/CollectionSort.php create mode 100644 src/Catalog/Services/CollectionIndexer.php create mode 100644 src/Catalog/Services/CollectionService.php create mode 100644 src/Localization/Services/StorefrontLabels.php rename src/Review/{ => Filament}/Extensions/ProductResourceExtension.php (78%) rename src/Review/{ => Filament}/Pages/ManageProductReviews.php (99%) diff --git a/docs/collections.md b/docs/collections.md new file mode 100644 index 0000000..6b15831 --- /dev/null +++ b/docs/collections.md @@ -0,0 +1,101 @@ +# Collections + +`Modules\Core\Catalog\Services\CollectionService` provides category browsing/nav AND +single-collection lookup for a storefront — `list()`, `getById()`, `getBySlug()` — +all reading directly from the Meilisearch index, mirroring +`Modules\Core\Catalog\Services\ProductService` (see `product-listing.md`) exactly. + +--- + +## Why it reads from the index, not the database + +Lunar's own `Lunar\Search\CollectionIndexer` only carries `id`/`name`/`created_at` — +nowhere near enough for a storefront category page or a nav tree. +`Modules\Core\Catalog\Services\CollectionIndexer` extends it to add everything +`CollectionService` needs: + +| Field | Source | Notes | +|---|---|---| +| `parent_id` | `$model->parent_id` | Filterable. The nested-set tree's parent pointer — `null` for a top-level collection. | +| `_lft` | `$model->_lft` | Filterable and sortable. The nested-set tree position — lets `CollectionService` resolve tree order without a database read. | +| `collection_group_id` | `$model->collection_group_id` | Filterable. Mirrors `Collection::scopeInGroup()`. | +| `slugs` | `$model->urls->pluck('slug')` | Filterable. Every locale's `Url::slug`, so `getBySlug()` resolves purely from the index. | +| `thumbnail` | `$model->getThumbnailImage()` | Display only. `null` if the collection has no thumbnail image. | + +`name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale +by Lunar's base indexer and resolved by `CollectionService` exactly like +`ProductService` does — see `product-listing.md`'s "Locale resolution" section, same +logic, same `LanguageCache::defaultLocale()` fallback. + +--- + +## Usage + +```php +use Modules\Core\Catalog\DTOs\CollectionFilters; +use Modules\Core\Catalog\Enums\CollectionSort; +use Modules\Core\Catalog\Services\CollectionService; + +$service = app(CollectionService::class); + +// Top-level collections only (parent_id IS NULL) — for building a nav tree +$roots = $service->list( + filters: new CollectionFilters(rootOnly: true), + sort: CollectionSort::Position, +); + +// Children of a specific collection +$children = $service->list( + filters: new CollectionFilters(parentId: 222), + sort: CollectionSort::Position, +); + +// Filter by collection group +$collections = $service->list(filters: new CollectionFilters(groupId: 4)); + +// Single collection, by primary key or slug +$collection = $service->getById(223); +$collection = $service->getBySlug('keychains'); +``` + +`CollectionFilters(parentId: ..., rootOnly: ...)` are mutually exclusive — if both are +set, `parentId` wins. There's no `parentId: null` shorthand for "root only", since +that would be ambiguous with "don't filter by parent at all" (the DTO's actual +default); `rootOnly` names the root-collections case explicitly instead. + +`CollectionSort::Position` (`_lft:asc`) is the recommended default for any nav/tree +UI — it matches the order an admin arranges collections in Lunar's own Filament UI. +`Name` and `Newest` are also available, mirroring `ProductSort`'s shape. + +--- + +## Registration + +Like `ProductIndexer`, `CollectionIndexer` must be registered in the consuming app's +own `config/lunar/search.php`: + +```php +'indexers' => [ + Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class, + // ... +], +``` + +New/changed 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. If `SCOUT_QUEUE` is enabled, +the queue worker also needs restarting after deploying changes to the indexer class — +see `docs/lunar.md` "Gotchas". + +--- + +## When to still use Eloquent directly + +A single collection's full detail page (breadcrumb via `$collection->breadcrumb`, +tree ancestors/descendants, route-model-bound `Collection $collection` in a +controller signature) should keep reading Eloquent directly rather than going through +`CollectionService` — the indexed document doesn't carry ancestor chains or the full +nested-set relations, and route-model binding already gives a controller the full +model for free. `CollectionService` is for browsing/listing and lightweight +by-id/by-slug lookups where a full Eloquent hydration would be wasteful, the same +tradeoff `ProductService` makes for products. diff --git a/docs/localization.md b/docs/localization.md index 1a1fd5a..abf98c7 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -199,9 +199,20 @@ registered. ### Seeding A starter set of common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`, -English + Greek) is seeded by `Modules\Core\Command\InstallLunarCommand` (overrides Lunar's own -`lunar:install`), guarded by `LanguageLine::where('group', 'storefront')->exists()` — same -idempotent pattern as the rest of that command, safe to run unattended on every boot. +`review.*`, `shop.*`, `pagination.*`, English + Greek) lives in +`Modules\Core\Localization\Services\StorefrontLabels::all()` — kept as its own class, separate +from the seeding logic, so the label list can be scanned/diffed without wading through the +seeding mechanics. + +`Modules\Core\Command\InstallLunarCommand` (overrides Lunar's own `lunar:install`) seeds them via +a **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 **Language +Lines** resource — is left untouched; only keys missing entirely are created. This is what makes +it safe to add new keys to `StorefrontLabels::all()` later and re-run `lunar:install` on an +already-installed store, without either silently skipping the new keys (the old guard's behavior) +or reverting an admin's edits back to the hardcoded default (what a naive `updateOrCreate` would +do). New writes go through `TranslationService::create()`, so the usual cache-invalidation and +activity-log events fire for them too. ### Admin UI diff --git a/docs/product-listing.md b/docs/product-listing.md index 5161635..2332acc 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -72,7 +72,8 @@ needs, listing and detail alike: | Field | Source | Notes | |---|---|---| | `id` | — | Newly marked **filterable** — needed for `getById()`'s `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. | -| `collections` | `$product->collections` | Array of `{id, name}` — `name` is the translated collection name. Filterable on the nested field `collections.id`, not `collections` itself. | +| `collections` | `$product->collections` | Array of `{id, name}` — directly assigned collections only, `name` is the translated collection name. Not filterable — see `collection_ids`. | +| `collection_ids` | `$product->collections` + `->ancestors` | Filterable. Flat array of every directly-assigned collection's id, unioned with all of its ancestors' ids. `ProductFilters(collectionId: ...)` filters against this field, not `collections`, since products are typically attached only to leaf collections — a plain direct-match filter would never return anything for a parent/root category page. | | `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. | | `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. | | `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. | diff --git a/src/Catalog/DTOs/CollectionFilters.php b/src/Catalog/DTOs/CollectionFilters.php new file mode 100644 index 0000000..3d1044e --- /dev/null +++ b/src/Catalog/DTOs/CollectionFilters.php @@ -0,0 +1,24 @@ + '_lft:asc', + self::Name => 'name:asc', + self::Newest => 'created_at:desc', + }; + } +} diff --git a/src/Catalog/Services/CollectionIndexer.php b/src/Catalog/Services/CollectionIndexer.php new file mode 100644 index 0000000..117a63e --- /dev/null +++ b/src/Catalog/Services/CollectionIndexer.php @@ -0,0 +1,69 @@ +with(['urls', 'media']); + } + + 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; + + return $data; + } +} diff --git a/src/Catalog/Services/CollectionService.php b/src/Catalog/Services/CollectionService.php new file mode 100644 index 0000000..001c239 --- /dev/null +++ b/src/Catalog/Services/CollectionService.php @@ -0,0 +1,147 @@ + $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 + */ + 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 '); + } +} diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php index 3640f27..79f6ed3 100644 --- a/src/Catalog/Services/ProductIndexer.php +++ b/src/Catalog/Services/ProductIndexer.php @@ -16,7 +16,14 @@ 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}, ...] — filterable via `collections.id` + * - 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 @@ -49,7 +56,7 @@ class ProductIndexer extends BaseProductIndexer ...parent::getFilterableFields(), 'id', 'brand', - 'collections.id', + 'collection_ids', 'price', 'slugs', 'channel_ids', @@ -68,6 +75,7 @@ class ProductIndexer extends BaseProductIndexer { return parent::makeAllSearchableUsing($query)->with([ 'collections', + 'collections.ancestors', 'media', 'tags', 'urls', @@ -89,6 +97,11 @@ class ProductIndexer extends BaseProductIndexer 'id' => $collection->id, 'name' => $collection->translateAttribute('name'), ])->all(); + $data['collection_ids'] = $model->collections + ->flatMap(fn ($collection) => [$collection->id, ...$collection->ancestors->pluck('id')]) + ->unique() + ->values() + ->all(); $data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); $data['tags'] = $model->tags->pluck('value')->all(); $data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all(); diff --git a/src/Catalog/Services/ProductService.php b/src/Catalog/Services/ProductService.php index f881d6c..b820b07 100644 --- a/src/Catalog/Services/ProductService.php +++ b/src/Catalog/Services/ProductService.php @@ -157,7 +157,7 @@ class ProductService } $clauses = Collection::make([ - $filters->collectionId !== null ? "collections.id = \"{$filters->collectionId}\"" : null, + $filters->collectionId !== null ? "collection_ids = \"{$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, diff --git a/src/Command/InstallLunarCommand.php b/src/Command/InstallLunarCommand.php index e07d3a3..e55b9c9 100644 --- a/src/Command/InstallLunarCommand.php +++ b/src/Command/InstallLunarCommand.php @@ -18,7 +18,9 @@ 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; /** * Overrides Lunar's own lunar:install to skip the interactive prompts (migrate @@ -32,7 +34,7 @@ class InstallLunarCommand extends Command protected $description = 'Seed the default Lunar store data (countries, channel, currency, tax zone, attributes, product type)'; - public function handle(): void + public function handle(TranslationService $translations): void { $this->components->info('Seeding default Lunar store data...'); @@ -242,10 +244,8 @@ 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('Publishing Filament assets'); $this->call('filament:assets'); @@ -253,32 +253,29 @@ 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); } } } diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 355748a..12b8468 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -20,7 +20,7 @@ use Modules\Core\Auth\Mail\InviteMail; use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension; use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Localization\Filament\Resources\LanguageLineResource; -use Modules\Core\Review\Extensions\ProductResourceExtension; +use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; class CorePlugin implements Plugin diff --git a/src/Localization/Services/StorefrontLabels.php b/src/Localization/Services/StorefrontLabels.php new file mode 100644 index 0000000..3267c2b --- /dev/null +++ b/src/Localization/Services/StorefrontLabels.php @@ -0,0 +1,84 @@ +> keyed by `group.key` dot-notation, + * each value a locale => text map (`en`/`el`). + */ + public static function all(): array + { + return [ + 'nav.home' => ['en' => 'Home', 'el' => 'Αρχική'], + 'nav.products' => ['en' => 'Products', 'el' => 'Προϊόντα'], + 'nav.cart' => ['en' => 'Cart', 'el' => 'Καλάθι'], + 'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'], + 'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'], + 'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'], + 'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'], + 'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'], + 'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'], + 'cart.remove' => ['en' => 'Remove', 'el' => 'Αφαίρεση'], + 'product.add_to_cart' => ['en' => 'Add to Cart', 'el' => 'Προσθήκη στο Καλάθι'], + 'product.out_of_stock' => ['en' => 'Out of Stock', 'el' => 'Εξαντλήθηκε'], + 'product.price' => ['en' => 'Price', 'el' => 'Τιμή'], + 'product.description' => ['en' => 'Description', 'el' => 'Περιγραφή'], + 'product.no_image' => ['en' => 'No image', 'el' => 'Χωρίς εικόνα'], + 'product.read_more' => ['en' => 'Read more', 'el' => 'Περισσότερα'], + 'product.reviews' => ['en' => 'Reviews', 'el' => 'Αξιολογήσεις'], + 'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'], + 'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'], + 'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'], + 'customer_reviews' => [ + 'en' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews', + 'el' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών', + ], + 'pagination.nav_label' => ['en' => 'Pagination', 'el' => 'Σελιδοποίηση'], + 'pagination.next' => ['en' => 'Next page', 'el' => 'Επόμενη σελίδα'], + 'pagination.previous' => ['en' => 'Previous page', 'el' => 'Προηγούμενη σελίδα'], + 'pagination.page' => ['en' => 'Page :page', 'el' => 'Σελίδα :page'], + 'review.rating' => ['en' => 'Rating', 'el' => 'Βαθμολογία'], + 'review.write_label' => ['en' => 'Write a review', 'el' => 'Γράψε μια αξιολόγηση'], + 'review.name' => ['en' => 'Name', 'el' => 'Όνομα'], + 'review.name_optional' => ['en' => 'Optional', 'el' => 'Προαιρετικό'], + 'review.email' => ['en' => 'Email', 'el' => 'Email'], + 'review.email_not_published' => ['en' => 'Will not be published', 'el' => 'Δεν θα δημοσιευτεί'], + 'review.save_info' => [ + 'en' => 'Save my name and email for the next time I comment.', + 'el' => 'Αποθήκευσε το όνομα και το email μου για την επόμενη φορά που θα σχολιάσω.', + ], + 'review.submit' => ['en' => 'Submit', 'el' => 'Υποβολή'], + 'review.stars_count' => ['en' => '{1} :count star|[2,*] :count stars', 'el' => '{1} :count αστέρι|[2,*] :count αστέρια'], + 'review.no_reviews_yet' => ['en' => 'No reviews yet.', 'el' => 'Δεν υπάρχουν αξιολογήσεις ακόμα.'], + 'review.write_first' => ['en' => 'Write the first review', 'el' => 'Γράψε την πρώτη'], + 'review.write_new' => ['en' => 'Add a review', 'el' => 'Πρόσθεσε μια'], + 'review.for_product' => ['en' => 'review for ":name"', 'el' => 'αξιολόγηση για το «:name»'], + 'shop.showing_results' => [ + 'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results', + 'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα', + ], + 'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'], + 'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'], + 'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'], + 'shop.sort_price_asc' => ['en' => 'Price: Low to High', 'el' => 'Τιμή: Αύξουσα'], + 'shop.sort_price_desc' => ['en' => 'Price: High to Low', 'el' => 'Τιμή: Φθίνουσα'], + 'shop.sort_newest' => ['en' => 'Newest', 'el' => 'Νεότερα'], + 'shop.no_products' => ['en' => 'No products found in this category.', 'el' => 'Δεν βρέθηκαν προϊόντα σε αυτή την κατηγορία.'], + 'shop.search_label' => ['en' => 'Search products', 'el' => 'Αναζήτηση προϊόντων'], + 'shop.search_placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτησε προϊόντα…'], + 'shop.filter_price' => ['en' => 'Filter by price', 'el' => 'Φίλτρο τιμής'], + 'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'], + 'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'], + 'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'], + ]; + } +} diff --git a/src/Review/Extensions/ProductResourceExtension.php b/src/Review/Filament/Extensions/ProductResourceExtension.php similarity index 78% rename from src/Review/Extensions/ProductResourceExtension.php rename to src/Review/Filament/Extensions/ProductResourceExtension.php index 120154e..9d36031 100644 --- a/src/Review/Extensions/ProductResourceExtension.php +++ b/src/Review/Filament/Extensions/ProductResourceExtension.php @@ -1,9 +1,9 @@ Date: Thu, 27 Aug 2026 23:09:33 +0300 Subject: [PATCH 034/110] Feature: Adding Facets, Updating Indexers --- docs/collections.md | 15 +++++ docs/product-listing.md | 36 ++++++++++- src/Catalog/DTOs/ProductFilters.php | 1 + src/Catalog/Services/CollectionIndexer.php | 24 ++++++- src/Catalog/Services/ProductIndexer.php | 10 +++ src/Catalog/Services/ProductService.php | 75 ++++++++++++++++++++-- 6 files changed, 152 insertions(+), 9 deletions(-) diff --git a/docs/collections.md b/docs/collections.md index 6b15831..affba4f 100644 --- a/docs/collections.md +++ b/docs/collections.md @@ -21,6 +21,8 @@ nowhere near enough for a storefront category page or a nav tree. | `collection_group_id` | `$model->collection_group_id` | Filterable. Mirrors `Collection::scopeInGroup()`. | | `slugs` | `$model->urls->pluck('slug')` | Filterable. Every locale's `Url::slug`, so `getBySlug()` resolves purely from the index. | | `thumbnail` | `$model->getThumbnailImage()` | Display only. `null` if the collection has no thumbnail image. | +| `ancestors` | `$model->ancestors` | Display only. Array of `{id, name}`, ordered root-first — a breadcrumb (`Home > Apparel > Keychains`) can render directly from a single `getById()`/`getBySlug()` call, no extra queries. Empty array for a top-level collection. | +| `product_count` | Queried from the *product* Meilisearch index at collection-index time | Display only. How many products are in this collection **or any of its descendants** — matches what `ProductService::list(ProductFilters(collectionId: ...))` would return, not just direct assignment. Computed via `Product::search('')->options(['filter' => "collection_ids = \"{id}\""])`, so it depends on the product index already being current — reindex products *before* collections (see "Gotchas" below). | `name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale by Lunar's base indexer and resolved by `CollectionService` exactly like @@ -87,6 +89,19 @@ lunar:meilisearch:setup` re-syncs index settings, and existing documents need the queue worker also needs restarting after deploying changes to the indexer class — see `docs/lunar.md` "Gotchas". +**`product_count` needs the product index reindexed first.** `config/lunar/search.php`'s +`indexers` array is typically ordered `Collection` before `Product`, so a plain +`lunar:search:index --refresh` computes `product_count` against whatever the product +index held *before* this run — stale if products changed too. `lunar:search:index` +takes an explicit model list as its argument (`--ignore` restricts it to only those), +so reindex products first, then collections, when both need a fresh `--refresh` in the +same deploy: + +``` +php artisan lunar:search:index "Lunar\Models\Product" --ignore --refresh +php artisan lunar:search:index "Lunar\Models\Collection" --ignore --refresh +``` + --- ## When to still use Eloquent directly diff --git a/docs/product-listing.md b/docs/product-listing.md index 2332acc..d422d69 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -33,9 +33,9 @@ $service = app(ProductService::class); // "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator. $products = $service->list(perPage: 24, page: 1); -// Filter by collection, brand, and/or price range +// Filter by collection, brand, price range, and/or stock $products = $service->list( - filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0), + filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true), perPage: 24, page: 1, ); @@ -56,10 +56,41 @@ $product = $service->getById(367); // array, or null if not found // Single product, by URL slug (any locale — slugs are indexed across all languages) $product = $service->getBySlug('erotika-mprelok'); // array, or null if not found + +// Facet counts for a sidebar — value => matching product count, scoped to whatever +// $filters is passed. Does NOT exclude the faceted field itself from $filters — see +// facets()'s docblock for why, and how to build a standard "every option's count, +// unaffected by that option's own currently-selected value" sidebar. +$brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17)); +// ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135] + +// Min/max price across matching products, for sizing a price-range slider. +// minPrice/maxPrice are ALWAYS excluded from the filter driving this (unlike +// facets(), which doesn't auto-exclude) — the slider's own bounds shouldn't shrink +// to whatever range is currently selected on it. Other filters (collectionId, +// brand, inStockOnly) still apply normally. +$range = $service->priceRange(new ProductFilters(collectionId: 17)); +// ['min' => 0.0, 'max' => 120.0] ``` All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query. +`facets()` only makes sense on discrete-value filterable fields (`brand`, `in_stock`) — a numeric +field like `price` would return one "facet" per exact price, not a usable range bucket. Use +`priceRange()` for `price` instead, which reads Meilisearch's `facetStats` (min/max), a different +feature from `facetDistribution`. + +--- + +## Stock goes stale between orders + +`in_stock` reflects `ProductVariant::stock`/`purchasable` as of the **last reindex**, not live +inventory. Nothing in this codebase currently reindexes a product when an order decrements its +stock — that's a cart/checkout concern, not something `ProductIndexer` can solve on its own (see +`Modules\Core\Catalog\Observers\ProductOptionReindexObserver` for the equivalent pattern once an +order → stock → reindex pipeline exists to hook into). Until then, `in_stock`/`product_count` can +drift from the database the same way every other indexed field already can between writes. + --- ## Fields this depends on: `Modules\Core\Catalog\Services\ProductIndexer` @@ -81,6 +112,7 @@ needs, listing and detail alike: | `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. | | `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). | | `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. | +| `in_stock` | `$model->variants` | Filterable boolean. `true` if ANY variant currently passes `ProductVariant::canBeFulfilledAtQuantity(1)` — Lunar's own purchasability rule (`purchasable === 'always'` ignores stock entirely; `in_stock` checks `stock` alone; anything else checks `stock + backorder`). Only as fresh as the last reindex — see "Stock goes stale" below. | `name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see "Locale resolution" below for how `ProductService` resolves them down to one value per request. diff --git a/src/Catalog/DTOs/ProductFilters.php b/src/Catalog/DTOs/ProductFilters.php index 902aa1a..6bdcaf0 100644 --- a/src/Catalog/DTOs/ProductFilters.php +++ b/src/Catalog/DTOs/ProductFilters.php @@ -23,5 +23,6 @@ class ProductFilters public readonly ?string $brand = null, public readonly ?float $minPrice = null, public readonly ?float $maxPrice = null, + public readonly bool $inStockOnly = false, ) {} } diff --git a/src/Catalog/Services/CollectionIndexer.php b/src/Catalog/Services/CollectionIndexer.php index 117a63e..29eb4ef 100644 --- a/src/Catalog/Services/CollectionIndexer.php +++ b/src/Catalog/Services/CollectionIndexer.php @@ -5,6 +5,7 @@ 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; /** @@ -20,6 +21,15 @@ use Lunar\Search\CollectionIndexer as BaseCollectionIndexer; * - 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 @@ -49,7 +59,7 @@ class CollectionIndexer extends BaseCollectionIndexer public function makeAllSearchableUsing(Builder $query): Builder { - return parent::makeAllSearchableUsing($query)->with(['urls', 'media']); + return parent::makeAllSearchableUsing($query)->with(['urls', 'media', 'ancestors']); } public function toSearchableArray(Model $model): array @@ -63,6 +73,18 @@ class CollectionIndexer extends BaseCollectionIndexer $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; } diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php index 79f6ed3..d4b1011 100644 --- a/src/Catalog/Services/ProductIndexer.php +++ b/src/Catalog/Services/ProductIndexer.php @@ -37,6 +37,12 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media; * - channel_ids (filterable) — Lunar's base indexer only indexes "status" as * filterable, not channel assignment, so search results can't otherwise be * scoped to products actually assigned+enabled on the current sales channel + * - in_stock (filterable) — true if ANY variant can currently be purchased at + * quantity 1, via ProductVariant::canBeFulfilledAtQuantity() (Lunar's own + * purchasability rule: `purchasable === 'always'` is always true regardless of + * stock, `in_stock` checks stock alone, anything else checks stock+backorder). + * Reflects stock as of the last reindex only — nothing currently reindexes a + * product when an order decrements its stock (see docs/product-listing.md). * * A review is created/edited independently of its product (Modules\Core\Providers\ * ReviewServiceProvider re-indexes the product on review create/update/delete), so @@ -60,6 +66,7 @@ class ProductIndexer extends BaseProductIndexer 'price', 'slugs', 'channel_ids', + 'in_stock', ]; } @@ -116,6 +123,9 @@ class ProductIndexer extends BaseProductIndexer ->wherePivot('enabled', true) ->pluck('lunar_channels.id') ->toArray(); + $data['in_stock'] = $model->variants->contains( + fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1) + ); return $data; } diff --git a/src/Catalog/Services/ProductService.php b/src/Catalog/Services/ProductService.php index b820b07..616426e 100644 --- a/src/Catalog/Services/ProductService.php +++ b/src/Catalog/Services/ProductService.php @@ -60,6 +60,60 @@ class ProductService ); } + /** + * Facet value counts for the given filter/field, scoped to the SAME filters + * `list()` would apply. Note this does NOT exclude `$field` itself from + * `$filters` — e.g. `facets('brand', new ProductFilters(brand: 'Acme'))` would + * scope the counts to only "Acme" already, collapsing every other brand's count + * to whatever remains under that filter. For a standard "faceted sidebar" (every + * brand's count reflecting collection/price/stock filters but NOT the brand + * filter itself), build a `$filters` that omits the field being faceted on and + * apply that field's own filter separately in the UI/query layer. + * + * `$field` must be one of ProductIndexer's filterable fields; only discrete-value + * fields make sense here (`brand`, `in_stock`) — a numeric field like `price` + * would return one "facet" per exact price, not a usable range bucket. Use + * `priceRange()` for `price` instead. + * + * @return array facet value => matching product count + */ + public function facets(string $field, ?ProductFilters $filters = null): array + { + return $this->rawFacets($field, $this->buildFilter($filters))['facetDistribution'][$field] ?? []; + } + + /** + * The min/max `price` across products matching the given filters (minus + * `minPrice`/`maxPrice` themselves, same "scoped but not self-collapsing" + * reasoning as `facets()` — a price slider's own bounds shouldn't shrink to + * whatever range is currently selected). Backed by Meilisearch's `facetStats`, + * not `facetDistribution` — the right feature for a numeric field's range, + * where `facets('price')` would otherwise return one entry per exact price. + * + * @return array{min: ?float, max: ?float} null/null if no product matches + */ + public function priceRange(?ProductFilters $filters = null): array + { + $filter = $this->buildFilter($filters, exclude: ['price']); + $stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null; + + return [ + 'min' => $stats['min'] ?? null, + 'max' => $stats['max'] ?? null, + ]; + } + + private function rawFacets(string $field, ?string $filter): array + { + return Product::search('') + ->options([ + 'filter' => $filter, + 'facets' => [$field], + 'hitsPerPage' => 0, + ]) + ->raw(); + } + /** * Look up a single product by its URL slug (any locale - slugs are indexed across * all languages, see Modules\Core\Catalog\Services\ProductIndexer). Returns the full @@ -150,18 +204,27 @@ class ProductService return collect($rawResponse['hits'] ?? [])->values()->all(); } - private function buildFilter(?ProductFilters $filters): ?string + /** + * @param array $exclude filter + * fields to leave out even if set on $filters — e.g. priceRange() excludes + * 'price' so a price slider's own bounds don't shrink to whatever range is + * already selected on it. + */ + private function buildFilter(?ProductFilters $filters, array $exclude = []): ?string { if ($filters === null) { return null; } $clauses = Collection::make([ - $filters->collectionId !== null ? "collection_ids = \"{$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(); + 'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null, + 'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, + 'price' => Collection::make([ + $filters->minPrice !== null ? "price >= {$filters->minPrice}" : null, + $filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null, + ])->filter()->join(' AND ') ?: null, + 'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null, + ])->except($exclude)->filter(); return $clauses->isEmpty() ? null : $clauses->join(' AND '); } From e1299fafeec3c4c71f327b62f9c4f50e62adf471 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 23:09:52 +0300 Subject: [PATCH 035/110] Bump Version to 0.7.0 --- CHANGELOG.md | 15 +++++++++++++++ composer.json | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3950e1a..90ae4d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.7.0] - 2026-08-27 + +### Added +- `Modules\Core\Catalog\Services\CollectionService` provides category browsing/nav AND single-collection lookup from Meilisearch, mirroring `ProductService` exactly (`list()`, `getById()`, `getBySlug()`, same locale-resolution logic). `Modules\Core\Catalog\Services\CollectionIndexer` extends Lunar's own `Lunar\Search\CollectionIndexer` (which only carried `id`/`name`/`created_at`) to add `parent_id`, `_lft`/`_rgt` (nested-set tree position, filterable/sortable), `collection_group_id`, `slugs`, and `thumbnail`. `Modules\Core\Catalog\DTOs\CollectionFilters` supports `parentId` (children of a specific collection), `groupId`, and `rootOnly` (top-level collections, `parent_id IS NULL` — mutually exclusive with `parentId`). `Modules\Core\Catalog\Enums\CollectionSort` adds `Position` (`_lft:asc`, the recommended default for nav/tree UIs — matches admin arrangement order), `Name`, `Newest`. Must be registered in a consuming app's `config/lunar/search.php` (`Lunar\Models\Collection::class => CollectionIndexer::class`), same as `ProductIndexer`. Documented in `docs/collections.md`. +- `Modules\Core\Localization\Services\StorefrontLabels::all()` extracts the default storefront UI label list out of `InstallLunarCommand` into its own class, and adds every previously-missing key (`nav.contact`, `product.description`/`no_image`/`read_more`/`reviews`, `customer_reviews`, `pagination.*`, `review.*`, `shop.*`) that had already been seeded manually in some stores but was absent from the command's own list — bringing the code-side default back in sync with what a real store actually has. `InstallLunarCommand::seedStorefrontLabels()` now does a **per-key upsert** instead of 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 **Language Lines** resource) is left untouched, and only missing keys are created via `TranslationService::create()`. This makes it safe to add new keys to `StorefrontLabels::all()` later and re-run `lunar:install` on an already-installed store without either silently skipping the new keys (the old guard's behavior) or reverting an admin's edits back to the hardcoded default. Documented in `docs/localization.md` ("Seeding"). +- `Modules\Core\Catalog\Services\CollectionIndexer` adds `ancestors` — `[{id, name}, ...]` ordered root-first (via the newly eager-loaded `ancestors` relation) — so a breadcrumb can render directly from `CollectionService::getById()`/`getBySlug()` with zero extra queries, and `product_count` — how many products are in a collection or any of its descendants, queried from the product Meilisearch index at collection-index time via the same `collection_ids` field `ProductFilters(collectionId:)` filters against. Documented in `docs/collections.md`, including the reindex-ordering gotcha (`product_count` needs the product index reindexed first). +- `Modules\Core\Catalog\Services\ProductIndexer` adds a filterable `in_stock` boolean — `true` if any variant currently passes `ProductVariant::canBeFulfilledAtQuantity(1)` (Lunar's own purchasability rule, not a naive `stock > 0` check). `Modules\Core\Catalog\DTOs\ProductFilters` gets a matching `inStockOnly` flag. Reflects stock as of the last reindex only — nothing currently reindexes a product when an order decrements its stock, since that's a cart/checkout concern this doesn't attempt to solve; see `docs/product-listing.md` ("Stock goes stale between orders"). +- `Modules\Core\Catalog\Services\ProductService::facets(string $field, ?ProductFilters $filters = null): array` returns Meilisearch facet value counts (e.g. `['Brand A' => 48, 'Brand B' => 135]`) for a discrete-value filterable field, scoped to the given filters. Uses Scout's plain `->options(['facets' => [...]])`, merged directly into the raw Meilisearch query the same way `filter`/`sort` already are — no adoption of Lunar's separate `SearchManager`/`Search` facade needed. `ProductService::priceRange(?ProductFilters $filters = null): array{min, max}` covers the numeric-field case `facets()` explicitly doesn't (`price` would otherwise return one "facet" per exact price) — backed by Meilisearch's `facetStats`, not `facetDistribution`. `priceRange()` always excludes `minPrice`/`maxPrice` from the filter it builds (via a new `$exclude` parameter on the private `buildFilter()`), so a price slider's own bounds don't shrink to whatever range is already selected on it; other filters (`collectionId`, `brand`, `inStockOnly`) still apply normally. Documented in `docs/product-listing.md`. + +### Changed +- **Breaking:** Renamed the `Product` module to `Catalog`, flattened. Every class under `Modules\Core\Product\*` (`Contracts`, `DTOs`, `Enums`, `Services`, `Observers`, `Filament\Extensions`, `OptionTypes`) now lives under `Modules\Core\Catalog\*` at the same sub-path — e.g. `Modules\Core\Product\Services\ProductService` is now `Modules\Core\Catalog\Services\ProductService`, `Modules\Core\Product\DTOs\ProductFilters` is now `Modules\Core\Catalog\DTOs\ProductFilters`. Class names themselves are unchanged (still `ProductService`, `ProductIndexer`, `ProductFilters`, etc.) — only the namespace/folder moved, to make room for `Collection` as a sibling concern under the same `Catalog` umbrella rather than a disconnected top-level module. Consuming apps must update every `use Modules\Core\Product\...` import and any FQCN reference (`config/lunar/search.php`'s indexer registration, service provider bindings). +- **Breaking:** `Modules\Core\Providers\ProductServiceProvider` renamed to `Modules\Core\Providers\CatalogServiceProvider` (composer.json's provider list updated accordingly) — it now only wires `Catalog`-namespace classes (`ProductOptionTypeManager`, `ProductOptionReindexObserver`), so the name follows the same by-concern convention as `LocalizationServiceProvider`/`ReviewServiceProvider`. +- **Breaking:** `Modules\Core\Review`'s flat `Extensions/`/`Pages/` folders now nest under `Filament/`, matching the strict per-concern subfolder convention already applied to `Product`(now `Catalog`)/`Localization`. `Modules\Core\Review\Extensions\ProductResourceExtension` is now `Modules\Core\Review\Filament\Extensions\ProductResourceExtension`; `Modules\Core\Review\Pages\ManageProductReviews` is now `Modules\Core\Review\Filament\Pages\ManageProductReviews`. `Modules\Core\Review\Models\ProductReview` is unchanged. +- **Breaking:** `ProductFilters(collectionId: ...)` now matches a product in that collection **or any of its descendant collections**, not just direct assignment. Products in a Shopify-imported tree are typically attached only to leaf collections, so filtering strictly on direct assignment meant a parent/root category page (`CollectionFilters(rootOnly: true)`'s results, or any non-leaf collection) always returned zero products even though real products existed several levels down. `Modules\Core\Catalog\Services\ProductIndexer` adds a new filterable `collection_ids` field — every directly-assigned collection's id unioned with all of its ancestors' ids (via the newly eager-loaded `collections.ancestors`) — and `ProductService::buildFilter()` now filters `collectionId` against `collection_ids` instead of the old `collections.id`. The display-only `collections` field (`{id, name}`, direct assignments) is unchanged and no longer filterable. + ## [0.6.1] - 2026-08-27 ### Added diff --git a/composer.json b/composer.json index 24fd660..af6fb8e 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.6.1", + "version": "0.7.0", "autoload": { "psr-4": { "Modules\\Core\\": "src/" From a8ddbb8056c7495612d09d656969cfa0d19a8b1d Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Fri, 28 Aug 2026 00:27:46 +0300 Subject: [PATCH 036/110] Feature: Creating Cart Views, rules for abandonment --- config/core.php | 16 +++ docs/cart.md | 110 ++++++++++++++++ docs/lunar.md | 59 ++++++++- src/Cart/Filament/Resources/CartResource.php | 118 ++++++++++++++++++ .../CartResource/Pages/ListCarts.php | 40 ++++++ .../Resources/CartResource/Pages/ViewCart.php | 112 +++++++++++++++++ src/CorePlugin.php | 2 + 7 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 docs/cart.md create mode 100644 src/Cart/Filament/Resources/CartResource.php create mode 100644 src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php create mode 100644 src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php diff --git a/config/core.php b/config/core.php index 5e0f027..aad0445 100644 --- a/config/core.php +++ b/config/core.php @@ -16,4 +16,20 @@ return [ 'auto_create_customer_for_user' => true, + /* + |-------------------------------------------------------------------------- + | Cart Abandonment Threshold + |-------------------------------------------------------------------------- + | + | How long a cart (that hasn't converted to a placed order) can go without + | activity before Modules\Core\Cart\Filament\Resources\CartResource treats + | it as "Abandoned" rather than "Ongoing". Anything DateInterval::createFromDateString() + | accepts works, e.g. '1 hour', '30 minutes', '2 days'. + | + */ + + 'cart' => [ + 'abandoned_after' => '1 hour', + ], + ]; diff --git a/docs/cart.md b/docs/cart.md new file mode 100644 index 0000000..72cef0d --- /dev/null +++ b/docs/cart.md @@ -0,0 +1,110 @@ +# Cart Admin Visibility + +`Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into +customer/user carts in the Filament admin panel. Lunar itself ships no cart admin view at +all — no Filament resource for `Cart`/`CartLine` exists anywhere in `lunarphp/lunar` or +`lunarphp/core` — this is a from-scratch addition, not an extension of something Lunar +half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the underlying Lunar cart +mechanics this resource reads from. + +--- + +## Scope: only carts with a known customer or user + +`CartResource::getEloquentQuery()` filters to `Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')` +— an anonymous guest's session cart is excluded entirely. + +This was a deliberate call, not an oversight: an anonymous cart carries no identity a staff +member could act on — no name, no email, nothing to follow up with — so listing every guest +session cart would be noise, not a real admin capability. This does **not** mirror Shopify's +admin (Shopify has no "all carts" view at all — only "Abandoned checkouts," gated on a +shopper reaching checkout and entering contact info, a later/narrower stage than Lunar's +`Cart`). Lunar's own `Cart` model already gets `user_id`/`customer_id` set the moment a +shopper is authenticated (via `Lunar\Listeners\CartSessionAuthListener` on login), with no +checkout step required — so scoping to "identifiable" here is broader than Shopify's +equivalent, not a copy of it. + +--- + +## "Abandoned" vs "Completed" — not `Cart::completed_at` + +`Lunar\Models\Cart::completed_at` is declared and cast (`'completed_at' => 'datetime'`) but +**never actually written anywhere in Lunar core** — grep `vendor/lunarphp/core/src` for it; +the only hits are the property declaration and the cast. It is not a real signal. + +The list page's tabs (`ListCarts::getTabs()`) instead key off whether the cart has a +**placed** order: + +- **Abandoned** — mirrors `Cart::scopeActive()` exactly: no orders at all, or only orders + still in draft (`placed_at IS NULL`). Default active tab on page load. +- **Completed** — has at least one order with `placed_at IS NOT NULL`. + +```php +// Abandoned +$query->active(); + +// Completed +$query->whereHas('orders', fn ($query) => $query->whereNotNull('placed_at')); +``` + +There is deliberately **no "All" tab.** Every row shown is always scoped to one of the two +states above — the list never runs an unfiltered `Cart::query()->get()` over the whole +(potentially large) table. + +--- + +## Why this scales fine at a large cart count + +Two things keep this cheap regardless of how many carts exist (10,000+): + +- **The list is always paginated.** Filament applies `LIMIT`/`OFFSET` to whichever tab's + query is active — a page only ever fetches one page's worth of rows, never the whole + table, "All" tab or not (and there is no "All" tab — see above). +- **No per-row queries.** `lines_count`/`lines_sum_quantity` use Filament's built-in + `->counts('lines')`/`->sum('lines', 'quantity')`, which fold into the same query as the + rest of the list (one `LEFT JOIN`-based aggregate, not N separate lookups). There's no + per-record `getStateUsing()` closure anywhere in this table doing its own query — that's + the pattern to avoid if a future column needs derived data (see `Modules\Core\Catalog\ + Services\ProductIndexer` for the general "compute once at index time / one aggregate + query, never per-row" principle this project follows elsewhere). + +The one thing that **does** scan more rows as the cart count grows is +`CartResource::getNavigationBadge()` (see below) — but it's a `COUNT(*)`, not a fetch, and +runs once per admin page load, not once per cart row. + +--- + +## Navigation badge — abandoned cart count + +```php +public static function getNavigationBadge(): ?string +{ + return (string) static::getEloquentQuery()->active()->count(); +} +``` + +Shows the number of abandoned carts (not all carts — a converted cart isn't something a +staff member needs to keep noticing) next to "Carts" in the sidebar. `->count()` compiles to +a single `SELECT COUNT(*) ...` — confirmed via query log — no rows are ever loaded just to +render the badge. + +--- + +## The view page runs the cart's full calculate pipeline — once + +`ViewCart::resolveRecord()` calls `$cart->calculate()` before rendering, since `CartLine`'s +computed properties (`unitPrice`, `total`, etc.) and `Cart`'s own totals (`subTotal`, `total`, +...) are plain public properties populated as a side effect of that pipeline — never +persisted, so a plain Eloquent-fetched `Cart` has them all `null`/unset (see `docs/lunar.md` +Gotchas). This only runs on the single-record view page, not per row in the list table — +running the full 5-step pipeline for every row of a paginated list would be needless cost for +data the list doesn't display. + +--- + +## Not built: staff editing a cart + +The resource is deliberately read-only (`canCreate()` returns `false`, no edit page +registered). A cart is owned by the storefront's own add/update/remove flow +(`CartSession`/`Cart::add()`/etc.) — hand-editing cart contents from the admin panel isn't a +supported use case here. diff --git a/docs/lunar.md b/docs/lunar.md index a6a9e8d..f21eb27 100644 --- a/docs/lunar.md +++ b/docs/lunar.md @@ -554,7 +554,11 @@ Customer resolution order: session → `$user->latestCustomer()`. ```php use Lunar\Facades\CartSession; -$cart = CartSession::current(); // calculates totals; returns null if no cart +$cart = CartSession::current(); // returns null unless a cart already exists in + // session — does NOT auto-create one (see Gotchas) +$cart = CartSession::manager(); // force-creates a cart if none exists yet — use + // this (or __call forwarding, see Gotchas) for + // "give me a cart to add to" flows $cart->recalculate(); // force recalculation CartSession::createOrder(); // creates order, removes cart from session @@ -563,6 +567,39 @@ CartSession::forget(); // clear session (soft deletes cart by def CartSession::forget(delete: false); // clear session, keep cart in DB ``` +Session/identity: the active cart's id is stored under session key `lunar.cart_session.session_key` +(default `lunar_cart`). `CartSession`'s underlying manager (`Lunar\Managers\CartSessionManager`) — +not `Lunar\Base\CartSessionInterface`, which is stale/incomplete, see Gotchas — resolves the current +cart from that session key, falling back to the authenticated user's active cart +(`$user->carts()->active()->first()`) if the session has none. + +### `config/lunar/cart_session.php` + +| Key | Default | Meaning | +|---|---|---| +| `session_key` | `'lunar_cart'` | Laravel session key storing the active cart id. | +| `auto_create` | `false` | Whether `CartSession::current()` auto-creates a cart when none exists — it does **not**, by default (see Gotchas). | +| `allow_multiple_orders_per_cart` | `false` | If false, a cart with a completed order is abandoned in favor of a fresh cart on next fetch. | +| `delete_on_forget` | `true` | Whether `forget()` (called on logout) soft-deletes the cart — see the auth-policy note above. | + +### `config/lunar/cart.php` (cart-line-relevant keys) + +| Key | Default | Meaning | +|---|---|---| +| `auth_policy` | `'merge'` | Guest→user cart reconciliation on login: `merge` or `override`. | +| `pipelines.cart` | `CalculateLines, ApplyShipping, ApplyDiscounts, CalculateTax, Calculate` | Steps run on `$cart->calculate()`. | +| `pipelines.cart_lines` | `[GetUnitPrice::class]` | Steps run per-line before cart-level calc. | +| `actions.add_to_cart` | `AddOrUpdatePurchasable::class` | Swappable action behind `Cart::add()`. | +| `actions.get_existing_cart_line` | `GetExistingCartLine::class` | Line-matching logic for add-or-merge (see "Adding items" above). | +| `actions.update_cart_line` | `UpdateCartLine::class` | Behind `Cart::updateLine()`. | +| `actions.remove_from_cart` | `RemovePurchasable::class` | Behind `Cart::remove()`. | +| `validators.add_to_cart` | `[CartLineQuantity, CartLineStock]` | Run before add. | +| `validators.update_cart_line` | `[CartLineQuantity, CartLineStock]` | Run before update. | +| `validators.remove_from_cart` | `[]` | None by default. | +| `eager_load` | 7 relation paths (currency, `lines.purchasable.*`, `lines.cart.currency`) | Auto-eager-loaded whenever the session manager fetches a cart by id. Does **not** include `addresses`/`shippingAddress`/`billingAddress`, `discounts`, or `customer` — add these yourself if needed, to avoid N+1s. | +| `prune_tables.enabled` | `false` | Whether scheduled cart pruning runs. | +| `prune_tables.prune_interval` | `90` (days) | Age threshold for pruning. | + ### Adding items ```php @@ -573,6 +610,11 @@ $cart->addLines([ ]); ``` +`add()` matches an existing line by purchasable **and exact `meta` equality** (config +`lunar.cart.actions.get_existing_cart_line`, default `GetExistingCartLine`) — if it matches, the +existing line's quantity is incremented instead of a new line being created; any difference in +`meta` (e.g. a different chosen option) makes it a separate line for the same purchasable. + ### Updating and removing ```php @@ -664,6 +706,14 @@ class MyPipeline `merge` — guest cart items combine with user's existing cart on login. `override` — guest cart replaces user's cart. +This is wired via `Lunar\Listeners\CartSessionAuthListener`, listening on Laravel's own +`Illuminate\Auth\Events\Login`/`Logout`. On login, if the session already has a cart with no +`user_id` yet, it associates that cart to the user (running the policy above); if the session has +no cart at all, it looks up and resumes the user's own active cart instead. **On logout, it calls +`CartSession::forget()`** — which, per `cart_session.delete_on_forget` (default `true`), **soft- +deletes the cart**. A logged-in customer's cart is gone on logout unless that config is set to +`false`. + ### Shipping options ```php @@ -1209,3 +1259,10 @@ Real bugs/traps hit while building against Lunar in this package — not obvious - **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Catalog\Services\ProductService` / `docs/product-listing.md`. - **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Catalog\Services\ProductIndexer::translatedName()`. - **A running `queue:work` process does not pick up an edited/newly-added Scout indexer class.** It loads PHP classes once at boot and keeps them for the process's lifetime. Symptoms: reindexing commands succeed with no errors, calling `toSearchableArray()` directly (e.g. via `artisan tinker`, which always boots fresh) returns the new fields correctly, but documents written via `$model->searchable()` through the live queue are still missing them. Restart the queue worker after deploying an indexer change — no code fix needed. +- **`CartSession::current()` returns `null` for a fresh visitor by default.** `cart_session.auto_create` defaults to `false`, so nothing auto-creates a cart just from checking `current()`. Use `CartSession::manager()` (force-creates) for an "add to cart" flow, or rely on the fact that `add()`/`remove()`/etc. auto-create via `__call` forwarding (next entry) — don't gate an add-to-cart button on `current() !== null`, it will be null for every guest who hasn't added anything yet. +- **`CartSession`'s facade/interface don't declare `add()`, `remove()`, `updateLine()`, `clear()`, etc. at all — they work anyway, via `__call` magic.** `CartSessionManager::__call()` forwards any undeclared method call straight to the underlying `Cart` model (auto-creating one first if needed). So `CartSession::add($variant, 2)` genuinely works, but neither the facade's `@method` docblock nor `Lunar\Base\CartSessionInterface` mention it — reading either in isolation makes it look unsupported. Trust the manager's source (`Lunar\Managers\CartSessionManager`), not the interface, which is also missing several real methods (`manager()`, `createOrder()`, the shipping-estimate methods) and has a stale signature for `current()`. +- **`Cart::calculate()` is a no-op if totals already look populated — even right after you mutated lines with raw Eloquent.** It's memoized via `isCalculated()` (true when `total` and every line's `total` are non-blank). Every built-in mutator (`add`, `remove`, `updateLine`, `clear`, `associate`, …) already calls `$this->refresh()->recalculate()` to force past this memo — but custom code that touches `CartLine` rows directly (raw `update()`, a queued job, a migration) must call `$cart->recalculate()` itself, or `total`/`subTotal`/etc. silently stay stale. +- **`CartLine`'s computed properties (`unitPrice`, `subTotal`, `total`, `taxAmount`, …) are plain public properties, not DB columns or Eloquent attributes.** A raw `CartLine::find($id)` (no `calculate()` having run on its owning cart) has all of these as `null`/unset — they only populate as a side effect of the owning `Cart`'s pipeline running. Don't read them off a line fetched outside of `CartSession`/`Cart::add()` etc. without calling `$cart->calculate()` first. +- **Logging out deletes the cart by default.** `CartSessionAuthListener::logout()` calls `CartSession::forget()`, and `cart_session.delete_on_forget` defaults to `true` — so a logged-in customer's cart is soft-deleted the moment they log out, guest or not. Set `delete_on_forget` to `false` in `config/lunar/cart_session.php` if carts should survive a logout. +- **Lunar dispatches no cart events at all** — no "item added," "cart created," "line removed," nothing under `Lunar\Events\Cart*`/`CartLine*` exists (unlike products/collections, which have their own Scout indexing hooks). The only reactive surface is `CartLineObserver` (`creating`/`updating`, and it only validates the purchasable type — doesn't dispatch anything). If a feature needs to react to cart changes (reindexing, abandoned-cart notifications, analytics), it has to be built from scratch on plain Eloquent model events (`CartLine::created`, etc.) — there's no Lunar-native pattern to hook into. +- **No Filament admin resource exists for `Cart`/`CartLine`.** Carts aren't visible anywhere in the admin panel except indirectly through an order's `cart` relationship once that cart has become an order. Don't assume there's an admin cart-viewer to check against when debugging — there isn't one. diff --git a/src/Cart/Filament/Resources/CartResource.php b/src/Cart/Filament/Resources/CartResource.php new file mode 100644 index 0000000..cb00c5b --- /dev/null +++ b/src/Cart/Filament/Resources/CartResource.php @@ -0,0 +1,118 @@ +where(fn (Builder $query) => $query->whereNotNull('user_id')->orWhereNotNull('customer_id')); + } + + /** + * Count only, not a fetch — no rows are loaded. Scoped to genuinely abandoned + * carts specifically (mirrors ListCarts::getTabs()'s "Abandoned" query, not + * "Ongoing"), since that's the number a staff member glancing at the sidebar + * actually wants: how many carts might need following up on, not the total + * including ones someone is actively shopping in right now. + */ + public static function getNavigationBadge(): ?string + { + return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count(); + } + + /** + * `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very + * different things together: a cart someone is actively shopping in right now, + * and one that's genuinely been left behind. Lunar tracks no time-based + * staleness signal of its own — `Cart::updated_at` plus a configurable + * threshold (`config('core.cart.abandoned_after')`, default 1 hour) is what + * this resource uses to tell them apart. A cart with no recent activity is + * "Abandoned"; anything more recent is "Ongoing". + */ + public static function abandonedCutoff(): Carbon + { + return now()->sub(config('core.cart.abandoned_after', '1 hour')); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('Cart') + ->sortable(), + Tables\Columns\TextColumn::make('customer.full_name') + ->label('Customer') + ->placeholder('—') + ->searchable() + ->url(fn (Cart $record) => $record->customer_id !== null + ? CustomerResource::getUrl('view', ['record' => $record->customer_id]) + : null), + Tables\Columns\TextColumn::make('user.email') + ->label('User') + ->placeholder('—') + ->searchable(), + Tables\Columns\TextColumn::make('lines_count') + ->label('Lines') + ->counts('lines') + ->sortable(), + Tables\Columns\TextColumn::make('lines_sum_quantity') + ->label('Items') + ->sum('lines', 'quantity') + ->sortable(), + Tables\Columns\TextColumn::make('currency.code') + ->label('Currency'), + Tables\Columns\TextColumn::make('updated_at') + ->label('Last activity') + ->dateTime() + ->sortable(), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + ]) + ->defaultSort('updated_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListCarts::route('/'), + 'view' => Pages\ViewCart::route('/{record}'), + ]; + } + + public static function canCreate(): bool + { + return false; + } +} diff --git a/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php b/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php new file mode 100644 index 0000000..99ed9a3 --- /dev/null +++ b/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php @@ -0,0 +1,40 @@ + Tab::make('Ongoing') + ->modifyQueryUsing(fn (Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())), + 'abandoned' => Tab::make('Abandoned') + ->modifyQueryUsing(fn (Builder $query) => $query->active()->where('updated_at', '<=', CartResource::abandonedCutoff())), + 'completed' => Tab::make('Completed') + ->modifyQueryUsing(fn (Builder $query) => $query->whereHas( + 'orders', + fn (Builder $query) => $query->whereNotNull('placed_at'), + )), + ]; + } +} diff --git a/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php b/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php new file mode 100644 index 0000000..7ea4460 --- /dev/null +++ b/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php @@ -0,0 +1,112 @@ +label('View Customer') + ->icon('heroicon-o-user') + ->url(fn (Cart $record) => CustomerResource::getUrl('view', ['record' => $record->customer_id])) + ->visible(fn (Cart $record) => $record->customer_id !== null), + ]; + } + + /** + * Cart's computed properties (subTotal/total/etc.) are plain public properties + * populated as a side effect of the pipeline calculate() runs — never persisted, + * so they don't exist on a plain Eloquent-fetched record. Calculated once here + * (a single view page load), not per-row in the list table, since running the + * full pipeline for every row of a paginated table would be expensive for no + * real benefit — see docs/lunar.md's Cart gotchas. + */ + protected function resolveRecord(int|string $key): Cart + { + /** @var Cart $cart */ + $cart = parent::resolveRecord($key); + + return $cart->calculate(); + } + + public function infolist(Infolist $infolist): Infolist + { + return $infolist + ->schema([ + Section::make('Cart') + ->columns(3) + ->schema([ + TextEntry::make('id'), + TextEntry::make('customer.full_name') + ->label('Customer') + ->placeholder('—') + ->url(fn (Cart $record) => $record->customer_id !== null + ? CustomerResource::getUrl('view', ['record' => $record->customer_id]) + : null), + TextEntry::make('user.email') + ->label('User') + ->placeholder('—'), + TextEntry::make('currency.code') + ->label('Currency'), + TextEntry::make('completedOrderPlacedAt') + ->label('Ordered at') + ->state(fn (Cart $record) => $record->orders()->whereNotNull('placed_at')->value('placed_at')) + ->dateTime() + ->placeholder('Not ordered'), + TextEntry::make('updated_at') + ->label('Last activity') + ->dateTime(), + ]), + Section::make('Lines') + ->schema([ + RepeatableEntry::make('lines') + ->hiddenLabel() + ->schema([ + TextEntry::make('purchasable.sku') + ->label('SKU') + ->placeholder('—'), + TextEntry::make('quantity'), + TextEntry::make('unitPrice') + ->label('Unit price') + ->formatStateUsing(fn (CartLine $record) => $record->unitPrice?->formatted() ?? '—'), + TextEntry::make('total') + ->label('Line total') + ->formatStateUsing(fn (CartLine $record) => $record->total?->formatted() ?? '—'), + ]) + ->columns(4), + ]), + Section::make('Totals') + ->columns(3) + ->schema([ + TextEntry::make('subTotal') + ->label('Subtotal') + ->formatStateUsing(fn (Cart $record) => $record->subTotal?->formatted() ?? '—'), + TextEntry::make('discountTotal') + ->label('Discount') + ->formatStateUsing(fn (Cart $record) => $record->discountTotal?->formatted() ?? '—'), + TextEntry::make('taxTotal') + ->label('Tax') + ->formatStateUsing(fn (Cart $record) => $record->taxTotal?->formatted() ?? '—'), + TextEntry::make('total') + ->label('Total') + ->formatStateUsing(fn (Cart $record) => $record->total?->formatted() ?? '—') + ->weight('bold'), + ]), + ]); + } +} diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 12b8468..e18c627 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -17,6 +17,7 @@ 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; @@ -39,6 +40,7 @@ class CorePlugin implements Plugin ->login(Login::class) ->resources([ LanguageLineResource::class, + CartResource::class, ]) ->plugin(ShippingPlugin::make()); From f6ef0761d67f39aa554e9de56d1e8ea9c18e4688 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Fri, 28 Aug 2026 13:14:47 +0300 Subject: [PATCH 037/110] Feature: Creating Cart Service, Cart Events, Save For Later functionality, Adding Filament Views for viewing abandoned carts --- docs/cart.md | 184 ++++++++++++- docs/recovery-strategies.md | 128 +++++++++ src/Cart/Commands/DetectAbandonedCarts.php | 84 ++++++ src/Cart/Events/CartCleared.php | 18 ++ src/Cart/Events/CartCouponApplied.php | 13 + src/Cart/Events/CartCouponRemoved.php | 13 + src/Cart/Events/CartLineAdded.php | 14 + src/Cart/Events/CartLineMovedToCart.php | 18 ++ src/Cart/Events/CartLineRemoved.php | 18 ++ src/Cart/Events/CartLineSaved.php | 20 ++ src/Cart/Events/CartLineUpdated.php | 18 ++ .../Exceptions/InvalidCouponException.php | 19 ++ src/Cart/Filament/Resources/CartResource.php | 12 +- .../CartResource/Pages/ListCarts.php | 29 +- src/Cart/Pipelines/ZeroSavedForLaterPrice.php | 33 +++ src/Cart/Services/CartService.php | 250 ++++++++++++++++++ src/Providers/CartServiceProvider.php | 23 ++ src/Recovery/Events/CartAbandoned.php | 31 +++ src/Recovery/Events/CheckoutAbandoned.php | 33 +++ 19 files changed, 935 insertions(+), 23 deletions(-) create mode 100644 docs/recovery-strategies.md create mode 100644 src/Cart/Commands/DetectAbandonedCarts.php create mode 100644 src/Cart/Events/CartCleared.php create mode 100644 src/Cart/Events/CartCouponApplied.php create mode 100644 src/Cart/Events/CartCouponRemoved.php create mode 100644 src/Cart/Events/CartLineAdded.php create mode 100644 src/Cart/Events/CartLineMovedToCart.php create mode 100644 src/Cart/Events/CartLineRemoved.php create mode 100644 src/Cart/Events/CartLineSaved.php create mode 100644 src/Cart/Events/CartLineUpdated.php create mode 100644 src/Cart/Exceptions/InvalidCouponException.php create mode 100644 src/Cart/Pipelines/ZeroSavedForLaterPrice.php create mode 100644 src/Cart/Services/CartService.php create mode 100644 src/Providers/CartServiceProvider.php create mode 100644 src/Recovery/Events/CartAbandoned.php create mode 100644 src/Recovery/Events/CheckoutAbandoned.php diff --git a/docs/cart.md b/docs/cart.md index 72cef0d..f2d542d 100644 --- a/docs/cart.md +++ b/docs/cart.md @@ -26,31 +26,63 @@ equivalent, not a copy of it. --- -## "Abandoned" vs "Completed" — not `Cart::completed_at` +## Four states, not two — and not `Cart::completed_at` `Lunar\Models\Cart::completed_at` is declared and cast (`'completed_at' => 'datetime'`) but **never actually written anywhere in Lunar core** — grep `vendor/lunarphp/core/src` for it; -the only hits are the property declaration and the cast. It is not a real signal. +the only hits are the property declaration and the cast. It is not a real signal. `Cart` has +no `status` column at all — every state below is derived from relations/timestamps, not a +single field. -The list page's tabs (`ListCarts::getTabs()`) instead key off whether the cart has a -**placed** order: +`Cart::scopeActive()` (Lunar's own "not yet converted to an order" scope) actually mixes two +distinct states together: no order ever started, vs. a draft order exists +(`placed_at IS NULL`) but was never placed — checkout was started, not finished. Those are +different purchase-intent signals (see "Abandoned Cart vs Abandoned Checkout" below) and +different reachability (checkout usually captures an email even for a guest), so +`ListCarts::getTabs()` splits them into four tabs instead of `scopeActive()`'s two-state +split: -- **Abandoned** — mirrors `Cart::scopeActive()` exactly: no orders at all, or only orders - still in draft (`placed_at IS NULL`). Default active tab on page load. -- **Completed** — has at least one order with `placed_at IS NOT NULL`. +- **Ongoing** — `scopeActive()` and recent `updated_at` (within `abandonedCutoff()`). Default + active tab on page load. +- **Abandoned Cart** — `whereDoesntHave('orders')` and stale `updated_at`. +- **Abandoned Checkout** — has an order with `placed_at IS NULL`, and stale `updated_at`. +- **Completed** — has an order with `placed_at IS NOT NULL`. ```php -// Abandoned -$query->active(); +// Ongoing +$query->active()->where('updated_at', '>', CartResource::abandonedCutoff()); + +// Abandoned Cart +$query->whereDoesntHave('orders')->where('updated_at', '<=', CartResource::abandonedCutoff()); + +// Abandoned Checkout +$query->whereHas('orders', fn ($q) => $q->whereNull('placed_at')) + ->where('updated_at', '<=', CartResource::abandonedCutoff()); // Completed -$query->whereHas('orders', fn ($query) => $query->whereNotNull('placed_at')); +$query->whereHas('orders', fn ($q) => $q->whereNotNull('placed_at')); ``` -There is deliberately **no "All" tab.** Every row shown is always scoped to one of the two +There is deliberately **no "All" tab.** Every row shown is always scoped to one of the four states above — the list never runs an unfiltered `Cart::query()->get()` over the whole (potentially large) table. +### Abandoned Cart vs Abandoned Checkout — why they're not one bucket + +Different purchase intent, different reachability, and different recovery strategy — see +`docs/recovery-strategies.md` for the full marketing-strategy discussion. In short: + +- **Abandoned Cart** (no order started) is a weak intent signal — often window-shopping, not + a near-purchase. Frequently unreachable (no email/identity at all for a true guest). + Recovery leans on on-site retargeting and ad remarketing rather than email. +- **Abandoned Checkout** (draft order, never placed) is a strong intent signal — the shopper + committed to buying and something blocked completion. Checkout typically captures contact + info even for a guest, so this state is usually reachable. This is the state the + researched 1h/24h/72h recovery-email cadence targets specifically. + +`Modules\Core\Cart\Events\CartAbandoned` and `Modules\Core\Checkout\Events\CheckoutAbandoned` +mirror this same split (see "Events" below) rather than one combined event. + --- ## Why this scales fine at a large cart count @@ -108,3 +140,133 @@ The resource is deliberately read-only (`canCreate()` returns `false`, no edit p registered). A cart is owned by the storefront's own add/update/remove flow (`CartSession`/`Cart::add()`/etc.) — hand-editing cart contents from the admin panel isn't a supported use case here. + +--- + +## `CartService` — the storefront-facing API + +`Modules\Core\Cart\Services\CartService` mirrors `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. + +- `current()` / `currentOrCreate()` — the latter force-creates a cart (`CartSession::manager()`), + the former doesn't (`CartSession::current()`, returns `null` for a fresh visitor — see + `docs/lunar.md`'s Cart gotchas). +- `addLine()` / `updateLine()` / `removeLine()` / `clear()` — thin wrappers over + `Cart::add()`/`updateLine()`/`remove()`/`clear()`. No boboko-owned exception types wrap + Lunar's own cart exceptions (`InvalidCartLineQuantityException`, `CartLineIdMismatchException`, + etc.) — they propagate as-is; a wrapper would add indirection with identical semantics. +- `applyCoupon()` / `removeCoupon()` — sets/clears `Cart::coupon_code` (there's no dedicated + Lunar action for this, unlike add/update/remove). `applyCoupon()` validates via + `Discounts::validateCoupon()` first and throws `Modules\Core\Cart\Exceptions\ + InvalidCouponException` on a bad code — `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. +- `saveForLater()` / `moveToCart()` / `activeLines()` / `savedLines()` — see "Save for later" + below. + +Every mutating method returns the recalculated `Cart` (matching Lunar's own `Cart::add()` +etc., which already return `$this` after `refresh()->recalculate()`) and dispatches a +matching domain event. + +### Events — Lunar dispatches none of its own + +`Lunar` dispatches zero cart events — no "item added," no "cart created" (see +`docs/lunar.md`'s Cart gotchas). `CartService` fills that gap with its own, dispatched after +the underlying Lunar operation completes: + +`CartLineAdded`, `CartLineUpdated`, `CartLineRemoved`, `CartCleared`, `CartCouponApplied`, +`CartCouponRemoved`, `CartLineSaved`, `CartLineMovedToCart` — all under +`Modules\Core\Cart\Events`. `CartAbandoned`/`CheckoutAbandoned` live under +`Modules\Core\Recovery\Events` instead, not `Cart`/`Checkout` — see "Abandonment detection" +below for why. + +**None of these currently have a listener.** They're dispatched-but-unconsumed by design — +built so something downstream (reindexing, notifications, a future read-side reporting +service) has a hook to attach to, not because a concrete consumer exists today. This was a +deliberate decision, not an oversight — see the "don't build speculative infrastructure" +calls made elsewhere in this project (e.g. not wrapping Lunar's cart exceptions). + +**Why not wired to Spatie's Activity Log:** `Cart`/`CartLine` already use Lunar's own +`LogsActivity` trait (Spatie's package, Lunar's defaults) — confirmed from source, this logs +model saves/deletes automatically, independent of actor. `Modules\Core\Logging\ +ActivityLogService` (this project's own wrapper, used by e.g. `LogTranslationActivity`) is +hardcoded to the `staff` guard — correctly scoped for staff-driven writes (Filament admin +actions), but wrong for customer-driven cart activity, which would resolve `causedBy()` to +`null` every time. Both `ActivityLogService` and `Cart`/`CartLine`'s native `LogsActivity` +write to the **same** `log_name = 'lunar'` / `activity_log` table, with no built-in +separation beyond reading `causer_type` per row — a real limitation worth knowing about, but +not one this project is fixing by giving Cart a distinct `log_name`, since every other Lunar +model logs to `'lunar'` too and a Cart-only carve-out would just be inconsistent. The +intended fix, if this becomes a real need, is a read-side service that queries `activity_log` +and classifies by `causer_type`/`log_name` — not touching every write site. + +### Save for later + +A `CartLine` can be moved out of the purchasable cart without being deleted — flagged via +`meta.saved_for_later`, not a new column (matches the free-form-JSON pattern already used +elsewhere, e.g. `ProductOptionValue::meta`). `Modules\Core\Cart\Pipelines\ +ZeroSavedForLaterPrice` (registered in `config('lunar.cart.pipelines.cart_lines')`, after the +stock `GetUnitPrice`) zeroes `unitPrice`/`unitPriceInclTax` for flagged lines **before** +Lunar's own `CalculateLines` pipeline step sums the cart — `CalculateLines` sums every +`CartLine` unconditionally with no meta-based exclusion of its own, so zeroing the price +upstream is what makes `Cart::subTotal`/`total` naturally correct without a second pass or +callers needing a different totals accessor. + +`Lunar\Actions\Carts\UpdateCartLine` **replaces** the whole `meta` column on write (plain +`update(['meta' => $meta])`, not a merge) — `saveForLater()`/`moveToCart()` read the line's +existing meta and merge in the flag change before calling `Cart::updateLine()`, or an +unrelated meta key set by something else would be silently wiped. + +### Coupons + +See `CartService::applyCoupon()`/`removeCoupon()` above. `Lunar\Base\Casts\CouponString` +just upper-cases the code; `Lunar\Managers\DiscountManager::validateCoupon()` (via the +`Discounts` facade) is the actual check — does a matching `Discount` (type `AmountOff` or +`BuyXGetY`) exist, `active()`, with `max_uses` not exhausted. + +--- + +## Abandonment detection + +"Abandoned" is a **derived** state (`Cart::updated_at` older than +`config('core.cart.abandoned_after')`, default `1 hour`) — nothing transitions a cart into it +via a normal Eloquent write, so there's no model-event hook to dispatch from directly. +`Modules\Core\Cart\Commands\DetectAbandonedCarts` (registered on an hourly schedule by +`Modules\Core\Providers\CartServiceProvider`) is the only place that moment gets detected: it +queries the same two branches `ListCarts::getTabs()` uses (no order at all vs. draft order +never placed) and dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned` +for anything currently stale. + +### Cart/Checkout have zero abandonment-related writes — by design + +`DetectAbandonedCarts` **only dispatches** — it never writes to `Cart`/`Order` at all. An +earlier version recorded an "already notified" marker on `Cart::meta`/`Order::meta` to avoid +refiring the same event every run, but that `->save()` call bumped `Cart::updated_at` as an +Eloquent side effect — since `updated_at` is also the field abandonment staleness is computed +from, the write **un-staled the very cart it had just marked abandoned**: confirmed live, a +cart that correctly fired `CartAbandoned` showed back up as "Ongoing," not "Abandoned Cart," +on the very next tab-count check. + +The fix wasn't to write the marker more carefully — it was to stop `Cart`/`Checkout` from +having any way to write abandonment state at all. Deduplication ("has this cart already been +notified") is deliberately **not** this command's job; it belongs to `Recovery` (not yet +built — see `docs/recovery-strategies.md`), which will own its own tracking table, keeping +`Cart`/`Order` permanently free of abandonment-related columns or `meta` keys. + +**Current tradeoff, accepted deliberately**: until `Recovery` exists, every cart still +matching the "abandoned" query refires its event on every hourly run — there is no dedup at +all right now. That's fine today only because nothing consumes these events yet (see +"Events" above); it would need addressing before anything real listens for them. + +--- + +## Recovery Sequences — design only, not built + +See `docs/recovery-strategies.md` — a full marketing-strategy discussion and a first-pass +feature design for an admin-configurable sequence of "touches" (delay + optional discount + +label) per abandonment type. Explicitly parked as an open design question, not scoped for +implementation yet — whether this belongs under `Cart`, a new `Recovery`/`Marketing` concern, +and how far the touch model needs to flex (channel choice, value-based branching, segment +targeting) are all still undecided. diff --git a/docs/recovery-strategies.md b/docs/recovery-strategies.md new file mode 100644 index 0000000..bd1a78f --- /dev/null +++ b/docs/recovery-strategies.md @@ -0,0 +1,128 @@ +# Cart/Checkout Recovery Strategies — Design Notes + +**Status: open design discussion, not scoped or built.** This is a record of the +reasoning behind an eventual "Recovery Sequences" feature, kept so the discussion doesn't +have to be re-derived from scratch later. Nothing in this document is implemented. + +See `docs/cart.md` for what's actually built today (the four-state cart classification, +`CartAbandoned`/`CheckoutAbandoned` events, `DetectAbandonedCarts`). + +--- + +## Why Abandoned Cart and Abandoned Checkout need different strategies + +Established in `docs/cart.md`: Abandoned Cart (no order ever started) is a weak purchase-intent +signal and often unreachable (no identity for a true guest). Abandoned Checkout (a draft order +exists, `placed_at IS NULL`) is a strong intent signal and usually reachable, since checkout +typically captures an email/address even for a guest. + +That difference in intent and reachability drives genuinely different marketing strategy, not +just a different admin filter: + +### Abandoned Cart strategy — re-engagement, not completion + +- **On-site retargeting first** (exit-intent popups, "still thinking it over?" banners on + return visits) — often the only viable channel, since email may not exist yet. +- **Ad platform retargeting** (Meta/Google dynamic remarketing) is the dominant channel here + specifically because it works off a browser/device signal, not an email address — the one + thing reliably available for an anonymous cart. +- **Soft messaging** ("did you forget something?") rather than urgency-driven — intent is + weak, so aggressive discounting is often poor ROI: it trains browsers who were never close + to buying to expect a coupon. +- **Longer, gentler cadence** — a single reminder around 24h, maybe a second a few days out, + sometimes trigger-based (a price drop, back-in-stock) rather than a fixed schedule. + +### Abandoned Checkout strategy — completion, not re-engagement + +- **Speed matters most.** This is where the classic 1h/24h/72h recovery-email cadence lives — + conversion drops sharply with delay, since the shopper is often still in a "was about to + buy" mental state within the first hour. +- **Direct, urgency-framed messaging** ("complete your order"), sometimes showing cart + contents/total, occasionally a countdown or limited-time incentive on later touches. +- **Discount escalation pays off here** — a small incentive (free shipping, 10% off) on the + 2nd/3rd touch is standard, because it's nudging someone who already decided to buy past + whatever blocked them (price shock, a broken payment step, indecision on shipping cost) — + not manufacturing demand from nothing. +- **SMS is more viable** — checkout often captures a phone number, and the higher intent + justifies a more direct channel than for cart-stage. + +--- + +## The broader strategy space (beyond cadence + discount) + +Raised as context for how far a "Recovery Sequence" feature might eventually need to flex, +without committing to building any of it yet: + +**Message-content strategies** +- Social proof ("X people have this in their cart," reviews shown in the reminder) +- Scarcity/urgency framing (low-stock count, countdown timer on an offer) +- Personalized alternatives — a cheaper or complementary item instead of just re-showing the + abandoned one, useful when the likely blocker was price + +**Channel strategies** +- Email (the baseline; nothing built yet — see `docs/cart.md`'s "Recovery Sequences" section) +- SMS — checkout-stage specifically, opt-in required +- Push notifications — not relevant yet given this project's storefront maturity, noted for + completeness +- On-site remarketing (banner/modal on the shopper's next visit) — doesn't require email at + all, arguably the highest-value channel for Abandoned Cart specifically +- Ad platform sync (pushing abandoned-cart product data to a custom audience for paid retargeting) + +**Escalation/segmentation strategies** +- Value-based branching — a high-value abandoned checkout might skip straight to a bigger + incentive rather than waiting through a full ladder +- Repeat-abandoner suppression — a customer who's abandoned 3+ times without ever completing + either stops receiving emails (fatigue/spam risk) or gets a different tactic (e.g. a "what + stopped you?" survey) instead of another discount +- New vs. returning customer branching — a first-time visitor's abandoned cart might warrant + "welcome discount" framing instead of a generic recovery email, since the blocker was + likely trust/unfamiliarity rather than price + +**Timing refinement** +- Time-of-day/timezone-aware sending (don't fire a touch at 3am local time even if the delay + technically elapsed) +- Cart-content-triggered timing — a fast-moving/low-stock item might warrant an earlier, more + urgent first touch than a cart of always-in-stock staples + +--- + +## First-pass feature shape (discussed, not finalized) + +An admin defines, independently per abandonment type (Abandoned Cart, Abandoned Checkout), an +ordered sequence of **touches**. Each touch is three ideas: + +1. **How long to wait** since the abandonment began +2. **What offer to attach**, optional — reusing whatever `Discount` already exists in the + system rather than inventing a new pricing concept +3. **A label**, so staff can see what a touch represents in the admin UI + +The system continuously re-evaluates every abandoned cart/checkout against its sequence, and +when a cart becomes due for the next touch it hasn't had yet, that becomes a signal — this +feature's responsibility ends there. Actually sending anything (email, SMS, on-site banner) is +explicitly out of scope for this feature; something else, not yet designed, would consume that +signal. + +### What this requires that isn't built yet + +- **A fixed "abandonment began at" timestamp**, captured once and never re-derived — a + sequence needs to schedule touches from a stable starting point, not from `Cart::updated_at`, + which keeps moving every time the cart (or its own bookkeeping) is written to. This is the + same underlying issue as the known bug in `docs/cart.md`'s "Abandonment detection" section — + fixing that bug properly (freezing the abandonment moment) is very likely a prerequisite for + this feature, not a separate concern. +- **Re-evaluation, not one-shot detection** — `DetectAbandonedCarts` today marks a cart + abandoned once and stops; a sequence needs a cart to be revisited on every scheduler run to + check "which touch, if any, is now due," for as long as it stays unrecovered. + +### Still undecided + +- **Which concern this belongs under.** Not `Cart` (it's not a cart-mechanics concern) — + candidates raised: a new `Recovery` concern, or `Marketing`. Not decided. +- **How far the touch model needs to flex.** The three-idea shape above (delay, discount, + label) covers cadence + discount escalation cleanly, but doesn't yet accommodate channel + choice, value-based branching, or segment targeting from the broader strategy list above. + Whether those get folded into the touch model, layered on top some other way, or deliberately + left out of v1 is unresolved. +- **Whether "recovery" is cart/checkout-specific at all**, or a more general "scheduled + customer touch based on a triggering condition" mechanism that cart/checkout abandonment + happens to be the first use case for. diff --git a/src/Cart/Commands/DetectAbandonedCarts.php b/src/Cart/Commands/DetectAbandonedCarts.php new file mode 100644 index 0000000..80f51aa --- /dev/null +++ b/src/Cart/Commands/DetectAbandonedCarts.php @@ -0,0 +1,84 @@ +whereDoesntHave('orders') + ->where('updated_at', '<=', $cutoff) + ->with('lines') + ->chunkById(200, function ($carts) use (&$cartsAbandoned) { + foreach ($carts as $cart) { + if ($cart->lines->isEmpty()) { + continue; + } + + Event::dispatch(new CartAbandoned($cart)); + + $cartsAbandoned++; + } + }); + + Cart::query() + ->whereHas('orders', fn ($query) => $query->whereNull('placed_at')) + ->where('updated_at', '<=', $cutoff) + ->with(['orders' => fn ($query) => $query->whereNull('placed_at')]) + ->chunkById(200, function ($carts) use (&$checkoutsAbandoned) { + foreach ($carts as $cart) { + $order = $cart->orders->first(); + + if ($order === null) { + continue; + } + + Event::dispatch(new CheckoutAbandoned($cart, $order)); + + $checkoutsAbandoned++; + } + }); + + $this->components->info("Dispatched CartAbandoned for {$cartsAbandoned} cart(s), CheckoutAbandoned for {$checkoutsAbandoned} checkout(s)."); + } +} diff --git a/src/Cart/Events/CartCleared.php b/src/Cart/Events/CartCleared.php new file mode 100644 index 0000000..388bba7 --- /dev/null +++ b/src/Cart/Events/CartCleared.php @@ -0,0 +1,18 @@ + $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, + ) {} +} diff --git a/src/Cart/Events/CartCouponApplied.php b/src/Cart/Events/CartCouponApplied.php new file mode 100644 index 0000000..3a77cd0 --- /dev/null +++ b/src/Cart/Events/CartCouponApplied.php @@ -0,0 +1,13 @@ + Tab::make('Ongoing') ->modifyQueryUsing(fn (Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())), - 'abandoned' => Tab::make('Abandoned') - ->modifyQueryUsing(fn (Builder $query) => $query->active()->where('updated_at', '<=', CartResource::abandonedCutoff())), + 'abandoned_cart' => Tab::make('Abandoned Cart') + ->modifyQueryUsing(fn (Builder $query) => $query + ->whereDoesntHave('orders') + ->where('updated_at', '<=', CartResource::abandonedCutoff())), + 'abandoned_checkout' => Tab::make('Abandoned Checkout') + ->modifyQueryUsing(fn (Builder $query) => $query + ->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at')) + ->where('updated_at', '<=', CartResource::abandonedCutoff())), 'completed' => Tab::make('Completed') ->modifyQueryUsing(fn (Builder $query) => $query->whereHas( 'orders', diff --git a/src/Cart/Pipelines/ZeroSavedForLaterPrice.php b/src/Cart/Pipelines/ZeroSavedForLaterPrice.php new file mode 100644 index 0000000..af5fcb3 --- /dev/null +++ b/src/Cart/Pipelines/ZeroSavedForLaterPrice.php @@ -0,0 +1,33 @@ +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); + } +} diff --git a/src/Cart/Services/CartService.php b/src/Cart/Services/CartService.php new file mode 100644 index 0000000..7d8ddc6 --- /dev/null +++ b/src/Cart/Services/CartService.php @@ -0,0 +1,250 @@ +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 + */ + 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 + */ + 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(), + ]; + } +} diff --git a/src/Providers/CartServiceProvider.php b/src/Providers/CartServiceProvider.php new file mode 100644 index 0000000..81a9ba3 --- /dev/null +++ b/src/Providers/CartServiceProvider.php @@ -0,0 +1,23 @@ +app->runningInConsole()) { + $this->commands([DetectAbandonedCarts::class]); + } + + $this->app->booted(function () { + $this->app->make(Schedule::class) + ->command(DetectAbandonedCarts::class) + ->hourly(); + }); + } +} diff --git a/src/Recovery/Events/CartAbandoned.php b/src/Recovery/Events/CartAbandoned.php new file mode 100644 index 0000000..fa7ae7e --- /dev/null +++ b/src/Recovery/Events/CartAbandoned.php @@ -0,0 +1,31 @@ + Date: Fri, 28 Aug 2026 13:15:40 +0300 Subject: [PATCH 038/110] Bump version to 0.8.0 --- CHANGELOG.md | 5 +++++ composer.json | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ae4d7..2e74978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.8.0] - 2026-08-27 + +### Added +- `Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into carts in the Filament admin panel — Lunar ships no cart admin view at all. Scoped to carts with a known `user_id`/`customer_id` (an anonymous guest cart carries no identity staff could act on); list table shows customer/user, line/item counts (via Filament's built-in `->counts()`/`->sum()`, no per-row queries), currency, and last activity. List page has only two tabs, **Abandoned** (default active) and **Completed** — no "All" tab, so the list never runs an unfiltered fetch over the whole table. They key off whether the cart has a **placed** order (`orders.placed_at IS NOT NULL`), not `Cart::completed_at` — that column is declared/cast on the model but never actually written anywhere in Lunar core, so it's not a real signal; "Abandoned" mirrors Lunar's own `Cart::scopeActive()`. `getNavigationBadge()` shows the abandoned-cart count in the sidebar via a single `COUNT(*)` query, no rows loaded. View page runs `$cart->calculate()` once so line/cart totals (plain public properties Lunar never persists) are populated, without paying that cost per row in the list. Documented in `docs/cart.md`. + ## [0.7.0] - 2026-08-27 ### Added diff --git a/composer.json b/composer.json index af6fb8e..4acfd07 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.7.0", + "version": "0.8.0", "autoload": { "psr-4": { "Modules\\Core\\": "src/" @@ -37,6 +37,7 @@ "Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\LocalizationServiceProvider", "Modules\\Core\\Providers\\CatalogServiceProvider", + "Modules\\Core\\Providers\\CartServiceProvider", "Modules\\Core\\Providers\\ReviewServiceProvider" ] } From c6c44db7425e2827e3b288091a847a2ac3e53341 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Fri, 28 Aug 2026 13:19:29 +0300 Subject: [PATCH 039/110] Feature: Sweeping features of most known e-commerce shops, creating artifacts for the research --- docs/scratch/analytics-feature-survey.html | 449 +++++++++++++++ docs/scratch/checkout-feature-survey.html | 429 ++++++++++++++ .../customer-accounts-feature-survey.html | 476 ++++++++++++++++ docs/scratch/discounts-feature-survey.html | 527 ++++++++++++++++++ docs/scratch/payments-feature-survey.html | 448 +++++++++++++++ docs/scratch/privacy-feature-survey.html | 492 ++++++++++++++++ .../products-collections-feature-survey.html | 508 +++++++++++++++++ docs/scratch/shipping-feature-survey.html | 465 ++++++++++++++++ 8 files changed, 3794 insertions(+) create mode 100644 docs/scratch/analytics-feature-survey.html create mode 100644 docs/scratch/checkout-feature-survey.html create mode 100644 docs/scratch/customer-accounts-feature-survey.html create mode 100644 docs/scratch/discounts-feature-survey.html create mode 100644 docs/scratch/payments-feature-survey.html create mode 100644 docs/scratch/privacy-feature-survey.html create mode 100644 docs/scratch/products-collections-feature-survey.html create mode 100644 docs/scratch/shipping-feature-survey.html diff --git a/docs/scratch/analytics-feature-survey.html b/docs/scratch/analytics-feature-survey.html new file mode 100644 index 0000000..18ee9a7 --- /dev/null +++ b/docs/scratch/analytics-feature-survey.html @@ -0,0 +1,449 @@ +Analytics Feature Survey + + + +
+ +
+
boboko / analytics · competitive survey
+

What analytics elsewhere can do that boboko can't yet

+

+ A feature-by-feature pass across Shopify, WooCommerce Analytics, and PrestaShop's + stats modules — sourced, not recalled from memory — checked against what + Lunar's admin Dashboard actually ships today and what + raw data already sits in lunar_orders/lunar_carts unused. + This is genuinely new territory for boboko — most rows below land on partial or + missing, and that's an honest read, not an undersell. +

+
+ ● have + ◐ partial + ○ missing +
+
+ +
+
+ 01 +

Sales & revenue dashboard

+
+

What loads the moment staff open the admin panel — this is the one area where Lunar ships more than expected.

+ +
+
Revenue / order-count stat cards with period-over-period trend
+ have +
Verified from source: OrderStatsOverview widget — today vs. yesterday, last 7 vs. prior 7, last 30 vs. prior 30 days, both order count and sub-total, with up/down trend icons. Registered by default on Lunar's Dashboard page, and boboko's panel (3dealer/app/Providers/PanelServiceProvider.php) registers the stock panel with no pages()/Dashboard override — this ships as-is.
+
+ +
+
Sales-over-time chart (revenue + order count, 12-month trend)
+ have +
OrdersSalesChart — ApexCharts area chart, monthly buckets over the trailing year, dual y-axis (order count / sub-total). Same "no override" reasoning as above applies to every widget on this page.
+
+ +
+
Average order value (AOV) trend, segmented by customer group
+ have +
AverageOrderValueChart — one series per CustomerGroup plus a synthetic guest series, monthly average of sub_total over the trailing year.
+
+ +
+
New vs. returning customer split
+ have +
NewVsReturningCustomersChart reads Order::new_customer, a real boolean column set by Lunar\Jobs\Orders\MarkAsNewCustomer (true when no prior order existed for that customer at placement time) — not a cosmetic flag.
+
+ +
+
Live/latest-orders feed on the dashboard
+ have +
LatestOrdersTable — last 10 placed orders, 60s polling, reuses OrderResource's own table columns.
+
+ +
+
Real-time dashboard vs. scheduled email reports
+ partial +
The dashboard widgets above poll every 60s (near-real-time, pull-based) — there is no scheduled/emailed report anywhere in Lunar or boboko-core. Industry pattern researched: real-time suits operational checks, scheduled digest suits weekly/monthly strategic review — boboko only has the first half.
+
+
+ +
+
+ 02 +

Product & catalog performance

+
+

Which products are actually selling, and what's about to run out.

+ +
+
Best-sellers / top-products report
+ have +
PopularProductsTable — groups lunar_order_lines by product identifier over the trailing 12 months, ranked by quantity sold, with revenue (sub_total) alongside. Physical products only (whereType('physical')).
+
+ +
+
Per-product detail stats (views, conversion, revenue for one SKU)
+ missing +
PrestaShop's statsproduct module was researched as the comparison point (per-product page-view + sales detail) — boboko has no page-view capture at all (see 04), so even the sales half of this can't be built without the traffic half.
+
+ +
+
Catalog-wide statistics (active/inactive counts, category breakdown)
+ missing +
PrestaShop's statscatalog module researched as the reference. No equivalent surface in Lunar or boboko-core — would be a straightforward aggregate over lunar_products/lunar_collections, just not built.
+
+ +
+
Inventory / stock-turnover report
+ missing +
ProductVariant::$stock is a plain point-in-time integer column — no stock-movement ledger or history table exists in lunarphp/core (grepped the models and migrations directories). Turnover reporting needs a time series of stock levels or receipts/sales deltas; today's schema only has "current stock," so there's nothing to compute turnover from yet, not just a missing report.
+
+ +
+
Low-stock / reorder alerting surfaced in a report
+ missing +
The Cart survey already noted ProductIndexer's in_stock field exists for search/listing purposes — nothing aggregates it into a "low stock" admin view or report.
+
+
+ +
+
+ 03 +

Customer analytics

+
+

Value and behavior at the level of one shopper, or a group of them.

+ +
+
Per-customer order count / average spend / lifetime spend
+ have +
Verified from source: CustomerStatsOverviewWidget on the customer view page — total orders, average spend, and total spend, computed live from orders()->sum()/average(). This is per-customer lookup, not an aggregate report across all customers.
+
+ +
+
Customer Lifetime Value (CLV) as a store-wide metric/segment
+ partial +
The per-customer total-spend figure above is the raw ingredient, but there's no store-wide CLV report, no ranking of customers by CLV, and no predictive/forward-looking CLV — WooCommerce Analytics' Customer Analytics extension (researched) computes this plus churn and RFM segments, none of which exist here.
+
+ +
+
Cohort retention analysis
+ missing +
Researched as a WooCommerce/Metorik feature (retention rate by signup-month cohort). No cohort concept, table, or query exists anywhere in Lunar or boboko-core.
+
+
+ +
+
+ 04 +

Behavioral & funnel tracking

+
+

What happens before an order exists — the storefront side neither repo instruments at all.

+ +
+
Page-view / product-view event capture
+ missing +
Grepped both repos for gtag/dataLayer/GA4/any client-side event tracker — zero hits. No storefront event of any kind is dispatched, captured, or stored anywhere.
+
+ +
+
Conversion funnel (view → add to cart → checkout → purchase)
+ missing +
Shopify's funnel report (researched) needs a session-scoped event stream across all four stages. boboko has only the last stage as durable data (a placed Order) — no view or add-to-cart events exist to build the earlier steps from, consistent with the Cart survey's finding that Lunar dispatches zero cart events.
+
+ +
+
Abandoned-cart aggregate value/rate reporting
+ partial +
Distinct from the Cart survey's per-cart admin lookup (CartResource, already shipped) — this is a rolled-up metric: total abandoned value this week, abandonment rate as a percentage of carts started. The underlying rows exist in lunar_carts/lunar_cart_lines (same query CartResource's Abandoned tab already runs), but nothing aggregates them into a rate or a trend — it's list-only today.
+
+ +
+
Traffic-source / campaign attribution (UTM-based)
+ missing +
No UTM capture, no marketing/session table anywhere in either repo. Researched as the backbone of Shopify's/GA4's acquisition reporting — would need a session table capturing utm_source/medium/campaign at first touch, tied forward to the eventual order.
+
+
+ +
+
+ 05 +

Tax, accounting & export

+
+

Getting numbers out of boboko and into someone else's books.

+ +
+
Tax / VAT breakdown captured per order
+ have +
Verified from source: lunar_orders migration stores both tax_breakdown (JSON, per-rate detail) and tax_total as real columns on every placed order — this is genuine underlying data, not inferred.
+
+ +
+
Tax / VAT report for accounting (e.g. by tax zone, by period)
+ partial +
The per-order data above is complete enough to build this from, but nothing aggregates tax_breakdown/tax_total across orders into a filing-ready report by TaxZone or period — no such widget, page, or query exists in Lunar or boboko-core.
+
+ +
+
CSV / accounting-software export of orders or sales data
+ missing +
Grepped for Exporter/ExportAction/Excel:: across lunarphp/lunar and boboko-core's src — no hits. Filament ships export actions as a first-party feature elsewhere in the ecosystem; nothing here wires one up for orders.
+
+ +
+
Sales by channel
+ partial +
Order::channel_id is a real, always-populated foreign key (verified in the lunar_orders migration) — every order already knows its channel. No report groups by it; the dashboard's charts are all channel-blind.
+
+
+ +
+
+ 06 +

Audit trail vs. analytics

+
+

A distinction worth being explicit about, since it's easy to mistake one for the other.

+ +
+
Activity log (Spatie activitylog) on core models
+ have +
Verified from source and docs/lunar.md's Activity Logging section: Lunar\Base\Traits\LogsActivity covers Order, Cart, Product, Customer, and 15 other models, recording only dirty attributes per change under the lunar log name.
+
+ +
+
This counts as analytics
+ missing +
It doesn't, and isn't listed as "have" anywhere above for that reason — activity log is a per-record change history for compliance/support ("who edited this order's shipping address"), not aggregate reporting ("how much revenue this month"). No row in this survey is satisfied by activity-log data.
+
+
+ +
+ Compiled 2026-08-28 — sources cited inline: docs/lunar.md §Filament Panel Integration and §Activity Logging plus direct reads of vendor/lunarphp/lunar/src/Filament/Widgets/Dashboard, vendor/lunarphp/core models/migrations, and 3dealer/app/Providers/PanelServiceProvider.php are repo-verified; Shopify/WooCommerce/PrestaShop feature claims are from web research, not repo reads. + boboko-core / docs +
+ +
diff --git a/docs/scratch/checkout-feature-survey.html b/docs/scratch/checkout-feature-survey.html new file mode 100644 index 0000000..f543775 --- /dev/null +++ b/docs/scratch/checkout-feature-survey.html @@ -0,0 +1,429 @@ +Checkout Feature Survey + + + +
+ +
+
boboko / checkout · competitive survey
+

What checkout elsewhere can do that boboko can't yet

+

+ A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's checkout + layer — sourced, not recalled from memory — checked against what + Lunar's Cart::createOrder() / order-creation pipeline + actually supports today. Companion to the Cart survey: this starts where that one + left off — address and shipping-option capture through to a placed order. For + deciding what to design next, not a build order. +

+
+ ● have + ◐ partial + ○ missing +
+
+ +
+
+ 01 +

Getting to checkout

+
+

Who's allowed to check out, and in how many steps.

+ +
+
Guest checkout (no account required)
+ have +
Structural, not bolted-on: Order.user_id and customer_id are both nullable, and ValidateCartForOrderCreation never checks for either — it only requires a billing address and, if shippable, a shipping address + option. A cart with no user_id creates an order fine.
+
+ +
+
One-page vs. multi-step checkout
+ missing +
Pure storefront-UI concern — Lunar has no opinion here, it just exposes setShippingAddress()/setBillingAddress()/setShippingOption() as independent calls that a UI can sequence however it likes. WooCommerce and PrestaShop both ship one-page as a plugin/theme layer, not core, so this isn't a Lunar gap so much as storefront work still to do.
+
+ +
+
Address autocomplete (type-ahead, from Google Places / Loqate)
+ missing +
Research: cuts address-entry keystrokes by 70%+ and is a proven abandonment-reduction tactic (Google Maps Platform, Loqate). No Lunar hook for it either way — it's a storefront form concern layered on top of the same setShippingAddress() call.
+
+ +
+
Express/accelerated checkout (Shop Pay, Apple Pay, Google Pay equivalents)
+ missing +
Research: Shopify reports Shop Pay can lift conversion up to 50% over guest checkout, mobile especially. Lunar's Payments facade is driver-based (Payments::driver('card')) so a wallet driver is architecturally pluggable, but none ships, and there's no one-tap "skip the address form" path since address capture still runs through the standard cart-address flow first.
+
+ +
+
Terms & conditions acceptance at checkout
+ partial +
Order.meta and Cart.meta are both free-form JSON columns carried straight through FillOrderFromCart ('meta' => $cart->meta) — technically able to record a timestamp/version of accepted terms today, but no dedicated field, checkbox validation, or admin display exists.
+
+
+ +
+
+ 02 +

Order creation mechanics

+
+

What actually happens inside createOrder(), verified from source.

+ +
+
Duplicate-order prevention on repeat submits
+ have +
Two layers, both real: Cart::draftOrder() matches on fingerprint() + total, so re-running createOrder() on an unchanged cart reuses the same draft order instead of duplicating it (CreateOrder::execute()); once an order is placed, hasCompletedOrders() throws DisallowMultipleCartOrdersException unless allowMultipleOrders is explicitly passed.
+
+ +
+
Draft order created before payment, finalized after
+ have +
Order::isDraft()/isPlaced() gate on placed_at; orders.draft_status config (default awaiting-payment) sets the initial status. The order exists — and can be re-run through the pipeline idempotently via the fingerprint match above — before a payment driver ever authorizes anything.
+
+ +
+
Order address, line, and shipping-line snapshotting from cart
+ have +
The whole orders.pipelines.creation chain does this explicitly — FillOrderFromCart, CreateOrderLines, CreateOrderAddresses, CreateShippingLine, CleanUpOrderLines, MapDiscountBreakdown — each copying cart state into immutable order rows rather than referencing the cart live.
+
+ +
+
Address validation before order creation
+ have +
ValidateCartForOrderCreation requires country_id, first_name, line_one, city, postcode on billing always, and on shipping too unless the chosen ShippingOption->collect is true (in-store pickup skips a shipping address).
+
+ +
+
Exchange rate and currency locked at order time
+ have +
FillOrderFromCart copies currency_code and exchange_rate from the cart's currency onto the order at creation — later currency-config changes don't retroactively alter placed orders.
+
+
+ +
+
+ 03 +

Confirmation & communication

+
+

What tells the customer (and staff) an order happened.

+ +
+
Order confirmation email on placement
+ missing +
Surprising given how close it looks to shipping: every status in config/lunar/orders.php carries a mailers and notifications array, but grep across core turns up exactly one reader of that config (Order::getStatusLabelAttribute(), and it only reads label). Nothing in core ever dispatches a mailer or notification from a status change — those keys are unwired placeholders, not a working feature.
+
+ +
+
Order-status-changed events
+ missing +
Same gap as Cart's event survey found — src/Events/ in core contains only PaymentAttemptEvent. No OrderCreated, no OrderStatusUpdated. Confirmation email, staff Slack ping, or customer SMS on status change all have to be built from scratch on plain Eloquent model events (Order::updated()), same pattern as the cart-event gap.
+
+ +
+
Order tracking / status lookup for guests
+ missing +
Research: PrestaShop's order-tracking extensions explicitly cover "non-logged-in customers track their orders." Lunar has the data (Order.reference, status, OrderAddress.contact_email) but no lookup mechanism — a guest with no account has no route back to their order without the confirmation email that also doesn't exist yet.
+
+ +
+
New-customer detection on first order
+ have +
CreateOrder::execute() dispatches MarkAsNewCustomer::dispatch($order->id) as a queued job after every order creation — genuinely wired, unlike the mail/notification config above.
+
+
+ +
+
+ 04 +

Abandoned checkout recovery

+
+

Distinct from abandoned cart recovery (covered in the Cart survey) — this is someone who reached address/email capture and still left.

+ +
+
Draft orders are queryable and staff-visible
+ partial +
The data exists — Order::isDraft() plus the address already captured on it — but per the Cart survey's finding, there's no Filament resource for Cart and (unverified here, likely the same gap) no dedicated "abandoned checkout" view distinguishing a draft order with a captured address from one that never got that far.
+
+ +
+
Automated recovery email (post-address-capture)
+ missing +
Research: Shopify's built-in template fires after a shopper enters details and leaves, with editable wait time and an optional discount. boboko has strictly better raw material for this than the cart-abandonment case — a draft order after address capture always has OrderAddress.contact_email, where an abandoned guest cart usually has none — but nothing sends on it.
+
+ +
+
Abandoned-checkout stage tracking (email captured vs. shipping selected vs. payment started)
+ missing +
No event dispatch anywhere in the checkout pipeline (see 03) means no timestamped record of which step a checkout got to — only the current state of the draft order, not its history.
+
+
+ +
+
+ 05 +

Pricing, tax & locale at checkout

+
+

What the customer sees the moment money is on screen.

+ +
+
Tax-inclusive vs. tax-exclusive price display
+ have +
TaxZone.price_display is a first-class enum (tax_inclusive/tax_exclusive), and Price::priceExTax()/priceIncTax() both exist on the model — more complete than PrestaShop, where dual-price display is a separately-sold addon module, not core.
+
+ +
+
Full tax breakdown shown at checkout (per-line, per-rate)
+ have +
Cart.taxBreakdown and OrderLine.tax_breakdown are both populated structured objects (iterate .amounts), not just a lump-sum total — the data supports a itemized tax display, a storefront just has to render it.
+
+ +
+
Multi-currency checkout (pay in shopper's own currency)
+ have +
Currency.exchange_rate plus sync_prices per non-default currency, and the rate is snapshotted onto the order at creation (see 02) — the same mechanics PrestaShop needs an addon for.
+
+ +
+
Multi-language checkout copy
+ partial +
Product/collection/attribute copy is fully translatable via attribute_data + Language, but checkout itself — form labels, validation errors, status labels — is storefront-owned Laravel localization, not something Lunar's order pipeline touches either way.
+
+ +
+
Click-and-collect / in-store pickup as a checkout option
+ have +
ShippingOption.collect is a real boolean the validator checks directly — when true, ValidateCartForOrderCreation skips the shipping-address requirement entirely. Modeled at the same level as the collection driver in the Table Rate Shipping add-on.
+
+
+ +
+ Compiled 2026-08-28 — sources cited inline; vendor/lunarphp/core/src reads are marked by file/class name, Shopify/WooCommerce/PrestaShop claims are marked "Research." + boboko-core / docs +
+ +
diff --git a/docs/scratch/customer-accounts-feature-survey.html b/docs/scratch/customer-accounts-feature-survey.html new file mode 100644 index 0000000..32a9097 --- /dev/null +++ b/docs/scratch/customer-accounts-feature-survey.html @@ -0,0 +1,476 @@ +Customer Accounts Feature Survey + + + +
+ +
+
boboko / customer accounts · competitive survey
+

What customer accounts elsewhere can do that boboko can't yet

+

+ A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's + account layer — sourced, not recalled from memory — checked against what + Lunar's Customer/Address/CustomerGroup + models actually support today and what exists (or doesn't) in boboko-core + and 3dealer right now. For deciding what to design next, not a build order. +

+
+ ● have + ◐ partial + ○ missing +
+
+ +
+
+ 01 +

Whether an account exists at all

+
+

The storefront-facing account experience, as distinct from staff/admin auth in Modules\Core\Auth.

+ +
+
Customer↔User linking (data model)
+ have +
Fully modeled by Lunar core — Customer::users() / User::customers() via customer_user pivot (LunarUser trait), plus User::latestCustomer().
+
+ +
+
Customer record auto-created on signup
+ have +
Modules\Core\Customer\Listeners\CreateCustomerForUser attaches a new Customer to every User on UserCreated, gated by config('core.auto_create_customer_for_user').
+
+ +
+
Storefront login / registration UI
+ missing +
3dealer has no auth scaffolding at all — no Breeze/Fortify/Sanctum in composer.json, no login/register views, nothing in routes/web.php. Only Modules\Core\Auth's Filament staff panel login exists.
+
+ +
+
Account/profile page (name, addresses, orders)
+ missing +
No AccountController, no account/profile route, no matching Blade views anywhere in 3dealer's app/ or resources/views — confirmed by exhaustive grep.
+
+ +
+
Account nav link in header
+ missing +
resources/views/components/header.blade.php has a cart icon and a search button but no account/login link at all — not even a dead one. The cart icon itself links to /cart, which also has no matching route, matching this codebase's known stubbed-UI pattern.
+
+
+ +
+
+ 02 +

Order history & tracking

+
+

Letting a customer see and follow their own orders without contacting support.

+ +
+
Order history data (per customer)
+ have +
Fully modeled — Customer::orders() and User::orders() both exist (Lunar\Models\Order), with status, line items, addresses, and transactions already relational.
+
+ +
+
Self-service order history / status page
+ missing +
No storefront route or controller reads Order for a logged-in customer — the data exists, nothing surfaces it. Shopify's rebuilt (2026) customer-accounts UI and PrestaShop's order-detail tracking page are both native; WooCommerce ships this in My Account by default.
+
+ +
+
Shipment tracking numbers surfaced to customer
+ missing +
No tracking-number field found on Order/OrderLine/shipping models in vendor/lunarphp/core; PrestaShop's tracking module patches this same gap with a third-party add-on, so it isn't a "native everywhere" bar either.
+
+ +
+
Reorder / buy-again from order history
+ missing +
Needs an order-history UI to exist first (see above) plus a "re-add these lines to cart" action — Lunar's Cart::add() already supports the mechanics, nothing wires an Order line back into a new cart.
+
+
+ +
+
+ 03 +

Saved addresses

+
+

What a returning customer doesn't have to retype.

+ +
+
Multiple saved addresses per customer
+ have +
Customer::addresses() (HasMany) — Lunar\Models\Address has no cap on count.
+
+ +
+
Separate default shipping / billing address
+ have +
Address::shipping_default and billing_default booleans; AddressObserver auto-unsets the previous default when a new one is flagged, so only one of each can be true at a time.
+
+ +
+
Self-service address book (add/edit/delete UI)
+ missing +
Only Filament's staff-facing AddressRelationManager (src/Customer/RelationManagers/AddressRelationManager.php) touches addresses today — that's an admin back-office view, not a storefront one. No customer-facing CRUD exists.
+
+ +
+
Address autocomplete / validation at entry
+ missing +
Nothing in lunarphp/core or boboko-core wires a geocoding/validation service — this is a storefront-only concern layered on top of the plain line_one…postcode fields.
+
+
+ +
+
+ 04 +

Login & identity

+
+

How a customer gets in, and how forgiving that path is.

+ +
+
Email + password login
+ missing +
No storefront auth guard/routes configured — see 01. Modules\Core\Auth\Services\OtpService/UserOtpService exist but are wired to staff/Filament login, not a customer-facing flow.
+
+ +
+
Passwordless / magic-link / OTP login
+ partial +
UserOtpService and UserOtpMail already implement an OTP-by-email mechanism for the staff panel — the building block for a customer-facing passwordless flow exists, just not exposed to a storefront route. Shopify ships this as sign-in links (6-digit email code) by default in its 2026 customer accounts.
+
+ +
+
Social login (Google / Apple / Facebook)
+ missing +
No laravel/socialite in either composer.json. Shopify offers Google/Facebook sign-in and "Sign in with Shop" natively; this would be a from-scratch integration here.
+
+ +
+
Guest checkout → account conversion
+ missing +
No storefront checkout flow exists yet in 3dealer to convert from — this depends on checkout being built before it's meaningful. Lunar's Cart::user_id/customer_id nullable-until-claimed design would support it once a checkout and account UI exist.
+
+
+ +
+
+ 05 +

Payments & saved methods

+
+

Whether a returning customer can skip re-entering card details.

+ +
+
Saved payment methods on account
+ missing +
No tokenized-card storage model found in lunarphp/core or boboko-core's payment integration. Even Shopify gates this behind Enterprise; WooCommerce's version depends entirely on gateway-level tokenization (e.g. Stripe), not a core feature.
+
+
+ +
+
+ 06 +

Wishlist & saved items

+
+

Keeping track of products outside the cart.

+ +
+
Wishlist / saved-for-later products
+ missing +
No wishlist model, table, or reference anywhere in src/ or vendor/lunarphp — grep confirms zero hits. Shopify also has no native wishlist (third-party apps like Flits fill the gap); WooCommerce/PrestaShop are the same story via plugins, so this is a genuinely common gap, not a boboko-specific one.
+
+
+ +
+
+ 07 +

Groups, pricing & B2B

+
+

Where boboko is already ahead of a typical single-tenant storefront — Lunar's CustomerGroup does real work here.

+ +
+
Customer groups for differentiated pricing/visibility
+ have +
CustomerGroup model plus HasCustomerGroups trait — Product::customerGroup() scope and Price's polymorphic customer-group awareness are both real, shipped behavior, not scaffolding.
+
+ +
+
Scheduled group availability (time-boxed access)
+ have +
HasCustomerGroups::scheduleCustomerGroup() / unscheduleCustomerGroup(), backed by CanScheduleAvailability — supports a starts_at/ends_at window per group, e.g. early access for wholesale.
+
+ +
+
Multi-user company / B2B accounts
+ partial +
Customer::users()->sync([...]) already supports attaching several Users to one Customer record — the data model allows a shared company account today, but nothing (invite flow, role/permission split between company users, storefront switch-account UI) is built on top of it. PrestaShop's "Multi-User Customer Account" add-on is the closest native comparison, and it's a paid third-party module there too.
+
+ +
+
Self-service customer-group selection at registration
+ missing +
Groups exist and are assignable (HasCustomerGroups::bootHasCustomerGroups() auto-syncs default groups on creation), but nothing lets a customer request/select a group like "wholesale" at signup — that's currently a staff-only Filament action via CustomerResourceExtension.
+
+
+ +
+
+ 08 +

Loyalty, retention & data rights

+
+

Longer-tail account features — noted for completeness, not depth (data rights specifically overlaps a separate Privacy survey).

+ +
+
Loyalty / rewards points program
+ missing +
No points/loyalty model anywhere in lunarphp/core or boboko-core — Discount's BuyXGetY type is the closest primitive, but it's a promo mechanic, not an accruing balance. PrestaShop and WooCommerce both rely on third-party modules for this too (Knowband, Webkul, Yith).
+
+ +
+
Self-service data export / account deletion
+ missing +
The only related tool is boboko:anonymize — a local-environment-only dev command that scrubs users/lunar_customers for testing, not a customer-facing GDPR flow. WooCommerce's closest native equivalent is also a paid add-on (Data Privacy Manager); flagged briefly here, full treatment belongs to the separate Privacy survey.
+
+ +
+
Subscription / recurring-order management
+ missing +
No subscription model, billing-cycle field, or recurring-cart concept found in lunarphp/core. This is WooCommerce Subscriptions/Shopify-app territory on the platforms researched too — not a core-package feature anywhere.
+
+
+ +
+ Compiled 2026-08-28 — chips backed by web research (Shopify/WooCommerce/PrestaShop feature claims) are noted inline by platform name; all other claims are direct reads of vendor/lunarphp/core/src, boboko-core's src/, and 3dealer's app//resources/views/routes. + boboko-core / docs +
+ +
diff --git a/docs/scratch/discounts-feature-survey.html b/docs/scratch/discounts-feature-survey.html new file mode 100644 index 0000000..9147f01 --- /dev/null +++ b/docs/scratch/discounts-feature-survey.html @@ -0,0 +1,527 @@ +Discounts Feature Survey + + +
+ +
+

boboko-core · competitive spec sheet

+

What discounts & promotions elsewhere can do that boboko can’t yet

+

A feature-by-feature audit of Lunar's Discount engine against promotion tooling in Shopify, WooCommerce, and PrestaShop. Each row is graded against the underlying Lunar source, not the docs.

+
+ have + partial + missing +
+
+ +
+
+ 01 +

Core discount mechanics

+
+

The two shipped discount types and the machinery that decides whether they fire.

+ +
+
+
+ Percentage / fixed-amount off cart or line items + have +
+

Built in as Lunar\DiscountTypes\AmountOff. applyPercentage() and applyFixedValue() distribute the discount across eligible lines, tracking per-currency fixed values (data.fixed_values.{code}) so the amount is currency-aware, not a single converted number.

+
+
+
+ Buy X get Y (free or discounted) + have +
+

Built in as Lunar\DiscountTypes\BuyXGetY. Condition lines and reward lines are configured separately via discountableConditions/discountableRewards; getRewardQuantity() computes how many reward units a given condition quantity earns, with an optional max_reward_qty cap.

+
+
+
+ Coupon-code discounts + have +
+

checkDiscountConditions() compares strtoupper($cart->coupon_code) against $discount->coupon; Discounts::validateCoupon() exposes a standalone check. Coupon is cast via CouponString on the model.

+
+
+
+ Automatic (no-code) discounts + have +
+

A blank coupon column makes a discount apply to every eligible cart with no code entered — DiscountManager::getDiscounts() queries whereNull('coupon')->orWhere('coupon', '') when the cart carries no coupon code.

+
+
+
+ Minimum cart spend condition + have +
+

checkDiscountConditions() reads data.min_prices.{currency} and compares it against $lines->sum('subTotal.value'). Configurable per-currency in the admin form's "Minimum cart amount" fieldset — but only enforced by AmountOff, see row below.

+
+
+
+ Scoping to products, variants, collections, brands (incl. exclusions) + have +
+

AmountOff::getEligibleLines() filters/rejects cart lines against discountableLimitations/discountableExclusions plus collections()/brands() pivot rows typed limitation or exclusion. Configured through five separate Filament relation managers on the discount record.

+
+
+
+ +
+
+ 02 +

Timing, status, and usage limits

+
+

Whether a discount is currently live, and how hard its usage caps are enforced.

+ +
+
+
+ Scheduled / expiring discount windows + have +
+

Discount::getStatusAttribute() derives active/pending/expired/scheduled from starts_at/ends_at; the Filament table badges this status column directly (green/gray/red/blue via DiscountResource::getTableColumns()).

+
+
+
+ Global max-uses cap + have +
+

Discount::scopeUsable() filters query-side (uses < max_uses OR max_uses IS NULL) before a discount is even fetched; checkDiscountConditions() re-checks it in AmountOff. markAsUsed() increments uses and attaches the user via discount_user.

+
+
+
+ Per-user max-uses cap + partial +
+

checkDiscountConditions() calls usesByUser() only when $cart->user exists — a guest checkout cannot be capped per-customer since there's no user_id to key against, only customer_id. Wholesale/B2B carts often complete without a Laravel User attached, so the cap silently no-ops for them.

+
+
+
+ Usage/eligibility checks on Buy X Get Y + missing +
+

BuyXGetY::apply() never calls checkDiscountConditions() — grep the method body, it's absent. A coupon-gated, min-spend-gated, or max-uses-capped BOGO discount ignores all three conditions; only the min-quantity/reward math runs. AmountOff::apply() calls it correctly by contrast.

+
+
+
+ +
+
+ 03 +

Multiple discounts, priority, and stacking

+
+

What happens when more than one discount could legally apply to the same cart.

+ +
+ The stop field is dead code. It's a real column, cast as boolean on the model, and it's a live toggle in the Filament admin form (DiscountResource::getStopFormComponent()) — but a repo-wide grep of both lunarphp/core and lunarphp/lunar for reads of $discount->stop outside the model and the form turns up nothing. DiscountManager::apply() is a plain unconditional foreach over every fetched discount; nothing ever breaks the loop. Staff can toggle a setting that has zero runtime effect. +
+ +
+
+
+ Priority ordering between discounts + have +
+

DiscountManager::getDiscounts() ends with orderBy('priority', 'desc')->orderBy('id'), and the admin form exposes low/medium/high (1/5/10) presets. This genuinely controls apply order.

+
+
+
+ Stopping further discounts once one applies ("exclusive" discount) + missing +
+

See callout above — stop is unread at runtime. Every active, eligible discount is applied every time; there is no way to make one discount exclusive of the rest short of writing a custom AbstractDiscountType that inspects $cart->discounts itself.

+
+
+
+ Per-class combination rules (product vs. order vs. shipping discounts) + missing +
+

Shopify models discounts as Product/Order/Shipping classes with an explicit "Combines with" toggle per pair. Lunar has no discount class concept at all — AmountOff and BuyXGetY are the only two types and neither declares a class or combination policy.

+
+
+
+ Customer-facing stacking transparency (which discounts combined, and why) + partial +
+

$cart->discountBreakdown (a collection of DiscountBreakdown value objects, one per applied discount with its affected lines) gives a storefront the raw data to render "2 promotions applied," but no UI ships to render it — it's a data structure a storefront app must build its own component against.

+
+
+
+ "Best deal wins" line-level conflict resolution + have +
+

Both AmountOff::applyFixedValue() and applyPercentage() explicitly skip a line when $line->discountTotal->value > $amount — "if this line already has a greater discount value, don't add this one as they already have a better deal." This is a real per-line max-discount guard, just not a whole-cart exclusivity rule.

+
+
+
+ +
+
+ 04 +

Volume, tiers, and bundles

+
+

"Buy more, save more" mechanics — and the separate pricing layer that actually implements some of them in Lunar.

+ +
+ Tiered/volume pricing exists — but it's not a Discount. PricingManager::get() filters a purchasable's Price rows for min_quantity > 1 AND $this->qty >= $price->min_quantity and picks the cheapest matching price break. This is quantity-break pricing baked into the price table itself, resolved at Pricing::for($variant)->qty($n)->get() time — it never touches the Discount model, coupon system, or discount breakdown at all. A storefront gets the discounted unit price with no visible "discount applied" line. +
+ +
+
+
+ Per-SKU quantity price breaks + have +
+

Via the Price model's min_quantity/pricing pipeline described above, not Discount. Configured directly on product variant pricing in the admin, no separate promotion object needed.

+
+
+
+ Cart-wide tiered discount ("spend $100, save 10%; spend $200, save 20%") + missing +
+

AmountOff takes one flat percentage or fixed value per discount record; there is no multi-tier threshold structure in data. Reaching this today means creating several separate Discount rows, each with its own min_prices floor, and hoping only the intended one wins (compounded by the stop gap in section 03).

+
+
+
+ Bundle / kit discount (buy this set, get a fixed bundle price) + missing +
+

No bundle or kit concept anywhere in lunarphp/core's catalog or discount models. Shopify/WooCommerce/PrestaShop all support this via dedicated bundle apps or plugins layered on the same primitive Lunar lacks — a discount keyed to a co-purchased product set rather than any single line.

+
+
+
+ Free-gift-with-purchase (a distinct SKU added free, not a percentage off an existing line) + have +
+

BuyXGetY's automatically_add_rewards flag drives processAutomaticRewards(), which inserts a brand-new CartLine for a randomly selected reward product and zeroes its price via discountTotal. $cart->freeItems tracks which purchasables were added this way.

+
+
+
+ +
+
+ 05 +

Customer targeting

+
+

Lunar has two genuinely different mechanisms here that solve overlapping-looking problems — conflating them is the easiest mistake to make.

+ +
+ CustomerGroup pricing and Discount customer-group scoping are not the same feature. Pricing::for($variant)->customerGroups($groups)->get() resolves a different base price per customer group directly from the Price table (wholesale sees $8, retail sees $10 — two rows, no discount object, no coupon, nothing to "apply"). Discount::customerGroups() is a separate pivot (customer_group_discount, via the HasCustomerGroups trait) that scopes whether a promotion is visible/enabled to a group at all, with its own starts_at/ends_at/enabled/visible per-pivot-row scheduling. One is differential pricing; the other is promotion eligibility. Both exist and both work, but they're wired into completely separate code paths. +
+ +
+
+
+ Differential pricing per customer group (wholesale/VIP base price) + have +
+

PricingManager::get(): $potentialGroupPrice filters Price rows with a matching customer_group_id and picks the cheapest; falls back to $basePrice when no group price exists.

+
+
+
+ Restricting a discount/coupon to specific customer groups + have +
+

DiscountManager::getDiscounts() applies ->customerGroup($this->customerGroups) via the shared HasCustomerGroups trait's scopeCustomerGroup(), configured on the discount's own "Availability" sub-page (ManageDiscountAvailability) alongside channel restriction.

+
+
+
+ Restricting a discount to specific named customers + have +
+

Discount::customers() pivot (customer_discount), checked in checkDiscountConditions(): if the discount has any tied customers, a cart without a matching customer_id fails eligibility outright. Managed via CustomerLimitationRelationManager in the admin.

+
+
+
+ First-purchase / welcome discount + missing +
+

Lunar does compute an order-level new_customer boolean (Jobs\Orders\MarkAsNewCustomer, ! $previousOrder) — but it's a post-order reporting flag surfaced only in the Filament order table/dashboard chart. Nothing reads it during ApplyDiscounts; there's no "is this customer's first order" condition available to a Discount at checkout time.

+
+
+
+ Referral discounts (reward both referrer and referee) + missing +
+

No referral concept anywhere in lunarphp/core or lunarphp/lunar — not a model, job, or config key. Common as a bolt-on in WooCommerce/Shopify via loyalty apps (e.g. WPLoyalty's referral-points module); would need to be built from scratch on top of Discount::customers() at best.

+
+
+
+ Loyalty points redeemable as a discount + missing +
+

No points ledger, balance, or redemption model exists in Lunar core. A loyalty program (points-to-discount conversion, VIP-tier multipliers) is a third-party plugin layer in every researched competitor, not core commerce logic — same gap here, but Lunar offers no AbstractDiscountType hook obviously suited to "redeem N points" either, since discount eligibility has no notion of a spendable balance.

+
+
+
+ +
+
+ 06 +

Extensibility

+
+

What it takes to reach a feature Lunar doesn't ship, without forking the package.

+ +
+
+
+ Registering a custom discount type + have +
+

Discounts::addType(MyType::class) appends to DiscountManager::$types (seeded with just AmountOff::class, BuyXGetY::class). A new type extends AbstractDiscountType and implements apply(CartContract $cart) — the same contract the two built-ins use, so it participates in the same unconditional-foreach loop from section 03.

+
+
+
+ Admin UI for a custom discount type + partial +
+

Requires additionally implementing Lunar\Admin\Base\LunarPanelDiscountInterface (lunarPanelSchema()/lunarPanelOnFill()/lunarPanelOnSave()) for DiscountResource::getDefaultForm() to render a config section for it. The interface exists and is wired in, but there is no shipped example implementation to copy from beyond AmountOff/BuyXGetY, which are hard-coded into the form rather than using the interface themselves.

+
+
+
+ +
+

Compiled 2026-08-28 · boboko-core / docs

+

Section 01–03 and 05–06 rows are grounded directly in vendor/lunarphp/core/src and vendor/lunarphp/lunar/src source reads (file/method citations inline). Section 02's per-user cap and section 04's pricing-vs-discount distinction are likewise direct source reads. Comparative claims about Shopify, WooCommerce, and PrestaShop feature sets and terminology (discount classes, cart-rule compatibility, loyalty/referral plugins) are sourced from current public documentation and app-store listings via web research, not from reading those platforms' source.

+
+ +
diff --git a/docs/scratch/payments-feature-survey.html b/docs/scratch/payments-feature-survey.html new file mode 100644 index 0000000..cb29969 --- /dev/null +++ b/docs/scratch/payments-feature-survey.html @@ -0,0 +1,448 @@ +Payments Feature Survey + + + + + + + +
+ +
+

boboko-core · competitive gap survey · 03

+

What payments elsewhere can do that boboko can't yet

+

+ Lunar's payment layer (Lunar\Facades\Payments, Transaction, the offline + driver) is wired for a single "pay on delivery / bank transfer" flow. Everything downstream of + that — cards, wallets, saved methods, self-service refunds, retries — is either scaffolded in + Lunar core and unused here, or absent from the stack entirely. This is a research survey, not a + build plan. +

+
+ 4 have + 9 partial + 14 missing +
+
+ +
+
+ 01 +

Payment method breadth

+
+

boboko currently ships one payment type: cash-in-hand via the offline driver. Every card/wallet/BNPL path below is theoretically pluggable but has zero live implementation.

+
+ +
+
Offline / pay-on-accounthave
+
The only configured type in config/lunar/payments.php (3dealer's published copy): 'cash-in-hand' => ['driver' => 'offline', 'authorized' => 'payment-offline'], backed by Lunar\PaymentTypes\OfflinePayment.
+
+ +
+
Card payments (Stripe/other gateway)missing
+
lunarphp/stripe is not present in either boboko-core/vendor/lunarphp or 3dealer/vendor/lunarphp, and not listed in either composer.json. docs/lunar.md's Stripe section documents Lunar's general capability, not something wired into this project.
+
+ +
+
Digital wallets (Apple Pay, Google Pay, Shop Pay)missing
+
Depends entirely on a card gateway (Stripe Payment Request Button or similar) that isn't installed. Shopify bundles Apple Pay, Google Pay, and Shop Pay as one-tap checkout by default.
+
+ +
+
Buy-now-pay-later (Klarna, Afterpay, Affirm)missing
+
No BNPL driver or config entry anywhere in the repo. Shopify bundles Klarna natively in eligible regions with Pay-in-4, Pay-Later, and financing tiers; WooCommerce and PrestaShop both offer it as installable gateway plugins.
+
+ +
+
Bank transfer / open banking (SEPA, Pay by Bank)missing
+
Not represented as a distinct payment type; only the generic cash-in-hand offline flow exists, which is manual reconciliation rather than an automated bank-transfer rail.
+
+ +
+
Crypto / stablecoin checkoutmissing
+
No driver, no research finding of it being used in this stack. Industry-wide it's still marginal — stablecoin payment volume is roughly 0.02% of global payments in 2026 per Nuvei's trend report — so this is low-priority even elsewhere.
+
+ +
+
Pluggable driver architecture for adding methodshave
+
Lunar\Managers\PaymentManager extends Laravel's Manager; Payments::extend('custom', fn ($app) => ...) registers a new driver, and any class extending Lunar\PaymentTypes\AbstractPayment implementing authorize()/capture()/refund() plugs in. The scaffolding is solid — nothing beyond offline is plugged into it yet.
+
+ +
+
+ +
+
+ 02 +

Capture, refund & transaction lifecycle

+
+

The core primitives (intent/capture/refund, partial amounts, transaction chaining) exist in Lunar and are exposed in the Filament admin — but nothing calls them outside cash-in-hand, and none of it is customer-facing.

+
+ +
+
Authorize / capture / refund contracthave
+
Lunar\Base\PaymentTypeInterface defines authorize(), capture(Transaction $t, $amount), refund(Transaction $t, int $amount, $notes); Transaction::capture()/refund() forward to the transaction's own driver() via Payments::driver($this->driver).
+
+ +
+
Manual vs. automatic capture policypartial
+
The interface supports separate authorize/capture steps (intent vs. capture transaction types), but OfflinePayment::capture() just returns new PaymentCapture(true) unconditionally — there's no real deferred-capture gateway wired up to exercise the distinction.
+
+ +
+
Partial capturepartial
+
Admin Filament action passes an arbitrary $data['amount'] to $transaction->capture(bcmul($data['amount'], $record->currency->factor)) in ManageOrder.php — the plumbing supports partial amounts, but only staff can trigger it, and only against a real (non-offline) driver would it mean anything.
+
+ +
+
Partial / staged refundshave
+
Same file: the "refund" Filament action computes $response = $transaction->refund(bcmul($data['amount'], ...), $data['notes']), and isPartiallyRefunded() / order status logic (partial-refund, refunded) compares refundTotal against captureTotal/intentTotal. This genuinely works today through the offline driver's no-op refund().
+
+ +
+
Multiple payment attempts per orderpartial
+
Transaction.parent_transaction_id chains captures to intents and refunds to captures, and nothing in the model stops multiple transaction rows per order — but no code path in this repo actually retries a failed attempt with a second transaction; it's schema support, not a driven flow.
+
+ +
+
Transaction audit trailhave
+
Lunar\Observers\TransactionObserver::created() logs every transaction (amount, type, status, card_type, last_four, reference, notes) via Spatie activity log automatically — this is real and unconditional, independent of driver.
+
+ +
+
Webhook handling for async payment eventsmissing
+
Lunar's Stripe package registers a stripe/webhook route, but that package isn't installed here, so there is no webhook endpoint of any kind in this project today.
+
+ +
+
Payment attempt events for downstream hookshave
+
Lunar\Events\PaymentAttemptEvent is dispatched from OfflinePayment::authorize() with the resulting PaymentAuthorize DTO — a real, listenable event, though only one driver currently fires it.
+
+ +
+
+ +
+
+ 03 +

Customer-facing payment experience

+
+

Everything a shopper would touch directly — saved cards, one-click repeat purchase, self-service refunds — is absent. Lunar's payment layer is staff/checkout-oriented, not account-oriented.

+
+ +
+
Saved payment methods on customer accountmissing
+
No vault/tokenization model exists anywhere in Lunar\Models — no PaymentMethod/Card model, no field on Customer. 2026 trend research (Nuvei, Checkout.com) treats network-tokenized saved cards as baseline for one-click checkout.
+
+ +
+
One-click repeat purchasemissing
+
Depends on saved payment methods, which don't exist. No "reorder" or "buy again" affordance found in boboko-core or 3dealer.
+
+ +
+
Customer self-service refund requestsmissing
+
The only refund entry point is the Filament staff action in ManageOrder.php (Actions\Action::make('refund')), gated behind admin auth. WooCommerce/PrestaShop ecosystems commonly expose a customer-initiated return/refund request flow; nothing equivalent exists here.
+
+ +
+
Split / partial payment plans (pay-in-installments at checkout)missing
+
Distinct from BNPL-as-a-gateway: this is a native "split into N charges" checkout option, seen as marketplace split-payment modules in the PrestaShop ecosystem. No equivalent concept in Lunar's cart/order/payment pipeline.
+
+ +
+
3D Secure / SCA authenticationmissing
+
3DS is a property of the card gateway integration (e.g. Stripe PaymentIntents), which isn't installed. WooPayments explicitly advertises 3DS/SCA compatibility with visible card-brand + last-four confirmation as a baseline expectation in 2026.
+
+ +
+
Fraud detection / risk scoringmissing
+
No fraud-scoring hook in PaymentTypeInterface or the offline driver. getPaymentChecks() exists as an extension point (Lunar\Base\DataTransferObjects\PaymentChecks, an iterable of pass/fail PaymentCheck DTOs) but AbstractPayment::getPaymentChecks() just returns an empty collection — real fraud tooling (Stripe Radar-style) isn't behind it.
+
+ +
+
Payment check / validation extension pointpartial
+
Transaction::paymentChecks() → driver's getPaymentChecks($transaction) is real, typed infrastructure for surfacing checks (e.g. "AVS matched") in the admin UI — but the default implementation is a no-op, so nothing populates it today.
+
+ +
+
+ +
+
+ 04 +

Currency, subscriptions & recurring billing

+
+

Lunar's multi-currency model covers pricing display, not multi-currency payment settlement; recurring billing/dunning has no representation at all.

+
+ +
+
Multi-currency pricing displayhave
+
Lunar\Models\Currency (code, exchange_rate, decimal_places, default) with sync_prices-gated conversion, documented in docs/lunar.md "Channels and Currencies" — this is genuinely wired, cart/pricing layer already uses it.
+
+ +
+
Multi-currency payment processing (charge in customer's currency)partial
+
Pricing can display and calculate in any configured currency, but no payment driver in this project actually settles a charge — so whether a real gateway would charge in-currency is untested; the pricing half is there, the processing half isn't proven.
+
+ +
+
Recurring billing / subscriptionsmissing
+
No subscription model, no recurring-charge scheduler anywhere in Lunar\Models or boboko-core. This is a one-time-purchase order/cart model end to end.
+
+ +
+
Failed-payment retry / dunningmissing
+
No retry scheduling, no dunning email sequence, no soft-decline handling anywhere in the payment layer — there's nothing to retry against since there's no recurring billing and no live gateway. WooPayments' dunning (1-3 day delayed retry on soft declines) is the comparison point.
+
+ +
+
PCI compliance / tokenized card storagemissing
+
No card data is collected or stored anywhere in this codebase (offline driver never touches card fields), so there's no PCI-scope exposure today — but also no tokenized-vault capability to build saved cards or 3DS on top of when a real gateway is added.
+
+ +
+
+ +
+

Compiled 2026-08-28 · boboko-core / docs

+

Section 01 (driver architecture) and section 02 (transaction lifecycle, refund/capture, observer, events) are grounded in direct reads of vendor/lunarphp/core/src/{Managers,PaymentTypes,Models,Observers,Events,Base} and vendor/lunarphp/lunar/src/Filament/Resources/OrderResource/Pages/ManageOrder.php, plus the published config/lunar/payments.php in 3dealer — not from docs/lunar.md alone, which was cross-checked and found to describe Lunar's general Stripe capability rather than anything installed in this project.

+

Sections 03 and 04, and the competitive framing throughout, draw on 2026 web research covering Shopify, WooCommerce/WooPayments, and PrestaShop payment modules, plus general industry trend reporting (Nuvei, Checkout.com, Mastercard). Those claims are marked by comparison language ("Shopify bundles...", "WooPayments advertises...") rather than citation to this repo.

+
+ +
diff --git a/docs/scratch/privacy-feature-survey.html b/docs/scratch/privacy-feature-survey.html new file mode 100644 index 0000000..a5dfe97 --- /dev/null +++ b/docs/scratch/privacy-feature-survey.html @@ -0,0 +1,492 @@ +Privacy Feature Survey + + + +
+ +
+
boboko / privacy & compliance · competitive survey
+

What privacy & compliance elsewhere can do that boboko can't yet

+

+ A feature-by-feature pass across GDPR/CCPA compliance tooling used by Shopify, + WooCommerce, and dedicated consent-management platforms — sourced, not recalled + from memory — checked against master and the substantial, + unmerged Privacy branch ("Feature: Creating Privacy + Basics") already built in this repo. For deciding what to finish and merge + next, not a build order. +

+
+ ● have + ◐ partial + ○ missing +
+
+ Most "partial" rows below are fully coded on the unmerged Privacy + branch (53 files, +3127/‑24 across two commits: 9f540cb, + 59303cf) but not on master — treated as partial, not + have, until it merges. boboko:anonymize is the one privacy-adjacent + command that already lives on master today. +
+
+ +
+
+ 01 +

Right of access & erasure

+
+

GDPR Art. 15 (access) and Art. 17 (erasure) — the two rights every DSAR tool is built around.

+ +
+
Data export request (right of access)
+ partial +
On Privacy branch only: PrivacyService::requestExportForCustomer()/requestExportForUser() queue ExportDataSubjectJob, which gathers every registered provider's data and writes a CSV-per-provider zip via WriteExportToCsvListener. Not on master.
+
+ +
+
Data erasure request (right to be forgotten)
+ partial +
On Privacy branch only: PrivacyService::requestErasureForCustomer()/requestErasureForUser(), extensible via config('core.privacy.providers') — the same config-array-registration pattern as NotificationRegistry, keyed off Modules\Core\Privacy\Contracts\PersonalDataProvider.
+
+ +
+
Cancellable grace period before erasure
+ partial +
On Privacy branch only: 30-day default (core.privacy.grace_period_days), reverted automatically on login via CancelErasureOnLoginListener — same pattern Shopify's own account-deletion flow uses. No native platform documents this as a first-party primitive; it's usually left to a third-party app.
+
+ +
+
Immediate erasure for regulator/legal requests
+ partial +
On Privacy branch only: requestImmediateErasureForCustomer()/ForUser(), typed to accept only Staff $requestedBy so a self-service path cannot reach it even by accident.
+
+ +
+
Multi-tenant erasure scoping (business account vs. individual login)
+ partial +
On Privacy branch only, and a genuinely uncommon feature: PrivacyService splits every operation into Customer-scope vs. User-scope, plus a sole-owner cascade (CascadeCustomerErasureListener) when erasing the last linked User orphans a Customer. No researched competitor product handles B2B multi-seat erasure this explicitly.
+
+ +
+
Right to rectification (self-service data correction)
+ missing +
No dedicated flow found on either branch — Art. 16 is generally satisfied today only incidentally, by a customer editing their own profile/address through existing account forms, not a tracked rectification request.
+
+ +
+
Dummy data anonymization for local dev
+ have +
On master: src/Command/AnonymizeCommand.php (boboko:anonymize) — scrubs users/lunar_customers, environment-guarded to local only. Distinct from GDPR erasure; the Privacy branch README diff explicitly flags this is not the compliance tool.
+
+
+ +
+
+ 02 +

Anonymization, pseudonymization & retention

+
+

Deletion isn't the only lawful outcome — these are three different operations, often confused with each other.

+ +
+
Legal-retention pseudonymization (orders/invoices)
+ partial +
On Privacy branch only: OrderDataProvider::eraseForCustomer() clears PII fields but keeps order rows/totals/tax data intact, citing GDPR Art. 17(3)(b)'s legal-obligation exception — reports ErasureOutcome::Pseudonymized, not Erased, distinctly.
+
+ +
+
Per-provider retention policy, owned by the data's own module
+ partial +
On Privacy branch only: PersonalDataProvider deliberately has no central taxonomy — each provider (CustomerDataProvider, AddressDataProvider, OrderDataProvider, CartDataProvider, ReviewDataProvider) decides erase vs. pseudonymize vs. skip for its own table. docs/privacy.md flags ReviewDataProvider's scope choice as needing review before relying on it.
+
+ +
+
Automatic data retention / auto-deletion after N days
+ missing +
Neither branch has a scheduled sweep that erases stale data on its own — every erasure on the Privacy branch is triggered by an explicit request, not a retention-policy timer (e.g. "delete guest carts after 2 years," "purge OTP logs after 90 days").
+
+ +
+
Audit trail of what was erased/exported and why
+ partial +
On Privacy branch only: DataErasureRequest.report stores the full per-provider outcome as a snapshot (not a live lookup), specifically so the audit record stays readable after the underlying data is gone.
+
+
+ +
+
+ 03 +

Consent & cookies

+
+

What a visitor is asked before tracking starts, and whether that choice is recorded anywhere.

+ +
+
Cookie consent banner (categorized: essential/analytics/marketing)
+ missing +
No code on either branch. Shopify ships a first-party Customer Privacy API recognizing four consent signals (analytics, marketing, preferences, sale-of-data); WooCommerce relies entirely on third-party plugins for this.
+
+ +
+
Granular marketing-consent tracking (email/SMS opt-in, per channel)
+ missing +
Not modeled anywhere in Modules\Core — no consent flag found on the Customer/User models on either branch.
+
+ +
+
Timestamped, versioned consent log (audit trail per visitor)
+ missing +
Standard feature of dedicated CMPs (OneTrust, Enzuzo, Consentmo) — a logged record of which policy version a visitor consented to and when. Nothing comparable exists in this codebase; the Privacy branch's audit trail covers erasure/export requests only, not consent events.
+
+ +
+
Google Consent Mode v2 / IAB TCF v2.3 integration
+ missing +
Storefront/analytics-layer concern, not present in boboko-core at all — would live in the 3dealer storefront, not this package.
+
+
+ +
+
+ 04 +

Policy & agreement management

+
+

Terms of service and privacy policy as tracked, versioned documents — not just static pages.

+ +
+
Terms-of-service / privacy-policy versioning
+ missing +
No version-tracked policy document model on either branch — best practice researched: store version hashes or dated text alongside each acceptance record, review at least annually.
+
+ +
+
Per-user acceptance tracking (clickwrap audit trail)
+ missing +
No record of "which policy version did this customer accept, and when" anywhere in Modules\Core. Researched as a standard requirement for surviving a legal dispute or regulatory inquiry.
+
+ +
+
Re-acceptance prompt on material policy change
+ missing +
Depends on the versioning row above existing first — nothing to gate a re-prompt on today.
+
+
+ +
+
+ 05 +

Payment data & PCI-DSS scope

+
+

Whether cardholder data ever actually reaches boboko's own infrastructure.

+ +
+
Card data never touches application servers (tokenization)
+ have +
Verified from source: docs/lunar.md "Stripe integration" — payment flows through Lunar's Stripe driver (Lunar\Stripe\Facades\Stripe, fetchOrCreateIntent()/PaymentIntents), so PAN never lands in a boboko/Lunar database. Researched: this pattern alone can cut PCI-DSS scope by roughly 90% per industry sources.
+
+ +
+
Self-attested SAQ-A eligibility documentation
+ missing +
The technical precondition (no card data touching the server) is met, but nothing in docs/ documents or asserts SAQ-A eligibility for a consuming app's own compliance paperwork.
+
+
+ +
+
+ 06 +

Regional & regulatory coverage

+
+

Beyond GDPR — the other regimes a storefront selling outside the EU may need.

+ +
+
CCPA "Do Not Sell/Share My Info" opt-out
+ missing +
No opt-out flag or page found on either branch. Shopify's Customer Privacy API models this as a distinct fourth consent signal ("sale of data") alongside analytics/marketing/preferences — boboko has no equivalent signal at all yet.
+
+ +
+
Geo-targeted regulatory detection (GDPR vs. CCPA vs. LGPD banner)
+ missing +
Third-party CMPs (Consentmo, UniConsent) auto-detect visitor region to show the applicable banner/rights. No geo-based privacy-regime logic anywhere in this codebase.
+
+ +
+
Age verification / minor-data restrictions (COPPA-adjacent)
+ missing +
No age gate or minor-specific data handling found on either branch.
+
+
+ +
+
+ 07 +

Incident & vendor accountability

+
+

What happens when something goes wrong, or when a third party is handling data on the shop's behalf.

+ +
+
Data breach notification workflow
+ missing +
No incident-tracking model or notification path found on either branch — GDPR Art. 33/34's 72-hour authority-notification and affected-subject-notification duties have no tooling here today.
+
+ +
+
Subprocessor / third-party vendor disclosure list
+ missing +
No subprocessor registry in code — Stripe is the one third-party data processor identifiable from docs/lunar.md, but nothing formally tracks or discloses it as a subprocessor.
+
+ +
+
Data processing agreement (DPA) tracking per vendor
+ missing +
Not applicable to application code directly, but no config or doc references a DPA registry either — purely a legal/ops artifact today, not represented in boboko-core at all.
+
+
+ +
+ Compiled 2026-08-28 — have/partial statuses sourced from direct reads of master and the unmerged Privacy branch (commits 9f540cb, 59303cf) via git show; competitor/regulatory claims sourced from web research, cited inline. + boboko-core / docs +
+ +
diff --git a/docs/scratch/products-collections-feature-survey.html b/docs/scratch/products-collections-feature-survey.html new file mode 100644 index 0000000..239aa6d --- /dev/null +++ b/docs/scratch/products-collections-feature-survey.html @@ -0,0 +1,508 @@ +Products & Collections Feature Survey + + + +
+ +
+
boboko / products & collections · competitive survey
+

What products & collections elsewhere can do that boboko can't yet

+

+ A feature-by-feature pass across Shopify, WooCommerce, PrestaShop, and general + 2026 storefront UX trends — checked against what + Modules\Core\Catalog actually ships in boboko-core + and what 3dealer's storefront actually calls. Unlike the rest of this survey + series, this concern is not a blank slate: a real Meilisearch-backed catalog + layer (listing, filtering, facets, search, collections, a product-option-type + system) was built this session. The gaps here are mostly about storefront wiring + and discovery/merchandising UX, not backend plumbing. +

+
+ Read this first: the category page's sort dropdown, price + slider, in-stock checkbox, and sidebar search box are all visually present but + functionally dead — none of them submit a request or call a filter. The backend + methods they'd need (ProductService::facets(), + priceRange(), list()'s sort param) already exist and + work; nothing in CategoryController passes them through yet. +
+
+ ● have + ◐ partial + ○ missing +
+
31 features surveyed — 8 have · 10 partial · 13 missing
+
+ +
+
+ 01 +

Core listing & filtering plumbing

+
+

The Meilisearch-backed layer everything else in this survey sits on top of — this is where most of this session's real build lives.

+ +
+
Paginated product listing, index-backed (not DB reads)
+ have +
ProductService::list() reads Product::search('') via Meilisearch and returns a real LengthAwarePaginator — used end-to-end by CategoryController::show() and rendered by x-product-grid.
+
+ +
+
Filter by collection (including descendant collections)
+ have +
ProductFilters::collectionId matches ProductIndexer's collection_ids field, which unions a product's direct collections with all ancestors — so a parent-category page picks up products attached only to a leaf subcategory. Wired in CategoryController.
+
+ +
+
Filter by brand, price range, stock status
+ partial +
ProductFilters supports brand, minPrice/maxPrice, inStockOnly, fully implemented in ProductService::buildFilter() — but category/show.blade.php's price slider and in-stock checkbox are hardcoded markup with no form submission; CategoryController never constructs a ProductFilters with any of these three.
+
+ +
+
Faceted counts for a filter sidebar (brand, stock, etc.)
+ partial +
ProductService::facets() returns value→count via Meilisearch facetDistribution, correctly scoped to co-applied filters — but nothing storefront-side calls it. No brand/attribute facet list renders anywhere in category/show.blade.php.
+
+ +
+
Price-range slider backed by real min/max
+ partial +
ProductService::priceRange() reads Meilisearch facetStats for a correct, filter-scoped min/max — the sidebar instead shows a static "€10 - €50" label with a non-functional apply button.
+
+ +
+
Sort (price asc/desc, newest)
+ partial +
ProductSort enum + ProductIndexer::getSortableFields() (price, created_at) work end-to-end in ProductService::list(sort: ...) — the storefront's sort <select> is explicitly commented {{-- Dummy — not wired to real sorting yet --}} and includes a "popularity" option with no backing signal at all.
+
+ +
+
Free-text product search
+ partial +
ProductSearchService::search() is a complete, locale-aware, fallback-safe implementation (attributesToSearchOn targeting current + default locale) — but no search route exists in 3dealer (routes/web.php only has product.show/category.show), and both the header search icon and the sidebar search box are inert buttons/inputs.
+
+ +
+
Single-product lookup by slug or id, index-only
+ have +
ProductService::getById()/getBySlug(), both zero-database-read lookups against the slugs/id filterable fields. ProductController::show() uses getById() directly.
+
+
+ +
+
+ 02 +

Collections & navigation

+
+

Category tree browsing, breadcrumbs, and merchandising — what turns a flat product list into a navigable store.

+ +
+
Category tree browsing (root / children / by group)
+ have +
CollectionService::list() with CollectionFilters(rootOnly/parentId/groupId), backed by CollectionIndexer's nested-set parent_id/_lft fields — no database read needed to build a nav tree.
+
+ +
+
Top-nav category dropdown
+ have +
components/header.blade.php renders a CSS-only hover dropdown from a $categories list passed into the layout, linking to category.show.
+
+ +
+
Breadcrumb navigation
+ partial +
CollectionIndexer indexes a full root-first ancestors array ({id, name}) specifically so a breadcrumb needs zero extra queries — but category/show.blade.php and product/show.blade.php both build a flat two-level x-breadcrumb (Home → this category/product) by hand, never reading ancestors. A product under a three-deep category shows no intermediate levels.
+
+ +
+
Category landing page merchandising (banner, pinned/featured products)
+ missing +
category/show.blade.php renders only the collection name/description above a plain product grid — no banner image field, no "featured in this category" pinning above organic results. CollectionIndexer's thumbnail field exists but isn't read on the category page at all (only used, if anywhere, for nav-level imagery).
+
+ +
+
Sub-category faceting (filter by attribute within a category)
+ missing +
No attribute-value facet (size, material, etc.) is indexed as filterable on ProductIndexer beyond brand and in_stock — a category page can't offer "filter dresses by size" the way Shopify/WooCommerce faceted nav does; would need new filterable fields on custom product attributes plus sidebar UI.
+
+ +
+
Product count shown per category
+ partial +
CollectionIndexer computes product_count (including descendant collections) at index time by querying the product index directly — correct and cheap, but nothing in category/show.blade.php or the nav dropdown displays it.
+
+
+ +
+
+ 03 +

Product detail page

+
+

What a shopper sees once they land on a single product — media, variants, reviews, cross-sell.

+ +
+
Multi-image gallery with lightbox
+ have +
product/show.blade.php's product-gallery Stimulus controller — thumbnail rail, main image, full popover lightbox with prev/next/counter — fed from ProductIndexer's full media array (not just a single thumbnail).
+
+ +
+
Variant selection via color swatches
+ have +
End-to-end: ColorOptionType lets an admin attach a hex code to an option value → ProductIndexer::mapVariant() embeds meta.hex per variant → x-ui.color-swatch renders real swatch buttons wired to a product-form Stimulus controller that swaps price/image on selection.
+
+ +
+
Swatches for non-color attributes (pattern, texture, material)
+ partial +
The ProductOptionTypeInterface system is explicitly built to be extensible — a PatternOptionType or MaterialOptionType is a new class plus a Filament form, no core change needed — but only ColorOptionType is registered, and x-ui.color-swatch itself hardcodes a background-color swatch, not a generic swatch renderer.
+
+ +
+
Customer reviews with ratings, photos, staff replies
+ have +
ProductReview model, fully indexed (items/count/average_rating, PII-safe), live-reindexed on review create/update/delete via ReviewServiceProvider, and rendered in product/show.blade.php's Reviews tab with x-review-card/x-review-form.
+
+ +
+
Structured data / schema.org Product markup
+ missing +
No application/ld+json or itemscope markup anywhere in 3dealer's views. Rich results (price/rating/availability in Google Shopping) are a significant organic-CTR lever per 2026 SEO guidance — the product page already has every field (price, rating, stock) a Product schema block would need, just not emitted.
+
+ +
+
Related products / "customers also bought" / cross-sell
+ missing +
Raw Lunar already models this (Lunar\Base\Enums\ProductAssociation::CROSS_SELL/UP_SELL/ALTERNATE, $product->associate()/associations() — see docs/lunar.md "Products and Variants") but nothing in Modules\Core\Catalog surfaces it, and the "Σχετικά προϊόντα" block at the bottom of product/show.blade.php is four fully hardcoded fake products with href => '#'.
+
+ +
+
Recently-viewed products
+ missing +
No session/cookie tracking of viewed products anywhere in 3dealer or core — a standard discovery module on both Shopify and WooCommerce storefronts per current UX research.
+
+ +
+
Product badges (new / sale / bestseller)
+ missing +
No badge concept on ProductIndexer's document and no badge markup on x-ui.product-card — would need either a computed signal (e.g. "new" from created_at, "sale" from compare_price already indexed per-variant) or an admin-set tag, neither wired to a visual badge today.
+
+ +
+
Size chart / fit guide
+ missing +
No size-chart content field on Product/ProductType and no UI for it on the product page. Not especially relevant to 3dealer's current catalog (3D-printed goods), but a real gap for any apparel-leaning store built on this core.
+
+ +
+
Stock notification ("notify me when back in stock")
+ missing +
in_stock is indexed and known per-product (ProductIndexer::toSearchableArray()), but there's no subscription model, email trigger, or UI for a shopper to ask to be notified — the signal exists, nothing acts on it.
+
+ +
+
Product Q&A section
+ missing +
No question/answer model anywhere in core — only the separate review system (ProductReview) exists, which is a distinct concept (post-purchase rating, not pre-purchase Q&A).
+
+
+ +
+
+ 04 +

Emerging discovery & merchandising UX

+
+

2026 trend-adjacent features, mostly backed on other platforms by paid apps/plugins rather than core — useful for calibrating how unusual these gaps are.

+ +
+
Quick-view modal (preview from listing grid, no page load)
+ missing +
x-ui.product-card links straight to product.show with a hover-revealed "add to cart" button only — no modal/preview interaction. Current UX research flags quick-view modals as a common INP (responsiveness) failure point, so the absence isn't purely a gap to close blindly.
+
+ +
+
Infinite scroll as an alternative to pagination
+ missing +
category/show.blade.php uses classic x-ui.pagination against the real paginator from ProductService::list() — works correctly, just page-based rather than scroll-based. Research is genuinely mixed on whether infinite scroll is even preferable for conversion/SEO, so this is a parity note, not a clear gap.
+
+ +
+
Product comparison tool (side-by-side spec table)
+ missing +
Not in boboko-core, and notably not native on Shopify or WooCommerce either — both rely on third-party apps (Bear Specs & Compare, Equate, WooCommerce's own paid "Advanced Product Comparison" extension). A real gap, but not one competitors solve in-platform for free.
+
+ +
+
Product bundles / kits
+ missing +
No bundle/kit concept (a purchasable grouping of several variants as one line item) anywhere in Lunar\Models\Product/ProductVariant or Modules\Core\Catalog.
+
+ +
+
360°/video product media, AR try-on
+ partial +
ProductIndexer's media array is just Spatie media-library images (url/thumb) — no video or 360° asset type modeled, and no AR integration. The gallery component (product-gallery Stimulus controller) is generic enough to extend to a video slide without a rewrite, but nothing does today.
+
+ +
+
Variant-specific SEO URLs (distinct slug per color/size)
+ partial +
Lunar's HasUrls/Url model supports per-locale slugs per product (indexed in ProductIndexer's slugs field), but there's no per-variant URL — selecting a color swatch changes displayed price/image via product-form client-side state, not the URL, so a specific variant can't be linked or indexed separately.
+
+
+ +
+ Compiled 2026-08-28 — Modules\Core\Catalog source, docs, and 3dealer storefront claims are direct reads; 2026 UX-trend, quick-view/infinite-scroll, and product-comparison-tooling claims are sourced from web research and marked accordingly in context. + boboko-core / docs +
+ +
diff --git a/docs/scratch/shipping-feature-survey.html b/docs/scratch/shipping-feature-survey.html new file mode 100644 index 0000000..ccccbc7 --- /dev/null +++ b/docs/scratch/shipping-feature-survey.html @@ -0,0 +1,465 @@ +Shipping Feature Survey + + + +
+ +
+
boboko / shipping · competitive survey
+

What shipping elsewhere can do that boboko can't yet

+

+ A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's shipping + layer — sourced, not recalled from memory — checked against what + Lunar core's ShippingManifest and the + lunarphp/table-rate-shipping add-on actually support + today, and what's actually wired up in boboko-core and 3dealer right now. + For deciding what to design next, not a build order. +

+
+ ● have + ◐ partial + ○ missing +
+
+ +
+
+ 01 +

Core plumbing

+
+

The mechanism Lunar core provides for offering and applying a shipping charge — everything else in this survey is built on top of it.

+ +
+
Pluggable shipping option providers
+ have +
Lunar\Base\ShippingModifier abstract class + ShippingManifest::addOption() — any package can register options onto the manifest via a pipeline of modifiers (ShippingModifiers::getModifiers()).
+
+ +
+
Shipping applied to cart totals during calculate()
+ have +
Lunar\Pipelines\Cart\ApplyShipping — reads ShippingManifest::getShippingOption($cart) or a manual shippingOptionOverride, writes a ShippingBreakdown and shippingSubTotal onto the cart before CalculateTax runs.
+
+ +
+
Cart-level shippable check
+ have +
Cart::isShippable() — true if any line's purchasable (e.g. ProductVariant::isShippable()) is shippable; a digital-only cart skips the shipping-address requirement entirely.
+
+ +
+
Selecting a shipping option on the cart
+ have +
Cart::setShippingOption() → SetShippingOption action, validated by ShippingOptionValidator, triggers a recalculate. Nothing in 3dealer's storefront calls it yet — no shipping step exists in the UI.
+
+ +
+
Order-time shipping line snapshot
+ have +
Lunar\Pipelines\Order\Creation\CreateShippingLine writes an immutable shipping-type order line from the cart's shipping breakdown at checkout — survives later rate changes.
+
+
+ +
+
+ 02 +

Rate configuration (table-rate-shipping add-on)

+
+

lunarphp/table-rate-shipping is installed (composer.json, pinned ^1.3) and its ShippingPlugin is registered in CorePlugin::boot() — so 3dealer inherits it automatically, it doesn't need its own registration.

+ +
+
Geographic shipping zones (country / state / postcode)
+ have +
ShippingZone model, type unrestricted|countries|states|postcodes; ShippingZoneResolver::get() matches a cart's address against zone scope, falling back to any unrestricted zone.
+
+ +
+
Flat-rate shipping
+ have +
Drivers\ShippingMethods\FlatRate::resolve() — one price per cart subtotal via Pricing::for($shippingRate).
+
+ +
+
Weight- or total-tiered rates ("ship by")
+ have +
Drivers\ShippingMethods\ShipBy::resolve() — data['charge_by'] is cart_total or weight, tiered via priceBreaks, with customer-group price overrides taking priority.
+
+ +
+
Free-shipping threshold
+ have +
Drivers\ShippingMethods\FreeShipping::resolve() — data['minimum_spend'] (per-currency array supported), optional use_discount_amount to check against post-discount subtotal.
+
+ +
+
In-store pickup / collection
+ have +
Drivers\ShippingMethods\Collection::resolve() — zero-price option, flagged collect: true on the ShippingOption. Single implicit "store" — no concept of which location, no per-location stock or hours.
+
+ +
+
Per-product shipping exclusions by zone
+ have +
ShippingExclusionList + ShippingZone::shippingExclusions() — every driver checks it before resolving and returns null if any cart line's product is excluded from that zone.
+
+ +
+
Per-customer-group rate visibility
+ have +
ShippingMethod::customerGroups() pivot carries visible, enabled, starts_at, ends_at — scheduling and audience-gating a rate is already modeled.
+
+ +
+
Filament admin UI for zones/methods/rates
+ have +
ShippingZoneResource, ShippingMethodResource, ShippingExclusionListResource ship with the add-on — usable as soon as the Filament plugin is registered, which it is via CorePlugin.
+
+ +
+
Storefront checkout step to pick a rate
+ missing +
No shipping views exist in 3dealer's resources/views beyond a passing mention in components/footer.blade.php — the whole backend above is unwired to any customer-facing UI.
+
+
+ +
+
+ 03 +

Carrier integration

+
+

Real carriers quoting and printing on Lunar's behalf, rather than merchant-defined flat/tiered rates.

+ +
+
Real-time carrier rate shopping (USPS/UPS/FedEx/DHL)
+ missing +
No driver in table-rate-shipping calls an external carrier API — all four shipped drivers (FlatRate, ShipBy, FreeShipping, Collection) compute from local data. Shopify's CarrierService API is the model for this: shop sends weight/dims/destination, carrier returns live rates at checkout.
+
+ +
+
Product/variant weight & dimensions for rating
+ partial +
ProductVariant has weight_value/weight_unit (referenced in ShipBy's weight tier and docs/lunar.md) but no length/width/height fields exist in core migrations — enough for weight-tier rating, not enough for carrier-grade dimensional/volumetric quotes.
+
+ +
+
Shipping label generation & printing (staff-facing)
+ missing +
No label concept anywhere in core or the add-on. Shopify has this built in for US merchants (USPS/UPS labels from admin or mobile); WooCommerce/PrestaShop lean on Shippo/EasyPost-style apps.
+
+ +
+
Return / exchange label generation
+ missing +
No returns concept exists in Lunar core at all — this sits behind both "labels" and "returns," neither of which exists yet.
+
+ +
+
Shipment tracking numbers on orders
+ missing +
OrderShippingZone pivot table records which zone an order matched, but no field anywhere stores a carrier tracking number or shipment status.
+
+
+ +
+
+ 04 +

Fulfillment logistics

+
+

Where an order physically ships from, and whether it can ship from more than one place.

+ +
+
Multi-warehouse / multi-location inventory
+ missing +
No warehouse, location, or fulfillment-center model anywhere in vendor/lunarphp/core or lunar — stock is a flat quantity on the variant. WooCommerce needs Calcurates or WooCommerce Warehouses add-ons for this; it's genuinely not a Lunar concept at all.
+
+ +
+
Split shipment (one order, multiple packages/warehouses)
+ missing +
Downstream of multi-warehouse — with a single implicit stock pool, there's nothing to split by. CreateShippingLine writes exactly one shipping line per order.
+
+ +
+
Multiple pickup locations (choose a specific store)
+ missing +
The Collection driver models pickup as a single yes/no rate per zone — no location entity to pick from, no per-location hours/capacity.
+
+ +
+
Local delivery (distinct from carrier shipping or pickup)
+ missing +
No radius/zone-based "we deliver it ourselves" driver — only ShipBy/FlatRate (carrier-agnostic priced shipping) and Collection (pickup) exist as concepts.
+
+ +
+
Delivery date / time-slot selection at checkout
+ missing +
No date/time field on ShippingOption, CartAddress, or the order shipping line. WooCommerce needs a dedicated delivery-date-picker plugin for this too — not a gap unique to Lunar, but still open here.
+
+
+ +
+
+ 05 +

International & risk

+
+

What happens when a shipment crosses a border, or something goes wrong in transit.

+ +
+
Customs documentation / HS codes per product
+ missing +
No HS-code or customs-description field found on Product/ProductVariant migrations. Every international shipment needs one per line item to clear customs — researched requirement, not yet modeled anywhere in Lunar.
+
+ +
+
Duties/taxes collected at checkout (DDP)
+ missing +
Lunar's CalculateTax pipeline step handles sales tax/VAT on the cart itself, not import duty estimation for cross-border orders. DDP vs. DDU is the standard framing (seller-collects-upfront vs. customer-pays-on-delivery) — neither is modeled.
+
+ +
+
Country/zone-restricted shipping
+ have +
ShippingZone type countries/states/postcodes already scopes which rates apply where — the building block international shipping would sit on top of.
+
+ +
+
Shipping insurance / package protection at checkout
+ missing +
No insurance line-item concept in core. On Shopify this is exclusively third-party (ShipInsure, Route, Simply Shipping Protection) — not a platform-native feature there either, so the gap is normal, not distinctive.
+
+
+ +
+ Compiled 2026-08-28 — inline citations from vendor/lunarphp/core and vendor/lunarphp/table-rate-shipping source are direct reads; DDP/DDU, carrier-API, label, and warehouse claims are sourced from web research on Shopify/WooCommerce/PrestaShop, marked accordingly by context. + boboko-core / docs +
+ +
From ca36c31cab2e676195235761ff729b5d8ad426aa Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sat, 29 Aug 2026 00:27:20 +0300 Subject: [PATCH 040/110] Hotfix: Correcting Wrong Position on Checkout, Correcting Cart Display Pages --- .../Resources/CartResource/Pages/ListCarts.php | 15 ++++++++------- src/CorePlugin.php | 1 - .../Filament/Pages/ManagePickupManifests.php | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php b/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php index 719b510..a1e5b4d 100644 --- a/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php +++ b/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php @@ -35,20 +35,21 @@ class ListCarts extends ListRecords public function getTabs(): array { return [ - 'ongoing' => Tab::make('Ongoing') - ->modifyQueryUsing(fn (Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())), 'abandoned_cart' => Tab::make('Abandoned Cart') - ->modifyQueryUsing(fn (Builder $query) => $query + ->modifyQueryUsing(fn(Builder $query) => $query ->whereDoesntHave('orders') ->where('updated_at', '<=', CartResource::abandonedCutoff())), 'abandoned_checkout' => Tab::make('Abandoned Checkout') - ->modifyQueryUsing(fn (Builder $query) => $query - ->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at')) + ->modifyQueryUsing(fn(Builder $query) => $query + ->whereHas('orders', fn(Builder $query) => $query->whereNull('placed_at')) ->where('updated_at', '<=', CartResource::abandonedCutoff())), + + 'ongoing' => Tab::make('Ongoing') + ->modifyQueryUsing(fn(Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())), 'completed' => Tab::make('Completed') - ->modifyQueryUsing(fn (Builder $query) => $query->whereHas( + ->modifyQueryUsing(fn(Builder $query) => $query->whereHas( 'orders', - fn (Builder $query) => $query->whereNotNull('placed_at'), + fn(Builder $query) => $query->whereNotNull('placed_at'), )), ]; } diff --git a/src/CorePlugin.php b/src/CorePlugin.php index e5c0920..53c60ba 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -50,7 +50,6 @@ class CorePlugin implements Plugin CartResource::class, ]) ->plugin(ShippingPlugin::make()) - ->plugin(ShippingPlugin::make()) ->pages([ManagePickupManifests::class]); LunarPanel::extensions([ diff --git a/src/Shipping/Filament/Pages/ManagePickupManifests.php b/src/Shipping/Filament/Pages/ManagePickupManifests.php index 993c513..4f7ba24 100644 --- a/src/Shipping/Filament/Pages/ManagePickupManifests.php +++ b/src/Shipping/Filament/Pages/ManagePickupManifests.php @@ -25,6 +25,23 @@ class ManagePickupManifests extends Page implements HasTable protected static ?string $navigationLabel = 'Pickup Manifests'; + /** + * Without an explicit group, this page had no navigation group at all — + * Filament's Panel::getUrl() falls back to "first item in the first + * navigation group" when no homeUrl is set (neither Lunar nor CorePlugin + * sets one), and an ungrouped page sorted ahead of every one of Lunar's + * own grouped resources (Sales, Catalog, etc.), making this page the + * panel's de facto home instead of the real Dashboard. Grouping it under + * Sales — alongside CartResource, OrderResource — fixes that by letting + * a legitimate item sort first again. Sorted last within the group + * deliberately (a high explicit navigationSort — Lunar's own + * OrderResource uses 1) so this page never competes to be first even as + * more Sales-group items are added later. + */ + protected static ?string $navigationGroup = 'Sales'; + + protected static ?int $navigationSort = 100; + protected static string $view = 'core::shipping.filament.pages.manage-pickup-manifests'; public function table(Table $table): Table From 6671c5e9d1b2ba1a5ee53de1626b47c2f300f268 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sat, 29 Aug 2026 01:14:58 +0300 Subject: [PATCH 041/110] Feature: Adding Checkout Services and Events --- docs/checkout.md | 155 ++++++++++++++++++ src/Checkout/Events/BillingAddressSet.php | 20 +++ src/Checkout/Events/OrderPlaced.php | 21 +++ src/Checkout/Events/ShippingAddressSet.php | 22 +++ .../Events/ShippingOptionSelected.php | 24 +++ .../InvalidShippingOptionException.php | 21 +++ src/Checkout/Services/CheckoutService.php | 124 ++++++++++++++ 7 files changed, 387 insertions(+) create mode 100644 docs/checkout.md create mode 100644 src/Checkout/Events/BillingAddressSet.php create mode 100644 src/Checkout/Events/OrderPlaced.php create mode 100644 src/Checkout/Events/ShippingAddressSet.php create mode 100644 src/Checkout/Events/ShippingOptionSelected.php create mode 100644 src/Checkout/Exceptions/InvalidShippingOptionException.php create mode 100644 src/Checkout/Services/CheckoutService.php diff --git a/docs/checkout.md b/docs/checkout.md new file mode 100644 index 0000000..9f486b8 --- /dev/null +++ b/docs/checkout.md @@ -0,0 +1,155 @@ +# Checkout — Design Notes + +**Status: design finalized, not yet built.** This is the design spec for +`Modules\Core\Checkout\Services\CheckoutService`, plus the three-stage lifecycle model it's +part of. Nothing in this document is implemented yet. + +--- + +## Three-stage lifecycle: Cart → Checkout → Order + +Each stage is its own concern, not a phase inside a shared one — matching the pattern already +established this session (`Recovery` was split out from `Cart` specifically because +abandonment detection is a different lifecycle stage than line-item mutation, even though it +reads `Cart` state). + +- **`Cart`** — line items, coupons, save-for-later (`docs/cart.md`). Ends the moment + `Cart::createOrder()` is called. +- **`Checkout`** — the placement moment itself: setting addresses, selecting a shipping + option, placing the order. Starts where Cart ends, ends the instant an `Order` exists. + This document. +- **`Order`** — everything after an order exists: status transitions (`Order::status`, + changed via the Filament admin `EditOrder` page — always staff-driven, never part of + checkout itself), fulfillment/shipment tracking. **Named and scoped here, not yet built** — + same status as `Recovery` before it existed as real code. + +`Modules\Core\Checkout\Events\OrderPlaced` (see below) is the handoff point: `Checkout` +dispatches it the moment an order exists; `Order`'s own listeners (not built yet) would be +what reacts to it — e.g. sending a confirmation email, initializing whatever `Order` needs to +initialize. `Checkout` itself has no opinion about what happens after `OrderPlaced` fires. + +### Where `Order` would likely absorb work that currently lives under `Shipping` + +`Modules\Core\Shipping`'s `Shipment`/`ShipmentInfo` models are already order-scoped +(`Shipment::order(): BelongsTo`), and `PollShipmentTrackingJob`/ +`ShipmentStatusUpdatedByCarrier` are fulfillment/tracking concerns that happen entirely after +an order exists — conceptually closer to `Order` than to `Shipping`'s actual job (carrier +rate quoting, `ShippingRateInterface` drivers, `ShippingManifest`). Not decided whether/when +this gets moved; noted here so the boundary is visible when `Order` is actually scoped. + +--- + +## `CheckoutService` + +Mirrors `Modules\Core\Cart\Services\CartService`'s shape (see `docs/cart.md`) — one +boboko-owned API a storefront calls, keeping Lunar's own `Cart`/`ShippingManifest` primitives +an implementation detail. + +| Method | Wraps | Dispatches | +|---|---|---| +| `setShippingAddress(array\|Addressable $address)` | `Cart::setShippingAddress()` | `ShippingAddressSet($cart, $address)` | +| `setBillingAddress(array\|Addressable $address)` | `Cart::setBillingAddress()` | `BillingAddressSet($cart, $address)` | +| `getShippingOptions()` | `ShippingManifest::getOptions($cart)` | — (read-only) | +| `selectShippingOption(string $identifier)` | `Cart::setShippingOption()` | `ShippingOptionSelected($cart, $option)` — throws `InvalidShippingOptionException` if `$identifier` doesn't resolve | +| `placeOrder(string $fingerprint)` | `Cart::checkFingerprint()` then `Cart::createOrder()` | `OrderPlaced($order)` | + +### `getShippingOptions()` — already fully backed by the merged Shipping-Carriers work + +`ShippingManifest::getOptions($cart)` runs every registered `ShippingRateInterface` driver +through a pipeline — this already includes ACS/Box Now live-rate quoting +(`Modules\Core\Shipping\Carriers\Acs\AcsRateDriver`/`BoxNowRateDriver`, merged from the +`Shipping-Carriers` branch) alongside `table-rate-shipping`'s own flat-rate/free-shipping/ +collection drivers. `CheckoutService` doesn't need to build any rate-resolution logic — it's +a thin pass-through to what already exists and works. + +### `placeOrder()` — fingerprint check is mandatory, not optional + +`placeOrder(string $fingerprint): Order` requires the fingerprint the shopper's last-seen +cart total was built from (`Cart::fingerprint()`) as a parameter — not an optional +after-the-fact check a caller might forget. `Cart::checkFingerprint()` throws Lunar's own +`FingerprintMismatchException` if the cart's contents/total changed since that fingerprint +was generated (a line's price changed, stock adjusted the total, another tab modified the +cart), forcing re-confirmation instead of silently placing an order at a different total than +what the shopper approved. + +### No exception wrapping — same reasoning as `CartService` + +Confirmed from source: `Lunar\Validation\Cart\ValidateCartForOrderCreation` (the validator +`Cart::createOrder()` runs via `config('lunar.cart.validators.order_create')`) already throws +`Lunar\Exceptions\Carts\CartException` with a field-keyed `MessageBag` +(`$exception->errors()`) — billing/shipping address completeness, missing shipping option, +duplicate-order guard. This is already the right shape for a storefront to catch and render +as form errors directly; wrapping it in a boboko-owned exception type would add indirection +with identical semantics, the same call made for `CartService`'s cart-line exceptions. + +`FingerprintMismatchException` (from the mandatory fingerprint check above) propagates +as-is for the same reason. + +**One genuine exception to this rule**: `selectShippingOption()` throws +`Modules\Core\Checkout\Exceptions\InvalidShippingOptionException` when `$identifier` doesn't +resolve to a real option (`ShippingManifest::getOption()` just returns `null` — Lunar has no +matching exception type here to propagate, unlike `CartException`/`FingerprintMismatchException` +above). Same reasoning as `Modules\Core\Cart\Exceptions\InvalidCouponException` for +`Discounts::validateCoupon()`, which also just returns a bool with nothing to reuse. Confirmed +live: an invalid identifier previously returned the cart unchanged with no signal at all — +fixed to throw instead, verified via a real container test. + +### Validated from source: the real precondition chain + +`ValidateCartForOrderCreation::validate()`, read directly from `vendor/lunarphp/core`: + +1. No completed order already exists on this cart (duplicate-order guard). +2. A billing address is set and passes `country_id`/`first_name`/`line_one`/`city`/`postcode` + required-field validation. +3. If the cart `isShippable()` (has at least one non-digital line): + - A shipping option must already be selected (`Cart::getShippingOption()` — which only + resolves anything once `shippingAddress->shipping_option` has been persisted via + `selectShippingOption()`, confirmed from `Lunar\Base\ShippingManifest::getShippingOption()`). + - Unless that option is collect/pickup (`$shippingOption->collect`), a shipping address is + also required and validated the same way as billing. + +This is why `CheckoutService`'s methods exist in the order they're listed above — a +storefront checkout flow has to drive them roughly in that sequence for `placeOrder()` to +ever succeed. + +--- + +## Events — richer payload than `CartService`'s, deliberately + +`Modules\Core\Checkout\Events`: `ShippingAddressSet`, `BillingAddressSet`, +`ShippingOptionSelected`, `OrderPlaced`. + +Unlike `CartService`'s events (which carry a plain `Cart`/`CartLine` model reference — see +`docs/cart.md`), these carry richer, already-resolved payload — e.g. `ShippingOptionSelected` +includes the resolved `ShippingOption` (name, price, carrier identifier), not just the +string identifier a listener would have to re-resolve. Deliberate divergence from +`CartService`'s convention: a live-priced shipping quote or a submitted address is +meaningfully more expensive/awkward for a listener to re-derive later than a `CartLine` +model reference is. + +**Why this matters beyond `Checkout` itself:** the Analytics survey (`docs/scratch/ +analytics-feature-survey.html`) found conversion-funnel tracking (product view → add to cart +→ checkout → purchase) entirely missing, with zero underlying data captured anywhere. The +Checkout survey separately flagged "abandoned-checkout stage tracking (email captured vs. +shipping selected vs. payment started)" as missing. One event per real state transition here +— not just a single `OrderPlaced` at the end — is what gives a future analytics/reporting +listener (not built) the funnel-stage data neither gap currently has anything to build on. + +**None of these have a listener yet.** Same status as `CartService`'s events — dispatched, +unconsumed, built so something downstream has a hook to attach to. + +--- + +## Explicitly out of scope for `CheckoutService` + +- **Order-status-changed events** — post-placement, staff-driven (`Order::status` changes via + the Filament admin `EditOrder` page, never through checkout). Belongs to `Order` (see + above), not `Checkout`. +- **Order confirmation email** — needs `OrderPlaced` as a trigger, but actual sending is + separate infrastructure, same "detection/signal only, sending is a later concern" deferral + already applied to `Recovery` (`docs/recovery-strategies.md`). +- **Guest order tracking/lookup** — a separate storefront feature, not part of the placement + flow itself. +- **Payment** — authorizing/capturing a transaction against the placed order. Genuinely + separate from `Checkout` as scoped here; `CheckoutService::placeOrder()` produces an + `Order`, what happens to pay for it is out of this document's scope. diff --git a/src/Checkout/Events/BillingAddressSet.php b/src/Checkout/Events/BillingAddressSet.php new file mode 100644 index 0000000..9dd48b4 --- /dev/null +++ b/src/Checkout/Events/BillingAddressSet.php @@ -0,0 +1,20 @@ +cart->currentOrCreate()->setShippingAddress($address); + + Event::dispatch(new ShippingAddressSet($cart, $address)); + + return $cart; + } + + public function setBillingAddress(array|Addressable $address): Cart + { + $cart = $this->cart->currentOrCreate()->setBillingAddress($address); + + Event::dispatch(new BillingAddressSet($cart, $address)); + + return $cart; + } + + /** + * Every shipping option currently available for the cart — already + * fully backed by the merged Shipping-Carriers work: this runs every + * registered Lunar\Shipping\Interfaces\ShippingRateInterface driver + * (ACS/Box Now live-rate quoting alongside table-rate-shipping's own + * flat-rate/free-shipping/collection drivers) through + * ShippingManifest's pipeline. No rate-resolution logic lives here — + * this is a thin pass-through. + * + * @return Collection + */ + public function getShippingOptions(): Collection + { + return ShippingManifest::getOptions($this->cart->currentOrCreate()); + } + + /** + * @throws InvalidShippingOptionException if $identifier doesn't resolve + * to a real, currently-available option for the cart + */ + public function selectShippingOption(string $identifier): Cart + { + $cartBefore = $this->cart->currentOrCreate(); + $option = ShippingManifest::getOption($cartBefore, $identifier); + + if ($option === null) { + throw new InvalidShippingOptionException($identifier); + } + + $cart = $cartBefore->setShippingOption($option); + + Event::dispatch(new ShippingOptionSelected($cart, $option)); + + return $cart; + } + + /** + * $fingerprint is mandatory, not optional — the caller must prove the + * cart total the shopper last saw (Cart::fingerprint()) still matches + * before an order is placed. Cart::checkFingerprint() throws Lunar's own + * FingerprintMismatchException on a mismatch (a line's price changed, + * stock adjusted the total, another tab modified the cart) rather than + * silently placing an order at a different total than what was shown. + * + * No exception wrapping: Lunar\Validation\Cart\ValidateCartForOrderCreation + * (run inside Cart::createOrder()) already throws + * Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag + * ($exception->errors()) for address/shipping-option validation and the + * duplicate-order guard — already the right shape for a storefront to + * render as form errors directly. FingerprintMismatchException + * propagates the same way, for the same reason. + * + * @throws \Lunar\Exceptions\FingerprintMismatchException + * @throws \Lunar\Exceptions\Carts\CartException + */ + public function placeOrder(string $fingerprint): Order + { + $cart = $this->cart->currentOrCreate(); + $cart->checkFingerprint($fingerprint); + + $order = $cart->createOrder(); + + Event::dispatch(new OrderPlaced($order)); + + return $order; + } +} From f85fb51ecd7c0a11e5025531d35f6dd05d859577 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sat, 29 Aug 2026 10:31:58 +0300 Subject: [PATCH 042/110] Feature: Adding Caches and Fallbacks to Live Pricing Requests --- src/Providers/ShippingServiceProvider.php | 11 ++++ src/Shipping/Carriers/Acs/AcsRateDriver.php | 58 +++++++++++------- src/Shipping/Concerns/CachesLivePricing.php | 38 ++++++++++++ .../Filament/Pages/ManageShippingRates.php | 58 +++++++++++------- .../Listeners/FlushLivePricingCache.php | 61 +++++++++++++++++++ 5 files changed, 182 insertions(+), 44 deletions(-) create mode 100644 src/Shipping/Concerns/CachesLivePricing.php create mode 100644 src/Shipping/Listeners/FlushLivePricingCache.php diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php index c93b899..2b77e83 100644 --- a/src/Providers/ShippingServiceProvider.php +++ b/src/Providers/ShippingServiceProvider.php @@ -3,6 +3,7 @@ namespace Modules\Core\Providers; use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; use Livewire\Mechanisms\ComponentRegistry; @@ -10,6 +11,11 @@ use Lunar\Models\Order; use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates; use Lunar\Shipping\Models\ShippingMethod; +use Modules\Core\Cart\Events\CartCleared; +use Modules\Core\Cart\Events\CartLineAdded; +use Modules\Core\Cart\Events\CartLineRemoved; +use Modules\Core\Cart\Events\CartLineUpdated; +use Modules\Core\Checkout\Events\ShippingAddressSet; use Modules\Core\Shipping\Carriers\Acs\AcsClient; use Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService; use Modules\Core\Shipping\Carriers\Acs\AcsRateDriver; @@ -20,6 +26,7 @@ use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; +use Modules\Core\Shipping\Listeners\FlushLivePricingCache; use Modules\Core\Shipping\Models\Shipment; class ShippingServiceProvider extends ServiceProvider @@ -62,6 +69,10 @@ class ShippingServiceProvider extends ServiceProvider return $order->hasMany(Shipment::class); }); + foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) { + Event::listen($event, [FlushLivePricingCache::class, 'handle']); + } + // Deferred: the Shipping facade resolves a binding registered in // lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(), // and provider boot order between packages isn't guaranteed. diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php index 6c8af72..0daea90 100644 --- a/src/Shipping/Carriers/Acs/AcsRateDriver.php +++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php @@ -8,12 +8,14 @@ use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Models\ShippingRate; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; +use Modules\Core\Shipping\Concerns\CachesLivePricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing; class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing { use ResolvesFixedPricing; + use CachesLivePricing; public ShippingRate $shippingRate; @@ -51,35 +53,45 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode); } + /** + * Wrapped in CachesLivePricing's cache so a live-pricing outage within + * the cache window still serves the last successful quote instead of + * immediately falling back. A cold cache during an outage falls back + * to the rate's own configured static price (resolveFixedPrice()) — + * see ManageShippingRates, which now allows a static price to be + * configured on a "live" rate specifically for this fallback. + */ private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption { - try { - $destination = $this->areaResolver->resolve($postcode); + return $this->cached($shippingRate, $cart, function () use ($shippingRate, $shippingMethod, $cart, $postcode) { + try { + $destination = $this->areaResolver->resolve($postcode); - $response = $this->client->call('ACS_Price_Calculation', [ - 'Billing_Code' => config('acs.billing_code'), - 'Acs_Station_Destination' => $destination->stationId, - 'Weight' => $this->totalWeightInKg($cart), - 'Pickup_Date' => now()->toDateString(), - 'Charge_Type' => 2, - ])->throwIfError(); - } catch (AcsApiException $e) { - report($e); + $response = $this->client->call('ACS_Price_Calculation', [ + 'Billing_Code' => config('acs.billing_code'), + 'Acs_Station_Destination' => $destination->stationId, + 'Weight' => $this->totalWeightInKg($cart), + 'Pickup_Date' => now()->toDateString(), + 'Charge_Type' => 2, + ])->throwIfError(); + } catch (AcsApiException $e) { + report($e); - return null; - } + return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart); + } - $amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100); + $amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100); - return new ShippingOption( - name: $shippingMethod->name ?: $this->name(), - description: $shippingMethod->description ?: $this->description(), - identifier: $shippingRate->getIdentifier(), - price: new Price($amount, $cart->currency, 1), - taxClass: $shippingRate->getTaxClass(), - taxReference: $shippingRate->getTaxReference(), - meta: ['acs_station_destination' => $destination->stationId], - ); + return new ShippingOption( + name: $shippingMethod->name ?: $this->name(), + description: $shippingMethod->description ?: $this->description(), + identifier: $shippingRate->getIdentifier(), + price: new Price($amount, $cart->currency, 1), + taxClass: $shippingRate->getTaxClass(), + taxReference: $shippingRate->getTaxReference(), + meta: ['acs_station_destination' => $destination->stationId], + ); + }); } public function on(ShippingRate $shippingRate): self diff --git a/src/Shipping/Concerns/CachesLivePricing.php b/src/Shipping/Concerns/CachesLivePricing.php new file mode 100644 index 0000000..bd6bed2 --- /dev/null +++ b/src/Shipping/Concerns/CachesLivePricing.php @@ -0,0 +1,38 @@ +id}.{$cart->id}", + now()->addMinutes(30), + $resolve, + ); + } +} diff --git a/src/Shipping/Filament/Pages/ManageShippingRates.php b/src/Shipping/Filament/Pages/ManageShippingRates.php index bf6cdb3..672e1d1 100644 --- a/src/Shipping/Filament/Pages/ManageShippingRates.php +++ b/src/Shipping/Filament/Pages/ManageShippingRates.php @@ -2,10 +2,12 @@ namespace Modules\Core\Shipping\Filament\Pages; +use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Forms\Get; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Model; use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates; use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingRate; @@ -17,13 +19,20 @@ use Lunar\Shipping\Models\ShippingRate; * ShippingZoneResource::getPages() — is untouched; the container simply * hands back this subclass whenever the vendor class is resolved. * - * Hides the price / price-break fields for a rate whose method has + * Relabels the price / price-break fields for a rate whose method has * charge_by = "live" (see ShippingMethodResourceExtension, which adds that - * option to methods whose driver supports live pricing) — those fields - * would otherwise be dead configuration the driver never reads. Pricing - * strategy (cart_total / weight / live) stays entirely on the Shipping - * Method, matching Lunar's own existing charge_by convention; nothing new - * is stored on the rate itself. + * option to methods whose driver supports live pricing) — they stay + * visible and editable, but as the fallback price used when the live API + * call fails (see AcsRateDriver::resolveLivePrice()), not the primary + * price. Pricing strategy (cart_total / weight / live) stays entirely on + * the Shipping Method, matching Lunar's own existing charge_by convention; + * nothing new is stored on the rate itself. + * + * Also re-binds the vendor price field's afterStateHydrated(): the vendor + * callback reads $record->basePrices->first()->price->decimal with no + * null-guard, which crashes on any rate with no basePrices row — routine + * for a live rate that has never had a fallback price configured. Same + * logic, just null-safe. */ class ManageShippingRates extends BaseManageShippingRates { @@ -32,13 +41,13 @@ class ManageShippingRates extends BaseManageShippingRates $form = parent::form($form); return $form->schema( - $this->hidePriceFieldsWhenLive($form->getComponents()) + $this->labelPriceFieldsAsFallbackWhenLive($form->getComponents()) ); } - private function hidePriceFieldsWhenLive(array $components): array + private function labelPriceFieldsAsFallbackWhenLive(array $components): array { - $isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live'; + $isLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) === 'live'; foreach ($components as $component) { if (! method_exists($component, 'getName')) { @@ -46,11 +55,25 @@ class ManageShippingRates extends BaseManageShippingRates } if ($component->getName() === 'price') { - $component->visible($isNotLive)->required($isNotLive)->dehydrated(true); + $component->required(fn (Get $get) => ! $isLive($get)) + ->helperText(fn (Get $get) => $isLive($get) + ? 'Used only if the live API call fails.' + : null) + ->afterStateHydrated(static function (TextInput $component, ?Model $record = null): void { + if (! $record) { + return; + } + + $basePrice = $record->basePrices->first(); + + $component->state($basePrice?->price->decimal); + }); } if ($component->getName() === 'prices') { - $component->visible($isNotLive)->dehydrated(true); + $component->helperText(fn (Get $get) => $isLive($get) + ? 'Used only if the live API call fails.' + : null); } } @@ -68,7 +91,9 @@ class ManageShippingRates extends BaseManageShippingRates ->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label')) ->formatStateUsing(function ($state, ShippingRate $record) { if (static::methodChargeBy($record->shipping_method_id) === 'live') { - return 'Live API pricing'; + return $state === null + ? 'Live API pricing, no fallback set' + : $state->price->formatted.' (fallback)'; } return $state?->price->formatted; @@ -80,15 +105,6 @@ class ManageShippingRates extends BaseManageShippingRates ); } - protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void - { - if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') { - return; - } - - parent::saveShippingRate($shippingRate, $data); - } - protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string { if (blank($method)) { diff --git a/src/Shipping/Listeners/FlushLivePricingCache.php b/src/Shipping/Listeners/FlushLivePricingCache.php new file mode 100644 index 0000000..b772b07 --- /dev/null +++ b/src/Shipping/Listeners/FlushLivePricingCache.php @@ -0,0 +1,61 @@ +cart; + + foreach ($this->livePricingRateIds() as $rateId) { + Cache::forget("shipping.live_price.{$rateId}.{$cart->id}"); + } + } + + /** + * @return array + */ + private function livePricingRateIds(): array + { + return ShippingRate::query() + ->whereHas('shippingMethod', fn ($query) => $query->whereIn( + 'driver', + $this->liveDriverKeys(), + )) + ->pluck('id') + ->all(); + } + + /** + * @return array + */ + private function liveDriverKeys(): array + { + return Shipping::getSupportedDrivers() + ->filter(fn ($driver) => $driver instanceof SupportsLivePricing) + ->keys() + ->all(); + } +} From be0c037c6260a4680c45f9246b8f972fce6415e6 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sat, 29 Aug 2026 13:41:17 +0300 Subject: [PATCH 043/110] Bump version to 0.9.0 --- CHANGELOG.md | 15 +++++++++++++++ composer.json | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e74978..9fa4959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.9.0] - 2026-08-29 + +### Added +- `Modules\Core\Cart\Services\CartService` — the boboko-owned API for all cart mutation, wrapping Lunar's `CartSession`/`Cart` primitives: `addLine()`, `updateLine()`, `removeLine()`, `clear()`, `applyCoupon()`/`removeCoupon()` (throws `InvalidCouponException` on an invalid code), and save-for-later (`saveForLater()`/`moveToCart()`/`activeLines()`/`savedLines()`, backed by a `meta.saved_for_later` flag and a new `Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice` cart-line pipeline step that zeroes a saved line's price so it's excluded from cart totals without being removed). Dispatches 8 real domain events (`CartLineAdded`/`Updated`/`Removed`/`Saved`/`MovedToCart`, `CartCleared`, `CartCouponApplied`/`Removed`) — none have a listener yet, built so a future concern (analytics, recovery) has something to attach to. Documented in `docs/cart.md`. +- `Modules\Core\Checkout\Services\CheckoutService` — the boboko-owned API for the checkout stage (address → shipping selection → order placement), sitting between `CartService` and `Order`: `setShippingAddress()`/`setBillingAddress()`, `getShippingOptions()`/`selectShippingOption()` (throws the new `InvalidShippingOptionException` on an identifier that doesn't resolve — previously a silent no-op), and `placeOrder(string $fingerprint)` (the fingerprint is mandatory, not optional — forces re-confirmation via Lunar's own `FingerprintMismatchException` if the cart changed since the shopper last saw its total). Dispatches `ShippingAddressSet`/`BillingAddressSet`/`ShippingOptionSelected`/`OrderPlaced`, each carrying richer, already-resolved payload (e.g. the resolved `ShippingOption`, not just its identifier) than `CartService`'s events. No exception wrapping otherwise — Lunar's own `CartException`/`FingerprintMismatchException` are already the right shape for a storefront to render as form errors. Documented in `docs/checkout.md`. +- `Modules\Core\Cart\Filament\Resources\CartResource`'s list view now classifies every cart into one of four states — **Ongoing**, **Abandoned Cart**, **Abandoned Checkout**, **Completed** — instead of the previous two-tab Abandoned/Completed split, distinguishing a cart that never reached checkout from one that has a started-but-unplaced order (mirrors the real distinction in Lunar's own `Cart::scopeActive()`). Abandonment threshold is a fixed, configurable cutoff (`config('core.cart.abandoned_after')`, default 1 hour). Added a customer hyperlink (list column + a "View Customer" header action on the view page, both pointing straight at `customers/{id}` via the plain `customer_id` column, no extra query via the `customer` relation). +- `Modules\Core\Cart\Commands\DetectAbandonedCarts` (`boboko:cart:detect-abandoned`, scheduled hourly) dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned` for carts/checkouts past the abandonment cutoff — detection only, no persistence; a real tracking table is left for when `Recovery` is built as its own concern. Fixed a self-defeating bug from an earlier draft: marking a cart as notified by writing to it bumped `updated_at`, which immediately un-staled it for the next run's own cutoff check. +- Merged the `Shipping-Carriers` branch: live carrier rate quoting and fulfillment for **ACS Courier** and **Box Now** (`Modules\Core\Shipping\Carriers\{Acs,BoxNow}`) on top of `lunarphp/table-rate-shipping` — `AcsRateDriver`/`BoxNowRateDriver` (live + static price-break resolution), `AcsFulfillmentService`/`BoxNowFulfillmentService` (shipment creation, label printing, cancellation via the new `Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface`, resolved per-carrier via contextual container binding), `Modules\Core\Shipping\Models\Shipment`/`ShipmentInfo`, `PollShipmentTrackingJob` (scheduled every 30 minutes), `ManagePickupManifests` (Filament page for carrier manifest batching), and an `OrderViewExtension` adding a "Create Shipment" header action to Lunar's order view. Carrier credentials are published config (`config/shippingCarriers/{acs,boxnow}.php`), never committed. +- `Modules\Core\Shipping\Concerns\CachesLivePricing` caches a live-priced carrier quote per `(rate, cart)` for 30 minutes — a real, billed API call that's otherwise re-run on every `getShippingOptions()`/`selectShippingOption()` call within the same checkout attempt. `Modules\Core\Shipping\Listeners\FlushLivePricingCache` invalidates it on the only two things that can change a quote: a cart line changing or the shipping address changing (deliberately **not** on order placement — the price the shopper was quoted must still be readable afterwards). Scoped generically to any `SupportsLivePricing` driver, not hardcoded to ACS. +- `AcsRateDriver::resolveLivePrice()` now falls back to the rate's own configured static price if the live ACS API call fails (previously: the shipping option silently disappeared from the list on any API error, including a brief outage). `ManageShippingRates` (our Filament subclass of the vendor rates page) now allows a static price to be configured and saved on a "live" rate specifically for this fallback — previously those fields were hidden and discarded on save for any live-priced rate. + +### Fixed +- Fixed a crash (`Attempt to read property "price" on null`) opening/editing a live-priced shipping rate with no fallback price configured yet — the vendor `ManageShippingRates` page's `afterStateHydrated` callback for the price field had no null-guard for a rate with zero `basePrices`, which is now the routine case for an unconfigured live rate. +- Fixed the Filament admin panel's home URL (`/boboko/home`) incorrectly resolving to the Shipping module's `ManagePickupManifests` page instead of the Dashboard — Filament falls back to the first item of the first registered navigation group when no explicit `homeUrl()` is set, and `ManagePickupManifests` had no `navigationGroup`/`navigationSort` of its own. Fixed via explicit `navigationGroup = 'Sales'` / `navigationSort = 100`, placing it after Sales in the nav instead of first overall. + ## [0.8.0] - 2026-08-27 ### Added diff --git a/composer.json b/composer.json index 7955955..03126cd 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.8.0", + "version": "0.9.0", "autoload": { "psr-4": { "Modules\\Core\\": "src/" From 661e8b9a96ee5430bdec4fd6a613fac64982492d Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Mon, 31 Aug 2026 12:50:29 +0300 Subject: [PATCH 044/110] Feat: Adding Payment Drivers, configuring stripe --- composer.json | 3 +- src/Checkout/Contracts/PaymentDriver.php | 41 +++++++ src/Payment/Drivers/StripePaymentDriver.php | 100 ++++++++++++++++++ .../PaymentNotConfirmedException.php | 22 ++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 src/Checkout/Contracts/PaymentDriver.php create mode 100644 src/Payment/Drivers/StripePaymentDriver.php create mode 100644 src/Payment/Exceptions/PaymentNotConfirmedException.php diff --git a/composer.json b/composer.json index 2e2963d..0e69457 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "lunarphp/table-rate-shipping": "^1.3", "lunarphp/search": "*", "lunarphp/meilisearch": "*", - "spatie/laravel-translation-loader": "^2.8" + "spatie/laravel-translation-loader": "^2.8", + "lunarphp/stripe": "1.3.0" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/src/Checkout/Contracts/PaymentDriver.php b/src/Checkout/Contracts/PaymentDriver.php new file mode 100644 index 0000000..f8c56ac --- /dev/null +++ b/src/Checkout/Contracts/PaymentDriver.php @@ -0,0 +1,41 @@ + $id], a redirect-based + * provider: its callback payload) — passed explicitly by the caller + * (a controller, a webhook job) rather than a driver reaching into the + * global request(), so confirm() works the same whether it's called + * from a synchronous HTTP request or an async webhook/job with no + * active request at all. + * + * @param array $data + * + * @throws \Lunar\Exceptions\FingerprintMismatchException + * @throws \Lunar\Exceptions\Carts\CartException + */ + public function confirm(Cart $cart, string $fingerprint, array $data): Order; +} diff --git a/src/Payment/Drivers/StripePaymentDriver.php b/src/Payment/Drivers/StripePaymentDriver.php new file mode 100644 index 0000000..d705513 --- /dev/null +++ b/src/Payment/Drivers/StripePaymentDriver.php @@ -0,0 +1,100 @@ +first(); + + if ($paymentIntentModel && ! $paymentIntentModel->isActive()) { + throw new PaymentNotConfirmedException('Payment intent already processed.'); + } + + if (! $paymentIntentModel) { + $paymentIntentModel = StripePaymentIntent::create([ + 'intent_id' => $paymentIntentId, + 'cart_id' => $cart->id, + ]); + } + + $paymentIntentModel->update(['processing_at' => now()]); + + $stripe = Stripe::getClient(); + $paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId); + + if (! $paymentIntent) { + throw new PaymentNotConfirmedException('Unable to locate payment intent.'); + } + + $policy = config('lunar.stripe.policy', 'automatic'); + + if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && $policy === 'automatic') { + $paymentIntent = $stripe->paymentIntents->capture($paymentIntentId); + } + + if ($paymentIntent->status !== PaymentIntent::STATUS_SUCCEEDED) { + $paymentIntentModel->update(['status' => $paymentIntent->status]); + + throw new PaymentNotConfirmedException( + $paymentIntent->last_payment_error->message ?? "Payment intent status: {$paymentIntent->status}." + ); + } + + try { + $order = $this->checkout->placeOrder($fingerprint); + } catch (DisallowMultipleCartOrdersException|CartException $e) { + throw new PaymentNotConfirmedException($e->getMessage(), previous: $e); + } + + $paymentIntentModel->order_id = $order->id; + $paymentIntentModel->status = $paymentIntent->status; + $paymentIntentModel->processed_at = now(); + $paymentIntentModel->save(); + + UpdateOrderFromIntent::execute($order, $paymentIntent); + + return $order->refresh(); + } +} diff --git a/src/Payment/Exceptions/PaymentNotConfirmedException.php b/src/Payment/Exceptions/PaymentNotConfirmedException.php new file mode 100644 index 0000000..a5cef0d --- /dev/null +++ b/src/Payment/Exceptions/PaymentNotConfirmedException.php @@ -0,0 +1,22 @@ + Date: Mon, 31 Aug 2026 13:16:13 +0300 Subject: [PATCH 045/110] Feat: Upgrading Lunar to 1.5 --- composer.json | 9 ++-- .../Extensions/StaffResourceExtension.php | 6 +-- src/Auth/Filament/Pages/Login.php | 4 +- .../Exceptions/InvalidCouponException.php | 4 +- src/Cart/Filament/Resources/CartResource.php | 30 +++++++----- .../CartResource/Pages/ListCarts.php | 2 +- .../Resources/CartResource/Pages/ViewCart.php | 10 ++-- .../Contracts/ProductOptionTypeInterface.php | 2 +- .../ProductOptionResourceExtension.php | 10 ++-- .../ValuesRelationManagerExtension.php | 10 ++-- src/Checkout/Contracts/PaymentDriver.php | 6 ++- src/Checkout/Services/CheckoutService.php | 6 ++- src/Command/CreateAdminCommand.php | 3 +- src/Command/ExportCommand.php | 9 ++-- src/CorePlugin.php | 8 ++-- .../AddressRelationManager.php | 14 +++--- .../RelationManagers/UserRelationManager.php | 12 +++-- .../Resources/LanguageLineResource.php | 47 +++++++++++-------- .../Pages/EditLanguageLine.php | 3 +- .../Pages/ListLanguageLines.php | 3 +- src/MigrateImport/ImporterFactory.php | 3 +- .../JudgeMe/JudgeMeCsvReader.php | 4 +- .../JudgeMe/JudgeMeExportImporter.php | 3 +- .../Shopify/ShopifyCsvReader.php | 4 +- .../Shopify/ShopifyExportImporter.php | 6 ++- src/Notification/NotificationRegistry.php | 3 +- src/Option/LazyOption.php | 9 ++-- src/Option/None.php | 6 ++- src/Option/Option.php | 3 +- src/Option/Some.php | 5 +- src/Payment/Drivers/StripePaymentDriver.php | 3 +- src/ResultType/Error.php | 19 ++++---- src/ResultType/Result.php | 14 +++--- src/ResultType/Success.php | 17 +++---- .../Filament/Pages/ManageProductReviews.php | 24 +++++----- .../Carriers/Acs/AcsFulfillmentService.php | 3 +- src/Shipping/Concerns/CachesLivePricing.php | 3 +- .../Extensions/OrderViewExtension.php | 23 +++++---- .../ShippingMethodListExtension.php | 7 +-- .../ShippingMethodResourceExtension.php | 19 ++++---- .../Filament/Pages/ManagePickupManifests.php | 14 +++--- .../Filament/Pages/ManageShippingRates.php | 12 ++--- 42 files changed, 229 insertions(+), 173 deletions(-) diff --git a/composer.json b/composer.json index 0e69457..1afb163 100644 --- a/composer.json +++ b/composer.json @@ -10,15 +10,15 @@ }, "require": { "php": "^8.5", - "lunarphp/lunar": "1.3.0", + "lunarphp/lunar": "1.5.0", "laravel/framework": "^12.0", "laravel/tinker": "^3.0", "symfony/yaml": "^7.0", - "lunarphp/table-rate-shipping": "^1.3", + "lunarphp/table-rate-shipping": "1.5.0", "lunarphp/search": "*", "lunarphp/meilisearch": "*", "spatie/laravel-translation-loader": "^2.8", - "lunarphp/stripe": "1.3.0" + "lunarphp/stripe": "^1.5" }, "require-dev": { "fakerphp/faker": "^1.23", @@ -28,7 +28,8 @@ "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", "pestphp/pest": "^4.6", - "pestphp/pest-plugin-laravel": "^4.1" + "pestphp/pest-plugin-laravel": "^4.1", + "filament/upgrade": "^4.0" }, "extra": { "laravel": { diff --git a/src/Auth/Extensions/StaffResourceExtension.php b/src/Auth/Extensions/StaffResourceExtension.php index 02d04db..a4d604d 100644 --- a/src/Auth/Extensions/StaffResourceExtension.php +++ b/src/Auth/Extensions/StaffResourceExtension.php @@ -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); } } diff --git a/src/Auth/Filament/Pages/Login.php b/src/Auth/Filament/Pages/Login.php index 883aa4b..d431ffd 100644 --- a/src/Auth/Filament/Pages/Login.php +++ b/src/Auth/Filament/Pages/Login.php @@ -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.', ]); diff --git a/src/Cart/Exceptions/InvalidCouponException.php b/src/Cart/Exceptions/InvalidCouponException.php index f024053..c009444 100644 --- a/src/Cart/Exceptions/InvalidCouponException.php +++ b/src/Cart/Exceptions/InvalidCouponException.php @@ -12,8 +12,8 @@ use RuntimeException; */ class InvalidCouponException extends RuntimeException { - public function __construct(public readonly string $code) + public function __construct(public readonly string $couponCode) { - parent::__construct("The coupon code \"{$code}\" is not valid."); + parent::__construct("The coupon code \"{$couponCode}\" is not valid."); } } diff --git a/src/Cart/Filament/Resources/CartResource.php b/src/Cart/Filament/Resources/CartResource.php index e1c2e0f..bba5a5f 100644 --- a/src/Cart/Filament/Resources/CartResource.php +++ b/src/Cart/Filament/Resources/CartResource.php @@ -2,6 +2,10 @@ 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; @@ -24,9 +28,9 @@ class CartResource extends Resource { protected static ?string $model = Cart::class; - protected static ?string $navigationIcon = 'heroicon-o-shopping-cart'; + protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shopping-cart'; - protected static ?string $navigationGroup = 'Sales'; + protected static string | \UnitEnum | null $navigationGroup = 'Sales'; protected static ?string $modelLabel = 'Cart'; @@ -70,37 +74,37 @@ class CartResource extends Resource { return $table ->columns([ - Tables\Columns\TextColumn::make('id') + TextColumn::make('id') ->label('Cart') ->sortable(), - Tables\Columns\TextColumn::make('customer.full_name') + TextColumn::make('customer.full_name') ->label('Customer') ->placeholder('—') ->searchable() ->url(fn (Cart $record) => $record->customer_id !== null ? CustomerResource::getUrl('view', ['record' => $record->customer_id]) : null), - Tables\Columns\TextColumn::make('user.email') + TextColumn::make('user.email') ->label('User') ->placeholder('—') ->searchable(), - Tables\Columns\TextColumn::make('lines_count') + TextColumn::make('lines_count') ->label('Lines') ->counts('lines') ->sortable(), - Tables\Columns\TextColumn::make('lines_sum_quantity') + TextColumn::make('lines_sum_quantity') ->label('Items') ->sum('lines', 'quantity') ->sortable(), - Tables\Columns\TextColumn::make('currency.code') + TextColumn::make('currency.code') ->label('Currency'), - Tables\Columns\TextColumn::make('updated_at') + TextColumn::make('updated_at') ->label('Last activity') ->dateTime() ->sortable(), ]) - ->actions([ - Tables\Actions\ViewAction::make(), + ->recordActions([ + ViewAction::make(), ]) ->defaultSort('updated_at', 'desc'); } @@ -108,8 +112,8 @@ class CartResource extends Resource public static function getPages(): array { return [ - 'index' => Pages\ListCarts::route('/'), - 'view' => Pages\ViewCart::route('/{record}'), + 'index' => ListCarts::route('/'), + 'view' => ViewCart::route('/{record}'), ]; } diff --git a/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php b/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php index a1e5b4d..488c7c1 100644 --- a/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php +++ b/src/Cart/Filament/Resources/CartResource/Pages/ListCarts.php @@ -2,7 +2,7 @@ namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages; -use Filament\Resources\Components\Tab; +use Filament\Schemas\Components\Tabs\Tab; use Filament\Resources\Pages\ListRecords; use Illuminate\Database\Eloquent\Builder; use Modules\Core\Cart\Filament\Resources\CartResource; diff --git a/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php b/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php index 7ea4460..79c5124 100644 --- a/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php +++ b/src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php @@ -2,11 +2,11 @@ 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\RepeatableEntry; -use Filament\Infolists\Components\Section; use Filament\Infolists\Components\TextEntry; -use Filament\Infolists\Infolist; use Filament\Resources\Pages\ViewRecord; use Lunar\Admin\Filament\Resources\CustomerResource; use Lunar\Models\Cart; @@ -44,10 +44,10 @@ class ViewCart extends ViewRecord return $cart->calculate(); } - public function infolist(Infolist $infolist): Infolist + public function infolist(Schema $schema): Schema { - return $infolist - ->schema([ + return $schema + ->components([ Section::make('Cart') ->columns(3) ->schema([ diff --git a/src/Catalog/Contracts/ProductOptionTypeInterface.php b/src/Catalog/Contracts/ProductOptionTypeInterface.php index 49fb205..09f6688 100644 --- a/src/Catalog/Contracts/ProductOptionTypeInterface.php +++ b/src/Catalog/Contracts/ProductOptionTypeInterface.php @@ -2,7 +2,7 @@ namespace Modules\Core\Catalog\Contracts; -use Filament\Forms\Components\Component; +use Filament\Schemas\Components\Component; /** * A Product Option Type describes how a category of Lunar `ProductOption` (e.g. diff --git a/src/Catalog/Filament/Extensions/ProductOptionResourceExtension.php b/src/Catalog/Filament/Extensions/ProductOptionResourceExtension.php index b816ce6..46e0913 100644 --- a/src/Catalog/Filament/Extensions/ProductOptionResourceExtension.php +++ b/src/Catalog/Filament/Extensions/ProductOptionResourceExtension.php @@ -2,8 +2,8 @@ namespace Modules\Core\Catalog\Filament\Extensions; +use Filament\Schemas\Schema; use Filament\Forms\Components\Select; -use Filament\Forms\Form; use Illuminate\Support\Str; use Lunar\Admin\Support\Extending\ResourceExtension; use Modules\Core\Catalog\Services\ProductOptionTypeManager; @@ -16,7 +16,7 @@ use Modules\Core\Catalog\Services\ProductOptionTypeManager; */ class ProductOptionResourceExtension extends ResourceExtension { - public function extendForm(Form $form): Form + public function extendForm(Schema $schema): Schema { $options = collect(ProductOptionTypeManager::get()->all()) ->keys() @@ -24,11 +24,11 @@ class ProductOptionResourceExtension extends ResourceExtension ->all(); if ($options === []) { - return $form; + return $schema; } - return $form->schema([ - ...$form->getComponents(), + return $schema->components([ + ...$schema->getComponents(), Select::make('meta.option_type') ->label('Option Type') ->options($options) diff --git a/src/Catalog/Filament/Extensions/ValuesRelationManagerExtension.php b/src/Catalog/Filament/Extensions/ValuesRelationManagerExtension.php index 1429d89..827fa9c 100644 --- a/src/Catalog/Filament/Extensions/ValuesRelationManagerExtension.php +++ b/src/Catalog/Filament/Extensions/ValuesRelationManagerExtension.php @@ -2,7 +2,7 @@ namespace Modules\Core\Catalog\Filament\Extensions; -use Filament\Forms\Form; +use Filament\Schemas\Schema; use Lunar\Admin\Support\Extending\RelationManagerExtension; use Lunar\Models\ProductOption; use Modules\Core\Catalog\Services\ProductOptionTypeManager; @@ -15,7 +15,7 @@ use Modules\Core\Catalog\Services\ProductOptionTypeManager; */ class ValuesRelationManagerExtension extends RelationManagerExtension { - public function extendForm(Form $form): Form + public function extendForm(Schema $schema): Schema { /** @var ProductOption $option */ $option = $this->caller->getOwnerRecord(); @@ -23,11 +23,11 @@ class ValuesRelationManagerExtension extends RelationManagerExtension $type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null); if ($type === null) { - return $form; + return $schema; } - return $form->schema([ - ...$form->getComponents(), + return $schema->components([ + ...$schema->getComponents(), ...$type->getMetaForm(), ]); } diff --git a/src/Checkout/Contracts/PaymentDriver.php b/src/Checkout/Contracts/PaymentDriver.php index f8c56ac..4f458c0 100644 --- a/src/Checkout/Contracts/PaymentDriver.php +++ b/src/Checkout/Contracts/PaymentDriver.php @@ -2,6 +2,8 @@ namespace Modules\Core\Checkout\Contracts; +use Lunar\Exceptions\FingerprintMismatchException; +use Lunar\Exceptions\Carts\CartException; use Lunar\Models\Cart; use Lunar\Models\Order; @@ -34,8 +36,8 @@ interface PaymentDriver * * @param array $data * - * @throws \Lunar\Exceptions\FingerprintMismatchException - * @throws \Lunar\Exceptions\Carts\CartException + * @throws FingerprintMismatchException + * @throws CartException */ public function confirm(Cart $cart, string $fingerprint, array $data): Order; } diff --git a/src/Checkout/Services/CheckoutService.php b/src/Checkout/Services/CheckoutService.php index 0cd79b5..46c258a 100644 --- a/src/Checkout/Services/CheckoutService.php +++ b/src/Checkout/Services/CheckoutService.php @@ -2,6 +2,8 @@ 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; @@ -107,8 +109,8 @@ class CheckoutService * render as form errors directly. FingerprintMismatchException * propagates the same way, for the same reason. * - * @throws \Lunar\Exceptions\FingerprintMismatchException - * @throws \Lunar\Exceptions\Carts\CartException + * @throws FingerprintMismatchException + * @throws CartException */ public function placeOrder(string $fingerprint): Order { diff --git a/src/Command/CreateAdminCommand.php b/src/Command/CreateAdminCommand.php index a728930..a281c42 100644 --- a/src/Command/CreateAdminCommand.php +++ b/src/Command/CreateAdminCommand.php @@ -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, }, ), diff --git a/src/Command/ExportCommand.php b/src/Command/ExportCommand.php index 5a01855..4d6725a 100644 --- a/src/Command/ExportCommand.php +++ b/src/Command/ExportCommand.php @@ -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) { diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 53c60ba..5d19cd4 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -2,6 +2,7 @@ namespace Modules\Core; +use Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder; use Filament\Contracts\Plugin; use Filament\Panel; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -59,7 +60,7 @@ class CorePlugin implements Plugin ValuesRelationManager::class => ValuesRelationManagerExtension::class, ShippingMethodResource::class => ShippingMethodResourceExtension::class, ListShippingMethod::class => ShippingMethodListExtension::class, - OrderResource\Pages\ManageOrder::class => OrderViewExtension::class, + ManageOrder::class => OrderViewExtension::class, ]); Product::macro('reviews', function (): HasMany { @@ -73,9 +74,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) { diff --git a/src/Customer/RelationManagers/AddressRelationManager.php b/src/Customer/RelationManagers/AddressRelationManager.php index b430797..2041db4 100644 --- a/src/Customer/RelationManagers/AddressRelationManager.php +++ b/src/Customer/RelationManagers/AddressRelationManager.php @@ -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'), ]); } diff --git a/src/Customer/RelationManagers/UserRelationManager.php b/src/Customer/RelationManagers/UserRelationManager.php index ff3f862..0bb7fef 100644 --- a/src/Customer/RelationManagers/UserRelationManager.php +++ b/src/Customer/RelationManagers/UserRelationManager.php @@ -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() diff --git a/src/Localization/Filament/Resources/LanguageLineResource.php b/src/Localization/Filament/Resources/LanguageLineResource.php index 3fe9469..8d4b718 100644 --- a/src/Localization/Filament/Resources/LanguageLineResource.php +++ b/src/Localization/Filament/Resources/LanguageLineResource.php @@ -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 + * @return array