9 Commits
36 changed files with 846 additions and 345 deletions
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Catalog;
/**
* 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.
*
* One source: a localized index array — ProductService::list()/getById()/
* random(), and now ProductSearchService::search() too, all return the exact
* same document shape (see ProductListingResult), so this is the only mapping
* every storefront listing page needs.
*/
final class ProductCard
{
/**
* @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
{
return [
'name' => $product['name'],
'price' => $product['price'],
'image' => $product['media'][0]['url'] ?? null,
'href' => route('product.show', ['id' => $product['id']]),
];
}
}
@@ -7,20 +7,21 @@
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,
public readonly ?int $minPrice,
public readonly ?int $maxPrice,
public readonly ?float $minPrice,
public readonly ?float $maxPrice,
public readonly bool $inStockOnly,
public readonly int $page,
) {}
@@ -29,14 +30,17 @@ public static function fromRequest(Request $request): self
{
return new self(
sort: ProductSort::tryFrom((string) $request->query('sort')),
minPrice: self::intOrNull($request->query('price_min')),
maxPrice: self::intOrNull($request->query('price_max')),
minPrice: self::floatOrNull($request->query('price_min')),
maxPrice: self::floatOrNull($request->query('price_max')),
inStockOnly: $request->boolean('in_stock'),
page: max(1, (int) $request->query('page', 1)),
);
}
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
@@ -79,8 +82,8 @@ public function isRefined(): bool
|| $this->page > 1;
}
private static function intOrNull(mixed $value): ?int
private static function floatOrNull(mixed $value): ?float
{
return is_numeric($value) ? (int) $value : null;
return is_numeric($value) ? (float) $value : null;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Catalog;
use Closure;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Services\ProductSearchService;
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 the category page (scoped to a collection), the all-products
* page, and the search page (scoped to a query — see $query below); 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,
private readonly ProductSearchService $search,
) {}
/**
* @param Closure(array<string, string|int>): string $url
* @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>
*/
public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array
{
$filters = $listing->filters($collectionId);
// Listing reads from the Meilisearch index via ProductService/
// ProductSearchService, not Eloquent. Both return a
// ProductListingResult — the product page plus the price-slider
// bounds (and available tags) from one call; the controller no
// longer stitches list()/search() + priceRange() together itself.
// 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,
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
$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])),
),
];
}
}
+40
View File
@@ -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));
}
}
+10 -42
View File
@@ -2,15 +2,15 @@
namespace App\Http\Controllers;
use App\Catalog\CategoryListing;
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,
) {}
@@ -19,46 +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());
// Listing/filtering reads from the Meilisearch index via ProductService,
// not Eloquent — see Modules\Core\Catalog\Services\ProductService. list() returns a
// real LengthAwarePaginator of plain arrays (already localized/flattened),
// not Product models. Sort/filter/page all come from the query string via
// CategoryListing, which is the single source of truth for that state.
$products = $this->products->list(
filters: $filters,
perPage: $perPage,
page: $listing->page,
sort: $listing->sort,
)->through(fn (array $product) => [
'name' => $product['name'],
'price' => $product['price'],
'image' => $product['media'][0]['url'] ?? null,
'href' => route('product.show', ['id' => $product['id']]),
])->appends($listing->query(['page' => null]));
$data = $this->listingPage->build(
$listing,
fn (array $query) => route('category.show', ['id' => $collectionData['id']] + $query),
$collectionData['id'],
);
// Slider bounds — the price span of everything matching the *other*
// filters (priceRange() drops the price filter itself, so the handles
// don't collapse to whatever's already selected). Whole euros.
$priceRange = $this->products->priceRange($filters);
$priceFloor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null;
$priceCeil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null;
// A price param is only a real filter if it's tighter than the bounds —
// drives whether the "clear" link shows.
$priceFiltered = ($listing->minPrice !== null && $listing->minPrice > ($priceFloor ?? PHP_INT_MIN))
|| ($listing->maxPrice !== null && $listing->maxPrice < ($priceCeil ?? PHP_INT_MAX));
return view('category.show', [
'collection' => $collectionData,
'products' => $products,
'listing' => $listing,
'priceFloor' => $priceFloor,
'priceCeil' => $priceCeil,
'priceFiltered' => $priceFiltered,
]);
return view('category.show', [...$data, 'collection' => $collectionData]);
}
}
+6 -11
View File
@@ -2,11 +2,14 @@
namespace App\Http\Controllers;
use App\Catalog\ProductCard;
use App\Models\StoicPage;
use Lunar\Models\Product;
use Modules\Core\Catalog\Services\ProductService;
class HomeController extends Controller
{
public function __construct(private readonly ProductService $products) {}
public function index(string $locale)
{
$page = StoicPage::firstWhere('slug', 'home');
@@ -15,16 +18,8 @@ public function index(string $locale)
abort(404);
}
$products = Product::with(['variants.prices.currency', 'media'])
->inRandomOrder()
->limit(13)
->get()
->map(fn (Product $product) => [
'name' => $product->translateAttribute('name'),
'price' => $product->variants->first()?->prices->first()?->price->decimal,
'image' => $product->media->first()?->getUrl(),
'href' => route('product.show', ['id' => $product->id]),
]);
$products = collect($this->products->random(13))
->map(fn (array $product) => ProductCard::fromIndexed($product));
return view('home', [
'page' => $page,
+23 -9
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)
{
@@ -16,14 +37,7 @@ public function show(string $locale, int $id)
$collection = $product['collections'][0] ?? null;
$variantsData = collect($product['variants'])
->map(fn (array $variant) => [
'id' => $variant['id'],
'price' => $variant['prices'][0]['price'] ?? null,
'image' => $variant['media'][0]['url'] ?? null,
])
->values()
->all();
$variantsData = $this->products->variantSummaries($product);
$firstVariant = $product['variants'][0] ?? null;
$option = $firstVariant['options'][0]['option'] ?? null;
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Catalog\ProductListing;
use App\Catalog\ProductListingPage;
class SearchController extends Controller
{
public function __construct(private readonly ProductListingPage $listingPage) {}
public function show(string $locale)
{
$query = trim((string) request()->query('q', ''));
if ($query === '') {
return redirect()->route('products');
}
$listing = ProductListing::fromRequest(request());
$data = $this->listingPage->build(
$listing,
fn (array $overrides) => route('search', ['q' => $query] + $overrides),
collectionId: null,
query: $query,
);
return view('search.index', [...$data, 'query' => $query]);
}
}
Generated
+103 -102
View File
@@ -515,11 +515,11 @@
},
{
"name": "boboko/core",
"version": "0.10.0",
"version": "0.14.0",
"source": {
"type": "git",
"url": "https://code.radical-elements.com/boboko/core.git",
"reference": "d680200ab1270f861e8d4e58804c215bb2dcd6ef"
"reference": "4ff9bdacc3394bb02537998a2d43adbc2799768a"
},
"require": {
"laravel/framework": "^12.0",
@@ -556,7 +556,8 @@
"Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\CartServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider"
"Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider"
]
}
},
@@ -566,7 +567,7 @@
}
},
"description": "Core module — authentication and shared panel behaviour",
"time": "2026-08-31T11:18:51+00:00"
"time": "2026-09-04T10:06:26+00:00"
},
{
"name": "brick/math",
@@ -1685,16 +1686,16 @@
},
{
"name": "filament/actions",
"version": "v4.12.6",
"version": "v4.12.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/actions.git",
"reference": "537593a66af7fbc1a6f4d1bd8e58767a4b6847e9"
"reference": "7bf5935b85e33b7627496e49a36bd196bf5dc228"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/actions/zipball/537593a66af7fbc1a6f4d1bd8e58767a4b6847e9",
"reference": "537593a66af7fbc1a6f4d1bd8e58767a4b6847e9",
"url": "https://api.github.com/repos/filamentphp/actions/zipball/7bf5935b85e33b7627496e49a36bd196bf5dc228",
"reference": "7bf5935b85e33b7627496e49a36bd196bf5dc228",
"shasum": ""
},
"require": {
@@ -1730,20 +1731,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-08-05T20:40:59+00:00"
"time": "2026-08-31T17:01:19+00:00"
},
{
"name": "filament/filament",
"version": "v4.12.6",
"version": "v4.12.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/panels.git",
"reference": "affce81542de43cf87573eda0667697c3bd79f0e"
"reference": "5a51dfc51c31ef6e3bd063ba63923254356fb7f6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/panels/zipball/affce81542de43cf87573eda0667697c3bd79f0e",
"reference": "affce81542de43cf87573eda0667697c3bd79f0e",
"url": "https://api.github.com/repos/filamentphp/panels/zipball/5a51dfc51c31ef6e3bd063ba63923254356fb7f6",
"reference": "5a51dfc51c31ef6e3bd063ba63923254356fb7f6",
"shasum": ""
},
"require": {
@@ -1787,20 +1788,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-08-05T20:40:40+00:00"
"time": "2026-08-31T17:01:25+00:00"
},
{
"name": "filament/forms",
"version": "v4.12.6",
"version": "v4.12.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/forms.git",
"reference": "29b4fce3ca9faab1af9e1d65d1a0b8feafc6ae75"
"reference": "282eba77756ed7f28251981a8f27d3018b15006d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/forms/zipball/29b4fce3ca9faab1af9e1d65d1a0b8feafc6ae75",
"reference": "29b4fce3ca9faab1af9e1d65d1a0b8feafc6ae75",
"url": "https://api.github.com/repos/filamentphp/forms/zipball/282eba77756ed7f28251981a8f27d3018b15006d",
"reference": "282eba77756ed7f28251981a8f27d3018b15006d",
"shasum": ""
},
"require": {
@@ -1837,20 +1838,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-08-05T20:41:13+00:00"
"time": "2026-08-31T17:01:17+00:00"
},
{
"name": "filament/infolists",
"version": "v4.12.6",
"version": "v4.12.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/infolists.git",
"reference": "87a964ec71f40a195142ed28f013b241ef88ae8c"
"reference": "097ef96993e0ef5bb82f7660813e699c89b6cec8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/infolists/zipball/87a964ec71f40a195142ed28f013b241ef88ae8c",
"reference": "87a964ec71f40a195142ed28f013b241ef88ae8c",
"url": "https://api.github.com/repos/filamentphp/infolists/zipball/097ef96993e0ef5bb82f7660813e699c89b6cec8",
"reference": "097ef96993e0ef5bb82f7660813e699c89b6cec8",
"shasum": ""
},
"require": {
@@ -1882,20 +1883,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-08-05T20:40:46+00:00"
"time": "2026-08-31T17:01:18+00:00"
},
{
"name": "filament/notifications",
"version": "v4.12.6",
"version": "v4.12.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/notifications.git",
"reference": "ad33db1c66df7d88f388f3558bf216288c04f7fe"
"reference": "dd2e23e6a3e5a68854e424812adc5c75fbfa17f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/notifications/zipball/ad33db1c66df7d88f388f3558bf216288c04f7fe",
"reference": "ad33db1c66df7d88f388f3558bf216288c04f7fe",
"url": "https://api.github.com/repos/filamentphp/notifications/zipball/dd2e23e6a3e5a68854e424812adc5c75fbfa17f7",
"reference": "dd2e23e6a3e5a68854e424812adc5c75fbfa17f7",
"shasum": ""
},
"require": {
@@ -1929,20 +1930,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-08-05T20:40:49+00:00"
"time": "2026-08-31T17:01:18+00:00"
},
{
"name": "filament/query-builder",
"version": "v4.12.6",
"version": "v4.12.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/query-builder.git",
"reference": "024843db4765b51c0edcfae1a6ca97ad80f72ee8"
"reference": "899a11d90a95cb2b2671f204e234e246a347ad72"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/query-builder/zipball/024843db4765b51c0edcfae1a6ca97ad80f72ee8",
"reference": "024843db4765b51c0edcfae1a6ca97ad80f72ee8",
"url": "https://api.github.com/repos/filamentphp/query-builder/zipball/899a11d90a95cb2b2671f204e234e246a347ad72",
"reference": "899a11d90a95cb2b2671f204e234e246a347ad72",
"shasum": ""
},
"require": {
@@ -1975,20 +1976,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-08-05T20:42:37+00:00"
"time": "2026-08-31T17:01:19+00:00"
},
{
"name": "filament/schemas",
"version": "v4.12.6",
"version": "v4.12.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/schemas.git",
"reference": "40cee8d1d5aa7be2c32597254013587214611d7d"
"reference": "890cb1dac78fce8c63825195a8d9ca6c6bcc8029"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/schemas/zipball/40cee8d1d5aa7be2c32597254013587214611d7d",
"reference": "40cee8d1d5aa7be2c32597254013587214611d7d",
"url": "https://api.github.com/repos/filamentphp/schemas/zipball/890cb1dac78fce8c63825195a8d9ca6c6bcc8029",
"reference": "890cb1dac78fce8c63825195a8d9ca6c6bcc8029",
"shasum": ""
},
"require": {
@@ -2020,11 +2021,11 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-08-05T20:40:36+00:00"
"time": "2026-08-31T17:01:16+00:00"
},
{
"name": "filament/spatie-laravel-media-library-plugin",
"version": "v4.12.6",
"version": "v4.12.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git",
@@ -2061,16 +2062,16 @@
},
{
"name": "filament/support",
"version": "v4.12.6",
"version": "v4.12.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/support.git",
"reference": "9fc269265f97b3a8c9529e6e32c6f2fed0b3e62e"
"reference": "37f6b22412f098c8770db1d0edeeb36cf6e47433"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/support/zipball/9fc269265f97b3a8c9529e6e32c6f2fed0b3e62e",
"reference": "9fc269265f97b3a8c9529e6e32c6f2fed0b3e62e",
"url": "https://api.github.com/repos/filamentphp/support/zipball/37f6b22412f098c8770db1d0edeeb36cf6e47433",
"reference": "37f6b22412f098c8770db1d0edeeb36cf6e47433",
"shasum": ""
},
"require": {
@@ -2115,20 +2116,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-08-05T20:40:43+00:00"
"time": "2026-09-01T08:55:04+00:00"
},
{
"name": "filament/tables",
"version": "v4.12.6",
"version": "v4.12.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/tables.git",
"reference": "78c78deed4005b05f80b5d6b623ff9830704644f"
"reference": "812745d91c3836cbb8046d9a619386ab7ac07ce7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/tables/zipball/78c78deed4005b05f80b5d6b623ff9830704644f",
"reference": "78c78deed4005b05f80b5d6b623ff9830704644f",
"url": "https://api.github.com/repos/filamentphp/tables/zipball/812745d91c3836cbb8046d9a619386ab7ac07ce7",
"reference": "812745d91c3836cbb8046d9a619386ab7ac07ce7",
"shasum": ""
},
"require": {
@@ -2161,20 +2162,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-08-05T20:41:42+00:00"
"time": "2026-08-31T17:01:34+00:00"
},
{
"name": "filament/widgets",
"version": "v4.12.6",
"version": "v4.12.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/widgets.git",
"reference": "70b472cee64d5eebadfd51bb81b72ee00a89fbfd"
"reference": "9f3b5ad3aea0f52908ee88775a890f0a50e8d903"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/widgets/zipball/70b472cee64d5eebadfd51bb81b72ee00a89fbfd",
"reference": "70b472cee64d5eebadfd51bb81b72ee00a89fbfd",
"url": "https://api.github.com/repos/filamentphp/widgets/zipball/9f3b5ad3aea0f52908ee88775a890f0a50e8d903",
"reference": "9f3b5ad3aea0f52908ee88775a890f0a50e8d903",
"shasum": ""
},
"require": {
@@ -2205,7 +2206,7 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2026-08-05T20:41:58+00:00"
"time": "2026-08-31T17:01:20+00:00"
},
{
"name": "fruitcake/php-cors",
@@ -2992,16 +2993,16 @@
},
{
"name": "laravel/framework",
"version": "v12.68.0",
"version": "v12.69.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "1343c22b92edd48e29ff273d8ce7c15cf75d974c"
"reference": "0c07b0b1f88af44d8558ffadf66900a860f93c23"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/1343c22b92edd48e29ff273d8ce7c15cf75d974c",
"reference": "1343c22b92edd48e29ff273d8ce7c15cf75d974c",
"url": "https://api.github.com/repos/laravel/framework/zipball/0c07b0b1f88af44d8558ffadf66900a860f93c23",
"reference": "0c07b0b1f88af44d8558ffadf66900a860f93c23",
"shasum": ""
},
"require": {
@@ -3210,7 +3211,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2026-08-25T14:18:36+00:00"
"time": "2026-09-01T21:34:37+00:00"
},
{
"name": "laravel/prompts",
@@ -3763,16 +3764,16 @@
},
{
"name": "league/flysystem",
"version": "3.35.3",
"version": "3.36.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
"reference": "5fc8404762179ae514678487b23494fd69b2309c"
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5fc8404762179ae514678487b23494fd69b2309c",
"reference": "5fc8404762179ae514678487b23494fd69b2309c",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
"shasum": ""
},
"require": {
@@ -3840,9 +3841,9 @@
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
"source": "https://github.com/thephpleague/flysystem/tree/3.35.3"
"source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
},
"time": "2026-08-22T12:55:54+00:00"
"time": "2026-09-02T08:00:27+00:00"
},
{
"name": "league/flysystem-local",
@@ -4293,16 +4294,16 @@
},
{
"name": "livewire/livewire",
"version": "v3.8.6",
"version": "v3.8.7",
"source": {
"type": "git",
"url": "https://github.com/livewire/livewire.git",
"reference": "be5729e9accfa9255a28d82f7759a3cc4be0f079"
"reference": "ff019f8f6f48b7a2315922e45a70ad8fd75d1934"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/livewire/livewire/zipball/be5729e9accfa9255a28d82f7759a3cc4be0f079",
"reference": "be5729e9accfa9255a28d82f7759a3cc4be0f079",
"url": "https://api.github.com/repos/livewire/livewire/zipball/ff019f8f6f48b7a2315922e45a70ad8fd75d1934",
"reference": "ff019f8f6f48b7a2315922e45a70ad8fd75d1934",
"shasum": ""
},
"require": {
@@ -4357,7 +4358,7 @@
"description": "A front-end framework for Laravel.",
"support": {
"issues": "https://github.com/livewire/livewire/issues",
"source": "https://github.com/livewire/livewire/tree/v3.8.6"
"source": "https://github.com/livewire/livewire/tree/v3.8.7"
},
"funding": [
{
@@ -4365,7 +4366,7 @@
"type": "github"
}
],
"time": "2026-08-24T15:02:02+00:00"
"time": "2026-08-31T15:40:46+00:00"
},
{
"name": "lukascivil/treewalker",
@@ -5099,16 +5100,16 @@
},
{
"name": "monolog/monolog",
"version": "3.10.0",
"version": "3.11.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
"reference": "147f303310f06334f03f409e49d7ad1e275ff05a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/147f303310f06334f03f409e49d7ad1e275ff05a",
"reference": "147f303310f06334f03f409e49d7ad1e275ff05a",
"shasum": ""
},
"require": {
@@ -5186,7 +5187,7 @@
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
"source": "https://github.com/Seldaek/monolog/tree/3.11.0"
},
"funding": [
{
@@ -5198,7 +5199,7 @@
"type": "tidelift"
}
],
"time": "2026-01-02T08:56:05+00:00"
"time": "2026-09-02T12:39:56+00:00"
},
{
"name": "nesbot/carbon",
@@ -7105,16 +7106,16 @@
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.4",
"version": "2.3.5",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "98dbc9412932af5825e6d5aa5d6bc4de7d82538a"
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/98dbc9412932af5825e6d5aa5d6bc4de7d82538a",
"reference": "98dbc9412932af5825e6d5aa5d6bc4de7d82538a",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9",
"shasum": ""
},
"require": {
@@ -7146,9 +7147,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.4"
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5"
},
"time": "2026-08-30T16:25:38+00:00"
"time": "2026-08-31T16:05:28+00:00"
},
{
"name": "pragmarx/google2fa",
@@ -8791,16 +8792,16 @@
},
{
"name": "spatie/laravel-medialibrary",
"version": "11.23.5",
"version": "11.23.7",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-medialibrary.git",
"reference": "8ca16954d607de1853c9609e88eb91eab43d67b9"
"reference": "94b11766ea5e10b1e2a88159b24937eae89a5d5f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/8ca16954d607de1853c9609e88eb91eab43d67b9",
"reference": "8ca16954d607de1853c9609e88eb91eab43d67b9",
"url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/94b11766ea5e10b1e2a88159b24937eae89a5d5f",
"reference": "94b11766ea5e10b1e2a88159b24937eae89a5d5f",
"shasum": ""
},
"require": {
@@ -8885,7 +8886,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-medialibrary/issues",
"source": "https://github.com/spatie/laravel-medialibrary/tree/11.23.5"
"source": "https://github.com/spatie/laravel-medialibrary/tree/11.23.7"
},
"funding": [
{
@@ -8897,7 +8898,7 @@
"type": "github"
}
],
"time": "2026-08-10T08:15:27+00:00"
"time": "2026-09-03T13:52:57+00:00"
},
{
"name": "spatie/laravel-package-tools",
@@ -12654,16 +12655,16 @@
},
{
"name": "technikermathe/blade-lucide-icons",
"version": "v3.171.0",
"version": "v3.174.0",
"source": {
"type": "git",
"url": "https://github.com/PascaleBeier/blade-lucide-icons.git",
"reference": "6419d6426865c9e69195405d52bdd8a859356353"
"reference": "e22d65587b2a6f21405161f399bda8c871135724"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/6419d6426865c9e69195405d52bdd8a859356353",
"reference": "6419d6426865c9e69195405d52bdd8a859356353",
"url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/e22d65587b2a6f21405161f399bda8c871135724",
"reference": "e22d65587b2a6f21405161f399bda8c871135724",
"shasum": ""
},
"require": {
@@ -12713,9 +12714,9 @@
],
"support": {
"issues": "https://github.com/PascaleBeier/blade-lucide-icons/issues",
"source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.171.0"
"source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.174.0"
},
"time": "2026-08-30T02:22:57+00:00"
"time": "2026-09-04T01:58:29+00:00"
},
{
"name": "thecodingmachine/safe",
@@ -12986,16 +12987,16 @@
},
{
"name": "ueberdosis/tiptap-php",
"version": "2.1.2",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap-php.git",
"reference": "0561e2146edbcdc622b5d4008d0ee02582f8642b"
"reference": "5a2e8155c5b09c9ad4efd480550270a6924865b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ueberdosis/tiptap-php/zipball/0561e2146edbcdc622b5d4008d0ee02582f8642b",
"reference": "0561e2146edbcdc622b5d4008d0ee02582f8642b",
"url": "https://api.github.com/repos/ueberdosis/tiptap-php/zipball/5a2e8155c5b09c9ad4efd480550270a6924865b9",
"reference": "5a2e8155c5b09c9ad4efd480550270a6924865b9",
"shasum": ""
},
"require": {
@@ -13035,7 +13036,7 @@
],
"support": {
"issues": "https://github.com/ueberdosis/tiptap-php/issues",
"source": "https://github.com/ueberdosis/tiptap-php/tree/2.1.2"
"source": "https://github.com/ueberdosis/tiptap-php/tree/2.2.0"
},
"funding": [
{
@@ -13051,7 +13052,7 @@
"type": "open_collective"
}
],
"time": "2026-08-11T08:47:45+00:00"
"time": "2026-08-31T07:00:09+00:00"
},
{
"name": "vlucas/phpdotenv",
+1 -1
View File
@@ -114,7 +114,7 @@ services:
- "${VALKEY_PORT:-6339}:6379"
meilisearch:
image: getmeili/meilisearch:v1.10
image: getmeili/meilisearch:v1.12
ports:
- "${MEILISEARCH_PORT:-7700}:7700"
environment:
+1 -1
View File
@@ -116,7 +116,7 @@ services:
- valkeydata:/data
meilisearch:
image: getmeili/meilisearch:v1.10
image: getmeili/meilisearch:v1.12
restart: unless-stopped
environment:
MEILI_MASTER_KEY: ${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}
+7
View File
@@ -47,6 +47,12 @@ if [ "$APP_ENV" != "production" ]; then
php artisan vendor:publish --tag=core-assets --force --ansi --quiet
php artisan vendor:publish --tag=public --force --ansi --quiet
php artisan filament:assets --ansi --quiet
# No --force: core-views publishes editable Blade templates (order
# notification emails), meant to be hand-customized per app — unlike
# core-assets above, republishing must not silently wipe local edits.
# Only fills in the vendor/core views directory if it doesn't exist yet.
php artisan vendor:publish --tag=core-views --ansi --quiet
fi
# Everything below is universal, in both dev and production: application STATE
@@ -80,6 +86,7 @@ php artisan lunar:install --quiet || true
# just shipped, same reasoning as optimize:clear above.
echo "[entrypoint] Syncing search indexes..."
php artisan lunar:meilisearch:setup
php artisan lunar:meilisearch:tune-product-search --quiet || true
php artisan lunar:search:index --quiet || true
if [ "$APP_ENV" = "production" ]; then
+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())
}
}
@@ -1,154 +0,0 @@
{{--
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.
Vars: $collection, $products (LengthAwarePaginator), $listing (App\Catalog\CategoryListing)
--}}
@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>
</div>
<div></div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
{{-- Products --}}
<div>
@if($products->isEmpty())
<p class="text-neutral-500">{{ __('storefront.shop.no_products') }}</p>
@else
<x-product-grid :products="$products->items()" cols="3" />
<div class="mt-12">
<x-ui.pagination :paginator="$products" />
</div>
@endif
</div>
{{-- Sidebar — sort, price and in-stock are wired to the back-end; the
search box is still a placeholder. --}}
<aside class="flex flex-col gap-10">
{{-- Placeholder — not wired yet. --}}
<div class="-mt-2">
<label for="shop-search" class="sr-only">{{ __('storefront.shop.search_label') }}</label>
<div class="relative">
<x-ui.input
type="search"
id="shop-search"
:placeholder="__('storefront.shop.search_placeholder')"
class="pr-10"
/>
<button
type="button"
class="absolute right-0 top-1/2 -translate-y-1/2"
aria-label="{{ __('storefront.shop.search_label') }}"
>
<x-ui.icon name="search" :size="20" />
</button>
</div>
</div>
{{-- 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
along as a hidden field so it survives a filter change; `page` is
deliberately absent, so filtering drops back to page 1. Without JS
the sr-only submit button applies the range inputs. --}}
<form
method="get"
action="{{ route('category.show', ['id' => $collection['id']]) }}"
data-controller="auto-submit"
data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
class="contents"
>
@if ($listing->sort)
<input type="hidden" name="sort" value="{{ $listing->sort->value }}">
@endif
@if ($priceFloor !== null && $priceCeil !== null && $priceCeil > $priceFloor)
<div class="flex flex-col gap-4">
<p class="font-extrabold text-h4">{{ __('storefront.shop.filter_price') }}</p>
<x-ui.range-slider
name="price"
:min="$priceFloor"
:max="$priceCeil"
:min-value="max($priceFloor, $listing->minPrice ?? $priceFloor)"
:max-value="min($priceCeil, $listing->maxPrice ?? $priceCeil)"
prefix="€"
separator=" - "
:legend="__('storefront.shop.filter_price')"
:min-label="__('storefront.shop.price_min')"
:max-label="__('storefront.shop.price_max')"
>
{{-- Clear the price filter — a plain link back to the URL
without price_*; only shown when actually filtered. --}}
@if ($priceFiltered)
<a
href="{{ route('category.show', ['id' => $collection['id']] + $listing->query(['price_min' => null, 'price_max' => null, 'page' => null])) }}"
class="underline-slide [--slide-h:1px] font-display text-sm font-bold uppercase italic"
>{{ __('storefront.shop.reset') }}</a>
@endif
</x-ui.range-slider>
</div>
@endif
<div class="flex flex-col gap-4 [&_label]:text-base">
<p class="font-extrabold text-h4">{{ __('storefront.shop.availability') }}</p>
<x-ui.checkbox id="shop-in-stock" name="in_stock" :checked="$listing->inStockOnly">
{{ __('storefront.shop.in_stock_only') }}
</x-ui.checkbox>
</div>
<button type="submit" class="sr-only">{{ __('storefront.shop.apply') }}</button>
</form>
</aside>
</div>
+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>
@@ -5,9 +5,14 @@
'href' => '#',
])
{{-- data-turbo-frame="_top" on the links: this card renders inside the
category page's <turbo-frame id="category-listing">, so without it a click
would try to load the product page *into* that frame, not find a matching
frame, and fail with "content missing". It's a no-op anywhere there's no
frame (homepage grids, related products). --}}
<div class="group">
<div class="relative flex items-center justify-center mb-4">
<a href="{{ $href }}" class="block w-full border border-black overflow-hidden bg-white aspect-[251/335] flex items-center justify-center">
<a href="{{ $href }}" data-turbo-frame="_top" class="block w-full border border-black overflow-hidden bg-white aspect-[251/335] flex items-center justify-center">
@if($image)
<img
src="{{ $image }}"
@@ -28,7 +33,7 @@ class="w-full h-auto block"
</div>
<div class="flex items-baseline justify-between gap-4">
<a href="{{ $href }}" class="font-bold text-xl leading-snug hover:text-brand">{{ $name }}</a>
<a href="{{ $href }}" data-turbo-frame="_top" class="font-bold text-xl leading-snug hover:text-brand">{{ $name }}</a>
@if($price !== null)
<span class="font-bold text-xl shrink-0"><x-ui.price :amount="$price" /></span>
@endif
+1 -1
View File
@@ -17,7 +17,7 @@
data-controller="appear"
data-appear-visible-class="is-visible">
<span class="text-dance">Επικοινώνησε</span><br>
μαζί μας <span class="text-dance">άμεσα</span>
μαζί μας <span class="text-dance [--dance-delay:200ms]">άμεσα</span>
</h1>
<p class="max-w-md ">
+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
+100
View File
@@ -0,0 +1,100 @@
@extends('layouts.app')
@section('title', 'Αναζήτηση')
@section('content')
<section class="px-8 py-16 lg:px-16">
<form method="GET" action="{{ route('search') }}" class="max-w-xl mb-12">
<x-ui.field label="Αναζήτηση" for="search-q">
<x-ui.input id="search-q" name="q" value="{{ $query }}" autocomplete="off" />
</x-ui.field>
</form>
@if ($query === '')
<p class="text-neutral-500">Πληκτρολόγησε κάτι για αναζήτηση.</p>
@else
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
<div>
@if ($results->isEmpty())
<p class="text-neutral-500">Δεν βρέθηκαν αποτελέσματα για "{{ $query }}".</p>
@else
<p class="mb-6 text-neutral-500">{{ $results->count() }} αποτελέσματα για "{{ $query }}"</p>
<ul>
@foreach ($results as $product)
<li>
#{{ $product->id }} —
<a href="{{ route('product.show', ['id' => $product->id]) }}">
{{ $product->translateAttribute('name') ?? '(no name — id: ' . $product->id . ')' }}
</a>
</li>
@endforeach
</ul>
@endif
</div>
{{-- Filter sidebar — same shape/components as category/partials/listing.blade.php,
adapted to SearchListing's query() (which always carries `q`) instead of
CategoryListing's collection-scoped one. --}}
<aside class="flex flex-col gap-10">
<form
method="get"
action="{{ route('search') }}"
data-controller="auto-submit"
data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
class="contents"
>
<input type="hidden" name="q" value="{{ $listing->query }}">
@if ($listing->sort)
<input type="hidden" name="sort" value="{{ $listing->sort->value }}">
@endif
@if ($priceFloor !== null && $priceCeil !== null && $priceCeil > $priceFloor)
<div class="flex flex-col gap-4">
<p class="font-extrabold text-h4">{{ __('storefront.shop.filter_price') }}</p>
<x-ui.range-slider
name="price"
:min="$priceFloor"
:max="$priceCeil"
:min-value="max($priceFloor, $listing->minPrice ?? $priceFloor)"
:max-value="min($priceCeil, $listing->maxPrice ?? $priceCeil)"
prefix="€"
separator=" - "
:legend="__('storefront.shop.filter_price')"
:min-label="__('storefront.shop.price_min')"
:max-label="__('storefront.shop.price_max')"
>
@if ($priceFiltered)
<a
href="{{ route('search', $listing->query(['price_min' => null, 'price_max' => null, 'page' => null])) }}"
class="underline-slide [--slide-h:1px] font-display text-sm font-bold uppercase italic"
>{{ __('storefront.shop.reset') }}</a>
@endif
</x-ui.range-slider>
</div>
@endif
<div class="flex flex-col gap-4 [&_label]:text-base">
<p class="font-extrabold text-h4">{{ __('storefront.shop.availability') }}</p>
<x-ui.checkbox id="search-in-stock" name="in_stock" :checked="$listing->inStockOnly">
{{ __('storefront.shop.in_stock_only') }}
</x-ui.checkbox>
</div>
<button type="submit" class="sr-only">{{ __('storefront.shop.apply') }}</button>
</form>
</aside>
</div>
@endif
</section>
@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
@@ -0,0 +1,96 @@
{{--
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 (all from App\Catalog\ProductListingPage::build):
$products (LengthAwarePaginator), $sortOptions (array),
$listing (App\Catalog\ProductListing), $listingAction (string, GET form target),
$clearPriceUrl (?string), $priceFloor / $priceCeil (?int)
--}}
<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">
<x-shop.result-count :paginator="$products" />
<x-shop.sort :options="$sortOptions" id="shop-sort" />
</div>
<div></div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
{{-- Products --}}
<div>
@if($products->isEmpty())
<p class="text-neutral-500">{{ __('storefront.shop.no_products') }}</p>
@else
<x-product-grid :products="$products->items()" cols="3" />
<div class="mt-12">
<x-ui.pagination :paginator="$products" />
</div>
@endif
</div>
{{-- 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
along as a hidden field so it survives a filter change; `page` is
deliberately absent, so filtering drops back to page 1. Without JS
the sr-only submit button applies the range inputs. --}}
<form
method="get"
action="{{ $listingAction }}"
data-controller="auto-submit"
data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
class="contents"
>
@if ($listing->sort)
<input type="hidden" name="sort" value="{{ $listing->sort->value }}" />
@endif
@if ($priceFloor !== null && $priceCeil !== null && $priceCeil > $priceFloor)
<div class="flex flex-col gap-4 -mt-2">
<p class="font-extrabold text-h4">{{ __('storefront.shop.filter_price') }}</p>
<x-ui.range-slider
name="price"
:min="$priceFloor"
:max="$priceCeil"
:min-value="max($priceFloor, $listing->minPrice ?? $priceFloor)"
:max-value="min($priceCeil, $listing->maxPrice ?? $priceCeil)"
prefix="€"
separator=" - "
:legend="__('storefront.shop.filter_price')"
:min-label="__('storefront.shop.price_min')"
:max-label="__('storefront.shop.price_max')"
>
@if ($clearPriceUrl)
<a
href="{{ $clearPriceUrl }}"
class="underline-slide [--slide-h:1px] font-display text-sm font-bold uppercase italic"
>{{ __('storefront.shop.reset') }}</a>
@endif
</x-ui.range-slider>
</div>
@endif
<div class="flex flex-col gap-4 [&_label]:text-base">
<p class="font-extrabold text-h4">{{ __('storefront.shop.availability') }}</p>
<x-ui.checkbox id="shop-in-stock" name="in_stock" :checked="$listing->inStockOnly">
{{ __('storefront.shop.in_stock_only') }}
</x-ui.checkbox>
</div>
<button type="submit" class="sr-only">{{ __('storefront.shop.apply') }}</button>
</form>
</aside>
</div>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Payment of <strong>{{ $amount }}</strong> for your order <strong>{{ $reference }}</strong> has been captured.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Good news — your order <strong>{{ $reference }}</strong> has been delivered.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>A refund of <strong>{{ $amount }}</strong> has been issued for your order <strong>{{ $reference }}</strong>.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is now: <strong>{{ $statusLabel }}</strong></p>
+5
View File
@@ -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(