Feat: Updating Product Cards, Listing Pages and SearchController

This commit is contained in:
2026-09-04 13:07:57 +03:00
parent 1f0612861b
commit 72b9abf92f
3 changed files with 50 additions and 107 deletions
+4 -19
View File
@@ -2,8 +2,6 @@
namespace App\Catalog; namespace App\Catalog;
use Lunar\Models\Product;
/** /**
* Presentation shaping — how a storefront product listing/grid card is built: * Presentation shaping — how a storefront product listing/grid card is built:
* name, price, image, href. Deliberately not in boboko-core: `href` depends on * name, price, image, href. Deliberately not in boboko-core: `href` depends on
@@ -12,26 +10,13 @@
* CategoryController / ProductController / SearchController don't each * CategoryController / ProductController / SearchController don't each
* hand-write the same name/price/image/href mapping. * hand-write the same name/price/image/href mapping.
* *
* Two sources, because the storefront reads products both ways: a hydrated * One source: a localized index array — ProductService::list()/getById()/
* Eloquent model (full-text search via ProductSearchService), or a localized * random(), and now ProductSearchService::search() too, all return the exact
* index array (ProductService::list()/getById()/random()). Model callers must * same document shape (see ProductListingResult), so this is the only mapping
* eager-load `variants.prices` and `media`. * every storefront listing page needs.
*/ */
final class ProductCard 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<string, mixed> $product one item from ProductService's localized array shape * @param array<string, mixed> $product one item from ProductService's localized array shape
* @return array{name: ?string, price: ?string, image: ?string, href: string} * @return array{name: ?string, price: ?string, image: ?string, href: string}
+35 -16
View File
@@ -4,41 +4,60 @@
use Closure; use Closure;
use Modules\Core\Catalog\Enums\ProductSort; use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Services\ProductSearchService;
use Modules\Core\Catalog\Services\ProductService; use Modules\Core\Catalog\Services\ProductService;
/** /**
* Assembles the data the shared shop listing body (shop/partials/listing.blade.php) * 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" * 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 * link. Used by the category page (scoped to a collection), the all-products
* all-products page; the `$url` closure turns a query-param array into a URL for * page, and the search page (scoped to a query — see $query below); the `$url`
* whichever page is calling, so this class never has to know the route. * 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 final class ProductListingPage
{ {
private const PER_PAGE = 12; 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, string|int>): string $url * @param Closure(array<string, string|int>): string $url
* @param ?int $collectionId scope to a collection, or null for every product * @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<string, mixed> * @return array<string, mixed>
*/ */
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); $filters = $listing->filters($collectionId);
// Listing reads from the Meilisearch index via ProductService, not // Listing reads from the Meilisearch index via ProductService/
// Eloquent. list() returns a ProductListingResult — the product page // ProductSearchService, not Eloquent. Both return a
// plus the price-slider bounds from one call; the controller no longer // ProductListingResult — the product page plus the price-slider
// stitches list() + priceRange() together itself. Sort/filter/page all // bounds (and available tags) from one call; the controller no
// come from $listing (the query string). // longer stitches list()/search() + priceRange() together itself.
$result = $this->products->list( // Sort/filter/page all come from $listing (the query string).
filters: $filters, $result = $query !== null
perPage: self::PER_PAGE, ? $this->search->search(
page: $listing->page, query: $query,
sort: $listing->sort, 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 $products = $result->products
->through(ProductCard::fromIndexed(...)) ->through(ProductCard::fromIndexed(...))
+11 -72
View File
@@ -2,20 +2,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Catalog\ProductCard; use App\Catalog\ProductListing;
use App\Catalog\ProductSortOptions; use App\Catalog\ProductListingPage;
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;
class SearchController extends Controller class SearchController extends Controller
{ {
private const PER_PAGE = 12; public function __construct(private readonly ProductListingPage $listingPage) {}
public function __construct(private readonly ProductSearchService $search) {}
public function show(string $locale) public function show(string $locale)
{ {
@@ -27,68 +19,15 @@ public function show(string $locale)
return redirect()->route('products'); return redirect()->route('products');
} }
$sort = ProductSort::tryFrom((string) request()->query('sort')); $listing = ProductListing::fromRequest(request());
$page = max(1, (int) request()->query('page', 1));
$cards = $this->cardsFor($query, $sort); $data = $this->listingPage->build(
$listing,
$products = new LengthAwarePaginator( fn (array $overrides) => route('search', ['q' => $query] + $overrides),
items: $cards->forPage($page, self::PER_PAGE)->values(), collectionId: null,
total: $cards->count(), query: $query,
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,
)),
); );
return view('search.index', [ return view('search.index', [...$data, 'query' => $query]);
'query' => $query,
'products' => $products,
'sortOptions' => $sortOptions,
]);
} }
}
/**
* 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<int, array<string, mixed>>
*/
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<int, Product> $results
* @return EloquentCollection<int, Product>
*/
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
};
}
}