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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,28 @@ @layer components {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Search overlay (popover) — opacity fade over the header ──── */
|
||||
#search-overlay {
|
||||
/* Fallback height until the `nav-search` controller measures the real
|
||||
header on open; the header has no fixed height below `lg`. */
|
||||
--search-overlay-h: 6.5rem;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
display 0.2s allow-discrete,
|
||||
overlay 0.2s allow-discrete;
|
||||
}
|
||||
|
||||
#search-overlay:popover-open {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
#search-overlay:popover-open {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Shared underline-slide animation ────────────────────────── */
|
||||
.underline-slide {
|
||||
background-image: linear-gradient(currentColor, currentColor);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// Turbo doesn't scroll for <turbo-frame> navigations, so after the frame swaps
|
||||
// its contents (a sort, filter, or pagination link) this brings the top of the
|
||||
// frame back into view — otherwise clicking pagination at the bottom of the
|
||||
// list leaves you stranded down there. The frame's own scroll-margin-top keeps
|
||||
// it clear of the sticky header.
|
||||
//
|
||||
// `turbo:frame-render` fires only on a content swap, not on the initial page
|
||||
// render, and the frame element itself persists across swaps — so the listener
|
||||
// is bound once in connect().
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.addEventListener('turbo:frame-render', this.#toTop)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener('turbo:frame-render', this.#toTop)
|
||||
}
|
||||
|
||||
#toTop = () => {
|
||||
this.element.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import AutoSubmitController from './auto-submit-controller'
|
||||
import BackToTopController from './back-to-top-controller'
|
||||
import CarouselController from './carousel-controller'
|
||||
import DropdownController from './dropdown-controller'
|
||||
import FrameScrollController from './frame-scroll-controller'
|
||||
import NavSearchController from './nav-search-controller'
|
||||
import ProductFormController from './product-form-controller'
|
||||
import ProductGalleryController from './product-gallery-controller'
|
||||
import QuantityController from './quantity-controller'
|
||||
@@ -21,6 +23,8 @@ export function registerControllers(application) {
|
||||
application.register('back-to-top', BackToTopController)
|
||||
application.register('carousel', CarouselController)
|
||||
application.register('dropdown', DropdownController)
|
||||
application.register('frame-scroll', FrameScrollController)
|
||||
application.register('nav-search', NavSearchController)
|
||||
application.register('product-form', ProductFormController)
|
||||
application.register('product-gallery', ProductGalleryController)
|
||||
application.register('quantity', QuantityController)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// The search overlay is a [popover] that must sit exactly over the header. The
|
||||
// header has no fixed height below `lg`, so on open we copy its current height
|
||||
// onto --search-overlay-h and move focus into the field. Escape / click-away
|
||||
// close come from the Popover API; the fade is CSS (#search-overlay).
|
||||
export default class extends Controller {
|
||||
static targets = ['input']
|
||||
|
||||
connect() {
|
||||
this.element.addEventListener('toggle', this.#onToggle)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener('toggle', this.#onToggle)
|
||||
}
|
||||
|
||||
#onToggle = (event) => {
|
||||
if (event.newState !== 'open') return
|
||||
|
||||
const header = this.element.closest('header')
|
||||
if (header) {
|
||||
this.element.style.setProperty('--search-overlay-h', `${header.offsetHeight}px`)
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.inputTarget.focus())
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,13 @@
|
||||
refresh or bookmark reproduces the exact same view. With no JS the
|
||||
frame is just a block and the links do full-page navigations to the
|
||||
same URLs. --}}
|
||||
<turbo-frame id="category-listing" data-turbo-action="advance">
|
||||
@include('category.partials.listing')
|
||||
<turbo-frame
|
||||
id="category-listing"
|
||||
data-turbo-action="advance"
|
||||
data-controller="frame-scroll"
|
||||
class="scroll-mt-28"
|
||||
>
|
||||
@include('shop.partials.listing')
|
||||
</turbo-frame>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
{{-- Products (CSS-only hover dropdown) --}}
|
||||
<div class="group relative h-full flex items-center">
|
||||
<a href="{{ url('/'.app()->getLocale().'/products') }}" class="nav-link uppercase inline-flex items-center gap-1 relative font-display font-extrabold italic text-black no-underline py-8">
|
||||
<a href="{{ route('products') }}" class="nav-link uppercase inline-flex items-center gap-1 relative font-display font-extrabold italic text-black no-underline py-8">
|
||||
<span>{{ __('storefront.nav.products') }}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4 not-italic transition-transform group-hover:rotate-180">
|
||||
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
||||
@@ -47,10 +47,18 @@
|
||||
</a>
|
||||
|
||||
{{-- Search --}}
|
||||
<button type="button" class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity" aria-label="Search">
|
||||
<button
|
||||
type="button"
|
||||
popovertarget="search-overlay"
|
||||
popovertargetaction="toggle"
|
||||
class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
|
||||
aria-label="{{ __('storefront.shop.search_label') }}"
|
||||
>
|
||||
<x-ui.icon name="search" :size="40" />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-search-overlay />
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{{--
|
||||
Full-width search bar that covers the header. A [popover] (top layer, so it
|
||||
sits over the sticky header with no z-index juggling; Escape and click-away
|
||||
close come for free). The header's search button opens it via popovertarget.
|
||||
The `nav-search` controller measures the header on open (→ --search-overlay-h,
|
||||
since the header isn't a fixed height below `lg`) and focuses the field.
|
||||
Fade transition lives in app.css (#search-overlay).
|
||||
--}}
|
||||
<div
|
||||
id="search-overlay"
|
||||
popover
|
||||
data-controller="nav-search"
|
||||
class="fixed w-full inset-x-0 top-0 bottom-auto m-0 h-[var(--search-overlay-h)] border-0 bg-brand p-0"
|
||||
>
|
||||
<form
|
||||
method="get"
|
||||
action="{{ route('search') }}"
|
||||
class="flex h-full items-center gap-6 px-10"
|
||||
>
|
||||
<label for="search-overlay-input" class="sr-only">{{ __('storefront.shop.search_label') }}</label>
|
||||
<input
|
||||
id="search-overlay-input"
|
||||
type="search"
|
||||
name="q"
|
||||
required
|
||||
autocomplete="off"
|
||||
data-nav-search-target="input"
|
||||
placeholder="{{ __('storefront.search.placeholder') }}"
|
||||
class="min-w-0 flex-1 appearance-none border-0 border-b border-black bg-transparent pb-2 placeholder:text-black/60 focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
popovertarget="search-overlay"
|
||||
popovertargetaction="hide"
|
||||
aria-label="{{ __('storefront.nav.close') }}"
|
||||
class="shrink-0 text-black transition-opacity hover:opacity-70"
|
||||
>
|
||||
<x-ui.icon name="close" :size="44" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
@props(['paginator'])
|
||||
|
||||
{{-- "Showing 1–12 of 48 products" — takes any LengthAwarePaginator. Shared by
|
||||
the category listing and the search results page. --}}
|
||||
<p {{ $attributes }}>
|
||||
{{ trans_choice('storefront.shop.showing_results', $paginator->total(), [
|
||||
'first' => $paginator->firstItem() ?? 0,
|
||||
'last' => $paginator->lastItem() ?? 0,
|
||||
'total' => $paginator->total(),
|
||||
]) }}
|
||||
</p>
|
||||
@@ -0,0 +1,24 @@
|
||||
@props([
|
||||
'options', // [['label' => string, 'href' => string, 'current' => bool], ...] — build with App\Catalog\ProductSortOptions
|
||||
'id' => 'sort',
|
||||
])
|
||||
|
||||
{{-- Product sort dropdown. Options are plain links (built per page, so each can
|
||||
carry its own state); following one navigates the enclosing turbo-frame and
|
||||
advances the URL. Shared by the category listing and search results. --}}
|
||||
@php
|
||||
$active = collect($options)->firstWhere('current', true) ?? ($options[0] ?? ['label' => '']);
|
||||
@endphp
|
||||
|
||||
<x-ui.dropdown
|
||||
:id="$id"
|
||||
:label="$active['label']"
|
||||
:ariaLabel="__('storefront.shop.sort_label')"
|
||||
triggerClass="min-w-48"
|
||||
>
|
||||
@foreach ($options as $option)
|
||||
<x-ui.dropdown.item :href="$option['href']" :current="$option['current'] ?? false">
|
||||
{{ $option['label'] }}
|
||||
</x-ui.dropdown.item>
|
||||
@endforeach
|
||||
</x-ui.dropdown>
|
||||
@@ -138,7 +138,7 @@ class="max-w-xs"
|
||||
<div class="border-b border-black" data-controller="carousel">
|
||||
<div class="flex items-center justify-between gap-4 px-8 py-12 border-b border-black">
|
||||
<h2 class="font-display font-extrabold text-h2" data-stoic="pages/home#classics_title">{{ $page->classics_title }}</h2>
|
||||
<x-ui.button size="lg" :href="url('/'.app()->getLocale().'/products')">Όλα τα προϊόντα</x-ui.button>
|
||||
<x-ui.button size="lg" :href="route('products')">Όλα τα προϊόντα</x-ui.button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 px-8 py-12">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<x-breadcrumb class="mb-14 justify-end" :items="[
|
||||
$collection
|
||||
? ['label' => $collection['name'], 'href' => route('category.show', ['id' => $collection['id']])]
|
||||
: ['label' => __('storefront.nav.products'), 'href' => '/products'],
|
||||
: ['label' => __('storefront.nav.products'), 'href' => route('products')],
|
||||
['label' => $product['name']],
|
||||
]" />
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', __('storefront.shop.all_products') . ' — ' . config('app.name'))
|
||||
|
||||
@push('seo')
|
||||
{{-- The bare listing is indexable; filtered / sorted / paged variants
|
||||
consolidate onto it and are kept out of the index. --}}
|
||||
<link rel="canonical" href="{{ route('products') }}">
|
||||
@if($listing->isRefined())
|
||||
<meta name="robots" content="noindex,follow">
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-5xl mx-auto pt-26 pb-12">
|
||||
|
||||
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
|
||||
<h1 class="font-medium text-h2">{{ __('storefront.shop.all_products') }}</h1>
|
||||
|
||||
<x-breadcrumb :items="[
|
||||
['label' => __('storefront.nav.home'), 'href' => route('home')],
|
||||
['label' => __('storefront.shop.all_products')],
|
||||
]" />
|
||||
</div>
|
||||
|
||||
{{-- Same reloadable listing body as the category page; sort/filter/page
|
||||
navigate this frame and advance the URL. --}}
|
||||
<turbo-frame
|
||||
id="products-listing"
|
||||
data-turbo-action="advance"
|
||||
data-controller="frame-scroll"
|
||||
class="scroll-mt-28"
|
||||
>
|
||||
@include('shop.partials.listing')
|
||||
</turbo-frame>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,36 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@php
|
||||
// $heading = __('storefront.search.results_for') . ' "' . $query . '"';
|
||||
$heading = '"' . $query . '"';
|
||||
@endphp
|
||||
|
||||
@section('title', $query . ' — ' . config('app.name'))
|
||||
|
||||
@push('seo')
|
||||
<meta name="robots" content="noindex,follow">
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-5xl mx-auto pt-26 pb-12">
|
||||
|
||||
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
|
||||
<h1 class="font-medium text-h2">{{ $heading }}</h1>
|
||||
|
||||
<x-breadcrumb :items="[
|
||||
['label' => __('storefront.nav.home'), 'href' => route('home')],
|
||||
['label' => __('storefront.search.results_for') . $heading],
|
||||
]" />
|
||||
</div>
|
||||
|
||||
<turbo-frame
|
||||
id="search-listing"
|
||||
data-turbo-action="advance"
|
||||
data-controller="frame-scroll"
|
||||
class="scroll-mt-28"
|
||||
>
|
||||
@include('search.partials.results')
|
||||
</turbo-frame>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,22 @@
|
||||
{{--
|
||||
Reloadable body of the search results page — result count + sort, product
|
||||
grid, pagination. Re-rendered on full load and on every
|
||||
<turbo-frame id="search-listing"> navigation, straight from ?q= and ?sort=.
|
||||
|
||||
Vars: $query (non-empty string), $products (LengthAwarePaginator), $sortOptions (array)
|
||||
--}}
|
||||
|
||||
@if ($products->isEmpty())
|
||||
<p class="text-neutral-500">{{ __('storefront.shop.no_products') }}</p>
|
||||
@else
|
||||
<div class="flex items-center justify-between gap-6 flex-wrap mb-7">
|
||||
<x-shop.result-count :paginator="$products" />
|
||||
<x-shop.sort :options="$sortOptions" id="search-sort" />
|
||||
</div>
|
||||
|
||||
<x-product-grid :products="$products->items()" cols="4" />
|
||||
|
||||
<div class="mt-12">
|
||||
<x-ui.pagination :paginator="$products" />
|
||||
</div>
|
||||
@endif
|
||||
+16
-53
@@ -1,56 +1,20 @@
|
||||
{{--
|
||||
The reloadable body of the category listing — count + sort, product grid +
|
||||
pagination, and the filter sidebar. Rendered both on full page load and on
|
||||
every <turbo-frame id="category-listing"> navigation, always straight from
|
||||
the query string ($listing). Anything that must reflect the applied
|
||||
sort/filter/page state belongs in here.
|
||||
Reloadable body of a product listing — result count + sort, product grid +
|
||||
pagination, and the filter sidebar. Shared by the category page (scoped to a
|
||||
collection) and the all-products page. Rendered on full load and on every
|
||||
turbo-frame navigation, straight from the query string.
|
||||
|
||||
Vars: $collection, $products (LengthAwarePaginator), $listing (App\Catalog\CategoryListing)
|
||||
Vars (all from App\Catalog\ProductListingPage::build):
|
||||
$products (LengthAwarePaginator), $sortOptions (array),
|
||||
$listing (App\Catalog\ProductListing), $listingAction (string, GET form target),
|
||||
$clearPriceUrl (?string), $priceFloor / $priceCeil (?int)
|
||||
--}}
|
||||
|
||||
@php
|
||||
// Keys are the `sort` URL param values — the ProductSort enum cases, plus
|
||||
// "popularity" for the default (no param). App\Catalog\CategoryListing is
|
||||
// the single place that validates them back into the enum.
|
||||
$sortLabels = [
|
||||
'popularity' => __('storefront.shop.sort_popularity'),
|
||||
'price_asc' => __('storefront.shop.sort_price_asc'),
|
||||
'price_desc' => __('storefront.shop.sort_price_desc'),
|
||||
'newest' => __('storefront.shop.sort_newest'),
|
||||
];
|
||||
$currentSort = $listing->sort?->value ?? 'popularity';
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
|
||||
|
||||
<div class="flex items-center justify-between gap-6 flex-wrap mb-7">
|
||||
<p>
|
||||
{{ trans_choice('storefront.shop.showing_results', $products->total(), [
|
||||
'first' => $products->firstItem() ?? 0,
|
||||
'last' => $products->lastItem() ?? 0,
|
||||
'total' => $products->total(),
|
||||
]) }}
|
||||
</p>
|
||||
|
||||
{{-- Sort options are plain links carrying the rest of the applied state;
|
||||
following one navigates the frame and advances the URL. Changing
|
||||
sort drops back to page 1. --}}
|
||||
<x-ui.dropdown
|
||||
id="category-sort"
|
||||
:label="$sortLabels[$currentSort]"
|
||||
:ariaLabel="__('storefront.shop.sort_label')"
|
||||
triggerClass="min-w-48"
|
||||
>
|
||||
@foreach ($sortLabels as $value => $label)
|
||||
<x-ui.dropdown.item
|
||||
:href="route('category.show', ['id' => $collection['id']] + $listing->query([
|
||||
'sort' => $value === 'popularity' ? null : $value,
|
||||
'page' => null,
|
||||
]))"
|
||||
:current="$value === $currentSort"
|
||||
>{{ $label }}</x-ui.dropdown.item>
|
||||
@endforeach
|
||||
</x-ui.dropdown>
|
||||
<x-shop.result-count :paginator="$products" />
|
||||
<x-shop.sort :options="$sortOptions" id="shop-sort" />
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
@@ -70,11 +34,10 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Sidebar — sort, price and in-stock are wired to the back-end; the
|
||||
search box is still a placeholder. --}}
|
||||
{{-- Sidebar — sort, price and in-stock are wired to the back-end; product
|
||||
search lives in the nav overlay, not here. --}}
|
||||
<aside class="flex flex-col gap-10">
|
||||
|
||||
|
||||
{{-- Filter form: a plain GET form whose fields ARE the state. Changing
|
||||
any control auto-submits (auto-submit controller); Turbo captures
|
||||
the GET, navigates the frame, and advances the URL. `sort` rides
|
||||
@@ -83,7 +46,7 @@
|
||||
the sr-only submit button applies the range inputs. --}}
|
||||
<form
|
||||
method="get"
|
||||
action="{{ route('category.show', ['id' => $collection['id']]) }}"
|
||||
action="{{ $listingAction }}"
|
||||
data-controller="auto-submit"
|
||||
data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
|
||||
class="contents"
|
||||
@@ -93,7 +56,7 @@ class="contents"
|
||||
@endif
|
||||
|
||||
@if ($priceFloor !== null && $priceCeil !== null && $priceCeil > $priceFloor)
|
||||
<div class="flex flex-col gap-4 -mt-2">
|
||||
<div class="flex flex-col gap-4 -mt-2">
|
||||
<p class="font-extrabold text-h4">{{ __('storefront.shop.filter_price') }}</p>
|
||||
|
||||
<x-ui.range-slider
|
||||
@@ -108,9 +71,9 @@ class="contents"
|
||||
:min-label="__('storefront.shop.price_min')"
|
||||
:max-label="__('storefront.shop.price_max')"
|
||||
>
|
||||
@if ($priceFiltered)
|
||||
@if ($clearPriceUrl)
|
||||
<a
|
||||
href="{{ route('category.show', ['id' => $collection['id']] + $listing->query(['price_min' => null, 'price_max' => null, 'page' => null])) }}"
|
||||
href="{{ $clearPriceUrl }}"
|
||||
class="underline-slide [--slide-h:1px] font-display text-sm font-bold uppercase italic"
|
||||
>{{ __('storefront.shop.reset') }}</a>
|
||||
@endif
|
||||
@@ -5,6 +5,7 @@
|
||||
use App\Http\Controllers\HomeController;
|
||||
use App\Http\Controllers\LegalPageController;
|
||||
use App\Http\Controllers\ProductController;
|
||||
use App\Http\Controllers\SearchController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Bare `/` has no {locale} segment to prefix-match against, so it's declared outside
|
||||
@@ -22,6 +23,8 @@
|
||||
->group(function () {
|
||||
Route::get('/', [HomeController::class, 'index'])->name('home');
|
||||
|
||||
Route::get('/products', [ProductController::class, 'index'])->name('products');
|
||||
|
||||
Route::get('/products/{id}', [ProductController::class, 'show'])->name(
|
||||
'product.show',
|
||||
);
|
||||
@@ -30,6 +33,8 @@
|
||||
'category.show',
|
||||
);
|
||||
|
||||
Route::get('/search', [SearchController::class, 'show'])->name('search');
|
||||
|
||||
Route::get('/contact', [ContactController::class, 'index'])->name('contact');
|
||||
|
||||
Route::get('/terms-and-conditions', [LegalPageController::class, 'terms'])->name(
|
||||
|
||||
Reference in New Issue
Block a user