Files
shahikitchen/app/menu/page.tsx
T
Zeeshan Khan ffaef59049 feat: mobile menu video auto-play on scroll pause (2s)
- On mobile (<768px): posters shown while scrolling the menu.
- After user stops scrolling and lingers on a video item for ~2s: the animation (video) plays in place of the poster (replaces img opacity, plays video, hides gradient).
- On scroll/touchmove resume: immediately pause videos and show posters.
- Initial load on mobile: auto-plays the first focused video item after settle.
- Added isMobile state + resize listener, scroll/touch listeners with 2s debounce timeout.
- Helper functions: playMobileVideo(id), pauseAllMobileVideos(), findFocusedVideoItem() (visibility + center scoring).
- Data attributes data-id and data-has-video added to .menu-card for reliable targeting.
- Desktop hover logic untouched.
- Only affects items with .video in menu-data (e.g. Lahore Chana now works on mobile too).
- Cleanup on unmount/resize to desktop.
- Builds cleanly.
2026-06-02 15:24:47 +02:00

418 lines
18 KiB
TypeScript

"use client";
/**
* MENU PAGE — Premium Sidebar Navigation
*/
import { useEffect, useState, useMemo } from "react";
import { menuCategories } from "@/lib/menu-data";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
import { useCart } from "@/components/CartContext";
import { useLanguage } from "@/lib/language-context";
import { getTranslation } from "@/lib/translations";
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 { 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" });
};
// Mobile video auto-play on scroll pause (2s)
useEffect(() => {
const updateMobile = () => setIsMobile(window.innerWidth < 768);
updateMobile();
window.addEventListener("resize", updateMobile);
return () => window.removeEventListener("resize", updateMobile);
}, []);
const playMobileVideo = (id: string) => {
document.querySelectorAll<HTMLElement>(".menu-card").forEach((card) => {
if (card.dataset.id !== id) return;
const media = card.querySelector<HTMLElement>(".relative.h-52");
if (!media) return;
const img = media.querySelector<HTMLImageElement>("img");
const video = media.querySelector<HTMLVideoElement>("video");
const gradient = media.querySelector<HTMLElement>(".absolute.inset-0.bg-gradient-to-b");
if (img) img.style.opacity = "0";
if (video) {
video.style.opacity = "1";
video.play().catch(() => {});
}
if (gradient) gradient.style.opacity = "0";
});
};
const pauseAllMobileVideos = () => {
document.querySelectorAll<HTMLElement>(".menu-card").forEach((card) => {
const media = card.querySelector<HTMLElement>(".relative.h-52");
if (!media) return;
const img = media.querySelector<HTMLImageElement>("img");
const video = media.querySelector<HTMLVideoElement>("video");
const gradient = media.querySelector<HTMLElement>(".absolute.inset-0.bg-gradient-to-b");
if (img) img.style.opacity = "";
if (video) {
video.style.opacity = "0";
video.pause();
video.currentTime = 0;
}
if (gradient) gradient.style.opacity = "";
});
};
const findFocusedVideoItem = (): string | null => {
const cards = document.querySelectorAll<HTMLElement>('.menu-card[data-has-video="true"]');
let bestId: string | null = null;
let bestScore = 0;
const vh = window.innerHeight;
const viewportCenter = vh / 2;
cards.forEach((card) => {
const rect = card.getBoundingClientRect();
if (rect.bottom <= 0 || rect.top >= vh) return;
const visibleTop = Math.max(rect.top, 0);
const visibleBottom = Math.min(rect.bottom, vh);
const visibleHeight = visibleBottom - visibleTop;
const intersectionRatio = visibleHeight / rect.height;
const cardCenter = rect.top + rect.height / 2;
const distFromCenter = Math.abs(cardCenter - viewportCenter);
const centerScore = Math.max(0, 1 - distFromCenter / (vh / 2));
const score = intersectionRatio * 0.6 + centerScore * 0.4;
if (score > bestScore && score > 0.15) {
bestScore = score;
bestId = card.dataset.id || null;
}
});
return bestId;
};
useEffect(() => {
if (!isMobile) {
pauseAllMobileVideos();
return;
}
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
const onScrollOrTouch = () => {
pauseAllMobileVideos();
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const focusedId = findFocusedVideoItem();
if (focusedId) {
playMobileVideo(focusedId);
}
}, 2000);
};
window.addEventListener("scroll", onScrollOrTouch, { passive: true });
window.addEventListener("touchmove", onScrollOrTouch, { passive: true });
// Initial play after load on mobile (if not scrolling)
const initialTimeout = setTimeout(() => {
const firstFocused = findFocusedVideoItem();
if (firstFocused) {
playMobileVideo(firstFocused);
}
}, 1500);
return () => {
window.removeEventListener("scroll", onScrollOrTouch);
window.removeEventListener("touchmove", onScrollOrTouch);
if (scrollTimeout) clearTimeout(scrollTimeout);
clearTimeout(initialTimeout);
};
}, [isMobile]);
return (
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
<Navbar />
{/* Page Header */}
<div className="max-w-7xl mx-auto px-6 pt-14 pb-10">
<div className="max-w-3xl">
<div className="text-[#B38B4D] text-xs tracking-[3.5px] mb-4 font-medium">AUTHENTIC GENEROUS ROYAL</div>
<h1 className="text-6xl md:text-7xl tracking-[-2.8px] leading-none mb-5 text-[#101724]">{t.menu.title}</h1>
<p className="text-2xl text-[#4b5563] max-w-2xl">
{t.menu.subtitle}
</p>
</div>
</div>
{/* 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-24">
<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>
{/* Modern mobile-first category menu: horizontal scroller on phones/tablets, vertical sidebar on desktop */}
<div className="flex lg:flex-col gap-2 overflow-x-auto pb-3 lg:pb-0 pr-1 lg:pr-4 snap-x snap-mandatory [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{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}
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>
</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) => (
<div
key={item.id}
data-id={item.id}
data-has-video={!!item.video}
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 cursor-pointer touch-manipulation"
onClick={() => addToCart({ id: item.id, name: item.name, price: item.price, image: item.image })}
>
{/* Media - Restored Premium Video Hover + Poster Logic */}
{item.video ? (
<div
className="relative h-52 overflow-hidden bg-[#F2EDE4] cursor-pointer"
onMouseEnter={(e) => {
const video = e.currentTarget.querySelector("video");
if (video) video.play().catch(() => {});
}}
onMouseLeave={(e) => {
const video = e.currentTarget.querySelector("video");
if (video) {
video.pause();
video.currentTime = 0;
}
}}
onClick={() => addToCart({ id: item.id, name: item.name, price: item.price, image: item.image })}
>
{/* Static Poster (first frame) */}
<img
src={`/images/dishes/${item.video.replace(".mp4", "-poster.jpg")}`}
alt={item.name}
className="absolute inset-0 w-full h-full object-cover group-hover:opacity-0 transition-opacity duration-150"
loading="lazy"
onError={(e) => {
// Fallback poster logic
const base = (item.video || item.image || "").replace(".mp4", "").replace(/-optimized/g, "");
const fallbacks = [
`/images/dishes/${base}-poster.jpg`,
`/images/dishes/${base}-optimized-poster.jpg`,
`/images/dishes/${item.image}`,
].filter(Boolean);
let i = 0;
const tryNext = () => {
if (i < fallbacks.length) {
(e.target as HTMLImageElement).src = fallbacks[i++];
}
};
tryNext();
}}
/>
{/* Video on Hover - robust source selection with optimized fallbacks */}
<video
muted
loop
playsInline
preload="metadata"
className="absolute inset-0 w-full h-full object-cover opacity-0 group-hover:opacity-100 transition-opacity duration-150"
>
{/* Optimized variants first (smaller, better quality) */}
<source
src={`/videos/${item.video.replace(".mp4", "-optimized.webm")}`}
type="video/webm"
/>
<source
src={`/videos/${item.video.replace(".mp4", "-optimized.mp4")}`}
type="video/mp4"
/>
{/* Base variants as fallback */}
<source
src={`/videos/${item.video.replace(".mp4", ".webm")}`}
type="video/webm"
/>
<source src={`/videos/${item.video}`} type="video/mp4" />
</video>
<div className="absolute inset-0 bg-gradient-to-b from-black/5 via-black/10 to-black/25 group-hover:opacity-0 transition-opacity" />
</div>
) : (
/* Static Image for items without video */
<div
className="relative h-52 overflow-hidden bg-[#F2EDE4] cursor-pointer"
onClick={() => addToCart({ id: item.id, name: item.name, price: item.price, image: item.image })}
>
<img
src={`/images/dishes/${item.image}`}
alt={item.name}
className="w-full h-full object-cover group-hover:scale-[1.03] transition-transform duration-700"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/5 via-black/10 to-black/25" />
</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>
<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>
</div>
{item.description && (
<p className="text-[#6B665F] text-[15px] leading-relaxed mb-5">
{(t as any).menuDescriptions?.[item.id] || item.description}
</p>
)}
<div className="mt-auto">
{item.isVegetarian && (
<span className="text-xs px-3 py-1 rounded-full bg-[#3F5C4A]/10 text-[#3F5C4A] tracking-wider">
VEGETARIAN
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
))
)}
</div>
</div>
</div>
<Footer />
</div>
);
}