diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..3e7a5ae
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,192 @@
+# 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
+
+
+- **Greek register:** `singular` — use the informal second person (εσύ/σου/σε). e.g. "Η κριτική σου", "Το όνομά σου", "Το email σου"
+
+
+All UI text written for this project must follow the register above.
+
+---
+
+## 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
+Open
+
...
+```
+
+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 ``, `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 ` ` in the layout ``.
+
+### 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 ` ` in `` with matching `imagesrcset`/`imagesizes` — this lets the browser start the fetch as soon as it parses ``, instead of waiting to discover the ` ` 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.
diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php
new file mode 100644
index 0000000..7709f8b
--- /dev/null
+++ b/app/Http/Controllers/ProductController.php
@@ -0,0 +1,45 @@
+load([
+ "variants.prices.currency",
+ "variants.values.option",
+ "media",
+ "collections",
+ ]);
+
+ $option = $product->variants->first()?->values->first()?->option;
+
+ $variantsData = $product->variants
+ ->map(
+ fn($v) => [
+ "id" => $v->id,
+ "price" => $v->prices->first()?->price->decimal,
+ "image" => null, // variant-level media not differentiated yet
+ ],
+ )
+ ->values()
+ ->toArray();
+
+ $firstImage = $product->media->first()?->getUrl();
+
+ // temp categories here
+ $categories = \Lunar\Models\Collection::orderBy("_lft")->get();
+
+ // dd($product);
+
+ return view("product.show", [
+ "categories" => $categories,
+ "product" => $product,
+ "option" => $option,
+ "variantsData" => $variantsData,
+ ]);
+ }
+}
diff --git a/package.json b/package.json
index 08696ba..fb3b9f4 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/public/images/logo.png b/public/images/logo.png
new file mode 100644
index 0000000..1a0cf2b
Binary files /dev/null and b/public/images/logo.png differ
diff --git a/resources/css/app.css b/resources/css/app.css
index 3e6abea..91b7f37 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -1,11 +1,289 @@
-@import 'tailwindcss';
-
+@import "tailwindcss";
+@import "./fonts.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: 27px;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+ 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;
+ }
+}
+
+@keyframes back-to-top-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);
+ }
+
+ /* ── 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: back-to-top-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);
+ }
}
diff --git a/resources/css/fonts.css b/resources/css/fonts.css
new file mode 100644
index 0000000..a5804aa
--- /dev/null
+++ b/resources/css/fonts.css
@@ -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+ */
+}
diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-200.woff2 b/resources/fonts/manrope/manrope-v20-greek_latin-200.woff2
new file mode 100644
index 0000000..8a582d4
Binary files /dev/null and b/resources/fonts/manrope/manrope-v20-greek_latin-200.woff2 differ
diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-300.woff2 b/resources/fonts/manrope/manrope-v20-greek_latin-300.woff2
new file mode 100644
index 0000000..82e42a7
Binary files /dev/null and b/resources/fonts/manrope/manrope-v20-greek_latin-300.woff2 differ
diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-500.woff2 b/resources/fonts/manrope/manrope-v20-greek_latin-500.woff2
new file mode 100644
index 0000000..a0b372e
Binary files /dev/null and b/resources/fonts/manrope/manrope-v20-greek_latin-500.woff2 differ
diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-600.woff2 b/resources/fonts/manrope/manrope-v20-greek_latin-600.woff2
new file mode 100644
index 0000000..9d1dedf
Binary files /dev/null and b/resources/fonts/manrope/manrope-v20-greek_latin-600.woff2 differ
diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-700.woff2 b/resources/fonts/manrope/manrope-v20-greek_latin-700.woff2
new file mode 100644
index 0000000..7d6f7eb
Binary files /dev/null and b/resources/fonts/manrope/manrope-v20-greek_latin-700.woff2 differ
diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-800.woff2 b/resources/fonts/manrope/manrope-v20-greek_latin-800.woff2
new file mode 100644
index 0000000..c274b8e
Binary files /dev/null and b/resources/fonts/manrope/manrope-v20-greek_latin-800.woff2 differ
diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-regular.woff2 b/resources/fonts/manrope/manrope-v20-greek_latin-regular.woff2
new file mode 100644
index 0000000..77d4cc6
Binary files /dev/null and b/resources/fonts/manrope/manrope-v20-greek_latin-regular.woff2 differ
diff --git a/resources/js/app.js b/resources/js/app.js
index e59d6a0..6748964 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1 +1,10 @@
-import './bootstrap';
+import "./bootstrap";
+import "./utils/strip-accents";
+
+import { Application } from "@hotwired/stimulus";
+import { registerControllers } from "./stimulus/index";
+
+const application = Application.start();
+application.debug = false;
+
+registerControllers(application);
diff --git a/resources/js/stimulus/back-to-top-controller.js b/resources/js/stimulus/back-to-top-controller.js
new file mode 100644
index 0000000..b34d729
--- /dev/null
+++ b/resources/js/stimulus/back-to-top-controller.js
@@ -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' })
+ }
+}
diff --git a/resources/js/stimulus/controllers/dropdown_controller.js b/resources/js/stimulus/controllers/dropdown_controller.js
new file mode 100644
index 0000000..04fe0ca
--- /dev/null
+++ b/resources/js/stimulus/controllers/dropdown_controller.js
@@ -0,0 +1,17 @@
+import { Controller } from '@hotwired/stimulus'
+
+export default class extends Controller {
+ static targets = ['menu']
+
+ open() {
+ this.menuTarget.classList.add('is-open')
+ }
+
+ close() {
+ this.menuTarget.classList.remove('is-open')
+ }
+
+ toggle() {
+ this.menuTarget.classList.toggle('is-open')
+ }
+}
diff --git a/resources/js/stimulus/index.js b/resources/js/stimulus/index.js
new file mode 100644
index 0000000..3d58270
--- /dev/null
+++ b/resources/js/stimulus/index.js
@@ -0,0 +1,20 @@
+// Register all Stimulus controllers here.
+// Example:
+// import HelloController from './controllers/hello_controller';
+// application.register('hello', HelloController);
+
+import BackToTopController from './back-to-top-controller'
+import ProductFormController from './product-form-controller'
+import ProductGalleryController from './product-gallery-controller'
+import QuantityController from './quantity-controller'
+import StarRatingController from './star-rating-controller'
+import TabsController from './tabs-controller'
+
+export function registerControllers(application) {
+ application.register('back-to-top', BackToTopController)
+ application.register('product-form', ProductFormController)
+ application.register('product-gallery', ProductGalleryController)
+ application.register('quantity', QuantityController)
+ application.register('star-rating', StarRatingController)
+ application.register('tabs', TabsController)
+}
diff --git a/resources/js/stimulus/product-form-controller.js b/resources/js/stimulus/product-form-controller.js
new file mode 100644
index 0000000..083ab55
--- /dev/null
+++ b/resources/js/stimulus/product-form-controller.js
@@ -0,0 +1,50 @@
+import { Controller } from '@hotwired/stimulus'
+
+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 = '€' + parseFloat(variant.price).toFixed(2)
+ }
+
+ 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')
+ }
+ })
+ }
+}
diff --git a/resources/js/stimulus/product-gallery-controller.js b/resources/js/stimulus/product-gallery-controller.js
new file mode 100644
index 0000000..b16c080
--- /dev/null
+++ b/resources/js/stimulus/product-gallery-controller.js
@@ -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(); }
+ }
+}
diff --git a/resources/js/stimulus/quantity-controller.js b/resources/js/stimulus/quantity-controller.js
new file mode 100644
index 0000000..3df324b
--- /dev/null
+++ b/resources/js/stimulus/quantity-controller.js
@@ -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)
+ }
+}
diff --git a/resources/js/stimulus/star-rating-controller.js b/resources/js/stimulus/star-rating-controller.js
new file mode 100644
index 0000000..e7ceb14
--- /dev/null
+++ b/resources/js/stimulus/star-rating-controller.js
@@ -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')
+ })
+ }
+}
diff --git a/resources/js/stimulus/tabs-controller.js b/resources/js/stimulus/tabs-controller.js
new file mode 100644
index 0000000..019ae22
--- /dev/null
+++ b/resources/js/stimulus/tabs-controller.js
@@ -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}`
+ })
+ }
+}
diff --git a/resources/js/utils/strip-accents.js b/resources/js/utils/strip-accents.js
new file mode 100644
index 0000000..44544b5
--- /dev/null
+++ b/resources/js/utils/strip-accents.js
@@ -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 })
diff --git a/resources/views/components/back-to-top.blade.php b/resources/views/components/back-to-top.blade.php
new file mode 100644
index 0000000..8ab37fe
--- /dev/null
+++ b/resources/views/components/back-to-top.blade.php
@@ -0,0 +1,26 @@
+
diff --git a/resources/views/components/breadcrumb.blade.php b/resources/views/components/breadcrumb.blade.php
new file mode 100644
index 0000000..35d688c
--- /dev/null
+++ b/resources/views/components/breadcrumb.blade.php
@@ -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:
+
+--}}
+
+
+ merge(['class' => 'flex items-center flex-wrap gap-1 text-base text-black']) }}>
+ @foreach ($items as $index => $item)
+
+ @if (!$loop->first)
+ /
+ @endif
+
+ @if (!empty($item['href']))
+ {{ $item['label'] }}
+ @else
+ {{ $item['label'] }}
+ @endif
+
+ @endforeach
+
+
diff --git a/resources/views/components/footer.blade.php b/resources/views/components/footer.blade.php
new file mode 100644
index 0000000..d1ede61
--- /dev/null
+++ b/resources/views/components/footer.blade.php
@@ -0,0 +1,66 @@
+
diff --git a/resources/views/components/header.blade.php b/resources/views/components/header.blade.php
new file mode 100644
index 0000000..a8ef7e7
--- /dev/null
+++ b/resources/views/components/header.blade.php
@@ -0,0 +1,49 @@
+
diff --git a/resources/views/components/newsletter.blade.php b/resources/views/components/newsletter.blade.php
new file mode 100644
index 0000000..a4fb1b8
--- /dev/null
+++ b/resources/views/components/newsletter.blade.php
@@ -0,0 +1,29 @@
+@props([])
+
+
+
+
Γράψου στο newsletter για στην πρώτη σου παραγγελία
+
+
+
+
diff --git a/resources/views/components/product-grid.blade.php b/resources/views/components/product-grid.blade.php
new file mode 100644
index 0000000..e48e670
--- /dev/null
+++ b/resources/views/components/product-grid.blade.php
@@ -0,0 +1,21 @@
+@props([
+ 'title' => null,
+ 'products' => [],
+])
+
+
+ @if($title)
+
+ @endif
+
+
+ @foreach($products as $product)
+
+ @endforeach
+
+
diff --git a/resources/views/components/review-card.blade.php b/resources/views/components/review-card.blade.php
new file mode 100644
index 0000000..1d8478c
--- /dev/null
+++ b/resources/views/components/review-card.blade.php
@@ -0,0 +1,27 @@
+@props(['review'])
+
+
+
+
+
+
+
+ {{ $review['name'] ?? 'Ανώνυμος' }}
+
+
+
{{ $review['date'] }}
+
+
+ {{ $review['text'] }}
+
+ @if (!empty($review['image']))
+
+
+
+ @endif
+
+
diff --git a/resources/views/components/review-form.blade.php b/resources/views/components/review-form.blade.php
new file mode 100644
index 0000000..1fbe204
--- /dev/null
+++ b/resources/views/components/review-form.blade.php
@@ -0,0 +1,66 @@
+@props(['product'])
+
+
diff --git a/resources/views/components/reviews-stars.blade.php b/resources/views/components/reviews-stars.blade.php
new file mode 100644
index 0000000..b257219
--- /dev/null
+++ b/resources/views/components/reviews-stars.blade.php
@@ -0,0 +1,36 @@
+@props([
+ 'rating' => 0,
+ 'count' => 0,
+ 'showCount' => false,
+ 'size' => 24,
+])
+
+
+
+
+ @for ($i = 1; $i <= 5; $i++)
+
+
+
+
+ @endfor
+
+
+ @if ($showCount && $count > 0)
+
+ ({{ $count }} customer {{ $count === 1 ? 'review' : 'reviews' }})
+
+ @endif
+
+
diff --git a/resources/views/components/ui/button.blade.php b/resources/views/components/ui/button.blade.php
new file mode 100644
index 0000000..40d3390
--- /dev/null
+++ b/resources/views/components/ui/button.blade.php
@@ -0,0 +1,26 @@
+@props([
+ 'tag' => 'button',
+ 'href' => null,
+ 'type' => 'button',
+ 'size' => 'lg',
+])
+
+@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]',
+ };
+
+ $class = 'btn-primary relative 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 }}>
+ {{ $slot }}
+{{ $tag }}>
diff --git a/resources/views/components/ui/checkbox.blade.php b/resources/views/components/ui/checkbox.blade.php
new file mode 100644
index 0000000..a630e3a
--- /dev/null
+++ b/resources/views/components/ui/checkbox.blade.php
@@ -0,0 +1,26 @@
+@props([
+ 'label' => null,
+ 'required' => false,
+ 'disabled' => false,
+ 'checked' => false,
+ 'value' => '1',
+])
+
+
+ merge(['class' => 'checkbox appearance-none w-[16px] h-[16px] shrink-0 relative border border-black bg-transparent cursor-pointer']) }}
+ >
+
+ @if ($label || $slot->isNotEmpty())
+ @php $labelContent = $label ?: $slot; @endphp
+ get('id')) for="{{ $attributes->get('id') }}" @endif
+ class="flex items-center justify-center cursor-pointer {{ $disabled ? 'opacity-50' : '' }}"
+ >{{ $labelContent }}
+ @endif
+
diff --git a/resources/views/components/ui/color-swatch.blade.php b/resources/views/components/ui/color-swatch.blade.php
new file mode 100644
index 0000000..1ddfd67
--- /dev/null
+++ b/resources/views/components/ui/color-swatch.blade.php
@@ -0,0 +1,64 @@
+@props(['variants', 'option' => null])
+
+@php
+$colorMap = [
+ 'light blue' => 'rgb(0, 189, 255)',
+ 'terracotta' => 'rgb(217, 104, 73)',
+ 'red-black gradient' => 'rgb(198, 68, 68)',
+ 'green-purple gradient'=> 'rgb(5, 170, 61)',
+ 'metallic blue' => 'rgb(53, 121, 151)',
+ 'copper' => 'rgb(242, 155, 55)',
+ 'purple' => 'rgb(165, 77, 207)',
+ 'red' => 'rgb(255, 0, 0)',
+ 'black' => 'rgb(0, 0, 0)',
+ 'gray' => 'rgb(128, 128, 128)',
+ 'rainbow' => 'rgb(97, 195, 231)',
+ 'white' => 'rgb(255, 255, 255)',
+ 'gold' => 'rgb(212, 154, 6)',
+ 'brown' => 'rgb(154, 86, 48)',
+ 'blue' => 'rgb(0, 91, 211)',
+ 'orange' => 'rgb(255, 138, 0)',
+ 'pink' => 'rgb(255, 192, 203)',
+ 'yellow' => 'rgb(255, 229, 0)',
+ 'green' => 'rgb(5, 170, 61)',
+ 'blue-purple gradient' => 'rgb(159, 113, 247)',
+ 'silver' => 'rgb(211, 211, 211)',
+ 'sparkly black' => 'rgb(5, 5, 5)',
+ 'plum' => 'rgb(198, 34, 159)',
+];
+@endphp
+
+
+ @if($option)
+
+ {{ $option->translate('name') }}:
+
+ @endif
+
+
+ @foreach($variants as $variant)
+ @php
+ $value = $variant->values->first();
+ $label = $value?->translate('name') ?? '';
+ $labelEn = mb_strtolower($value?->translate('name', 'en') ?? '');
+ $labelEl = mb_strtolower($value?->translate('name', 'el') ?? '');
+ $bg = $colorMap[$labelEl] ?? $colorMap[$labelEn] ?? '#cccccc';
+ @endphp
+
+ @endforeach
+
+
diff --git a/resources/views/components/ui/field.blade.php b/resources/views/components/ui/field.blade.php
new file mode 100644
index 0000000..293d897
--- /dev/null
+++ b/resources/views/components/ui/field.blade.php
@@ -0,0 +1,35 @@
+@props([
+ 'label' => null,
+ 'labelDescription' => null,
+ 'for' => null,
+ 'description' => null,
+ 'error' => null,
+ 'required' => false,
+])
+
+merge(['class' => 'flex flex-col gap-2']) }}>
+
+ @if ($label)
+
{{ $label }}@if ($required)* @endif @if ($labelDescription) ({{ $labelDescription }}) @endif
+ @endif
+
+ {{ $slot }}
+
+ @if ($description && ! $error)
+
{{ $description }}
+ @endif
+
+ @if ($error)
+
{{ $error }}
+ @endif
+
+
diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php
new file mode 100644
index 0000000..bb38b00
--- /dev/null
+++ b/resources/views/components/ui/icon.blade.php
@@ -0,0 +1,50 @@
+@props([
+ 'name',
+ 'size' => 24,
+ 'color' => 'currentColor',
+ 'stroke' => null,
+])
+
+@php
+ $fillIcons = ['facebook', 'instagram', 'tiktok', 'search', 'bag'];
+ $svgStroke = $stroke ?? (in_array($name, $fillIcons) ? 'none' : $color);
+@endphp
+
+@php
+ $icons = [
+ 'search' => ' ',
+
+ 'bag' => ' ',
+
+ 'close' => ' ',
+
+ 'arrow-left' => ' ',
+
+ 'arrow-right' => ' ',
+
+ 'arrow-up' => ' ',
+
+ 'arrow-down' => ' ',
+
+ 'facebook' => ' ',
+
+ 'tiktok' => ' ',
+
+ 'instagram' => ' ',
+
+ ];
+
+ $path = $icons[$name] ?? '';
+@endphp
+
+@if($path)
+ {!! $path !!}
+@endif
diff --git a/resources/views/components/ui/input.blade.php b/resources/views/components/ui/input.blade.php
new file mode 100644
index 0000000..f0afb95
--- /dev/null
+++ b/resources/views/components/ui/input.blade.php
@@ -0,0 +1,26 @@
+@props([
+ 'type' => 'text',
+ 'placeholder' => null,
+ 'required' => false,
+ 'disabled' => false,
+ 'readonly' => false,
+ 'value' => null,
+ 'autocomplete' => null,
+ 'placeholderClass' => 'placeholder:text-neutral-400',
+])
+
+ merge([
+ 'class' => 'w-full bg-transparent border-0 border-b border-black py-2 ' . $placeholderClass . '
+ focus:outline-none
+ disabled:cursor-not-allowed disabled:opacity-50
+ read-only:opacity-60',
+ ]) }}
+/>
diff --git a/resources/views/components/ui/payment-icon.blade.php b/resources/views/components/ui/payment-icon.blade.php
new file mode 100644
index 0000000..322b83f
--- /dev/null
+++ b/resources/views/components/ui/payment-icon.blade.php
@@ -0,0 +1,53 @@
+@props([
+ 'name',
+ 'height' => 24,
+])
+
+@php
+ $icons = [
+
+ 'gpay' => [
+ 'viewBox' => '0 0 59 24',
+ 'paths' => ' ',
+ ],
+
+ 'applepay' => [
+ 'viewBox' => '0 0 60 24',
+ 'paths' => ' ',
+ ],
+
+ 'visa' => [
+ 'viewBox' => '0 0 63 20',
+ 'paths' => ' ',
+ ],
+
+ 'mastercard' => [
+ 'viewBox' => '0 0 50 30',
+ 'paths' => ' ',
+ ],
+
+ 'klarna' => [
+ 'viewBox' => '0 0 70 16',
+ 'paths' => ' ',
+ ],
+
+ 'paypal' => [
+ 'viewBox' => '0 0 31 36',
+ 'paths' => ' ',
+ ],
+
+ ];
+
+ $icon = $icons[$name] ?? null;
+@endphp
+
+@if($icon)
+ {!! $icon['paths'] !!}
+@endif
diff --git a/resources/views/components/ui/product-card.blade.php b/resources/views/components/ui/product-card.blade.php
new file mode 100644
index 0000000..e5bf0db
--- /dev/null
+++ b/resources/views/components/ui/product-card.blade.php
@@ -0,0 +1,33 @@
+@props([
+ 'name' => '',
+ 'price' => null,
+ 'image' => null,
+ 'href' => '#',
+])
+
+
diff --git a/resources/views/components/ui/quantity.blade.php b/resources/views/components/ui/quantity.blade.php
new file mode 100644
index 0000000..5544cba
--- /dev/null
+++ b/resources/views/components/ui/quantity.blade.php
@@ -0,0 +1,38 @@
+@props(['name' => 'quantity', 'value' => 1, 'min' => 1])
+
+
diff --git a/resources/views/components/ui/tabs.blade.php b/resources/views/components/ui/tabs.blade.php
new file mode 100644
index 0000000..71dab86
--- /dev/null
+++ b/resources/views/components/ui/tabs.blade.php
@@ -0,0 +1,50 @@
+@props(['tabs' => [], 'size' => 'md'])
+@php
+ $sizeClasses = match($size) {
+ 'lg' => 'text-[36px] font-extrabold [--slide-h:5px]',
+ default => 'text-xl font-bold [--slide-h:3px]',
+ };
+@endphp
+
+{{--
+ Usage:
+
+ ...
+ ...
+ ...
+
+--}}
+
+
+
+ @foreach($tabs as $i => $tab)
+ {{ $tab['label'] }}
+ @endforeach
+
+
+ @foreach($tabs as $i => $tab)
+ @php $panelKey = $tab['id']; $panel = $$panelKey ?? null; @endphp
+
{{ $panel }}
+ @endforeach
+
diff --git a/resources/views/components/ui/textarea.blade.php b/resources/views/components/ui/textarea.blade.php
new file mode 100644
index 0000000..b8c2cb7
--- /dev/null
+++ b/resources/views/components/ui/textarea.blade.php
@@ -0,0 +1,22 @@
+@props([
+ 'placeholder' => null,
+ 'required' => false,
+ 'disabled' => false,
+ 'readonly' => false,
+ 'rows' => 4,
+])
+
+
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
new file mode 100644
index 0000000..d3c2607
--- /dev/null
+++ b/resources/views/layouts/app.blade.php
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+ {{-- Favicons --}}
+
+
+
+
+
+
+ @yield('title', config('app.name'))
+
+
+ @stack('seo')
+
+ @vite(['resources/css/app.css', 'resources/js/app.js'])
+
+ @stack('head')
+
+
+
+
+
+ @yield('content')
+
+
+
+
+
+
+ @stack('scripts')
+
+
diff --git a/resources/views/product/show.blade.php b/resources/views/product/show.blade.php
new file mode 100644
index 0000000..5179eee
--- /dev/null
+++ b/resources/views/product/show.blade.php
@@ -0,0 +1,236 @@
+@extends('layouts.app')
+
+@section('title', $product->translateAttribute('name') . ' — ' . config('app.name'))
+@section('description', $product->translateAttribute('description'))
+
+@section('content')
+
+
+ @php $collection = $product->collections->first(); @endphp
+
+
+
+
+
+ {{-- Image --}}
+
+ @if($product->media->isNotEmpty())
+ {{-- Thumbnails --}}
+
+
+
+
+ @foreach($product->media as $i => $media)
+
+
+
+
+
+ @endforeach
+
+
+
+
+
+ {{-- Main image --}}
+
+
+
+
+ {{-- Lightbox --}}
+
+ {{-- Prev --}}
+
+
+ {{-- Image + close button as a unit --}}
+
+
+
+
+
+ {{-- Next --}}
+
+
+ {{-- Counter --}}
+
+
+ @else
+
+ No image
+
+ @endif
+
+
+ {{-- Info --}}
+
+
+
+ {{ $product->translateAttribute('name') }}
+
+
+
+
+ @if($product->variants->first()?->prices->isNotEmpty())
+
+ €{{ number_format($product->variants->first()->prices->first()->price->decimal, 2) }}
+
+ @endif
+
+ @php
+ $desc = strip_tags($product->translateAttribute('description') ?? '');
+ $descTruncated = Str::limit($desc, 137);
+ $descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
+ @endphp
+
+ {{ $descTruncated }}
+ @if($descNeedsMore)
+
Περισσότερα
+ @endif
+
+
+ @if($option && $product->variants->count() >= 1)
+
+ @endif
+
+
+
+ Προσθήκη στο καλάθι
+
+
+
+
+
+
+
+
+
+ {!! $product->translateAttribute('description') !!}
+
+ @if($product->translateAttribute('details'))
+ {!! $product->translateAttribute('details') !!}
+ @endif
+
+ Όλα τα προϊόντα εκτυπώνονται και προετοιμάζονται κατά παραγγελία. Ο χρόνος προετοιμασίας κυμαίνεται μεταξύ 2 και 7 εργάσιμων ημερών.
+ Όλα τα προϊόντα κατασκευάζονται με τρισδιάστατη εκτύπωση σε ειδικούς εκτυπωτές πλαστικού υλικού. Πιθανώς να έχουν εμφανείς γραμμές ένωσης, στρώσεις εκτύπωσης υλικού και μικρές ατέλειες. Είναι φυσιολογικό για το αποτέλεσμα αυτής της δημιουργικής διαδικασίας.
+ Όλα τα προϊόντα είναι σχεδιασμένα και κατασκευασμένα είτε για διακόσμηση είτε για χρήση σε λογικά, ρεαλιστικά πλαίσια. Υπερβολική ισχύς, υψηλότατες θερμοκρασίες και αποσυναρμολόγηση μπορεί να προκαλέσουν ζημιά στο προϊόν για την οποία δεν ευθύνεται το 3Dealer.
+
+
+
+ {{-- @if(count($reviews) > 0)
+
+ @foreach($reviews as $review)
+
+ @endforeach
+
+ @else
+ Δεν υπάρχουν αξιολογήσεις ακόμα.
+ @endif
+
+
+ {{ count($reviews) > 0 ? 'Πρόσθεσε μια' : 'Γράψε την πρώτη' }} αξιολόγηση για το «{{ $product->translateAttribute('name') }}»
+ --}}
+
+
+
+
+
+
+
+
+
+
+
+@endsection
diff --git a/resources/views/product/show2.blade.php b/resources/views/product/show2.blade.php
new file mode 100644
index 0000000..5179eee
--- /dev/null
+++ b/resources/views/product/show2.blade.php
@@ -0,0 +1,236 @@
+@extends('layouts.app')
+
+@section('title', $product->translateAttribute('name') . ' — ' . config('app.name'))
+@section('description', $product->translateAttribute('description'))
+
+@section('content')
+
+
+ @php $collection = $product->collections->first(); @endphp
+
+
+
+
+
+ {{-- Image --}}
+
+ @if($product->media->isNotEmpty())
+ {{-- Thumbnails --}}
+
+
+
+
+ @foreach($product->media as $i => $media)
+
+
+
+
+
+ @endforeach
+
+
+
+
+
+ {{-- Main image --}}
+
+
+
+
+ {{-- Lightbox --}}
+
+ {{-- Prev --}}
+
+
+ {{-- Image + close button as a unit --}}
+
+
+
+
+
+ {{-- Next --}}
+
+
+ {{-- Counter --}}
+
+
+ @else
+
+ No image
+
+ @endif
+
+
+ {{-- Info --}}
+
+
+
+ {{ $product->translateAttribute('name') }}
+
+
+
+
+ @if($product->variants->first()?->prices->isNotEmpty())
+
+ €{{ number_format($product->variants->first()->prices->first()->price->decimal, 2) }}
+
+ @endif
+
+ @php
+ $desc = strip_tags($product->translateAttribute('description') ?? '');
+ $descTruncated = Str::limit($desc, 137);
+ $descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
+ @endphp
+
+ {{ $descTruncated }}
+ @if($descNeedsMore)
+
Περισσότερα
+ @endif
+
+
+ @if($option && $product->variants->count() >= 1)
+
+ @endif
+
+
+
+ Προσθήκη στο καλάθι
+
+
+
+
+
+
+
+
+
+ {!! $product->translateAttribute('description') !!}
+
+ @if($product->translateAttribute('details'))
+ {!! $product->translateAttribute('details') !!}
+ @endif
+
+ Όλα τα προϊόντα εκτυπώνονται και προετοιμάζονται κατά παραγγελία. Ο χρόνος προετοιμασίας κυμαίνεται μεταξύ 2 και 7 εργάσιμων ημερών.
+ Όλα τα προϊόντα κατασκευάζονται με τρισδιάστατη εκτύπωση σε ειδικούς εκτυπωτές πλαστικού υλικού. Πιθανώς να έχουν εμφανείς γραμμές ένωσης, στρώσεις εκτύπωσης υλικού και μικρές ατέλειες. Είναι φυσιολογικό για το αποτέλεσμα αυτής της δημιουργικής διαδικασίας.
+ Όλα τα προϊόντα είναι σχεδιασμένα και κατασκευασμένα είτε για διακόσμηση είτε για χρήση σε λογικά, ρεαλιστικά πλαίσια. Υπερβολική ισχύς, υψηλότατες θερμοκρασίες και αποσυναρμολόγηση μπορεί να προκαλέσουν ζημιά στο προϊόν για την οποία δεν ευθύνεται το 3Dealer.
+
+
+
+ {{-- @if(count($reviews) > 0)
+
+ @foreach($reviews as $review)
+
+ @endforeach
+
+ @else
+ Δεν υπάρχουν αξιολογήσεις ακόμα.
+ @endif
+
+
+ {{ count($reviews) > 0 ? 'Πρόσθεσε μια' : 'Γράψε την πρώτη' }} αξιολόγηση για το «{{ $product->translateAttribute('name') }}»
+ --}}
+
+
+
+
+
+
+
+
+
+
+
+@endsection
diff --git a/routes/web.php b/routes/web.php
index 86a06c5..1a076ed 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,7 +1,12 @@
name(
+ "product.show",
+);
diff --git a/vite.config.js b/vite.config.js
index 0ebb6df..3ebf4c9 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -22,7 +22,7 @@ export default defineConfig({
cors: true,
hmr: {
host: "localhost",
- clientPort: process.env.VITE_PORT || 5173,
+ clientPort: process.env.VITE_PORT || 5174,
},
watch: {
ignored: ["**/storage/framework/views/**"],