Update to v1.1 website code from kottgard-production-v1.1.zip

This commit is contained in:
root
2026-06-24 09:16:59 +00:00
parent f1f53fe4df
commit 74b7b44e43
75 changed files with 406 additions and 148 deletions
+2 -1
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 796 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 796 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 719 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 787 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 800 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 800 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 634 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 992 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 744 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 693 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 693 KiB

After

Width:  |  Height:  |  Size: 99 KiB

+118
View File
@@ -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()
+3
View File
@@ -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>
+9
View File
@@ -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">
+2 -2
View File
@@ -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" />
+91 -86
View File
@@ -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,93 +17,98 @@ 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>
<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">
{t('contact.title')}
</h2>
<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" />
<ul className="mb-8 space-y-5">
<li className="flex gap-4">
<MapPin className="mt-0.5 h-5 w-5 shrink-0 text-gold-400" />
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gold-400">
{t('contact.addressLabel')}
</p>
<p className="text-brand-100">{SITE_ADDRESS}</p>
</div>
</li>
<li className="flex gap-4">
<Phone className="mt-0.5 h-5 w-5 shrink-0 text-gold-400" />
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gold-400">
{t('contact.phoneLabel')}
</p>
<a href={telHref} className="text-brand-100 hover:text-white">
{SITE_PHONE_DISPLAY}
</a>
</div>
</li>
<li className="flex gap-4">
<Clock className="mt-0.5 h-5 w-5 shrink-0 text-gold-400" />
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gold-400">
{t('contact.hoursLabel')}
</p>
<p className="text-brand-100">
{t('contact.hoursValue', { hours: SITE_HOURS })}
</p>
</div>
</li>
</ul>
<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-6 font-display text-3xl font-bold sm:mb-8 md:text-4xl">
{t('contact.title')}
</h2>
<div className="flex flex-col gap-3 sm:flex-row">
<Button
href="/about#contact"
variant="secondary"
size="lg"
className="w-full border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15 sm:w-auto"
>
{t('contact.learnMore')}
</Button>
<Button href={WHATSAPP_URL} external variant="gold" size="lg" className="w-full sm:w-auto">
<MessageCircle className="h-4 w-4" />
{t('contact.writeUs')}
</Button>
<Button
href={telHref}
variant="secondary"
size="lg"
className="w-full border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15 sm:w-auto"
>
<Phone className="h-4 w-4" />
{t('contact.callUs')}
</Button>
<ul className="mb-8 space-y-5">
<li className="flex gap-4">
<MapPin className="mt-0.5 h-5 w-5 shrink-0 text-gold-400" />
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gold-400">
{t('contact.addressLabel')}
</p>
<p className="text-brand-100">{SITE_ADDRESS}</p>
</div>
</div>
</li>
<li className="flex gap-4">
<Phone className="mt-0.5 h-5 w-5 shrink-0 text-gold-400" />
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gold-400">
{t('contact.phoneLabel')}
</p>
<a href={telHref} className="text-brand-100 hover:text-white">
{SITE_PHONE_DISPLAY}
</a>
</div>
</li>
<li className="flex gap-4">
<Clock className="mt-0.5 h-5 w-5 shrink-0 text-gold-400" />
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gold-400">
{t('contact.hoursLabel')}
</p>
<p className="text-brand-100">
{t('contact.hoursValue', { hours: SITE_HOURS })}
</p>
</div>
</li>
</ul>
<div className="flex flex-col justify-center">
<a
href={MAPS_URL}
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"
>
<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>
</a>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<Button
href="/about#contact"
variant="secondary"
size="lg"
className="w-full border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15 sm:w-auto"
>
{t('contact.learnMore')}
</Button>
<Button href={WHATSAPP_URL} external variant="gold" size="lg" className="w-full sm:w-auto">
<MessageCircle className="h-4 w-4" />
{t('contact.writeUs')}
</Button>
<Button
href={telHref}
variant="secondary"
size="lg"
className="w-full border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15 sm:w-auto"
>
<Phone className="h-4 w-4" />
{t('contact.callUs')}
</Button>
</div>
<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
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
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"
>
<Icon className="h-4 w-4 text-gold-400" />
{label}
</a>
))}
</div>
</div>
</div>
+101 -12
View File
@@ -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,10 +80,49 @@ 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>
+11 -2
View File
@@ -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" />
-10
View File
@@ -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 />
</>
);
}
+52 -21
View File
@@ -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="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>
<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'
)}
>
{loop.map((badge, i) => (
<BadgeCard key={`${badge.title}-${i}`} badge={badge} />
))}
</div>
</div>
</section>
</div>
);
}
+3 -3
View File
@@ -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}
+1 -1
View File
@@ -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 {
+10
View File
@@ -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%)' },
},
},
},
},