setting up front 1

This commit is contained in:
elvira
2026-07-31 17:41:55 +03:00
parent b88fde739c
commit bf8b9219fc
48 changed files with 2250 additions and 11 deletions
+192
View File
@@ -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
<!-- 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.
---
## 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.
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use Lunar\Models\Product;
class ProductController extends Controller
{
public function show(Product $product)
{
$product->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,
]);
}
}
+1 -3
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+282 -4
View File
@@ -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);
}
}
+54
View File
@@ -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+ */
}
+10 -1
View File
@@ -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);
@@ -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,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')
}
}
+20
View File
@@ -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)
}
@@ -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')
}
})
}
}
@@ -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,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')
})
}
}
+21
View File
@@ -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}`
})
}
}
+60
View File
@@ -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,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,66 @@
<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="/contact" class="underline-slide [--slide-h:5px] font-extrabold italic">Επικοινωνία</a></li>
<li><a href="/shipping" class="underline-slide [--slide-h:5px] font-extrabold italic">Πληροφορίες αποστολών</a></li>
<li><a href="/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="">
&copy; {{ date('Y') }} {{ config('app.name') }}. Με επιφύλαξη παντός δικαιώματος.
</p>
<a href="/terms" class="underline-slide [--slide-h:1px] pb-0">Όροι χρήσης</a>
<span>|</span>
<a href="/privacy" class="underline-slide [--slide-h:1px] pb-0 ">Πολιτική απορρήτου</a>
</div>
</div>
</footer>
@@ -0,0 +1,49 @@
<header class="sticky top-0 z-50 bg-neutral-200 border-b border-black">
<div class="flex items-center gap-14 px-10">
{{-- Logo --}}
<a href="/" class="shrink-0">
<img src="/images/logo.png" alt="{{ config('app.name') }}" class="h-16 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="/products" class="nav-link uppercase inline-flex items-center gap-1 relative font-display font-extrabold italic text-black no-underline py-8">
<span>Products</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4 not-italic transition-transform group-hover:rotate-180">
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
</a>
<div class="nav-dropdown">
{{-- @foreach($categories as $category)
<a href="/category/{{ $category->id }}">
{{ $category->translateAttribute('name') }}
</a>
@endforeach --}}
</div>
</div>
{{-- Contact --}}
<a href="/contact" class="nav-link uppercase inline-flex items-center gap-1 relative font-display font-extrabold italic text-black no-underline"><span>Contact</span></a>
</nav>
{{-- Right side actions --}}
<div class="ml-auto flex items-center gap-8">
{{-- Cart --}}
<a href="/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,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,21 @@
@props([
'title' => null,
'products' => [],
])
<section {{ $attributes }}>
@if($title)
<h2 class="font-display font-extrabold text-h2 mb-10">{{ $title }}</h2>
@endif
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
@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="Βαθμολογία" for="rating" :required="true">
<div
id="rating"
data-controller="star-rating"
class="flex items-center gap-1.5 text-brand"
role="radiogroup"
aria-label="Βαθμολογία"
aria-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="{{ $i }} {{ $i === 1 ? 'αστέρι' : 'αστέρια' }}"
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="Γράψε μια αξιολόγηση" for="review-content" :required="true">
<x-ui.textarea id="review-content" name="content" :required="true" />
</x-ui.field>
<x-ui.field label="Όνομα" for="review-name" labelDescription="Προαιρετικό">
<x-ui.input id="review-name" name="name" autocomplete="name" />
</x-ui.field>
<x-ui.field label="Email" for="review-email" :required="true" labelDescription="Δεν θα δημοσιευτεί">
<x-ui.input id="review-email" name="email" type="email" :required="true" autocomplete="email" />
</x-ui.field>
<x-ui.checkbox id="review-save-info" name="save_info" >
<span class="text-sm">Αποθήκευσε το όνομα και το email μου για την επόμενη φορά που θα σχολιάσω.</span>
</x-ui.checkbox>
<div>
<x-ui.button type="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">
({{ $count }} customer {{ $count === 1 ? 'review' : 'reviews' }})
</span>
@endif
</div>
@@ -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 }}>
<span class="relative z-[1]">{{ $slot }}</span>
</{{ $tag }}>
@@ -0,0 +1,26 @@
@props([
'label' => null,
'required' => false,
'disabled' => false,
'checked' => false,
'value' => '1',
])
<div class="flex items-center gap-3 ">
<input
type="checkbox"
value="{{ $value }}"
@checked($checked)
@disabled($disabled)
@required($required)
{{ $attributes->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
<label
@if($attributes->get('id')) for="{{ $attributes->get('id') }}" @endif
class="flex items-center justify-center cursor-pointer {{ $disabled ? 'opacity-50' : '' }}"
>{{ $labelContent }}</label>
@endif
</div>
@@ -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
<div {{ $attributes }}>
@if($option)
<p class="font-bold mb-3 text-sm uppercase tracking-wide">
{{ $option->translate('name') }}: <span data-product-form-target="colorName" class="font-normal normal-case"></span>
</p>
@endif
<div
class="flex flex-wrap gap-2"
role="group"
aria-label="{{ $option?->translate('name') ?? 'Color' }}"
>
@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
<button
type="button"
class="color-swatch"
data-product-form-target="swatch"
data-action="click->product-form#selectVariant"
data-variant-id="{{ $variant->id }}"
style="background-color: {{ $bg }};"
aria-label="{{ $label }}"
aria-pressed="false"
title="{{ $label }}"
></button>
@endforeach
</div>
</div>
@@ -0,0 +1,35 @@
@props([
'label' => null,
'labelDescription' => null,
'for' => null,
'description' => null,
'error' => null,
'required' => false,
])
<div {{ $attributes->merge(['class' => 'flex flex-col gap-2']) }}>
@if ($label)
<label
@if ($for) for="{{ $for }}" @endif
>{{ $label }}@if ($required)<span class="ml-1" aria-hidden="true">*</span>@endif @if ($labelDescription) <span class="text-sm text-neutral-500">({{ $labelDescription }})</span> @endif</label>
@endif
{{ $slot }}
@if ($description && ! $error)
<p
@if ($for) id="{{ $for }}-description" @endif
class="text-sm text-neutral-500"
>{{ $description }}</p>
@endif
@if ($error)
<p
@if ($for) id="{{ $for }}-error" @endif
role="alert"
class="text-sm text-red-600"
>{{ $error }}</p>
@endif
</div>
@@ -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' => '<path stroke-width=0.5 d="M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z"/>',
'bag' => '<path stroke-width=0.5 d="M20.0049 22H4.00488C3.4526 22 3.00488 21.5523 3.00488 21V3C3.00488 2.44772 3.4526 2 4.00488 2H20.0049C20.5572 2 21.0049 2.44772 21.0049 3V21C21.0049 21.5523 20.5572 22 20.0049 22ZM19.0049 20V4H5.00488V20H19.0049ZM9.00488 6V8C9.00488 9.65685 10.348 11 12.0049 11C13.6617 11 15.0049 9.65685 15.0049 8V6H17.0049V8C17.0049 10.7614 14.7663 13 12.0049 13C9.24346 13 7.00488 10.7614 7.00488 8V6H9.00488Z"/>',
'close' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" d="M6 6L18 18M18 6L6 18"/>',
'arrow-left' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M19 12H5M5 12L11 6M5 12L11 18"/>',
'arrow-right' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M5 12H19M19 12L13 6M19 12L13 18"/>',
'arrow-up' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M12 19V5M12 5L6 11M12 5L18 11"/>',
'arrow-down' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M12 5V19M12 19L6 13M12 19L18 13"/>',
'facebook' => '<path d="M14 13.5H16.5L17.5 9.5H14V7.5C14 6.47062 14 5.5 16 5.5H17.5V2.1401C17.1743 2.09685 15.943 2 14.6429 2C11.9284 2 10 3.65686 10 6.69971V9.5H7V13.5H10V22H14V13.5Z"/>',
'tiktok' => '<path d="M16 8.24537V15.5C16 19.0899 13.0899 22 9.5 22C5.91015 22 3 19.0899 3 15.5C3 11.9101 5.91015 9 9.5 9C10.0163 9 10.5185 9.06019 11 9.17393V12.3368C10.5454 12.1208 10.0368 12 9.5 12C7.567 12 6 13.567 6 15.5C6 17.433 7.567 19 9.5 19C11.433 19 13 17.433 13 15.5V2H16C16 4.76142 18.2386 7 21 7V10C19.1081 10 17.3696 9.34328 16 8.24537Z"/>',
'instagram' => '<path d="M12.001 9C10.3436 9 9.00098 10.3431 9.00098 12C9.00098 13.6573 10.3441 15 12.001 15C13.6583 15 15.001 13.6569 15.001 12C15.001 10.3427 13.6579 9 12.001 9ZM12.001 7C14.7614 7 17.001 9.2371 17.001 12C17.001 14.7605 14.7639 17 12.001 17C9.24051 17 7.00098 14.7629 7.00098 12C7.00098 9.23953 9.23808 7 12.001 7ZM18.501 6.74915C18.501 7.43926 17.9402 7.99917 17.251 7.99917C16.5609 7.99917 16.001 7.4384 16.001 6.74915C16.001 6.0599 16.5617 5.5 17.251 5.5C17.9393 5.49913 18.501 6.0599 18.501 6.74915ZM12.001 4C9.5265 4 9.12318 4.00655 7.97227 4.0578C7.18815 4.09461 6.66253 4.20007 6.17416 4.38967C5.74016 4.55799 5.42709 4.75898 5.09352 5.09255C4.75867 5.4274 4.55804 5.73963 4.3904 6.17383C4.20036 6.66332 4.09493 7.18811 4.05878 7.97115C4.00703 9.0752 4.00098 9.46105 4.00098 12C4.00098 14.4745 4.00753 14.8778 4.05877 16.0286C4.0956 16.8124 4.2012 17.3388 4.39034 17.826C4.5591 18.2606 4.7605 18.5744 5.09246 18.9064C5.42863 19.2421 5.74179 19.4434 6.17187 19.6094C6.66619 19.8005 7.19148 19.9061 7.97212 19.9422C9.07618 19.9939 9.46203 20 12.001 20C14.4755 20 14.8788 19.9934 16.0296 19.9422C16.8117 19.9055 17.3385 19.7996 17.827 19.6106C18.2604 19.4423 18.5752 19.2402 18.9074 18.9085C19.2436 18.5718 19.4445 18.2594 19.6107 17.8283C19.8013 17.3358 19.9071 16.8098 19.9432 16.0289C19.9949 14.9248 20.001 14.5389 20.001 12C20.001 9.52552 19.9944 9.12221 19.9432 7.97137C19.9064 7.18906 19.8005 6.66149 19.6113 6.17318C19.4434 5.74038 19.2417 5.42635 18.9084 5.09255C18.573 4.75715 18.2616 4.55693 17.8271 4.38942C17.338 4.19954 16.8124 4.09396 16.0298 4.05781C14.9258 4.00605 14.5399 4 12.001 4ZM12.001 2C14.7176 2 15.0568 2.01 16.1235 2.06C17.1876 2.10917 17.9135 2.2775 18.551 2.525C19.2101 2.77917 19.7668 3.1225 20.3226 3.67833C20.8776 4.23417 21.221 4.7925 21.476 5.45C21.7226 6.08667 21.891 6.81333 21.941 7.8775C21.9885 8.94417 22.001 9.28333 22.001 12C22.001 14.7167 21.991 15.0558 21.941 16.1225C21.8918 17.1867 21.7226 17.9125 21.476 18.55C21.2218 19.2092 20.8776 19.7658 20.3226 20.3217C19.7668 20.8767 19.2076 21.22 18.551 21.475C17.9135 21.7217 17.1876 21.89 16.1235 21.94C15.0568 21.9875 14.7176 22 12.001 22C9.28431 22 8.94514 21.99 7.87848 21.94C6.81431 21.8908 6.08931 21.7217 5.45098 21.475C4.79264 21.2208 4.23514 20.8767 3.67931 20.3217C3.12348 19.7658 2.78098 19.2067 2.52598 18.55C2.27848 17.9125 2.11098 17.1867 2.06098 16.1225C2.01348 15.0558 2.00098 14.7167 2.00098 12C2.00098 9.28333 2.01098 8.94417 2.06098 7.8775C2.11014 6.8125 2.27848 6.0875 2.52598 5.45C2.78014 4.79167 3.12348 4.23417 3.67931 3.67833C4.23514 3.1225 4.79348 2.78 5.45098 2.525C6.08848 2.2775 6.81348 2.11 7.87848 2.06C8.94514 2.0125 9.28431 2 12.001 2Z"/>',
];
$path = $icons[$name] ?? '';
@endphp
@if($path)
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="{{ $color }}"
stroke="{{ $svgStroke }}"
style="width: {{ $size }}px; height: {{ $size }}px; flex-shrink: 0;"
aria-hidden="true"
{{ $attributes }}
>{!! $path !!}</svg>
@endif
@@ -0,0 +1,26 @@
@props([
'type' => 'text',
'placeholder' => null,
'required' => false,
'disabled' => false,
'readonly' => false,
'value' => null,
'autocomplete' => null,
'placeholderClass' => 'placeholder:text-neutral-400',
])
<input
type="{{ $type }}"
@if ($placeholder) placeholder="{{ $placeholder }}" @endif
@if ($value !== null) value="{{ $value }}" @endif
@if ($autocomplete) autocomplete="{{ $autocomplete }}" @endif
@required($required)
@disabled($disabled)
@readonly($readonly)
{{ $attributes->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',
]) }}
/>
@@ -0,0 +1,53 @@
@props([
'name',
'height' => 24,
])
@php
$icons = [
'gpay' => [
'viewBox' => '0 0 59 24',
'paths' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M34.4747 2.73453C33.4828 1.71843 32.1038 1.18619 30.6764 1.21038H24.7976V18.7259H27.0233V11.6375H30.6764C32.1764 11.6375 33.4586 11.1294 34.4747 10.1375L34.7166 9.89558C36.6279 7.8392 36.4827 4.64576 34.4747 2.73453ZM32.9264 8.56498C32.3699 9.1698 31.5716 9.5085 30.7248 9.48431H27.0233V3.36354H30.749C31.5474 3.36354 32.2974 3.67805 32.878 4.23448C34.0634 5.39573 34.0876 7.35534 32.9264 8.56498Z" fill="#3C4043"/><path fill-rule="evenodd" clip-rule="evenodd" d="M41.2003 6.36344C39.0955 6.36344 37.4988 7.13761 36.4585 8.68594L38.4181 9.91978C39.1439 8.85529 40.1116 8.32305 41.3455 8.32305C42.1196 8.32305 42.8938 8.61337 43.4744 9.14561C44.055 9.67785 44.3937 10.4036 44.3937 11.1536V11.6617C43.547 11.1778 42.4583 10.9359 41.1519 10.9359C39.6036 10.9359 38.3697 11.2988 37.4504 12.0487C36.5069 12.7987 36.0472 13.7664 36.0472 15.0244C36.023 16.1615 36.4827 17.226 37.3536 17.9518C38.2246 18.7259 39.3133 19.1372 40.6197 19.1372C42.1438 19.1372 43.3535 18.4598 44.2728 17.0808H44.3695V18.7501H46.4985V11.3471C46.4985 9.79881 46.0388 8.56498 45.0711 7.69404C44.1034 6.8231 42.8212 6.36344 41.2003 6.36344ZM43.2809 16.0647C42.6035 16.7421 41.7083 17.105 40.789 17.105C40.16 17.1292 39.5552 16.9115 39.0471 16.5244C38.5875 16.1857 38.3214 15.6535 38.3214 15.0486C38.3214 14.3954 38.6359 13.839 39.2165 13.4035C39.8213 12.9923 40.5955 12.7745 41.4906 12.7745C42.7244 12.7503 43.6922 13.0406 44.3937 13.5971C44.3695 14.5406 44.0067 15.3631 43.2809 16.0647Z" fill="#3C4043"/><path fill-rule="evenodd" clip-rule="evenodd" d="M56.2724 6.75052L52.837 15.3631H52.7886L49.2807 6.75052H46.8614L51.7241 17.976L48.9662 24H51.2645L58.6675 6.75052H56.2724Z" fill="#3C4043"/><path d="M19.4405 10.2871C19.4405 9.60224 19.3854 8.91742 19.2752 8.24658H9.92163V12.1179H15.2803C15.0599 13.3617 14.3436 14.4798 13.2966 15.1786V17.6943H16.4926C18.3661 15.9473 19.4405 13.3617 19.4405 10.2871Z" fill="#4285F4"/><path d="M9.92214 20.1258C12.5946 20.1258 14.8538 19.2313 16.4931 17.694L13.2972 15.1783C12.4017 15.7933 11.2584 16.1426 9.92214 16.1426C7.33233 16.1426 5.14202 14.3677 4.35681 11.9918H1.06445V14.5913C2.74507 17.9875 6.17519 20.1258 9.92214 20.1258Z" fill="#34A853"/><path d="M4.35721 11.9919C3.94388 10.7481 3.94388 9.39243 4.35721 8.1346V5.54907H1.06433C-0.354777 8.38617 -0.354777 11.7404 1.06433 14.5775L4.35721 11.9919Z" fill="#FBBC04"/><path d="M9.92214 3.98372C11.341 3.95577 12.7048 4.50083 13.7242 5.49311L16.562 2.61409C14.7574 0.909038 12.388 -0.0273434 9.92214 0.000608242C6.17519 0.000608242 2.74507 2.15289 1.06445 5.54902L4.35681 8.14853C5.14202 5.75866 7.33233 3.98372 9.92214 3.98372Z" fill="#EA4335"/>',
],
'applepay' => [
'viewBox' => '0 0 60 24',
'paths' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M10.9637 3.08637C10.2491 3.91555 9.14025 4.53743 8.00674 4.4453C7.85889 3.36276 8.42565 2.1881 9.06632 1.47409C9.75628 0.644914 10.9883 0.0460653 11.9986 0C12.1218 1.15163 11.6537 2.2572 10.9637 3.08637Z" fill="black"/><path fill-rule="evenodd" clip-rule="evenodd" d="M11.974 4.67562C10.9883 4.60653 10.0766 4.95202 9.36202 5.22841C8.89383 5.41267 8.47493 5.5739 8.17923 5.5739C7.83425 5.5739 7.41535 5.41267 6.94716 5.22841C6.35577 4.99808 5.66581 4.72169 4.92657 4.74472C3.25096 4.76775 1.69856 5.68906 0.836109 7.16315C-0.913428 10.1113 0.367923 14.4645 2.06818 16.8599C2.90598 18.0576 3.91628 19.3474 5.22227 19.3013C5.78902 19.2783 6.20792 19.1171 6.65147 18.9328C7.1443 18.7255 7.66176 18.5182 8.47493 18.5182C9.23881 18.5182 9.73164 18.7255 10.2245 18.9328C10.6434 19.1171 11.0623 19.3013 11.7029 19.2783C13.0582 19.2553 13.9207 18.0806 14.7585 16.906C15.6456 15.6392 16.0398 14.3954 16.1137 14.1881V14.1651C16.1137 14.1651 16.0891 14.1651 16.0891 14.142C15.7934 14.0038 13.5018 12.9904 13.4771 10.2495C13.4525 7.94626 15.3006 6.77159 15.5963 6.58733C15.6209 6.58733 15.6209 6.5643 15.6209 6.5643C14.4381 4.90595 12.6147 4.72169 11.974 4.67562Z" fill="black"/><path fill-rule="evenodd" clip-rule="evenodd" d="M28.2373 1.35893H21.387V19.1631H24.2208V13.0595H28.1387C31.7364 13.0595 34.2498 10.6641 34.2498 7.18618C34.2498 3.70825 31.7857 1.35893 28.2373 1.35893ZM27.4981 10.7562H24.2208V3.68522H27.4981C29.9622 3.68522 31.3667 4.97505 31.3667 7.20921C31.3667 9.44338 29.9622 10.7562 27.4981 10.7562Z" fill="black"/><path fill-rule="evenodd" clip-rule="evenodd" d="M40.9522 6.08061C37.946 6.08061 35.7036 7.762 35.6297 10.0653H38.1924C38.4142 8.95969 39.4491 8.24568 40.8783 8.24568C42.6032 8.24568 43.5889 9.02879 43.5889 10.4798V11.4702L40.0405 11.6775C36.7386 11.8618 34.9644 13.1747 34.9644 15.4779C34.9644 17.7812 36.8125 19.3244 39.4491 19.3244C41.2233 19.3244 42.8743 18.4491 43.6381 17.0441H43.6874V19.1862H46.324V10.2956C46.2994 7.73896 44.2049 6.08061 40.9522 6.08061ZM43.5889 14.3263C43.5889 15.9846 42.135 17.1823 40.213 17.1823C38.7099 17.1823 37.7489 16.4683 37.7489 15.3858C37.7489 14.2572 38.6852 13.6123 40.4348 13.5202L43.5889 13.3359V14.3263Z" fill="black"/><path fill-rule="evenodd" clip-rule="evenodd" d="M55.0224 19.8541C53.8889 22.9635 52.583 24 49.8231 24C49.6013 24 48.9114 23.977 48.7389 23.9309V21.7889C48.9114 21.8119 49.3549 21.8349 49.5767 21.8349C50.8334 21.8349 51.5234 21.3282 51.9669 19.9923L52.2133 19.2092L47.4083 6.24184H50.3652L53.7165 16.7447H53.7657L57.117 6.24184H60L55.0224 19.8541Z" fill="black"/>',
],
'visa' => [
'viewBox' => '0 0 63 20',
'paths' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M18.4896 0.449865L13.0729 13.5709L10.9635 2.37429C10.8073 1.3246 9.92188 0.424873 8.69792 0.424873H0V1.02469C1.79688 1.3246 3.46354 1.92442 4.94792 2.6742C5.54688 2.97411 5.96354 3.59892 6.14583 4.3237L10.2083 19.844H15.625L23.75 0.449865H18.4896Z" fill="#172B85"/><path fill-rule="evenodd" clip-rule="evenodd" d="M25.8333 0.449865L21.6406 19.844H26.7448L30.9635 0.449865H25.8333Z" fill="#172B85"/><path fill-rule="evenodd" clip-rule="evenodd" d="M37.5521 5.82325C37.7083 4.77357 38.5938 4.17375 39.6615 4.17375C41.3021 4.02379 43.125 4.3237 44.6094 5.07348L45.5208 0.89973C44.0104 0.29991 42.3698 0 40.8594 0C35.9115 0 32.2917 2.6742 32.2917 6.42307C32.2917 9.24723 34.8438 10.7468 36.6406 11.6465C38.5938 12.5462 39.349 13.1461 39.1927 14.0208C39.1927 15.3704 37.6823 15.9702 36.1979 15.9702C34.401 15.9702 32.5781 15.5203 30.9375 14.7706L30.026 18.9443C31.849 19.6941 33.8021 19.994 35.599 19.994C41.1458 20.144 44.6094 17.4698 44.6094 13.421C44.6354 8.3475 37.5521 8.04759 37.5521 5.82325Z" fill="#172B85"/><path fill-rule="evenodd" clip-rule="evenodd" d="M58.4375 0.449865H54.0885C53.1771 0.449865 52.2917 1.04969 51.9792 1.94942L44.4792 19.844H49.7396L50.7812 17.0199H57.2396L57.8385 19.844H62.5L58.4375 0.449865ZM52.1354 12.9711L54.8438 5.6733L56.3542 12.9711H52.1354Z" fill="#172B85"/>',
],
'mastercard' => [
'viewBox' => '0 0 50 30',
'paths' => '<circle cx="35" cy="15" r="15" fill="#F9A000"/><circle cx="15" cy="15" r="15" fill="#ED0006"/>',
],
'klarna' => [
'viewBox' => '0 0 70 16',
'paths' => '<path d="M3.505 0H0V15.6925H3.505V0Z" fill="#17120F"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12.249 0H8.81784C8.81784 2.89008 7.52652 5.5342 5.27594 7.26825L3.92314 8.3136L9.18678 15.6925H13.5158L8.67026 8.90392C10.9577 6.55496 12.249 3.39431 12.249 0Z" fill="#17120F"/><path d="M17.8324 0H14.5242V15.6802H17.8324V0Z" fill="#17120F"/><path fill-rule="evenodd" clip-rule="evenodd" d="M27.8309 5.53421C26.9331 4.907 25.8632 4.53805 24.6948 4.53805C21.6203 4.53805 19.1237 7.09608 19.1237 10.269C19.1237 13.4297 21.6203 16 24.6948 16C25.8509 16 26.9331 15.6311 27.8309 15.0038V15.6925H30.9915V4.83321H27.8309V5.53421ZM24.9531 13.0484C23.3666 13.0484 22.0876 11.794 22.0876 10.2567C22.0876 8.71945 23.3666 7.46503 24.9531 7.46503C26.5396 7.46503 27.8186 8.71945 27.8186 10.2567C27.8186 11.794 26.5396 13.0484 24.9531 13.0484Z" fill="#17120F"/><path fill-rule="evenodd" clip-rule="evenodd" d="M61.1591 5.53421C60.2614 4.907 59.1914 4.53805 58.0231 4.53805C54.9485 4.53805 52.452 7.09608 52.452 10.269C52.452 13.4297 54.9485 16 58.0231 16C59.1914 16 60.2614 15.6311 61.1591 15.0038V15.6925H64.3198V4.83321H61.1591V5.53421ZM58.2937 13.0484C56.7072 13.0484 55.4282 11.794 55.4282 10.2567C55.4282 8.71945 56.7072 7.46503 58.2937 7.46503C59.8801 7.46503 61.1591 8.71945 61.1591 10.2567C61.1591 11.794 59.8678 13.0484 58.2937 13.0484Z" fill="#17120F"/><path fill-rule="evenodd" clip-rule="evenodd" d="M67.6772 11.8432C66.5826 11.8432 65.6971 12.7533 65.6971 13.8847C65.6971 15.0161 66.5826 15.9262 67.6772 15.9262C68.7717 15.9262 69.6572 15.0161 69.6572 13.8847C69.6572 12.7533 68.7717 11.8432 67.6772 11.8432Z" fill="#17120F"/><path fill-rule="evenodd" clip-rule="evenodd" d="M46.844 4.55036C45.5773 4.55036 44.3843 4.9562 43.5849 6.06304V4.83322H40.4366V15.6803H43.6218V9.98618C43.6218 8.33821 44.7041 7.52653 45.9954 7.52653C47.3851 7.52653 48.1845 8.37511 48.1845 9.96158V15.6803H51.3451V8.79325C51.3451 6.25981 49.3897 4.55036 46.844 4.55036Z" fill="#17120F"/><path fill-rule="evenodd" clip-rule="evenodd" d="M35.8739 6.24749V4.8332H32.6395V15.6802H35.8862V10.6257C35.8862 8.91621 37.6941 7.99384 38.9362 7.99384H38.9854V4.8332C37.6941 4.8332 36.5134 5.39891 35.8739 6.24749Z" fill="#17120F"/>',
],
'paypal' => [
'viewBox' => '0 0 31 36',
'paths' => '<path d="M27.4956 9.14636C27.4623 9.35989 27.4241 9.5782 27.3812 9.80247C25.9053 17.379 20.8568 19.9964 14.4087 19.9964H11.1257C10.3373 19.9964 9.6728 20.569 9.54986 21.3468L7.86889 32.0072L7.39292 35.0289C7.31291 35.5395 7.7068 36 8.22191 36H14.045C14.7346 36 15.3201 35.499 15.4286 34.8191L15.486 34.523L16.5825 27.5656L16.6529 27.1841C16.76 26.5018 17.3469 26.0007 18.0364 26.0007H18.9073C24.5488 26.0007 28.9654 23.7102 30.2562 17.082C30.7954 14.3131 30.5162 12.0011 29.0893 10.3751C28.6577 9.88479 28.1219 9.47799 27.4956 9.14636Z" fill="#179BD7"/><path d="M25.9514 8.5315C25.7261 8.46588 25.4933 8.40623 25.2547 8.35255C25.015 8.30008 24.7691 8.25355 24.5163 8.21299C23.631 8.06983 22.6612 8.00183 21.6221 8.00183H12.8515C12.6355 8.00183 12.4305 8.05073 12.2468 8.13902C11.8422 8.33346 11.5418 8.71643 11.469 9.18524L9.60289 21.0028L9.54932 21.3475C9.67226 20.5697 10.3367 19.9971 11.1252 19.9971H14.4082C20.8563 19.9971 25.9051 17.3785 27.3807 9.8032C27.4246 9.57892 27.4617 9.36062 27.495 9.14709C27.1217 8.94905 26.7172 8.77965 26.2818 8.63528C26.1746 8.5995 26.0637 8.56491 25.9514 8.5315Z" fill="#222D65"/><path d="M11.469 9.18464C11.5418 8.71578 11.8426 8.33285 12.2468 8.13958C12.4319 8.05129 12.6359 8.00239 12.8516 8.00239H21.6225C22.6616 8.00239 23.6314 8.07039 24.5164 8.21355C24.7695 8.25411 25.0153 8.30064 25.255 8.35311C25.4937 8.40682 25.7262 8.46647 25.9518 8.53206C26.0638 8.56547 26.1747 8.60006 26.2832 8.63467C26.7186 8.779 27.1232 8.94961 27.4965 9.14645C27.9357 6.34656 27.493 4.44021 25.9793 2.71397C24.3103 0.813597 21.298 0 17.4436 0H6.25336C5.46593 0 4.79457 0.572595 4.67266 1.35161L0.0119593 30.8954C-0.0800735 31.4799 0.370819 32.0073 0.960104 32.0073H7.86874L9.60329 21.0021L11.469 9.18464Z" fill="#253B80"/>',
],
];
$icon = $icons[$name] ?? null;
@endphp
@if($icon)
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="{{ $icon['viewBox'] }}"
fill="none"
style="height: {{ $height }}px; width: auto;"
aria-hidden="true"
{{ $attributes }}
>{!! $icon['paths'] !!}</svg>
@endif
@@ -0,0 +1,33 @@
@props([
'name' => '',
'price' => null,
'image' => null,
'href' => '#',
])
<div class="group relative">
<a href="{{ $href }}" class="block border border-black overflow-hidden bg-white mb-4 relative -z-1">
@if($image)
<img
src="{{ $image }}"
alt="{{ $name }}"
class="w-full h-auto block"
>
@else
<div class="w-full aspect-square bg-neutral-300 flex items-center justify-center text-neutral-500 text-sm">
Χωρίς εικόνα
</div>
@endif
</a>
<div class="absolute inset-x-0 top-0 bottom-[calc(theme(spacing.4)+1.5rem+0.25rem)] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-100 ">
<x-ui.button size="md">Προσθήκη στο καλάθι</x-ui.button>
</div>
<div class="flex items-baseline justify-between gap-4">
<a href="{{ $href }}" class="font-bold text-xl leading-snug hover:text-brand">{{ $name }}</a>
@if($price !== null)
<span class="font-bold text-xl shrink-0">$ {{ $price }}</span>
@endif
</div>
</div>
@@ -0,0 +1,38 @@
@props(['name' => 'quantity', 'value' => 1, 'min' => 1])
<div
data-controller="quantity"
data-quantity-min-value="{{ $min }}"
class="inline-flex items-stretch gap-2"
role="group"
aria-label="Quantity"
>
<input
type="number"
name="{{ $name }}"
id="{{ $name }}"
value="{{ $value }}"
min="{{ $min }}"
data-quantity-target="input"
data-action="change->quantity#clamp"
class="w-10 border border-black bg-transparent text-center font-display text-[19px] font-bold [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none focus:outline-none"
aria-label="Quantity"
{{ $attributes }}
>
<div class="flex flex-col -ml-px gap-2 justify-between">
<button
type="button"
data-action="click->quantity#increment"
class="w-10 aspect-square border border-black flex items-center justify-center leading-none font-bold text-xl hover:bg-black hover:text-neutral-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-black"
aria-label="Increase quantity"
>+</button>
<button
type="button"
data-action="click->quantity#decrement"
data-quantity-target="decrement"
class="aspect-square w-10 border border-black -mt-px flex items-center justify-center leading-none font-bold text-xl hover:bg-black hover:text-neutral-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-black disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-black"
aria-label="Decrease quantity"
>−</button>
</div>
</div>
@@ -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:
<x-ui.tabs :tabs="[
['id' => 'description', 'label' => 'Description'],
['id' => 'info', 'label' => 'Additional information'],
['id' => 'reviews', 'label' => 'Reviews (0)'],
]">
<x-slot name="description">...</x-slot>
<x-slot name="info">...</x-slot>
<x-slot name="reviews">...</x-slot>
</x-ui.tabs>
--}}
<div data-controller="tabs" {{ $attributes }}>
<div class="flex gap-10 mb-8" role="tablist">
@foreach($tabs as $i => $tab)
<button
type="button"
role="tab"
data-tabs-target="button"
data-action="click->tabs#show"
data-panel="{{ $tab['id'] }}"
class="underline-slide font-display pb-1 {{ $sizeClasses }} {{ $i === 0 ? 'is-active' : '' }}"
aria-selected="{{ $i === 0 ? 'true' : 'false' }}"
aria-controls="tab-panel-{{ $tab['id'] }}"
id="tab-btn-{{ $tab['id'] }}"
>{{ $tab['label'] }}</button>
@endforeach
</div>
@foreach($tabs as $i => $tab)
@php $panelKey = $tab['id']; $panel = $$panelKey ?? null; @endphp
<div
role="tabpanel"
data-tabs-target="panel"
id="tab-panel-{{ $tab['id'] }}"
aria-labelledby="tab-btn-{{ $tab['id'] }}"
tabindex="0"
@if($i !== 0) hidden @endif
>{{ $panel }}</div>
@endforeach
</div>
@@ -0,0 +1,22 @@
@props([
'placeholder' => null,
'required' => false,
'disabled' => false,
'readonly' => false,
'rows' => 4,
])
<textarea
rows="{{ $rows }}"
@if ($placeholder) placeholder="{{ $placeholder }}" @endif
@required($required)
@disabled($disabled)
@readonly($readonly)
{{ $attributes->merge([
'class' => 'w-full bg-transparent border-0 border-b border-black resize-none py-2
placeholder:text-neutral-400
focus:outline-none
disabled:cursor-not-allowed disabled:opacity-50
read-only:opacity-60',
]) }}
>{{ $slot }}</textarea>
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
{{-- Favicons --}}
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>@yield('title', config('app.name'))</title>
<meta name="description" content="@yield('description', '')">
@stack('seo')
@vite(['resources/css/app.css', 'resources/js/app.js'])
@stack('head')
</head>
<body class="min-h-screen antialiased">
<x-header />
<main id="main-content" class="px-4 sm:px-8 min-h-[calc(100vh-12rem)] sm:min-h-[calc(100vh-8rem)]">
@yield('content')
</main>
<x-footer />
<x-back-to-top />
@stack('scripts')
</body>
</html>
+236
View File
@@ -0,0 +1,236 @@
@extends('layouts.app')
@section('title', $product->translateAttribute('name') . ' — ' . config('app.name'))
@section('description', $product->translateAttribute('description'))
@section('content')
<div class="max-w-7xl mx-auto py-12">
@php $collection = $product->collections->first(); @endphp
<x-breadcrumb class="mb-14 justify-end" :items="[
$collection
? ['label' => $collection->translateAttribute('name'), 'href' => '/category/' . $collection->id]
: ['label' => 'Products', 'href' => '/products'],
['label' => $product->translateAttribute('name')],
]" />
<div
class="grid grid-cols-1 md:grid-cols-2 gap-12"
data-controller="product-form"
data-product-form-variants-value="{{ json_encode($variantsData) }}"
>
{{-- Image --}}
<div
data-controller="product-gallery"
class="flex gap-4 items-start"
>
@if($product->media->isNotEmpty())
{{-- Thumbnails --}}
<div class="flex flex-col items-center gap-1 w-[116px] shrink-0 -mt-12.5">
<button
type="button"
data-action="click->product-gallery#scrollUp"
class="gallery-arrow w-full flex items-center justify-center py-2"
aria-label="Scroll thumbnails up"
><x-ui.icon name="arrow-up" :size="30" /></button>
<div
data-product-gallery-target="track"
class="flex flex-col gap-4 overflow-hidden"
style="max-height: var(--gallery-height, 600px)"
>
@foreach($product->media as $i => $media)
<button
type="button"
data-action="click->product-gallery#select"
data-product-gallery-target="thumb"
data-src="{{ $media->getUrl() }}"
data-alt="{{ $product->translateAttribute('name') }}"
class="block w-full shrink-0 border border-black overflow-hidden "
aria-label="View image {{ $i + 1 }}"
aria-pressed="{{ $i === 0 ? 'true' : 'false' }}"
>
<!-- opacity-50 transition-opacity {{ $i === 0 ? 'opacity-100' : '' }}" -->
<img src="{{ $media->getUrl() }}" alt="" class="w-full h-auto object-cover" aria-hidden="true">
</button>
@endforeach
</div>
<button
type="button"
data-action="click->product-gallery#scrollDown"
class="gallery-arrow w-full flex items-center justify-center py-2"
aria-label="Scroll thumbnails down"
><x-ui.icon name="arrow-down" :size="30" /></button>
</div>
{{-- Main image --}}
<button
type="button"
class="flex-1 border border-black bg-white cursor-zoom-in block p-0"
data-action="click->product-gallery#openLightbox"
aria-label="View full size image"
>
<img
data-product-form-target="image"
data-product-gallery-target="main"
src="{{ $product->media->first()->getUrl() }}"
alt="{{ $product->translateAttribute('name') }}"
class="w-full h-auto block"
>
</button>
{{-- Lightbox --}}
<div
id="product-lightbox"
popover
class="product-lightbox"
data-product-gallery-target="lightbox"
data-action="click->product-gallery#closeLightboxOnBackdrop"
role="dialog"
aria-label="Product image gallery"
aria-modal="true"
>
{{-- Prev --}}
<button
type="button"
data-action="click->product-gallery#prevImage click->product-gallery#noop"
class="lightbox-arrow absolute left-8 top-1/2 -translate-y-1/2"
aria-label="Previous image"
><x-ui.icon name="arrow-left" :size="72" color="white" /></button>
{{-- Image + close button as a unit --}}
<div class="relative" data-action="click->product-gallery#noop">
<img
data-product-gallery-target="lightboxImage"
src=""
alt=""
class="max-h-[90vh] max-w-[80vw] object-contain block"
>
<button
type="button"
popovertarget="product-lightbox"
popovertargetaction="hide"
class="lightbox-close absolute -top-10 -right-10"
aria-label="Close gallery"
><x-ui.icon name="close" :size="36" color="white" /></button>
</div>
{{-- Next --}}
<button
type="button"
data-action="click->product-gallery#nextImage click->product-gallery#noop"
class="lightbox-arrow absolute right-8 top-1/2 -translate-y-1/2"
aria-label="Next image"
><x-ui.icon name="arrow-right" :size="72" color="white" /></button>
{{-- Counter --}}
<p
data-product-gallery-target="lightboxCounter"
class="absolute bottom-6 right-8 text-white text-sm"
aria-live="polite"
></p>
</div>
@else
<div class="w-full bg-neutral-300 flex items-center justify-center text-neutral-500 min-h-64">
No image
</div>
@endif
</div>
{{-- Info --}}
<div class="flex flex-col gap-6">
<h1 class="font-display font-medium text-4xl lg:text-[54px]">
{{ $product->translateAttribute('name') }}
</h1>
<x-reviews-stars :rating="3" :count="24" :showCount="true" />
@if($product->variants->first()?->prices->isNotEmpty())
<p class="text-2xl font-bold" data-product-form-target="price">
€{{ number_format($product->variants->first()->prices->first()->price->decimal, 2) }}
</p>
@endif
@php
$desc = strip_tags($product->translateAttribute('description') ?? '');
$descTruncated = Str::limit($desc, 137);
$descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
@endphp
<div class="text-base leading-relaxed">
{{ $descTruncated }}
@if($descNeedsMore)
<a href="#tab-panel-description" class="underline-slide font-semibold whitespace-nowrap">Περισσότερα</a>
@endif
</div>
@if($option && $product->variants->count() >= 1)
<x-ui.color-swatch :variants="$product->variants" :option="$option" />
@endif
<div class="flex items-stretch gap-10">
<x-ui.quantity name="quantity" />
<x-ui.button class="flex-1">Προσθήκη στο καλάθι</x-ui.button>
</div>
</div>
</div>
<x-ui.tabs class="mt-16" size="lg" :tabs="[
['id' => 'description', 'label' => 'Περιγραφή'],
// ['id' => 'reviews', 'label' => 'Αξιολογήσεις (' . count($reviews) . ')'],
['id' => 'reviews', 'label' => 'Αξιολογήσεις (3)'],
]">
<x-slot name="description">
<div class="leading-7 [&_p]:mt-4">
{!! $product->translateAttribute('description') !!}
</div>
@if($product->translateAttribute('details'))
<div class="mt-6">{!! $product->translateAttribute('details') !!}</div>
@endif
<ul class="mt-8 flex flex-col gap-3 text-neutral-600 list-disc list-outside pl-5">
<li>Όλα τα προϊόντα εκτυπώνονται και προετοιμάζονται κατά παραγγελία. Ο χρόνος προετοιμασίας κυμαίνεται μεταξύ 2 και 7 εργάσιμων ημερών.</li>
<li>Όλα τα προϊόντα κατασκευάζονται με τρισδιάστατη εκτύπωση σε ειδικούς εκτυπωτές πλαστικού υλικού. Πιθανώς να έχουν εμφανείς γραμμές ένωσης, στρώσεις εκτύπωσης υλικού και μικρές ατέλειες. Είναι φυσιολογικό για το αποτέλεσμα αυτής της δημιουργικής διαδικασίας.</li>
<li>Όλα τα προϊόντα είναι σχεδιασμένα και κατασκευασμένα είτε για διακόσμηση είτε για χρήση σε λογικά, ρεαλιστικά πλαίσια. Υπερβολική ισχύς, υψηλότατες θερμοκρασίες και αποσυναρμολόγηση μπορεί να προκαλέσουν ζημιά στο προϊόν για την οποία δεν ευθύνεται το 3Dealer.</li>
</ul>
</x-slot>
<x-slot name="reviews">
{{-- @if(count($reviews) > 0)
<div class="mb-10">
@foreach($reviews as $review)
<x-review-card :review="$review" />
@endforeach
</div>
@else
<p class="text-neutral-500 mb-10">Δεν υπάρχουν αξιολογήσεις ακόμα.</p>
@endif
<h3 class="text-h4 font-bold">
{{ count($reviews) > 0 ? 'Πρόσθεσε μια' : 'Γράψε την πρώτη' }} αξιολόγηση για το «{{ $product->translateAttribute('name') }}»
</h3> --}}
<x-review-form :product="$product" />
</x-slot>
</x-ui.tabs>
<x-product-grid
class="mt-20"
title="Σχετικά προϊόντα"
:products="[
['name' => 'Camper', 'price' => '30', 'image' => null, 'href' => '#'],
['name' => 'Ponderer IPA','price' => '25', 'image' => null, 'href' => '#'],
['name' => 'Squish Red', 'price' => '35', 'image' => null, 'href' => '#'],
['name' => 'Red Light', 'price' => '25', 'image' => null, 'href' => '#'],
]"
/>
</div>
<x-newsletter class="py-20" />
@endsection
+236
View File
@@ -0,0 +1,236 @@
@extends('layouts.app')
@section('title', $product->translateAttribute('name') . ' — ' . config('app.name'))
@section('description', $product->translateAttribute('description'))
@section('content')
<div class="max-w-7xl mx-auto py-12">
@php $collection = $product->collections->first(); @endphp
<x-breadcrumb class="mb-14 justify-end" :items="[
$collection
? ['label' => $collection->translateAttribute('name'), 'href' => '/category/' . $collection->id]
: ['label' => 'Products', 'href' => '/products'],
['label' => $product->translateAttribute('name')],
]" />
<div
class="grid grid-cols-1 md:grid-cols-2 gap-12"
data-controller="product-form"
data-product-form-variants-value="{{ json_encode($variantsData) }}"
>
{{-- Image --}}
<div
data-controller="product-gallery"
class="flex gap-4 items-start"
>
@if($product->media->isNotEmpty())
{{-- Thumbnails --}}
<div class="flex flex-col items-center gap-1 w-[116px] shrink-0 -mt-12.5">
<button
type="button"
data-action="click->product-gallery#scrollUp"
class="gallery-arrow w-full flex items-center justify-center py-2"
aria-label="Scroll thumbnails up"
><x-ui.icon name="arrow-up" :size="30" /></button>
<div
data-product-gallery-target="track"
class="flex flex-col gap-4 overflow-hidden"
style="max-height: var(--gallery-height, 600px)"
>
@foreach($product->media as $i => $media)
<button
type="button"
data-action="click->product-gallery#select"
data-product-gallery-target="thumb"
data-src="{{ $media->getUrl() }}"
data-alt="{{ $product->translateAttribute('name') }}"
class="block w-full shrink-0 border border-black overflow-hidden "
aria-label="View image {{ $i + 1 }}"
aria-pressed="{{ $i === 0 ? 'true' : 'false' }}"
>
<!-- opacity-50 transition-opacity {{ $i === 0 ? 'opacity-100' : '' }}" -->
<img src="{{ $media->getUrl() }}" alt="" class="w-full h-auto object-cover" aria-hidden="true">
</button>
@endforeach
</div>
<button
type="button"
data-action="click->product-gallery#scrollDown"
class="gallery-arrow w-full flex items-center justify-center py-2"
aria-label="Scroll thumbnails down"
><x-ui.icon name="arrow-down" :size="30" /></button>
</div>
{{-- Main image --}}
<button
type="button"
class="flex-1 border border-black bg-white cursor-zoom-in block p-0"
data-action="click->product-gallery#openLightbox"
aria-label="View full size image"
>
<img
data-product-form-target="image"
data-product-gallery-target="main"
src="{{ $product->media->first()->getUrl() }}"
alt="{{ $product->translateAttribute('name') }}"
class="w-full h-auto block"
>
</button>
{{-- Lightbox --}}
<div
id="product-lightbox"
popover
class="product-lightbox"
data-product-gallery-target="lightbox"
data-action="click->product-gallery#closeLightboxOnBackdrop"
role="dialog"
aria-label="Product image gallery"
aria-modal="true"
>
{{-- Prev --}}
<button
type="button"
data-action="click->product-gallery#prevImage click->product-gallery#noop"
class="lightbox-arrow absolute left-8 top-1/2 -translate-y-1/2"
aria-label="Previous image"
><x-ui.icon name="arrow-left" :size="72" color="white" /></button>
{{-- Image + close button as a unit --}}
<div class="relative" data-action="click->product-gallery#noop">
<img
data-product-gallery-target="lightboxImage"
src=""
alt=""
class="max-h-[90vh] max-w-[80vw] object-contain block"
>
<button
type="button"
popovertarget="product-lightbox"
popovertargetaction="hide"
class="lightbox-close absolute -top-10 -right-10"
aria-label="Close gallery"
><x-ui.icon name="close" :size="36" color="white" /></button>
</div>
{{-- Next --}}
<button
type="button"
data-action="click->product-gallery#nextImage click->product-gallery#noop"
class="lightbox-arrow absolute right-8 top-1/2 -translate-y-1/2"
aria-label="Next image"
><x-ui.icon name="arrow-right" :size="72" color="white" /></button>
{{-- Counter --}}
<p
data-product-gallery-target="lightboxCounter"
class="absolute bottom-6 right-8 text-white text-sm"
aria-live="polite"
></p>
</div>
@else
<div class="w-full bg-neutral-300 flex items-center justify-center text-neutral-500 min-h-64">
No image
</div>
@endif
</div>
{{-- Info --}}
<div class="flex flex-col gap-6">
<h1 class="font-display font-medium text-4xl lg:text-[54px]">
{{ $product->translateAttribute('name') }}
</h1>
<x-reviews-stars :rating="3" :count="24" :showCount="true" />
@if($product->variants->first()?->prices->isNotEmpty())
<p class="text-2xl font-bold" data-product-form-target="price">
€{{ number_format($product->variants->first()->prices->first()->price->decimal, 2) }}
</p>
@endif
@php
$desc = strip_tags($product->translateAttribute('description') ?? '');
$descTruncated = Str::limit($desc, 137);
$descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
@endphp
<div class="text-base leading-relaxed">
{{ $descTruncated }}
@if($descNeedsMore)
<a href="#tab-panel-description" class="underline-slide font-semibold whitespace-nowrap">Περισσότερα</a>
@endif
</div>
@if($option && $product->variants->count() >= 1)
<x-ui.color-swatch :variants="$product->variants" :option="$option" />
@endif
<div class="flex items-stretch gap-10">
<x-ui.quantity name="quantity" />
<x-ui.button class="flex-1">Προσθήκη στο καλάθι</x-ui.button>
</div>
</div>
</div>
<x-ui.tabs class="mt-16" size="lg" :tabs="[
['id' => 'description', 'label' => 'Περιγραφή'],
// ['id' => 'reviews', 'label' => 'Αξιολογήσεις (' . count($reviews) . ')'],
['id' => 'reviews', 'label' => 'Αξιολογήσεις (3)'],
]">
<x-slot name="description">
<div class="leading-7 [&_p]:mt-4">
{!! $product->translateAttribute('description') !!}
</div>
@if($product->translateAttribute('details'))
<div class="mt-6">{!! $product->translateAttribute('details') !!}</div>
@endif
<ul class="mt-8 flex flex-col gap-3 text-neutral-600 list-disc list-outside pl-5">
<li>Όλα τα προϊόντα εκτυπώνονται και προετοιμάζονται κατά παραγγελία. Ο χρόνος προετοιμασίας κυμαίνεται μεταξύ 2 και 7 εργάσιμων ημερών.</li>
<li>Όλα τα προϊόντα κατασκευάζονται με τρισδιάστατη εκτύπωση σε ειδικούς εκτυπωτές πλαστικού υλικού. Πιθανώς να έχουν εμφανείς γραμμές ένωσης, στρώσεις εκτύπωσης υλικού και μικρές ατέλειες. Είναι φυσιολογικό για το αποτέλεσμα αυτής της δημιουργικής διαδικασίας.</li>
<li>Όλα τα προϊόντα είναι σχεδιασμένα και κατασκευασμένα είτε για διακόσμηση είτε για χρήση σε λογικά, ρεαλιστικά πλαίσια. Υπερβολική ισχύς, υψηλότατες θερμοκρασίες και αποσυναρμολόγηση μπορεί να προκαλέσουν ζημιά στο προϊόν για την οποία δεν ευθύνεται το 3Dealer.</li>
</ul>
</x-slot>
<x-slot name="reviews">
{{-- @if(count($reviews) > 0)
<div class="mb-10">
@foreach($reviews as $review)
<x-review-card :review="$review" />
@endforeach
</div>
@else
<p class="text-neutral-500 mb-10">Δεν υπάρχουν αξιολογήσεις ακόμα.</p>
@endif
<h3 class="text-h4 font-bold">
{{ count($reviews) > 0 ? 'Πρόσθεσε μια' : 'Γράψε την πρώτη' }} αξιολόγηση για το «{{ $product->translateAttribute('name') }}»
</h3> --}}
<x-review-form :product="$product" />
</x-slot>
</x-ui.tabs>
<x-product-grid
class="mt-20"
title="Σχετικά προϊόντα"
:products="[
['name' => 'Camper', 'price' => '30', 'image' => null, 'href' => '#'],
['name' => 'Ponderer IPA','price' => '25', 'image' => null, 'href' => '#'],
['name' => 'Squish Red', 'price' => '35', 'image' => null, 'href' => '#'],
['name' => 'Red Light', 'price' => '25', 'image' => null, 'href' => '#'],
]"
/>
</div>
<x-newsletter class="py-20" />
@endsection
+7 -2
View File
@@ -1,7 +1,12 @@
<?php
use App\Http\Controllers\ProductController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
Route::get("/", function () {
return view("welcome");
});
Route::get("/products/{product}", [ProductController::class, "show"])->name(
"product.show",
);
+1 -1
View File
@@ -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/**"],