Files
shahikitchen-prod/app/menu/page.tsx
T

419 lines
18 KiB
TypeScript

"use client";
/**
* MENU PAGE — Premium Sidebar Navigation
*/
import { useEffect, useRef, useState, useMemo } from "react";
import { menuCategories } from "@/lib/menu-data";
import { buildCartLineFromMenuItem } from "@/application/cart/cart-line-builder";
import type { MenuItem } from "@/domain/menu/entities";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { motion } from "framer-motion";
import Navbar from "@/components/Navbar";
import PageHeader from "@/components/PageHeader";
import Footer from "@/components/Footer";
import { useCart } from "@/components/CartContext";
import WishlistButton from "@/components/WishlistButton";
import { useLanguage } from "@/lib/language-context";
import { getTranslation } from "@/lib/translations";
import {
dishImageUrl,
getMenuPosterSrc,
getMenuPosterCandidates,
applyNextImageFallback,
} from "@/lib/assets";
import { playHoverSound } from "@/lib/sounds";
import DishDetailsModal from "@/components/DishDetailsModal";
import { hasDishDetailModal } from "@/domain/menu/dish-details";
gsap.registerPlugin(ScrollTrigger);
export default function MenuPage() {
const [activeCategory, setActiveCategory] = useState("All");
const [searchQuery, setSearchQuery] = useState("");
const [showVegetarianOnly, setShowVegetarianOnly] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [detailItem, setDetailItem] = useState<MenuItem | null>(null);
const categoryListRef = useRef<HTMLDivElement>(null);
const { addToCart } = useCart();
const { language } = useLanguage();
const t = getTranslation(language);
// Sidebar categories
const sidebarCategories = [
{ id: "All", name: t.menu.allDishes || "All Dishes" },
...menuCategories.map((cat) => ({ id: cat.id, name: cat.name })),
];
// Filtering logic
const filteredCategories = useMemo(() => {
return menuCategories
.map((category) => {
let items = category.items;
// Sidebar filter
if (activeCategory !== "All" && category.id !== activeCategory) {
items = [];
}
// Search
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase().trim();
items = items.filter(
(item) =>
item.name.toLowerCase().includes(q) ||
(item.description && item.description.toLowerCase().includes(q))
);
}
// Vegetarian
if (showVegetarianOnly) {
items = items.filter((item) => item.isVegetarian);
}
return { ...category, items };
})
.filter((category) => category.items.length > 0);
}, [searchQuery, showVegetarianOnly, activeCategory]);
const handleCategorySelect = (id: string) => {
setActiveCategory(id);
window.scrollTo({ top: 220, behavior: "smooth" });
};
const getItemDescription = (item: MenuItem) =>
(t as { menuDescriptions?: Record<string, string> }).menuDescriptions?.[item.id] ||
item.description ||
"";
const openDishDetails = (item: MenuItem) => {
setDetailItem(item);
};
/** Detail-modal dishes use their photo on the card; video plays only in the modal. */
const getCardImageSrc = (item: MenuItem, categoryId: string) =>
hasDishDetailModal(item.id, categoryId) && item.image
? dishImageUrl(item.image)
: getMenuPosterSrc(item);
const getCardImageCandidates = (item: MenuItem, categoryId: string) =>
hasDishDetailModal(item.id, categoryId) && item.image
? [dishImageUrl(item.image), ...getMenuPosterCandidates(item)]
: getMenuPosterCandidates(item);
const renderMenuPrice = (item: MenuItem) => {
if (item.pricing === "weight") {
return (
<div className="shrink-0 text-right">
<div className="text-[#B38B4D] text-xl font-medium tracking-[-0.5px] tabular-nums leading-tight">
{item.pricePerHalfKg ?? item.price}
</div>
<div className="text-[10px] text-[#8A8478] tracking-wide">{t.menu.perHalfKg}</div>
<div className="text-[#B38B4D] text-sm font-medium tabular-nums mt-0.5">
{item.pricePerKg} {t.menu.perKg}
</div>
</div>
);
}
return (
<div className="shrink-0 text-right">
<div className="text-[#B38B4D] text-2xl font-medium tracking-[-0.5px] tabular-nums">
{item.price}
</div>
<div className="text-[10px] text-[#8A8478] tracking-widest -mt-1">KR</div>
</div>
);
};
// isMobile detection - used to enable advanced desktop hover effects + sounds only on desktop
useEffect(() => {
const updateMobile = () => setIsMobile(window.innerWidth < 768);
updateMobile();
window.addEventListener("resize", updateMobile);
return () => window.removeEventListener("resize", updateMobile);
}, []);
useEffect(() => {
const container = categoryListRef.current;
if (!container) return;
const activeButton = container.querySelector<HTMLElement>(
`[data-category-id="${activeCategory}"]`
);
if (!activeButton) return;
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
activeButton.scrollIntoView({
behavior: prefersReducedMotion ? "auto" : "smooth",
block: "nearest",
inline: "center",
});
}, [activeCategory]);
return (
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
<Navbar />
<PageHeader
eyebrow="AUTHENTIC • GENEROUS • ROYAL"
title={t.menu.title}
subtitle={t.menu.subtitle}
/>
{/* Main Layout */}
<div className="max-w-7xl mx-auto px-6 pb-20">
<div className="flex flex-col lg:flex-row gap-10">
{/* BEAUTIFUL SIDEBAR */}
<div className="lg:w-72 flex-shrink-0">
<div className="sticky top-[116px]">
<div className="mb-6">
<div className="text-xs tracking-[3px] text-[#c99a2e] mb-2">EXPLORE OUR</div>
<h3 className="font-serif text-3xl tracking-tight">Signature Categories</h3>
</div>
{/* Smooth scroll: horizontal on mobile, vertical on desktop when list exceeds viewport */}
<div className="relative">
<div
ref={categoryListRef}
className="menu-category-scroll flex gap-2 overflow-x-auto overflow-y-hidden scroll-smooth overscroll-x-contain overscroll-y-contain pb-3 pr-1 snap-x snap-mandatory scroll-px-1 [-webkit-overflow-scrolling:touch] lg:flex-col lg:overflow-x-hidden lg:overflow-y-auto lg:max-h-[calc(100dvh-13.5rem)] lg:snap-none lg:scroll-px-0 lg:pb-2 lg:pr-2"
>
{sidebarCategories.map((cat) => {
const isActive = activeCategory === cat.id;
const itemCount = cat.id === "All"
? menuCategories.reduce((sum, c) => sum + c.items.length, 0)
: menuCategories.find(c => c.id === cat.id)?.items.length || 0;
return (
<button
key={cat.id}
data-category-id={cat.id}
onClick={() => handleCategorySelect(cat.id)}
className={`flex-shrink-0 snap-start min-w-[140px] lg:min-w-0 lg:w-full flex items-center justify-between px-5 py-3 lg:py-3.5 rounded-2xl text-left transition-all active:scale-[0.985] group ${
isActive
? "bg-[#101724] text-white shadow-lg"
: "hover:bg-white hover:shadow-sm active:bg-[#EDE6D9] text-[#101724] border border-transparent hover:border-[#e5e1d7]"
}`}
>
<span className={`font-medium tracking-[-0.1px] text-sm lg:text-base ${isActive ? "" : "group-hover:text-[#0f5a4a]"}`}>
{cat.name}
</span>
<span className={`text-[10px] lg:text-xs px-2 py-0.5 rounded-full font-mono tabular-nums ${
isActive ? "bg-white/20" : "bg-[#e5e1d7] text-[#68717f]"
}`}>
{itemCount}
</span>
</button>
);
})}
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-[#F8F5F0] to-transparent lg:hidden"
/>
</div>
</div>
</div>
{/* MAIN CONTENT */}
<div className="flex-1 min-w-0">
{/* Search + Vegetarian Filter */}
<div className="mb-8 flex flex-col md:flex-row gap-4 items-center">
<input
type="text"
placeholder={t.menu.searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full md:w-80 rounded-2xl border border-[#e5e1d7] bg-white px-5 py-3 text-sm placeholder:text-[#8A8478] focus:border-[#c99a2e] focus:ring-2 focus:ring-[#c99a2e]/20 transition-all"
/>
<button
onClick={() => setShowVegetarianOnly(!showVegetarianOnly)}
className={`px-6 py-3 rounded-2xl text-sm font-medium border transition-all active:scale-[0.985] ${
showVegetarianOnly
? "bg-[#0f5a4a] text-white border-[#0f5a4a]"
: "border-[#e5e1d7] hover:border-[#c99a2e] text-[#101724] bg-white"
}`}
>
{showVegetarianOnly ? t.menu.vegetarianOnly : t.menu.showVegetarian}
</button>
{(searchQuery || showVegetarianOnly || activeCategory !== "All") && (
<button
onClick={() => {
setSearchQuery("");
setShowVegetarianOnly(false);
setActiveCategory("All");
}}
className="text-sm font-medium text-[#c99a2e] hover:text-[#8f6b22] active:underline"
>
{t.menu.clearFilters}
</button>
)}
</div>
{/* Menu Items */}
{filteredCategories.length === 0 ? (
<div className="text-center py-16 text-[#6B665F]">
{t.menu.noResults}
</div>
) : (
filteredCategories.map((category) => (
<div key={category.id} className="mb-16">
<div className="flex items-center gap-4 mb-7">
<div className="text-3xl tracking-[-1px] text-[#101724]">{category.name}</div>
<div className="flex-1 h-px bg-gradient-to-r from-[#e5e1d7] to-transparent" />
<div className="text-sm font-medium text-[#68717f] tracking-widest">
{category.items.length} {t.menu.dishes}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{category.items.map((item) => {
const usesDetailModal = hasDishDetailModal(item.id, category.id);
return (
<motion.div
key={item.id}
data-id={item.id}
className={`menu-card group bg-white border border-[#EDE6D9] rounded-2xl overflow-hidden flex flex-col hover:border-[#c99a2e]/40 active:border-[#c99a2e] active:scale-[0.985] transition-all duration-150 touch-manipulation ${
usesDetailModal ? "" : "cursor-pointer"
}`}
onClick={
usesDetailModal
? undefined
: () => addToCart(buildCartLineFromMenuItem(item))
}
whileHover={!isMobile ? {
y: -4,
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
transition: { type: "spring", stiffness: 300, damping: 20 }
} : {}}
whileTap={{ scale: 0.985 }}
>
{/* Media - Always poster for performance. Advanced desktop hover effect (scale + lift + glow) + subtle sound. Mobile uses simple scale. */}
<div
className={`relative h-52 overflow-hidden bg-[#F2EDE4] ${
usesDetailModal ? "cursor-pointer" : ""
}`}
onClick={
usesDetailModal
? (e) => {
e.stopPropagation();
openDishDetails(item);
}
: undefined
}
onMouseEnter={() => {
if (!isMobile) playHoverSound();
}}
>
<motion.img
src={getCardImageSrc(item, category.id)}
alt={item.name}
className="absolute inset-0 w-full h-full object-cover"
loading="lazy"
whileHover={!isMobile ? {
scale: 1.08,
y: -3,
transition: { type: "spring", stiffness: 260, damping: 20 }
} : {}}
onError={(e) => {
applyNextImageFallback(
e.target as HTMLImageElement,
getCardImageCandidates(item, category.id),
(e.target as HTMLImageElement).src
);
}}
/>
{/* Subtle gradient + hover shine overlay for advanced desktop feel */}
<div className="absolute inset-0 bg-gradient-to-b from-black/5 via-black/10 to-black/25" />
{!isMobile && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
)}
<div className="absolute top-3 right-3 z-10">
<WishlistButton
item={{ id: item.id, name: item.name, price: item.price, image: item.image }}
size="sm"
/>
</div>
</div>
<div className="p-6 flex flex-col flex-1">
<div className="flex justify-between items-start gap-4 mb-4">
<h3 className="text-[22px] leading-tight tracking-[-0.4px] text-[#2C2A26]">
{item.name}
</h3>
{renderMenuPrice(item)}
</div>
{usesDetailModal ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openDishDetails(item);
}}
className="text-left text-[10px] text-[#8A8478] tracking-wide hover:text-[#B38B4D] hover:underline underline-offset-2 transition-colors mb-4"
>
{t.menu.seeDetails}
</button>
) : (
item.description && (
<p className="text-[#6B665F] text-[15px] leading-relaxed mb-4">
{getItemDescription(item)}
</p>
)
)}
<div className="mt-auto space-y-3">
{item.isVegetarian && (
<span className="inline-block text-xs px-3 py-1 rounded-full bg-[#3F5C4A]/10 text-[#3F5C4A] tracking-wider">
VEGETARIAN
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
addToCart(buildCartLineFromMenuItem(item));
}}
className="w-full py-3.5 text-sm tracking-[0.6px] border border-[#B38B4D] text-[#B38B4D] rounded-full hover:bg-[#B38B4D] hover:text-white active:bg-[#8C6B3A] active:text-white active:scale-[0.985] font-medium transition-all touch-manipulation"
>
{t.wishlistDrawer.addToCart}
</button>
</div>
</div>
</motion.div>
);
})}
</div>
</div>
))
)}
</div>
</div>
</div>
<Footer />
{detailItem && (
<DishDetailsModal
item={detailItem}
description={getItemDescription(detailItem)}
isOpen={!!detailItem}
onClose={() => setDetailItem(null)}
labels={{
ingredients: t.menu.ingredients,
close: t.menu.closeDetails,
}}
/>
)}
</div>
);
}