generated from boboko/starter
Merge branch 'elv' into cart-temp
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -4,36 +4,51 @@
|
|||||||
|
|
||||||
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
|
||||||
* @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).
|
||||||
|
$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,
|
filters: $filters,
|
||||||
perPage: self::PER_PAGE,
|
perPage: self::PER_PAGE,
|
||||||
page: $listing->page,
|
page: $listing->page,
|
||||||
@@ -46,8 +61,7 @@ public function build(ProductListing $listing, Closure $url, ?int $collectionId
|
|||||||
|
|
||||||
// Slider bounds — the price span of everything matching the *other*
|
// Slider bounds — the price span of everything matching the *other*
|
||||||
// filters, rounded to whole euros, plus whether the current price params
|
// filters, rounded to whole euros, plus whether the current price params
|
||||||
// actually narrow that span. All computed in core now
|
// actually narrow that span. All computed in core
|
||||||
// (ProductService::priceSliderBounds()).
|
|
||||||
$priceBounds = $result->priceBounds;
|
$priceBounds = $result->priceBounds;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -2,93 +2,30 @@
|
|||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
$query = trim((string) request()->query('q', ''));
|
$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 === '') {
|
if ($query === '') {
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+9
-9
@@ -515,11 +515,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "boboko/core",
|
"name": "boboko/core",
|
||||||
"version": "0.13.1",
|
"version": "0.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://code.radical-elements.com/boboko/core.git",
|
"url": "https://code.radical-elements.com/boboko/core.git",
|
||||||
"reference": "e9aa08a3383f9dfa48564fd9b40d2193d5a1d2f3"
|
"reference": "4ff9bdacc3394bb02537998a2d43adbc2799768a"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
@@ -567,7 +567,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Core module — authentication and shared panel behaviour",
|
"description": "Core module — authentication and shared panel behaviour",
|
||||||
"time": "2026-09-03T15:28:22+00:00"
|
"time": "2026-09-04T10:06:26+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -2281,16 +2281,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "google/protobuf",
|
"name": "google/protobuf",
|
||||||
"version": "v5.36.0",
|
"version": "v5.36.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/protocolbuffers/protobuf-php.git",
|
"url": "https://github.com/protocolbuffers/protobuf-php.git",
|
||||||
"reference": "9c105104b54709ecd902494ab340ed2122789b2d"
|
"reference": "d64d16befba8632967f604b9644c0bb8f64cfbc3"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/9c105104b54709ecd902494ab340ed2122789b2d",
|
"url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/d64d16befba8632967f604b9644c0bb8f64cfbc3",
|
||||||
"reference": "9c105104b54709ecd902494ab340ed2122789b2d",
|
"reference": "d64d16befba8632967f604b9644c0bb8f64cfbc3",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2319,9 +2319,9 @@
|
|||||||
"proto"
|
"proto"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.36.0"
|
"source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.36.1"
|
||||||
},
|
},
|
||||||
"time": "2026-08-20T13:06:50+00:00"
|
"time": "2026-08-31T22:07:31+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "graham-campbell/result-type",
|
"name": "graham-campbell/result-type",
|
||||||
|
|||||||
Reference in New Issue
Block a user