Update to v1.0 of website code from kottgard-production-v1.zip
@@ -1,5 +1,16 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const 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: {
|
images: {
|
||||||
formats: ['image/avif', 'image/webp'],
|
formats: ['image/avif', 'image/webp'],
|
||||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 2560],
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 2560],
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"clean": "rm -rf .next",
|
||||||
"build": "next build",
|
"dev": "bash scripts/dev.sh",
|
||||||
|
"build": "bash scripts/build.sh",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"annotate": "node docs/annotate-all.mjs",
|
"annotate": "node docs/annotate-all.mjs",
|
||||||
"images:populate": "bash scripts/populate-site-images.sh",
|
"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": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 508 KiB After Width: | Height: | Size: 701 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 554 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 554 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 796 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 796 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 719 KiB |
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 787 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 373 KiB After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 800 KiB |
|
Before Width: | Height: | Size: 373 KiB After Width: | Height: | Size: 800 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 800 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 634 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 526 KiB |
|
Before Width: | Height: | Size: 480 KiB After Width: | Height: | Size: 992 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 644 KiB |
|
Before Width: | Height: | Size: 360 KiB After Width: | Height: | Size: 744 KiB |
|
Before Width: | Height: | Size: 834 KiB After Width: | Height: | Size: 693 KiB |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 834 KiB After Width: | Height: | Size: 693 KiB |
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -41,16 +41,13 @@ const notoArabic = Noto_Sans_Arabic({
|
|||||||
const siteDescription =
|
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.';
|
'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 = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: `${SITE_NAME} — Premium Halal Meat Delivery`,
|
default: `${SITE_NAME} — Premium Halal Meat Delivery`,
|
||||||
template: `%s | ${SITE_NAME}`,
|
template: `%s | ${SITE_NAME}`,
|
||||||
},
|
},
|
||||||
description: siteDescription,
|
description: siteDescription,
|
||||||
|
themeColor: '#8B1F1F',
|
||||||
keywords: [
|
keywords: [
|
||||||
'halal meat',
|
'halal meat',
|
||||||
'halal chicken',
|
'halal chicken',
|
||||||
|
|||||||
@@ -1,27 +1,12 @@
|
|||||||
import Hero from '@/components/home/Hero';
|
import type { Metadata } from 'next';
|
||||||
import TrustBadges from '@/components/home/TrustBadges';
|
import HomeView from '@/components/home/HomeView';
|
||||||
import AboutPreview from '@/components/home/AboutPreview';
|
import { SITE_NAME } from '@/lib/constants';
|
||||||
import CategoryGrid from '@/components/home/CategoryGrid';
|
|
||||||
import FeaturedProducts from '@/components/home/FeaturedProducts';
|
export const metadata: Metadata = {
|
||||||
import HowItWorks from '@/components/home/HowItWorks';
|
title: 'Home',
|
||||||
import WeeklyOffers from '@/components/home/WeeklyOffers';
|
description: `${SITE_NAME} — premium 100% Halal meat delivery. Fresh chicken, beef, and lamb; frozen fish and seafood only.`,
|
||||||
import SocialFollow from '@/components/home/SocialFollow';
|
};
|
||||||
import ContactPreview from '@/components/home/ContactPreview';
|
|
||||||
import CTA from '@/components/home/CTA';
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return <HomeView />;
|
||||||
<>
|
|
||||||
<Hero />
|
|
||||||
<TrustBadges />
|
|
||||||
<AboutPreview />
|
|
||||||
<CategoryGrid />
|
|
||||||
<FeaturedProducts />
|
|
||||||
<HowItWorks />
|
|
||||||
<WeeklyOffers />
|
|
||||||
<CTA />
|
|
||||||
<SocialFollow />
|
|
||||||
<ContactPreview />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -67,17 +67,17 @@ export default function ProductPage() {
|
|||||||
|
|
||||||
<div className="grid gap-10 lg:grid-cols-2">
|
<div className="grid gap-10 lg:grid-cols-2">
|
||||||
<div>
|
<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
|
<AppImage
|
||||||
src={product.images[selectedImage] || product.image}
|
src={product.images[selectedImage] || product.image}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-contain p-3 sm:p-5"
|
||||||
priority
|
priority
|
||||||
sizes="(max-width: 1024px) 100vw, 640px"
|
sizes="(max-width: 1024px) 100vw, 640px"
|
||||||
/>
|
/>
|
||||||
{product.badge && (
|
{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}
|
{product.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import AppImage from '@/components/ui/AppImage';
|
import AppImage from '@/components/ui/AppImage';
|
||||||
import Link from 'next/link';
|
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
@@ -42,12 +41,10 @@ export default function AboutPreview() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href="/about">
|
<Button href="/about" variant="secondary">
|
||||||
<Button variant="secondary">
|
{t('aboutPreview.readMore')}
|
||||||
{t('aboutPreview.readMore')}
|
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
||||||
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl shadow-premium-lg">
|
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl shadow-premium-lg">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { ArrowRight, MessageCircle } from 'lucide-react';
|
import { ArrowRight, MessageCircle } from 'lucide-react';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
@@ -13,31 +12,29 @@ export default function CTA() {
|
|||||||
<section className="py-20">
|
<section className="py-20">
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<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 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="pointer-events-none 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 -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">
|
<h2 className="mb-4 font-display text-3xl font-bold text-white md:text-4xl">
|
||||||
{t('cta.title')}
|
{t('cta.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto mb-8 max-w-xl text-brand-100">{t('cta.subtitle')}</p>
|
<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">
|
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||||
<Link href="/shop">
|
<Button href="/shop" variant="gold" size="lg">
|
||||||
<Button variant="gold" size="lg">
|
{t('cta.button')}
|
||||||
{t('cta.button')}
|
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
||||||
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
</Link>
|
href={WHATSAPP_URL}
|
||||||
<a href={WHATSAPP_URL} target="_blank" rel="noopener noreferrer">
|
external
|
||||||
<Button
|
variant="secondary"
|
||||||
variant="secondary"
|
size="lg"
|
||||||
size="lg"
|
className="border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15"
|
||||||
className="border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15"
|
>
|
||||||
>
|
<MessageCircle className="h-4 w-4" />
|
||||||
<MessageCircle className="h-4 w-4" />
|
{t('cta.whatsapp')}
|
||||||
{t('cta.whatsapp')}
|
</Button>
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Phone, MapPin, Clock, MessageCircle, ExternalLink } from 'lucide-react';
|
import { Phone, MapPin, Clock, MessageCircle, ExternalLink } from 'lucide-react';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Logo from '@/components/ui/Logo';
|
import Logo from '@/components/ui/Logo';
|
||||||
@@ -64,31 +63,27 @@ export default function ContactPreview() {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
<Link href="/about#contact">
|
<Button
|
||||||
<Button
|
href="/about#contact"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="lg"
|
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"
|
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')}
|
{t('contact.learnMore')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
<Button href={WHATSAPP_URL} external variant="gold" size="lg" className="w-full sm:w-auto">
|
||||||
<a href={WHATSAPP_URL} target="_blank" rel="noopener noreferrer">
|
<MessageCircle className="h-4 w-4" />
|
||||||
<Button variant="gold" size="lg" className="w-full sm:w-auto">
|
{t('contact.writeUs')}
|
||||||
<MessageCircle className="h-4 w-4" />
|
</Button>
|
||||||
{t('contact.writeUs')}
|
<Button
|
||||||
</Button>
|
href={telHref}
|
||||||
</a>
|
variant="secondary"
|
||||||
<a href={telHref}>
|
size="lg"
|
||||||
<Button
|
className="w-full border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15 sm:w-auto"
|
||||||
variant="secondary"
|
>
|
||||||
size="lg"
|
<Phone className="h-4 w-4" />
|
||||||
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.callUs')}
|
||||||
>
|
</Button>
|
||||||
<Phone className="h-4 w-4" />
|
|
||||||
{t('contact.callUs')}
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import ProductCard from '@/components/product/ProductCard';
|
import ProductCard from '@/components/product/ProductCard';
|
||||||
import { getFeaturedProducts } from '@/lib/products';
|
import { getFeaturedProducts } from '@/lib/products';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
@@ -25,12 +24,10 @@ export default function FeaturedProducts() {
|
|||||||
<h2 className="section-heading">{t('featured.title')}</h2>
|
<h2 className="section-heading">{t('featured.title')}</h2>
|
||||||
<p className="mt-2 max-w-lg text-gray-500">{t('featured.subtitle')}</p>
|
<p className="mt-2 max-w-lg text-gray-500">{t('featured.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/shop">
|
<Button href="/shop" variant="secondary">
|
||||||
<Button variant="secondary">
|
{t('featured.viewAll')}
|
||||||
{t('featured.viewAll')}
|
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
||||||
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import AppImage from '@/components/ui/AppImage';
|
import AppImage from '@/components/ui/AppImage';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Logo from '@/components/ui/Logo';
|
import Logo from '@/components/ui/Logo';
|
||||||
@@ -14,7 +13,7 @@ export default function Hero() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative min-h-[85vh] overflow-hidden bg-brand-950">
|
<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
|
<AppImage
|
||||||
src={PAGE_IMAGES.hero}
|
src={PAGE_IMAGES.hero}
|
||||||
alt=""
|
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="
|
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAUH/8QAIhAAAgEDBQAAAAAAAAAAAAAAAQIDAAQRBQYhIjFB/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAZEQACAwEAAAAAAAAAAAAAAAAAAQIRITH/2gAMAwEAAhEDEEA/ALextba0t0t7eJI4kHVEU4AqT/9k="
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="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-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-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="max-w-2xl animate-slide-up">
|
||||||
<div className="mb-8 flex items-center gap-4">
|
<div className="mb-8 flex items-center gap-4">
|
||||||
<Logo size="lg" className="ring-gold-400/40 shadow-premium-lg" />
|
<Logo size="lg" className="ring-gold-400/40 shadow-premium-lg" />
|
||||||
@@ -70,26 +69,24 @@ export default function Hero() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row">
|
<div className="flex flex-col gap-4 sm:flex-row">
|
||||||
<Link href="/shop">
|
<Button href="/shop" variant="gold" size="lg">
|
||||||
<Button variant="gold" size="lg">
|
{t('hero.shopNow')}
|
||||||
{t('hero.shopNow')}
|
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
||||||
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
</Link>
|
href={WHATSAPP_URL}
|
||||||
<a href={WHATSAPP_URL} target="_blank" rel="noopener noreferrer">
|
external
|
||||||
<Button
|
variant="secondary"
|
||||||
variant="secondary"
|
size="lg"
|
||||||
size="lg"
|
className="border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15"
|
||||||
className="border-white/30 bg-white/10 text-white hover:border-gold-400/50 hover:bg-white/15"
|
>
|
||||||
>
|
{t('hero.whatsapp')}
|
||||||
{t('hero.whatsapp')}
|
</Button>
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,12 +57,10 @@ export default function HowItWorks() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<a href={WHATSAPP_URL} target="_blank" rel="noopener noreferrer">
|
<Button href={WHATSAPP_URL} external variant="gold" size="lg">
|
||||||
<Button variant="gold" size="lg">
|
<MessageCircle className="h-4 w-4" />
|
||||||
<MessageCircle className="h-4 w-4" />
|
{t('howItWorks.whatsapp')}
|
||||||
{t('howItWorks.whatsapp')}
|
</Button>
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default function Footer() {
|
|||||||
{ label: t('nav.fish'), href: '/shop?category=fish' },
|
{ label: t('nav.fish'), href: '/shop?category=fish' },
|
||||||
],
|
],
|
||||||
company: [
|
company: [
|
||||||
|
{ label: t('nav.home'), href: '/' },
|
||||||
{ label: t('nav.about'), href: '/about' },
|
{ label: t('nav.about'), href: '/about' },
|
||||||
{ label: t('nav.halalCert'), href: '/about#halal' },
|
{ label: t('nav.halalCert'), href: '/about#halal' },
|
||||||
{ label: t('nav.delivery'), href: '/about#delivery' },
|
{ label: t('nav.delivery'), href: '/about#delivery' },
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ShoppingCart, Heart, User, Menu, X, Search } from 'lucide-react';
|
import { ShoppingCart, Heart, User, Menu, X, Search } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { useCart } from '@/presentation/hooks/useCart';
|
import { useCart } from '@/presentation/hooks/useCart';
|
||||||
import { useWishlist } from '@/presentation/hooks/useWishlist';
|
import { useWishlist } from '@/presentation/hooks/useWishlist';
|
||||||
import { useAuth } from '@/presentation/hooks/useAuth';
|
import { useAuth } from '@/presentation/hooks/useAuth';
|
||||||
@@ -11,6 +13,7 @@ import Logo from '@/components/ui/Logo';
|
|||||||
import { SITE_LOCATION } from '@/lib/constants';
|
import { SITE_LOCATION } from '@/lib/constants';
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
|
const pathname = usePathname();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const { summary } = useCart();
|
const { summary } = useCart();
|
||||||
const { items: wishlistItems } = useWishlist();
|
const { items: wishlistItems } = useWishlist();
|
||||||
@@ -19,7 +22,10 @@ export default function Header() {
|
|||||||
const wishlistCount = wishlistItems.length;
|
const wishlistCount = wishlistItems.length;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const isHome = pathname === '/' || pathname === '/home';
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
|
{ href: '/', label: t('nav.home'), matchHome: true },
|
||||||
{ href: '/shop', label: t('nav.shop') },
|
{ href: '/shop', label: t('nav.shop') },
|
||||||
{ href: '/shop?category=chicken', label: t('nav.chicken') },
|
{ href: '/shop?category=chicken', label: t('nav.chicken') },
|
||||||
{ href: '/shop?category=beef', label: t('nav.beef') },
|
{ href: '/shop?category=beef', label: t('nav.beef') },
|
||||||
@@ -46,15 +52,25 @@ export default function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden items-center gap-8 lg:flex">
|
<nav className="hidden items-center gap-8 lg:flex">
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => {
|
||||||
<Link
|
const active =
|
||||||
key={link.href}
|
'matchHome' in link && link.matchHome
|
||||||
href={link.href}
|
? isHome
|
||||||
className="text-sm font-medium text-gray-600 transition-colors hover:text-brand-700"
|
: pathname === link.href || pathname.startsWith(`${link.href}?`);
|
||||||
>
|
return (
|
||||||
{link.label}
|
<Link
|
||||||
</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>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 sm:gap-2">
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
@@ -114,16 +130,26 @@ export default function Header() {
|
|||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<div className="border-t border-gray-100 bg-white lg:hidden">
|
<div className="border-t border-gray-100 bg-white lg:hidden">
|
||||||
<nav className="flex flex-col px-4 py-4">
|
<nav className="flex flex-col px-4 py-4">
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => {
|
||||||
<Link
|
const active =
|
||||||
key={link.href}
|
'matchHome' in link && link.matchHome
|
||||||
href={link.href}
|
? isHome
|
||||||
className="rounded-lg px-4 py-3 text-sm font-medium text-gray-600 transition-colors hover:bg-brand-50 hover:text-brand-700"
|
: pathname === link.href || pathname.startsWith(`${link.href}?`);
|
||||||
onClick={() => setMobileOpen(false)}
|
return (
|
||||||
>
|
<Link
|
||||||
{link.label}
|
key={link.href}
|
||||||
</Link>
|
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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useWishlist } from '@/presentation/hooks/useWishlist';
|
|||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { products } from '@/lib/products';
|
import { products } from '@/lib/products';
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: LocalizedProduct;
|
product: LocalizedProduct;
|
||||||
}
|
}
|
||||||
@@ -22,13 +21,13 @@ export default function ProductCard({ product }: ProductCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-premium group overflow-hidden">
|
<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}`}>
|
<Link href={`/product/${product.slug}`}>
|
||||||
<AppImage
|
<AppImage
|
||||||
src={product.image}
|
src={product.image}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
fill
|
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"
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 320px"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,31 +1,109 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'secondary' | 'gold' | 'ghost';
|
variant?: 'primary' | 'secondary' | 'gold' | 'ghost';
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
href?: string;
|
||||||
|
external?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
function getButtonClasses(
|
||||||
({ className, variant = 'primary', size = 'md', children, ...props }, ref) => {
|
variant: ButtonProps['variant'],
|
||||||
const variants = {
|
size: ButtonProps['size'],
|
||||||
primary: 'btn-primary',
|
className?: string
|
||||||
secondary: 'btn-secondary',
|
) {
|
||||||
gold: 'btn-gold',
|
const variants = {
|
||||||
ghost:
|
primary: 'btn-primary',
|
||||||
'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',
|
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 = {
|
const sizes = {
|
||||||
sm: 'px-4 py-2 text-xs',
|
sm: 'px-4 py-2 text-xs',
|
||||||
md: '',
|
md: '',
|
||||||
lg: 'px-8 py-4 text-base',
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref as React.Ref<HTMLButtonElement>}
|
||||||
className={cn(variants[variant], sizes[size], className)}
|
type={type}
|
||||||
|
className={classes}
|
||||||
|
disabled={disabled}
|
||||||
|
form={form}
|
||||||
|
formAction={formAction}
|
||||||
|
formEncType={formEncType}
|
||||||
|
formMethod={formMethod}
|
||||||
|
formNoValidate={formNoValidate}
|
||||||
|
formTarget={formTarget}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const ar: TranslationDict = {
|
|||||||
demoEmail: 'demo@kottgard.se',
|
demoEmail: 'demo@kottgard.se',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
home: 'الرئيسية',
|
||||||
shop: 'المتجر',
|
shop: 'المتجر',
|
||||||
chicken: 'الدجاج',
|
chicken: 'الدجاج',
|
||||||
beef: 'لحم البقر',
|
beef: 'لحم البقر',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const en: TranslationDict = {
|
|||||||
demoEmail: 'demo@kottgard.se',
|
demoEmail: 'demo@kottgard.se',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
home: 'Home',
|
||||||
shop: 'Shop',
|
shop: 'Shop',
|
||||||
chicken: 'Chicken',
|
chicken: 'Chicken',
|
||||||
beef: 'Beef',
|
beef: 'Beef',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const fa: TranslationDict = {
|
|||||||
demoEmail: 'demo@kottgard.se',
|
demoEmail: 'demo@kottgard.se',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
home: 'خانه',
|
||||||
shop: 'فروشگاه',
|
shop: 'فروشگاه',
|
||||||
chicken: 'مرغ',
|
chicken: 'مرغ',
|
||||||
beef: 'گوشت گاو',
|
beef: 'گوشت گاو',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const sv: TranslationDict = {
|
|||||||
demoEmail: 'demo@kottgard.se',
|
demoEmail: 'demo@kottgard.se',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
home: 'Hem',
|
||||||
shop: 'Butik',
|
shop: 'Butik',
|
||||||
chicken: 'Kyckling',
|
chicken: 'Kyckling',
|
||||||
beef: 'Nötkött',
|
beef: 'Nötkött',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const tr: TranslationDict = {
|
|||||||
demoEmail: 'demo@kottgard.se',
|
demoEmail: 'demo@kottgard.se',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
home: 'Ana Sayfa',
|
||||||
shop: 'Mağaza',
|
shop: 'Mağaza',
|
||||||
chicken: 'Tavuk',
|
chicken: 'Tavuk',
|
||||||
beef: 'Dana',
|
beef: 'Dana',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const ur: TranslationDict = {
|
|||||||
demoEmail: 'demo@kottgard.se',
|
demoEmail: 'demo@kottgard.se',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
home: 'ہوم',
|
||||||
shop: 'خریداری',
|
shop: 'خریداری',
|
||||||
chicken: 'مرغی',
|
chicken: 'مرغی',
|
||||||
beef: 'گائے کا گوشت',
|
beef: 'گائے کا گوشت',
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ import type { Category } from '@/domain/entities';
|
|||||||
/** Single folder — every website image file is here */
|
/** Single folder — every website image file is here */
|
||||||
export const IMAGE_FOLDER = '/images/site';
|
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 */
|
/** Build path to a file in the site image folder */
|
||||||
export function siteImage(filename: string): string {
|
export function siteImage(filename: string): string {
|
||||||
return `${IMAGE_FOLDER}/${filename}`;
|
return `${IMAGE_FOLDER}/${filename}?v=${SITE_IMAGE_VERSION}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProductImageId =
|
export type ProductImageId =
|
||||||
|
|||||||