Feature: Adding Product Service to Lunar

This commit introduces a Product service to Lunar. This product service calls Meilisearch to fetch an indexed product. The indexer has been updated to also include the collection and the price of the product.

A product search service has also been created to be used by the frontend's search
This commit is contained in:
2026-08-24 12:41:28 +03:00
parent f7da26b487
commit 5cb6c529a0
7 changed files with 387 additions and 8 deletions
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Catalog;
/**
* Filter input for ProductService::list(). All fields are optional — omitted
* filters are simply not added to the Meilisearch query. Values are matched
* against Modules\Core\Search\ProductIndexer's document fields, so filtering
* only works on stores where that indexer is registered and the index has
* been re-synced (see docs/product-listing.md).
*/
class ProductFilters
{
public function __construct(
public readonly ?int $collectionId = null,
public readonly ?string $brand = null,
public readonly ?float $minPrice = null,
public readonly ?float $maxPrice = null,
) {}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Modules\Core\Catalog;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
/**
* Storefront product listing/filtering, reading directly from the Meilisearch index
* (Modules\Core\Search\ProductIndexer) rather than the database — no ->get() model
* hydration, so callers get plain arrays of the indexed document, not Eloquent models.
* Consumers needing the full record (e.g. a product detail page) should look the
* product up directly via Lunar's Product model instead.
*
* Full-text query search lives separately in Modules\Core\Search\ProductSearchService;
* this service is for browsing/filtering without a search term.
*/
class ProductService
{
/**
* @return array{data: array<int, array>, meta: array}
*/
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1): array
{
$paginator = Product::search('')
->options([
'filter' => $this->buildFilter($filters),
])
->paginateRaw(perPage: $perPage, page: $page);
// 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.
$rawResponse = $paginator->items();
return [
'data' => collect($rawResponse['hits'] ?? [])->values()->all(),
'meta' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
],
];
}
private function buildFilter(?ProductFilters $filters): ?string
{
if ($filters === null) {
return null;
}
$clauses = Collection::make([
$filters->collectionId !== null ? "collections = \"{$filters->collectionId}\"" : null,
$filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
])->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
}
}