Update to v1.0 of website code from kottgard-production-v1.zip

This commit is contained in:
root
2026-06-23 09:24:46 +00:00
parent e0273c46d7
commit 582a2f33ad
60 changed files with 658 additions and 160 deletions
+11
View File
@@ -1,5 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Bundle zustand reliably — avoids missing ./vendor-chunks/zustand.js in dev
transpilePackages: ['zustand'],
async redirects() {
return [
{
source: '/index',
destination: '/',
permanent: true,
},
];
},
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 2560],
+5 -3
View File
@@ -3,13 +3,15 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"clean": "rm -rf .next",
"dev": "bash scripts/dev.sh",
"build": "bash scripts/build.sh",
"start": "next start",
"lint": "next lint",
"annotate": "node docs/annotate-all.mjs",
"images:populate": "bash scripts/populate-site-images.sh",
"images:fish": "bash scripts/update-fish-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'"
},
"dependencies": {
"clsx": "^2.1.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 508 KiB

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 KiB

After

Width:  |  Height:  |  Size: 992 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 834 KiB

After

Width:  |  Height:  |  Size: 693 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 834 KiB

After

Width:  |  Height:  |  Size: 693 KiB

+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Prepare a sharp, full-frame beef boneless product photo for the shop."""
from __future__ import annotations
import argparse
import urllib.request
from pathlib import Path
from PIL import Image, ImageEnhance, ImageFilter, ImageOps, ImageStat
ROOT = Path(__file__).resolve().parents[1]
SITE = ROOT / "public" / "images" / "site"
OUT_PATH = SITE / "beef-boneless.jpg"
SOURCE_CACHE = SITE / "_beef_boneless_src.jpg"
# High-res premium raw boneless beef (Unsplash)
PRIMARY_URL = (
"https://images.unsplash.com/photo-1603048297172-c92544798d5a"
"?auto=format&fit=crop&w=1800&h=1800&crop=center&q=95"
)
# Original product reference (lower res fallback)
FALLBACK_URL = (
"https://www.hotcurrymarket.fi/wp-content/uploads/2025/01/"
"FRESH-BEEF-BONELESS-WITHOUT-FAT-1KG.jpg"
)
OUTPUT_SIZE = (1400, 1400)
def download(url: str, dest: Path) -> None:
req = urllib.request.Request(
url,
headers={"User-Agent": "Mozilla/5.0 (compatible; Kottgard-site-builder/1.0)"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
dest.write_bytes(resp.read())
def trim_near_white(img: Image.Image, tolerance: int = 18) -> Image.Image:
"""Remove excess light borders from the source photo."""
rgb = img.convert("RGB")
pixels = rgb.load()
w, h = rgb.size
def row_is_border(y: int) -> bool:
samples = [pixels[x, y] for x in range(0, w, max(1, w // 24))]
return all(min(p) >= 255 - tolerance for p in samples)
def col_is_border(x: int) -> bool:
samples = [pixels[x, y] for y in range(0, h, max(1, h // 24))]
return all(min(p) >= 255 - tolerance for p in samples)
top = 0
while top < h and row_is_border(top):
top += 1
bottom = h - 1
while bottom > top and row_is_border(bottom):
bottom -= 1
left = 0
while left < w and col_is_border(left):
left += 1
right = w - 1
while right > left and col_is_border(right):
right -= 1
if right - left > 80 and bottom - top > 80:
return rgb.crop((left, top, right + 1, bottom + 1))
return rgb
def enhance_photo(img: Image.Image) -> Image.Image:
img = ImageOps.autocontrast(img, cutoff=0.5)
img = ImageEnhance.Brightness(img).enhance(1.03)
img = ImageEnhance.Contrast(img).enhance(1.08)
img = ImageEnhance.Color(img).enhance(1.1)
img = ImageEnhance.Sharpness(img).enhance(1.35)
return img.filter(ImageFilter.UnsharpMask(radius=1.0, percent=110, threshold=2))
def crop_center_cover(img: Image.Image, size: tuple[int, int]) -> Image.Image:
tw, th = size
w, h = img.size
scale = max(tw / w, th / h)
nw, nh = int(w * scale), int(h * scale)
resized = img.resize((nw, nh), Image.Resampling.LANCZOS)
left = (nw - tw) // 2
top = (nh - th) // 2
return resized.crop((left, top, left + tw, top + th))
def prepare_product_image(source: Path, output: Path = OUT_PATH) -> None:
beef = Image.open(source).convert("RGB")
beef = trim_near_white(beef)
beef = enhance_photo(beef)
# Full-frame crop — meat fills the product image (no tiny subject in white void)
beef = crop_center_cover(beef, OUTPUT_SIZE)
beef.save(output, "JPEG", quality=96, optimize=True, subsampling=0)
print(f"Saved product photo {OUTPUT_SIZE[0]}x{OUTPUT_SIZE[1]}{output}")
def main() -> None:
parser = argparse.ArgumentParser(description="Prepare beef boneless product photo")
parser.add_argument("--url", default=PRIMARY_URL)
parser.add_argument("--input", type=Path)
parser.add_argument("--output", type=Path, default=OUT_PATH)
parser.add_argument("--use-cache", action="store_true")
parser.add_argument("--fallback", action="store_true", help="Use hot curry source")
args = parser.parse_args()
if args.input:
src = args.input
elif args.use_cache and SOURCE_CACHE.exists():
src = SOURCE_CACHE
print(f"Using cached source → {src}")
else:
url = FALLBACK_URL if args.fallback else args.url
print(f"Downloading source image…\n {url}")
try:
download(url, SOURCE_CACHE)
except Exception as exc:
print(f"Primary download failed ({exc}), trying fallback…")
download(FALLBACK_URL, SOURCE_CACHE)
src = SOURCE_CACHE
prepare_product_image(src, args.output)
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Production build must not reuse a dev .next folder.
set -euo pipefail
cd "$(dirname "$0")/.."
pkill -f "next dev" 2>/dev/null || true
rm -rf .next
echo "Building production bundle (clean cache)…"
exec npx next build
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Always start dev with a fresh .next cache — prevents missing vendor-chunks (zustand.js).
set -euo pipefail
cd "$(dirname "$0")/.."
pkill -f "next dev" 2>/dev/null || true
if lsof -ti:3000 >/dev/null 2>&1; then
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
sleep 1
fi
rm -rf .next
echo "Starting Next.js dev server (clean cache)…"
exec npx next dev
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""Normalize site images: square canvas, HALAL badge + Kött Gård logo. Colors preserved."""
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
SITE = ROOT / "public" / "images" / "site"
BACKUP = SITE / "_originals"
LOGO_PATH = SITE / "logo.jpeg"
BRAND_NAME = "Kött Gård"
SIZE = (1400, 1400)
BG = (255, 255, 255)
BURGUNDY = (139, 31, 31)
GOLD = (212, 175, 55)
SKIP = {"logo.jpeg", "README.txt"}
SKIP_PATTERNS = (" copy", "-1.jpg") # duplicate uploads from user
FILL_PRODUCT = 0.9
FILL_PAGE = 0.94
PAGE_IMAGES = {"hero.jpg", "about.jpg", "weekly-offers.jpg"}
CATEGORY_IMAGES = {
"category-chicken.jpg",
"category-beef.jpg",
"category-lamb.jpg",
"category-fish.jpg",
}
def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
paths = [
"/System/Library/Fonts/Supplemental/Arial Bold.ttf"
if bold
else "/System/Library/Fonts/Supplemental/Arial.ttf",
"/Library/Fonts/Arial.ttf",
]
for p in paths:
try:
return ImageFont.truetype(p, size)
except OSError:
continue
return ImageFont.load_default()
def preserve_colors(img: Image.Image) -> Image.Image:
"""Keep original photo pixels — no contrast, brightness, or color tweaks."""
return img.convert("RGB")
def prep_logo(diameter: int) -> Image.Image:
logo = Image.open(LOGO_PATH).convert("RGBA")
w, h = logo.size
side = min(w, h)
logo = logo.crop(((w - side) // 2, (h - side) // 2, (w + side) // 2, (h + side) // 2))
logo = logo.resize((diameter, diameter), Image.Resampling.LANCZOS)
mask = Image.new("L", (diameter, diameter), 0)
ImageDraw.Draw(mask).ellipse((0, 0, diameter - 1, diameter - 1), fill=255)
logo.putalpha(mask)
return logo
def draw_halal_badge(canvas: Image.Image, x: int, y: int, size: int = 130) -> None:
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
draw.ellipse((x + 4, y + 5, x + size + 4, y + size + 5), fill=(0, 0, 0, 100))
draw.ellipse((x - 2, y - 2, x + size + 2, y + size + 2), fill=(*GOLD, 255))
draw.ellipse((x + 6, y + 6, x + size - 6, y + size - 6), fill=(255, 255, 255, 255))
draw.ellipse((x + 10, y + 10, x + size - 10, y + size - 10), outline=(*BURGUNDY, 255), width=4)
f_ar = load_font(24, bold=True)
f_halal = load_font(28, bold=True)
f_sub = load_font(11, bold=True)
ar = draw.textbbox((0, 0), "حلال", font=f_ar)
draw.text((x + (size - ar[2] + ar[0]) // 2, y + 22), "حلال", fill=(*BURGUNDY, 255), font=f_ar)
hl = draw.textbbox((0, 0), "HALAL", font=f_halal)
draw.text((x + (size - hl[2] + hl[0]) // 2, y + 52), "HALAL", fill=(*BURGUNDY, 255), font=f_halal)
ce = draw.textbbox((0, 0), "CERTIFIED", font=f_sub)
draw.text((x + (size - ce[2] + ce[0]) // 2, y + 92), "CERTIFIED", fill=(*GOLD, 255), font=f_sub)
canvas.alpha_composite(layer)
def draw_logo_badge(canvas: Image.Image, logo: Image.Image, x: int, y: int) -> None:
d = logo.size[0]
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
draw.ellipse((x + 3, y + 4, x + d + 3, y + d + 4), fill=(0, 0, 0, 90))
draw.ellipse((x - 4, y - 4, x + d + 4, y + d + 4), fill=(255, 255, 255, 245), outline=(*GOLD, 255), width=3)
canvas.alpha_composite(layer)
canvas.alpha_composite(logo, (x, y))
def draw_brand_name(canvas: Image.Image, x: int, y: int) -> None:
"""Brand name pill beside the logo badge."""
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
font = load_font(22, bold=True)
bbox = draw.textbbox((0, 0), BRAND_NAME, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
pad_x, pad_y = 14, 8
box = (x - tw - pad_x * 2, y, x - 8, y + th + pad_y * 2)
draw.rounded_rectangle(box, radius=10, fill=(255, 255, 255, 235), outline=(*GOLD, 255), width=2)
draw.text((box[0] + pad_x, box[1] + pad_y - 2), BRAND_NAME, fill=(*BURGUNDY, 255), font=font)
canvas.alpha_composite(layer)
def already_branded(path: Path) -> bool:
"""Skip images already at catalog size (user uploads with logos baked in)."""
with Image.open(path) as img:
return img.size == SIZE
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 compose(source: Path, dest: Path, logo: Image.Image) -> None:
photo = preserve_colors(Image.open(source))
fill = FILL_PAGE if dest.name in PAGE_IMAGES else FILL_PRODUCT
if dest.name in CATEGORY_IMAGES:
fill = 0.92
canvas = Image.new("RGBA", SIZE, (*BG, 255))
max_side = int(SIZE[0] * fill)
photo.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
shadow = Image.new("RGBA", photo.size, (0, 0, 0, 0))
sh_draw = ImageDraw.Draw(shadow)
sh_draw.rounded_rectangle(
(8, 12, photo.width - 8, photo.height - 4),
radius=18,
fill=(0, 0, 0, 35),
)
px = (SIZE[0] - photo.width) // 2
py = (SIZE[1] - photo.height) // 2 - 16
canvas.alpha_composite(shadow, (px, py + 6))
canvas.alpha_composite(photo.convert("RGBA"), (px, py))
margin = 28
draw_halal_badge(canvas, margin, margin)
logo_size = 108
logo_x = SIZE[0] - logo_size - margin
logo_y = SIZE[1] - logo_size - margin
draw_logo_badge(canvas, logo, logo_x, logo_y)
draw_brand_name(canvas, logo_x, logo_y + logo_size // 2 - 12)
canvas.convert("RGB").save(dest, "JPEG", quality=94, optimize=True, subsampling=0)
def ensure_catalog_files() -> None:
pairs = [
("beef-steak.jpg", "beef-steak-2.jpg"),
("beef-nihari-2.jpg", "beef-nihari.jpg"),
("category-lamb.jpg", "lamb-shoulder.jpg"),
]
for target, source in pairs:
t, s = SITE / target, SITE / source
if not t.exists() and s.exists():
shutil.copy2(s, t)
print(f"Created missing {target} from {source}")
def main() -> None:
parser = argparse.ArgumentParser(description="Brand site images (colors preserved)")
parser.add_argument("--force", action="store_true", help="Re-process even if already 1400×1400")
parser.add_argument("--only", nargs="*", help="Process only these filenames")
args = parser.parse_args()
if not LOGO_PATH.exists():
raise SystemExit(f"Missing logo: {LOGO_PATH}")
BACKUP.mkdir(exist_ok=True)
ensure_catalog_files()
logo = prep_logo(108)
files = sorted(SITE.glob("*.jpg"))
processed = 0
skipped = 0
for path in files:
if should_skip(path):
continue
if args.only and path.name not in args.only:
continue
if not args.force and already_branded(path):
print(f"{path.name} (already branded, skipped)")
skipped += 1
continue
backup = BACKUP / path.name
if not backup.exists():
shutil.copy2(path, backup)
source = backup if backup.exists() else path
compose(source, path, logo)
print(f"{path.name}")
processed += 1
print(f"\nDone — {processed} processed, {skipped} skipped. Originals in {BACKUP}")
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import HomeView from '@/components/home/HomeView';
import { SITE_NAME } from '@/lib/constants';
export const metadata: Metadata = {
title: 'Home',
description:
'Welcome to Kött Gård — premium 100% Halal meat delivery. Fresh chicken, beef, and lamb; frozen fish and seafood only.',
alternates: {
canonical: '/',
},
};
export default function HomeAliasPage() {
return <HomeView />;
}
+1 -4
View File
@@ -41,16 +41,13 @@ const notoArabic = Noto_Sans_Arabic({
const siteDescription =
'Premium 100% Halal meat delivery. Fresh chicken, beef, and lamb; frozen fish and seafood only — customized to your preference and delivered to your door.';
export const viewport = {
themeColor: '#8B1F1F',
};
export const metadata: Metadata = {
title: {
default: `${SITE_NAME} — Premium Halal Meat Delivery`,
template: `%s | ${SITE_NAME}`,
},
description: siteDescription,
themeColor: '#8B1F1F',
keywords: [
'halal meat',
'halal chicken',
+9 -24
View File
@@ -1,27 +1,12 @@
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';
import type { Metadata } from 'next';
import HomeView from '@/components/home/HomeView';
import { SITE_NAME } from '@/lib/constants';
export const metadata: Metadata = {
title: 'Home',
description: `${SITE_NAME} — premium 100% Halal meat delivery. Fresh chicken, beef, and lamb; frozen fish and seafood only.`,
};
export default function HomePage() {
return (
<>
<Hero />
<TrustBadges />
<AboutPreview />
<CategoryGrid />
<FeaturedProducts />
<HowItWorks />
<WeeklyOffers />
<CTA />
<SocialFollow />
<ContactPreview />
</>
);
return <HomeView />;
}
+3 -3
View File
@@ -67,17 +67,17 @@ export default function ProductPage() {
<div className="grid gap-10 lg:grid-cols-2">
<div>
<div className="relative mb-4 aspect-square overflow-hidden rounded-2xl bg-gray-50">
<div className="relative mb-4 aspect-square overflow-hidden rounded-2xl bg-white shadow-premium ring-1 ring-gray-100">
<AppImage
src={product.images[selectedImage] || product.image}
alt={product.name}
fill
className="object-cover"
className="object-contain p-3 sm:p-5"
priority
sizes="(max-width: 1024px) 100vw, 640px"
/>
{product.badge && (
<span className="absolute start-4 top-4 rounded-full bg-gold-500 px-4 py-1.5 text-sm font-semibold text-white shadow-gold">
<span className="absolute start-4 top-4 z-20 rounded-full bg-gold-500 px-4 py-1.5 text-sm font-semibold text-white shadow-gold">
{product.badge}
</span>
)}
+4 -7
View File
@@ -1,7 +1,6 @@
'use client';
import AppImage from '@/components/ui/AppImage';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import Button from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation';
@@ -42,12 +41,10 @@ export default function AboutPreview() {
))}
</div>
<Link href="/about">
<Button variant="secondary">
{t('aboutPreview.readMore')}
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
</Button>
</Link>
<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">
+17 -20
View File
@@ -1,6 +1,5 @@
'use client';
import Link from 'next/link';
import Button from '@/components/ui/Button';
import { ArrowRight, MessageCircle } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
@@ -13,31 +12,29 @@ export default function CTA() {
<section className="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="absolute -right-20 -top-20 h-60 w-60 rounded-full bg-gold-500/10" />
<div className="absolute -bottom-16 -left-16 h-48 w-48 rounded-full bg-gold-500/10" />
<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" />
<div className="relative">
<div className="relative z-10">
<h2 className="mb-4 font-display text-3xl font-bold text-white md:text-4xl">
{t('cta.title')}
</h2>
<p className="mx-auto mb-8 max-w-xl text-brand-100">{t('cta.subtitle')}</p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
<Link href="/shop">
<Button variant="gold" size="lg">
{t('cta.button')}
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
</Button>
</Link>
<a href={WHATSAPP_URL} target="_blank" rel="noopener noreferrer">
<Button
variant="secondary"
size="lg"
className="border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15"
>
<MessageCircle className="h-4 w-4" />
{t('cta.whatsapp')}
</Button>
</a>
<Button href="/shop" variant="gold" size="lg">
{t('cta.button')}
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
</Button>
<Button
href={WHATSAPP_URL}
external
variant="secondary"
size="lg"
className="border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15"
>
<MessageCircle className="h-4 w-4" />
{t('cta.whatsapp')}
</Button>
</div>
</div>
</div>
+21 -26
View File
@@ -1,6 +1,5 @@
'use client';
import Link from 'next/link';
import { Phone, MapPin, Clock, MessageCircle, ExternalLink } from 'lucide-react';
import Button from '@/components/ui/Button';
import Logo from '@/components/ui/Logo';
@@ -64,31 +63,27 @@ export default function ContactPreview() {
</ul>
<div className="flex flex-col gap-3 sm:flex-row">
<Link href="/about#contact">
<Button
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>
</Link>
<a href={WHATSAPP_URL} target="_blank" rel="noopener noreferrer">
<Button variant="gold" size="lg" className="w-full sm:w-auto">
<MessageCircle className="h-4 w-4" />
{t('contact.writeUs')}
</Button>
</a>
<a href={telHref}>
<Button
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>
</a>
<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>
+4 -7
View File
@@ -1,6 +1,5 @@
'use client';
import Link from 'next/link';
import ProductCard from '@/components/product/ProductCard';
import { getFeaturedProducts } from '@/lib/products';
import Button from '@/components/ui/Button';
@@ -25,12 +24,10 @@ export default function FeaturedProducts() {
<h2 className="section-heading">{t('featured.title')}</h2>
<p className="mt-2 max-w-lg text-gray-500">{t('featured.subtitle')}</p>
</div>
<Link href="/shop">
<Button variant="secondary">
{t('featured.viewAll')}
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
</Button>
</Link>
<Button href="/shop" variant="secondary">
{t('featured.viewAll')}
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
</Button>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
+18 -21
View File
@@ -1,6 +1,5 @@
'use client';
import Link from 'next/link';
import AppImage from '@/components/ui/AppImage';
import Button from '@/components/ui/Button';
import Logo from '@/components/ui/Logo';
@@ -14,7 +13,7 @@ export default function Hero() {
return (
<section className="relative min-h-[85vh] overflow-hidden bg-brand-950">
<div className="absolute inset-0">
<div className="pointer-events-none absolute inset-0">
<AppImage
src={PAGE_IMAGES.hero}
alt=""
@@ -26,10 +25,10 @@ export default function Hero() {
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAUH/8QAIhAAAgEDBQAAAAAAAAAAAAAAAQIDAAQRBQYhIjFB/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAZEQACAwEAAAAAAAAAAAAAAAAAAQIRITH/2gAMAwEAAhEDEEA/ALextba0t0t7eJI4kHVEU4AqT/9k="
/>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-brand-950/95 via-brand-900/85 to-brand-900/40" />
<div className="absolute inset-0 bg-gradient-to-t from-brand-950/60 via-transparent to-transparent" />
<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 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="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="mb-8 flex items-center gap-4">
<Logo size="lg" className="ring-gold-400/40 shadow-premium-lg" />
@@ -70,26 +69,24 @@ export default function Hero() {
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<Link href="/shop">
<Button variant="gold" size="lg">
{t('hero.shopNow')}
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
</Button>
</Link>
<a href={WHATSAPP_URL} target="_blank" rel="noopener noreferrer">
<Button
variant="secondary"
size="lg"
className="border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15"
>
{t('hero.whatsapp')}
</Button>
</a>
<Button href="/shop" variant="gold" size="lg">
{t('hero.shopNow')}
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
</Button>
<Button
href={WHATSAPP_URL}
external
variant="secondary"
size="lg"
className="border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15"
>
{t('hero.whatsapp')}
</Button>
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-cream-50 to-transparent" />
<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" />
</section>
);
}
+27
View File
@@ -0,0 +1,27 @@
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 />
</>
);
}
+4 -6
View File
@@ -57,12 +57,10 @@ export default function HowItWorks() {
</div>
<div className="text-center">
<a href={WHATSAPP_URL} target="_blank" rel="noopener noreferrer">
<Button variant="gold" size="lg">
<MessageCircle className="h-4 w-4" />
{t('howItWorks.whatsapp')}
</Button>
</a>
<Button href={WHATSAPP_URL} external variant="gold" size="lg">
<MessageCircle className="h-4 w-4" />
{t('howItWorks.whatsapp')}
</Button>
</div>
</div>
</section>
+1
View File
@@ -25,6 +25,7 @@ export default function Footer() {
{ label: t('nav.fish'), href: '/shop?category=fish' },
],
company: [
{ label: t('nav.home'), href: '/' },
{ label: t('nav.about'), href: '/about' },
{ label: t('nav.halalCert'), href: '/about#halal' },
{ label: t('nav.delivery'), href: '/about#delivery' },
+45 -19
View File
@@ -1,8 +1,10 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import { ShoppingCart, Heart, User, Menu, X, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useCart } from '@/presentation/hooks/useCart';
import { useWishlist } from '@/presentation/hooks/useWishlist';
import { useAuth } from '@/presentation/hooks/useAuth';
@@ -11,6 +13,7 @@ import Logo from '@/components/ui/Logo';
import { SITE_LOCATION } from '@/lib/constants';
export default function Header() {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const { summary } = useCart();
const { items: wishlistItems } = useWishlist();
@@ -19,7 +22,10 @@ export default function Header() {
const wishlistCount = wishlistItems.length;
const { t } = useTranslation();
const isHome = pathname === '/' || pathname === '/home';
const navLinks = [
{ href: '/', label: t('nav.home'), matchHome: true },
{ href: '/shop', label: t('nav.shop') },
{ href: '/shop?category=chicken', label: t('nav.chicken') },
{ href: '/shop?category=beef', label: t('nav.beef') },
@@ -46,15 +52,25 @@ export default function Header() {
</Link>
<nav className="hidden items-center gap-8 lg:flex">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-sm font-medium text-gray-600 transition-colors hover:text-brand-700"
>
{link.label}
</Link>
))}
{navLinks.map((link) => {
const active =
'matchHome' in link && link.matchHome
? isHome
: pathname === link.href || pathname.startsWith(`${link.href}?`);
return (
<Link
key={link.href}
href={link.href}
aria-current={active ? 'page' : undefined}
className={cn(
'text-sm font-medium transition-colors hover:text-brand-700',
active ? 'text-brand-800' : 'text-gray-600'
)}
>
{link.label}
</Link>
);
})}
</nav>
<div className="flex items-center gap-1 sm:gap-2">
@@ -114,16 +130,26 @@ export default function Header() {
{mobileOpen && (
<div className="border-t border-gray-100 bg-white lg:hidden">
<nav className="flex flex-col px-4 py-4">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="rounded-lg px-4 py-3 text-sm font-medium text-gray-600 transition-colors hover:bg-brand-50 hover:text-brand-700"
onClick={() => setMobileOpen(false)}
>
{link.label}
</Link>
))}
{navLinks.map((link) => {
const active =
'matchHome' in link && link.matchHome
? isHome
: pathname === link.href || pathname.startsWith(`${link.href}?`);
return (
<Link
key={link.href}
href={link.href}
aria-current={active ? 'page' : undefined}
className={cn(
'rounded-lg px-4 py-3 text-sm font-medium transition-colors hover:bg-brand-50 hover:text-brand-700',
active ? 'bg-brand-50 text-brand-800' : 'text-gray-600'
)}
onClick={() => setMobileOpen(false)}
>
{link.label}
</Link>
);
})}
</nav>
</div>
)}
+2 -3
View File
@@ -9,7 +9,6 @@ import { useWishlist } from '@/presentation/hooks/useWishlist';
import { useTranslation } from '@/hooks/useTranslation';
import { cn } from '@/lib/utils';
import { products } from '@/lib/products';
interface ProductCardProps {
product: LocalizedProduct;
}
@@ -22,13 +21,13 @@ export default function ProductCard({ product }: ProductCardProps) {
return (
<div className="card-premium group overflow-hidden">
<div className="relative aspect-[4/3] overflow-hidden bg-gray-50">
<div className="relative aspect-[4/3] overflow-hidden bg-white">
<Link href={`/product/${product.slug}`}>
<AppImage
src={product.image}
alt={product.name}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
className="object-contain p-2 transition-transform duration-500 group-hover:scale-[1.03]"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 320px"
/>
</Link>
+94 -16
View File
@@ -1,31 +1,109 @@
import Link from 'next/link';
import { cn } from '@/lib/utils';
import { ButtonHTMLAttributes, forwardRef } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'gold' | 'ghost';
size?: 'sm' | 'md' | 'lg';
href?: string;
external?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', children, ...props }, ref) => {
const variants = {
primary: 'btn-primary',
secondary: 'btn-secondary',
gold: 'btn-gold',
ghost:
'inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-700',
};
function getButtonClasses(
variant: ButtonProps['variant'],
size: ButtonProps['size'],
className?: string
) {
const variants = {
primary: 'btn-primary',
secondary: 'btn-secondary',
gold: 'btn-gold',
ghost:
'inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-700',
};
const sizes = {
sm: 'px-4 py-2 text-xs',
md: '',
lg: 'px-8 py-4 text-base',
};
const sizes = {
sm: 'px-4 py-2 text-xs',
md: '',
lg: 'px-8 py-4 text-base',
};
return cn(variants[variant ?? 'primary'], sizes[size ?? 'md'], className);
}
function isHttpHref(href: string) {
return /^https?:\/\//i.test(href);
}
const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
children,
href,
external,
type = 'button',
disabled,
form,
formAction,
formEncType,
formMethod,
formNoValidate,
formTarget,
name,
value,
...props
},
ref
) => {
const classes = getButtonClasses(variant, size, className);
if (href) {
const openInNewTab = external || isHttpHref(href);
const linkClassName = cn(classes, disabled && 'pointer-events-none opacity-50');
if (href.startsWith('/') && !openInNewTab) {
return (
<Link
href={href}
ref={ref as React.Ref<HTMLAnchorElement>}
className={linkClassName}
aria-disabled={disabled || undefined}
>
{children}
</Link>
);
}
return (
<a
href={disabled ? undefined : href}
ref={ref as React.Ref<HTMLAnchorElement>}
className={linkClassName}
aria-disabled={disabled || undefined}
{...(openInNewTab ? { target: '_blank' as const, rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
);
}
return (
<button
ref={ref}
className={cn(variants[variant], sizes[size], className)}
ref={ref as React.Ref<HTMLButtonElement>}
type={type}
className={classes}
disabled={disabled}
form={form}
formAction={formAction}
formEncType={formEncType}
formMethod={formMethod}
formNoValidate={formNoValidate}
formTarget={formTarget}
name={name}
value={value}
{...props}
>
{children}
+1
View File
@@ -12,6 +12,7 @@ export const ar: TranslationDict = {
demoEmail: 'demo@kottgard.se',
},
nav: {
home: 'الرئيسية',
shop: 'المتجر',
chicken: 'الدجاج',
beef: 'لحم البقر',
+1
View File
@@ -12,6 +12,7 @@ export const en: TranslationDict = {
demoEmail: 'demo@kottgard.se',
},
nav: {
home: 'Home',
shop: 'Shop',
chicken: 'Chicken',
beef: 'Beef',
+1
View File
@@ -12,6 +12,7 @@ export const fa: TranslationDict = {
demoEmail: 'demo@kottgard.se',
},
nav: {
home: 'خانه',
shop: 'فروشگاه',
chicken: 'مرغ',
beef: 'گوشت گاو',
+1
View File
@@ -12,6 +12,7 @@ export const sv: TranslationDict = {
demoEmail: 'demo@kottgard.se',
},
nav: {
home: 'Hem',
shop: 'Butik',
chicken: 'Kyckling',
beef: 'Nötkött',
+1
View File
@@ -12,6 +12,7 @@ export const tr: TranslationDict = {
demoEmail: 'demo@kottgard.se',
},
nav: {
home: 'Ana Sayfa',
shop: 'Mağaza',
chicken: 'Tavuk',
beef: 'Dana',
+1
View File
@@ -12,6 +12,7 @@ export const ur: TranslationDict = {
demoEmail: 'demo@kottgard.se',
},
nav: {
home: 'ہوم',
shop: 'خریداری',
chicken: 'مرغی',
beef: 'گائے کا گوشت',
+4 -1
View File
@@ -18,9 +18,12 @@ import type { Category } from '@/domain/entities';
/** Single folder — every website image file is here */
export const IMAGE_FOLDER = '/images/site';
/** Bump when site images are re-processed (cache bust) */
export const SITE_IMAGE_VERSION = '11';
/** Build path to a file in the site image folder */
export function siteImage(filename: string): string {
return `${IMAGE_FOLDER}/${filename}`;
return `${IMAGE_FOLDER}/${filename}?v=${SITE_IMAGE_VERSION}`;
}
export type ProductImageId =