22 Commits
Author SHA1 Message Date
elvira 9abe45c5f7 minor changes in comments 2026-09-04 13:19:24 +03:00
elvira e57bfe3d42 minor change in contact page 2026-09-04 13:09:10 +03:00
arvanitakis 72b9abf92f Feat: Updating Product Cards, Listing Pages and SearchController 2026-09-04 13:07:57 +03:00
elvira 1f0612861b general products page and small rewrite of search and category controllers etc 2026-09-03 21:32:40 +03:00
arvanitakis b2207a622c Feat: Updating COntrollers to remove business logic moved to boboko/core 2026-09-03 11:47:38 +03:00
arvanitakis b87e22381f Feat: Publishing Payment and Order Emails 2026-09-03 11:46:55 +03:00
arvanitakis 53f30ff51a Feat: Bump Meilisearch Version to 1.12 2026-09-03 11:46:19 +03:00
elvira 210ed3b094 removed search from category filters and fixed product links 2026-09-02 14:05:20 +03:00
arvanitakis 5e2ec7a60a Feat: Entrypoint updates 2026-09-02 13:23:01 +03:00
arvanitakis 2d1624bcb2 Feat: Moving Fonts under resources, so vite can pick them up and serve them 2026-09-01 13:00:26 +03:00
arvanitakis ea6ebe435e Feature: Updating entrypoints, to support caching, migrations, etc 2026-09-01 12:43:19 +03:00
arvanitakis 2d75bb9e01 Feature: Gitgnoring generated assets, since these are built/baked into the image at container starts 2026-09-01 12:36:43 +03:00
elvira 8a07c772f8 product category page: front-end sorting and filtering using turboframes 2026-08-31 22:56:40 +03:00
elvira b070a7d1e6 category page filters components and general ui - no functionality yet 2026-08-31 21:52:47 +03:00
arvanitakis 74e554884f Feat: Updating Entrypoint to setup lunar meilisearch 2026-08-31 14:56:17 +03:00
arvanitakis 4387459aed Feat: Upgrading to Boboko 0.10.0 / Lunar 1.5 2026-08-31 14:25:22 +03:00
arvanitakis adb847f442 Feature: Adding Meilisearch to 3dealer 2026-08-31 13:14:40 +03:00
arvanitakis c7cd1138fe Feat: Updating routes and controllers to use the updated Services coming from boboko/core 2026-08-29 00:17:34 +03:00
arvanitakis defe1dab12 Feat: Updating Product imports after Boboko restructure 2026-08-27 11:46:33 +03:00
arvanitakis 86c645fb36 Feat: Adding Color Product Option Types 2026-08-27 01:58:37 +03:00
arvanitakis 0dcaf112be Feature: Updating Core imports after restructure 2026-08-27 01:21:57 +03:00
arvanitakis b3405c1b60 Feat: Updating Home, Product and Category Controllers and veiws to utilize the product service 2026-08-27 00:55:56 +03:00
83 changed files with 3692 additions and 2979 deletions
+3
View File
@@ -19,6 +19,9 @@
/public/sitemap.xml
/public/logos/core
/public/storage
/public/css/filament
/public/js/filament
/public/fonts/filament
/storage/*.key
/storage/framework/migrated
/storage/pail
+5 -7
View File
@@ -119,13 +119,11 @@ COPY docker/php/php.dev.ini /etc/php/8.5/cli/conf.d/99-app.ini
WORKDIR /var/www/html
COPY composer.json composer.lock ./
RUN composer install \
--no-interaction \
--no-scripts \
--prefer-dist \
--ignore-platform-reqs
# No build-time `composer install` here: composer.json's boboko/core path repo
# (../boboko-core) isn't visible in the build context, only once bind-mounted at
# container start — entrypoint.sh already runs composer install +
# composer update boboko/* on every boot, so this would be redundant even if it
# could work.
COPY docker/entrypoint.sh /entrypoint.sh
COPY docker/entrypoint-worker.sh /entrypoint-worker.sh
RUN chmod +x /entrypoint.sh /entrypoint-worker.sh
+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']]),
];
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Catalog;
use Illuminate\Http\Request;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Enums\ProductSort;
/**
* 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 has no query string at all.
*/
final class ProductListing
{
private function __construct(
public readonly ?ProductSort $sort,
public readonly ?float $minPrice,
public readonly ?float $maxPrice,
public readonly bool $inStockOnly,
public readonly int $page,
) {}
public static function fromRequest(Request $request): self
{
return new self(
sort: ProductSort::tryFrom((string) $request->query('sort')),
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)),
);
}
/**
* @param ?int $collectionId scope to a collection (category page); null = every product
*/
public function filters(?int $collectionId = null): ProductFilters
{
return new ProductFilters(
collectionId: $collectionId,
minPrice: $this->minPrice,
maxPrice: $this->maxPrice,
inStockOnly: $this->inStockOnly,
);
}
/**
* The applied params as a clean array (defaults omitted), with `$overrides`
* merged on top — pass `['key' => null]` to drop one.
*
* @param array<string, string|int|null> $overrides
* @return array<string, string|int>
*/
public function query(array $overrides = []): array
{
return array_filter([
'sort' => $this->sort?->value,
'price_min' => $this->minPrice,
'price_max' => $this->maxPrice,
'in_stock' => $this->inStockOnly ? 1 : null,
'page' => $this->page > 1 ? $this->page : null,
...$overrides,
], fn ($value) => $value !== null);
}
/**
* Whether the listing is reordered/narrowed enough that it shouldn't be
* 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
{
return $this->sort !== null
|| $this->minPrice !== null
|| $this->maxPrice !== null
|| $this->page > 1;
}
private static function floatOrNull(mixed $value): ?float
{
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));
}
}
+16 -35
View File
@@ -2,50 +2,31 @@
namespace App\Http\Controllers;
use Illuminate\Pagination\LengthAwarePaginator;
use Lunar\Models\Collection;
use Modules\Core\Catalog\ProductFilters;
use Modules\Core\Catalog\ProductService;
use App\Catalog\ProductListing;
use App\Catalog\ProductListingPage;
use Illuminate\Http\Response;
use Modules\Core\Catalog\Services\CollectionService;
class CategoryController extends Controller
{
public function __construct(
private readonly ProductService $products,
private readonly ProductListingPage $listingPage,
private readonly CollectionService $collections,
) {}
public function show(string $locale, Collection $collection)
public function show(string $locale, int $collection)
{
$perPage = 12;
$page = (int) request('page', 1);
$collectionData = $this->collections->getById($collection);
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
// Listing/filtering reads from the Meilisearch index via ProductService,
// not Eloquent — see Modules\Core\Catalog\ProductService. It returns plain
// arrays (already localized/flattened), not Product models.
$result = $this->products->list(
filters: new ProductFilters(collectionId: $collection->id),
perPage: $perPage,
page: $page,
$listing = ProductListing::fromRequest(request());
$data = $this->listingPage->build(
$listing,
fn (array $query) => route('category.show', ['id' => $collectionData['id']] + $query),
$collectionData['id'],
);
$products = new LengthAwarePaginator(
items: collect($result['data'])->map(fn (array $product) => [
'name' => $product['name'],
'price' => $product['price'],
'image' => $product['media'][0]['url'] ?? null,
'href' => route('product.show', ['product' => $product['id']]),
]),
total: $result['meta']['total'],
perPage: $result['meta']['per_page'],
currentPage: $result['meta']['current_page'],
options: [
'path' => request()->url(),
'query' => request()->query(),
],
);
return view('category.show', [
'collection' => $collection,
'products' => $products,
]);
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', ['product' => $product]),
]);
$products = collect($this->products->random(13))
->map(fn (array $product) => ProductCard::fromIndexed($product));
return view('home', [
'page' => $page,
+35 -28
View File
@@ -2,44 +2,51 @@
namespace App\Http\Controllers;
use Lunar\Models\Product;
use App\Catalog\ProductListing;
use App\Catalog\ProductListingPage;
use Illuminate\Http\Response;
use Modules\Core\Catalog\Services\ProductService;
class ProductController extends Controller
{
public function show(string $locale, Product $product)
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)
{
$product->load([
"variants.prices.currency",
"variants.values.option",
"media",
"collections",
]);
$listing = ProductListing::fromRequest(request());
$option = $product->variants->first()?->values->first()?->option;
$data = $this->listingPage->build(
$listing,
fn (array $query) => route('products', $query),
);
$variantsData = $product->variants
->map(
fn($v) => [
"id" => $v->id,
"price" => $v->prices->first()?->price->decimal,
"image" => null, // variant-level media not differentiated yet
],
)
->values()
->toArray();
return view('products.index', $data);
}
$firstImage = $product->media->first()?->getUrl();
public function show(string $locale, int $id)
{
$product = $this->products->getById($id);
abort_if($product === null, Response::HTTP_NOT_FOUND);
// temp categories here
$categories = \Lunar\Models\Collection::orderBy("_lft")->get();
$collection = $product['collections'][0] ?? null;
// dd($product);
$variantsData = $this->products->variantSummaries($product);
return view("product.show", [
"categories" => $categories,
"product" => $product,
"option" => $option,
"variantsData" => $variantsData,
$firstVariant = $product['variants'][0] ?? null;
$option = $firstVariant['options'][0]['option'] ?? null;
return view('product.show', [
'collection' => $collection,
'product' => $product,
'option' => $option,
'variantsData' => $variantsData,
]);
}
}
+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]);
}
}
+16
View File
@@ -6,9 +6,14 @@
use App\Models\Staff;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as ViewInstance;
use Lunar\Facades\ModelManifest;
use Lunar\Facades\Telemetry;
use Modules\Core\Catalog\DTOs\CollectionFilters;
use Modules\Core\Catalog\Enums\CollectionSort;
use Modules\Core\Catalog\Services\CollectionService;
class AppServiceProvider extends ServiceProvider
{
@@ -16,6 +21,17 @@ public function boot(): void
{
Telemetry::optOut();
// header.blade.php's category dropdown — root collections only, resolved
// per-request so the composer runs after `locale` middleware has already
// set App::getLocale(), which CollectionService's name resolution depends on.
View::composer('components.header', function (ViewInstance $view) {
$view->with('categories', app(CollectionService::class)->list(
filters: new CollectionFilters(rootOnly: true),
perPage: 100,
sort: CollectionSort::Position,
)->items());
});
if ($this->app->environment('production')) {
URL::forceScheme('https');
}
Generated
+1079 -1648
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -54,6 +54,7 @@
*/
'cart_lines' => [
Lunar\Pipelines\CartLine\GetUnitPrice::class,
Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice::class,
],
],
+1 -1
View File
@@ -44,5 +44,5 @@
| Determines whether the cart sholud be soft deleted when the user logs out.
|
*/
'delete_on_forget' => true,
'delete_on_forget' => false,
];
+3
View File
@@ -1,5 +1,7 @@
<?php
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
return [
'default' => env('PAYMENTS_TYPE', 'cash-in-hand'),
@@ -7,6 +9,7 @@
'types' => [
'cash-in-hand' => [
'driver' => 'offline',
'payment_driver' => OfflinePaymentDriver::class,
'authorized' => 'payment-offline',
],
],
+2 -2
View File
@@ -46,10 +46,10 @@
'indexers' => [
Lunar\Models\Brand::class => Lunar\Search\BrandIndexer::class,
Lunar\Models\Collection::class => Lunar\Search\CollectionIndexer::class,
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class,
Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class,
Lunar\Models\Product::class => Modules\Core\Search\ProductIndexer::class,
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class,
],
+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}
+28 -7
View File
@@ -5,17 +5,38 @@ set -e
# by app only) — just wait for app's migration to finish, then start the process.
mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs storage/framework bootstrap/cache
if [ "$APP_ENV" != "production" ]; then
echo "[entrypoint] Waiting for migrations to complete..."
timeout=60
while [ ! -f storage/framework/migrated ] && [ "$timeout" -gt 0 ]; do
# Same reasoning as entrypoint.sh: this container gets replaced on every deploy
# (dev and production alike), so this is a fresh boot clearing stale artifacts
# left on disk (cached config, compiled views), not a running process being
# told to forget in-memory code. queue:work/schedule:work themselves still
# can't pick up a later code change without an actual process restart — this
# only fixes what's stale on disk at boot.
echo "[entrypoint] Clearing cached config/routes/views..."
php artisan optimize:clear --quiet
echo "[entrypoint] Caches cleared"
# Universal, in both dev and production — app's entrypoint always writes this
# marker after `migrate --force` completes (single app instance, so there's
# exactly one writer), and queue/scheduler must never start against a database
# schema that migration hasn't finished bringing up to date yet.
echo "[entrypoint] Waiting for migrations to complete..."
timeout=60
while [ ! -f storage/framework/migrated ] && [ "$timeout" -gt 0 ]; do
sleep 1
timeout=$((timeout - 1))
done
if [ ! -f storage/framework/migrated ]; then
done
if [ ! -f storage/framework/migrated ]; then
echo "[entrypoint] Timed out waiting for migrations" >&2
exit 1
fi
fi
if [ "$APP_ENV" = "production" ]; then
# Same reasoning as entrypoint.sh's own production-only optimize step —
# queue:work/schedule:work read config on every job/tick too, so this
# avoids paying the same uncached-config cost app pays per request.
echo "[entrypoint] Caching config/routes/views for production..."
php artisan optimize --quiet
echo "[entrypoint] Production caches built"
fi
exec "$@"
+70 -21
View File
@@ -4,6 +4,12 @@ set -e
# Only the app container runs setup; queue/scheduler use entrypoint-worker.sh instead
# and just wait on the migrated marker this script writes below.
if [ "$APP_ENV" != "production" ]; then
# Dev-only: the app dir is bind-mounted from a fresh checkout, so there's no
# image-build step that already installed vendor/ or published assets — this
# container has to do it at boot instead. In production the Dockerfile already
# runs composer install and asset publishing at IMAGE BUILD time (see
# Dockerfile's `production` stage), so repeating them here would be wasteful,
# not just redundant-but-safe.
echo "[entrypoint] Installing Composer dependencies..."
git config --global --add safe.directory /var/www/html 2>/dev/null || true
git config --global --add safe.directory /var/www/boboko-core 2>/dev/null || true
@@ -21,37 +27,80 @@ mkdir -p storage/app/public storage/framework/cache storage/framework/sessions s
chown -R www-data:www-data storage bootstrap/cache
chmod -R 775 storage bootstrap/cache
# Composer packages (dev) may have just changed above, or (production) this is a
# freshly built image — either way, clear any cached config/routes/compiled views
# left over from a previous boot before anything below reads them. Production
# runs a single app instance that gets replaced on every deploy, not a running
# process being told to forget in-memory code — the actual bug this fixes is
# stale artifacts still sitting in bootstrap/cache or storage/framework/views on
# a fresh boot (e.g. after the Lunar 1.5/Filament v4 upgrade, a leftover compiled
# view referenced a class that upgrade removed).
echo "[entrypoint] Clearing cached config/routes/views..."
php artisan optimize:clear --quiet
echo "[entrypoint] Caches cleared"
php artisan storage:link --quiet 2>/dev/null || true
if [ "$APP_ENV" != "production" ]; then
# The app dir is bind-mounted from a fresh checkout, so package assets (which the
# Dockerfile publishes at build time in production) need to be generated here
# instead. Cheap and idempotent, safe to repeat on every boot.
# Dev-only for the same reason as the composer step above — production's
# image already has these published at build time.
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
echo "[entrypoint] Running migrations..."
rm -f storage/framework/migrated
# php artisan migrate --force
echo "[entrypoint] Touching migrated file"
touch storage/framework/migrated
echo "[entrypoint] Touched migrated file"
# 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
# that must be current on every boot, not a build-time concern composer/assets
# are. Safe to run unconditionally on every boot because production runs a
# single app instance — no concurrent replicas that would race each other
# running `migrate --force` at the same time.
# boboko/core overrides lunar:install to skip the interactive prompts (migrate
# confirm, admin creation, GitHub star) and just seed the idempotent store
# defaults: countries, channel, language, currency, tax zone, attributes,
# product type. queue/scheduler wait on the marker above rather than running
# this themselves, since the country import's check-then-insert isn't safe to
# run concurrently.
echo "[entrypoint] Trying Lunar install"
php artisan lunar:install --quiet || true
echo "[entrypoint] Running migrations..."
rm -f storage/framework/migrated
php artisan migrate --force
echo "[entrypoint] Touching migrated file"
touch storage/framework/migrated
echo "[entrypoint] Touched migrated file"
# Upserts by primary key (no --refresh), so this stays cheap and idempotent on
# every boot rather than flushing and rebuilding the whole index each time.
echo "[entrypoint] Syncing search indexes..."
php artisan lunar:search:index --quiet || true
# boboko/core overrides lunar:install to skip the interactive prompts (migrate
# confirm, admin creation, GitHub star) and just seed the idempotent store
# defaults: countries, channel, language, currency, tax zone, attributes,
# product type. queue/scheduler wait on the marker above rather than running
# this themselves, since the country import's check-then-insert isn't safe to
# run concurrently. A fresh production install needs this seeding the same as
# a fresh dev one does — and re-running it against an already-seeded store is
# a no-op per key (see InstallLunarCommand's idempotent upserts).
echo "[entrypoint] Trying Lunar install"
php artisan lunar:install --quiet || true
# Upserts by primary key (no --refresh), so this stays cheap and idempotent on
# every boot rather than flushing and rebuilding the whole index each time. Runs
# in production too — a deploy that changed an indexer's field list needs this
# to keep Meilisearch's index settings and documents in sync with the code that
# 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
# The counterpart to optimize:clear above: config/routes/views/events get
# compiled once here, at the end of boot, after everything that could
# change them (migrations, lunar:install, index sync) has already run —
# so production actually gets the request-time performance win caching is
# for, rather than staying permanently uncached. Dev deliberately never
# does this: caching config here would mean .env/config edits stop taking
# effect until the next optimize:clear, which is the opposite of what dev
# needs on every iteration.
echo "[entrypoint] Caching config/routes/views for production..."
php artisan optimize --quiet
echo "[entrypoint] Production caches built"
fi
exec "$@"
-44
View File
@@ -1,44 +0,0 @@
<?php
return [
'nav' => [
'home' => 'Αρχική',
'products' => 'Προϊόντα',
'contact' => 'Επικοινωνία',
],
'product' => [
'description' => 'Περιγραφή',
'reviews' => 'Αξιολογήσεις',
],
// Laravel pluralization: {0} zero|{1} one|[2,*] many. :count is replaced automatically.
'customer_reviews' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
'shop' => [
// Laravel pluralization keyed on the total result count.
'showing_results' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
'no_products' => 'Δεν βρέθηκαν προϊόντα σε αυτή την κατηγορία.',
'search_label' => 'Αναζήτηση προϊόντων',
'search_placeholder' => 'Αναζήτησε προϊόντα…',
'filter_price' => 'Φίλτρο τιμής',
'apply' => 'Εφαρμογή',
'availability' => 'Διαθεσιμότητα',
'in_stock_only' => 'Μόνο διαθέσιμα προϊόντα',
'sort_label' => 'Ταξινόμηση προϊόντων',
'sort_default' => 'Προεπιλεγμένη ταξινόμηση',
'sort_popularity' => 'Δημοφιλή',
'sort_price_asc' => 'Τιμή: Αύξουσα',
'sort_price_desc' => 'Τιμή: Φθίνουσα',
'sort_newest' => 'Νεότερα',
],
'pagination' => [
'nav_label' => 'Σελιδοποίηση',
'page' => 'Σελίδα :page',
'next' => 'Επόμενη σελίδα',
'previous' => 'Προηγούμενη σελίδα',
],
];
-44
View File
@@ -1,44 +0,0 @@
<?php
return [
'nav' => [
'home' => 'Home',
'products' => 'Products',
'contact' => 'Contact',
],
'product' => [
'description' => 'Description',
'reviews' => 'Reviews',
],
// Laravel pluralization: {0} zero|{1} one|[2,*] many. :count is replaced automatically.
'customer_reviews' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
'shop' => [
// Laravel pluralization keyed on the total result count.
'showing_results' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
'no_products' => 'No products found in this category.',
'search_label' => 'Search products',
'search_placeholder' => 'Search products…',
'filter_price' => 'Filter by price',
'apply' => 'Apply',
'availability' => 'Availability',
'in_stock_only' => 'In-stock products only',
'sort_label' => 'Sort products',
'sort_default' => 'Default sorting',
'sort_popularity' => 'Popularity',
'sort_price_asc' => 'Price: Low to High',
'sort_price_desc' => 'Price: High to Low',
'sort_newest' => 'Newest',
],
'pagination' => [
'nav_label' => 'Pagination',
'page' => 'Page :page',
'next' => 'Next page',
'previous' => 'Previous page',
],
];
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
.fi-pagination-items,.fi-pagination-overview,.fi-pagination-records-per-page-select:not(.fi-compact){display:none}@supports (container-type:inline-size){.fi-pagination{container-type:inline-size}@container (min-width: 28rem){.fi-pagination-records-per-page-select.fi-compact{display:none}.fi-pagination-records-per-page-select:not(.fi-compact){display:inline}}@container (min-width: 56rem){.fi-pagination:not(.fi-simple)>.fi-pagination-previous-btn{display:none}.fi-pagination-overview{display:inline}.fi-pagination:not(.fi-simple)>.fi-pagination-next-btn{display:none}.fi-pagination-items{display:flex}}}@supports not (container-type:inline-size){@media(min-width:640px){.fi-pagination-records-per-page-select.fi-compact{display:none}.fi-pagination-records-per-page-select:not(.fi-compact){display:inline}}@media(min-width:768px){.fi-pagination:not(.fi-simple)>.fi-pagination-previous-btn{display:none}.fi-pagination-overview{display:inline}.fi-pagination:not(.fi-simple)>.fi-pagination-next-btn{display:none}.fi-pagination-items{display:flex}}}.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;white-space:normal;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1}.tippy-box[data-theme~=light]{color:#26323d;box-shadow:0 0 20px 4px #9aa1b126,0 4px 80px -8px #24282f40,0 4px 4px -2px #5b5e6926;background-color:#fff}.tippy-box[data-theme~=light][data-placement^=top]>.tippy-arrow:before{border-top-color:#fff}.tippy-box[data-theme~=light][data-placement^=bottom]>.tippy-arrow:before{border-bottom-color:#fff}.tippy-box[data-theme~=light][data-placement^=left]>.tippy-arrow:before{border-left-color:#fff}.tippy-box[data-theme~=light][data-placement^=right]>.tippy-arrow:before{border-right-color:#fff}.tippy-box[data-theme~=light]>.tippy-backdrop{background-color:#fff}.tippy-box[data-theme~=light]>.tippy-svg-arrow{fill:#fff}.fi-sortable-ghost{opacity:.3}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
function r({state:o}){return{state:o,rows:[],shouldUpdateRows:!0,init:function(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(t,e)=>{let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(t)===0&&s(e)===0||this.updateRows()})},addRow:function(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow:function(t){this.rows.splice(t,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows:function(t){let e=Alpine.raw(this.rows);this.rows=[];let s=e.splice(t.oldIndex,1)[0];e.splice(t.newIndex,0,s),this.$nextTick(()=>{this.rows=e,this.updateState()})},updateRows:function(){if(!this.shouldUpdateRows){this.shouldUpdateRows=!0;return}let t=[];for(let[e,s]of Object.entries(this.state??{}))t.push({key:e,value:s});this.rows=t},updateState:function(){let t={};this.rows.forEach(e=>{e.key===""||e.key===null||(t[e.key]=e.value)}),this.shouldUpdateRows=!1,this.state=t}}}export{r as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
function i({state:a,splitKeys:n}){return{newTag:"",state:a,createTag:function(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag:function(t){this.state=this.state.filter(e=>e!==t)},reorderTags:function(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(t){["Enter",...n].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(n.length===0){this.createTag();return}let t=n.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{i as default};
@@ -1 +0,0 @@
function r({initialHeight:t,shouldAutosize:i,state:s}){return{state:s,wrapperEl:null,init:function(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight:function(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=t+"rem")},resize:function(){if(this.setInitialHeight(),this.$el.scrollHeight<=0)return;let e=this.$el.scrollHeight+"px";this.wrapperEl.style.height!==e&&(this.wrapperEl.style.height=e)},setUpResizeObserver:function(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
function d(){return{checkboxClickController:null,collapsedGroups:[],isLoading:!1,selectedRecords:[],shouldCheckUniqueSelection:!0,lastCheckedRecord:null,livewireId:null,init:function(){this.livewireId=this.$root.closest("[wire\\:id]").attributes["wire:id"].value,this.$wire.$on("deselectAllTableRecords",()=>this.deselectAllRecords()),this.$watch("selectedRecords",()=>{if(!this.shouldCheckUniqueSelection){this.shouldCheckUniqueSelection=!0;return}this.selectedRecords=[...new Set(this.selectedRecords)],this.shouldCheckUniqueSelection=!1}),this.$nextTick(()=>this.watchForCheckboxClicks()),Livewire.hook("element.init",({component:e})=>{e.id===this.livewireId&&this.watchForCheckboxClicks()})},mountAction:function(e,t=null){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableAction(e,t)},mountBulkAction:function(e){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableBulkAction(e)},toggleSelectRecordsOnPage:function(){let e=this.getRecordsOnPage();if(this.areRecordsSelected(e)){this.deselectRecords(e);return}this.selectRecords(e)},toggleSelectRecordsInGroup:async function(e){this.isLoading=!0;let t=await this.$wire.getGroupedSelectableTableRecordKeys(e);this.areRecordsSelected(this.getRecordsInGroupOnPage(e))?this.deselectRecords(t):this.selectRecords(t),this.isLoading=!1},getRecordsInGroupOnPage:function(e){let t=[];for(let s of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])s.dataset.group===e&&t.push(s.value);return t},getRecordsOnPage:function(){let e=[];for(let t of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])e.push(t.value);return e},selectRecords:function(e){for(let t of e)this.isRecordSelected(t)||this.selectedRecords.push(t)},deselectRecords:function(e){for(let t of e){let s=this.selectedRecords.indexOf(t);s!==-1&&this.selectedRecords.splice(s,1)}},selectAllRecords:async function(){this.isLoading=!0,this.selectedRecords=await this.$wire.getAllSelectableTableRecordKeys(),this.isLoading=!1},deselectAllRecords:function(){this.selectedRecords=[]},isRecordSelected:function(e){return this.selectedRecords.includes(e)},areRecordsSelected:function(e){return e.every(t=>this.isRecordSelected(t))},toggleCollapseGroup:function(e){if(this.isGroupCollapsed(e)){this.collapsedGroups.splice(this.collapsedGroups.indexOf(e),1);return}this.collapsedGroups.push(e)},isGroupCollapsed:function(e){return this.collapsedGroups.includes(e)},resetCollapsedGroups:function(){this.collapsedGroups=[]},watchForCheckboxClicks:function(){this.checkboxClickController&&this.checkboxClickController.abort(),this.checkboxClickController=new AbortController;let{signal:e}=this.checkboxClickController;this.$root?.addEventListener("click",t=>t.target?.matches(".fi-ta-record-checkbox")&&this.handleCheckboxClick(t,t.target),{signal:e})},handleCheckboxClick:function(e,t){if(!this.lastChecked){this.lastChecked=t;return}if(e.shiftKey){let s=Array.from(this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[]);if(!s.includes(this.lastChecked)){this.lastChecked=t;return}let l=s.indexOf(this.lastChecked),r=s.indexOf(t),o=[l,r].sort((c,n)=>c-n),i=[];for(let c=o[0];c<=o[1];c++)s[c].checked=t.checked,i.push(s[c].value);t.checked?this.selectRecords(i):this.deselectRecords(i)}this.lastChecked=t}}}export{d as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+30 -1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "./fonts.css";
@import "./dropdown.css";
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@@ -20,7 +21,7 @@ @theme {
--text-h1: 60px;
--text-h2: 48px;
--text-h3: 36px;
--text-h4: 27px;
--text-h4: 26px;
}
/* ═══════════════════════════════════════════════════════════════════
@@ -43,6 +44,12 @@ @layer base {
button:not(:disabled) {
cursor: pointer;
}
/* <turbo-frame> is a custom element — inline by default. Give it a box so
the grid it wraps on the category page lays out normally. */
turbo-frame {
display: block;
}
}
@keyframes spin {
@@ -206,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);
+40
View File
@@ -0,0 +1,40 @@
/* ── Dropdown (Popover API) ──────────────────────────────────── */
/* The panel is a [popover] → it renders in the top layer, so its
containing block is the viewport, not the .dropdown wrapper, and
CSS alone can't tie it to the trigger (anchor positioning isn't
everywhere yet). The `dropdown` Stimulus controller measures the
trigger on open and writes --dropdown-top/left/width here; the
open/close animation below stays pure CSS. Opens on a click, so
animating transform + opacity is CLS-safe. */
.dropdown-panel {
top: var(--dropdown-top, 0);
left: var(--dropdown-left, 0);
min-width: var(--dropdown-width, 0);
opacity: 0;
transform: translateY(-4px);
transition:
opacity 0.2s ease,
transform 0.2s ease,
display 0.2s allow-discrete,
overlay 0.2s allow-discrete;
}
.dropdown-panel:popover-open {
opacity: 1;
transform: translateY(-1px);
}
@starting-style {
.dropdown-panel:popover-open {
opacity: 0;
transform: translateY(-4px);
}
}
/* Caret flips while the panel is open — :has() is the only route
back up from the popover's :popover-open state to the caret. */
.dropdown:has(.dropdown-panel:popover-open) .dropdown-caret {
transform: rotate(180deg);
}
+7
View File
@@ -1,5 +1,12 @@
import "./bootstrap";
import "./utils/strip-accents";
import "./utils/refresh-scroll";
// Frames only — no site-wide Turbo Drive. <turbo-frame> navigations still work
// (that's how the category listing reloads); every other link and form on the
// site keeps its normal full-page browser behaviour.
import "@hotwired/turbo";
window.Turbo.session.drive = false;
import { Application } from "@hotwired/stimulus";
import { registerControllers } from "./stimulus/index";
@@ -0,0 +1,27 @@
import { Controller } from '@hotwired/stimulus'
// Submits the host <form> a short beat after a control inside it changes,
// coalescing a burst — rapid slider nudges, or holding an arrow key on a range
// input — into a single submit. Wire it on the <form>:
//
// <form data-controller="auto-submit"
// data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
// data-auto-submit-delay-value="300"> (delay optional, ms)
//
// `change` covers native inputs (checkbox, select); the range slider emits its
// own `range-slider:change` on commit. Uses requestSubmit() (not submit()) so a
// <turbo-frame> around the form still captures the navigation and validation runs.
export default class extends Controller {
static values = { delay: { type: Number, default: 300 } }
submit() {
clearTimeout(this.#timer)
this.#timer = setTimeout(() => this.element.requestSubmit(), this.delayValue)
}
disconnect() {
clearTimeout(this.#timer)
}
#timer
}
@@ -1,17 +0,0 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['menu']
open() {
this.menuTarget.classList.add('is-open')
}
close() {
this.menuTarget.classList.remove('is-open')
}
toggle() {
this.menuTarget.classList.toggle('is-open')
}
}
@@ -0,0 +1,27 @@
import { Controller } from '@hotwired/stimulus'
// Positions the popover panel directly under its trigger.
//
// A [popover] renders in the top layer, so its containing block is the
// viewport, not the .dropdown wrapper — CSS alone can't tie it to the trigger
// without anchor positioning, which isn't in every browser yet. So on each
// open we measure the trigger and write the geometry to CSS custom properties
// that .dropdown-panel consumes (top / left / min-width). The open/close
// animation stays entirely in CSS; the controller only feeds it three numbers.
export default class extends Controller {
static targets = ['trigger', 'panel']
// Wired to `click->dropdown#position` on the trigger, which also fires for
// keyboard activation (Enter/Space on a <button>), so this runs before the
// native popover toggle paints the panel.
position() {
const rect = this.triggerTarget.getBoundingClientRect()
const style = this.panelTarget.style
style.setProperty('--dropdown-top', `${rect.bottom + window.scrollY}px`)
style.setProperty('--dropdown-left', `${rect.left + window.scrollX}px`)
style.setProperty('--dropdown-width', `${rect.width}px`)
}
}
@@ -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' })
}
}
+10
View File
@@ -4,21 +4,31 @@
// application.register('hello', HelloController);
import AppearController from './appear-controller'
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'
import RangeSliderController from './range-slider-controller'
import StarRatingController from './star-rating-controller'
import TabsController from './tabs-controller'
export function registerControllers(application) {
application.register('appear', AppearController)
application.register('auto-submit', AutoSubmitController)
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)
application.register('range-slider', RangeSliderController)
application.register('star-rating', StarRatingController)
application.register('tabs', TabsController)
}
@@ -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())
}
}
@@ -0,0 +1,166 @@
import { Controller } from '@hotwired/stimulus'
// Dual-thumb range slider.
//
// Two real <input type="range"> elements stay authoritative — they carry the
// value, the form data, native keyboard support and the no-JS fallback. On
// connect this controller hides their <label>s and mirrors their state onto a
// presentational track: a baseline, a filled span between the two carets, and
// the carets themselves, all positioned with the --min / --max percentage
// custom properties written on the track element.
//
// Pointer drag moves the carets (writing back to the inputs); the keyboard
// drives the inputs directly. Values can't cross — min stays one step below
// max and vice versa. Emits `range-slider:input` while dragging and
// `range-slider:change` on commit, both with { min, max }.
export default class extends Controller {
static targets = ['minInput', 'maxInput', 'field', 'track', 'minThumb', 'maxThumb', 'output']
static values = {
min: Number,
max: Number,
step: { type: Number, default: 1 },
prefix: { type: String, default: '' },
suffix: { type: String, default: '' },
separator: { type: String, default: ' – ' },
}
connect() {
this.#clamp()
this.fieldTargets.forEach((field) => field.classList.add('sr-only'))
this.#render()
}
disconnect() {
this.#stopDrag()
}
// ── keyboard / programmatic ──────────────────────────────────────
onInput(event) {
this.#clamp(this.#side(event.target))
this.#render()
this.#emit('input')
}
onChange(event) {
this.#clamp(this.#side(event.target))
this.#render()
this.#emit('change')
}
// The real inputs are visually hidden, so mirror their focus ring onto
// the matching caret to keep a visible focus indicator for keyboard use.
syncFocus(event) {
const thumb = event.target === this.minInputTarget ? this.minThumbTarget : this.maxThumbTarget
thumb.classList.toggle('ring-2', event.type === 'focus')
thumb.classList.toggle('ring-black', event.type === 'focus')
}
// ── pointer drag ────────────────────────────────────────────────────
thumbPointerDown(event) {
const input = event.currentTarget === this.minThumbTarget ? this.minInputTarget : this.maxInputTarget
this.#startDrag(event, input)
}
trackPointerDown(event) {
if (event.target.closest('button')) return // a caret handles its own press
const value = this.#valueAt(event.clientX)
const input = Math.abs(value - this.#lo) <= Math.abs(value - this.#hi)
? this.minInputTarget
: this.maxInputTarget
input.value = value
this.#clamp(this.#side(input))
this.#render()
this.#startDrag(event, input)
}
// ── internals ──────────────────────────────────────────────────────
#startDrag(event, input) {
event.preventDefault()
this.#stopDrag()
const side = this.#side(input)
this.#onMove = (e) => {
input.value = this.#valueAt(e.clientX)
this.#clamp(side)
this.#render()
this.#emit('input')
}
this.#onUp = () => {
this.#stopDrag()
this.#emit('change')
}
window.addEventListener('pointermove', this.#onMove)
window.addEventListener('pointerup', this.#onUp)
}
#side(input) {
return input === this.maxInputTarget ? 'max' : 'min'
}
#stopDrag() {
if (this.#onMove) window.removeEventListener('pointermove', this.#onMove)
if (this.#onUp) window.removeEventListener('pointerup', this.#onUp)
this.#onMove = this.#onUp = null
}
get #lo() { return Number(this.minInputTarget.value) }
get #hi() { return Number(this.maxInputTarget.value) }
// Keep both thumbs inside the group bounds and stop them crossing. When a
// thumb is being moved (`side`), only that one gives way, so the other
// stays put instead of being dragged along.
#clamp(side = null) {
const gap = this.stepValue
let lo = Math.max(this.minValue, Math.min(this.maxValue, Number(this.minInputTarget.value)))
let hi = Math.max(this.minValue, Math.min(this.maxValue, Number(this.maxInputTarget.value)))
if (side === 'max') hi = Math.max(hi, lo + gap)
else if (side === 'min') lo = Math.min(lo, hi - gap)
else if (lo > hi - gap) lo = hi - gap
this.minInputTarget.value = lo
this.maxInputTarget.value = hi
}
#valueAt(clientX) {
const rect = this.trackTarget.getBoundingClientRect()
const ratio = rect.width ? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) : 0
const raw = this.minValue + ratio * (this.maxValue - this.minValue)
const step = this.stepValue
return Math.round(raw / step) * step
}
#percent(value) {
const span = this.maxValue - this.minValue
return span ? ((value - this.minValue) / span) * 100 : 0
}
#render() {
const lo = this.#lo
const hi = this.#hi
this.trackTarget.style.setProperty('--min', `${this.#percent(lo)}%`)
this.trackTarget.style.setProperty('--max', `${this.#percent(hi)}%`)
if (this.hasOutputTarget) {
const fmt = (v) => `${this.prefixValue}${v}${this.suffixValue}`
this.outputTarget.textContent = fmt(lo) + this.separatorValue + fmt(hi)
}
}
#emit(name) {
const detail = { min: this.#lo, max: this.#hi }
const key = `${detail.min},${detail.max}`
if (name === 'input' && key === this.#lastInputKey) return // no change since last frame
this.#lastInputKey = key
this.dispatch(name, { detail })
}
#onMove = null
#onUp = null
#lastInputKey = null
}
+64
View File
@@ -0,0 +1,64 @@
// Make a refresh land back where you were — accurately.
//
// Turbo Drive is off site-wide (see app.js), so a refresh is a full browser
// load. The browser restores the scroll position early in that load — before
// the Manrope web fonts swap in and reflow the header, <h1> and result count
// above the product grid — so it settles a bit too low. We record the position
// ourselves and re-apply it once the layout has actually stopped moving.
//
// Separately: drop focus on the way out. Otherwise the browser re-focuses
// whatever filter control was active and scrolls it into view on reload, and
// that sidebar stacks below the grid on narrow screens — hence the jump to the
// bottom.
//
// The real fix for the drift is preloading the above-the-fold font weights so
// there's no reflow to chase; this keeps the restore correct until then, and
// harmless after.
const key = 'scrollY:' + location.pathname + location.search
let frame = 0
window.addEventListener(
'scroll',
() => {
if (frame) return
frame = requestAnimationFrame(() => {
frame = 0
try {
sessionStorage.setItem(key, String(Math.round(window.scrollY)))
} catch {}
})
},
{ passive: true },
)
window.addEventListener('pagehide', () => {
const el = document.activeElement
if (el && el !== document.body) el.blur()
})
// Only reloads and back/forward should resume a position; a fresh visit to the
// page starts where it naturally would.
const [nav] = performance.getEntriesByType('navigation')
if (nav && (nav.type === 'reload' || nav.type === 'back_forward')) {
let saved = null
try {
saved = sessionStorage.getItem(key)
} catch {}
if (saved !== null) {
const y = Number(saved)
const apply = () => window.scrollTo(0, y)
window.addEventListener(
'load',
() => {
apply()
// Fonts (and any late above-the-fold image) can still nudge
// layout a frame or two after load — re-apply once they settle.
document.fonts?.ready.then(() => requestAnimationFrame(apply))
},
{ once: true },
)
}
}
+31 -93
View File
@@ -1,107 +1,45 @@
@extends('layouts.app')
@section('title', $collection->translateAttribute('name') . ' — ' . config('app.name'))
@section('description', strip_tags($collection->translateAttribute('description') ?? ''))
@section('title', $collection['name'] . ' — ' . config('app.name'))
@section('description', strip_tags($collection['description'] ?? ''))
@push('seo')
{{-- Filtered / sorted / paged variants all consolidate onto the bare
category URL; the noindex keeps the near-duplicate variants out of the
index while still letting crawlers follow through to the products. --}}
<link rel="canonical" href="{{ route('category.show', ['id' => $collection['id']]) }}">
@if($listing->isRefined())
<meta name="robots" content="noindex,follow">
@endif
@endpush
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-8 py-12">
<div class="max-w-5xl mx-auto pt-26 pb-12">
<div class="flex items-end justify-between gap-6 flex-wrap mb-10">
<h1 class="font-display font-extrabold text-h1">{{ $collection->translateAttribute('name') }}</h1>
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
<h1 class="font-medium text-h2">{{ $collection['name'] }}</h1>
<x-breadcrumb :items="[
['label' => __('general.nav.home'), 'href' => route('home')],
['label' => $collection->translateAttribute('name')],
['label' => __('storefront.nav.home'), 'href' => route('home')],
['label' => $collection['name']],
]" />
</div>
<div class="flex items-center justify-between gap-6 flex-wrap border-b border-black pb-6 mb-10">
<p class="text-neutral-600">
{{ trans_choice('general.shop.showing_results', $products->total(), [
'first' => $products->firstItem() ?? 0,
'last' => $products->lastItem() ?? 0,
'total' => $products->total(),
]) }}
</p>
{{-- Dummy — not wired to real sorting yet --}}
<x-ui.select
:ariaLabel="__('general.shop.sort_label')"
:options="[
['value' => 'default', 'label' => __('general.shop.sort_default')],
['value' => 'popularity', 'label' => __('general.shop.sort_popularity')],
['value' => 'price-asc', 'label' => __('general.shop.sort_price_asc')],
['value' => 'price-desc', 'label' => __('general.shop.sort_price_desc')],
['value' => 'newest', 'label' => __('general.shop.sort_newest')],
]"
value="default"
/>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-12">
{{-- Products --}}
<div>
@if($products->isEmpty())
<p class="text-neutral-500">{{ __('general.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 — search, price and availability filters are dummy for now --}}
<aside class="flex flex-col gap-10">
<div>
<label for="shop-search" class="sr-only">{{ __('general.shop.search_label') }}</label>
<div class="relative">
<x-ui.input
type="search"
id="shop-search"
:placeholder="__('general.shop.search_placeholder')"
class="pr-10"
/>
<button
type="button"
class="absolute right-0 top-1/2 -translate-y-1/2"
aria-label="{{ __('general.shop.search_label') }}"
{{-- Everything that reflects sort / filter / page state lives in this
frame. A sort link or filter submit inside it navigates the frame;
the controller re-renders it straight from the query string, and
data-turbo-action="advance" keeps the address bar in sync so a
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"
data-controller="frame-scroll"
class="scroll-mt-28"
>
<x-ui.icon name="search" :size="20" />
</button>
</div>
</div>
<div class="flex flex-col gap-4">
<h2 class="font-display font-extrabold uppercase">{{ __('general.shop.filter_price') }}</h2>
<div class="flex items-center gap-2" aria-hidden="true">
<x-ui.icon name="arrow-left" :size="20" />
<span class="flex-1 h-px bg-black"></span>
<x-ui.icon name="arrow-right" :size="20" />
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-sm">€10 - €50</span>
<button type="button" class="underline-slide [--slide-h:1px] font-display font-bold italic uppercase text-sm">
{{ __('general.shop.apply') }}
</button>
</div>
</div>
<div class="flex flex-col gap-4">
<h2 class="font-display font-extrabold uppercase">{{ __('general.shop.availability') }}</h2>
<x-ui.checkbox id="shop-in-stock" name="in_stock">
{{ __('general.shop.in_stock_only') }}
</x-ui.checkbox>
</div>
</aside>
</div>
@include('shop.partials.listing')
</turbo-frame>
</div>
@endsection
+16 -8
View File
@@ -11,23 +11,23 @@
{{-- 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">
<span>{{ __('general.nav.products') }}</span>
<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" />
</svg>
</a>
<div class="nav-dropdown">
{{-- @foreach($categories as $category)
<a href="/category/{{ $category->id }}">
{{ $category->translateAttribute('name') }}
@foreach($categories ?? [] as $category)
<a href="{{ route('category.show', ['id' => $category['id']]) }}">
{{ $category['name'] }}
</a>
@endforeach --}}
@endforeach
</div>
</div>
{{-- Contact --}}
<a href="{{ route('contact') }}" class="nav-link uppercase inline-flex items-center gap-1 relative font-display font-extrabold italic text-black no-underline"><span>{{ __('general.nav.contact') }}</span></a>
<a href="{{ route('contact') }}" class="nav-link uppercase inline-flex items-center gap-1 relative font-display font-extrabold italic text-black no-underline"><span>{{ __('storefront.nav.contact') }}</span></a>
</nav>
@@ -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>
@@ -17,7 +17,7 @@
<h2 class="font-display font-extrabold text-h2 mb-10">{{ $title }}</h2>
@endif
<div class="grid {{ $gridCols }} gap-6">
<div class="grid {{ $gridCols }} gap-9">
@foreach($products as $product)
<x-ui.product-card
:name="$product['name']"
@@ -8,13 +8,13 @@ class="flex flex-col gap-8 mt-10"
@csrf
{{-- Rating --}}
<x-ui.field label="Βαθμολογία" for="rating" :required="true">
<x-ui.field :label="__('storefront.review.rating')" for="rating" :required="true">
<div
id="rating"
data-controller="star-rating"
class="flex items-center gap-1.5 text-brand"
role="radiogroup"
aria-label="Βαθμολογία"
aria-label="{{ __('storefront.review.rating') }}"
aria-required="true"
>
<input type="hidden" name="rating" value="0" data-star-rating-target="input">
@@ -24,7 +24,7 @@ class="flex items-center gap-1.5 text-brand"
data-star-rating-target="star"
data-value="{{ $i }}"
data-action="click->star-rating#select mouseenter->star-rating#hover mouseleave->star-rating#leave"
aria-label="{{ $i }} {{ $i === 1 ? 'αστέρι' : 'αστέρια' }}"
aria-label="{{ trans_choice('storefront.review.stars_count', $i, ['count' => $i]) }}"
aria-pressed="false"
>
<svg
@@ -43,24 +43,24 @@ class="w-6 h-6"
</div>
</x-ui.field>
<x-ui.field label="Γράψε μια αξιολόγηση" for="review-content" :required="true">
<x-ui.field :label="__('storefront.review.write_label')" for="review-content" :required="true">
<x-ui.textarea id="review-content" name="content" :required="true" />
</x-ui.field>
<x-ui.field label="Όνομα" for="review-name" labelDescription="Προαιρετικό">
<x-ui.field :label="__('storefront.review.name')" for="review-name" :labelDescription="__('storefront.review.name_optional')">
<x-ui.input id="review-name" name="name" autocomplete="name" />
</x-ui.field>
<x-ui.field label="Email" for="review-email" :required="true" labelDescription="Δεν θα δημοσιευτεί">
<x-ui.field :label="__('storefront.review.email')" for="review-email" :required="true" :labelDescription="__('storefront.review.email_not_published')">
<x-ui.input id="review-email" name="email" type="email" :required="true" autocomplete="email" />
</x-ui.field>
<x-ui.checkbox id="review-save-info" name="save_info" >
<span class="text-sm">Αποθήκευσε το όνομα και το email μου για την επόμενη φορά που θα σχολιάσω.</span>
<span class="text-sm">{{ __('storefront.review.save_info') }}</span>
</x-ui.checkbox>
<div>
<x-ui.button type="submit">Υποβολή</x-ui.button>
<x-ui.button type="submit">{{ __('storefront.review.submit') }}</x-ui.button>
</div>
</form>
@@ -29,7 +29,7 @@ class="flex items-center gap-1.5 text-brand"
@if ($showCount && $count > 0)
<span class="text-sm text-neutral-500">
({{ trans_choice('general.customer_reviews', $count, ['count' => $count]) }})
({{ trans_choice('storefront.customer_reviews', $count, ['count' => $count]) }})
</span>
@endif
@@ -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,29 +1,32 @@
{{-- $variants: array of Modules\Core\Catalog\Services\ProductIndexer's mapVariant()
shape (id, options: [{option, value, meta}], ...) — plain arrays, not Eloquent
models, since this is fed from Modules\Core\Catalog\Services\ProductService. --}}
@props(['variants', 'option' => null])
<div {{ $attributes }}>
@if($option)
<p class="font-bold mb-3 text-sm uppercase tracking-wide">
{{ $option->translate('name') }}: <span data-product-form-target="colorName" class="font-normal normal-case"></span>
{{ $option }}: <span data-product-form-target="colorName" class="font-normal normal-case"></span>
</p>
@endif
<div
class="flex flex-wrap gap-2"
role="group"
aria-label="{{ $option?->translate('name') ?? 'Color' }}"
aria-label="{{ $option ?? 'Color' }}"
>
@foreach($variants as $variant)
@php
$value = $variant->values->first();
$label = $value?->translate('name') ?? '';
$bg = $value?->meta['hex'] ?? '#cccccc';
$value = $variant['options'][0] ?? null;
$label = $value['value'] ?? '';
$bg = $value['meta']['hex'] ?? '#cccccc';
@endphp
<button
type="button"
class="color-swatch"
data-product-form-target="swatch"
data-action="click->product-form#selectVariant"
data-variant-id="{{ $variant->id }}"
data-variant-id="{{ $variant['id'] }}"
style="background-color: {{ $bg }};"
aria-label="{{ $label }}"
aria-pressed="false"
@@ -0,0 +1,42 @@
@props([
'id',
'label' => null,
'ariaLabel' => null,
'triggerClass' => '',
])
<div
data-controller="dropdown"
{{ $attributes->merge(['class' => 'dropdown relative inline-flex items-center']) }}
>
{{-- Popover invoker buttons get implicit aria-expanded / aria-details from
the browser, so only the accessible name needs setting here.
click->dropdown#position measures this button and feeds the panel's
position to CSS before the native popover toggle paints it. --}}
<button
type="button"
popovertarget="{{ $id }}"
popovertargetaction="toggle"
data-dropdown-target="trigger"
data-action="click->dropdown#position"
@if($ariaLabel) aria-label="{{ $ariaLabel }}" @endif
class="bg-transparent border-b border-black pr-10 py-2.5 cursor-pointer focus:outline-none text-left whitespace-nowrap {{ $triggerClass }}"
>
{{ $label }}
</button>
<x-ui.icon
name="arrow-down"
:size="16"
class="dropdown-caret pointer-events-none absolute right-0 transition-transform duration-200"
/>
<div
id="{{ $id }}"
popover
data-dropdown-target="panel"
class="dropdown-panel absolute w-max max-w-xs bg-neutral-200 border border-black"
>
{{ $slot }}
</div>
</div>
@@ -0,0 +1,33 @@
@aware(['id'])
@props([
'href' => null,
'current' => false,
'close' => true,
])
{{--
A single option inside <x-ui.dropdown>.
- Renders a <button> by default, or an <a> when :href is given.
- `close` (button only): also dismiss the panel on click via
popovertargetaction="hide". Any data-action on the same button still
fires — Stimulus handler runs AND the popover closes. Pass :close="false"
for options that shouldn't close the panel (e.g. a multi-select filter).
- `current`: marks the active option (aria-current + bold).
- Everything else (data-action, data-*-param, aria-*, class, …) is forwarded.
`id` comes from the parent <x-ui.dropdown> via @aware.
--}}
@php $tag = $href ? 'a' : 'button'; @endphp
<{{ $tag }}
@if($tag === 'button') type="button" @endif
@if($href) href="{{ $href }}" @endif
@if($close && $tag === 'button') popovertarget="{{ $id }}" popovertargetaction="hide" @endif
@if($current) aria-current="true" @endif
{{ $attributes->merge(['class' => 'block w-full text-left px-5 py-2.5 cursor-pointer transition-colors hover:bg-black hover:text-neutral-200 aria-[current=true]:font-bold']) }}
>
{{ $slot }}
</{{ $tag }}>
@@ -9,21 +9,21 @@
--}}
@if($paginator->hasPages())
<nav aria-label="{{ __('general.pagination.nav_label') }}" {{ $attributes->merge(['class' => 'flex items-center gap-6 font-display font-bold']) }}>
<nav aria-label="{{ __('storefront.pagination.nav_label') }}" {{ $attributes->merge(['class' => 'flex items-center gap-6 font-display font-bold']) }}>
<ol class="flex items-center gap-6">
@foreach($paginator->getUrlRange(1, $paginator->lastPage()) as $page => $url)
<li>
@if($page === $paginator->currentPage())
<span class="underline-slide is-active" aria-current="page">{{ str_pad((string) $page, 2, '0', STR_PAD_LEFT) }}</span>
@else
<a href="{{ $url }}" class="underline-slide" aria-label="{{ __('general.pagination.page', ['page' => $page]) }}">{{ str_pad((string) $page, 2, '0', STR_PAD_LEFT) }}</a>
<a href="{{ $url }}" class="underline-slide" aria-label="{{ __('storefront.pagination.page', ['page' => $page]) }}">{{ str_pad((string) $page, 2, '0', STR_PAD_LEFT) }}</a>
@endif
</li>
@endforeach
</ol>
@if($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" aria-label="{{ __('general.pagination.next') }}">
<a href="{{ $paginator->nextPageUrl() }}" aria-label="{{ __('storefront.pagination.next') }}">
<x-ui.icon name="arrow-right" :size="24" />
</a>
@endif
@@ -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 }}"
@@ -17,18 +22,18 @@ class="w-full h-auto block"
>
@else
<div class="w-full aspect-square bg-neutral-300 flex items-center justify-center text-neutral-500 text-sm">
Χωρίς εικόνα
{{ __('storefront.product.no_image') }}
</div>
@endif
</a>
<x-ui.button size="md" position="absolute" class="opacity-0 group-hover:opacity-100 transition-opacity duration-100">
Προσθήκη στο καλάθι
{{ __('storefront.product.add_to_cart') }}
</x-ui.button>
</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
@@ -0,0 +1,121 @@
@props([
'min',
'max',
'minValue' => null,
'maxValue' => null,
'step' => 1,
'name' => null,
'legend' => 'Range',
'minLabel' => 'Minimum',
'maxLabel' => 'Maximum',
'prefix' => '',
'suffix' => '',
'separator' => ' – ',
])
{{-- The default slot sits on the readout row, to the right of the min–max
text — use it for a "clear"/"reset" control when the slider is filtered.
Left empty otherwise. --}}
@php
$minValue ??= $min;
$maxValue ??= $max;
// Stable when a name is given so focus can survive a re-render; random
// otherwise, just to keep the label/input association unique on the page.
$uid = 'rs-' . ($name ?: uniqid());
@endphp
<div
data-controller="range-slider"
data-range-slider-min-value="{{ $min }}"
data-range-slider-max-value="{{ $max }}"
data-range-slider-step-value="{{ $step }}"
data-range-slider-prefix-value="{{ $prefix }}"
data-range-slider-suffix-value="{{ $suffix }}"
data-range-slider-separator-value="{{ $separator }}"
{{ $attributes->merge(['class' => 'flex flex-col gap-4']) }}
>
<fieldset class="m-0 min-w-0 border-0 p-0">
<legend class="sr-only">{{ $legend }}</legend>
{{-- Real controls — keyboard, assistive tech, form values, no-JS
fallback. The controller adds `sr-only` to each on connect. --}}
<label data-range-slider-target="field" class="flex items-center gap-2 text-sm">
<span>{{ $minLabel }}</span>
<input
type="range"
id="{{ $uid }}-min"
min="{{ $min }}"
max="{{ $max }}"
step="{{ $step }}"
value="{{ $minValue }}"
@if($name) name="{{ $name }}_min" @endif
data-range-slider-target="minInput"
data-action="input->range-slider#onInput change->range-slider#onChange focus->range-slider#syncFocus blur->range-slider#syncFocus"
>
</label>
<label data-range-slider-target="field" class="flex items-center gap-2 text-sm">
<span>{{ $maxLabel }}</span>
<input
type="range"
id="{{ $uid }}-max"
min="{{ $min }}"
max="{{ $max }}"
step="{{ $step }}"
value="{{ $maxValue }}"
@if($name) name="{{ $name }}_max" @endif
data-range-slider-target="maxInput"
data-action="input->range-slider#onInput change->range-slider#onChange focus->range-slider#syncFocus blur->range-slider#syncFocus"
>
</label>
{{-- Presentational track — pointer control + visual state only; the
real controls above own accessibility. --min / --max start at
the extremes so first paint has no jump. --}}
<div
data-range-slider-target="track"
data-action="pointerdown->range-slider#trackPointerDown"
aria-hidden="true"
class="relative h-8 touch-none select-none [--min:0%] [--max:100%]"
>
<span class="pointer-events-none absolute inset-x-0 top-1/2 h-1.25 -translate-y-1/2 bg-neutral-500"></span>
<span class="pointer-events-none absolute top-1/2 left-[var(--min)] right-[calc(100%_-_var(--max))] h-1.25 -translate-y-1/2 bg-black"></span>
<button
type="button"
tabindex="-1"
data-range-slider-target="minThumb"
data-action="pointerdown->range-slider#thumbPointerDown"
class="absolute top-1/2 left-[var(--min)] grid h-8 w-6 -translate-x-1/4 -translate-y-1/2 cursor-grab place-items-center text-black active:cursor-grabbing"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 32" fill="none" aria-hidden="true" class="h-[32px] w-[22px]">
<path d="M17 4 L6 16 L17 28" stroke="currentColor" stroke-width="5" stroke-linecap="square" stroke-linejoin="miter" />
</svg>
</button>
<button
type="button"
tabindex="-1"
data-range-slider-target="maxThumb"
data-action="pointerdown->range-slider#thumbPointerDown"
class="absolute top-1/2 left-[var(--max)] grid h-8 w-6 -translate-x-1/2 -translate-y-1/2 cursor-grab place-items-center text-black active:cursor-grabbing"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 32" fill="none" aria-hidden="true" class="h-[32px] w-[22px]">
<path d="M5 4 L16 16 L5 28" stroke="currentColor" stroke-width="5" stroke-linecap="square" stroke-linejoin="miter" />
</svg>
</button>
</div>
</fieldset>
<div class="flex items-center justify-between gap-4">
{{-- Server-rendered so it's right on first paint and without JS; the
controller rewrites it as the values change. --}}
<span data-range-slider-target="output" aria-hidden="true" class="text-sm tabular-nums"
>{{ $prefix }}{{ $minValue }}{{ $suffix }}{{ $separator }}{{ $prefix }}{{ $maxValue }}{{ $suffix }}</span>
{{ $slot }}
</div>
</div>
+1 -10
View File
@@ -5,21 +5,12 @@
'ariaLabel' => null,
])
{{--
Native <select>, styled to match the site's bordered/uppercase look.
Usage:
<x-ui.select
:options="[['value' => 'a', 'label' => 'Option A'], ...]"
value="a"
ariaLabel="Sort products"
/>
--}}
<div class="relative inline-flex items-center">
<select
@if($name) name="{{ $name }}" @endif
@if($ariaLabel) aria-label="{{ $ariaLabel }}" @endif
{{ $attributes->merge(['class' => 'appearance-none bg-transparent border border-black pl-4 pr-10 py-2.5 font-display font-bold cursor-pointer focus:outline-none']) }}
{{ $attributes->merge(['class' => 'appearance-none bg-transparent border-b border-black pr-10 py-2.5 cursor-pointer focus:outline-none']) }}
>
@foreach($options as $option)
<option value="{{ $option['value'] }}" @selected($value === $option['value'])>{{ $option['label'] }}</option>
+2 -2
View File
@@ -1,6 +1,6 @@
@extends('layouts.app')
@section('title', __('general.nav.contact'))
@section('title', __('storefront.nav.contact'))
@section('description', 'Επικοινώνησε μαζί μας για οποιαδήποτε απορία ή ιδέα έχεις και θα σου απαντήσουμε το συντομότερο δυνατό.')
@section('content')
@@ -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 ">
+3 -3
View File
@@ -56,12 +56,12 @@ class="{{ $loop->first ? '' : 'hidden' }} group h-full flex flex-col justify-cen
class="w-full h-full object-cover"
>
@else
<span class="text-neutral-500 text-sm">Χωρίς εικόνα</span>
<span class="text-neutral-500 text-sm">{{ __('storefront.product.no_image') }}</span>
@endif
</a>
<x-ui.button size="md" position="absolute" class="opacity-0 group-hover:opacity-100 transition-opacity duration-100">
Προσθήκη στο καλάθι
{{ __('storefront.product.add_to_cart') }}
</x-ui.button>
</div>
@@ -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">
+10
View File
@@ -26,6 +26,16 @@
@stack('seo')
{{-- Preload the Manrope weights used in above-the-fold text (body 400,
category <h1> 500, generic headings 700, nav 800) so the swap-in
doesn't reflow the header and headings on load — which otherwise
throws off scroll restoration on refresh. Same URLs as the @font-face
rules in fonts.css, so each is fetched once. --}}
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-regular.woff2') }}">
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-500.woff2') }}">
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-700.woff2') }}">
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-800.woff2') }}">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@auth('staff')
+46 -43
View File
@@ -1,18 +1,16 @@
@extends('layouts.app')
@section('title', $product->translateAttribute('name') . ' — ' . config('app.name'))
@section('description', $product->translateAttribute('description'))
@section('title', $product['name'] . ' — ' . config('app.name'))
@section('description', $product['description'])
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-8 py-12">
@php $collection = $product->collections->first(); @endphp
<x-breadcrumb class="mb-14 justify-end" :items="[
$collection
? ['label' => $collection->translateAttribute('name'), 'href' => route('category.show', ['collection' => $collection])]
: ['label' => __('general.nav.products'), 'href' => '/products'],
['label' => $product->translateAttribute('name')],
? ['label' => $collection['name'], 'href' => route('category.show', ['id' => $collection['id']])]
: ['label' => __('storefront.nav.products'), 'href' => route('products')],
['label' => $product['name']],
]" />
<div
@@ -26,7 +24,7 @@ class="grid grid-cols-1 md:grid-cols-2 gap-12"
data-controller="product-gallery"
class="flex gap-4 items-start"
>
@if($product->media->isNotEmpty())
@if(!empty($product['media']))
{{-- Thumbnails --}}
<div class="flex flex-col items-center gap-1 w-[116px] shrink-0 -mt-12.5">
<button
@@ -41,20 +39,20 @@ class="gallery-arrow w-full flex items-center justify-center py-2"
class="flex flex-col gap-4 overflow-hidden"
style="max-height: var(--gallery-height, 600px)"
>
@foreach($product->media as $i => $media)
@foreach($product['media'] as $i => $media)
<button
type="button"
data-action="click->product-gallery#select"
data-product-gallery-target="thumb"
data-src="{{ $media->getUrl() }}"
data-alt="{{ $product->translateAttribute('name') }}"
data-src="{{ $media['url'] }}"
data-alt="{{ $product['name'] }}"
class="block w-full shrink-0 border border-black overflow-hidden "
aria-label="View image {{ $i + 1 }}"
aria-pressed="{{ $i === 0 ? 'true' : 'false' }}"
>
<!-- opacity-50 transition-opacity {{ $i === 0 ? 'opacity-100' : '' }}" -->
<img src="{{ $media->getUrl() }}" alt="" class="w-full h-auto object-cover" aria-hidden="true">
<img src="{{ $media['url'] }}" alt="" class="w-full h-auto object-cover" aria-hidden="true">
</button>
@endforeach
</div>
@@ -77,8 +75,8 @@ class="flex-1 border border-black bg-white cursor-zoom-in block p-0"
<img
data-product-form-target="image"
data-product-gallery-target="main"
src="{{ $product->media->first()->getUrl() }}"
alt="{{ $product->translateAttribute('name') }}"
src="{{ $product['media'][0]['url'] }}"
alt="{{ $product['name'] }}"
class="w-full h-auto block"
>
</button>
@@ -136,7 +134,7 @@ class="absolute bottom-6 right-8 text-white text-sm"
</div>
@else
<div class="w-full bg-neutral-300 flex items-center justify-center text-neutral-500 min-h-64">
No image
{{ __('storefront.product.no_image') }}
</div>
@endif
</div>
@@ -145,36 +143,36 @@ class="absolute bottom-6 right-8 text-white text-sm"
<div class="flex flex-col gap-6">
<h1 class="font-display font-medium text-4xl lg:text-[54px]">
{{ $product->translateAttribute('name') }}
{{ $product['name'] }}
</h1>
<x-reviews-stars :rating="3" :count="24" :showCount="true" />
<x-reviews-stars :rating="$product['reviews']['average_rating'] ?? 0" :count="$product['reviews']['count']" :showCount="true" />
@if($product->variants->first()?->prices->isNotEmpty())
@if($product['price'] !== null)
<p class="text-2xl font-bold" data-product-form-target="price">
<x-ui.price :amount="$product->variants->first()->prices->first()->price->decimal" />
<x-ui.price :amount="$product['price']" />
</p>
@endif
@php
$desc = strip_tags($product->translateAttribute('description') ?? '');
$desc = strip_tags($product['description'] ?? '');
$descTruncated = Str::limit($desc, 137);
$descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
@endphp
<div class="text-base leading-relaxed">
{{ $descTruncated }}
@if($descNeedsMore)
<a href="#tab-panel-description" class="underline-slide font-semibold whitespace-nowrap">Περισσότερα</a>
<a href="#tab-panel-description" class="underline-slide font-semibold whitespace-nowrap">{{ __('storefront.product.read_more') }}</a>
@endif
</div>
@if($option && $product->variants->count() >= 1)
<x-ui.color-swatch :variants="$product->variants" :option="$option" />
@if($option && !empty($product['variants']))
<x-ui.color-swatch :variants="$product['variants']" :option="$option" />
@endif
<div class="flex items-stretch gap-10">
<x-ui.quantity name="quantity" />
<x-ui.button class="flex-1">Προσθήκη στο καλάθι</x-ui.button>
<x-ui.button class="flex-1">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
</div>
</div>
@@ -182,17 +180,13 @@ class="absolute bottom-6 right-8 text-white text-sm"
</div>
<x-ui.tabs class="mt-16" size="lg" :tabs="[
['id' => 'description', 'label' => __('general.product.description')],
// ['id' => 'reviews', 'label' => __('general.product.reviews') . ' (' . count($reviews) . ')'],
['id' => 'reviews', 'label' => __('general.product.reviews') . ' (3)'],
['id' => 'description', 'label' => __('storefront.product.description')],
['id' => 'reviews', 'label' => __('storefront.product.reviews') . ' (' . $product['reviews']['count'] . ')'],
]">
<x-slot name="description">
<div class="leading-7 [&_p]:mt-4">
{!! $product->translateAttribute('description') !!}
{!! $product['description'] !!}
</div>
@if($product->translateAttribute('details'))
<div class="mt-6">{!! $product->translateAttribute('details') !!}</div>
@endif
<ul class="mt-8 flex flex-col gap-3 text-neutral-600 list-disc list-outside pl-5">
<li>Όλα τα προϊόντα εκτυπώνονται και προετοιμάζονται κατά παραγγελία. Ο χρόνος προετοιμασίας κυμαίνεται μεταξύ 2 και 7 εργάσιμων ημερών.</li>
<li>Όλα τα προϊόντα κατασκευάζονται με τρισδιάστατη εκτύπωση σε ειδικούς εκτυπωτές πλαστικού υλικού. Πιθανώς να έχουν εμφανείς γραμμές ένωσης, στρώσεις εκτύπωσης υλικού και μικρές ατέλειες. Είναι φυσιολογικό για το αποτέλεσμα αυτής της δημιουργικής διαδικασίας.</li>
@@ -200,34 +194,43 @@ class="absolute bottom-6 right-8 text-white text-sm"
</ul>
</x-slot>
<x-slot name="reviews">
{{-- @if(count($reviews) > 0)
@if(!empty($product['reviews']['items']))
<div class="mb-10">
@foreach($reviews as $review)
<x-review-card :review="$review" />
@foreach($product['reviews']['items'] as $review)
<x-review-card :review="[
'rating' => $review['rating'],
'name' => $review['reviewer_name'],
'date' => $review['reviewed_at'] ? \Illuminate\Support\Carbon::createFromTimestamp($review['reviewed_at'])->translatedFormat('d M Y') : '',
'text' => $review['body'],
'image' => $review['media'][0]['url'] ?? null,
]" />
@endforeach
</div>
@else
<p class="text-neutral-500 mb-10">Δεν υπάρχουν αξιολογήσεις ακόμα.</p>
<p class="text-neutral-500 mb-10">{{ __('storefront.review.no_reviews_yet') }}</p>
@endif
<h3 class="text-h4 font-bold">
{{ count($reviews) > 0 ? 'Πρόσθεσε μια' : 'Γράψε την πρώτη' }} αξιολόγηση για το «{{ $product->translateAttribute('name') }}»
</h3> --}}
{{ $product['reviews']['count'] > 0 ? __('storefront.review.write_new') : __('storefront.review.write_first') }}
{{ __('storefront.review.for_product', ['name' => $product['name']]) }}
</h3>
<x-review-form :product="$product" />
</x-slot>
</x-ui.tabs>
@if(!empty($product['recommendations']))
<x-product-grid
class="mt-20"
title="Σχετικά προϊόντα"
:products="[
['name' => 'Camper', 'price' => '30', 'image' => null, 'href' => '#'],
['name' => 'Ponderer IPA','price' => '25', 'image' => null, 'href' => '#'],
['name' => 'Squish Red', 'price' => '35', 'image' => null, 'href' => '#'],
['name' => 'Red Light', 'price' => '25', 'image' => null, 'href' => '#'],
]"
:products="collect($product['recommendations'])->map(fn (array $recommendation) => [
'name' => $recommendation['name'],
'price' => $recommendation['price'],
'image' => $recommendation['image'],
'href' => route('product.show', ['id' => $recommendation['id']]),
])->all()"
/>
@endif
</div>
+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>
+17 -2
View File
@@ -5,21 +5,36 @@
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
// the group below purely to give `locale` middleware a route to run on — the group's
// `Route::prefix('{locale}')` requires a non-empty first segment, so `/` would
// otherwise 404 before the middleware (which already redirects an empty/unrecognized
// locale segment to the resolved default) ever gets a chance to run. Middleware runs
// before controller parameter binding, so this never actually reaches
// HomeController::index()'s required $locale argument — the middleware always
// redirects a request with no matching locale segment first.
Route::get('/', [HomeController::class, 'index'])->middleware('locale');
Route::prefix('{locale}')
->middleware('locale')
->group(function () {
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/products/{product}', [ProductController::class, 'show'])->name(
Route::get('/products', [ProductController::class, 'index'])->name('products');
Route::get('/products/{id}', [ProductController::class, 'show'])->name(
'product.show',
);
Route::get('/category/{collection}', [CategoryController::class, 'show'])->name(
Route::get('/category/{id}', [CategoryController::class, 'show'])->name(
'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(