generated from boboko/starter
Compare commits
29
Commits
master
...
2d1624bcb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d1624bcb2 | ||
|
|
ea6ebe435e | ||
|
|
2d75bb9e01 | ||
|
|
8a07c772f8 | ||
|
|
b070a7d1e6 | ||
|
|
74e554884f | ||
|
|
4387459aed | ||
|
|
adb847f442 | ||
|
|
c7cd1138fe | ||
|
|
defe1dab12 | ||
|
|
86c645fb36 | ||
|
|
0dcaf112be | ||
|
|
b3405c1b60 | ||
|
|
65205e2760 | ||
|
|
36ff25657d | ||
|
|
4923bf82c3 | ||
|
|
325d71bac5 | ||
|
|
525c920f83 | ||
|
|
90e32e337d | ||
|
|
50c9670f2a | ||
|
|
092fab8740 | ||
|
|
fab9cc08a8 | ||
|
|
45057f9083 | ||
|
|
7677d10821 | ||
|
|
e46276a31b | ||
|
|
bf8b9219fc | ||
|
|
b88fde739c | ||
|
|
ace8290c62 | ||
|
|
dbc8866056 |
+7
-1
@@ -8,7 +8,7 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_LOCALE=el
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
@@ -79,6 +79,11 @@ STOIC_SECRET=dev-secret-change-me
|
||||
STOIC_SSO_SECRET=dev-sso-secret-change-me
|
||||
STOIC_HOST=http://localhost:2727
|
||||
|
||||
SCOUT_DRIVER=meilisearch
|
||||
SCOUT_QUEUE=true
|
||||
MEILISEARCH_KEY=dev-meilisearch-key-change-me
|
||||
MEILISEARCH_HOST=http://meilisearch:7700
|
||||
|
||||
# Host-side ports — change these if another project on this machine already uses the defaults
|
||||
APP_PORT=8090
|
||||
STOIC_PORT=2727
|
||||
@@ -86,3 +91,4 @@ VALKEY_PORT=6339
|
||||
POSTGRES_HOST_PORT=5436
|
||||
MAILPIT_PORT=8025
|
||||
VITE_PORT=5173
|
||||
MEILISEARCH_PORT=7700
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# Front-end Guidelines
|
||||
|
||||
This is a Laravel 12 e-commerce project using Lunar PHP (headless). These guidelines apply to all front-end work.
|
||||
|
||||
---
|
||||
|
||||
## Project Language Settings
|
||||
|
||||
<!-- CHANGE THESE PER PROJECT -->
|
||||
- **Greek register:** `singular` — use the informal second person (εσύ/σου/σε). e.g. "Η κριτική σου", "Το όνομά σου", "Το email σου"
|
||||
<!-- singular = informal (εσύ) | plural = formal (εσείς) -->
|
||||
|
||||
All UI text written for this project must follow the register above.
|
||||
|
||||
---
|
||||
|
||||
## Localization
|
||||
|
||||
Uses classic Laravel localization (https://laravel.com/docs/13.x/localization) — no third-party i18n routing package.
|
||||
|
||||
**Locales:** Greek (`el`) is primary/default, English (`en`) is secondary. Set in `config('app.available_locales')` (`config/app.php`).
|
||||
|
||||
**URL structure:** every route is prefixed with `{locale}`, including the default — `3dealer.gr/el/...` and `3dealer.gr/en/...`. Bare root (`/`) 301-redirects to `/el` (see `routes/web.php`). This avoids the ambiguity of an unprefixed default locale (see the reasoning in project memory / past conversation — prefixing every locale keeps hreflang symmetrical and scales cleanly to a third language later).
|
||||
|
||||
**URL slugs are the same across both locales, permanently** — e.g. `/el/products/{x}` and `/en/products/{x}`, not `/el/proionta/{x}` vs `/en/products/{x}`. This is a deliberate, standing decision for this project, not a placeholder to revisit later. Only the visible content is translated, not the slug. Do not introduce per-locale translated slugs (e.g. a `lang/{locale}/routes.php` segment map) unless the user explicitly asks for that to change.
|
||||
|
||||
**How it's wired:**
|
||||
- All routes live inside `Route::prefix('{locale}')->where('locale', ...)->middleware('setlocale')` in `routes/web.php`.
|
||||
- `App\Http\Middleware\SetLocale` validates the `{locale}` segment (404s if not in `available_locales`), calls `App::setLocale()`, and shares `$currentLocale`, `$altLocale`, and `$altLocaleUrl` (the same page in the other locale) to every view — this is what powers both the header's language switcher and the `<head>` hreflang tags in `resources/views/layouts/app.blade.php`. Don't recompute this logic elsewhere; read those shared variables instead.
|
||||
- When adding a new route, put it inside that `{locale}` group and give it a route `name()` — the alt-locale URL generation in the middleware depends on the current route being named (falls back to the locale root if unnamed).
|
||||
- When linking to a page in Blade, prefer `route('name', [...])` (locale is a normal route param, defaults to `app()->getLocale()` implicitly via the shared route group) over hand-built path strings, except for pages that don't have a controller/route yet (e.g. `/products`, `/contact`, `/cart` placeholders in the header currently use `url('/'.app()->getLocale().'/products')` — replace with `route()` once those pages exist).
|
||||
- **Every controller action for a route inside the `{locale}` group must declare `$locale` as its first parameter, even if unused** — e.g. `show(string $locale, Product $product)`. Laravel's `ControllerDispatcher` ultimately calls the controller with `...array_values($parameters)`, i.e. **positionally**. If a route-bound model parameter (like `$product`) isn't preceded by a matching `$locale` parameter in the method signature, the resolved values shift out of position and the wrong value (the locale string) gets passed where the model was expected — a `TypeError` that's easy to misread as a binding failure. Closures with zero declared parameters are unaffected (PHP just ignores the extra positional arg), so this only bites real controller methods.
|
||||
|
||||
**Where translatable strings go:**
|
||||
- Most UI copy is editable by the client/marketing team via **Stoic** (the headless CMS) — don't hardcode it here.
|
||||
- Small strings that don't need client editing (nav labels, aria-labels, tab labels, pluralized counts) go in Laravel's own lang files at **`lang/{locale}/*.php`** (project root, per Laravel 12+ convention — not `resources/lang`). Current file: `lang/el/general.php` / `lang/en/general.php`. Use `__('general.key')` or `trans_choice('general.key', $count, [...])` for pluralized strings.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Laravel 12** + **Lunar PHP 1.3** (headless e-commerce)
|
||||
- **Blade** for templating (`@extends`, `@section`, `@yield`, components)
|
||||
- **Tailwind CSS v4** via `@tailwindcss/vite` — zero config, `@import 'tailwindcss'` only
|
||||
- **Stimulus JS** for interactivity — controllers registered in `resources/js/stimulus/index.js`
|
||||
- **Popover API** (native browser) for overlays — no JS overlay libraries
|
||||
- SQLite database, `npm run dev` for local asset compilation
|
||||
|
||||
---
|
||||
|
||||
## Key File Locations
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| CSS entry point | `resources/css/app.css` |
|
||||
| Font definitions | `resources/css/fonts.css` |
|
||||
| JS entry point | `resources/js/app.js` |
|
||||
| Stimulus controllers | `resources/js/stimulus/` |
|
||||
| JS utilities | `resources/js/utils/` |
|
||||
| Layout | `resources/views/layouts/app.blade.php` |
|
||||
| UI components (atomic) | `resources/views/components/ui/` |
|
||||
| Section components | `resources/views/components/` |
|
||||
| Page views | `resources/views/{page}/` |
|
||||
| Public assets | `public/images/` |
|
||||
|
||||
---
|
||||
|
||||
## Design Reference
|
||||
|
||||
The Bluebeard template (`resources/css/bluebeard.css`) is used as a **reference only** for colors, spacing, typography, and effects. It is never imported or used directly. Everything is translated to Tailwind utilities or custom CSS where unavoidable.
|
||||
|
||||
The boboko project at `/Users/farenoubi/Projects/boboko-test` can be referenced for CSS architecture patterns.
|
||||
|
||||
---
|
||||
|
||||
## Tailwind vs. Custom CSS
|
||||
|
||||
**Prefer Tailwind utilities by default.** Write custom CSS only when something genuinely cannot be expressed as a utility:
|
||||
|
||||
- Pseudo-elements (`::before`, `::after`) with dynamic transforms or transitions
|
||||
- Complex descendant/sibling selectors (e.g. `.group:hover .nav-dropdown`)
|
||||
- Keyframe animations
|
||||
- The underline-slide animation on nav links (background-size trick)
|
||||
|
||||
**Never** write inline CSS (`style="..."`). **Never** write custom CSS for something Tailwind already covers.
|
||||
|
||||
When a component requires a CSS class (e.g. as a hook for a pseudo-element), still put all properties that can be Tailwind utilities as classes on the element in the component file. The CSS rule should contain only what genuinely cannot be a utility — pseudo-elements, complex selectors, keyframes. Do not default to putting all styles in the CSS class just because the class exists.
|
||||
|
||||
When custom CSS is needed, add it to `resources/css/app.css` inside `@layer components`. Keep the rule minimal — only what can't be a utility.
|
||||
|
||||
---
|
||||
|
||||
## Design Tokens
|
||||
|
||||
Defined in `@theme {}` in `app.css`:
|
||||
|
||||
- `--font-sans`: Manrope (body text)
|
||||
- `--font-display`: Manrope (headings, nav, buttons) — kept separate from `--font-sans` in case they diverge later
|
||||
|
||||
Reuse existing colors (`neutral-200`, `black`), font sizes, spacing, and border styles as components are built. Do not invent new values — check what's already in use first.
|
||||
|
||||
---
|
||||
|
||||
## Component Structure
|
||||
|
||||
### `resources/views/components/ui/` — atomic, reusable UI elements
|
||||
|
||||
Single-purpose, stateless or minimally stateful elements used across many contexts:
|
||||
|
||||
- `button.blade.php` ✓
|
||||
- `input.blade.php`
|
||||
- `input-group.blade.php`
|
||||
- `icon.blade.php`
|
||||
- `image.blade.php`
|
||||
- `tabs.blade.php` / `tab.blade.php`
|
||||
- `accordion.blade.php`
|
||||
- `tooltip.blade.php`
|
||||
- `badge.blade.php`
|
||||
- etc.
|
||||
|
||||
### `resources/views/components/` — section-level or composite components
|
||||
|
||||
Larger reusable blocks made of several elements, often with real content:
|
||||
|
||||
- `header.blade.php` ✓
|
||||
- `footer.blade.php`
|
||||
- `cta.blade.php`
|
||||
- `product-card.blade.php`
|
||||
- `contact-section.blade.php`
|
||||
- etc.
|
||||
|
||||
---
|
||||
|
||||
## Interactivity
|
||||
|
||||
### Popover API for overlays
|
||||
|
||||
For **tooltips, modals, dropdowns, and any overlay**: use the native [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API) with CSS — no JS, no libraries.
|
||||
|
||||
```html
|
||||
<button popovertarget="my-popover">Open</button>
|
||||
<div id="my-popover" popover>...</div>
|
||||
```
|
||||
|
||||
Style open/closed states with `[popover]` and `:popover-open` selectors in CSS.
|
||||
|
||||
### Stimulus JS for behavior
|
||||
|
||||
Use Stimulus when JS is genuinely needed (form handling, cart updates, dynamic state, etc.).
|
||||
|
||||
- Keep controllers lean: manage state and coordinate the DOM, don't build UI inside them
|
||||
- Use Stimulus values, targets, and outlets — avoid reading from the DOM imperatively
|
||||
- **Never inject HTML strings or classes from inside a controller.** Instead, drive appearance via data attributes or CSS classes toggled on existing elements, and let CSS handle the visual result
|
||||
- Never write inline JS (`onclick="..."`, etc.)
|
||||
|
||||
### Pure CSS for simple interactions
|
||||
|
||||
Hover dropdowns, focus states, active states — handle with CSS (`:hover`, `:focus-within`, `.group:hover`) before reaching for Stimulus.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility (ARIA)
|
||||
|
||||
Always add ARIA attributes. This is not optional:
|
||||
|
||||
- Interactive elements: `aria-label`, `aria-expanded`, `aria-controls`, `aria-haspopup` as appropriate
|
||||
- Images: meaningful `alt` text, or `alt=""` + `aria-hidden="true"` for decorative images
|
||||
- Icons used as buttons: `aria-label` on the button, `aria-hidden="true"` on the SVG
|
||||
- Form fields: always associated `<label>`, `aria-describedby` for hints/errors
|
||||
- Dynamic content: `aria-live` regions where content updates without navigation
|
||||
- When refactoring or adding interactivity, review ARIA impact — don't break existing roles
|
||||
|
||||
---
|
||||
|
||||
## Greek Typography
|
||||
|
||||
CSS `text-transform: uppercase` leaves Greek tonos accents in place (e.g. Ά instead of Α), which is incorrect. The utility at `resources/js/utils/strip-accents.js` handles this automatically for any element with the `uppercase` Tailwind class. **Always add the `uppercase` class to elements** rather than applying `text-transform: uppercase` in CSS — this keeps the strip-accents utility working without extra configuration.
|
||||
|
||||
---
|
||||
|
||||
## Performance & Core Web Vitals
|
||||
|
||||
Every frontend change should assume it will be measured by PageSpeed/Lighthouse. These rules exist because we hit each of these mistakes in production and had to fix them after the fact — bake them in up front instead.
|
||||
|
||||
### CSS bundling
|
||||
- Before adding a CSS import to a shared/global entry point (e.g. `app.css`), ask whether every page actually needs it. Page- or component-specific styles (date pickers, legal-page typography, admin-only widgets) should not ship on pages that don't use them.
|
||||
- Scope non-critical CSS to load only where it's used — either a dedicated build entry for that page, or import the `.css` file inside the same JS module that already lazy-loads that component (e.g. a Stimulus/JS controller), so the bundler emits it as a separate chunk fetched only when that component actually mounts.
|
||||
- Exception: never defer CSS for anything the page shows immediately on load (above-the-fold content, or a component that renders as soon as its JS runs — an FAQ accordion, a cookie-consent banner). Even a well-scoped async CSS chunk arrives a beat after the JS that displays the element, causing a visible flash of unstyled/wrong-state content and a layout shift. Keep that CSS in the blocking bundle on purpose.
|
||||
- Never import a full icon-font library (Phosphor, Font Awesome, etc.) — it ships every icon regardless of usage. Use inline SVGs for only the icons actually referenced; check the shared icon component for an existing icon before adding a new one.
|
||||
- Only declare design tokens / CSS custom properties that are actually used somewhere in the codebase (Tailwind v4 already tree-shakes unused `@theme` tokens from output, so this is for keeping the source legible, not for bytes).
|
||||
|
||||
### Fonts
|
||||
- `font-display: swap` means any text using a font weight will visibly reflow once that weight's file finishes downloading, if the fallback font's metrics differ. Preload every font weight used in above-the-fold text, not just the default body-copy weight — bold/black heading weights are usually the most visually prominent element on the page and cause the most visible reflow if not preloaded.
|
||||
- When introducing a new heavy-weight text utility (e.g. a bold/black heading class) in a hero or above-the-fold component, confirm that weight has a matching `<link rel="preload" as="font">` in the layout `<head>`.
|
||||
|
||||
### Images
|
||||
- Any image that could be the Largest Contentful Paint element (hero/banner images) must have: `fetchpriority="high"`, `loading="eager"`, explicit `width`/`height`, a responsive `srcset`/`sizes`, and a `<link rel="preload" as="image">` in `<head>` with matching `imagesrcset`/`imagesizes` — this lets the browser start the fetch as soon as it parses `<head>`, instead of waiting to discover the `<img>` tag in the body.
|
||||
- Every other image: `loading="lazy"` with explicit `width`/`height` to reserve its layout space.
|
||||
|
||||
### Layout shift (CLS)
|
||||
- Never animate `top`/`bottom`/`left`/`right`/`width`/`height`/`margin` on any element that appears or moves without direct user interaction (banners, toasts, slide-in modals, accordions). These are layout-affecting properties — the browser recalculates layout on every animation frame, and each frame's position delta counts toward CLS. Animate `transform` (translate/scale) and `opacity` instead — both are compositor-only and fully excluded from CLS scoring, no matter how far or long the animation runs.
|
||||
- This applies to vendor/third-party CSS too (cookie-consent widgets, modal libraries) — check its default show/hide mechanism before shipping, and override it if it animates a layout property directly.
|
||||
- Any element with a collapsed/expanded default state (accordions, dropdowns) needs its collapsed-state CSS in the blocking stylesheet, never a lazily-loaded chunk — otherwise it renders in its expanded/default state for a frame before the async CSS applies.
|
||||
|
||||
---
|
||||
|
||||
## Forms
|
||||
|
||||
Never add `novalidate` to forms. Always use the browser's default validation.
|
||||
|
||||
---
|
||||
|
||||
## Asking Questions
|
||||
|
||||
When the approach is unclear — layout structure, whether something should be a component, which Tailwind pattern fits — **ask before building**. A short question is cheaper than a refactor.
|
||||
+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,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Catalog;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
|
||||
/**
|
||||
* The parsed state of a category listing request. The query string is the single
|
||||
* source of truth for sort / filters / page — build one of these from the
|
||||
* request, read the applied values off it, and use query() to build links (sort
|
||||
* options, pagination, "clear filter") that carry the rest of the state along.
|
||||
*
|
||||
* A param is only ever emitted when it differs from its default, so a pristine
|
||||
* listing is just `/category/{id}` with no query string.
|
||||
*/
|
||||
final class CategoryListing
|
||||
{
|
||||
private function __construct(
|
||||
public readonly ?ProductSort $sort,
|
||||
public readonly ?int $minPrice,
|
||||
public readonly ?int $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::intOrNull($request->query('price_min')),
|
||||
maxPrice: self::intOrNull($request->query('price_max')),
|
||||
inStockOnly: $request->boolean('in_stock'),
|
||||
page: max(1, (int) $request->query('page', 1)),
|
||||
);
|
||||
}
|
||||
|
||||
public function filters(int $collectionId): 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. Feeds straight into
|
||||
* route('category.show', ['id' => $id] + $listing->query([...])).
|
||||
*
|
||||
* @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 category
|
||||
* 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 intOrNull(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Catalog\CategoryListing;
|
||||
use Illuminate\Http\Response;
|
||||
use Modules\Core\Catalog\Services\CollectionService;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductService $products,
|
||||
private readonly CollectionService $collections,
|
||||
) {}
|
||||
|
||||
public function show(string $locale, int $collection)
|
||||
{
|
||||
$collectionData = $this->collections->getById($collection);
|
||||
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
|
||||
|
||||
$listing = CategoryListing::fromRequest(request());
|
||||
$filters = $listing->filters($collectionData['id']);
|
||||
$perPage = 12;
|
||||
|
||||
// Listing/filtering reads from the Meilisearch index via ProductService,
|
||||
// not Eloquent — see Modules\Core\Catalog\Services\ProductService. list() returns a
|
||||
// real LengthAwarePaginator of plain arrays (already localized/flattened),
|
||||
// not Product models. Sort/filter/page all come from the query string via
|
||||
// CategoryListing, which is the single source of truth for that state.
|
||||
$products = $this->products->list(
|
||||
filters: $filters,
|
||||
perPage: $perPage,
|
||||
page: $listing->page,
|
||||
sort: $listing->sort,
|
||||
)->through(fn (array $product) => [
|
||||
'name' => $product['name'],
|
||||
'price' => $product['price'],
|
||||
'image' => $product['media'][0]['url'] ?? null,
|
||||
'href' => route('product.show', ['id' => $product['id']]),
|
||||
])->appends($listing->query(['page' => null]));
|
||||
|
||||
// Slider bounds — the price span of everything matching the *other*
|
||||
// filters (priceRange() drops the price filter itself, so the handles
|
||||
// don't collapse to whatever's already selected). Whole euros.
|
||||
$priceRange = $this->products->priceRange($filters);
|
||||
$priceFloor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null;
|
||||
$priceCeil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null;
|
||||
|
||||
// A price param is only a real filter if it's tighter than the bounds —
|
||||
// drives whether the "clear" link shows.
|
||||
$priceFiltered = ($listing->minPrice !== null && $listing->minPrice > ($priceFloor ?? PHP_INT_MIN))
|
||||
|| ($listing->maxPrice !== null && $listing->maxPrice < ($priceCeil ?? PHP_INT_MAX));
|
||||
|
||||
return view('category.show', [
|
||||
'collection' => $collectionData,
|
||||
'products' => $products,
|
||||
'listing' => $listing,
|
||||
'priceFloor' => $priceFloor,
|
||||
'priceCeil' => $priceCeil,
|
||||
'priceFiltered' => $priceFiltered,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function index(string $locale)
|
||||
{
|
||||
return view('contact');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\StoicPage;
|
||||
use Lunar\Models\Product;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function index(string $locale)
|
||||
{
|
||||
$page = StoicPage::firstWhere('slug', 'home');
|
||||
|
||||
if (! $page) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$products = Product::with(['variants.prices.currency', 'media'])
|
||||
->inRandomOrder()
|
||||
->limit(13)
|
||||
->get()
|
||||
->map(fn (Product $product) => [
|
||||
'name' => $product->translateAttribute('name'),
|
||||
'price' => $product->variants->first()?->prices->first()?->price->decimal,
|
||||
'image' => $product->media->first()?->getUrl(),
|
||||
'href' => route('product.show', ['id' => $product->id]),
|
||||
]);
|
||||
|
||||
return view('home', [
|
||||
'page' => $page,
|
||||
'heroProducts' => $products->take(5)->values(),
|
||||
'classicsProducts' => $products->slice(5)->values(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\StoicPage;
|
||||
|
||||
class LegalPageController extends Controller
|
||||
{
|
||||
public function terms(string $locale)
|
||||
{
|
||||
return $this->show('terms-and-conditions');
|
||||
}
|
||||
|
||||
public function shippingReturns(string $locale)
|
||||
{
|
||||
return $this->show('shipping-returns');
|
||||
}
|
||||
|
||||
public function privacy(string $locale)
|
||||
{
|
||||
return $this->show('privacy-policy');
|
||||
}
|
||||
|
||||
public function cookies(string $locale)
|
||||
{
|
||||
return $this->show('cookies-policy');
|
||||
}
|
||||
|
||||
private function show(string $slug)
|
||||
{
|
||||
$page = StoicPage::firstWhere('slug', $slug);
|
||||
|
||||
if (! $page) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('legal', ['page' => $page]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Response;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ProductService $products) {}
|
||||
|
||||
public function show(string $locale, int $id)
|
||||
{
|
||||
$product = $this->products->getById($id);
|
||||
abort_if($product === null, Response::HTTP_NOT_FOUND);
|
||||
|
||||
$collection = $product['collections'][0] ?? null;
|
||||
|
||||
$variantsData = collect($product['variants'])
|
||||
->map(fn (array $variant) => [
|
||||
'id' => $variant['id'],
|
||||
'price' => $variant['prices'][0]['price'] ?? null,
|
||||
'image' => $variant['media'][0]['url'] ?? null,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$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,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use JacobJoergensen\LaravelPaper\Attributes\ContentPath;
|
||||
use JacobJoergensen\LaravelPaper\Attributes\Driver;
|
||||
use JacobJoergensen\LaravelPaper\Attributes\Timestamps;
|
||||
use JacobJoergensen\LaravelPaper\Paper;
|
||||
|
||||
#[Driver("markdown")]
|
||||
#[ContentPath("resources/stoic/content/pages")]
|
||||
#[Timestamps]
|
||||
class StoicPage extends Model
|
||||
{
|
||||
use Paper;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class Stoic
|
||||
{
|
||||
public static array $config = [];
|
||||
public static array $presets = [];
|
||||
public static string $thumbs_path;
|
||||
|
||||
private static function loadConfig(): void
|
||||
{
|
||||
if (!empty(static::$config)) {
|
||||
return;
|
||||
}
|
||||
$config = Yaml::parseFile(resource_path("stoic/stoic_config.yml"));
|
||||
static::$config = $config;
|
||||
static::$thumbs_path = $config["thumbs_path"];
|
||||
static::$presets = $config["presets"];
|
||||
}
|
||||
|
||||
public static function presets(): array
|
||||
{
|
||||
static::loadConfig();
|
||||
return static::$presets;
|
||||
}
|
||||
|
||||
// Returns the public URL for a stoic image at a given preset.
|
||||
public static function image(string $filename, string $preset): string
|
||||
{
|
||||
static::loadConfig();
|
||||
|
||||
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
|
||||
if ($ext === "svg") {
|
||||
$rel = static::thumbsRelPath() . "images/" . $filename;
|
||||
return Storage::disk("public")->url($rel);
|
||||
}
|
||||
|
||||
$webp = substr($filename, 0, -strlen($ext)) . "webp";
|
||||
$rel = static::thumbsRelPath() . "presets/" . $preset . "/" . $webp;
|
||||
|
||||
return Storage::disk("public")->url($rel);
|
||||
}
|
||||
|
||||
// Generates the full srcset string for all configured presets.
|
||||
public static function srcset(string $filename): string
|
||||
{
|
||||
static::loadConfig();
|
||||
|
||||
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
if ($ext === "svg") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return collect(static::$presets)
|
||||
->map(
|
||||
fn($p) => static::image($filename, $p["code"]) .
|
||||
" " .
|
||||
$p["width"] .
|
||||
"w",
|
||||
)
|
||||
->implode(", ");
|
||||
}
|
||||
|
||||
// Returns [width, height] for a given preset from the sidecar JSON.
|
||||
public static function dimensions(string $filename, string $preset): array
|
||||
{
|
||||
static::loadConfig();
|
||||
|
||||
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
if ($ext === "svg") {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
$rel = static::thumbsRelPath() . "images/" . $filename . ".json";
|
||||
$path = Storage::disk("public")->path($rel);
|
||||
$meta = json_decode(@file_get_contents($path), true);
|
||||
|
||||
$p = $meta["presets"][$preset] ?? null;
|
||||
if (!$p) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
return [(int) $p["w"], (int) $p["h"]];
|
||||
}
|
||||
|
||||
// Path of the thumbs directory relative to the public storage disk root.
|
||||
// Uses a fixed marker so it works regardless of the app's base path (local vs Docker).
|
||||
private static function thumbsRelPath(): string
|
||||
{
|
||||
$marker = "/storage/app/public/";
|
||||
$pos = strpos(static::$thumbs_path, $marker);
|
||||
|
||||
if ($pos !== false) {
|
||||
return substr(static::$thumbs_path, $pos + strlen($marker));
|
||||
}
|
||||
|
||||
return ltrim(static::$thumbs_path, "/");
|
||||
}
|
||||
|
||||
public static function token(string $email): string
|
||||
{
|
||||
$ttl = 60;
|
||||
$payload = base64_encode(
|
||||
json_encode([
|
||||
"email" => $email,
|
||||
"exp" => time() + $ttl,
|
||||
]),
|
||||
);
|
||||
|
||||
$sig = hash_hmac(
|
||||
"sha256",
|
||||
$payload,
|
||||
config("services.stoic.sso_secret"),
|
||||
);
|
||||
|
||||
$token = "t=" . urlencode($payload) . "&s=" . $sig;
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public static function hasMany(
|
||||
string $relation,
|
||||
array $slugs,
|
||||
string $prefix,
|
||||
) {
|
||||
$slugs = array_map(
|
||||
fn($i) => str_replace($prefix . "/", "", $i),
|
||||
$slugs,
|
||||
);
|
||||
|
||||
$entries = $relation::whereIn("slug", $slugs)->get();
|
||||
|
||||
return collect(array_map(
|
||||
fn($i) => $entries->firstWhere("slug", $i),
|
||||
$slugs
|
||||
));
|
||||
}
|
||||
|
||||
public static function hasOne(
|
||||
string $relation,
|
||||
string $slug,
|
||||
string $prefix,
|
||||
) {
|
||||
$slug = str_replace($prefix . "/", "", $slug);
|
||||
return $relation::firstWhere("slug", $slug);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class Price
|
||||
{
|
||||
/**
|
||||
* Format a price the Greek way: comma as the decimal separator, dot as
|
||||
* the thousands separator, and no decimals shown unless the amount
|
||||
* actually needs them (19.00 -> "19", 19.50 -> "19,5", 19.55 -> "19,55").
|
||||
*/
|
||||
public static function format(float|int|string|null $amount, string $currency = '€'): ?string
|
||||
{
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$amount = round((float) $amount, 2);
|
||||
$cents = (int) round($amount * 100);
|
||||
|
||||
$decimals = match (true) {
|
||||
$cents % 100 === 0 => 0,
|
||||
$cents % 10 === 0 => 1,
|
||||
default => 2,
|
||||
};
|
||||
|
||||
return $currency.number_format($amount, $decimals, ',', '.');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"require": {
|
||||
"artesaos/seotools": "^1.4",
|
||||
"boboko/core": "0.*",
|
||||
"jacobjoergensen/laravel-paper": "^1.12",
|
||||
"open-telemetry/exporter-otlp": "^1.4",
|
||||
"open-telemetry/opentelemetry-auto-laravel": "^1.7",
|
||||
"open-telemetry/sdk": "^1.14",
|
||||
|
||||
Generated
+2375
-1860
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -78,7 +78,7 @@
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
'locale' => env('APP_LOCALE', 'el'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
|
||||
@@ -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 => Lunar\Search\ProductIndexer::class,
|
||||
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
|
||||
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class,
|
||||
],
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Search Engine
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default search connection that gets used while
|
||||
| using Laravel Scout. This connection is used when syncing all models
|
||||
| to the search service. You should adjust this based on your needs.
|
||||
|
|
||||
| Supported: "algolia", "meilisearch", "typesense",
|
||||
| "database", "collection", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SCOUT_DRIVER', 'collection'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Index Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify a prefix that will be applied to all search index
|
||||
| names used by Scout. This prefix may be useful if you have multiple
|
||||
| "tenants" or applications sharing the same search infrastructure.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('SCOUT_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Data Syncing
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to control if the operations that sync your data
|
||||
| with your search engines are queued. When this is set to "true" then
|
||||
| all automatic data syncing will get queued for better performance.
|
||||
|
|
||||
*/
|
||||
|
||||
'queue' => env('SCOUT_QUEUE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Transactions
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This configuration option determines if your data will only be synced
|
||||
| with your search indexes after every open database transaction has
|
||||
| been committed, thus preventing any discarded data from syncing.
|
||||
|
|
||||
*/
|
||||
|
||||
'after_commit' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Chunk Sizes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options allow you to control the maximum chunk size when you are
|
||||
| mass importing data into the search engine. This allows you to fine
|
||||
| tune each of these chunk sizes based on the power of the servers.
|
||||
|
|
||||
*/
|
||||
|
||||
'chunk' => [
|
||||
'searchable' => 500,
|
||||
'unsearchable' => 500,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Soft Deletes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows to control whether to keep soft deleted records in
|
||||
| the search indexes. Maintaining soft deleted records can be useful
|
||||
| if your application still needs to search for the records later.
|
||||
|
|
||||
*/
|
||||
|
||||
'soft_delete' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Identify User
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to control whether to notify the search engine
|
||||
| of the user performing the search. This is sometimes useful if the
|
||||
| engine supports any analytics based on this application's users.
|
||||
|
|
||||
| Supported engines: "algolia"
|
||||
|
|
||||
*/
|
||||
|
||||
'identify' => env('SCOUT_IDENTIFY', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Algolia Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your Algolia settings. Algolia is a cloud hosted
|
||||
| search engine which works great with Scout out of the box. Just plug
|
||||
| in your application ID and admin API key to get started searching.
|
||||
|
|
||||
*/
|
||||
|
||||
'algolia' => [
|
||||
'id' => env('ALGOLIA_APP_ID', ''),
|
||||
'secret' => env('ALGOLIA_SECRET', ''),
|
||||
'index-settings' => [
|
||||
// 'users' => [
|
||||
// 'searchableAttributes' => ['id', 'name', 'email'],
|
||||
// 'attributesForFaceting'=> ['filterOnly(email)'],
|
||||
// ],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Meilisearch Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your Meilisearch settings. Meilisearch is an open
|
||||
| source search engine with minimal configuration. Below, you can state
|
||||
| the host and key information for your own Meilisearch installation.
|
||||
|
|
||||
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
||||
|
|
||||
*/
|
||||
|
||||
'meilisearch' => [
|
||||
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
|
||||
'key' => env('MEILISEARCH_KEY'),
|
||||
'index-settings' => [
|
||||
// 'users' => [
|
||||
// 'filterableAttributes'=> ['id', 'name', 'email'],
|
||||
// ],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Typesense Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your Typesense settings. Typesense is an open
|
||||
| source search engine using minimal configuration. Below, you will
|
||||
| state the host, key, and schema configuration for the instance.
|
||||
|
|
||||
*/
|
||||
|
||||
'typesense' => [
|
||||
'client-settings' => [
|
||||
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
|
||||
'nodes' => [
|
||||
[
|
||||
'host' => env('TYPESENSE_HOST', 'localhost'),
|
||||
'port' => env('TYPESENSE_PORT', '8108'),
|
||||
'path' => env('TYPESENSE_PATH', ''),
|
||||
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
|
||||
],
|
||||
],
|
||||
'nearest_node' => [
|
||||
'host' => env('TYPESENSE_HOST', 'localhost'),
|
||||
'port' => env('TYPESENSE_PORT', '8108'),
|
||||
'path' => env('TYPESENSE_PATH', ''),
|
||||
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
|
||||
],
|
||||
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
|
||||
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
|
||||
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
|
||||
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
|
||||
],
|
||||
// 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
|
||||
'model-settings' => [
|
||||
// User::class => [
|
||||
// 'collection-schema' => [
|
||||
// 'fields' => [
|
||||
// [
|
||||
// 'name' => 'id',
|
||||
// 'type' => 'string',
|
||||
// ],
|
||||
// [
|
||||
// 'name' => 'name',
|
||||
// 'type' => 'string',
|
||||
// ],
|
||||
// [
|
||||
// 'name' => 'created_at',
|
||||
// 'type' => 'int64',
|
||||
// ],
|
||||
// ],
|
||||
// 'default_sorting_field' => 'created_at',
|
||||
// ],
|
||||
// 'search-parameters' => [
|
||||
// 'query_by' => 'name'
|
||||
// ],
|
||||
// ],
|
||||
],
|
||||
'import_action' => env('TYPESENSE_IMPORT_ACTION', 'upsert'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -35,4 +35,9 @@
|
||||
],
|
||||
],
|
||||
|
||||
'stoic' => [
|
||||
'sso_secret' => env('STOIC_SSO_SECRET'),
|
||||
'host' => env('STOIC_HOST'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
+14
-2
@@ -113,6 +113,17 @@ services:
|
||||
ports:
|
||||
- "${VALKEY_PORT:-6339}:6379"
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.10
|
||||
ports:
|
||||
- "${MEILISEARCH_PORT:-7700}:7700"
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
MEILI_ENV: development
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
|
||||
stoic:
|
||||
image: ghcr.io/lexx27/stoic:latest
|
||||
user: root
|
||||
@@ -137,10 +148,10 @@ services:
|
||||
working_dir: /app
|
||||
command: npm run dev
|
||||
ports:
|
||||
- "${VITE_PORT:-5173}:5173"
|
||||
- "${VITE_PORT:-5174}:${VITE_PORT:-5174}"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- VITE_PORT=${VITE_PORT:-5173}
|
||||
- VITE_PORT=${VITE_PORT:-5174}
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
@@ -149,3 +160,4 @@ volumes:
|
||||
vendor:
|
||||
node_modules:
|
||||
pgdata:
|
||||
meilidata:
|
||||
|
||||
@@ -115,6 +115,20 @@ services:
|
||||
volumes:
|
||||
- valkeydata:/data
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.10
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://localhost:7700/health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
otel-collector:
|
||||
image: otel/opentelemetry-collector-contrib:latest
|
||||
restart: unless-stopped
|
||||
@@ -145,3 +159,4 @@ volumes:
|
||||
logs:
|
||||
framework:
|
||||
valkeydata:
|
||||
meilidata:
|
||||
|
||||
+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 "$@"
|
||||
|
||||
+66
-14
@@ -4,8 +4,15 @@ 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
|
||||
composer install --no-interaction --prefer-dist
|
||||
|
||||
# Re-resolve boboko/* on every boot, whether it's a local path-repo checkout
|
||||
@@ -20,28 +27,73 @@ 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
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Running migrations..."
|
||||
rm -f storage/framework/migrated
|
||||
php artisan migrate --force
|
||||
touch storage/framework/migrated
|
||||
# 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.
|
||||
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"
|
||||
|
||||
# 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: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
-3
@@ -16,8 +16,6 @@
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo": "^8.0.23",
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"axios": "^1.15.2",
|
||||
"flatpickr": "^4.6.13"
|
||||
"axios": "^1.15.2"
|
||||
}
|
||||
}
|
||||
|
||||
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
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
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
+377
-4
@@ -1,11 +1,384 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@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';
|
||||
@source '../**/*.js';
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
DESIGN TOKENS
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
/*--font-sans: "Nunito", ui-sans-serif, system-ui, sans-serif;*/
|
||||
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
|
||||
/*--font-display: "DM Sans", ui-sans-serif, system-ui, sans-serif;*/
|
||||
--font-display: "Manrope", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--color-brand: #18c28a;
|
||||
--color-brand-light: #69cba7;
|
||||
|
||||
--text-h1: 60px;
|
||||
--text-h2: 48px;
|
||||
--text-h3: 36px;
|
||||
--text-h4: 26px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
BASE
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
@layer base {
|
||||
body {
|
||||
background-color: theme(colors.neutral.200);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 18px;
|
||||
line-height: 1.389;
|
||||
font-weight: 400;
|
||||
color: #000;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Browsers don't give <button> a pointer cursor by default (unlike <a>),
|
||||
and Tailwind's Preflight stopped forcing it a few versions back — so
|
||||
every button sitewide needs it set explicitly, once, here. */
|
||||
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 {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
COMPONENTS
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
@layer components {
|
||||
/* ── Nav link — only the span underline-slide animation ──────── */
|
||||
.nav-link span {
|
||||
display: inline-block;
|
||||
background-image: linear-gradient(currentColor, currentColor);
|
||||
background-position: 0 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 0 5px;
|
||||
transition: background-size 0.35s cubic-bezier(0.61, 1, 0.88, 1);
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.nav-link:hover span,
|
||||
.nav-link.is-active span {
|
||||
background-size: 100% 5px;
|
||||
}
|
||||
|
||||
/* ── Nav dropdown ─────────────────────────────────────────────── */
|
||||
.nav-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: -24px;
|
||||
min-width: 220px;
|
||||
background-color: theme(colors.neutral.200);
|
||||
border: 1px solid #000;
|
||||
border-top: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-4px);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
visibility 0.2s ease;
|
||||
}
|
||||
|
||||
.group:hover .nav-dropdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
.nav-dropdown a {
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-dropdown a:hover {
|
||||
background-color: #000;
|
||||
color: theme(colors.neutral.200);
|
||||
}
|
||||
|
||||
/* ── Hero star — same burst shape/spin as back-to-top, always on ─ */
|
||||
.rotating-star {
|
||||
transform-origin: center;
|
||||
animation: spin 7s infinite linear;
|
||||
}
|
||||
|
||||
/* ── Back to top ──────────────────────────────────────────────── */
|
||||
.back-to-top {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
visibility 0s 0.15s;
|
||||
}
|
||||
|
||||
.back-to-top.is-visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.back-to-top-shape path {
|
||||
fill: theme(colors.brand-light);
|
||||
transform-origin: center;
|
||||
animation: spin 7s infinite linear;
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.back-to-top a:hover .back-to-top-shape path {
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
.back-to-top-arrow path {
|
||||
transition: transform 0.5s cubic-bezier(0.39, 0.1, 0, 0.98);
|
||||
}
|
||||
|
||||
.back-to-top a:hover .back-to-top-arrow path {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
/* ── Product lightbox (popover) ──────────────────────────────── */
|
||||
.product-lightbox {
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
display 0.3s allow-discrete,
|
||||
overlay 0.3s allow-discrete;
|
||||
}
|
||||
|
||||
.product-lightbox:popover-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.product-lightbox:popover-open {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.product-lightbox::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
display 0.3s allow-discrete,
|
||||
overlay 0.3s allow-discrete;
|
||||
}
|
||||
|
||||
.product-lightbox:popover-open::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.product-lightbox:popover-open::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Shared underline-slide animation ────────────────────────── */
|
||||
.underline-slide {
|
||||
background-image: linear-gradient(currentColor, currentColor);
|
||||
background-position: 0 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 0 var(--slide-h, 2px);
|
||||
transition: background-size 0.35s cubic-bezier(0.61, 1, 0.88, 1);
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.underline-slide:hover,
|
||||
.underline-slide.is-active {
|
||||
background-size: 100% var(--slide-h, 2px);
|
||||
}
|
||||
|
||||
/* ── Lightbox arrows + close ─────────────────────────────────── */
|
||||
.lightbox-arrow svg {
|
||||
transition: transform 0.25s cubic-bezier(0.39, 0.1, 0, 0.98);
|
||||
}
|
||||
|
||||
.lightbox-arrow[aria-label="Previous image"]:hover svg {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
|
||||
.lightbox-arrow[aria-label="Next image"]:hover svg {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
|
||||
.lightbox-close svg {
|
||||
transition: stroke 0.2s ease;
|
||||
}
|
||||
|
||||
.lightbox-close:hover svg {
|
||||
stroke: theme(colors.brand);
|
||||
}
|
||||
|
||||
/* ── Gallery arrows ──────────────────────────────────────────── */
|
||||
.gallery-arrow svg {
|
||||
transition: transform 0.25s cubic-bezier(0.39, 0.1, 0, 0.98);
|
||||
}
|
||||
|
||||
.gallery-arrow:first-child:hover svg {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.gallery-arrow:last-child:hover svg {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
/* ── Checkbox ────────────────────────────────────────────────── */
|
||||
.checkbox:checked::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
background: theme(colors.brand);
|
||||
}
|
||||
|
||||
/* ── Color swatches ───────────────────────────────────────────── */
|
||||
.color-swatch {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #000;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: border-width 0.1s ease;
|
||||
}
|
||||
|
||||
.color-swatch.is-selected {
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
/* ── Button — ::before is the button bg, ::after is the shadow ── */
|
||||
.btn-primary::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: theme(colors.neutral.200);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.btn-primary::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: #000;
|
||||
z-index: -1;
|
||||
transform: translate(10px, 10px);
|
||||
transition: transform 0.35s cubic-bezier(0.2, 0.78, 0.12, 0.86);
|
||||
}
|
||||
|
||||
.btn-primary:hover::after {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
.text-dance {
|
||||
display: inline-block;
|
||||
font-weight: inherit;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.text-dance::before {
|
||||
content: attr(data-text);
|
||||
display: block;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.is-visible .text-dance {
|
||||
animation: text-dance 0.8s ease 3 forwards;
|
||||
animation-delay: var(--dance-delay, 0s);
|
||||
}
|
||||
|
||||
/* Typography for markdown rendered from Stoic legal-page bodies (h1.h6, p, lists,
|
||||
links, tables) — a plain set of descendant selectors, not expressible as
|
||||
utilities on the wrapper alone since the markup itself is generated by Str::markdown(). */
|
||||
.legal-content {
|
||||
@apply text-neutral-700;
|
||||
}
|
||||
|
||||
.legal-content > * + * {
|
||||
@apply mt-6;
|
||||
}
|
||||
|
||||
.legal-content h2 {
|
||||
@apply font-display text-2xl font-bold text-black;
|
||||
}
|
||||
|
||||
.legal-content h3 {
|
||||
@apply font-display text-xl font-bold text-black;
|
||||
}
|
||||
|
||||
.legal-content ul,
|
||||
.legal-content ol {
|
||||
@apply ml-6 space-y-2;
|
||||
}
|
||||
|
||||
.legal-content ul {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
.legal-content ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
.legal-content a {
|
||||
@apply underline hover:text-black;
|
||||
}
|
||||
|
||||
.legal-content strong {
|
||||
@apply font-bold text-black;
|
||||
}
|
||||
|
||||
.legal-content table {
|
||||
@apply w-full border-collapse text-left text-sm;
|
||||
}
|
||||
|
||||
.legal-content th,
|
||||
.legal-content td {
|
||||
@apply border border-neutral-300 px-3 py-2;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes text-dance {
|
||||
0%, 60% { font-weight: inherit; font-style: normal; }
|
||||
70%, 100% { font-weight: 800; font-style: italic; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* manrope-300 - greek_latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Manrope";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url("../fonts/manrope/manrope-v20-greek_latin-300.woff2")
|
||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* manrope-regular - greek_latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Manrope";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url("../fonts/manrope/manrope-v20-greek_latin-regular.woff2")
|
||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* manrope-500 - greek_latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Manrope";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url("../fonts/manrope/manrope-v20-greek_latin-500.woff2")
|
||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* manrope-600 - greek_latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Manrope";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url("../fonts/manrope/manrope-v20-greek_latin-600.woff2")
|
||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* manrope-700 - greek_latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Manrope";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url("../fonts/manrope/manrope-v20-greek_latin-700.woff2")
|
||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* manrope-800 - greek_latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Manrope";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url("../fonts/manrope/manrope-v20-greek_latin-800.woff2")
|
||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
-1
@@ -1 +1,17 @@
|
||||
import './bootstrap';
|
||||
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";
|
||||
|
||||
const application = Application.start();
|
||||
application.debug = false;
|
||||
|
||||
registerControllers(application);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static classes = ['visible']
|
||||
|
||||
connect() {
|
||||
this.observer = new IntersectionObserver(([entry]) => {
|
||||
if (!entry.isIntersecting) return
|
||||
|
||||
this.element.classList.add(this.visibleClass)
|
||||
this.observer.disconnect()
|
||||
}, { threshold: 0.3 })
|
||||
|
||||
this.observer.observe(this.element)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.observer?.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.scrollHandler = this.onScroll.bind(this)
|
||||
window.addEventListener('scroll', this.scrollHandler, { passive: true })
|
||||
this.onScroll()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
window.removeEventListener('scroll', this.scrollHandler)
|
||||
}
|
||||
|
||||
onScroll() {
|
||||
this.element.classList.toggle('is-visible', window.scrollY > 300)
|
||||
}
|
||||
|
||||
scrollToTop(e) {
|
||||
e.preventDefault()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['slide']
|
||||
static values = { index: { type: Number, default: 0 } }
|
||||
|
||||
connect() {
|
||||
this.showSlide()
|
||||
}
|
||||
|
||||
next() {
|
||||
this.indexValue = (this.indexValue + 1) % this.slideTargets.length
|
||||
}
|
||||
|
||||
prev() {
|
||||
this.indexValue = (this.indexValue - 1 + this.slideTargets.length) % this.slideTargets.length
|
||||
}
|
||||
|
||||
indexValueChanged() {
|
||||
this.showSlide()
|
||||
}
|
||||
|
||||
showSlide() {
|
||||
this.slideTargets.forEach((slide, i) => {
|
||||
slide.classList.toggle('hidden', i !== this.indexValue)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,30 @@
|
||||
// Register all Stimulus controllers here.
|
||||
// Example:
|
||||
// import HelloController from './controllers/hello_controller';
|
||||
// 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 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('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,51 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import { formatPrice } from '../utils/format-price'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['price', 'image', 'swatch', 'colorName']
|
||||
static values = { variants: Array, selected: Number }
|
||||
|
||||
connect() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const urlId = parseInt(params.get('variant'))
|
||||
const defaultId = this.variantsValue[0]?.id
|
||||
|
||||
this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId)
|
||||
? urlId
|
||||
: defaultId
|
||||
}
|
||||
|
||||
selectVariant(event) {
|
||||
const id = parseInt(event.currentTarget.dataset.variantId)
|
||||
this.selectedValue = id
|
||||
|
||||
const url = new URL(window.location)
|
||||
url.searchParams.set('variant', id)
|
||||
window.history.pushState({}, '', url)
|
||||
}
|
||||
|
||||
selectedValueChanged(id) {
|
||||
if (!id) return
|
||||
|
||||
const variant = this.variantsValue.find(v => v.id === id)
|
||||
if (!variant) return
|
||||
|
||||
if (this.hasPriceTarget && variant.price !== null) {
|
||||
this.priceTarget.textContent = formatPrice(variant.price)
|
||||
}
|
||||
|
||||
if (this.hasImageTarget && variant.image) {
|
||||
this.imageTarget.src = variant.image
|
||||
}
|
||||
|
||||
this.swatchTargets.forEach(swatch => {
|
||||
const isSelected = parseInt(swatch.dataset.variantId) === id
|
||||
swatch.classList.toggle('is-selected', isSelected)
|
||||
swatch.setAttribute('aria-pressed', String(isSelected))
|
||||
|
||||
if (isSelected && this.hasColorNameTarget) {
|
||||
this.colorNameTarget.textContent = swatch.getAttribute('aria-label')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
const SCROLL_STEP = 1;
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["main", "thumb", "track", "lightbox", "lightboxImage", "lightboxCounter"];
|
||||
|
||||
connect() {
|
||||
this.offset = 0;
|
||||
this.lightboxIndex = 0;
|
||||
this.#syncHeight();
|
||||
|
||||
this.mainTarget.addEventListener("load", () => this.#syncHeight());
|
||||
|
||||
this._onKeydown = this.#handleKeydown.bind(this);
|
||||
document.addEventListener("keydown", this._onKeydown);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("keydown", this._onKeydown);
|
||||
}
|
||||
|
||||
select(event) {
|
||||
const btn = event.currentTarget;
|
||||
this.#setThumb(btn);
|
||||
this.lightboxIndex = this.thumbTargets.indexOf(btn);
|
||||
}
|
||||
|
||||
scrollUp() {
|
||||
this.offset = Math.max(0, this.offset - SCROLL_STEP);
|
||||
this.#applyScroll();
|
||||
}
|
||||
|
||||
scrollDown() {
|
||||
this.offset = Math.min(this.thumbTargets.length - 1, this.offset + SCROLL_STEP);
|
||||
this.#applyScroll();
|
||||
}
|
||||
|
||||
closeLightboxOnBackdrop(event) {
|
||||
// Only close if clicking directly on the backdrop (the popover div itself),
|
||||
// not on the image, arrows, close button, or counter
|
||||
if (event.target === this.lightboxTarget) {
|
||||
this.lightboxTarget.hidePopover();
|
||||
}
|
||||
}
|
||||
|
||||
noop(event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
openLightbox() {
|
||||
this.#updateLightbox(this.lightboxIndex);
|
||||
this.lightboxTarget.showPopover();
|
||||
}
|
||||
|
||||
prevImage() {
|
||||
const next = (this.lightboxIndex - 1 + this.thumbTargets.length) % this.thumbTargets.length;
|
||||
this.#updateLightbox(next);
|
||||
}
|
||||
|
||||
nextImage() {
|
||||
const next = (this.lightboxIndex + 1) % this.thumbTargets.length;
|
||||
this.#updateLightbox(next);
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────
|
||||
|
||||
#updateLightbox(index) {
|
||||
this.lightboxIndex = index;
|
||||
const thumb = this.thumbTargets[index];
|
||||
if (!thumb) return;
|
||||
|
||||
this.lightboxImageTarget.src = thumb.dataset.src;
|
||||
this.lightboxImageTarget.alt = thumb.dataset.alt;
|
||||
this.lightboxCounterTarget.textContent =
|
||||
`${index + 1} of ${this.thumbTargets.length}`;
|
||||
}
|
||||
|
||||
#setThumb(btn) {
|
||||
const src = btn.dataset.src;
|
||||
const alt = btn.dataset.alt;
|
||||
|
||||
this.mainTarget.src = src;
|
||||
this.mainTarget.alt = alt;
|
||||
|
||||
this.thumbTargets.forEach((t) => {
|
||||
const active = t === btn;
|
||||
t.classList.toggle("border-2", active);
|
||||
t.classList.toggle("border-1", !active);
|
||||
t.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
}
|
||||
|
||||
#applyScroll() {
|
||||
if (!this.thumbTargets.length) return;
|
||||
const thumbHeight = this.thumbTargets[0].offsetHeight;
|
||||
const gap = 16; // gap-4 = 16px
|
||||
this.trackTarget.scrollTop = this.offset * (thumbHeight + gap);
|
||||
}
|
||||
|
||||
#syncHeight() {
|
||||
const h = this.mainTarget.offsetHeight;
|
||||
if (h > 0) {
|
||||
this.trackTarget.style.maxHeight = h + "px";
|
||||
}
|
||||
}
|
||||
|
||||
#handleKeydown(e) {
|
||||
if (!this.hasLightboxTarget) return;
|
||||
if (!this.lightboxTarget.matches(":popover-open")) return;
|
||||
|
||||
if (e.key === "ArrowLeft") { e.preventDefault(); this.prevImage(); }
|
||||
if (e.key === "ArrowRight") { e.preventDefault(); this.nextImage(); }
|
||||
if (e.key === "Escape") { this.lightboxTarget.hidePopover(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['input', 'decrement']
|
||||
static values = { min: { type: Number, default: 1 } }
|
||||
|
||||
connect() {
|
||||
this.#updateDecrement()
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.#setValue(this.#current + 1)
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.#setValue(this.#current - 1)
|
||||
}
|
||||
|
||||
clamp() {
|
||||
this.#setValue(this.#current)
|
||||
}
|
||||
|
||||
get #current() {
|
||||
return parseInt(this.inputTarget.value, 10) || this.minValue
|
||||
}
|
||||
|
||||
#setValue(val) {
|
||||
const clamped = Math.max(this.minValue, val)
|
||||
this.inputTarget.value = clamped
|
||||
this.#updateDecrement()
|
||||
this.inputTarget.dispatchEvent(new Event('quantity:change', { bubbles: true }))
|
||||
}
|
||||
|
||||
#updateDecrement() {
|
||||
const atMin = this.#current <= this.minValue
|
||||
this.decrementTarget.disabled = atMin
|
||||
this.decrementTarget.setAttribute('aria-disabled', atMin)
|
||||
}
|
||||
}
|
||||
@@ -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,31 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['star', 'input']
|
||||
static values = { rating: { type: Number, default: 0 } }
|
||||
|
||||
hover(event) {
|
||||
this.#fill(parseInt(event.currentTarget.dataset.value))
|
||||
}
|
||||
|
||||
leave() {
|
||||
this.#fill(this.ratingValue)
|
||||
}
|
||||
|
||||
select(event) {
|
||||
const val = parseInt(event.currentTarget.dataset.value)
|
||||
this.ratingValue = val
|
||||
this.inputTarget.value = val
|
||||
|
||||
this.starTargets.forEach(star => {
|
||||
star.setAttribute('aria-pressed', String(parseInt(star.dataset.value) === val))
|
||||
})
|
||||
}
|
||||
|
||||
#fill(upTo) {
|
||||
this.starTargets.forEach(star => {
|
||||
const filled = parseInt(star.dataset.value) <= upTo
|
||||
star.querySelector('svg').setAttribute('fill', filled ? 'currentColor' : 'none')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['button', 'panel']
|
||||
|
||||
show(event) {
|
||||
this.#activate(event.currentTarget.dataset.panel)
|
||||
}
|
||||
|
||||
#activate(panelId) {
|
||||
this.buttonTargets.forEach(btn => {
|
||||
const active = btn.dataset.panel === panelId
|
||||
btn.classList.toggle('is-active', active)
|
||||
btn.setAttribute('aria-selected', String(active))
|
||||
})
|
||||
|
||||
this.panelTargets.forEach(panel => {
|
||||
panel.hidden = panel.id !== `tab-panel-${panelId}`
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Mirrors App\Support\Price::format() — keep both in sync. Greek formatting:
|
||||
// comma as the decimal separator, dot as the thousands separator, and no
|
||||
// decimals shown unless the amount actually needs them.
|
||||
// 19.00 -> "€19", 19.50 -> "€19,5", 19.55 -> "€19,55"
|
||||
export function formatPrice(amount, currency = '€') {
|
||||
if (amount === null || amount === undefined) return null
|
||||
|
||||
const value = Math.round(parseFloat(amount) * 100) / 100
|
||||
const cents = Math.round(value * 100)
|
||||
|
||||
const decimals = cents % 100 === 0 ? 0 : (cents % 10 === 0 ? 1 : 2)
|
||||
|
||||
const formatted = value.toLocaleString('el-GR', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
})
|
||||
|
||||
return currency + formatted
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Strip Greek tonos (accent marks) from text inside elements that have the
|
||||
* `uppercase` CSS class.
|
||||
*
|
||||
* In Greek typography, capital letters do not carry the tonos accent.
|
||||
* CSS `text-transform: uppercase` uppercases the glyph but leaves the tonos
|
||||
* in place, producing visually incorrect output (e.g. Ά instead of Α).
|
||||
* This utility rewrites the text nodes directly so the rendered result is clean.
|
||||
*
|
||||
* The diaeresis (ϊ, ϋ) is preserved — it is retained in Greek uppercase.
|
||||
*
|
||||
* Runs once on DOMContentLoaded and then watches for dynamically added nodes
|
||||
* via MutationObserver.
|
||||
*/
|
||||
|
||||
const ACCENT_MAP = {
|
||||
// Lowercase with tonos → without tonos
|
||||
'ά': 'α', 'έ': 'ε', 'ή': 'η', 'ί': 'ι', 'ό': 'ο', 'ύ': 'υ', 'ώ': 'ω',
|
||||
// Uppercase with tonos → without tonos (for already-uppercased text)
|
||||
'Ά': 'Α', 'Έ': 'Ε', 'Ή': 'Η', 'Ί': 'Ι', 'Ό': 'Ο', 'Ύ': 'Υ', 'Ώ': 'Ω',
|
||||
// Combined tonos + diaeresis → diaeresis only (preserve the diaeresis)
|
||||
'ΐ': 'ϊ', 'ΰ': 'ϋ',
|
||||
}
|
||||
|
||||
const ACCENT_RE = /[άέήίόύώΆΈΉΊΌΎΏΐΰ]/g
|
||||
|
||||
function stripAccents(str) {
|
||||
return str.replace(ACCENT_RE, c => ACCENT_MAP[c] ?? c)
|
||||
}
|
||||
|
||||
function processElement(el) {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||
let node
|
||||
while ((node = walker.nextNode())) {
|
||||
const val = node.nodeValue
|
||||
if (val && /[άέήίόύώΆΈΉΊΌΎΏΐΰ]/.test(val)) {
|
||||
node.nodeValue = stripAccents(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyToPage() {
|
||||
document.querySelectorAll('.uppercase').forEach(processElement)
|
||||
}
|
||||
|
||||
// ── Initial pass ──────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', applyToPage)
|
||||
|
||||
// ── Observe dynamic additions ─────────────────────────────────────────────────
|
||||
const observer = new MutationObserver(mutations => {
|
||||
for (const { addedNodes } of mutations) {
|
||||
for (const node of addedNodes) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) continue
|
||||
if (node.classList?.contains('uppercase')) processElement(node)
|
||||
node.querySelectorAll?.('.uppercase').forEach(processElement)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
name: "Πολιτική Cookies"
|
||||
slug: "cookies-policy"
|
||||
|
||||
meta_title: ""
|
||||
meta_description: ""
|
||||
og_image: ""
|
||||
noindex: true
|
||||
|
||||
---
|
||||
|
||||
|
||||
Η παρούσα Πολιτική Cookies εξηγεί πώς η 3DEALER χρησιμοποιεί cookies και παρόμοιες τεχνολογίες κατά την επίσκεψή σας στο ηλεκτρονικό κατάστημα.
|
||||
|
||||
## 1. Τι είναι τα cookies
|
||||
|
||||
Τα cookies είναι μικρά αρχεία κειμένου που αποθηκεύονται στη συσκευή σας όταν επισκέπτεστε έναν ιστότοπο. Χρησιμοποιούνται για την ορθή λειτουργία του ιστότοπου, την αποθήκευση προτιμήσεων, τη συλλογή στατιστικών στοιχείων και, όπου επιτρέπεται, την προβολή ή μέτρηση διαφημίσεων.
|
||||
|
||||
Εκτός από cookies, ενδέχεται να χρησιμοποιούμε και παρόμοιες τεχνολογίες, όπως pixels και tags.
|
||||
|
||||
## 2. Ποια cookies χρησιμοποιούμε
|
||||
|
||||
Η 3DEALER χρησιμοποιεί διαφορετικές κατηγορίες cookies, ανάλογα με τον σκοπό τους.
|
||||
|
||||
### Απαραίτητα cookies
|
||||
|
||||
Τα απαραίτητα cookies είναι αυτά που απαιτούνται για τη σωστή και ασφαλή λειτουργία του ηλεκτρονικού καταστήματος.
|
||||
|
||||
Μπορούν να χρησιμοποιούνται, μεταξύ άλλων, για:
|
||||
|
||||
* τη λειτουργία του καλαθιού αγορών,
|
||||
* τη διατήρηση της συνεδρίας σας,
|
||||
* τη σύνδεση στον λογαριασμό σας,
|
||||
* την ολοκλήρωση του checkout,
|
||||
* την ασφάλεια του ιστοτόπου,
|
||||
* την αποθήκευση των επιλογών σας σχετικά με τα cookies.
|
||||
|
||||
Τα συγκεκριμένα cookies δεν μπορούν να απενεργοποιηθούν μέσω του μηχανισμού συγκατάθεσης, καθώς χωρίς αυτά ορισμένες βασικές λειτουργίες του ηλεκτρονικού καταστήματος δεν θα είναι διαθέσιμες.
|
||||
|
||||
### Cookies λειτουργικότητας
|
||||
|
||||
Τα cookies λειτουργικότητας χρησιμοποιούνται για την αποθήκευση επιλογών και προτιμήσεων που βελτιώνουν την εμπειρία χρήσης του ηλεκτρονικού καταστήματος.
|
||||
|
||||
**@TODO: Να καταγραφούν τα συγκεκριμένα cookies λειτουργικότητας που χρησιμοποιεί το Boboko/3DEALER.**
|
||||
|
||||
### Cookies Analytics
|
||||
|
||||
Τα cookies analytics χρησιμοποιούνται για να κατανοούμε τον τρόπο με τον οποίο οι επισκέπτες χρησιμοποιούν το ηλεκτρονικό κατάστημα.
|
||||
|
||||
Η 3DEALER χρησιμοποιεί υπηρεσίες όπως το **Google Analytics** και ενδέχεται να χρησιμοποιεί το **Google Tag Manager** για τη διαχείριση των σχετικών tags.
|
||||
|
||||
Οι πληροφορίες που συλλέγονται μπορούν να περιλαμβάνουν στοιχεία όπως:
|
||||
|
||||
* ποιες σελίδες επισκέπτεστε,
|
||||
* πόσο χρόνο παραμένετε στον ιστότοπο,
|
||||
* από ποια σελίδα προέρχεστε,
|
||||
* τη συσκευή και το πρόγραμμα περιήγησης που χρησιμοποιείτε,
|
||||
* γενικές πληροφορίες σχετικά με την αλληλεπίδρασή σας με τον ιστότοπο.
|
||||
|
||||
Τα συγκεκριμένα cookies ενεργοποιούνται μόνο σύμφωνα με τις επιλογές συγκατάθεσής σας, όπου απαιτείται από την ισχύουσα νομοθεσία.
|
||||
|
||||
### Cookies διαφήμισης και marketing
|
||||
|
||||
Η 3DEALER χρησιμοποιεί τεχνολογίες διαφημιστικής μέτρησης και marketing, όπως:
|
||||
|
||||
* **Meta Pixel**
|
||||
* **TikTok Pixel**
|
||||
|
||||
Οι τεχνολογίες αυτές μπορούν να χρησιμοποιούνται για τη μέτρηση της αποτελεσματικότητας των διαφημιστικών ενεργειών, την κατανόηση των αλληλεπιδράσεων με τις διαφημίσεις και, όπου υποστηρίζεται και επιτρέπεται, την προβολή πιο σχετικών διαφημίσεων.
|
||||
|
||||
Τα σχετικά cookies και pixels ενεργοποιούνται μόνο σύμφωνα με τις επιλογές συγκατάθεσής σας, όπου απαιτείται.
|
||||
|
||||
**@TODO: Να επιβεβαιωθούν τα ακριβή Meta/TikTok cookies και events που χρησιμοποιούνται στο production.**
|
||||
|
||||
## 3. Πίνακας cookies
|
||||
|
||||
Ο παρακάτω πίνακας θα πρέπει να ενημερωθεί με βάση τα πραγματικά cookies που χρησιμοποιούνται στο production περιβάλλον.
|
||||
|
||||
| Cookie / Τεχνολογία | Πάροχος | Κατηγορία | Σκοπός | Διάρκεια |
|
||||
| ------------------- | ---------------- | ---------- | ---------------------------------------------- | -------- |
|
||||
| @TODO | Boboko / 3DEALER | Απαραίτητο | Λειτουργία καταστήματος | @TODO |
|
||||
| @TODO | Boboko / 3DEALER | Απαραίτητο | Καλάθι / checkout | @TODO |
|
||||
| @TODO | Google | Analytics | Στατιστικά χρήσης | @TODO |
|
||||
| @TODO | Meta | Marketing | Διαφημιστική μέτρηση | @TODO |
|
||||
| @TODO | TikTok | Marketing | Διαφημιστική μέτρηση | @TODO |
|
||||
| @TODO | hCaptcha | Ασφάλεια | Προστασία από αυτοματοποιημένη/κακόβουλη χρήση | @TODO |
|
||||
|
||||
**@TODO: Ο πίνακας πρέπει να συμπληρωθεί μετά από έλεγχο του production site, ιδανικά με cookie scan.**
|
||||
|
||||
## 4. Συγκατάθεση για τη χρήση cookies
|
||||
|
||||
Κατά την πρώτη επίσκεψή σας στο ηλεκτρονικό κατάστημα, εμφανίζεται μηχανισμός διαχείρισης συγκατάθεσης cookies, μέσω του οποίου μπορείτε να επιλέξετε ποιες κατηγορίες μη απαραίτητων cookies επιτρέπετε.
|
||||
|
||||
Μπορείτε να:
|
||||
|
||||
* αποδεχτείτε όλα τα cookies,
|
||||
* απορρίψετε τα μη απαραίτητα cookies,
|
||||
* επιλέξετε συγκεκριμένες κατηγορίες cookies.
|
||||
|
||||
Τα απαραίτητα cookies παραμένουν ενεργά, καθώς είναι αναγκαία για τη λειτουργία του ηλεκτρονικού καταστήματος.
|
||||
|
||||
Η επιλογή σας αποθηκεύεται και εφαρμόζεται στις επόμενες επισκέψεις σας.
|
||||
|
||||
## 5. Ανάκληση ή αλλαγή συγκατάθεσης
|
||||
|
||||
Μπορείτε να αλλάξετε ή να ανακαλέσετε τη συγκατάθεσή σας για τη χρήση μη απαραίτητων cookies οποιαδήποτε στιγμή.
|
||||
|
||||
**@TODO: Να προστεθεί ο τρόπος με τον οποίο ανοίγει ξανά το cookie preference centre του Boboko, π.χ. μέσω συνδέσμου «Ρυθμίσεις Cookies» στο footer.**
|
||||
|
||||
Η ανάκληση της συγκατάθεσης δεν επηρεάζει τη νομιμότητα της επεξεργασίας που πραγματοποιήθηκε πριν από την ανάκλησή της.
|
||||
|
||||
## 6. Cookies τρίτων
|
||||
|
||||
Ορισμένες από τις υπηρεσίες που χρησιμοποιούμε παρέχονται από τρίτους, όπως η Google, η Meta, η TikTok και η hCaptcha.
|
||||
|
||||
Οι τρίτοι αυτοί πάροχοι ενδέχεται να επεξεργάζονται πληροφορίες που συλλέγονται μέσω cookies ή παρόμοιων τεχνολογιών σύμφωνα με τις δικές τους πολιτικές και τους δικούς τους όρους.
|
||||
|
||||
Η χρήση υπηρεσιών τρίτων μπορεί επίσης να συνεπάγεται διαβίβαση δεδομένων εκτός του Ευρωπαϊκού Οικονομικού Χώρου. Όπου απαιτείται, εφαρμόζονται οι προβλεπόμενες από τη νομοθεσία εγγυήσεις για τη συγκεκριμένη διαβίβαση.
|
||||
|
||||
## 7. Cookies και προσωπικά δεδομένα
|
||||
|
||||
Ορισμένα cookies ή παρόμοιες τεχνολογίες μπορεί να οδηγήσουν σε επεξεργασία πληροφοριών που θεωρούνται προσωπικά δεδομένα.
|
||||
|
||||
Για περισσότερες πληροφορίες σχετικά με τον τρόπο με τον οποίο η 3DEALER συλλέγει και επεξεργάζεται προσωπικά δεδομένα, δείτε την **Πολιτική Απορρήτου**.
|
||||
|
||||
## 8. Ρυθμίσεις του browser
|
||||
|
||||
Μπορείτε επίσης να περιορίσετε ή να διαγράψετε cookies μέσω των ρυθμίσεων του προγράμματος περιήγησής σας.
|
||||
|
||||
Ωστόσο, η απενεργοποίηση ορισμένων cookies ενδέχεται να επηρεάσει τη λειτουργία του ηλεκτρονικού καταστήματος ή να εμποδίσει τη σωστή λειτουργία ορισμένων υπηρεσιών.
|
||||
|
||||
## 9. Τροποποιήσεις της Πολιτικής Cookies
|
||||
|
||||
Η παρούσα Πολιτική Cookies μπορεί να τροποποιείται όταν αλλάζουν οι τεχνολογίες που χρησιμοποιούμε, οι υπηρεσίες του ηλεκτρονικού καταστήματος ή η σχετική νομοθεσία.
|
||||
|
||||
Η πιο πρόσφατη έκδοση θα είναι πάντοτε διαθέσιμη στην ιστοσελίδα της 3DEALER.
|
||||
|
||||
**Τελευταία ενημέρωση:** @TODO
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: "Αρχική"
|
||||
slug: "home"
|
||||
|
||||
meta_title: "3dealer — Ό,τι φαντάζεσαι, τυπωμένο σε 3D"
|
||||
meta_description: "Gaming φιγούρες, κρανία, κλειδοθήκες και ό,τι πιο τρελό σου περνάει απ' το μυαλό. Σχεδιασμένα και τυπωμένα σε 3D, ένα προς ένα."
|
||||
og_image: ""
|
||||
|
||||
trivia_image: "3dealer-clickers-square.jpeg"
|
||||
trivia_body: "Gaming φιγούρες, κρανία, κλειδοθήκες, dark humor και ό,τι πιο τρελό σου περνάει απ' το μυαλό, όλα σχεδιασμένα και τυπωμένα εδώ, στην Ελλάδα."
|
||||
trivia_body_image: "3d-printed-hands-heart-1200.png"
|
||||
classics_title: "Θρυλικά & ασυναγώνιστα"
|
||||
---
|
||||
|
||||
# Content
|
||||
@@ -0,0 +1,202 @@
|
||||
---
|
||||
name: "Πολιτική Απορρήτου"
|
||||
slug: "privacy-policy"
|
||||
|
||||
meta_title: ""
|
||||
meta_description: ""
|
||||
og_image: ""
|
||||
noindex: true
|
||||
|
||||
---
|
||||
|
||||
|
||||
Η 3DEALER («3DEALER», «εμείς», «μας») σέβεται την ιδιωτικότητα των επισκεπτών και των πελατών της και προστατεύει τα προσωπικά τους δεδομένα σύμφωνα με την ισχύουσα νομοθεσία, συμπεριλαμβανομένου του Γενικού Κανονισμού Προστασίας Δεδομένων (ΕΕ) 2016/679 («GDPR») και της σχετικής ελληνικής νομοθεσίας.
|
||||
|
||||
Η παρούσα Πολιτική Απορρήτου εξηγεί ποια προσωπικά δεδομένα συλλέγουμε, για ποιους σκοπούς τα χρησιμοποιούμε, με ποιους μπορεί να τα μοιραζόμαστε και ποια δικαιώματα έχετε.
|
||||
|
||||
## 1. Υπεύθυνος επεξεργασίας
|
||||
|
||||
Υπεύθυνος επεξεργασίας των προσωπικών δεδομένων που συλλέγονται μέσω του ηλεκτρονικού καταστήματος είναι:
|
||||
|
||||
**3DEALER**
|
||||
Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα
|
||||
ΑΦΜ: @TODO
|
||||
ΓΕΜΗ: @TODO
|
||||
Email: [print@3dealer.gr](mailto:print@3dealer.gr)
|
||||
Τηλέφωνο: 6976 443140
|
||||
|
||||
Η 3DEALER καθορίζει τους σκοπούς και τα μέσα επεξεργασίας των προσωπικών δεδομένων των πελατών και χρηστών του ηλεκτρονικού καταστήματος.
|
||||
|
||||
## 2. Ποια δεδομένα συλλέγουμε
|
||||
|
||||
Ανάλογα με τον τρόπο με τον οποίο χρησιμοποιείτε το ηλεκτρονικό κατάστημα, ενδέχεται να συλλέγουμε:
|
||||
|
||||
* ονοματεπώνυμο,
|
||||
* διεύθυνση email,
|
||||
* αριθμό τηλεφώνου,
|
||||
* διεύθυνση αποστολής και χρέωσης,
|
||||
* στοιχεία παραγγελίας και προϊόντων,
|
||||
* στοιχεία που σχετίζονται με την πληρωμή,
|
||||
* στοιχεία επικοινωνίας και το περιεχόμενο μηνυμάτων που μας αποστέλλετε,
|
||||
* στοιχεία λογαριασμού χρήστη,
|
||||
* κριτικές που επιλέγετε να δημοσιεύσετε,
|
||||
* τεχνικά δεδομένα σχετικά με τη χρήση της ιστοσελίδας,
|
||||
* δεδομένα που συλλέγονται μέσω cookies και παρόμοιων τεχνολογιών, σύμφωνα με την Πολιτική Cookies.
|
||||
|
||||
Δεν αποθηκεύουμε τα πλήρη στοιχεία της πιστωτικής ή χρεωστικής σας κάρτας στους διακομιστές της 3DEALER. Οι ηλεκτρονικές πληρωμές πραγματοποιούνται μέσω εξωτερικών παρόχων πληρωμών.
|
||||
|
||||
## 3. Δημιουργία λογαριασμού
|
||||
|
||||
Μπορείτε να δημιουργήσετε λογαριασμό στο ηλεκτρονικό κατάστημα για να διαχειρίζεστε τις παραγγελίες και τα στοιχεία σας.
|
||||
|
||||
Για τη δημιουργία και λειτουργία του λογαριασμού ενδέχεται να επεξεργαζόμαστε στοιχεία όπως το όνομα, email και στοιχεία σύνδεσης.
|
||||
|
||||
Η επεξεργασία αυτή είναι απαραίτητη για την παροχή της υπηρεσίας λογαριασμού.
|
||||
|
||||
Το ηλεκτρονικό κατάστημα υποστηρίζει επίσης guest checkout. Στην περίπτωση guest checkout δεν δημιουργείται λογαριασμός χρήστη. Τα στοιχεία που παρέχετε χρησιμοποιούνται αποκλειστικά στον βαθμό που είναι απαραίτητος για την επεξεργασία και ολοκλήρωση της παραγγελίας και για τις σχετικές νομικές υποχρεώσεις.
|
||||
|
||||
## 4. Παραγγελίες και αγορές
|
||||
|
||||
Όταν πραγματοποιείτε παραγγελία, επεξεργαζόμαστε τα απαραίτητα στοιχεία για:
|
||||
|
||||
* την καταχώριση και εκτέλεση της παραγγελίας,
|
||||
* την επεξεργασία της πληρωμής,
|
||||
* την αποστολή και παράδοση των προϊόντων,
|
||||
* την επικοινωνία μαζί σας σχετικά με την παραγγελία,
|
||||
* την έκδοση των απαραίτητων παραστατικών,
|
||||
* την τήρηση των φορολογικών και λογιστικών υποχρεώσεών μας,
|
||||
* την αντιμετώπιση τυχόν επιστροφών, ακυρώσεων ή παραπόνων.
|
||||
|
||||
Η νομική βάση για τις παραπάνω επεξεργασίες είναι κατά περίπτωση η εκτέλεση της σύμβασης, η συμμόρφωση με νομική υποχρέωση και το έννομο συμφέρον της 3DEALER για την ορθή λειτουργία και προστασία της επιχείρησής της.
|
||||
|
||||
## 5. Επικοινωνία
|
||||
|
||||
Εάν επικοινωνήσετε μαζί μας μέσω email, τηλεφώνου ή της φόρμας επικοινωνίας, επεξεργαζόμαστε τα στοιχεία που μας παρέχετε προκειμένου να απαντήσουμε στο αίτημά σας.
|
||||
|
||||
Η νομική βάση είναι, ανάλογα με την περίπτωση, η λήψη μέτρων κατόπιν αιτήματός σας πριν από τη σύναψη σύμβασης, η εκτέλεση σύμβασης ή το έννομο συμφέρον μας για την εξυπηρέτηση των χρηστών και πελατών μας.
|
||||
|
||||
## 6. Newsletter και εμπορική επικοινωνία
|
||||
|
||||
Εφόσον επιλέξετε να εγγραφείτε στο newsletter της 3DEALER, χρησιμοποιούμε τη διεύθυνση email σας για την αποστολή ενημερώσεων, προσφορών και άλλου εμπορικού περιεχομένου.
|
||||
|
||||
Η εγγραφή στο newsletter πραγματοποιείται με τη συγκατάθεσή σας.
|
||||
|
||||
Μπορείτε να ανακαλέσετε τη συγκατάθεσή σας οποιαδήποτε στιγμή, χρησιμοποιώντας τον σύνδεσμο απεγγραφής που περιλαμβάνεται στα emails ή επικοινωνώντας μαζί μας στο [print@3dealer.gr](mailto:print@3dealer.gr).
|
||||
|
||||
Η ανάκληση της συγκατάθεσης δεν επηρεάζει τη νομιμότητα της επεξεργασίας που πραγματοποιήθηκε πριν από αυτήν.
|
||||
|
||||
## 7. Κριτικές προϊόντων
|
||||
|
||||
Εφόσον επιλέξετε να υποβάλετε κριτική για ένα προϊόν, ενδέχεται να δημοσιεύσουμε το όνομα ή άλλο στοιχείο που επιλέξατε να εμφανίζεται μαζί με την κριτική.
|
||||
|
||||
Παρακαλούμε να μην συμπεριλαμβάνετε προσωπικά δεδομένα δικά σας ή τρίτων στο περιεχόμενο μιας δημόσιας κριτικής, εφόσον δεν είναι απαραίτητο.
|
||||
|
||||
## 8. Πάροχοι πληρωμών
|
||||
|
||||
Για την ολοκλήρωση των πληρωμών ενδέχεται να χρησιμοποιούμε τρίτους παρόχους, όπως Stripe και PayPal, καθώς και τραπεζικά ιδρύματα για πληρωμές μέσω τραπεζικής κατάθεσης ή IRIS.
|
||||
|
||||
Τα δεδομένα που απαιτούνται για την ολοκλήρωση μιας πληρωμής ενδέχεται να διαβιβάζονται στον αντίστοιχο πάροχο πληρωμών.
|
||||
|
||||
**@TODO: Να επιβεβαιωθούν οι ακριβείς πάροχοι πληρωμών/τράπεζες που θα χρησιμοποιούνται στο production και να προστεθούν στην τελική έκδοση.**
|
||||
|
||||
Οι πάροχοι αυτοί επεξεργάζονται τα δεδομένα σύμφωνα με τις δικές τους πολιτικές απορρήτου και τους ισχύοντες όρους τους.
|
||||
|
||||
## 9. Εταιρείες μεταφοράς και παράδοσης
|
||||
|
||||
Για την αποστολή των παραγγελιών σας χρησιμοποιούμε υπηρεσίες μεταφοράς και παράδοσης, μεταξύ άλλων από την ELTA Courier και την BOX NOW.
|
||||
|
||||
Για την παράδοση μιας παραγγελίας ενδέχεται να διαβιβάζονται στον αντίστοιχο πάροχο στοιχεία όπως ονοματεπώνυμο, διεύθυνση, τηλέφωνο και στοιχεία που είναι απαραίτητα για την παράδοση.
|
||||
|
||||
## 10. Λογιστικές και φορολογικές υπηρεσίες
|
||||
|
||||
Τα στοιχεία των παραγγελιών και τα απαραίτητα στοιχεία των πελατών ενδέχεται να διαβιβάζονται στο σύστημα Epsilon που χρησιμοποιείται από την 3DEALER για την έκδοση και διαχείριση παραστατικών και για την εκπλήρωση των φορολογικών και λογιστικών υποχρεώσεών της.
|
||||
|
||||
## 11. Ασφάλεια και προστασία από κατάχρηση
|
||||
|
||||
Χρησιμοποιούμε τεχνικά και οργανωτικά μέτρα για την προστασία των προσωπικών δεδομένων από απώλεια, μη εξουσιοδοτημένη πρόσβαση, αλλοίωση ή παράνομη επεξεργασία.
|
||||
|
||||
Για την προστασία της ιστοσελίδας από κακόβουλη ή αυτοματοποιημένη χρήση ενδέχεται να χρησιμοποιούμε την υπηρεσία hCaptcha. Η χρήση της υπηρεσίας μπορεί να συνεπάγεται επεξεργασία τεχνικών δεδομένων σύμφωνα με την πολιτική απορρήτου του αντίστοιχου παρόχου.
|
||||
|
||||
## 12. Analytics και διαφημιστικές υπηρεσίες
|
||||
|
||||
Η 3DEALER χρησιμοποιεί υπηρεσίες ανάλυσης επισκεψιμότητας και διαφημιστικής μέτρησης, όπως:
|
||||
|
||||
* Google Analytics
|
||||
* Google Tag Manager
|
||||
* Meta Pixel
|
||||
* TikTok Pixel
|
||||
|
||||
Οι συγκεκριμένες τεχνολογίες ενδέχεται να χρησιμοποιούν cookies ή παρόμοιες τεχνολογίες για τη συλλογή πληροφοριών σχετικά με τη χρήση της ιστοσελίδας και την αποτελεσματικότητα των διαφημιστικών ενεργειών.
|
||||
|
||||
Οι μη απολύτως απαραίτητες τεχνολογίες ενεργοποιούνται μόνο σύμφωνα με τις επιλογές συγκατάθεσης που πραγματοποιείτε μέσω του μηχανισμού διαχείρισης cookies.
|
||||
|
||||
Περισσότερες πληροφορίες παρέχονται στην Πολιτική Cookies.
|
||||
|
||||
**@TODO: Να επιβεβαιωθούν τα ακριβή εργαλεία, οι λογαριασμοί και οι υπηρεσίες που θα είναι ενεργά στο production.**
|
||||
|
||||
## 13. Ποιοι μπορεί να έχουν πρόσβαση στα δεδομένα
|
||||
|
||||
Η 3DEALER δεν πωλεί ούτε εκμισθώνει τα προσωπικά σας δεδομένα.
|
||||
|
||||
Πρόσβαση στα προσωπικά δεδομένα μπορεί να έχουν, στον βαθμό που είναι απαραίτητο για την παροχή των αντίστοιχων υπηρεσιών:
|
||||
|
||||
* πάροχοι πληρωμών,
|
||||
* εταιρείες courier,
|
||||
* πάροχοι υπηρεσιών φιλοξενίας και τεχνικής υποστήριξης,
|
||||
* πάροχοι υπηρεσιών email/newsletter,
|
||||
* πάροχοι analytics και διαφημιστικών υπηρεσιών,
|
||||
* λογιστικές και φορολογικές υπηρεσίες,
|
||||
* δημόσιες αρχές και φορείς, όταν αυτό απαιτείται από τον νόμο.
|
||||
|
||||
Οι τρίτοι πάροχοι που επεξεργάζονται δεδομένα για λογαριασμό της 3DEALER χρησιμοποιούνται σύμφωνα με τις απαιτήσεις της ισχύουσας νομοθεσίας περί προστασίας προσωπικών δεδομένων.
|
||||
|
||||
## 14. Διαβιβάσεις εκτός Ευρωπαϊκού Οικονομικού Χώρου
|
||||
|
||||
Ορισμένοι από τους παρόχους που χρησιμοποιούμε ενδέχεται να επεξεργάζονται δεδομένα εκτός του Ευρωπαϊκού Οικονομικού Χώρου.
|
||||
|
||||
Σε αυτές τις περιπτώσεις, η 3DEALER λαμβάνει τα κατάλληλα μέτρα ώστε η διαβίβαση να πραγματοποιείται σύμφωνα με τις απαιτήσεις του GDPR και να παρέχεται το απαιτούμενο επίπεδο προστασίας των προσωπικών δεδομένων.
|
||||
|
||||
**@TODO: Να ελεγχθούν οι ακριβείς χώρες επεξεργασίας και οι μηχανισμοί διαβίβασης για Stripe, PayPal, Google, Meta, TikTok, hCaptcha και τον πάροχο newsletter.**
|
||||
|
||||
## 15. Χρόνος διατήρησης
|
||||
|
||||
Διατηρούμε τα προσωπικά δεδομένα μόνο για όσο διάστημα είναι απαραίτητο για τον σκοπό για τον οποίο συλλέχθηκαν.
|
||||
|
||||
Τα δεδομένα που σχετίζονται με παραγγελίες και συναλλαγές διατηρούνται για όσο απαιτείται από τη φορολογική, λογιστική και εμπορική νομοθεσία.
|
||||
|
||||
Τα δεδομένα λογαριασμού διατηρούνται για όσο ο λογαριασμός παραμένει ενεργός, εκτός εάν υπάρχει νόμιμος λόγος για μεγαλύτερη διατήρηση.
|
||||
|
||||
Τα δεδομένα που χρησιμοποιούνται για newsletter διατηρούνται μέχρι την ανάκληση της συγκατάθεσης ή την απεγγραφή σας, εκτός εάν υπάρχει άλλος νόμιμος λόγος διατήρησης.
|
||||
|
||||
Τα δεδομένα επικοινωνίας διατηρούνται για όσο είναι απαραίτητο για την εξυπηρέτηση του αιτήματος και, όπου απαιτείται, για την προστασία των νόμιμων συμφερόντων μας.
|
||||
|
||||
## 16. Τα δικαιώματά σας
|
||||
|
||||
Σύμφωνα με τον GDPR, έχετε, υπό τις προϋποθέσεις που προβλέπει η νομοθεσία, δικαίωμα:
|
||||
|
||||
* πρόσβασης στα προσωπικά σας δεδομένα,
|
||||
* διόρθωσης ανακριβών ή ελλιπών δεδομένων,
|
||||
* διαγραφής των δεδομένων σας,
|
||||
* περιορισμού της επεξεργασίας,
|
||||
* φορητότητας των δεδομένων,
|
||||
* εναντίωσης σε συγκεκριμένες μορφές επεξεργασίας,
|
||||
* ανάκλησης της συγκατάθεσής σας, όταν η επεξεργασία βασίζεται σε συγκατάθεση.
|
||||
|
||||
Η άσκηση ενός δικαιώματος δεν σημαίνει ότι αυτό μπορεί να εφαρμοστεί σε κάθε περίπτωση. Για παράδειγμα, ενδέχεται να είμαστε υποχρεωμένοι να διατηρήσουμε ορισμένα δεδομένα λόγω φορολογικής ή άλλης νόμιμης υποχρέωσης.
|
||||
|
||||
Για την άσκηση των δικαιωμάτων σας μπορείτε να επικοινωνήσετε μαζί μας:
|
||||
|
||||
**Email:** [print@3dealer.gr](mailto:print@3dealer.gr)
|
||||
**Τηλέφωνο:** 6976 443140
|
||||
|
||||
Θα απαντήσουμε στο αίτημά σας χωρίς αδικαιολόγητη καθυστέρηση και, κατά κανόνα, εντός ενός (1) μήνα από την παραλαβή του.
|
||||
|
||||
## 17. Δικαίωμα υποβολής καταγγελίας
|
||||
|
||||
Εάν θεωρείτε ότι η επεξεργασία των προσωπικών σας δεδομένων παραβιάζει την ισχύουσα νομοθεσία, έχετε δικαίωμα να υποβάλετε καταγγελία στην Αρχή Προστασίας Δεδομένων Προσωπικού Χαρακτήρα.
|
||||
|
||||
## 18. Αλλαγές στην Πολιτική Απορρήτου
|
||||
|
||||
Η παρούσα Πολιτική Απορρήτου μπορεί να τροποποιείται όταν απαιτείται, για παράδειγμα λόγω αλλαγών στη νομοθεσία, στις υπηρεσίες που χρησιμοποιούμε ή στον τρόπο λειτουργίας του ηλεκτρονικού καταστήματος.
|
||||
|
||||
Η πιο πρόσφατη έκδοση θα είναι πάντοτε διαθέσιμη στην ιστοσελίδα μας.
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: "Αποστολές & Επιστροφές"
|
||||
slug: "shipping-returns"
|
||||
|
||||
meta_title: ""
|
||||
meta_description: ""
|
||||
og_image: ""
|
||||
noindex: true
|
||||
|
||||
---
|
||||
|
||||
## 1. Προετοιμασία παραγγελιών
|
||||
|
||||
Όλα τα προϊόντα της 3DEALER εκτυπώνονται και προετοιμάζονται κατά παραγγελία.
|
||||
|
||||
Ο συνήθης χρόνος προετοιμασίας μιας παραγγελίας είναι **2–7 εργάσιμες ημέρες**.
|
||||
|
||||
Ο χρόνος προετοιμασίας δεν περιλαμβάνει τον χρόνο μεταφοράς από την εταιρεία courier.
|
||||
|
||||
Σε περιόδους αυξημένης ζήτησης, ενδέχεται να υπάρξουν καθυστερήσεις. Σε περίπτωση σημαντικής καθυστέρησης, η 3DEALER θα ενημερώσει τον πελάτη.
|
||||
|
||||
## 2. Αποστολές
|
||||
|
||||
Οι παραγγελίες αποστέλλονται μέσω:
|
||||
|
||||
* ELTA Courier
|
||||
* BOX NOW
|
||||
|
||||
Αποστολές πραγματοποιούνται στις περιοχές που υποστηρίζονται από το ηλεκτρονικό κατάστημα:
|
||||
|
||||
* Ελλάδα
|
||||
* Κύπρος
|
||||
|
||||
Το διαθέσιμο κόστος και οι επιλογές αποστολής εμφανίζονται κατά την ολοκλήρωση της παραγγελίας.
|
||||
|
||||
**Κόστος αποστολής:** @TODO — οι τιμές αποστολής ορίζονται δυναμικά από τις ρυθμίσεις του Καταστήματος.
|
||||
|
||||
Δεν παρέχεται δωρεάν αποστολή.
|
||||
|
||||
Δεν υποστηρίζεται πληρωμή με αντικαταβολή.
|
||||
|
||||
## 3. Χρόνος παράδοσης
|
||||
|
||||
Ο χρόνος παράδοσης εξαρτάται από την εταιρεία courier και τον προορισμό.
|
||||
|
||||
Η 3DEALER δεν ευθύνεται για καθυστερήσεις που οφείλονται αποκλειστικά στην εταιρεία μεταφοράς, σε ακραία καιρικά φαινόμενα, απεργίες, προβλήματα στο δίκτυο μεταφορών ή άλλες περιστάσεις που βρίσκονται εκτός του εύλογου ελέγχου της.
|
||||
|
||||
Σε περίπτωση καθυστέρησης, μπορείτε να επικοινωνήσετε μαζί μας στο [print@3dealer.gr](mailto:print@3dealer.gr).
|
||||
|
||||
## 4. Παραλαβή παραγγελίας
|
||||
|
||||
Ο πελάτης είναι υπεύθυνος για την ορθότητα των στοιχείων παράδοσης που καταχωρίζει κατά την παραγγελία.
|
||||
|
||||
Σε περίπτωση λανθασμένης ή ελλιπούς διεύθυνσης, η παράδοση ενδέχεται να καθυστερήσει ή να απαιτηθεί νέα αποστολή. Τυχόν πρόσθετα έξοδα που προκύπτουν από λανθασμένα στοιχεία παράδοσης ενδέχεται να επιβαρύνουν τον πελάτη.
|
||||
|
||||
## 5. Προϊόν που παραδόθηκε κατεστραμμένο
|
||||
|
||||
Σε περίπτωση που το προϊόν παραδοθεί εμφανώς κατεστραμμένο, παρακαλούμε να επικοινωνήσετε μαζί μας το συντομότερο δυνατό στο [print@3dealer.gr](mailto:print@3dealer.gr) και να μας αποστείλετε φωτογραφίες του προϊόντος και της συσκευασίας.
|
||||
|
||||
Η 3DEALER θα εξετάσει το περιστατικό και, εφόσον διαπιστωθεί ζημιά που προέκυψε κατά τη μεταφορά ή άλλη έλλειψη συμμόρφωσης, θα προχωρήσει στην κατάλληλη λύση σύμφωνα με την ισχύουσα νομοθεσία.
|
||||
|
||||
## 6. Δικαίωμα υπαναχώρησης
|
||||
|
||||
Για τις αγορές που εμπίπτουν στο πεδίο εφαρμογής του δικαιώματος υπαναχώρησης, ο καταναλωτής μπορεί να υπαναχωρήσει από τη σύμβαση εντός δεκατεσσάρων (14) ημερολογιακών ημερών από την ημέρα παραλαβής του προϊόντος.
|
||||
|
||||
Για την άσκηση του δικαιώματος υπαναχώρησης, ο πελάτης πρέπει να ενημερώσει την 3DEALER με σαφή δήλωση στο [print@3dealer.gr](mailto:print@3dealer.gr).
|
||||
|
||||
Το προϊόν πρέπει να επιστραφεί χωρίς αδικαιολόγητη καθυστέρηση και, σε κάθε περίπτωση, εντός δεκατεσσάρων (14) ημερών από την ημέρα κατά την οποία ο πελάτης ενημέρωσε την 3DEALER για την απόφασή του να υπαναχωρήσει.
|
||||
|
||||
Ο πελάτης φέρει το άμεσο κόστος επιστροφής του προϊόντος, εκτός εάν έχει συμφωνηθεί διαφορετικά ή η 3DEALER δεν έχει ενημερώσει προηγουμένως σχετικά.
|
||||
|
||||
Ο πελάτης ευθύνεται για τυχόν μείωση της αξίας του προϊόντος που προκύπτει από χειρισμό πέρα από αυτόν που είναι απαραίτητος για τη διαπίστωση της φύσης, των χαρακτηριστικών και της λειτουργίας του.
|
||||
|
||||
## 7. Εξαιρέσεις από το δικαίωμα υπαναχώρησης
|
||||
|
||||
Το δικαίωμα υπαναχώρησης δεν εφαρμόζεται στις περιπτώσεις που εξαιρούνται από την ισχύουσα νομοθεσία.
|
||||
|
||||
Ειδικότερα, μπορεί να μην εφαρμόζεται σε προϊόντα που κατασκευάζονται σύμφωνα με τις ειδικές προδιαγραφές του πελάτη ή είναι σαφώς εξατομικευμένα.
|
||||
|
||||
**@TODO: Να επιβεβαιωθεί ποια προϊόντα της 3DEALER θεωρούνται εξατομικευμένα/custom και εάν υπάρχουν προϊόντα που δεν υπόκεινται στο δικαίωμα υπαναχώρησης.**
|
||||
|
||||
Το γεγονός ότι ένα προϊόν παράγεται μετά την υποβολή της παραγγελίας δεν αποτελεί από μόνο του εξαίρεση από το δικαίωμα υπαναχώρησης.
|
||||
|
||||
## 8. Επιστροφή χρημάτων
|
||||
|
||||
Σε περίπτωση έγκυρης υπαναχώρησης, η 3DEALER επιστρέφει τα χρήματα που έλαβε από τον πελάτη για την αγορά, συμπεριλαμβανομένων, όπου απαιτείται από τη νομοθεσία, των βασικών εξόδων παράδοσης.
|
||||
|
||||
Η επιστροφή χρημάτων πραγματοποιείται με το ίδιο μέσο πληρωμής που χρησιμοποιήθηκε για την αρχική συναλλαγή, εκτός εάν συμφωνηθεί διαφορετικά.
|
||||
|
||||
Η 3DEALER μπορεί να καθυστερήσει την επιστροφή χρημάτων μέχρι να παραλάβει το επιστρεφόμενο προϊόν ή μέχρι ο πελάτης να αποδείξει ότι το έχει αποστείλει, όποιο συμβεί πρώτο.
|
||||
|
||||
## 9. Αλλαγές προϊόντων
|
||||
|
||||
**@TODO: Να επιβεβαιωθεί εάν η 3DEALER προσφέρει αλλαγές προϊόντων/μεγέθους ή εάν οι αλλαγές γίνονται αποκλειστικά μέσω επιστροφής και νέας παραγγελίας.**
|
||||
|
||||
Σε περίπτωση που προσφέρονται αλλαγές, η διαδικασία και τυχόν έξοδα αποστολής θα γνωστοποιούνται στον πελάτη πριν από την ολοκλήρωση της αλλαγής.
|
||||
|
||||
## 10. Ελαττωματικά ή μη συμμορφούμενα προϊόντα
|
||||
|
||||
Σε περίπτωση που ένα προϊόν είναι ελαττωματικό ή δεν ανταποκρίνεται στη σύμβαση, ο πελάτης έχει τα δικαιώματα που προβλέπει η ισχύουσα νομοθεσία περί πώλησης αγαθών και προστασίας καταναλωτή.
|
||||
|
||||
Η ύπαρξη μικρών αισθητικών χαρακτηριστικών που είναι φυσιολογικά για την τρισδιάστατη εκτύπωση, όπως γραμμές στρώσεων ή μικρές διαφοροποιήσεις, δεν αποτελεί από μόνη της ελάττωμα.
|
||||
|
||||
Για οποιοδήποτε πρόβλημα με προϊόν, επικοινωνήστε μαζί μας στο [print@3dealer.gr](mailto:print@3dealer.gr), αναφέροντας τον αριθμό παραγγελίας και, όπου είναι δυνατόν, επισυνάπτοντας φωτογραφίες.
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: "Όροι & Προϋποθέσεις"
|
||||
slug: "terms-and-conditions"
|
||||
|
||||
meta_title: ""
|
||||
meta_description: ""
|
||||
og_image: ""
|
||||
noindex: true
|
||||
|
||||
---
|
||||
|
||||
## 1. Γενικά
|
||||
|
||||
Το ηλεκτρονικό κατάστημα 3DEALER (εφεξής «το Κατάστημα» ή «3DEALER») λειτουργεί από την επιχείρηση 3DEALER, με έδρα στην Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα.
|
||||
|
||||
**ΑΦΜ:** @TODO
|
||||
**ΓΕΜΗ:** @TODO
|
||||
|
||||
Για οποιαδήποτε πληροφορία ή επικοινωνία σχετικά με παραγγελίες, προϊόντα ή τη λειτουργία του Καταστήματος, μπορείτε να επικοινωνείτε μαζί μας:
|
||||
|
||||
**Email:** [print@3dealer.gr](mailto:print@3dealer.gr)
|
||||
**Τηλέφωνο:** 6976 443140
|
||||
|
||||
Η χρήση του Καταστήματος και η πραγματοποίηση αγορών μέσω αυτού προϋποθέτουν την αποδοχή των παρόντων Όρων & Προϋποθέσεων.
|
||||
|
||||
Οι παρόντες όροι διέπουν τις αγορές που πραγματοποιούνται μέσω του ηλεκτρονικού καταστήματος και συμπληρώνονται από την Πολιτική Απορρήτου και την Πολιτική Αποστολών & Επιστροφών.
|
||||
|
||||
## 2. Προϊόντα
|
||||
|
||||
Τα προϊόντα που διατίθενται μέσω του Καταστήματος περιγράφονται και απεικονίζονται με όσο το δυνατόν μεγαλύτερη ακρίβεια.
|
||||
|
||||
Τα προϊόντα της 3DEALER κατασκευάζονται με τρισδιάστατη εκτύπωση. Λόγω της φύσης της διαδικασίας παραγωγής, ενδέχεται να εμφανίζουν γραμμές στρώσεων, σημεία ένωσης, μικρές διαφοροποιήσεις ή άλλες μικρές αισθητικές ατέλειες. Τα χαρακτηριστικά αυτά αποτελούν φυσιολογικό αποτέλεσμα της διαδικασίας 3D εκτύπωσης και δεν θεωρούνται από μόνα τους ελάττωμα του προϊόντος.
|
||||
|
||||
Όλα τα προϊόντα εκτυπώνονται και προετοιμάζονται κατά παραγγελία. Ο συνήθης χρόνος προετοιμασίας είναι 2–7 εργάσιμες ημέρες.
|
||||
|
||||
Εκτός εάν αναφέρεται διαφορετικά στην περιγραφή συγκεκριμένου προϊόντος, τα προϊόντα προορίζονται για διακοσμητική χρήση ή για χρήση σε λογικά και ρεαλιστικά πλαίσια. Η χρήση με υπερβολική δύναμη, σε πολύ υψηλές θερμοκρασίες ή με τρόπο διαφορετικό από τον προβλεπόμενο ενδέχεται να προκαλέσει ζημιά στο προϊόν.
|
||||
|
||||
## 3. Τιμές
|
||||
|
||||
Όλες οι τιμές των προϊόντων αναγράφονται σε ευρώ (€) και περιλαμβάνουν τον ισχύοντα ΦΠΑ.
|
||||
|
||||
Το κόστος αποστολής υπολογίζεται κατά την ολοκλήρωση της παραγγελίας και εμφανίζεται πριν από την επιβεβαίωση της αγοράς.
|
||||
|
||||
**Κόστος αποστολής:** @TODO — το ακριβές κόστος υπολογίζεται δυναμικά ανάλογα με τις ρυθμίσεις αποστολής του Καταστήματος.
|
||||
|
||||
## 4. Παραγγελίες
|
||||
|
||||
Ο πελάτης μπορεί να πραγματοποιήσει αγορά είτε δημιουργώντας λογαριασμό στο Κατάστημα είτε χρησιμοποιώντας τη δυνατότητα guest checkout, χωρίς δημιουργία λογαριασμού.
|
||||
|
||||
Για την ολοκλήρωση μιας παραγγελίας απαιτούνται τα στοιχεία που είναι απαραίτητα για την επεξεργασία, πληρωμή και παράδοσή της.
|
||||
|
||||
Πριν από την οριστικοποίηση της παραγγελίας, ο πελάτης έχει τη δυνατότητα να ελέγξει τα στοιχεία της παραγγελίας και το συνολικό κόστος, συμπεριλαμβανομένων τυχόν εξόδων αποστολής.
|
||||
|
||||
Με την ολοκλήρωση της παραγγελίας αποστέλλεται επιβεβαίωση στη διεύθυνση email που έχει δηλώσει ο πελάτης.
|
||||
|
||||
**@TODO: Να επιβεβαιωθεί εάν η παραγγελία θεωρείται οριστική με την ολοκλήρωση της πληρωμής ή με την αποστολή της επιβεβαίωσης παραγγελίας.**
|
||||
|
||||
## 5. Τρόποι πληρωμής
|
||||
|
||||
Το Κατάστημα υποστηρίζει τους ακόλουθους τρόπους πληρωμής:
|
||||
|
||||
* Πιστωτική ή χρεωστική κάρτα
|
||||
* PayPal
|
||||
* Stripe
|
||||
* IRIS
|
||||
* Τραπεζική κατάθεση
|
||||
|
||||
Για τις ηλεκτρονικές πληρωμές ενδέχεται να χρησιμοποιούνται υπηρεσίες τρίτων παρόχων πληρωμών. Τα στοιχεία της κάρτας ή άλλα ευαίσθητα στοιχεία πληρωμής δεν αποθηκεύονται από την 3DEALER, αλλά υποβάλλονται σε επεξεργασία από τον αντίστοιχο πάροχο πληρωμών σύμφωνα με τους δικούς του όρους και την πολιτική απορρήτου του.
|
||||
|
||||
## 6. Ακυρώσεις παραγγελιών
|
||||
|
||||
**@TODO: Να επιβεβαιωθεί η πολιτική ακύρωσης.**
|
||||
|
||||
Ως γενικός κανόνας, ο πελάτης μπορεί να επικοινωνήσει με την 3DEALER το συντομότερο δυνατό μετά την πραγματοποίηση της παραγγελίας, εφόσον επιθυμεί την ακύρωσή της.
|
||||
|
||||
Εφόσον η παραγγελία δεν έχει ακόμη ξεκινήσει να προετοιμάζεται, η 3DEALER μπορεί να προχωρήσει σε ακύρωση και επιστροφή του ποσού που έχει καταβληθεί.
|
||||
|
||||
Εφόσον η παραγωγή έχει ήδη ξεκινήσει, ισχύουν οι όροι της Πολιτικής Αποστολών & Επιστροφών και, όπου εφαρμόζεται, το νόμιμο δικαίωμα υπαναχώρησης.
|
||||
|
||||
## 7. Δικαίωμα υπαναχώρησης και επιστροφές
|
||||
|
||||
Ο καταναλωτής έχει, όπου προβλέπεται από την ισχύουσα νομοθεσία, δικαίωμα υπαναχώρησης από τη σύμβαση εξ αποστάσεως εντός δεκατεσσάρων (14) ημερολογιακών ημερών από την παραλαβή του προϊόντος, χωρίς να χρειάζεται να αιτιολογήσει την απόφασή του.
|
||||
|
||||
Εξαιρέσεις από το δικαίωμα υπαναχώρησης ισχύουν στις περιπτώσεις που προβλέπονται από την ισχύουσα νομοθεσία, μεταξύ άλλων για προϊόντα που κατασκευάζονται σύμφωνα με τις ειδικές προδιαγραφές του καταναλωτή ή είναι σαφώς εξατομικευμένα.
|
||||
|
||||
Η διαδικασία και οι όροι επιστροφών περιγράφονται αναλυτικά στην Πολιτική Αποστολών & Επιστροφών.
|
||||
|
||||
## 8. Νομική εγγύηση και ελαττωματικά προϊόντα
|
||||
|
||||
Η 3DEALER ευθύνεται για τη συμμόρφωση των προϊόντων με τη σύμβαση και για τα νόμιμα δικαιώματα του καταναλωτή σε περίπτωση έλλειψης συμμόρφωσης ή ελαττώματος, σύμφωνα με την ισχύουσα νομοθεσία.
|
||||
|
||||
Η ύπαρξη φυσιολογικών χαρακτηριστικών της τρισδιάστατης εκτύπωσης, όπως οι γραμμές στρώσεων ή μικρές αισθητικές διαφοροποιήσεις που είναι αναμενόμενες από τη συγκεκριμένη διαδικασία παραγωγής, δεν αποτελεί από μόνη της έλλειψη συμμόρφωσης.
|
||||
|
||||
## 9. Διαθεσιμότητα
|
||||
|
||||
Η 3DEALER καταβάλλει κάθε προσπάθεια ώστε οι πληροφορίες σχετικά με τη διαθεσιμότητα των προϊόντων να είναι ακριβείς.
|
||||
|
||||
Σε εξαιρετικές περιπτώσεις όπου ένα προϊόν δεν μπορεί να παραχθεί ή να αποσταλεί, η 3DEALER θα επικοινωνήσει με τον πελάτη και θα ενημερώσει σχετικά με τις διαθέσιμες επιλογές.
|
||||
|
||||
## 10. Πνευματική ιδιοκτησία
|
||||
|
||||
Το περιεχόμενο του Καταστήματος, συμπεριλαμβανομένων ενδεικτικά των σχεδίων, φωτογραφιών, κειμένων, γραφικών, λογοτύπων και λοιπού υλικού, αποτελεί ιδιοκτησία της 3DEALER ή χρησιμοποιείται νόμιμα από αυτήν και προστατεύεται από την ισχύουσα νομοθεσία περί πνευματικής και βιομηχανικής ιδιοκτησίας.
|
||||
|
||||
Δεν επιτρέπεται η αντιγραφή, αναπαραγωγή, τροποποίηση, διανομή ή εμπορική εκμετάλλευση του περιεχομένου χωρίς προηγούμενη γραπτή άδεια.
|
||||
|
||||
## 11. Προσωπικά δεδομένα
|
||||
|
||||
Η επεξεργασία των προσωπικών δεδομένων των πελατών πραγματοποιείται σύμφωνα με την Πολιτική Απορρήτου της 3DEALER.
|
||||
|
||||
## 12. Τροποποίηση των Όρων
|
||||
|
||||
Η 3DEALER διατηρεί το δικαίωμα να τροποποιεί τους παρόντες Όρους & Προϋποθέσεις, όταν αυτό είναι απαραίτητο, ιδίως λόγω αλλαγών στη νομοθεσία, στις υπηρεσίες ή στον τρόπο λειτουργίας του Καταστήματος.
|
||||
|
||||
Οι όροι που ισχύουν για κάθε παραγγελία είναι εκείνοι που ήταν διαθέσιμοι κατά τον χρόνο πραγματοποίησης της συγκεκριμένης παραγγελίας.
|
||||
|
||||
## 13. Εφαρμοστέο δίκαιο
|
||||
|
||||
Οι παρόντες Όροι & Προϋποθέσεις διέπονται από το ελληνικό δίκαιο.
|
||||
|
||||
Για κάθε διαφορά που σχετίζεται με τη χρήση του Καταστήματος ή την αγορά προϊόντων εφαρμόζονται οι διατάξεις της ισχύουσας ελληνικής και ευρωπαϊκής νομοθεσίας περί προστασίας του καταναλωτή.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 689 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -1 +1,133 @@
|
||||
schemas: []
|
||||
schemas:
|
||||
- name: pages
|
||||
label: Pages
|
||||
type: files
|
||||
files:
|
||||
- name: home
|
||||
label: Home Page
|
||||
file: pages/home.md
|
||||
fields:
|
||||
- name: name
|
||||
label: Name
|
||||
ui: string
|
||||
- name: slug
|
||||
label: Slug
|
||||
ui: slug
|
||||
- name: meta_title
|
||||
label: Meta Title
|
||||
ui: string
|
||||
- name: meta_description
|
||||
label: Meta Description
|
||||
ui: string
|
||||
- name: og_image
|
||||
label: Og Image
|
||||
ui: image
|
||||
- name: trivia_body_image
|
||||
label: "Trivia Image (next to the intro paragraph)"
|
||||
ui: image
|
||||
- name: trivia_body
|
||||
label: "Trivia Body (intro paragraph under the hero)"
|
||||
ui: markdown
|
||||
- name: classics_title
|
||||
label: "Classics Section Title (favorites carousel heading)"
|
||||
ui: string
|
||||
- name: terms-and-conditions
|
||||
label: "Terms & Conditions Page"
|
||||
file: pages/terms-and-conditions.md
|
||||
fields:
|
||||
- name: name
|
||||
label: Name
|
||||
ui: string
|
||||
- name: slug
|
||||
label: Slug
|
||||
ui: slug
|
||||
- name: meta_title
|
||||
label: Meta Title
|
||||
ui: string
|
||||
- name: meta_description
|
||||
label: Meta Description
|
||||
ui: string
|
||||
- name: og_image
|
||||
label: Og Image
|
||||
ui: image
|
||||
- name: noindex
|
||||
label: Noindex
|
||||
ui: boolean
|
||||
- name: body
|
||||
label: Body
|
||||
ui: markdown
|
||||
- name: shipping-returns
|
||||
label: "Shipping & Returns Page"
|
||||
file: pages/shipping-returns.md
|
||||
fields:
|
||||
- name: name
|
||||
label: Name
|
||||
ui: string
|
||||
- name: slug
|
||||
label: Slug
|
||||
ui: slug
|
||||
- name: meta_title
|
||||
label: Meta Title
|
||||
ui: string
|
||||
- name: meta_description
|
||||
label: Meta Description
|
||||
ui: string
|
||||
- name: og_image
|
||||
label: Og Image
|
||||
ui: image
|
||||
- name: noindex
|
||||
label: Noindex
|
||||
ui: boolean
|
||||
- name: body
|
||||
label: Body
|
||||
ui: markdown
|
||||
- name: privacy-policy
|
||||
label: "Privacy Policy Page"
|
||||
file: pages/privacy-policy.md
|
||||
fields:
|
||||
- name: name
|
||||
label: Name
|
||||
ui: string
|
||||
- name: slug
|
||||
label: Slug
|
||||
ui: slug
|
||||
- name: meta_title
|
||||
label: Meta Title
|
||||
ui: string
|
||||
- name: meta_description
|
||||
label: Meta Description
|
||||
ui: string
|
||||
- name: og_image
|
||||
label: Og Image
|
||||
ui: image
|
||||
- name: noindex
|
||||
label: Noindex
|
||||
ui: boolean
|
||||
- name: body
|
||||
label: Body
|
||||
ui: markdown
|
||||
- name: cookies-policy
|
||||
label: "Cookies Policy Page"
|
||||
file: pages/cookies-policy.md
|
||||
fields:
|
||||
- name: name
|
||||
label: Name
|
||||
ui: string
|
||||
- name: slug
|
||||
label: Slug
|
||||
ui: slug
|
||||
- name: meta_title
|
||||
label: Meta Title
|
||||
ui: string
|
||||
- name: meta_description
|
||||
label: Meta Description
|
||||
ui: string
|
||||
- name: og_image
|
||||
label: Og Image
|
||||
ui: image
|
||||
- name: noindex
|
||||
label: Noindex
|
||||
ui: boolean
|
||||
- name: body
|
||||
label: Body
|
||||
ui: markdown
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
{{--
|
||||
The reloadable body of the category listing — count + sort, product grid +
|
||||
pagination, and the filter sidebar. Rendered both on full page load and on
|
||||
every <turbo-frame id="category-listing"> navigation, always straight from
|
||||
the query string ($listing). Anything that must reflect the applied
|
||||
sort/filter/page state belongs in here.
|
||||
|
||||
Vars: $collection, $products (LengthAwarePaginator), $listing (App\Catalog\CategoryListing)
|
||||
--}}
|
||||
|
||||
@php
|
||||
// Keys are the `sort` URL param values — the ProductSort enum cases, plus
|
||||
// "popularity" for the default (no param). App\Catalog\CategoryListing is
|
||||
// the single place that validates them back into the enum.
|
||||
$sortLabels = [
|
||||
'popularity' => __('storefront.shop.sort_popularity'),
|
||||
'price_asc' => __('storefront.shop.sort_price_asc'),
|
||||
'price_desc' => __('storefront.shop.sort_price_desc'),
|
||||
'newest' => __('storefront.shop.sort_newest'),
|
||||
];
|
||||
$currentSort = $listing->sort?->value ?? 'popularity';
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
|
||||
|
||||
<div class="flex items-center justify-between gap-6 flex-wrap mb-7">
|
||||
<p>
|
||||
{{ trans_choice('storefront.shop.showing_results', $products->total(), [
|
||||
'first' => $products->firstItem() ?? 0,
|
||||
'last' => $products->lastItem() ?? 0,
|
||||
'total' => $products->total(),
|
||||
]) }}
|
||||
</p>
|
||||
|
||||
{{-- Sort options are plain links carrying the rest of the applied state;
|
||||
following one navigates the frame and advances the URL. Changing
|
||||
sort drops back to page 1. --}}
|
||||
<x-ui.dropdown
|
||||
id="category-sort"
|
||||
:label="$sortLabels[$currentSort]"
|
||||
:ariaLabel="__('storefront.shop.sort_label')"
|
||||
triggerClass="min-w-48"
|
||||
>
|
||||
@foreach ($sortLabels as $value => $label)
|
||||
<x-ui.dropdown.item
|
||||
:href="route('category.show', ['id' => $collection['id']] + $listing->query([
|
||||
'sort' => $value === 'popularity' ? null : $value,
|
||||
'page' => null,
|
||||
]))"
|
||||
:current="$value === $currentSort"
|
||||
>{{ $label }}</x-ui.dropdown.item>
|
||||
@endforeach
|
||||
</x-ui.dropdown>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
|
||||
|
||||
{{-- Products --}}
|
||||
<div>
|
||||
@if($products->isEmpty())
|
||||
<p class="text-neutral-500">{{ __('storefront.shop.no_products') }}</p>
|
||||
@else
|
||||
<x-product-grid :products="$products->items()" cols="3" />
|
||||
|
||||
<div class="mt-12">
|
||||
<x-ui.pagination :paginator="$products" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Sidebar — sort, price and in-stock are wired to the back-end; the
|
||||
search box is still a placeholder. --}}
|
||||
<aside class="flex flex-col gap-10">
|
||||
|
||||
{{-- Placeholder — not wired yet. --}}
|
||||
<div class="-mt-2">
|
||||
<label for="shop-search" class="sr-only">{{ __('storefront.shop.search_label') }}</label>
|
||||
<div class="relative">
|
||||
<x-ui.input
|
||||
type="search"
|
||||
id="shop-search"
|
||||
:placeholder="__('storefront.shop.search_placeholder')"
|
||||
class="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-0 top-1/2 -translate-y-1/2"
|
||||
aria-label="{{ __('storefront.shop.search_label') }}"
|
||||
>
|
||||
<x-ui.icon name="search" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Filter form: a plain GET form whose fields ARE the state. Changing
|
||||
any control auto-submits (auto-submit controller); Turbo captures
|
||||
the GET, navigates the frame, and advances the URL. `sort` rides
|
||||
along as a hidden field so it survives a filter change; `page` is
|
||||
deliberately absent, so filtering drops back to page 1. Without JS
|
||||
the sr-only submit button applies the range inputs. --}}
|
||||
<form
|
||||
method="get"
|
||||
action="{{ route('category.show', ['id' => $collection['id']]) }}"
|
||||
data-controller="auto-submit"
|
||||
data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
|
||||
class="contents"
|
||||
>
|
||||
@if ($listing->sort)
|
||||
<input type="hidden" name="sort" value="{{ $listing->sort->value }}">
|
||||
@endif
|
||||
|
||||
@if ($priceFloor !== null && $priceCeil !== null && $priceCeil > $priceFloor)
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="font-extrabold text-h4">{{ __('storefront.shop.filter_price') }}</p>
|
||||
|
||||
<x-ui.range-slider
|
||||
name="price"
|
||||
:min="$priceFloor"
|
||||
:max="$priceCeil"
|
||||
:min-value="max($priceFloor, $listing->minPrice ?? $priceFloor)"
|
||||
:max-value="min($priceCeil, $listing->maxPrice ?? $priceCeil)"
|
||||
prefix="€"
|
||||
separator=" - "
|
||||
:legend="__('storefront.shop.filter_price')"
|
||||
:min-label="__('storefront.shop.price_min')"
|
||||
:max-label="__('storefront.shop.price_max')"
|
||||
>
|
||||
{{-- Clear the price filter — a plain link back to the URL
|
||||
without price_*; only shown when actually filtered. --}}
|
||||
@if ($priceFiltered)
|
||||
<a
|
||||
href="{{ route('category.show', ['id' => $collection['id']] + $listing->query(['price_min' => null, 'price_max' => null, 'page' => null])) }}"
|
||||
class="underline-slide [--slide-h:1px] font-display text-sm font-bold uppercase italic"
|
||||
>{{ __('storefront.shop.reset') }}</a>
|
||||
@endif
|
||||
</x-ui.range-slider>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col gap-4 [&_label]:text-base">
|
||||
<p class="font-extrabold text-h4">{{ __('storefront.shop.availability') }}</p>
|
||||
<x-ui.checkbox id="shop-in-stock" name="in_stock" :checked="$listing->inStockOnly">
|
||||
{{ __('storefront.shop.in_stock_only') }}
|
||||
</x-ui.checkbox>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="sr-only">{{ __('storefront.shop.apply') }}</button>
|
||||
</form>
|
||||
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@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-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">{{ $collection['name'] }}</h1>
|
||||
|
||||
<x-breadcrumb :items="[
|
||||
['label' => __('storefront.nav.home'), 'href' => route('home')],
|
||||
['label' => $collection['name']],
|
||||
]" />
|
||||
</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">
|
||||
@include('category.partials.listing')
|
||||
</turbo-frame>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,26 @@
|
||||
<div
|
||||
data-controller="back-to-top"
|
||||
class="back-to-top fixed right-8 bottom-6 w-[120px] z-[100]"
|
||||
aria-label="Back to top"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
data-action="click->back-to-top#scrollToTop"
|
||||
aria-label="Scroll back to top"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 135 124"
|
||||
aria-hidden="true"
|
||||
class="block w-full h-auto overflow-visible"
|
||||
>
|
||||
<g class="back-to-top-shape">
|
||||
<path d="m88.4 0 4 28.9 29.7-5.2-14.3 25.7L135 62l-27.2 12.6 14.3 25.7-29.7-5.2-4 28.9-20.9-21.1L46.6 124l-4-28.9-29.7 5.2 14.3-25.7L0 62l27.2-12.6-14.3-25.7 29.7 5.2 4-28.9 20.9 21.1L88.4 0z"/>
|
||||
</g>
|
||||
<g class="back-to-top-arrow">
|
||||
<path fill="none" stroke="#000" stroke-width="5" d="M85.1 59.5 67.5 42 50 59.5M67.6 42v44.4"/>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
@props(['items' => []])
|
||||
|
||||
{{--
|
||||
$items: array of ['label' => string, 'href' => string|null]
|
||||
Last item is the current page — no link, no href needed.
|
||||
|
||||
Example:
|
||||
<x-breadcrumb :items="[
|
||||
['label' => 'Jackets', 'href' => '/category/jackets'],
|
||||
['label' => 'Leather Jacket'],
|
||||
]" />
|
||||
--}}
|
||||
|
||||
<nav aria-label="Breadcrumb">
|
||||
<ol {{ $attributes->merge(['class' => 'flex items-center flex-wrap gap-1 text-base text-black']) }}>
|
||||
@foreach ($items as $index => $item)
|
||||
<li class="flex items-center gap-1">
|
||||
@if (!$loop->first)
|
||||
<span aria-hidden="true">/</span>
|
||||
@endif
|
||||
|
||||
@if (!empty($item['href']))
|
||||
<a href="{{ $item['href'] }}" class="underline-slide [--slide-h:1px]">{{ $item['label'] }}</a>
|
||||
@else
|
||||
<span class="pb-1" aria-current="page">{{ $item['label'] }}</span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -0,0 +1,67 @@
|
||||
<footer class="border-t border-black">
|
||||
<div class="max-w-7xl mx-auto px-10 py-20 flex flex-col items-center gap-16">
|
||||
|
||||
{{-- Logo --}}
|
||||
<a href="/" class="shrink-0">
|
||||
<img src="/images/logo.png" alt="{{ config('app.name') }}" class="h-40 w-auto">
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col items-center gap-16">
|
||||
{{-- Links --}}
|
||||
<nav aria-label="Footer navigation">
|
||||
<ul class="flex flex-wrap justify-center items-center gap-x-8 gap-y-3 font-semibold uppercase">
|
||||
<li><a href="{{ route('contact') }}" class="underline-slide [--slide-h:5px] font-extrabold italic">Επικοινωνία</a></li>
|
||||
<li><a href="{{ route('legal.shipping-returns') }}" class="underline-slide [--slide-h:5px] font-extrabold italic">Αποστολές & Επιστροφές</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{{-- Payment icons --}}
|
||||
<div class="flex flex-wrap justify-center items-center gap-5" aria-label="Αποδεκτοί τρόποι πληρωμής">
|
||||
<x-ui.payment-icon name="visa" :height="13" />
|
||||
<x-ui.payment-icon name="mastercard" :height="18" />
|
||||
<x-ui.payment-icon name="paypal" :height="18" />
|
||||
<x-ui.payment-icon name="applepay" :height="14" />
|
||||
<x-ui.payment-icon name="gpay" :height="14" />
|
||||
<x-ui.payment-icon name="klarna" :height="10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Social media --}}
|
||||
<nav aria-label="Social media">
|
||||
<ul class="flex items-center gap-4">
|
||||
<li>
|
||||
<a href="#" aria-label="Facebook" class="hover:text-brand">
|
||||
<x-ui.icon name="facebook" :size="22" color="text-neutral-300" />
|
||||
{{-- <span class="leading-none text-[22px] font-extrabold italic group-hover:underline">FB.</span> --}}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" aria-label="Instagram" class="hover:text-brand">
|
||||
<x-ui.icon name="instagram" :size="22" />
|
||||
{{-- <span class="leading-none text-[22px] font-extrabold italic group-hover:underline">IG.</span> --}}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" aria-label="TikTok" class="hover:text-brand">
|
||||
<x-ui.icon name="tiktok" :size="22" />
|
||||
{{-- <span class="leading-none text-[22px] font-extrabold italic group-hover:underline">TT.</span> --}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{{-- Copyright --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="">
|
||||
© {{ date('Y') }} {{ config('app.name') }}. Με επιφύλαξη παντός δικαιώματος.
|
||||
</p>
|
||||
|
||||
<a href="{{ route('legal.terms') }}" class="underline-slide [--slide-h:1px] pb-0">Όροι & Προϋποθέσεις</a>
|
||||
<span>|</span>
|
||||
<a href="{{ route('legal.privacy') }}" class="underline-slide [--slide-h:1px] pb-0">Πολιτική Απορρήτου</a>
|
||||
<span>|</span>
|
||||
<a href="{{ route('legal.cookies') }}" class="underline-slide [--slide-h:1px] pb-0">Πολιτική Cookies</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,56 @@
|
||||
<header class="sticky top-0 z-50 bg-neutral-200 border-b border-black lg:h-26">
|
||||
<div class="flex items-center gap-14 px-10">
|
||||
|
||||
{{-- Logo --}}
|
||||
<a href="{{ url('/'.app()->getLocale()) }}" class="shrink-0 lg:py-4">
|
||||
<img src="/images/logo.png" alt="{{ config('app.name') }}" class="h-18 w-auto">
|
||||
</a>
|
||||
|
||||
{{-- Primary nav --}}
|
||||
<nav class="flex items-center gap-6 h-full">
|
||||
|
||||
{{-- 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>{{ __('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="{{ route('category.show', ['id' => $category['id']]) }}">
|
||||
{{ $category['name'] }}
|
||||
</a>
|
||||
@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>{{ __('storefront.nav.contact') }}</span></a>
|
||||
|
||||
</nav>
|
||||
|
||||
{{-- Right side actions --}}
|
||||
<div class="ml-auto flex items-center gap-8">
|
||||
|
||||
{{-- Language switcher --}}
|
||||
@foreach ($altLocales ?? [] as $altLocale)
|
||||
<a href="{{ $altLocale['url'] }}" class="uppercase font-display font-extrabold text-sm hover:opacity-70 transition-opacity" hreflang="{{ $altLocale['code'] }}" aria-label="{{ $altLocale['name'] }}">
|
||||
{{ $altLocale['code'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
|
||||
{{-- Cart --}}
|
||||
<a href="{{ url('/'.app()->getLocale().'/cart') }}" class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity" aria-label="Cart">
|
||||
<x-ui.icon name="bag" :size="40" />
|
||||
</a>
|
||||
|
||||
{{-- Search --}}
|
||||
<button type="button" class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity" aria-label="Search">
|
||||
<x-ui.icon name="search" :size="40" />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,63 @@
|
||||
@props([
|
||||
'image' => null,
|
||||
])
|
||||
|
||||
<section {{ $attributes }}>
|
||||
<div class="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-2 border border-black">
|
||||
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16">
|
||||
<h2
|
||||
class="text-h2 leading-tight"
|
||||
aria-label="Γράψου στο newsletter μας"
|
||||
data-controller="appear"
|
||||
data-appear-visible-class="is-visible"
|
||||
>
|
||||
<span class="text-dance" data-text="Γράψου">Γράψου</span>
|
||||
στο
|
||||
<span class="text-dance [--dance-delay:200ms]" data-text="newsletter">newsletter</span>
|
||||
μας
|
||||
</h2>
|
||||
|
||||
<p class="max-w-md text-neutral-600">
|
||||
Γίνε μέλος της λίστας μας και κέρδισε <span class="font-bold">5% έκπτωση</span> στην πρώτη σου παραγγελία, μαζί με αποκλειστικές προσφορές και νέα.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="#" class="flex flex-col gap-6 max-w-md">
|
||||
@csrf
|
||||
<div class="relative">
|
||||
<x-ui.input
|
||||
name="email"
|
||||
type="email"
|
||||
:required="true"
|
||||
autocomplete="email"
|
||||
placeholder="Το email σου"
|
||||
class="pr-10 text-lg"
|
||||
placeholderClass="placeholder:text-black/40"
|
||||
aria-label="Το email σου"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Εγγραφή στο newsletter"
|
||||
class="absolute right-0 bottom-2 cursor-pointer"
|
||||
>
|
||||
<x-ui.icon name="arrow-right" size="22" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<x-ui.checkbox name="gdpr_consent" :required="true" id="newsletter_split_gdpr_consent" class="after:mix-blend-multiply">
|
||||
<span class="text-sm text-left">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
|
||||
</x-ui.checkbox>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class=" min-h-[320px] lg:min-h-full border-t border-black lg:border-t-0 lg:border-l flex items-center justify-center">
|
||||
<x-ui.stoic-image
|
||||
src="middle-fingers2.png"
|
||||
preset="medium"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
class="mix-blend-darken max-w-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,29 @@
|
||||
@props([])
|
||||
|
||||
<section {{ $attributes }}>
|
||||
<div class="max-w-3xl bg-brand mx-auto px-16 py-16 flex flex-col items-center text-center gap-8 border-1">
|
||||
<p class="text-h3 leading-snug">Γράψου στο newsletter για <span class="font-extrabold italic">5% έκπτωση</span> στην πρώτη σου παραγγελία</p>
|
||||
|
||||
<form method="POST" action="#" class="w-full flex flex-col gap-5">
|
||||
@csrf
|
||||
<x-ui.input
|
||||
name="email"
|
||||
type="email"
|
||||
:required="true"
|
||||
autocomplete="email"
|
||||
placeholder="hello@email.com"
|
||||
class="text-center border-black"
|
||||
placeholderClass="placeholder:text-black/40 placeholder:text-base"
|
||||
aria-label="Email"
|
||||
/>
|
||||
<div class="flex items-center justify-center">
|
||||
<x-ui.checkbox name="gdpr_consent" :required="true" id="gdpr_consent" class="after:mix-blend-multiply">
|
||||
<span class="text-sm text-left">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
|
||||
</x-ui.checkbox>
|
||||
</div>
|
||||
<div class="flex justify-center mt-4">
|
||||
<x-ui.button type="submit">Εγγραφή</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,30 @@
|
||||
@props([
|
||||
'title' => null,
|
||||
'products' => [],
|
||||
'cols' => 4,
|
||||
])
|
||||
|
||||
@php
|
||||
$gridCols = match((int) $cols) {
|
||||
3 => 'grid-cols-2 md:grid-cols-3',
|
||||
2 => 'grid-cols-1 md:grid-cols-2',
|
||||
default => 'grid-cols-2 md:grid-cols-4',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<section {{ $attributes }}>
|
||||
@if($title)
|
||||
<h2 class="font-display font-extrabold text-h2 mb-10">{{ $title }}</h2>
|
||||
@endif
|
||||
|
||||
<div class="grid {{ $gridCols }} gap-9">
|
||||
@foreach($products as $product)
|
||||
<x-ui.product-card
|
||||
:name="$product['name']"
|
||||
:price="$product['price'] ?? null"
|
||||
:image="$product['image'] ?? null"
|
||||
:href="$product['href'] ?? '#'"
|
||||
/>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,27 @@
|
||||
@props(['review'])
|
||||
|
||||
<article class="flex flex-col gap-3 py-6 border-b border-black last:border-0">
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<x-reviews-stars :rating="$review['rating']" :size="18" />
|
||||
<span class="font-bold">
|
||||
{{ $review['name'] ?? 'Ανώνυμος' }}
|
||||
</span>
|
||||
</div>
|
||||
<time class="text-sm text-neutral-500 shrink-0">{{ $review['date'] }}</time>
|
||||
</div>
|
||||
|
||||
<p class="leading-relaxed">{{ $review['text'] }}</p>
|
||||
|
||||
@if (!empty($review['image']))
|
||||
<div class="mt-1">
|
||||
<img
|
||||
src="{{ $review['image'] }}"
|
||||
alt="Φωτογραφία από τον αγοραστή"
|
||||
class="w-24 h-24 object-cover border border-black"
|
||||
>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</article>
|
||||
@@ -0,0 +1,66 @@
|
||||
@props(['product'])
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="#"
|
||||
class="flex flex-col gap-8 mt-10"
|
||||
>
|
||||
@csrf
|
||||
|
||||
{{-- Rating --}}
|
||||
<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="{{ __('storefront.review.rating') }}"
|
||||
aria-required="true"
|
||||
>
|
||||
<input type="hidden" name="rating" value="0" data-star-rating-target="input">
|
||||
@for($i = 1; $i <= 5; $i++)
|
||||
<button
|
||||
type="button"
|
||||
data-star-rating-target="star"
|
||||
data-value="{{ $i }}"
|
||||
data-action="click->star-rating#select mouseenter->star-rating#hover mouseleave->star-rating#leave"
|
||||
aria-label="{{ trans_choice('storefront.review.stars_count', $i, ['count' => $i]) }}"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 23 21"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
class="w-6 h-6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15.054 0 .689 4.894 5.057-.883-2.435 4.348L23 10.5l-4.635 2.141 2.435 4.348-5.061-.883L15.054 21 11.5 17.43 7.946 21l-.689-4.894-5.057.883 2.438-4.348L0 10.5l4.635-2.141L2.2 4.011l5.061.883L7.946 0 11.5 3.57Z"/>
|
||||
<path d="m14.34389687 2.13050652-2.84388733 2.85695266-2.84390736-2.85695076-.55294228 3.92571163-4.0049181-.69867801 1.93777848 3.45510769L2.38412858 10.5l3.6518917 1.68735027-1.9377842 3.4551077 4.00493431-.69867802.55292607 3.92570781L11.5 16.01253891l2.84389782 2.85694885.55294228-3.92570781 4.00492382.69867801-1.9377842-3.45510769L20.61587142 10.5l-3.6518917-1.68735027 1.9377842-3.4551077-4.00492382.69867802-.55294323-3.92571353M7.94631004 0l3.5536995 3.5699997L15.05368996 0l.68924999 4.89351082 5.06075954-.88287163-2.43848037 4.34787083L23 10.5l-4.63478088 2.14148998 2.43848037 4.34787083-5.06075954-.88286972L15.05368995 21 11.5 17.4300003 7.94629955 21l-.6892395-4.89350891-5.06075954.88286972 2.43848037-4.34787083L0 10.5l4.63478088-2.14148998-2.43847084-4.34787083 5.06075001.88287163L7.94631005 0Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endfor
|
||||
</div>
|
||||
</x-ui.field>
|
||||
|
||||
<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="__('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="__('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">{{ __('storefront.review.save_info') }}</span>
|
||||
</x-ui.checkbox>
|
||||
|
||||
<div>
|
||||
<x-ui.button type="submit">{{ __('storefront.review.submit') }}</x-ui.button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@@ -0,0 +1,36 @@
|
||||
@props([
|
||||
'rating' => 0,
|
||||
'count' => 0,
|
||||
'showCount' => false,
|
||||
'size' => 24,
|
||||
])
|
||||
|
||||
<div class="flex items-center gap-3" {{ $attributes }}>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-brand"
|
||||
role="img"
|
||||
aria-label="{{ $rating }} out of 5 stars"
|
||||
>
|
||||
@for ($i = 1; $i <= 5; $i++)
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 23 21"
|
||||
fill="{{ $i <= $rating ? 'currentColor' : 'none' }}"
|
||||
stroke="currentColor"
|
||||
style="width: {{ $size }}px; height: {{ $size }}px; flex-shrink: 0;"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15.054 0 .689 4.894 5.057-.883-2.435 4.348L23 10.5l-4.635 2.141 2.435 4.348-5.061-.883L15.054 21 11.5 17.43 7.946 21l-.689-4.894-5.057.883 2.438-4.348L0 10.5l4.635-2.141L2.2 4.011l5.061.883L7.946 0 11.5 3.57Z"/>
|
||||
<path d="m14.34389687 2.13050652-2.84388733 2.85695266-2.84390736-2.85695076-.55294228 3.92571163-4.0049181-.69867801 1.93777848 3.45510769L2.38412858 10.5l3.6518917 1.68735027-1.9377842 3.4551077 4.00493431-.69867802.55292607 3.92570781L11.5 16.01253891l2.84389782 2.85694885.55294228-3.92570781 4.00492382.69867801-1.9377842-3.45510769L20.61587142 10.5l-3.6518917-1.68735027 1.9377842-3.4551077-4.00492382.69867802-.55294323-3.92571353M7.94631004 0l3.5536995 3.5699997L15.05368996 0l.68924999 4.89351082 5.06075954-.88287163-2.43848037 4.34787083L23 10.5l-4.63478088 2.14148998 2.43848037 4.34787083-5.06075954-.88286972L15.05368995 21 11.5 17.4300003 7.94629955 21l-.6892395-4.89350891-5.06075954.88286972 2.43848037-4.34787083L0 10.5l4.63478088-2.14148998-2.43847084-4.34787083 5.06075001.88287163L7.94631005 0Z"/>
|
||||
</svg>
|
||||
@endfor
|
||||
</div>
|
||||
|
||||
@if ($showCount && $count > 0)
|
||||
<span class="text-sm text-neutral-500">
|
||||
({{ trans_choice('storefront.customer_reviews', $count, ['count' => $count]) }})
|
||||
</span>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
@props([
|
||||
'tag' => 'button',
|
||||
'href' => null,
|
||||
'type' => 'button',
|
||||
'size' => 'lg',
|
||||
'position' => 'relative',
|
||||
])
|
||||
|
||||
@php
|
||||
$tag = $href ? 'a' : $tag;
|
||||
|
||||
$sizeClasses = match($size) {
|
||||
'sm' => 'py-2 px-6 text-sm',
|
||||
'md' => 'py-3.5 px-6 text-base',
|
||||
default => 'py-5 px-[46px] text-[19px]',
|
||||
};
|
||||
|
||||
// $position defaults to 'relative' (needed so the ::before/::after layers
|
||||
// in .btn-primary position against the button itself), but callers that
|
||||
// need to place the button absolutely (e.g. a hover-reveal CTA over a
|
||||
// product image) must pass position="absolute" here rather than adding
|
||||
// "absolute" via the class prop — Tailwind's generated stylesheet always
|
||||
// orders the "relative" utility after "absolute", so on a class clash
|
||||
// "relative" silently wins and the button never actually gets positioned.
|
||||
$class = 'btn-primary '.$position.' isolate inline-flex items-center justify-center border border-black font-display font-bold italic text-black cursor-pointer no-underline uppercase ' . $sizeClasses;
|
||||
|
||||
$attrs = $href
|
||||
? $attributes->merge(['href' => $href, 'class' => $class])
|
||||
: $attributes->merge(['type' => $type, 'class' => $class]);
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} {{ $attrs }}>
|
||||
<span class="relative z-[1]">{{ $slot }}</span>
|
||||
</{{ $tag }}>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user