diff --git a/app/Catalog/ProductCard.php b/app/Catalog/ProductCard.php index b558e5b..d0c00f2 100644 --- a/app/Catalog/ProductCard.php +++ b/app/Catalog/ProductCard.php @@ -2,8 +2,6 @@ namespace App\Catalog; -use Lunar\Models\Product; - /** * Presentation shaping — how a storefront product listing/grid card is built: * name, price, image, href. Deliberately not in boboko-core: `href` depends on @@ -12,26 +10,13 @@ * CategoryController / ProductController / SearchController don't each * hand-write the same name/price/image/href mapping. * - * Two sources, because the storefront reads products both ways: a hydrated - * Eloquent model (full-text search via ProductSearchService), or a localized - * index array (ProductService::list()/getById()/random()). Model callers must - * eager-load `variants.prices` and `media`. + * One source: a localized index array — ProductService::list()/getById()/ + * random(), and now ProductSearchService::search() too, all return the exact + * same document shape (see ProductListingResult), so this is the only mapping + * every storefront listing page needs. */ final class ProductCard { - /** - * @return array{name: ?string, price: ?string, image: ?string, href: string} - */ - public static function fromModel(Product $product): array - { - return [ - 'name' => $product->translateAttribute('name'), //@todo check this - 'price' => $product->variants->first()?->prices->first()?->price->decimal, //@todo check this - 'image' => $product->media->first()?->getUrl(), - 'href' => route('product.show', ['id' => $product->id]), - ]; - } - /** * @param array $product one item from ProductService's localized array shape * @return array{name: ?string, price: ?string, image: ?string, href: string} diff --git a/app/Catalog/ProductListingPage.php b/app/Catalog/ProductListingPage.php index cd08a03..7eb3be1 100644 --- a/app/Catalog/ProductListingPage.php +++ b/app/Catalog/ProductListingPage.php @@ -4,41 +4,60 @@ use Closure; use Modules\Core\Catalog\Enums\ProductSort; +use Modules\Core\Catalog\Services\ProductSearchService; use Modules\Core\Catalog\Services\ProductService; /** * Assembles the data the shared shop listing body (shop/partials/listing.blade.php) * needs — the product page, price-slider bounds, sort links and the "clear price" - * link. Used by both the category page (scoped to a collection) and the - * all-products page; the `$url` closure turns a query-param array into a URL for - * whichever page is calling, so this class never has to know the route. + * link. Used by the category page (scoped to a collection), the all-products + * page, and the search page (scoped to a query — see $query below); the `$url` + * closure turns a query-param array into a URL for whichever page is calling, + * so this class never has to know the route. */ final class ProductListingPage { private const PER_PAGE = 12; - public function __construct(private readonly ProductService $products) {} + public function __construct( + private readonly ProductService $products, + private readonly ProductSearchService $search, + ) {} /** * @param Closure(array): string $url * @param ?int $collectionId scope to a collection, or null for every product + * @param ?string $query scope to a text search — when given, calls + * ProductSearchService::search() instead of ProductService::list(). + * ProductSearchService::search() returns the exact same + * Modules\Core\Catalog\DTOs\ProductListingResult list() does, so + * everything below this point is identical either way. * @return array */ - public function build(ProductListing $listing, Closure $url, ?int $collectionId = null): array + public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array { $filters = $listing->filters($collectionId); - // Listing reads from the Meilisearch index via ProductService, not - // Eloquent. list() returns a ProductListingResult — the product page - // plus the price-slider bounds from one call; the controller no longer - // stitches list() + priceRange() together itself. Sort/filter/page all - // come from $listing (the query string). - $result = $this->products->list( - filters: $filters, - perPage: self::PER_PAGE, - page: $listing->page, - sort: $listing->sort, - ); + // Listing reads from the Meilisearch index via ProductService/ + // ProductSearchService, not Eloquent. Both return a + // ProductListingResult — the product page plus the price-slider + // bounds (and available tags) from one call; the controller no + // longer stitches list()/search() + priceRange() together itself. + // Sort/filter/page all come from $listing (the query string). + $result = $query !== null + ? $this->search->search( + query: $query, + filters: $filters, + sort: $listing->sort, + perPage: self::PER_PAGE, + page: $listing->page, + ) + : $this->products->list( + filters: $filters, + perPage: self::PER_PAGE, + page: $listing->page, + sort: $listing->sort, + ); $products = $result->products ->through(ProductCard::fromIndexed(...)) diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 46fc875..1044520 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -2,20 +2,12 @@ namespace App\Http\Controllers; -use App\Catalog\ProductCard; -use App\Catalog\ProductSortOptions; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; -use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Collection; -use Lunar\Models\Product; -use Modules\Core\Catalog\Enums\ProductSort; -use Modules\Core\Catalog\Services\ProductSearchService; +use App\Catalog\ProductListing; +use App\Catalog\ProductListingPage; class SearchController extends Controller { - private const PER_PAGE = 12; - - public function __construct(private readonly ProductSearchService $search) {} + public function __construct(private readonly ProductListingPage $listingPage) {} public function show(string $locale) { @@ -27,68 +19,15 @@ public function show(string $locale) return redirect()->route('products'); } - $sort = ProductSort::tryFrom((string) request()->query('sort')); - $page = max(1, (int) request()->query('page', 1)); + $listing = ProductListing::fromRequest(request()); - $cards = $this->cardsFor($query, $sort); - - $products = new LengthAwarePaginator( - items: $cards->forPage($page, self::PER_PAGE)->values(), - total: $cards->count(), - perPage: self::PER_PAGE, - currentPage: $page, - options: ['path' => LengthAwarePaginator::resolveCurrentPath()], - ); - $products->appends(array_filter( - ['q' => $query, 'sort' => $sort?->value], - fn ($value) => $value !== null, - )); - - // Sort dropdown links: same query, swapped sort (default sort => no param). - $sortOptions = ProductSortOptions::build( - $sort, - fn (?ProductSort $option) => route('search', array_filter( - ['q' => $query, 'sort' => $option?->value], - fn ($value) => $value !== null, - )), + $data = $this->listingPage->build( + $listing, + fn (array $overrides) => route('search', ['q' => $query] + $overrides), + collectionId: null, + query: $query, ); - return view('search.index', [ - 'query' => $query, - 'products' => $products, - 'sortOptions' => $sortOptions, - ]); + return view('search.index', [...$data, 'query' => $query]); } - - /** - * Full-text matches as product-card arrays. ProductSearchService returns - * hydrated models in relevance order with no sort or pagination, so ordering - * is done here in PHP and the controller paginates the mapped collection. - * - * @return Collection> - */ - private function cardsFor(string $query, ?ProductSort $sort): Collection - { - $results = $this->search->search($query)->load(['variants.prices', 'media']); - - return $this->sortResults($results, $sort) - ->map(ProductCard::fromModel(...)) - ->values(); - } - - /** - * @param EloquentCollection $results - * @return EloquentCollection - */ - private function sortResults(EloquentCollection $results, ?ProductSort $sort): EloquentCollection - { - $price = fn (Product $product) => $product->variants->first()?->prices->first()?->price->value ?? 0; - - return match ($sort) { - ProductSort::PriceAsc => $results->sortBy($price)->values(), - ProductSort::PriceDesc => $results->sortByDesc($price)->values(), - ProductSort::Newest => $results->sortByDesc('created_at')->values(), - default => $results, // Meilisearch relevance order - }; - } -} +} \ No newline at end of file