Files

583 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { gsap } from 'gsap';
import {
getMenuPosterCandidates,
getMenuPosterSrc,
getMenuVideoSources,
applyNextImageFallback,
} from '@/lib/assets';
import { menuCategories, allMenuItems, type MenuItem, type MenuCategory } from '@/lib/menu-data';
import { useLanguage } from '@/lib/language-context';
import { getTranslation } from '@/lib/translations';
import {
getMenuItemDescription,
getMenuItemName,
} from '@/application/i18n/menu-localization';
import type { Language } from '@/domain/language/entities';
interface ScreenDisplayProps {
screen: 1 | 2;
}
const SCREEN1_CATEGORY_IDS = ['street-food', 'vegetarian', 'meat', 'chicken', 'burger-sandwich'];
const SCREEN2_CATEGORY_IDS = ['pizza', 'naan-roll', 'sweets', 'drinks'];
function SpiceParticles() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let width = window.innerWidth;
let height = window.innerHeight;
canvas.width = width;
canvas.height = height;
const particles = Array.from({ length: 42 }, () => ({
x: Math.random() * width,
y: Math.random() * height * 0.9,
vx: (Math.random() - 0.5) * 0.22,
vy: -0.12 - Math.random() * 0.22,
size: 1.1 + Math.random() * 1.9,
alpha: 0.12 + Math.random() * 0.22,
phase: Math.random() * Math.PI * 2,
speed: 0.018 + Math.random() * 0.012,
}));
const onResize = () => {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
};
window.addEventListener('resize', onResize);
let rafId: number;
const draw = () => {
ctx.clearRect(0, 0, width, height);
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
p.x += p.vx + Math.sin(p.phase) * 0.07;
p.y += p.vy;
p.phase += p.speed;
if (p.y < -10) {
p.y = height * 0.92 + Math.random() * 40;
p.x = Math.random() * width;
}
if (p.x < 0) p.x = width;
if (p.x > width) p.x = 0;
ctx.fillStyle = `hsla(38, 68%, 52%, ${p.alpha})`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
// occasional tiny sparkle
if (Math.random() < 0.014) {
ctx.fillStyle = `hsla(42, 90%, 78%, ${p.alpha * 0.9})`;
ctx.fillRect(p.x - 0.6, p.y - 0.6, 1.2, 1.2);
}
}
rafId = requestAnimationFrame(draw);
};
draw();
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<canvas
ref={canvasRef}
className="fixed inset-0 pointer-events-none z-[2] opacity-70 mix-blend-multiply"
/>
);
}
function MenuCard({
item,
isHighlighted,
language,
t,
}: {
item: MenuItem;
isHighlighted?: boolean;
language: Language;
t: ReturnType<typeof getTranslation>;
}) {
const poster = getMenuPosterSrc(item, 'optimized');
const displayName = getMenuItemName(language, item);
const desc = getMenuItemDescription(language, t, item);
return (
<motion.div
className={`group relative flex flex-col overflow-hidden rounded-2xl border bg-white transition-all duration-300 ${
isHighlighted
? 'border-[#B38B4D] ring-2 ring-[#B38B4D]/70 shadow-xl scale-[1.015] z-10'
: 'border-[#EDE6D9] hover:border-[#B38B4D]/40'
}`}
animate={isHighlighted ? { scale: 1.015 } : { scale: 1 }}
transition={{ type: 'spring', stiffness: 220, damping: 26 }}
>
<div className="relative h-36 bg-[#F2EDE4] overflow-hidden">
<motion.img
src={poster}
alt={displayName}
className="absolute inset-0 w-full h-full object-cover"
animate={{ scale: [1, 1.018, 1] }}
transition={{ duration: 8.5, repeat: Infinity, ease: 'easeInOut' }}
onError={(e) => {
applyNextImageFallback(
e.currentTarget,
getMenuPosterCandidates(item),
e.currentTarget.src
);
}}
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/5 via-transparent to-black/35" />
{/* Gold price pill */}
<div className="absolute top-3 right-3 px-3 py-1 rounded-full bg-white/95 text-[#B38B4D] text-lg font-semibold tracking-tight shadow-sm border border-[#EDE6D9]">
{item.price} <span className="text-[10px] font-normal tracking-widest align-super">KR</span>
</div>
{item.isVegetarian && (
<div className="absolute top-3 left-3 text-[10px] px-2.5 py-0.5 rounded-full bg-[#3F5C4A]/90 text-white tracking-[1px]">
VEG
</div>
)}
</div>
<div className="p-3.5 flex-1 flex flex-col">
<div className="font-medium tracking-[-0.3px] text-[15px] leading-tight text-[#2C2A26] mb-1 line-clamp-1 group-hover:text-[#B38B4D] transition-colors">
{displayName}
</div>
<p className="text-[#6B665F] text-[12px] leading-snug line-clamp-2 flex-1">
{desc}
</p>
</div>
</motion.div>
);
}
export default function ScreenDisplay({ screen }: ScreenDisplayProps) {
const { language } = useLanguage();
const t = getTranslation(language);
const screenLabel = screen === 1 ? t.screen.display1 : t.screen.display2;
const isScreen1 = screen === 1;
// Filtered data for this screen
const screenCategories = useMemo(() => {
const ids = isScreen1 ? SCREEN1_CATEGORY_IDS : SCREEN2_CATEGORY_IDS;
return menuCategories.filter((c) => ids.includes(c.id));
}, [isScreen1]);
// Cycle through ALL items for this screen, one by one in the large hero (not just signatures)
const cyclingItems = useMemo(() => {
return screenCategories.flatMap((category) => category.items);
}, [screenCategories]);
// Cycling through all menu items (large top animation)
const [currentIndex, setCurrentIndex] = useState(0);
const current = cyclingItems[currentIndex] || cyclingItems[0];
const heroTiltRef = useRef<HTMLDivElement>(null);
// Auto cycle through every item (large top hero shows all, one by one)
const CYCLE_INTERVAL_MS = 8200;
useEffect(() => {
const id = setInterval(() => {
setCurrentIndex((i) => (i + 1) % cyclingItems.length);
}, CYCLE_INTERVAL_MS);
return () => clearInterval(id);
}, [cyclingItems.length]);
// Beautiful 3D tilt on hero (like signature cards on homepage)
useEffect(() => {
const el = heroTiltRef.current;
if (!el) return;
const onMouseMove = (e: MouseEvent) => {
const rect = el.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width - 0.5) * 2;
const y = ((e.clientY - rect.top) / rect.height - 0.5) * 2;
gsap.to(el, {
rotationY: x * 7,
rotationX: -y * 5.5,
transformPerspective: 1400,
duration: 0.36,
ease: 'power2.out',
overwrite: true,
});
};
const onMouseLeave = () => {
gsap.to(el, {
rotationY: 0,
rotationX: 0,
duration: 1.35,
ease: 'elastic.out(1, 0.48)',
});
};
el.addEventListener('mousemove', onMouseMove);
el.addEventListener('mouseleave', onMouseLeave);
return () => {
el.removeEventListener('mousemove', onMouseMove);
el.removeEventListener('mouseleave', onMouseLeave);
};
}, []);
// Professional kiosk mode for screen1/screen2:
// Force clean fullscreen-like window (no side scrollbars) even in F11 or kiosk browser.
// Applies to html/body so the window itself has no scrollbar.
// User zooms the browser in/out to change reel density (# visible items) instead of scrolling.
// Cleanup restores previous state (good if ever navigating away in dev).
useEffect(() => {
const htmlEl = document.documentElement;
const bodyEl = document.body;
const prevHtmlClass = htmlEl.className;
const prevBodyClass = bodyEl.className;
const prevHtmlOverflow = htmlEl.style.overflow;
const prevBodyOverflow = bodyEl.style.overflow;
htmlEl.classList.add('screen-kiosk');
bodyEl.classList.add('screen-kiosk');
htmlEl.style.overflow = 'hidden';
bodyEl.style.overflow = 'hidden';
return () => {
// restore
htmlEl.style.overflow = prevHtmlOverflow;
bodyEl.style.overflow = prevBodyOverflow;
// remove only if we added (simple: toggle off)
htmlEl.classList.remove('screen-kiosk');
bodyEl.classList.remove('screen-kiosk');
};
}, []);
const currentDisplayName = current ? getMenuItemName(language, current) : '';
const translatedDesc = current ? getMenuItemDescription(language, t, current) : '';
// =====================================================
// CONTINUOUS MENU REEL (one line, loop like a film reel) - items move LEFT
// Enter from RIGHT, exit LEFT. Number of visible items is dynamic (more when zoom out, fewer when zoom in).
// The reel viewport is always full width (edge to edge, no max-w), no side whitespace.
// Posters only (no videos) for seamless movement. Numbers for easy ordering (screen1:1-26, screen2:27+).
// CSS marquee for stable seamless poster reel.
// Card sizes fixed px for consistent distance viewing; # visible auto-adjusts to current container width on zoom.
// Sized large for 43" TV distance viewing in restaurant.
// =====================================================
const allMenuItemsForReel = screenCategories.flatMap((c) => c.items);
const numItems = allMenuItemsForReel.length;
const startNumber = isScreen1 ? 1 : 27;
// Duplicate for seamless infinite loop (scroll 50% to repeat perfectly)
const reelItems = [...allMenuItemsForReel, ...allMenuItemsForReel];
// Card width tuned for 43" screen (~1920px), 3 visible + gaps ~1872px at default zoom.
// Poster 320px + bottom ~70px (py-5 + one-line name+right price) so cards fill the ~390px reel band vertically.
const REEL_CARD_WIDTH = 620; // px
const REEL_CARD_GAP = 16; // px (Tailwind gap-4)
// ReelCard: large poster + number+name on left, price on right (opposite side, same line) so price is always clearly visible as cards move left in the reel. Sized big for 43" TV distance viewing.
function ReelCard({ item, number }: { item: MenuItem; number: number }) {
const numStr = String(number).padStart(2, '0');
const reelName = getMenuItemName(language, item);
return (
<div
className="flex-shrink-0 bg-white rounded-3xl overflow-hidden border border-[#EDE6D9] shadow-xl flex flex-col"
style={{ width: `${REEL_CARD_WIDTH}px`, transform: 'translateZ(0)', backfaceVisibility: 'hidden' }}
>
<div className="relative bg-[#F2EDE4]" style={{ height: '320px' }}>
<motion.img
src={getMenuPosterSrc(item, 'optimized')}
alt={reelName}
className="w-full h-full object-cover"
animate={{ scale: [1, 1.015, 1] }}
transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }}
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/5 to-black/25" />
{/* Prominent number badge for easy ordering by number e.g. "give me number 13" */}
<div className="absolute top-2 left-2 z-10 px-3.5 py-1 rounded-full bg-black/75 text-[#E8D4A3] text-3xl font-mono font-bold tracking-[3px] shadow-lg border border-[#E8D4A3]/40">
{numStr}
</div>
</div>
<div className="px-4 py-5 flex items-baseline justify-between gap-4">
<div className="font-serif text-3xl tracking-[-0.5px] leading-none text-[#2C2A26]">
{numStr}. {reelName}
</div>
<div className="text-[#B38B4D] text-2xl font-semibold tracking-tight whitespace-nowrap">
{item.price} kr
</div>
</div>
</div>
);
}
return (
<div className="h-screen bg-[#F8F5F0] text-[#2C2A26] overflow-hidden select-none flex flex-col screen-root">
<SpiceParticles />
{/* Elegant top bar */}
<div className="relative z-20 h-14 border-b border-[#EDE6D9]/70 bg-[#F8F5F0]/95 backdrop-blur-md flex items-center px-9 text-sm flex-none">
<div className="flex items-center gap-3">
<div className="font-serif text-2xl tracking-[-1.5px] text-[#2C2A26]">SHAHI KITCHEN</div>
<div className="text-[10px] px-2.5 py-px rounded-full border border-[#B38B4D]/50 text-[#B38B4D] tracking-[2.5px] font-medium">
{screenLabel}
</div>
</div>
<div className="flex-1" />
<div className="flex items-center gap-6 text-[#6B665F] tracking-[1px] text-xs">
<div>ASKIM BACKAPLAN</div>
<div className="h-2.5 w-px bg-[#EDE6D9]" />
<div>{t.screen.prepared.toUpperCase()}</div>
</div>
</div>
{/* HERO — Large cycling animation showing ALL items one by one (not just signatures).
flex-1 so it absorbs extra viewport height on tall displays / when zoomed out.
On short effective viewports (zoomed in) it shrinks gracefully; text sized for fit. */}
<div className="flex-1 min-h-[260px] relative z-10 bg-[#101724] flex items-center justify-center overflow-hidden">
<div
ref={heroTiltRef}
className="absolute inset-0 will-change-transform"
style={{ transformStyle: 'preserve-3d' }}
>
{/* Media switches with crossfade + fresh start each cycle. Video when available, else large animated poster (Ken Burns). */}
<AnimatePresence mode="wait">
{current && (
current.video ? (
<motion.div
key={`${current.id}-video`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.45 }}
className="absolute inset-0"
>
<video
className="absolute inset-0 w-full h-full object-cover"
autoPlay
muted
loop
playsInline
preload="auto"
>
{(() => {
const srcs = getMenuVideoSources(current.video);
return (
<>
{srcs.webm && <source src={srcs.webm} type="video/webm" />}
{srcs.mp4 && <source src={srcs.mp4} type="video/mp4" />}
{srcs.fallback && <source src={srcs.fallback} type="video/mp4" />}
</>
);
})()}
</video>
</motion.div>
) : (
<motion.img
key={`${current.id}-poster`}
src={getMenuPosterSrc(current, 'optimized')}
alt={currentDisplayName}
className="absolute inset-0 w-full h-full object-cover"
initial={{ opacity: 0, scale: 1 }}
animate={{ opacity: 1, scale: [1, 1.06, 1] }}
exit={{ opacity: 0 }}
transition={{ opacity: { duration: 0.45 }, scale: { duration: 8, repeat: Infinity, ease: 'easeInOut' } }}
onError={(e) => {
applyNextImageFallback(
e.currentTarget,
getMenuPosterCandidates(current),
e.currentTarget.src
);
}}
/>
)
)}
</AnimatePresence>
{/* Rich overlays for text legibility + luxury feel (stay on top of changing media) */}
<div className="absolute inset-0 bg-gradient-to-b from-black/25 via-black/15 to-black/55" />
<div className="absolute inset-0 bg-[radial-gradient(#B38B4D_0.6px,transparent_1px)] bg-[length:5px_5px] opacity-[0.035]" />
</div>
{/* Animated dish info */}
<div className="relative z-10 max-w-4xl px-10 text-white">
<AnimatePresence mode="wait">
{current && (
<motion.div
key={current.id}
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -14 }}
transition={{ duration: 0.55, ease: [0.21, 0.92, 0.26, 1] }}
>
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-1 text-xs tracking-[3.5px] mb-4 border border-white/20">
{t.screen.menuTitle}
</div>
<h1 className="font-serif text-[56px] md:text-[64px] leading-[0.88] tracking-[-3.2px] mb-2 text-[#B38B4D] drop-shadow-[0_0_10px_rgba(179,139,77,0.85)]">
{currentDisplayName}
</h1>
<div className="flex items-baseline gap-2 mb-4">
<div className="text-5xl font-medium tracking-[-1.5px] text-[#E8D4A3]">
{current.price}
</div>
<div className="text-2xl text-white/70 tracking-[1px]">KR</div>
</div>
<p className="max-w-[620px] text-[17px] leading-tight text-white/90 tracking-[-0.1px] drop-shadow">
{translatedDesc}
</p>
<div className="mt-5 text-[11px] tracking-[3px] text-white/60">
{t.screen.prepared}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Cycle progress + counter (clean for many items) */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 text-white/70 text-xs tracking-[2px]">
<div>{currentIndex + 1} / {cyclingItems.length}</div>
<div className="w-32 h-px bg-white/20 rounded overflow-hidden">
<motion.div
key={currentIndex}
className="h-px bg-[#E8D4A3]"
initial={{ width: '0%' }}
animate={{ width: '100%' }}
transition={{ duration: CYCLE_INTERVAL_MS / 1000, ease: 'linear' }}
/>
</div>
</div>
</div>
{/* MENU REEL - continuous LEFT-moving reel (items move left: enter from right, exit left), entire menu in one line.
Dynamic # of items visible based on zoom (more when zoomed out). Always full width edge-to-edge (no side whitespace).
~8s per item, seamless loop with posters only. Fixed band height so layout sums <=100vh with flex-1 hero. */}
<div className="relative z-10 bg-[#F8F5F0] border-t border-[#EDE6D9]/60 overflow-hidden w-full flex-none">
<div className="max-w-[1680px] mx-auto px-4 py-1.5">
<div className="text-[#B38B4D] text-xs tracking-[4px] font-medium mb-1">THE MENU REEL</div>
</div>
{/* Full-width viewport for the reel; cards will fill based on container width. Height tuned to match card (poster + bottom text area) so items fill the band vertically with no large empty space under as they rotate left. */}
<div style={{ height: '390px' }} className="overflow-hidden w-full">
<div
className="menu-reel-track flex gap-4"
style={{
width: `${reelItems.length * (REEL_CARD_WIDTH + REEL_CARD_GAP)}px`,
animationDuration: `${numItems * 8}s`
}}
>
{reelItems.map((item, index) => {
const displayNumber = ((index % numItems) + startNumber);
return (
<ReelCard key={`${item.id}-${index}`} item={item} number={displayNumber} />
);
})}
</div>
</div>
</div>
{/* COMBO MEALS & DISCOUNT OFFERS - compact vertical footprint so total page height always <= 100vh in fullscreen (no scrollbar) */}
<div className="relative z-10 bg-[#F8F5F0] py-3 border-t border-[#EDE6D9]/60 flex-none">
<div className="max-w-[1680px] mx-auto px-8">
<div className="flex items-center gap-4 mb-3">
<div className="text-[#B38B4D] text-xs tracking-[3.5px] font-medium">COMBO MEALS & OFFERS</div>
<div className="flex-1 h-px bg-[#B38B4D]/25" />
<div className="text-xs text-[#6B665F]">Ask at counter by offer number</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Offer 1 */}
<div className="bg-white rounded-3xl border border-[#EDE6D9] shadow-xl p-4 flex flex-col">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[#B38B4D] text-xs tracking-[2px] font-semibold">OFFER 1</div>
<div className="text-[10px] px-2 py-0.5 rounded-full bg-[#B38B4D] text-white tracking-wider">BEST VALUE</div>
</div>
<div className="font-serif text-xl tracking-tight text-[#101724] leading-tight">SHAHI FAMILY COMBO</div>
<div className="text-xs text-[#6B665F] mt-0.5 flex-1">2 Chicken Curries + 2 Veg Curries + 4 Naan + 2 Rice + 4 Drinks</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-semibold text-[#B38B4D]">799 kr</span>
<span className="text-base line-through text-[#8A8478]">999 kr</span>
</div>
<div className="text-[10px] text-[#B38B4D] font-medium">SAVE 200 KR Serves 4</div>
</div>
{/* Offer 2 */}
<div className="bg-white rounded-3xl border border-[#EDE6D9] shadow-xl p-4 flex flex-col">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[#B38B4D] text-xs tracking-[2px] font-semibold">OFFER 2</div>
</div>
<div className="font-serif text-xl tracking-tight text-[#101724] leading-tight">LUNCH THALI SPECIAL</div>
<div className="text-xs text-[#6B665F] mt-0.5 flex-1">Any Curry + Rice + 2 Naan + Salad + Drink (11am3pm)</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-semibold text-[#B38B4D]">149 kr</span>
<span className="text-base line-through text-[#8A8478]">189 kr</span>
</div>
<div className="text-[10px] text-[#B38B4D] font-medium">SAVE 40 KR MonFri only</div>
</div>
{/* Offer 3 */}
<div className="bg-white rounded-3xl border border-[#EDE6D9] shadow-xl p-4 flex flex-col">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[#B38B4D] text-xs tracking-[2px] font-semibold">OFFER 3</div>
</div>
<div className="font-serif text-xl tracking-tight text-[#101724] leading-tight">PIZZA & ROLL DEAL</div>
<div className="text-xs text-[#6B665F] mt-0.5 flex-1">Any 2 Pizzas + Any 2 Rolls + 2 Drinks</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-semibold text-[#B38B4D]">229 kr</span>
<span className="text-base line-through text-[#8A8478]">299 kr</span>
</div>
<div className="text-[10px] text-[#B38B4D] font-medium">SAVE 70 KR Great for sharing</div>
</div>
{/* Offer 4 */}
<div className="bg-white rounded-3xl border border-[#EDE6D9] shadow-xl p-4 flex flex-col">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[#B38B4D] text-xs tracking-[2px] font-semibold">OFFER 4</div>
<div className="text-[10px] px-2 py-0.5 rounded-full bg-[#3F5C4A] text-white tracking-wider">VEG</div>
</div>
<div className="font-serif text-xl tracking-tight text-[#101724] leading-tight">VEG FEAST FOR 4</div>
<div className="text-xs text-[#6B665F] mt-0.5 flex-1">4 Veg Curries + 4 Naan + 2 Rice + 4 Drinks</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-semibold text-[#B38B4D]">599 kr</span>
<span className="text-base line-through text-[#8A8478]">749 kr</span>
</div>
<div className="text-[10px] text-[#B38B4D] font-medium">SAVE 150 KR Pure veg delight</div>
</div>
</div>
</div>
</div>
{/* Very subtle footer branding */}
<div className="relative z-10 py-2 text-center text-[10px] tracking-[2.5px] text-[#8A8478] border-t border-[#EDE6D9]/60 bg-[#F8F5F0] flex-none">
SHAHI KITCHEN GOTHENBURG FRESH EVERY DAY
</div>
</div>
);
}