general products page and small rewrite of search and category controllers etc

This commit is contained in:
elvira
2026-09-03 21:32:40 +03:00
parent b2207a622c
commit 1f0612861b
23 changed files with 564 additions and 108 deletions
+9 -29
View File
@@ -2,16 +2,15 @@
namespace App\Http\Controllers;
use App\Catalog\CategoryListing;
use App\Catalog\ProductCard;
use App\Catalog\ProductListing;
use App\Catalog\ProductListingPage;
use Illuminate\Http\Response;
use Modules\Core\Catalog\Services\CollectionService;
use Modules\Core\Catalog\Services\ProductService;
class CategoryController extends Controller
{
public function __construct(
private readonly ProductService $products,
private readonly ProductListingPage $listingPage,
private readonly CollectionService $collections,
) {}
@@ -20,33 +19,14 @@ public function show(string $locale, int $collection)
$collectionData = $this->collections->getById($collection);
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
$listing = CategoryListing::fromRequest(request());
$filters = $listing->filters($collectionData['id']);
$perPage = 12;
$listing = ProductListing::fromRequest(request());
// One call for both the product page and the price slider's bounds —
// see ProductService::list()'s own docblock for why a controller no
// longer orchestrates list() + priceSliderBounds() itself.
$listingResult = $this->products->list(
filters: $filters,
perPage: $perPage,
page: $listing->page,
sort: $listing->sort,
$data = $this->listingPage->build(
$listing,
fn (array $query) => route('category.show', ['id' => $collectionData['id']] + $query),
$collectionData['id'],
);
$products = $listingResult->products
->through(fn (array $product) => ProductCard::fromIndexed($product))
->appends($listing->query(['page' => null]));
$priceBounds = $listingResult->priceBounds;
return view('category.show', [
'collection' => $collectionData,
'products' => $products,
'listing' => $listing,
'priceFloor' => $priceBounds->floor,
'priceCeil' => $priceBounds->ceil,
'priceFiltered' => $priceBounds->filtered,
]);
return view('category.show', [...$data, 'collection' => $collectionData]);
}
}
+22 -1
View File
@@ -2,12 +2,33 @@
namespace App\Http\Controllers;
use App\Catalog\ProductListing;
use App\Catalog\ProductListingPage;
use Illuminate\Http\Response;
use Modules\Core\Catalog\Services\ProductService;
class ProductController extends Controller
{
public function __construct(private readonly ProductService $products) {}
public function __construct(
private readonly ProductService $products,
private readonly ProductListingPage $listingPage,
) {}
/**
* All products — the category page without a collection scope. Filters,
* sort, pagination and the shared listing body all work identically.
*/
public function index(string $locale)
{
$listing = ProductListing::fromRequest(request());
$data = $this->listingPage->build(
$listing,
fn (array $query) => route('products', $query),
);
return view('products.index', $data);
}
public function show(string $locale, int $id)
{
+94
View File
@@ -0,0 +1,94 @@
<?php
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;
class SearchController extends Controller
{
private const PER_PAGE = 12;
public function __construct(private readonly ProductSearchService $search) {}
public function show(string $locale)
{
$query = trim((string) request()->query('q', ''));
// Nothing to search for — send them to the all-products page rather than
// render an empty results page.
if ($query === '') {
return redirect()->route('products');
}
$sort = ProductSort::tryFrom((string) request()->query('sort'));
$page = max(1, (int) request()->query('page', 1));
$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,
)),
);
return view('search.index', [
'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
};
}
}