Update to v1.0 of website code from kottgard-production-v1.zip
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
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 =
|
||||
'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',
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,6 +12,7 @@ export const ar: TranslationDict = {
|
||||
demoEmail: 'demo@kottgard.se',
|
||||
},
|
||||
nav: {
|
||||
home: 'الرئيسية',
|
||||
shop: 'المتجر',
|
||||
chicken: 'الدجاج',
|
||||
beef: 'لحم البقر',
|
||||
|
||||
@@ -12,6 +12,7 @@ export const en: TranslationDict = {
|
||||
demoEmail: 'demo@kottgard.se',
|
||||
},
|
||||
nav: {
|
||||
home: 'Home',
|
||||
shop: 'Shop',
|
||||
chicken: 'Chicken',
|
||||
beef: 'Beef',
|
||||
|
||||
@@ -12,6 +12,7 @@ export const fa: TranslationDict = {
|
||||
demoEmail: 'demo@kottgard.se',
|
||||
},
|
||||
nav: {
|
||||
home: 'خانه',
|
||||
shop: 'فروشگاه',
|
||||
chicken: 'مرغ',
|
||||
beef: 'گوشت گاو',
|
||||
|
||||
@@ -12,6 +12,7 @@ export const sv: TranslationDict = {
|
||||
demoEmail: 'demo@kottgard.se',
|
||||
},
|
||||
nav: {
|
||||
home: 'Hem',
|
||||
shop: 'Butik',
|
||||
chicken: 'Kyckling',
|
||||
beef: 'Nötkött',
|
||||
|
||||
@@ -12,6 +12,7 @@ export const tr: TranslationDict = {
|
||||
demoEmail: 'demo@kottgard.se',
|
||||
},
|
||||
nav: {
|
||||
home: 'Ana Sayfa',
|
||||
shop: 'Mağaza',
|
||||
chicken: 'Tavuk',
|
||||
beef: 'Dana',
|
||||
|
||||
@@ -12,6 +12,7 @@ export const ur: TranslationDict = {
|
||||
demoEmail: 'demo@kottgard.se',
|
||||
},
|
||||
nav: {
|
||||
home: 'ہوم',
|
||||
shop: 'خریداری',
|
||||
chicken: 'مرغی',
|
||||
beef: 'گائے کا گوشت',
|
||||
|
||||
@@ -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 =
|
||||
|
||||