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
+22
View File
@@ -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' })
}
}
+4
View File
@@ -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())
}
}
+7 -2
View File
@@ -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>
+10 -2
View File
@@ -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>
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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']],
]" />
+38
View File
@@ -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
+36
View File
@@ -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
@@ -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