generated from boboko/starter
homepage, new newsletter section, multilang support
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_LOCALE=el
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
|
||||
@@ -14,6 +14,29 @@ ## Project Language Settings
|
||||
|
||||
---
|
||||
|
||||
## Localization
|
||||
|
||||
Uses classic Laravel localization (https://laravel.com/docs/13.x/localization) — no third-party i18n routing package.
|
||||
|
||||
**Locales:** Greek (`el`) is primary/default, English (`en`) is secondary. Set in `config('app.available_locales')` (`config/app.php`).
|
||||
|
||||
**URL structure:** every route is prefixed with `{locale}`, including the default — `3dealer.gr/el/...` and `3dealer.gr/en/...`. Bare root (`/`) 301-redirects to `/el` (see `routes/web.php`). This avoids the ambiguity of an unprefixed default locale (see the reasoning in project memory / past conversation — prefixing every locale keeps hreflang symmetrical and scales cleanly to a third language later).
|
||||
|
||||
**URL slugs are the same across both locales, permanently** — e.g. `/el/products/{x}` and `/en/products/{x}`, not `/el/proionta/{x}` vs `/en/products/{x}`. This is a deliberate, standing decision for this project, not a placeholder to revisit later. Only the visible content is translated, not the slug. Do not introduce per-locale translated slugs (e.g. a `lang/{locale}/routes.php` segment map) unless the user explicitly asks for that to change.
|
||||
|
||||
**How it's wired:**
|
||||
- All routes live inside `Route::prefix('{locale}')->where('locale', ...)->middleware('setlocale')` in `routes/web.php`.
|
||||
- `App\Http\Middleware\SetLocale` validates the `{locale}` segment (404s if not in `available_locales`), calls `App::setLocale()`, and shares `$currentLocale`, `$altLocale`, and `$altLocaleUrl` (the same page in the other locale) to every view — this is what powers both the header's language switcher and the `<head>` hreflang tags in `resources/views/layouts/app.blade.php`. Don't recompute this logic elsewhere; read those shared variables instead.
|
||||
- When adding a new route, put it inside that `{locale}` group and give it a route `name()` — the alt-locale URL generation in the middleware depends on the current route being named (falls back to the locale root if unnamed).
|
||||
- When linking to a page in Blade, prefer `route('name', [...])` (locale is a normal route param, defaults to `app()->getLocale()` implicitly via the shared route group) over hand-built path strings, except for pages that don't have a controller/route yet (e.g. `/products`, `/contact`, `/cart` placeholders in the header currently use `url('/'.app()->getLocale().'/products')` — replace with `route()` once those pages exist).
|
||||
- **Every controller action for a route inside the `{locale}` group must declare `$locale` as its first parameter, even if unused** — e.g. `show(string $locale, Product $product)`. Laravel's `ControllerDispatcher` ultimately calls the controller with `...array_values($parameters)`, i.e. **positionally**. If a route-bound model parameter (like `$product`) isn't preceded by a matching `$locale` parameter in the method signature, the resolved values shift out of position and the wrong value (the locale string) gets passed where the model was expected — a `TypeError` that's easy to misread as a binding failure. Closures with zero declared parameters are unaffected (PHP just ignores the extra positional arg), so this only bites real controller methods.
|
||||
|
||||
**Where translatable strings go:**
|
||||
- Most UI copy is editable by the client/marketing team via **Stoic** (the headless CMS) — don't hardcode it here.
|
||||
- Small strings that don't need client editing (nav labels, aria-labels, tab labels, pluralized counts) go in Laravel's own lang files at **`lang/{locale}/*.php`** (project root, per Laravel 12+ convention — not `resources/lang`). Current file: `lang/el/general.php` / `lang/en/general.php`. Use `__('general.key')` or `trans_choice('general.key', $count, [...])` for pluralized strings.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Laravel 12** + **Lunar PHP 1.3** (headless e-commerce)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Lunar\Models\Product;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$products = Product::with(['variants.prices.currency', 'media'])
|
||||
->inRandomOrder()
|
||||
->limit(13)
|
||||
->get()
|
||||
->map(fn (Product $product) => [
|
||||
'name' => $product->translateAttribute('name'),
|
||||
'price' => $product->variants->first()?->prices->first()?->price->decimal,
|
||||
'image' => $product->media->first()?->getUrl(),
|
||||
'href' => route('product.show', ['product' => $product]),
|
||||
]);
|
||||
|
||||
return view('home', [
|
||||
'heroProducts' => $products->take(5)->values(),
|
||||
'classicsProducts' => $products->slice(5)->values(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function show(Product $product)
|
||||
public function show(string $locale, Product $product)
|
||||
{
|
||||
$product->load([
|
||||
"variants.prices.currency",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
/**
|
||||
* Validate the {locale} route segment, apply it app-wide, and share
|
||||
* the current/alternate locale (and the alternate's URL) with all views
|
||||
* so the header switcher and layout hreflang tags don't recompute it.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$locale = $request->route('locale');
|
||||
$available = config('app.available_locales');
|
||||
|
||||
if (! in_array($locale, $available, true)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
App::setLocale($locale);
|
||||
|
||||
// Lets route() calls omit {locale} anywhere in the request lifecycle
|
||||
// (controllers, views) — without this, every route() call inside the
|
||||
// {locale} group would need locale passed explicitly every time.
|
||||
URL::defaults(['locale' => $locale]);
|
||||
|
||||
$altLocale = collect($available)->first(fn ($l) => $l !== $locale);
|
||||
$routeName = $request->route()->getName();
|
||||
|
||||
View::share('currentLocale', $locale);
|
||||
View::share('altLocale', $altLocale);
|
||||
View::share(
|
||||
'altLocaleUrl',
|
||||
$routeName
|
||||
? route($routeName, array_merge($request->route()->parameters(), ['locale' => $altLocale]))
|
||||
: url('/'.$altLocale),
|
||||
);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -11,7 +11,9 @@
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
$middleware->alias([
|
||||
'setlocale' => \App\Http\Middleware\SetLocale::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
Generated
+1068
-212
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -78,12 +78,25 @@
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
'locale' => env('APP_LOCALE', 'el'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Available Locales
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The locales the site is served in, and the ones allowed as the
|
||||
| {locale} route segment (see routes/web.php and SetLocale middleware).
|
||||
| Greek is the primary/default market, English is secondary.
|
||||
|
|
||||
*/
|
||||
|
||||
'available_locales' => ['el', 'en'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'nav' => [
|
||||
'products' => 'Προϊόντα',
|
||||
'contact' => 'Επικοινωνία',
|
||||
],
|
||||
|
||||
'product' => [
|
||||
'description' => 'Περιγραφή',
|
||||
'reviews' => 'Αξιολογήσεις',
|
||||
],
|
||||
|
||||
// Laravel pluralization: {0} zero|{1} one|[2,*] many. :count is replaced automatically.
|
||||
'customer_reviews' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
|
||||
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'nav' => [
|
||||
'products' => 'Products',
|
||||
'contact' => 'Contact',
|
||||
],
|
||||
|
||||
'product' => [
|
||||
'description' => 'Description',
|
||||
'reviews' => 'Reviews',
|
||||
],
|
||||
|
||||
// Laravel pluralization: {0} zero|{1} one|[2,*] many. :count is replaced automatically.
|
||||
'customer_reviews' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
|
||||
|
||||
];
|
||||
@@ -47,6 +47,15 @@ @keyframes back-to-top-spin {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes text-flicker-in {
|
||||
0%, 60% {
|
||||
opacity: 0;
|
||||
}
|
||||
70%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
COMPONENTS
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
@@ -107,6 +116,12 @@ @layer components {
|
||||
color: theme(colors.neutral.200);
|
||||
}
|
||||
|
||||
/* ── Hero star — same burst shape/spin as back-to-top, always on ─ */
|
||||
.hero-star {
|
||||
transform-origin: center;
|
||||
animation: back-to-top-spin 7s infinite linear;
|
||||
}
|
||||
|
||||
/* ── Back to top ──────────────────────────────────────────────── */
|
||||
.back-to-top {
|
||||
opacity: 0;
|
||||
@@ -286,4 +301,26 @@ @layer components {
|
||||
.btn-primary:hover::after {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
/* ── Text flicker — word switches from regular to italic/bold once
|
||||
its .is-visible ancestor appears; add data-text="<same word>" ── */
|
||||
.text-flicker {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.text-flicker::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-weight: extra-bold;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.is-visible .text-flicker::after {
|
||||
animation: text-flicker-in 0.7s ease 3 forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static classes = ['visible']
|
||||
|
||||
connect() {
|
||||
this.observer = new IntersectionObserver(([entry]) => {
|
||||
if (!entry.isIntersecting) return
|
||||
|
||||
this.element.classList.add(this.visibleClass)
|
||||
this.observer.disconnect()
|
||||
}, { threshold: 0.3 })
|
||||
|
||||
this.observer.observe(this.element)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.observer?.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['slide']
|
||||
static values = { index: { type: Number, default: 0 } }
|
||||
|
||||
connect() {
|
||||
this.showSlide()
|
||||
}
|
||||
|
||||
next() {
|
||||
this.indexValue = (this.indexValue + 1) % this.slideTargets.length
|
||||
}
|
||||
|
||||
prev() {
|
||||
this.indexValue = (this.indexValue - 1 + this.slideTargets.length) % this.slideTargets.length
|
||||
}
|
||||
|
||||
indexValueChanged() {
|
||||
this.showSlide()
|
||||
}
|
||||
|
||||
showSlide() {
|
||||
this.slideTargets.forEach((slide, i) => {
|
||||
slide.classList.toggle('hidden', i !== this.indexValue)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
// import HelloController from './controllers/hello_controller';
|
||||
// application.register('hello', HelloController);
|
||||
|
||||
import AppearController from './appear-controller'
|
||||
import BackToTopController from './back-to-top-controller'
|
||||
import CarouselController from './carousel-controller'
|
||||
import ProductFormController from './product-form-controller'
|
||||
import ProductGalleryController from './product-gallery-controller'
|
||||
import QuantityController from './quantity-controller'
|
||||
@@ -11,7 +13,9 @@ import StarRatingController from './star-rating-controller'
|
||||
import TabsController from './tabs-controller'
|
||||
|
||||
export function registerControllers(application) {
|
||||
application.register('appear', AppearController)
|
||||
application.register('back-to-top', BackToTopController)
|
||||
application.register('carousel', CarouselController)
|
||||
application.register('product-form', ProductFormController)
|
||||
application.register('product-gallery', ProductGalleryController)
|
||||
application.register('quantity', QuantityController)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div style="height: 120vh"></div>
|
||||
<x-newsletter-split class="py-20" />
|
||||
<div style="height: 120vh"></div>
|
||||
@endsection
|
||||
@@ -1,9 +1,9 @@
|
||||
<header class="sticky top-0 z-50 bg-neutral-200 border-b border-black">
|
||||
<header class="sticky top-0 z-50 bg-neutral-200 border-b border-black lg:h-26">
|
||||
<div class="flex items-center gap-14 px-10">
|
||||
|
||||
{{-- Logo --}}
|
||||
<a href="/" class="shrink-0">
|
||||
<img src="/images/logo.png" alt="{{ config('app.name') }}" class="h-16 w-auto">
|
||||
<a href="{{ url('/'.app()->getLocale()) }}" class="shrink-0 lg:py-4">
|
||||
<img src="/images/logo.png" alt="{{ config('app.name') }}" class="h-18 w-auto">
|
||||
</a>
|
||||
|
||||
{{-- Primary nav --}}
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
{{-- 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>
|
||||
<a href="{{ url('/'.app()->getLocale().'/products') }}" class="nav-link uppercase inline-flex items-center gap-1 relative font-display font-extrabold italic text-black no-underline py-8">
|
||||
<span>{{ __('general.nav.products') }}</span>
|
||||
<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>
|
||||
@@ -27,15 +27,22 @@
|
||||
</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>
|
||||
<a href="{{ url('/'.app()->getLocale().'/contact') }}" class="nav-link uppercase inline-flex items-center gap-1 relative font-display font-extrabold italic text-black no-underline"><span>{{ __('general.nav.contact') }}</span></a>
|
||||
|
||||
</nav>
|
||||
|
||||
{{-- Right side actions --}}
|
||||
<div class="ml-auto flex items-center gap-8">
|
||||
|
||||
{{-- Language switcher --}}
|
||||
@isset($altLocale)
|
||||
<a href="{{ $altLocaleUrl }}" class="uppercase font-display font-extrabold text-sm hover:opacity-70 transition-opacity" hreflang="{{ $altLocale }}" aria-label="{{ $altLocale === 'el' ? 'Δες τη σελίδα στα Ελληνικά' : 'View this page in English' }}">
|
||||
{{ $altLocale }}
|
||||
</a>
|
||||
@endisset
|
||||
|
||||
{{-- Cart --}}
|
||||
<a href="/cart" class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity" aria-label="Cart">
|
||||
<a href="{{ url('/'.app()->getLocale().'/cart') }}" class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity" aria-label="Cart">
|
||||
<x-ui.icon name="bag" :size="40" />
|
||||
</a>
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
@props([
|
||||
'image' => null,
|
||||
])
|
||||
|
||||
<section {{ $attributes }}>
|
||||
<div class="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-2 border border-black">
|
||||
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16">
|
||||
<h2
|
||||
class="text-h2 leading-tight"
|
||||
aria-label="Γράψου στο newsletter μας"
|
||||
data-controller="appear"
|
||||
data-appear-visible-class="is-visible"
|
||||
>
|
||||
<span class="text-flicker" data-text="Γράψου">Γράψου</span>
|
||||
στο
|
||||
<span class="text-flicker" data-text="newsletter">newsletter</span>
|
||||
μας
|
||||
</h2>
|
||||
|
||||
<p class="max-w-md text-neutral-600">
|
||||
Γίνε μέλος της λίστας μας και κέρδισε <span class="font-bold">5% έκπτωση</span> στην πρώτη σου παραγγελία, μαζί με αποκλειστικές προσφορές και νέα.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="#" class="flex flex-col gap-6 max-w-md">
|
||||
@csrf
|
||||
<div class="relative">
|
||||
<x-ui.input
|
||||
name="email"
|
||||
type="email"
|
||||
:required="true"
|
||||
autocomplete="email"
|
||||
placeholder="Το email σου"
|
||||
class="pr-10 text-lg"
|
||||
placeholderClass="placeholder:text-black/40"
|
||||
aria-label="Το email σου"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Εγγραφή στο newsletter"
|
||||
class="absolute right-0 bottom-2 cursor-pointer"
|
||||
>
|
||||
<x-ui.icon name="arrow-right" size="22" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<x-ui.checkbox name="gdpr_consent" :required="true" id="newsletter_split_gdpr_consent" class="after:mix-blend-multiply">
|
||||
<span class="text-sm text-left">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
|
||||
</x-ui.checkbox>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="relative bg-neutral-300 min-h-[320px] lg:min-h-full border-t border-black lg:border-t-0 lg:border-l">
|
||||
@if($image)
|
||||
<img src="{{ $image }}" alt="" aria-hidden="true" loading="lazy" width="800" height="800" class="w-full h-full object-cover">
|
||||
@else
|
||||
<div class="absolute inset-0 flex items-center justify-center text-neutral-500 text-sm">
|
||||
Χωρίς εικόνα
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -29,7 +29,7 @@ class="flex items-center gap-1.5 text-brand"
|
||||
|
||||
@if ($showCount && $count > 0)
|
||||
<span class="text-sm text-neutral-500">
|
||||
({{ $count }} customer {{ $count === 1 ? 'review' : 'reviews' }})
|
||||
({{ trans_choice('general.customer_reviews', $count, ['count' => $count]) }})
|
||||
</span>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
@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">
|
||||
@@ -42,11 +14,9 @@ class="flex flex-wrap gap-2"
|
||||
>
|
||||
@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';
|
||||
$value = $variant->values->first();
|
||||
$label = $value?->translate('name') ?? '';
|
||||
$bg = $value?->meta['hex'] ?? '#cccccc';
|
||||
@endphp
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<img
|
||||
src="{{ $image }}"
|
||||
alt="{{ $name }}"
|
||||
loading="lazy"
|
||||
class="w-full h-auto block"
|
||||
>
|
||||
@else
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', config('app.name') . ' — Ό,τι φαντάζεσαι, τυπωμένο σε 3D')
|
||||
@section('description', 'Gaming φιγούρες, κρανία, κλειδοθήκες και ό,τι πιο τρελό σου περνάει απ\' το μυαλό. Σχεδιασμένα και τυπωμένα σε 3D, ένα προς ένα.')
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="border-b border-black">
|
||||
|
||||
{{-- Hero --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 lg:divide-x lg:divide-black border-b border-black min-h-[calc(100vh-102px)]">
|
||||
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16">
|
||||
<svg viewBox="0 0 135 124" class="hero-star w-[173px] h-[159px] text-[#f3d121]" aria-hidden="true" fill="currentColor">
|
||||
<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"/>
|
||||
</svg>
|
||||
|
||||
<h1
|
||||
class="font-display font-semibold text-6xl lg:text-7xl leading-tight"
|
||||
aria-label="Ό,τι φαντάζεσαι, το τυπώνουμε"
|
||||
data-controller="appear"
|
||||
data-appear-visible-class="is-visible"
|
||||
>
|
||||
Ό,τι <span class="text-flicker" data-text="φαντάζεσαι">φαντάζεσαι</span>,<br>
|
||||
το <span class="text-flicker" data-text="τυπώνουμε">τυπώνουμε</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col items-center justify-center px-8 py-10 lg:px-10" data-controller="carousel">
|
||||
@if($heroProducts->isNotEmpty())
|
||||
<button
|
||||
type="button"
|
||||
data-action="carousel#prev"
|
||||
aria-label="Προηγούμενο προϊόν"
|
||||
class="absolute left-4 lg:left-8 top-1/2 -translate-y-1/2 hover:opacity-60 transition-opacity"
|
||||
>
|
||||
<x-ui.icon name="arrow-left" :size="56" />
|
||||
</button>
|
||||
|
||||
<div class="w-full ">
|
||||
@foreach($heroProducts as $product)
|
||||
<a
|
||||
href="{{ $product['href'] }}"
|
||||
data-carousel-target="slide"
|
||||
class="{{ $loop->first ? '' : 'hidden' }} block"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="border border-black bg-white mb-16 flex items-center justify-center overflow-hidden max-w-sm">
|
||||
@if($product['image'])
|
||||
<img
|
||||
src="{{ $product['image'] }}"
|
||||
alt="{{ $product['name'] }}"
|
||||
width="400"
|
||||
height="400"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
@else
|
||||
<span class="text-neutral-500 text-sm">Χωρίς εικόνα</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-4">
|
||||
<span class="font-display font-bold text-2xl">{{ $product['name'] }}</span>
|
||||
@if($product['price'] !== null)
|
||||
<span class="font-bold text-xl shrink-0">€{{ number_format($product['price'], 2) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-action="carousel#next"
|
||||
aria-label="Επόμενο προϊόν"
|
||||
class="absolute right-4 lg:right-8 top-1/2 -translate-y-1/2 hover:opacity-60 transition-opacity"
|
||||
>
|
||||
<x-ui.icon name="arrow-right" :size="56" />
|
||||
</button>
|
||||
@else
|
||||
<p class="text-neutral-500">Δεν βρέθηκαν προϊόντα.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Info / trivia --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 lg:divide-x lg:divide-black">
|
||||
<div class="bg-neutral-300 min-h-[320px] lg:min-h-full flex items-center justify-center text-neutral-500 text-sm">
|
||||
Χωρίς εικόνα
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-center gap-6 px-8 py-16 lg:px-16 text-center">
|
||||
<p class="max-w-md mx-auto text-neutral-600">
|
||||
Gaming φιγούρες, κρανία, κλειδοθήκες, dark humor και ό,τι πιο τρελό σου περνάει απ' το μυαλό — όλα σχεδιασμένα και τυπωμένα σε 3D, ένα προς ένα, εδώ στην Ελλάδα.
|
||||
</p>
|
||||
<h2 class="font-display font-extrabold text-h2 leading-tight">
|
||||
Στρώση-στρώση,<br>
|
||||
<span class="italic">0.1mm τη φορά</span>
|
||||
</h2>
|
||||
<p class="max-w-md mx-auto text-neutral-600">
|
||||
Τόσο λεπτή είναι κάθε στρώση υλικού σε ένα 3D εκτυπωμένο κομμάτι — πιο λεπτή κι από τρίχα. Δεν κρατάμε αποθέματα: κάθε παραγγελία τυπώνεται ειδικά για σένα, στο χρώμα και στο μέγεθος που θες.
|
||||
</p>
|
||||
<div>
|
||||
<x-ui.button size="md" :href="url('/'.app()->getLocale().'/products')">Δες τα προϊόντα</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Classics --}}
|
||||
<div class="border-b border-black" data-controller="carousel">
|
||||
<div class="flex items-center justify-between gap-4 px-8 py-8 border-b border-black">
|
||||
<h2 class="font-display font-extrabold text-h3">Τα αγαπημένα μας</h2>
|
||||
<x-ui.button size="sm" :href="url('/'.app()->getLocale().'/products')">Δες όλα</x-ui.button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 px-8 py-12">
|
||||
<button type="button" data-action="carousel#prev" aria-label="Προηγούμενα προϊόντα" class="shrink-0 hover:opacity-60 transition-opacity">
|
||||
<x-ui.icon name="arrow-left" :size="32" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1 overflow-hidden">
|
||||
@forelse($classicsProducts->chunk(4) as $page)
|
||||
<div data-carousel-target="slide" class="{{ $loop->first ? '' : 'hidden' }} grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
@foreach($page as $product)
|
||||
<x-ui.product-card
|
||||
:name="$product['name']"
|
||||
:price="$product['price']"
|
||||
:image="$product['image']"
|
||||
:href="$product['href']"
|
||||
/>
|
||||
@endforeach
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-neutral-500 text-center w-full">Δεν βρέθηκαν προϊόντα.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<button type="button" data-action="carousel#next" aria-label="Επόμενα προϊόντα" class="shrink-0 hover:opacity-60 transition-opacity">
|
||||
<x-ui.icon name="arrow-right" :size="32" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-20">
|
||||
<x-newsletter-split />
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="el">
|
||||
<html lang="{{ $currentLocale ?? app()->getLocale() }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -15,6 +15,13 @@
|
||||
<title>@yield('title', config('app.name'))</title>
|
||||
<meta name="description" content="@yield('description', '')">
|
||||
|
||||
{{-- Reciprocal hreflang: self-reference + the alternate locale + x-default --}}
|
||||
@isset($currentLocale)
|
||||
<link rel="alternate" hreflang="{{ $currentLocale }}" href="{{ url()->current() }}" />
|
||||
<link rel="alternate" hreflang="{{ $altLocale }}" href="{{ $altLocaleUrl }}" />
|
||||
<link rel="alternate" hreflang="x-default" href="{{ url('/') }}" />
|
||||
@endisset
|
||||
|
||||
@stack('seo')
|
||||
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@@ -24,7 +31,7 @@
|
||||
<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)]">
|
||||
<main id="main-content" class="min-h-[calc(100vh-12rem)] sm:min-h-[calc(100vh-8rem)]">
|
||||
@yield('content')
|
||||
</main>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@section('description', $product->translateAttribute('description'))
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto py-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-8 py-12">
|
||||
|
||||
@php $collection = $product->collections->first(); @endphp
|
||||
|
||||
@@ -182,9 +182,9 @@ class="absolute bottom-6 right-8 text-white text-sm"
|
||||
</div>
|
||||
|
||||
<x-ui.tabs class="mt-16" size="lg" :tabs="[
|
||||
['id' => 'description', 'label' => 'Περιγραφή'],
|
||||
// ['id' => 'reviews', 'label' => 'Αξιολογήσεις (' . count($reviews) . ')'],
|
||||
['id' => 'reviews', 'label' => 'Αξιολογήσεις (3)'],
|
||||
['id' => 'description', 'label' => __('general.product.description')],
|
||||
// ['id' => 'reviews', 'label' => __('general.product.reviews') . ' (' . count($reviews) . ')'],
|
||||
['id' => 'reviews', 'label' => __('general.product.reviews') . ' (3)'],
|
||||
]">
|
||||
<x-slot name="description">
|
||||
<div class="leading-7 [&_p]:mt-4">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@section('description', $product->translateAttribute('description'))
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto py-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-8 py-12">
|
||||
|
||||
@php $collection = $product->collections->first(); @endphp
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+17
-6
@@ -1,12 +1,23 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\HomeController;
|
||||
use App\Http\Controllers\ProductController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get("/", function () {
|
||||
return view("welcome");
|
||||
});
|
||||
// Bare root carries no language signal, so it just redirects to the default locale.
|
||||
Route::redirect('/', '/'.config('app.locale'));
|
||||
|
||||
Route::get("/products/{product}", [ProductController::class, "show"])->name(
|
||||
"product.show",
|
||||
);
|
||||
Route::prefix('{locale}')
|
||||
->where(['locale' => implode('|', config('app.available_locales'))])
|
||||
->middleware('setlocale')
|
||||
->group(function () {
|
||||
Route::get('/', [HomeController::class, 'index'])->name('home');
|
||||
|
||||
Route::get('/products/{product}', [ProductController::class, 'show'])->name(
|
||||
'product.show',
|
||||
);
|
||||
|
||||
Route::get('/__preview/newsletter-split', function () {
|
||||
return view('__preview_newsletter_split');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user