generated from boboko/starter
Compare commits
22
Commits
65205e2760
..
elv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9abe45c5f7 | ||
|
|
e57bfe3d42 | ||
|
|
72b9abf92f | ||
|
|
1f0612861b | ||
|
|
b2207a622c | ||
|
|
b87e22381f | ||
|
|
53f30ff51a | ||
|
|
210ed3b094 | ||
|
|
5e2ec7a60a | ||
|
|
2d1624bcb2 | ||
|
|
ea6ebe435e | ||
|
|
2d75bb9e01 | ||
|
|
8a07c772f8 | ||
|
|
b070a7d1e6 | ||
|
|
74e554884f | ||
|
|
4387459aed | ||
|
|
adb847f442 | ||
|
|
c7cd1138fe | ||
|
|
defe1dab12 | ||
|
|
86c645fb36 | ||
|
|
0dcaf112be | ||
|
|
b3405c1b60 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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']]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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])),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Catalog;
|
||||
|
||||
use Closure;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
|
||||
/**
|
||||
* Builds the option list for the shared <x-shop.sort> dropdown, so the label
|
||||
* map and "the default sort has no URL param" rule live in one place. Each page
|
||||
* supplies a `$url` closure that turns a sort (or null = default/relevance)
|
||||
* into the right href for that page — category vs. search build their URLs
|
||||
* differently.
|
||||
*/
|
||||
final class ProductSortOptions
|
||||
{
|
||||
/**
|
||||
* @param Closure(?ProductSort): string $url
|
||||
* @return array<int, array{label: string, href: string, current: bool}>
|
||||
*/
|
||||
public static function build(?ProductSort $current, Closure $url): array
|
||||
{
|
||||
$sorts = [
|
||||
null => 'storefront.shop.sort_popularity',
|
||||
ProductSort::PriceAsc->value => 'storefront.shop.sort_price_asc',
|
||||
ProductSort::PriceDesc->value => 'storefront.shop.sort_price_desc',
|
||||
ProductSort::Newest->value => 'storefront.shop.sort_newest',
|
||||
];
|
||||
|
||||
return array_map(function (string $key, string $label) use ($current, $url) {
|
||||
$sort = $key === '' ? null : ProductSort::from($key);
|
||||
|
||||
return [
|
||||
'label' => __($label),
|
||||
'href' => $url($sort),
|
||||
'current' => $sort === $current,
|
||||
];
|
||||
}, array_keys($sorts), array_values($sorts));
|
||||
}
|
||||
}
|
||||
@@ -2,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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,7 @@
|
||||
*/
|
||||
'cart_lines' => [
|
||||
Lunar\Pipelines\CartLine\GetUnitPrice::class,
|
||||
Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
+32
-11
@@ -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
|
||||
sleep 1
|
||||
timeout=$((timeout - 1))
|
||||
done
|
||||
if [ ! -f storage/framework/migrated ]; then
|
||||
echo "[entrypoint] Timed out waiting for migrations" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 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
|
||||
echo "[entrypoint] Timed out waiting for migrations" >&2
|
||||
exit 1
|
||||
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
@@ -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 "$@"
|
||||
|
||||
@@ -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' => 'Προηγούμενη σελίδα',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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 +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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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') }}"
|
||||
>
|
||||
<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>
|
||||
{{-- 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"
|
||||
>
|
||||
@include('shop.partials.listing')
|
||||
</turbo-frame>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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' => '#'],
|
||||
]"
|
||||
/>
|
||||
@if(!empty($product['recommendations']))
|
||||
<x-product-grid
|
||||
class="mt-20"
|
||||
title="Σχετικά προϊόντα"
|
||||
: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>
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', __('storefront.shop.all_products') . ' — ' . config('app.name'))
|
||||
|
||||
@push('seo')
|
||||
{{-- The bare listing is indexable; filtered / sorted / paged variants
|
||||
consolidate onto it and are kept out of the index. --}}
|
||||
<link rel="canonical" href="{{ route('products') }}">
|
||||
@if($listing->isRefined())
|
||||
<meta name="robots" content="noindex,follow">
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-5xl mx-auto pt-26 pb-12">
|
||||
|
||||
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
|
||||
<h1 class="font-medium text-h2">{{ __('storefront.shop.all_products') }}</h1>
|
||||
|
||||
<x-breadcrumb :items="[
|
||||
['label' => __('storefront.nav.home'), 'href' => route('home')],
|
||||
['label' => __('storefront.shop.all_products')],
|
||||
]" />
|
||||
</div>
|
||||
|
||||
{{-- Same reloadable listing body as the category page; sort/filter/page
|
||||
navigate this frame and advance the URL. --}}
|
||||
<turbo-frame
|
||||
id="products-listing"
|
||||
data-turbo-action="advance"
|
||||
data-controller="frame-scroll"
|
||||
class="scroll-mt-28"
|
||||
>
|
||||
@include('shop.partials.listing')
|
||||
</turbo-frame>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user