generated from boboko/starter
general products page and small rewrite of search and category controllers etc
This commit is contained in:
@@ -2,20 +2,39 @@
|
||||
|
||||
namespace App\Catalog;
|
||||
|
||||
use Lunar\Models\Product;
|
||||
|
||||
/**
|
||||
* Presentation shaping — how a product listing/grid card is built from
|
||||
* ProductService's localized array shape (list()/getById()/random() all
|
||||
* return it). Deliberately not in boboko-core: `href` depends on this
|
||||
* storefront's own routes, and another app built on the same core package
|
||||
* could want an entirely different card shape. Kept in one place so
|
||||
* HomeController/CategoryController don't each hand-write the same
|
||||
* name/price/image/href mapping.
|
||||
* Presentation shaping — how a storefront product listing/grid card is built:
|
||||
* name, price, image, href. Deliberately not in boboko-core: `href` depends on
|
||||
* this storefront's own routes, and another app on the same core package could
|
||||
* want an entirely different card shape. One place, so HomeController /
|
||||
* 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`.
|
||||
*/
|
||||
final class ProductCard
|
||||
{
|
||||
/**
|
||||
* @param array $product One item from ProductService's localized array shape.
|
||||
* @return array{name: ?string, price: ?float, image: ?string, href: string}
|
||||
* @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
|
||||
* @return array{name: ?string, price: ?string, image: ?string, href: string}
|
||||
*/
|
||||
public static function fromIndexed(array $product): array
|
||||
{
|
||||
|
||||
@@ -7,15 +7,16 @@
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
|
||||
/**
|
||||
* The parsed state of a category listing request. The query string is the single
|
||||
* source of truth for sort / filters / page — build one of these from the
|
||||
* request, read the applied values off it, and use query() to build links (sort
|
||||
* options, pagination, "clear filter") that carry the rest of the state along.
|
||||
* The parsed filter/sort/page state of a product-listing request — used by the
|
||||
* category page (scoped to a collection) and the all-products page. The query
|
||||
* string is the single source of truth; build one of these from the request,
|
||||
* read the applied values off it, and use query() to build links (sort options,
|
||||
* pagination, "clear filter") that carry the rest of the state along.
|
||||
*
|
||||
* A param is only ever emitted when it differs from its default, so a pristine
|
||||
* listing is just `/category/{id}` with no query string.
|
||||
* listing has no query string at all.
|
||||
*/
|
||||
final class CategoryListing
|
||||
final class ProductListing
|
||||
{
|
||||
private function __construct(
|
||||
public readonly ?ProductSort $sort,
|
||||
@@ -36,7 +37,10 @@ public static function fromRequest(Request $request): self
|
||||
);
|
||||
}
|
||||
|
||||
public function filters(int $collectionId): ProductFilters
|
||||
/**
|
||||
* @param ?int $collectionId scope to a collection (category page); null = every product
|
||||
*/
|
||||
public function filters(?int $collectionId = null): ProductFilters
|
||||
{
|
||||
return new ProductFilters(
|
||||
collectionId: $collectionId,
|
||||
@@ -48,8 +52,7 @@ public function filters(int $collectionId): ProductFilters
|
||||
|
||||
/**
|
||||
* The applied params as a clean array (defaults omitted), with `$overrides`
|
||||
* merged on top — pass `['key' => null]` to drop one. Feeds straight into
|
||||
* route('category.show', ['id' => $id] + $listing->query([...])).
|
||||
* merged on top — pass `['key' => null]` to drop one.
|
||||
*
|
||||
* @param array<string, string|int|null> $overrides
|
||||
* @return array<string, string|int>
|
||||
@@ -68,7 +71,7 @@ public function query(array $overrides = []): array
|
||||
|
||||
/**
|
||||
* Whether the listing is reordered/narrowed enough that it shouldn't be
|
||||
* indexed as its own page (the canonical still points at the bare category
|
||||
* indexed as its own page (the canonical still points at the bare listing
|
||||
* URL either way). A plain in-stock toggle is left indexable.
|
||||
*/
|
||||
public function isRefined(): bool
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Catalog;
|
||||
|
||||
use Closure;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
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.
|
||||
*/
|
||||
final class ProductListingPage
|
||||
{
|
||||
private const PER_PAGE = 12;
|
||||
|
||||
public function __construct(private readonly ProductService $products) {}
|
||||
|
||||
/**
|
||||
* @param Closure(array<string, string|int>): string $url
|
||||
* @param ?int $collectionId scope to a collection, or null for every product
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(ProductListing $listing, Closure $url, ?int $collectionId = 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,
|
||||
);
|
||||
|
||||
$products = $result->products
|
||||
->through(ProductCard::fromIndexed(...))
|
||||
->appends($listing->query(['page' => null]));
|
||||
|
||||
// Slider bounds — the price span of everything matching the *other*
|
||||
// filters, rounded to whole euros, plus whether the current price params
|
||||
// actually narrow that span. All computed in core now
|
||||
// (ProductService::priceSliderBounds()).
|
||||
$priceBounds = $result->priceBounds;
|
||||
|
||||
return [
|
||||
'listing' => $listing,
|
||||
'products' => $products,
|
||||
'listingAction' => $url([]),
|
||||
'priceFloor' => $priceBounds->floor,
|
||||
'priceCeil' => $priceBounds->ceil,
|
||||
'clearPriceUrl' => $priceBounds->filtered
|
||||
? $url($listing->query(['price_min' => null, 'price_max' => null, 'page' => null]))
|
||||
: null,
|
||||
'sortOptions' => ProductSortOptions::build(
|
||||
$listing->sort,
|
||||
fn (?ProductSort $sort) => $url($listing->query(['sort' => $sort?->value, 'page' => null])),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Catalog;
|
||||
|
||||
use Closure;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
|
||||
/**
|
||||
* Builds the option list for the shared <x-shop.sort> dropdown, so the label
|
||||
* map and "the default sort has no URL param" rule live in one place. Each page
|
||||
* supplies a `$url` closure that turns a sort (or null = default/relevance)
|
||||
* into the right href for that page — category vs. search build their URLs
|
||||
* differently.
|
||||
*/
|
||||
final class ProductSortOptions
|
||||
{
|
||||
/**
|
||||
* @param Closure(?ProductSort): string $url
|
||||
* @return array<int, array{label: string, href: string, current: bool}>
|
||||
*/
|
||||
public static function build(?ProductSort $current, Closure $url): array
|
||||
{
|
||||
$sorts = [
|
||||
null => 'storefront.shop.sort_popularity',
|
||||
ProductSort::PriceAsc->value => 'storefront.shop.sort_price_asc',
|
||||
ProductSort::PriceDesc->value => 'storefront.shop.sort_price_desc',
|
||||
ProductSort::Newest->value => 'storefront.shop.sort_newest',
|
||||
];
|
||||
|
||||
return array_map(function (string $key, string $label) use ($current, $url) {
|
||||
$sort = $key === '' ? null : ProductSort::from($key);
|
||||
|
||||
return [
|
||||
'label' => __($label),
|
||||
'href' => $url($sort),
|
||||
'current' => $sort === $current,
|
||||
];
|
||||
}, array_keys($sorts), array_values($sorts));
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user