Update to v1.1 website code from kottgard-production-v1.1.zip
@@ -11,7 +11,8 @@
|
||||
"annotate": "node docs/annotate-all.mjs",
|
||||
"images:populate": "bash scripts/populate-site-images.sh",
|
||||
"images:fish": "bash scripts/update-fish-images.sh",
|
||||
"images:brand": "bash -c '.venv-images/bin/python scripts/process-all-site-images.py 2>/dev/null || python3 scripts/process-all-site-images.py'"
|
||||
"images:brand": "bash -c '.venv-images/bin/python scripts/process-all-site-images.py 2>/dev/null || python3 scripts/process-all-site-images.py'",
|
||||
"images:optimize": "bash -c '.venv-images/bin/python scripts/optimize-site-images.py 2>/dev/null || python3 scripts/optimize-site-images.py'"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.0",
|
||||
|
||||
|
After Width: | Height: | Size: 701 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 796 KiB |
|
After Width: | Height: | Size: 796 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 719 KiB |
|
After Width: | Height: | Size: 787 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 800 KiB |
|
After Width: | Height: | Size: 800 KiB |
|
After Width: | Height: | Size: 800 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 634 KiB |
|
After Width: | Height: | Size: 526 KiB |
|
After Width: | Height: | Size: 992 KiB |
|
After Width: | Height: | Size: 644 KiB |
|
After Width: | Height: | Size: 744 KiB |
|
After Width: | Height: | Size: 693 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 693 KiB |
|
Before Width: | Height: | Size: 701 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 386 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 554 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 554 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 796 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 796 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 386 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 719 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 787 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 375 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 387 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 387 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 800 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 800 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 634 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 526 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 992 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 644 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 744 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 693 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 394 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 375 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 375 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 693 KiB After Width: | Height: | Size: 99 KiB |
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compress site images to <= 100 KB while preserving original colors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SITE = ROOT / "public" / "images" / "site"
|
||||
BACKUP = SITE / "_originals"
|
||||
SKIP = {"logo.jpeg", "README.txt"}
|
||||
SKIP_PATTERNS = (" copy", "-1.jpg")
|
||||
|
||||
|
||||
def should_skip(path: Path) -> bool:
|
||||
if path.name in SKIP or path.name.startswith("_"):
|
||||
return True
|
||||
return any(p in path.name for p in SKIP_PATTERNS)
|
||||
|
||||
|
||||
def encode_jpeg(img: Image.Image, quality: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=quality, optimize=True, subsampling=2)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def compress_to_target(img: Image.Image, max_bytes: int) -> tuple[Image.Image, int]:
|
||||
working = img.convert("RGB")
|
||||
scale = 1.0
|
||||
|
||||
while scale >= 0.35:
|
||||
if scale < 1.0:
|
||||
w, h = working.size
|
||||
nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
|
||||
candidate = working.resize((nw, nh), Image.Resampling.LANCZOS)
|
||||
else:
|
||||
candidate = working
|
||||
|
||||
lo, hi = 20, 92
|
||||
best_q = lo
|
||||
best_data = encode_jpeg(candidate, lo)
|
||||
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
data = encode_jpeg(candidate, mid)
|
||||
if len(data) <= max_bytes:
|
||||
best_q = mid
|
||||
best_data = data
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
|
||||
if len(best_data) <= max_bytes:
|
||||
return Image.open(io.BytesIO(best_data)).convert("RGB"), best_q
|
||||
|
||||
scale *= 0.88
|
||||
|
||||
data = encode_jpeg(candidate, 20)
|
||||
return Image.open(io.BytesIO(data)).convert("RGB"), 20
|
||||
|
||||
|
||||
def optimize_file(path: Path, max_bytes: int, dry_run: bool) -> tuple[int, int]:
|
||||
before = path.stat().st_size
|
||||
if before <= max_bytes:
|
||||
return before, before
|
||||
|
||||
with Image.open(path) as img:
|
||||
out, quality = compress_to_target(img, max_bytes)
|
||||
|
||||
if dry_run:
|
||||
buf = io.BytesIO()
|
||||
out.save(buf, format="JPEG", quality=quality, optimize=True, subsampling=2)
|
||||
return before, len(buf.getvalue())
|
||||
|
||||
out.save(path, format="JPEG", quality=quality, optimize=True, subsampling=2)
|
||||
return before, path.stat().st_size
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Optimize site images to <= 100 KB")
|
||||
parser.add_argument("--max-kb", type=int, default=100)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
max_bytes = args.max_kb * 1024
|
||||
BACKUP.mkdir(exist_ok=True)
|
||||
|
||||
files = sorted(SITE.glob("*.jpg")) + sorted(SITE.glob("*.jpeg"))
|
||||
changed = 0
|
||||
|
||||
for path in files:
|
||||
if should_skip(path):
|
||||
continue
|
||||
|
||||
backup = BACKUP / path.name
|
||||
if not backup.exists() and not args.dry_run:
|
||||
shutil.copy2(path, backup)
|
||||
|
||||
before, after = optimize_file(path, max_bytes, args.dry_run)
|
||||
if after < before or before > max_bytes:
|
||||
changed += 1
|
||||
print(
|
||||
f"{'~' if args.dry_run else '✓'} {path.name}: "
|
||||
f"{before // 1024} KB → {after // 1024} KB"
|
||||
)
|
||||
else:
|
||||
print(f"○ {path.name}: {before // 1024} KB (ok)")
|
||||
|
||||
print(f"\nDone — {changed} optimized, originals in {BACKUP}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import AboutIntro from '@/components/about/AboutIntro';
|
||||
import { ShieldCheck, Leaf, Award, Truck, Phone, Mail, MapPin } from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import {
|
||||
@@ -25,6 +26,8 @@ export default function AboutPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AboutIntro />
|
||||
|
||||
<div className="mx-auto max-w-4xl px-4 py-16 sm:px-6 lg:px-8">
|
||||
<section className="mb-16">
|
||||
<h2 className="section-heading mb-4">{t('about.ourStory')}</h2>
|
||||
|
||||
@@ -63,4 +63,13 @@
|
||||
.gold-accent {
|
||||
@apply bg-gradient-to-r from-gold-400 to-gold-500 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import AppImage from '@/components/ui/AppImage';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { PAGE_IMAGES } from '@/infrastructure/images';
|
||||
|
||||
export default function AboutPreview() {
|
||||
export default function AboutIntro() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const stats = [
|
||||
@@ -17,7 +15,7 @@ export default function AboutPreview() {
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-white py-20">
|
||||
<section className="border-b border-cream-300/60 bg-white py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div>
|
||||
@@ -29,7 +27,7 @@ export default function AboutPreview() {
|
||||
<p className="mb-4 leading-relaxed text-gray-600">{t('aboutPreview.p2')}</p>
|
||||
<p className="mb-8 leading-relaxed text-gray-600">{t('aboutPreview.p3')}</p>
|
||||
|
||||
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
@@ -40,11 +38,6 @@ export default function AboutPreview() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button href="/about" variant="secondary">
|
||||
{t('aboutPreview.readMore')}
|
||||
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl shadow-premium-lg">
|
||||
@@ -9,9 +9,9 @@ export default function CTA() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<section className="py-20">
|
||||
<section className="bg-cream-50 py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-brand-700 to-brand-900 px-8 py-16 text-center sm:px-16">
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-brand-700 via-brand-800 to-brand-900 px-8 py-14 text-center shadow-premium-lg sm:px-16 sm:py-16">
|
||||
<div className="pointer-events-none absolute -right-20 -top-20 h-60 w-60 rounded-full bg-gold-500/10" />
|
||||
<div className="pointer-events-none absolute -bottom-16 -left-16 h-48 w-48 rounded-full bg-gold-500/10" />
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Phone, MapPin, Clock, MessageCircle, ExternalLink } from 'lucide-react';
|
||||
import { Phone, MapPin, Clock, MessageCircle, Facebook, Instagram } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Logo from '@/components/ui/Logo';
|
||||
import {
|
||||
SITE_ADDRESS,
|
||||
SITE_PHONE_DISPLAY,
|
||||
SITE_HOURS,
|
||||
WHATSAPP_URL,
|
||||
MAPS_URL,
|
||||
FACEBOOK_URL,
|
||||
INSTAGRAM_URL,
|
||||
} from '@/lib/constants';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
@@ -17,14 +17,16 @@ export default function ContactPreview() {
|
||||
const telHref = `tel:+46725855050`;
|
||||
|
||||
return (
|
||||
<section className="bg-brand-950 py-20 text-white">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div>
|
||||
<section className="relative overflow-hidden rounded-t-[2rem] bg-brand-950 pb-16 pt-14 text-white sm:rounded-t-[2.5rem] sm:pb-20 sm:pt-16">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-transparent via-gold-500/60 to-transparent" />
|
||||
<div className="pointer-events-none absolute -right-24 top-20 h-64 w-64 rounded-full bg-gold-500/5 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -left-24 bottom-10 h-48 w-48 rounded-full bg-brand-700/30 blur-3xl" />
|
||||
|
||||
<div className="relative mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
|
||||
<span className="mb-2 inline-block text-sm font-semibold uppercase tracking-wider text-gold-400">
|
||||
{t('contact.label')}
|
||||
</span>
|
||||
<h2 className="mb-8 font-display text-3xl font-bold md:text-4xl">
|
||||
<h2 className="mb-6 font-display text-3xl font-bold sm:mb-8 md:text-4xl">
|
||||
{t('contact.title')}
|
||||
</h2>
|
||||
|
||||
@@ -62,7 +64,7 @@ export default function ContactPreview() {
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<Button
|
||||
href="/about#contact"
|
||||
variant="secondary"
|
||||
@@ -85,25 +87,28 @@ export default function ContactPreview() {
|
||||
{t('contact.callUs')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="mt-8 border-t border-white/10 pt-6">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gold-400">
|
||||
{t('social.label')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{[
|
||||
{ href: FACEBOOK_URL, icon: Facebook, label: t('social.facebook') },
|
||||
{ href: INSTAGRAM_URL, icon: Instagram, label: t('social.instagram') },
|
||||
{ href: WHATSAPP_URL, icon: MessageCircle, label: t('social.whatsapp') },
|
||||
].map(({ href, icon: Icon, label }) => (
|
||||
<a
|
||||
href={MAPS_URL}
|
||||
key={label}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="card-premium group flex flex-col items-center gap-4 bg-brand-900/50 p-8 text-center transition-all hover:bg-brand-900/70"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-medium text-brand-100 transition-colors hover:border-gold-500/40 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Logo size="lg" className="ring-gold-400/30" />
|
||||
<div>
|
||||
<p className="font-display text-xl font-bold">{t('site.name')}</p>
|
||||
<p className="mt-1 text-sm text-brand-200">{SITE_ADDRESS}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-semibold text-gold-400 transition-colors group-hover:text-gold-300">
|
||||
{t('contact.openMaps')}
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</span>
|
||||
<Icon className="h-4 w-4 text-gold-400" />
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import ProductCard from '@/components/product/ProductCard';
|
||||
import { getFeaturedProducts } from '@/lib/products';
|
||||
import { products } from '@/lib/products';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { ArrowRight, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { localizeProduct } from '@/lib/product-i18n';
|
||||
import { isRtl } from '@/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const AUTO_SCROLL_MS = 4500;
|
||||
|
||||
export default function FeaturedProducts() {
|
||||
const { t } = useTranslation();
|
||||
const featured = getFeaturedProducts()
|
||||
.slice(0, 4)
|
||||
.map((p) => localizeProduct(p, t));
|
||||
const { t, locale } = useTranslation();
|
||||
const rtl = isRtl(locale);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const allProducts = products.map((p) => localizeProduct(p, t));
|
||||
|
||||
const getScrollStep = useCallback(() => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return 320;
|
||||
const card = track.querySelector<HTMLElement>('[data-slide]');
|
||||
if (!card) return 320;
|
||||
const gap = 24;
|
||||
return card.offsetWidth + gap;
|
||||
}, []);
|
||||
|
||||
const scroll = useCallback(
|
||||
(direction: 'next' | 'prev') => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
|
||||
const step = getScrollStep();
|
||||
const forward = direction === 'next';
|
||||
const delta = forward ? step : -step;
|
||||
const scrollDelta = rtl ? -delta : delta;
|
||||
|
||||
const maxScroll = track.scrollWidth - track.clientWidth;
|
||||
const atEnd = track.scrollLeft >= maxScroll - 8;
|
||||
const atStart = track.scrollLeft <= 8;
|
||||
|
||||
if (forward && atEnd) {
|
||||
track.scrollTo({ left: rtl ? maxScroll : 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
if (!forward && atStart) {
|
||||
track.scrollTo({ left: rtl ? 0 : maxScroll, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
track.scrollBy({ left: scrollDelta, behavior: 'smooth' });
|
||||
},
|
||||
[getScrollStep, rtl]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const id = window.setInterval(() => scroll('next'), AUTO_SCROLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [paused, scroll]);
|
||||
|
||||
return (
|
||||
<section className="py-20">
|
||||
<section className="overflow-hidden bg-white py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-12 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-end">
|
||||
<div className="mb-10 flex flex-col items-start justify-between gap-4 sm:mb-12 sm:flex-row sm:items-end">
|
||||
<div>
|
||||
<span className="mb-2 inline-block text-sm font-semibold uppercase tracking-wider text-gold-600">
|
||||
{t('featured.label')}
|
||||
@@ -30,11 +80,50 @@ export default function FeaturedProducts() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{featured.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onFocus={() => setPaused(true)}
|
||||
onBlur={() => setPaused(false)}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-y-0 start-0 z-10 w-8 bg-gradient-to-r from-white to-transparent sm:w-16" />
|
||||
<div className="pointer-events-none absolute inset-y-0 end-0 z-10 w-8 bg-gradient-to-l from-white to-transparent sm:w-16" />
|
||||
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="scrollbar-hide flex snap-x snap-mandatory gap-6 overflow-x-auto scroll-smooth pb-2"
|
||||
>
|
||||
{allProducts.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
data-slide
|
||||
className="w-[min(85vw,280px)] shrink-0 snap-start sm:w-[300px]"
|
||||
>
|
||||
<ProductCard product={product} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll('prev')}
|
||||
aria-label={t('featured.title')}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-cream-300 bg-white text-brand-800 shadow-premium transition-colors hover:border-brand-300 hover:bg-brand-50"
|
||||
>
|
||||
<ChevronLeft className={cn('h-5 w-5', rtl && 'rotate-180')} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll('next')}
|
||||
aria-label={t('featured.title')}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-cream-300 bg-white text-brand-800 shadow-premium transition-colors hover:border-brand-300 hover:bg-brand-50"
|
||||
>
|
||||
<ChevronRight className={cn('h-5 w-5', rtl && 'rotate-180')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import AppImage from '@/components/ui/AppImage';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Logo from '@/components/ui/Logo';
|
||||
import TrustBadges from '@/components/home/TrustBadges';
|
||||
import { ArrowRight, ShieldCheck, MapPin, Clock } from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { PAGE_IMAGES } from '@/infrastructure/images';
|
||||
@@ -28,8 +29,8 @@ export default function Hero() {
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand-950/95 via-brand-900/85 to-brand-900/40" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-brand-950/60 via-transparent to-transparent" />
|
||||
|
||||
<div className="relative z-10 mx-auto flex min-h-[85vh] max-w-7xl flex-col justify-center px-4 py-20 sm:px-6 lg:px-8 lg:py-28">
|
||||
<div className="max-w-2xl animate-slide-up">
|
||||
<div className="relative z-10 mx-auto grid min-h-[85vh] max-w-7xl items-center gap-10 px-4 py-20 sm:px-6 lg:grid-cols-2 lg:gap-12 lg:px-8 lg:py-28">
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-8 flex items-center gap-4">
|
||||
<Logo size="lg" className="ring-gold-400/40 shadow-premium-lg" />
|
||||
<div>
|
||||
@@ -57,6 +58,10 @@ export default function Hero() {
|
||||
{t('hero.subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="mb-8 lg:hidden">
|
||||
<TrustBadges />
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex flex-wrap gap-4 text-sm text-brand-200">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-4 w-4 text-gold-400" />
|
||||
@@ -84,6 +89,10 @@ export default function Hero() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:flex lg:items-center lg:justify-center">
|
||||
<TrustBadges />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-[1] h-20 bg-gradient-to-t from-cream-50 to-transparent" />
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import Hero from '@/components/home/Hero';
|
||||
import TrustBadges from '@/components/home/TrustBadges';
|
||||
import AboutPreview from '@/components/home/AboutPreview';
|
||||
import CategoryGrid from '@/components/home/CategoryGrid';
|
||||
import FeaturedProducts from '@/components/home/FeaturedProducts';
|
||||
import HowItWorks from '@/components/home/HowItWorks';
|
||||
import WeeklyOffers from '@/components/home/WeeklyOffers';
|
||||
import SocialFollow from '@/components/home/SocialFollow';
|
||||
import ContactPreview from '@/components/home/ContactPreview';
|
||||
import CTA from '@/components/home/CTA';
|
||||
|
||||
export default function HomeView() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<TrustBadges />
|
||||
<AboutPreview />
|
||||
<CategoryGrid />
|
||||
<FeaturedProducts />
|
||||
<HowItWorks />
|
||||
<WeeklyOffers />
|
||||
<CTA />
|
||||
<SocialFollow />
|
||||
<ContactPreview />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ShieldCheck, Leaf, Award } from 'lucide-react';
|
||||
import { ShieldCheck, Leaf, Award, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { isRtl } from '@/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BadgeItem {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
function BadgeCard({ badge }: { badge: BadgeItem }) {
|
||||
const Icon = badge.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={badge.href}
|
||||
className="group flex w-[min(300px,80vw)] shrink-0 items-center gap-4 rounded-xl border border-white/10 bg-brand-900/55 px-5 py-4 shadow-premium backdrop-blur-sm transition-colors hover:border-gold-500/35 hover:bg-brand-800/65"
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-brand-800/80 ring-1 ring-gold-500/25 transition-colors group-hover:bg-gold-500/15">
|
||||
<Icon className="h-6 w-6 text-gold-400" />
|
||||
</div>
|
||||
<div className="min-w-0 text-start">
|
||||
<h3 className="font-display text-sm font-bold text-white sm:text-base">
|
||||
{badge.title}
|
||||
</h3>
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-brand-200">
|
||||
{badge.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrustBadges() {
|
||||
const { t } = useTranslation();
|
||||
const { t, locale } = useTranslation();
|
||||
const rtl = isRtl(locale);
|
||||
|
||||
const badges = [
|
||||
const badges: BadgeItem[] = [
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: t('trust.halal'),
|
||||
@@ -28,27 +61,25 @@ export default function TrustBadges() {
|
||||
},
|
||||
];
|
||||
|
||||
const loop = [...badges, ...badges];
|
||||
|
||||
return (
|
||||
<section className="bg-cream-100 py-16">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
{badges.map((badge) => (
|
||||
<Link
|
||||
key={badge.title}
|
||||
href={badge.href}
|
||||
className="group rounded-2xl border border-cream-300/80 bg-white p-8 text-center shadow-premium transition-all duration-300 hover:border-brand-200 hover:shadow-premium-lg"
|
||||
<div className="relative z-10 w-full">
|
||||
<div className="group/trust relative overflow-hidden rounded-2xl border border-white/5 bg-brand-950/20 py-3 backdrop-blur-[2px]">
|
||||
<div className="pointer-events-none absolute inset-y-0 start-0 z-10 w-10 bg-gradient-to-r from-brand-900/90 to-transparent sm:w-16" />
|
||||
<div className="pointer-events-none absolute inset-y-0 end-0 z-10 w-10 bg-gradient-to-l from-brand-900/90 to-transparent sm:w-16" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-max gap-4 motion-reduce:animate-none group-hover/trust:[animation-play-state:paused]',
|
||||
rtl ? 'animate-trust-marquee-rtl' : 'animate-trust-marquee'
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand-50 transition-colors group-hover:bg-gold-50">
|
||||
<badge.icon className="h-7 w-7 text-brand-700 transition-colors group-hover:text-gold-600" />
|
||||
</div>
|
||||
<h3 className="mb-2 font-display text-xl font-bold text-brand-900">
|
||||
{badge.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-500">{badge.description}</p>
|
||||
</Link>
|
||||
{loop.map((badge, i) => (
|
||||
<BadgeCard key={`${badge.title}-${i}`} badge={badge} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,9 +14,9 @@ export default function WeeklyOffers() {
|
||||
const fmt = (n: number) => formatPrice(n, getFormatLocale(locale));
|
||||
|
||||
return (
|
||||
<section className="bg-cream-100 py-20">
|
||||
<section className="bg-cream-100 pb-16 pt-16 sm:pb-20 sm:pt-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
|
||||
<div>
|
||||
<span className="mb-2 inline-block text-sm font-semibold uppercase tracking-wider text-gold-600">
|
||||
{t('offers.label')}
|
||||
@@ -112,7 +112,7 @@ export default function WeeklyOffers() {
|
||||
|
||||
<Link
|
||||
href="/shop"
|
||||
className="relative hidden aspect-[4/5] overflow-hidden rounded-2xl shadow-premium-lg lg:block"
|
||||
className="relative aspect-[16/10] overflow-hidden rounded-2xl shadow-premium-lg sm:aspect-[4/5] md:block lg:aspect-[4/5]"
|
||||
>
|
||||
<AppImage
|
||||
src={PAGE_IMAGES.weeklyOffersBanner}
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Category } from '@/domain/entities';
|
||||
export const IMAGE_FOLDER = '/images/site';
|
||||
|
||||
/** Bump when site images are re-processed (cache bust) */
|
||||
export const SITE_IMAGE_VERSION = '11';
|
||||
export const SITE_IMAGE_VERSION = '14';
|
||||
|
||||
/** Build path to a file in the site image folder */
|
||||
export function siteImage(filename: string): string {
|
||||
|
||||
@@ -56,6 +56,8 @@ const config: Config = {
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-out',
|
||||
'slide-up': 'slideUp 0.5s ease-out',
|
||||
'trust-marquee': 'trustMarquee 28s linear infinite',
|
||||
'trust-marquee-rtl': 'trustMarqueeRtl 28s linear infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
@@ -66,6 +68,14 @@ const config: Config = {
|
||||
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
trustMarquee: {
|
||||
'0%': { transform: 'translateX(0)' },
|
||||
'100%': { transform: 'translateX(-50%)' },
|
||||
},
|
||||
trustMarqueeRtl: {
|
||||
'0%': { transform: 'translateX(0)' },
|
||||
'100%': { transform: 'translateX(50%)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||