Replace entire repo content with kottgard-production-v1.1.zip (for shahikitchen-prod repo)
This commit is contained in:
@@ -1,320 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { OrbitControls, Environment } from '@react-three/drei';
|
||||
import { Suspense, useMemo, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
// ================== SMOKE PARTICLES ==================
|
||||
function SmokeParticles({ count = 120 }: { count?: number }) {
|
||||
const pointsRef = useRef<THREE.Points>(null!);
|
||||
|
||||
const particles = useMemo(() => {
|
||||
const positions = new Float32Array(count * 3);
|
||||
const velocities = new Float32Array(count * 3);
|
||||
const ages = new Float32Array(count);
|
||||
const sizes = new Float32Array(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
|
||||
// Start around the top of the gravy
|
||||
positions[i3 + 0] = (Math.random() - 0.5) * 2.2;
|
||||
positions[i3 + 1] = 0.6 + Math.random() * 0.3;
|
||||
positions[i3 + 2] = (Math.random() - 0.5) * 2.0;
|
||||
|
||||
velocities[i3 + 0] = (Math.random() - 0.5) * 0.008;
|
||||
velocities[i3 + 1] = 0.012 + Math.random() * 0.018;
|
||||
velocities[i3 + 2] = (Math.random() - 0.5) * 0.008;
|
||||
|
||||
ages[i] = Math.random() * 3.5;
|
||||
sizes[i] = 0.12 + Math.random() * 0.18;
|
||||
}
|
||||
|
||||
return { positions, velocities, ages, sizes };
|
||||
}, [count]);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
const points = pointsRef.current;
|
||||
if (!points) return;
|
||||
|
||||
const pos = points.geometry.attributes.position as THREE.BufferAttribute;
|
||||
const posArray = pos.array as Float32Array;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
|
||||
// Age and reset
|
||||
particles.ages[i] += delta * 0.9;
|
||||
|
||||
if (particles.ages[i] > 3.8) {
|
||||
// Respawn at the top of the gravy
|
||||
particles.ages[i] = 0;
|
||||
posArray[i3 + 0] = (Math.random() - 0.5) * 2.1;
|
||||
posArray[i3 + 1] = 0.55 + Math.random() * 0.15;
|
||||
posArray[i3 + 2] = (Math.random() - 0.5) * 1.9;
|
||||
particles.velocities[i3 + 0] = (Math.random() - 0.5) * 0.009;
|
||||
particles.velocities[i3 + 1] = 0.014 + Math.random() * 0.02;
|
||||
} else {
|
||||
// Rise with some turbulence
|
||||
posArray[i3 + 0] += particles.velocities[i3 + 0] + Math.sin(state.clock.elapsedTime * 1.5 + i) * 0.002;
|
||||
posArray[i3 + 1] += particles.velocities[i3 + 1];
|
||||
posArray[i3 + 2] += particles.velocities[i3 + 2] + Math.cos(state.clock.elapsedTime * 1.2 + i) * 0.002;
|
||||
|
||||
// Slow down as it rises
|
||||
particles.velocities[i3 + 1] *= 0.992;
|
||||
}
|
||||
}
|
||||
|
||||
pos.needsUpdate = true;
|
||||
});
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(particles.positions, 3));
|
||||
return geo;
|
||||
}, [particles.positions]);
|
||||
|
||||
return (
|
||||
<points ref={pointsRef} geometry={geometry}>
|
||||
<pointsMaterial
|
||||
size={0.22}
|
||||
color="#f4e9d8"
|
||||
transparent
|
||||
opacity={0.32}
|
||||
depthWrite={false}
|
||||
sizeAttenuation={true}
|
||||
/>
|
||||
</points>
|
||||
);
|
||||
}
|
||||
|
||||
// ================== BOWL ==================
|
||||
function Bowl() {
|
||||
return (
|
||||
<group>
|
||||
{/* Outer bowl */}
|
||||
<mesh position={[0, -0.25, 0]} castShadow receiveShadow>
|
||||
<cylinderGeometry args={[2.35, 1.95, 1.95, 72, 1, true]} />
|
||||
<meshPhongMaterial
|
||||
color="#2f2723"
|
||||
shininess={45}
|
||||
specular="#3a2f2a"
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Inner bowl wall */}
|
||||
<mesh position={[0, -0.25, 0]} castShadow receiveShadow>
|
||||
<cylinderGeometry args={[2.1, 1.72, 1.85, 72, 1, true]} />
|
||||
<meshPhongMaterial
|
||||
color="#1f1a17"
|
||||
shininess={25}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Bowl bottom inside */}
|
||||
<mesh position={[0, -1.15, 0]} receiveShadow>
|
||||
<cylinderGeometry args={[1.72, 1.72, 0.12, 72]} />
|
||||
<meshPhongMaterial color="#1f1a17" />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ================== GRAVY ==================
|
||||
function Gravy() {
|
||||
return (
|
||||
<group>
|
||||
{/* Main thick gravy */}
|
||||
<mesh position={[0, 0.05, 0]} receiveShadow>
|
||||
<cylinderGeometry args={[2.0, 1.65, 1.35, 72]} />
|
||||
<meshPhongMaterial
|
||||
color="#d46f2e"
|
||||
shininess={95}
|
||||
specular="#ffe8c4"
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Creamy top layer */}
|
||||
<mesh position={[0, 0.55, 0]}>
|
||||
<cylinderGeometry args={[1.92, 1.62, 0.42, 72]} />
|
||||
<meshPhongMaterial
|
||||
color="#f0a45f"
|
||||
shininess={120}
|
||||
specular="#fff4d9"
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Glossy highlights layer */}
|
||||
<mesh position={[0, 0.68, 0]}>
|
||||
<cylinderGeometry args={[1.78, 1.55, 0.18, 72]} />
|
||||
<meshPhongMaterial
|
||||
color="#f8c48a"
|
||||
shininess={140}
|
||||
specular="#ffffff"
|
||||
transparent
|
||||
opacity={0.65}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ================== CHICKEN PIECES ==================
|
||||
function ChickenPieces() {
|
||||
const pieces = [
|
||||
{ pos: [0.6, 0.45, 0.1], rot: [0.6, 1.2, 0.3], scale: 1.0 },
|
||||
{ pos: [-0.75, 0.38, 0.55], rot: [-0.4, -0.9, 0.5], scale: 0.95 },
|
||||
{ pos: [0.15, 0.52, -0.85], rot: [0.9, 0.4, -0.6], scale: 1.05 },
|
||||
{ pos: [-0.55, 0.42, -0.4], rot: [-0.7, 1.6, 0.2], scale: 0.9 },
|
||||
{ pos: [0.9, 0.35, -0.55], rot: [0.3, -1.1, -0.4], scale: 0.98 },
|
||||
{ pos: [-0.2, 0.48, 0.75], rot: [-0.5, 0.7, 0.8], scale: 0.92 },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{pieces.map((p, i) => (
|
||||
<group key={i} position={p.pos as [number, number, number]} rotation={p.rot as [number, number, number]} scale={p.scale}>
|
||||
{/* Main chicken body */}
|
||||
<mesh castShadow>
|
||||
<capsuleGeometry args={[0.42, 0.65, 8]} />
|
||||
<meshPhongMaterial
|
||||
color="#b84f25"
|
||||
shininess={55}
|
||||
specular="#3a1f14"
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Extra volume */}
|
||||
<mesh position={[0.12, 0.08, -0.1]} castShadow>
|
||||
<sphereGeometry args={[0.38]} />
|
||||
<meshPhongMaterial color="#a64520" shininess={40} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ================== HERBS & SPICES ==================
|
||||
function Toppings() {
|
||||
return (
|
||||
<>
|
||||
{/* Green herbs */}
|
||||
{Array.from({ length: 18 }).map((_, i) => {
|
||||
const angle = i * 0.7 + (i % 3) * 0.3;
|
||||
const radius = 0.55 + (i % 4) * 0.22;
|
||||
return (
|
||||
<mesh
|
||||
key={`herb-${i}`}
|
||||
position={[
|
||||
Math.cos(angle) * radius,
|
||||
0.82,
|
||||
Math.sin(angle) * radius * 0.9
|
||||
]}
|
||||
rotation={[Math.random() - 0.5, i, Math.random() - 0.5]}
|
||||
>
|
||||
<planeGeometry args={[0.22, 0.09]} />
|
||||
<meshPhongMaterial
|
||||
color="#2d5c3f"
|
||||
side={THREE.DoubleSide}
|
||||
transparent
|
||||
opacity={0.85}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Small spice bits */}
|
||||
{Array.from({ length: 26 }).map((_, i) => (
|
||||
<mesh
|
||||
key={`spice-${i}`}
|
||||
position={[
|
||||
(Math.random() - 0.5) * 2.6,
|
||||
0.78 + Math.random() * 0.12,
|
||||
(Math.random() - 0.5) * 2.3
|
||||
]}
|
||||
>
|
||||
<sphereGeometry args={[0.035 + Math.random() * 0.025]} />
|
||||
<meshPhongMaterial color={i % 4 === 0 ? "#8c3a1f" : "#3a2a1f"} />
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ================== MAIN MODEL ==================
|
||||
function ButterChickenModel() {
|
||||
return (
|
||||
<group>
|
||||
<Bowl />
|
||||
<Gravy />
|
||||
<ChickenPieces />
|
||||
<Toppings />
|
||||
|
||||
{/* Rising Smoke */}
|
||||
<SmokeParticles count={95} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ================== MAIN EXPORT ==================
|
||||
export default function ButterChicken3D() {
|
||||
return (
|
||||
<div className="w-full h-full min-h-[420px] rounded-2xl overflow-hidden bg-[#F2EDE4] relative">
|
||||
<Canvas
|
||||
camera={{ position: [0, 3.8, 8.5], fov: 40 }}
|
||||
style={{ background: 'transparent' }}
|
||||
gl={{
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
toneMapping: THREE.ACESFilmicToneMapping,
|
||||
toneMappingExposure: 1.05,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
{/* Warm restaurant lighting */}
|
||||
<ambientLight intensity={0.28} color="#fff4e6" />
|
||||
|
||||
<directionalLight
|
||||
position={[7, 13, 5]}
|
||||
intensity={1.85}
|
||||
color="#fff0d0"
|
||||
castShadow
|
||||
/>
|
||||
|
||||
<directionalLight
|
||||
position={[-7, 5, -8]}
|
||||
intensity={0.95}
|
||||
color="#ffe8c4"
|
||||
/>
|
||||
|
||||
<pointLight position={[-3, 4, 7]} intensity={0.7} color="#fff8e7" />
|
||||
|
||||
<ButterChickenModel />
|
||||
|
||||
<Environment preset="apartment" />
|
||||
</Suspense>
|
||||
|
||||
<OrbitControls
|
||||
enablePan={false}
|
||||
enableZoom={true}
|
||||
minDistance={4.5}
|
||||
maxDistance={13}
|
||||
minPolarAngle={Math.PI * 0.18}
|
||||
maxPolarAngle={Math.PI * 0.82}
|
||||
enableDamping
|
||||
dampingFactor={0.12}
|
||||
autoRotate={false}
|
||||
/>
|
||||
</Canvas>
|
||||
|
||||
<div className="absolute bottom-3 right-3 text-[10px] text-[#8A8478] tracking-widest pointer-events-none">
|
||||
DRAG TO ROTATE • SCROLL TO ZOOM
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* @deprecated Import from `@/presentation/providers/cart-provider`.
|
||||
* Kept for backward compatibility during Clean Architecture migration.
|
||||
*/
|
||||
export {
|
||||
CartProvider,
|
||||
useCart,
|
||||
type CartItem,
|
||||
} from '@/presentation/providers/cart-provider';
|
||||
@@ -1,230 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* =============================================================================
|
||||
* CART DRAWER (SLIDE-IN BASKET PANEL)
|
||||
* =============================================================================
|
||||
*
|
||||
* This is the visual "basket" that slides in from the right when the user
|
||||
* clicks the cart icon in the navbar or the "View Cart" toast action.
|
||||
*
|
||||
* KEY DESIGN CHOICES:
|
||||
* - Fixed position, full-height, max-w-md (beautiful on both mobile and desktop)
|
||||
* - Backdrop click closes it (standard mobile pattern)
|
||||
* - z-[990] sits above almost everything (including the sticky category nav) (below mobile menu)
|
||||
* - All cart mutations go through the context — this component is "dumb" UI only
|
||||
*
|
||||
* THE ORDERING FLOW (very important for restaurant context):
|
||||
* Instead of a traditional Stripe checkout, we generate a pre-filled WhatsApp
|
||||
* message containing every line item + quantities + grand total.
|
||||
* This matches the restaurant's current real-world ordering process.
|
||||
* The number 46739381089 is the same one used in the homepage reservation form.
|
||||
*
|
||||
* FUTURE ENHANCEMENTS (documented here so nothing is forgotten):
|
||||
* - Add "Special requests" textarea per order
|
||||
* - Show estimated preparation time
|
||||
* - Allow "Order for later" date/time picker
|
||||
* - Split "Pickup" vs "Delivery" with different messaging
|
||||
* - After WhatsApp opens, optionally clear the cart (or keep it — current choice)
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { buildCartWhatsAppMessage } from '@/application/messaging/whatsapp-message-builder';
|
||||
import {
|
||||
calculateLineTotal,
|
||||
formatLineQuantityLabel,
|
||||
} from '@/domain/shared/order-line';
|
||||
import { RESTAURANT_CONTACT } from '@/domain/shared/constants';
|
||||
import { container } from '@/infrastructure/di/container';
|
||||
import { useCart } from '@/presentation/providers/cart-provider';
|
||||
import { useLanguage } from '@/presentation/providers/language-provider';
|
||||
import { getTranslation } from '@/presentation/i18n/translations';
|
||||
|
||||
export default function CartDrawer() {
|
||||
const {
|
||||
items,
|
||||
isOpen,
|
||||
closeCart,
|
||||
totalPrice,
|
||||
removeFromCart,
|
||||
updateQuantity,
|
||||
clearCart
|
||||
} = useCart();
|
||||
|
||||
const { language } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
|
||||
/**
|
||||
* WHATSAPP DEEP LINK ORDERING
|
||||
*
|
||||
* Builds a human-readable, copy-paste friendly message that the restaurant staff
|
||||
* can immediately understand and action.
|
||||
*
|
||||
* The format deliberately mirrors how the restaurant currently receives orders
|
||||
* over the phone or via Instagram DMs.
|
||||
*/
|
||||
const orderViaWhatsApp = () => {
|
||||
const message = buildCartWhatsAppMessage(items, t.cartDrawer, language);
|
||||
if (!message) return;
|
||||
container.messagingGateway.openWhatsApp(message);
|
||||
};
|
||||
|
||||
// Guard clause — drawer only renders when explicitly opened via context
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* SEMI-TRANSPARENT BACKDROP */}
|
||||
{/* Clicking anywhere outside the drawer closes it (standard mobile pattern) */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-[980]"
|
||||
onClick={closeCart}
|
||||
/>
|
||||
|
||||
{/* THE ACTUAL DRAWER — slides in from right */}
|
||||
{/* z-[990] ensures it sits above sticky nav, category pills, and most other UI (below mobile menu) */}
|
||||
<div className="fixed top-0 right-0 h-full w-full max-w-md bg-[#F8F5F0] z-[990] shadow-2xl flex flex-col">
|
||||
{/* HEADER — Title + item count + quick clear + close button */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[#EDE6D9]">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl tracking-[-0.5px]">{t.cartDrawer.title}</h2>
|
||||
{items.length > 0 && (
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-[#EDE6D9] text-[#6B665F]">
|
||||
{items.length} {items.length === 1 ? t.cartDrawer.item : t.cartDrawer.items}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
onClick={clearCart}
|
||||
className="text-xs text-[#B38B4D] hover:underline"
|
||||
>
|
||||
{t.cartDrawer.clear}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={closeCart}
|
||||
className="text-[#6B665F] hover:text-[#2C2A26] text-2xl leading-none pl-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SCROLLABLE CONTENT AREA */}
|
||||
{/* Empty state is friendly and routes the user back to the menu */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="text-6xl mb-4">🛒</div>
|
||||
<p className="text-lg text-[#6B665F]">{t.cartDrawer.empty}</p>
|
||||
<Link
|
||||
href="/menu"
|
||||
onClick={closeCart}
|
||||
className="mt-6 text-[#B38B4D] hover:underline"
|
||||
>
|
||||
{t.cartDrawer.browseMenu}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex gap-4 border-b border-[#EDE6D9] pb-6">
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium tracking-[-0.3px]">{item.name}</h4>
|
||||
<p className="text-sm text-[#6B665F]">
|
||||
{item.pricingMode === 'weight'
|
||||
? `${item.pricePerHalfKg ?? item.price} kr / ½ kg`
|
||||
: `${item.price} kr × ${item.quantity}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right font-medium">
|
||||
{calculateLineTotal(item)} kr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity controls */}
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
||||
className="w-8 h-8 flex items-center justify-center border border-[#EDE6D9] rounded hover:bg-white transition"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="min-w-12 text-center font-medium tabular-nums">
|
||||
{formatLineQuantityLabel(item)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
||||
className="w-8 h-8 flex items-center justify-center border border-[#EDE6D9] rounded hover:bg-white transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => removeFromCart(item.id)}
|
||||
className="ml-auto text-xs text-[#B38B4D] hover:underline"
|
||||
>
|
||||
{t.cartDrawer.remove}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* STICKY FOOTER — always visible when cart has items */}
|
||||
{/* Contains the two primary actions: WhatsApp (primary) + Phone (secondary) */}
|
||||
{items.length > 0 && (
|
||||
<div className="p-6 border-t border-[#EDE6D9] bg-white">
|
||||
<button
|
||||
onClick={clearCart}
|
||||
className="text-xs text-[#8A8478] hover:text-[#B38B4D] mb-3 underline"
|
||||
>
|
||||
{t.cartDrawer.clearBasket}
|
||||
</button>
|
||||
|
||||
<div className="flex justify-between text-lg font-medium mb-4">
|
||||
<span>{t.cartDrawer.total}</span>
|
||||
<span>{totalPrice.toFixed(0)} kr</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-[#6B665F] mb-3">
|
||||
{t.cartDrawer.pickupNote}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={orderViaWhatsApp}
|
||||
className="btn-primary w-full py-4 rounded-full text-base tracking-[0.5px] font-medium mb-2 flex items-center justify-center gap-2"
|
||||
>
|
||||
{t.cartDrawer.sendViaWhatsApp}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.open(`tel:${RESTAURANT_CONTACT.phonePrimary}`, '_self')}
|
||||
className="btn-outline w-full py-3 rounded-full text-sm tracking-[0.5px] font-medium mb-3"
|
||||
>
|
||||
{t.cartDrawer.call}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={closeCart}
|
||||
className="w-full text-sm text-[#6B665F] hover:text-[#2C2A26] pt-1"
|
||||
>
|
||||
{t.cartDrawer.continueBrowsing}
|
||||
</button>
|
||||
|
||||
<p className="text-[10px] text-center text-[#8A8478] mt-4">
|
||||
{t.cartDrawer.whatsappHint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { dishImageUrl } from '@/lib/assets';
|
||||
import type { CateringPackage } from '@/lib/catering-data';
|
||||
import {
|
||||
CATERING_CATEGORY_ORDER,
|
||||
groupDishesByCategory,
|
||||
} from '@/lib/catering-data';
|
||||
import { useLanguage } from '@/lib/language-context';
|
||||
import { getTranslation } from '@/lib/translations';
|
||||
import { Users, UtensilsCrossed } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface CateringPackageCardProps {
|
||||
pkg: CateringPackage;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export default function CateringPackageCard({ pkg, index = 0 }: CateringPackageCardProps) {
|
||||
const { language } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
const grouped = groupDishesByCategory(pkg.includedDishes);
|
||||
|
||||
const packageName =
|
||||
(t.catering.packages as Record<string, string>)?.[pkg.id] ?? pkg.name;
|
||||
const packageDescription =
|
||||
(t.catering.packageDescriptions as Record<string, string>)?.[pkg.id] ?? pkg.description;
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-40px' }}
|
||||
transition={{ duration: 0.55, delay: index * 0.08, ease: [0.25, 1, 0.5, 1] }}
|
||||
className={`luxury-card flex h-full flex-col overflow-hidden rounded-[2rem] border bg-white transition-all duration-300 ${
|
||||
pkg.highlight
|
||||
? 'border-[#c99a2e]/50 shadow-lg shadow-[#c99a2e]/15 ring-1 ring-[#c99a2e]/25'
|
||||
: 'border-[#EDE6D9] hover:border-[#c99a2e]/35'
|
||||
}`}
|
||||
>
|
||||
{pkg.image && (
|
||||
<div className="relative h-48 overflow-hidden bg-[#F2EDE4] sm:h-52">
|
||||
<img
|
||||
src={dishImageUrl(pkg.image)}
|
||||
alt={packageName}
|
||||
className="h-full w-full object-cover transition-transform duration-500 hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#101724]/50 via-transparent to-transparent" />
|
||||
{pkg.highlight && (
|
||||
<span className="absolute top-4 left-4 rounded-full bg-gradient-to-r from-[#c99a2e] to-[#e8c56a] px-3 py-1 text-[10px] font-bold uppercase tracking-[0.2em] text-[#241806] shadow-md">
|
||||
{t.catering.mostPopular}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col p-6 sm:p-7">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-[22px] leading-tight tracking-[-0.4px] text-[#101724]">
|
||||
{packageName}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[#6B665F]">
|
||||
{packageDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-2xl border border-[#c99a2e]/25 bg-[#fffdf8] px-4 py-3.5">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-medium tabular-nums tracking-[-0.5px] text-[#B38B4D]">
|
||||
{pkg.pricePerHead}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-[#8a6a25]">
|
||||
{t.catering.perHead} · {pkg.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-bold uppercase tracking-[0.18em] text-[#8a6a25]">
|
||||
<UtensilsCrossed className="h-3.5 w-3.5 text-[#c99a2e]" />
|
||||
{t.catering.includedDishes}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4">
|
||||
{CATERING_CATEGORY_ORDER.map((category) => {
|
||||
const dishes = grouped[category];
|
||||
if (dishes.length === 0) return null;
|
||||
|
||||
const categoryLabel =
|
||||
(t.catering.categories as Record<string, string>)?.[category] ?? category;
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<p className="mb-1.5 text-[10px] font-bold uppercase tracking-[0.22em] text-[#c99a2e]">
|
||||
{categoryLabel}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{dishes.map((dish) => (
|
||||
<li
|
||||
key={dish.id}
|
||||
className="flex items-center gap-2 text-sm text-[#3d4654]"
|
||||
>
|
||||
<span className="h-1 w-1 shrink-0 rounded-full bg-[#c99a2e]" aria-hidden />
|
||||
{dish.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-2 border-t border-[#EDE6D9] pt-4 text-xs text-[#8A8478]">
|
||||
<Users className="h-3.5 w-3.5 text-[#B38B4D]" />
|
||||
{t.catering.guestNote}
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* GLOBAL FOOTER — Shahi Kitchen
|
||||
* Cream + gold palette matching the header for a cohesive look.
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { logoUrl } from '@/lib/assets';
|
||||
import { useLanguage } from '@/lib/language-context';
|
||||
import { getTranslation } from '@/lib/translations';
|
||||
import {
|
||||
MapPin,
|
||||
Clock,
|
||||
Phone,
|
||||
Mail,
|
||||
Heart,
|
||||
} from 'lucide-react';
|
||||
|
||||
function InstagramIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="2" y="2" width="20" height="20" rx="5" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FacebookIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const FOOTER_COPY: Record<
|
||||
string,
|
||||
{
|
||||
openingHours: string;
|
||||
experience: string;
|
||||
contactReserve: string;
|
||||
madeWith: string;
|
||||
}
|
||||
> = {
|
||||
sv: {
|
||||
openingHours: 'Öppettider',
|
||||
experience: 'Vår Upplevelse',
|
||||
contactReserve: 'Kontakt & Boka',
|
||||
madeWith: 'Tillagat med tradition och hjärta.',
|
||||
},
|
||||
en: {
|
||||
openingHours: 'Opening Hours',
|
||||
experience: 'Our Experience',
|
||||
contactReserve: 'Contact & Reserve',
|
||||
madeWith: 'Made with tradition and heart.',
|
||||
},
|
||||
ar: {
|
||||
openingHours: 'ساعات العمل',
|
||||
experience: 'تجربتنا',
|
||||
contactReserve: 'تواصل واحجز',
|
||||
madeWith: 'مُعدّ بتقاليد ومن القلب.',
|
||||
},
|
||||
tr: {
|
||||
openingHours: 'Çalışma Saatleri',
|
||||
experience: 'Deneyimimiz',
|
||||
contactReserve: 'İletişim & Rezervasyon',
|
||||
madeWith: 'Gelenek ve sevgiyle hazırlandı.',
|
||||
},
|
||||
hi: {
|
||||
openingHours: 'खुलने का समय',
|
||||
experience: 'हमारा अनुभव',
|
||||
contactReserve: 'संपर्क और बुकिंग',
|
||||
madeWith: 'परंपरा और दिल से बनाया गया।',
|
||||
},
|
||||
ur: {
|
||||
openingHours: 'کھلنے کے اوقات',
|
||||
experience: 'ہمارا تجربہ',
|
||||
contactReserve: 'رابطہ اور بکنگ',
|
||||
madeWith: 'روایت اور دل سے تیار۔',
|
||||
},
|
||||
};
|
||||
|
||||
function FooterHeading({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<h3 className="footer-heading mb-5 flex items-center gap-2 text-xs font-bold uppercase tracking-[0.22em] text-[#8a6a25]">
|
||||
<span className="footer-heading-line" aria-hidden="true" />
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Footer() {
|
||||
const { language } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
const copy = FOOTER_COPY[language] ?? FOOTER_COPY.en;
|
||||
|
||||
return (
|
||||
<footer id="contact" className="scroll-mt-header shahi-footer relative mt-auto overflow-hidden">
|
||||
<div className="footer-top-rail" aria-hidden="true" />
|
||||
|
||||
<div className="footer-main relative px-6 pb-12 pt-16">
|
||||
<div className="footer-glow footer-glow--gold" aria-hidden="true" />
|
||||
<div className="footer-glow footer-glow--emerald" aria-hidden="true" />
|
||||
|
||||
<div className="relative mx-auto max-w-7xl">
|
||||
<div className="grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4 lg:gap-8">
|
||||
{/* Brand */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="footer-logo-ring grid h-14 w-14 place-items-center rounded-2xl border p-1.5">
|
||||
<img
|
||||
src={logoUrl()}
|
||||
alt="Shahi Kitchen"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block font-serif text-xl tracking-tight text-[#101724]">
|
||||
Shahi Kitchen
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.28em] text-[#8a6a25]">
|
||||
Göteborg
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-[#4b5563]">{t.footer.tagline}</p>
|
||||
</div>
|
||||
|
||||
{/* Locations */}
|
||||
<div>
|
||||
<FooterHeading>{t.footer.locations}</FooterHeading>
|
||||
<div className="space-y-4">
|
||||
<div className="footer-info-card">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 shrink-0 text-[#c99a2e]" />
|
||||
<span className="text-sm font-semibold text-[#101724]">
|
||||
Shahi Kitchen (Askim)
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-[#4b5563]">
|
||||
Datavägen 10A, 436 32 Askim
|
||||
</p>
|
||||
</div>
|
||||
<div className="footer-info-card">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 shrink-0 text-[#c99a2e]" />
|
||||
<span className="text-sm font-semibold text-[#101724]">
|
||||
Shahi Sweets (Backaplan)
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-[#4b5563]">
|
||||
Krokegårdsgatan 5, 417 30 Göteborg
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<a
|
||||
href="tel:0739381089"
|
||||
className="footer-link inline-flex items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
<Phone className="h-3.5 w-3.5 text-[#c99a2e]" />
|
||||
0739-381089
|
||||
</a>
|
||||
<a
|
||||
href="mailto:hello@shahikitchen.se"
|
||||
className="footer-link inline-flex items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 text-[#c99a2e]" />
|
||||
hello@shahikitchen.se
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hours */}
|
||||
<div>
|
||||
<FooterHeading>{copy.openingHours}</FooterHeading>
|
||||
<div className="footer-info-card space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-[#c99a2e]" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#101724]">Askim</p>
|
||||
<p className="text-sm text-[#4b5563]">Mon – Sun · 11:00 – 21:00</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-[#c99a2e]" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#101724]">Backaplan</p>
|
||||
<p className="text-sm text-[#4b5563]">Mon – Sun · 11:00 – 21:00</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links + Social */}
|
||||
<div>
|
||||
<FooterHeading>{t.footer.explore}</FooterHeading>
|
||||
<nav className="mb-8 flex flex-col gap-2.5">
|
||||
<Link href="/" className="footer-link text-sm font-medium">
|
||||
{t.nav.home}
|
||||
</Link>
|
||||
<Link href="/menu" className="footer-link text-sm font-medium">
|
||||
{t.nav.menu}
|
||||
</Link>
|
||||
<Link href="/catering" className="footer-link text-sm font-medium">
|
||||
{t.nav.catering}
|
||||
</Link>
|
||||
<Link href="/locations" className="footer-link text-sm font-medium">
|
||||
{t.nav.locations}
|
||||
</Link>
|
||||
<Link href="/#experience" className="footer-link text-sm font-medium">
|
||||
{copy.experience}
|
||||
</Link>
|
||||
<Link href="/reserve" className="footer-link text-sm font-medium">
|
||||
{copy.contactReserve}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<FooterHeading>{t.footer.follow}</FooterHeading>
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href="https://www.instagram.com/Shahikitchen/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Instagram"
|
||||
className="footer-social-btn"
|
||||
>
|
||||
<InstagramIcon className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.facebook.com/shahikitchengbg/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Facebook"
|
||||
className="footer-social-btn"
|
||||
>
|
||||
<FacebookIcon className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="footer-bottom relative px-6 py-6">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-3 text-center text-[11px] uppercase tracking-[0.18em] text-[#6B665F] sm:flex-row sm:text-left">
|
||||
<p>© {new Date().getFullYear()} Shahi Kitchen · Göteborg</p>
|
||||
<p className="normal-case tracking-normal text-[#4b5563] italic flex items-center gap-1.5">
|
||||
<Heart className="h-3.5 w-3.5 text-[#c99a2e] fill-[#c99a2e]/20" aria-hidden="true" />
|
||||
{copy.madeWith}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { HEADER_TOTAL_HEIGHT } from '@/components/LanguageSwitcher';
|
||||
|
||||
/** Reserves vertical space for the fixed language bar + navbar (116px). */
|
||||
export default function HeaderSpacer() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
style={{ height: HEADER_TOTAL_HEIGHT }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/lib/language-context';
|
||||
import { languages, Language } from '@/lib/translations';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
export const LANGUAGE_BANNER_HEIGHT = 48;
|
||||
export const NAVBAR_HEIGHT = 68;
|
||||
export const HEADER_TOTAL_HEIGHT = LANGUAGE_BANNER_HEIGHT + NAVBAR_HEIGHT;
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const { language, setLanguage } = useLanguage();
|
||||
|
||||
const handleSelect = (lang: Language) => {
|
||||
setLanguage(lang);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lang-banner w-full border-b border-[#c99a2e]/25 bg-gradient-to-r from-[#0a4a3d] via-[#0f5a4a] to-[#0a4a3d]"
|
||||
style={{ height: LANGUAGE_BANNER_HEIGHT }}
|
||||
role="navigation"
|
||||
aria-label="Language selection"
|
||||
>
|
||||
<div className="mx-auto flex h-full max-w-7xl items-center gap-2 px-3 sm:px-6">
|
||||
<div className="hidden shrink-0 items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-[#d4a73d]/90 sm:flex">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span>Language</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center gap-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:gap-1.5">
|
||||
{languages.map((lang) => {
|
||||
const isActive = language === lang.code;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={lang.code}
|
||||
type="button"
|
||||
onClick={() => handleSelect(lang.code)}
|
||||
aria-pressed={isActive}
|
||||
aria-label={`${lang.name} (${lang.native})`}
|
||||
className={`flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-semibold transition-all sm:gap-1.5 sm:px-3 sm:py-1.5 sm:text-xs ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-[#c99a2e] to-[#e8c56a] text-[#1a1206] shadow-lg shadow-[#c99a2e]/35 ring-1 ring-[#f4d47f]/50'
|
||||
: 'text-white/80 hover:bg-white/12 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm sm:text-base" aria-hidden="true">
|
||||
{lang.flag}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{lang.native}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "./CartContext";
|
||||
import { useWishlist } from "./WishlistContext";
|
||||
import { ShoppingBag, Heart, ArrowRight, Home, UtensilsCrossed, MapPin, Star, Phone, X, LogIn, ChefHat } from "lucide-react";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import HeaderSpacer from "./HeaderSpacer";
|
||||
import { logoUrl } from "@/lib/assets";
|
||||
import { useLanguage } from "@/lib/language-context";
|
||||
import { getTranslation } from "@/lib/translations";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/**
|
||||
* =============================================================================
|
||||
* GLOBAL NAVIGATION BAR — Shahi Kitchen (Luxury Edition)
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
interface NavbarProps {
|
||||
variant?: "default" | "menu";
|
||||
}
|
||||
|
||||
export default function Navbar({ variant = "default" }: NavbarProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const { totalItems, openCart } = useCart();
|
||||
const { totalItems: wishlistCount, openWishlist } = useWishlist();
|
||||
const { language } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
const pathname = usePathname();
|
||||
|
||||
// Scroll effect - only for subtle visual polish, NOT height (height must stay consistent)
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setScrolled(window.scrollY > 20);
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/", label: t.nav.home },
|
||||
{ href: "/menu", label: t.nav.menu },
|
||||
{ href: "/catering", label: t.nav.catering },
|
||||
{ href: "/locations", label: t.nav.locations },
|
||||
{ href: "/login", label: t.nav.login },
|
||||
{ href: "/#experience", label: t.nav.experience },
|
||||
{ href: "/#contact", label: t.nav.contact },
|
||||
];
|
||||
|
||||
// Determine active link (supports hash links)
|
||||
const isActive = (href: string) => {
|
||||
if (href === "/") return pathname === "/";
|
||||
if (href.includes("#")) return false; // hash links handled separately
|
||||
return pathname === href;
|
||||
};
|
||||
|
||||
const closeMenu = () => setIsOpen(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="fixed top-0 left-0 right-0 z-[50]">
|
||||
<LanguageSwitcher />
|
||||
<nav
|
||||
className="h-[68px] border-b border-[#c99a2e]/25 bg-gradient-to-b from-[#fffcf7]/95 to-[#fbf7ef]/90 shadow-[0_4px_24px_-4px_rgba(16,23,36,0.08)] backdrop-blur-xl"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 flex items-center justify-between h-full">
|
||||
{/* Top subtle gold line for extra frame separation */}
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[#c99a2e]/20 to-transparent" />
|
||||
{/* Premium Animated Logo */}
|
||||
<Link href="/" className="group flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<motion.span
|
||||
whileHover={{ scale: 1.08, rotate: 2 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 15 }}
|
||||
className="grid h-12 w-12 place-items-center overflow-hidden rounded-2xl border border-[#c99a2e]/30 bg-white shadow-xl shadow-[#0f5a4a]/10 p-1 transition-all duration-300 group-hover:border-[#c99a2e]/70 group-hover:shadow-[#c99a2e]/25"
|
||||
>
|
||||
<img
|
||||
src={logoUrl()}
|
||||
alt="Shahi Kitchen Logo"
|
||||
className="h-10 w-10 object-contain transition-all duration-500"
|
||||
/>
|
||||
</motion.span>
|
||||
<div className="absolute inset-0 rounded-2xl bg-[#c99a2e]/0 group-hover:bg-[#c99a2e]/15 blur-2xl transition-all duration-500 pointer-events-none" />
|
||||
</div>
|
||||
<span className="hidden leading-none sm:block">
|
||||
<span className="block font-serif text-[21px] tracking-[-0.5px] text-[#101724] transition-colors group-hover:text-[#0f5a4a]">Shahi Kitchen</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.32em] text-[#8a6a25]">Shahi Taste • Gothenburg</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation - Premium Mesmerizing Design */}
|
||||
<div className="hidden md:block flex-shrink-0">
|
||||
<div className="flex items-center rounded-full border border-[#c99a2e]/20 bg-white/60 px-2 py-1.5 backdrop-blur-3xl shadow-sm">
|
||||
<div className="relative flex items-center gap-1 text-sm font-medium text-[#101724]">
|
||||
{navLinks.map((link, index) => {
|
||||
const active = isActive(link.href);
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.href}
|
||||
className="relative px-4 py-2 rounded-full transition-colors hover:text-[#0f5a4a] z-10 whitespace-nowrap"
|
||||
>
|
||||
<span className="relative z-10">{link.label}</span>
|
||||
|
||||
{/* Sliding Active Indicator - Very Premium */}
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId="activeNavPill"
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-r from-[#c99a2e] to-[#d4a73d] shadow-md"
|
||||
transition={{ type: "spring", stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mesmerizing Gold Underline on Hover */}
|
||||
<motion.span
|
||||
className="absolute bottom-1 left-1/2 h-[1.5px] w-0 bg-gradient-to-r from-[#c99a2e] to-[#f4d47f] rounded-full"
|
||||
whileHover={{ width: "60%", x: "-30%" }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Actions — Stunning Mesmerizing Buttons */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
{/* Wishlist Button */}
|
||||
<button
|
||||
onClick={openWishlist}
|
||||
className="group relative flex items-center gap-2 rounded-full border border-[#c99a2e]/30 bg-white/70 px-4 py-2.5 text-sm font-semibold text-[#101724] backdrop-blur-xl transition-all hover:border-[#c99a2e] hover:bg-white hover:shadow-lg active:scale-[0.985]"
|
||||
aria-label={t.wishlist}
|
||||
>
|
||||
<Heart className={`h-4 w-4 transition-transform group-hover:scale-110 ${wishlistCount > 0 ? 'fill-[#c99a2e]/30 text-[#c99a2e]' : ''}`} />
|
||||
{t.wishlist}
|
||||
{wishlistCount > 0 && (
|
||||
<span className="ml-0.5 rounded-full bg-[#c99a2e] px-2 py-px text-[10px] font-black text-white">{wishlistCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Cart Button - Elegant dark with gold accent */}
|
||||
<button
|
||||
onClick={openCart}
|
||||
className="group flex items-center gap-2.5 rounded-full border border-[#c99a2e]/30 bg-white/70 px-5 py-2.5 text-sm font-semibold text-[#101724] backdrop-blur-xl transition-all hover:border-[#c99a2e] hover:bg-white hover:shadow-lg active:scale-[0.985]"
|
||||
>
|
||||
<ShoppingBag className="h-4 w-4 transition-transform group-hover:scale-110" />
|
||||
{t.cart}
|
||||
{totalItems > 0 && (
|
||||
<span className="ml-0.5 rounded-full bg-[#c99a2e] px-2 py-px text-[10px] font-black text-white">{totalItems}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Reserve Table - The star of the nav, with mesmerizing gold gradient + shine */}
|
||||
<Link
|
||||
href="/reserve"
|
||||
className="relative overflow-hidden rounded-full bg-gradient-to-r from-[#c99a2e] via-[#d4a73d] to-[#c99a2e] px-7 py-2.5 text-sm font-bold text-[#241806] shadow-lg shadow-[#c99a2e]/25 transition-all hover:scale-[1.02] active:scale-[0.985] bg-[length:200%_100%] hover:bg-right"
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2 tracking-[0.3px]">
|
||||
{t.reserve}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
{/* Subtle shine sweep on hover */}
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent opacity-0 group-hover:animate-[shimmer_1.2s_ease] group-hover:opacity-100" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile Hamburger + Wishlist + Cart (compact, high touch target) */}
|
||||
<div className="md:hidden flex items-center gap-2">
|
||||
{/* MOBILE WISHLIST ICON */}
|
||||
<button
|
||||
onClick={openWishlist}
|
||||
className="relative flex items-center justify-center w-10 h-10 rounded-full hover:bg-[#F5F1E9] active:bg-[#EDE6D9] transition-colors"
|
||||
aria-label={t.wishlist}
|
||||
>
|
||||
<Heart className={`h-5 w-5 ${wishlistCount > 0 ? 'text-[#c99a2e] fill-[#c99a2e]/30' : 'text-[#101724]'}`} />
|
||||
{wishlistCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-[#c99a2e] text-white text-[10px] font-bold min-w-[18px] h-[18px] rounded-full flex items-center justify-center px-1 tabular-nums">
|
||||
{wishlistCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* MOBILE CART ICON (always visible) */}
|
||||
<button
|
||||
onClick={openCart}
|
||||
className="relative flex items-center justify-center w-10 h-10 rounded-full hover:bg-[#F5F1E9] active:bg-[#EDE6D9] transition-colors"
|
||||
aria-label="Open cart"
|
||||
>
|
||||
<ShoppingBag className="h-5 w-5 text-[#101724]" />
|
||||
{totalItems > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-[#B38B4D] text-white text-[10px] font-bold min-w-[18px] h-[18px] rounded-full flex items-center justify-center px-1 tabular-nums">
|
||||
{totalItems}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="text-[#B38B4D] p-2 -mr-1 active:text-[#8C6B3A] transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<span className={`block h-px w-6 bg-current transition-all duration-200 ${isOpen ? "rotate-45 translate-y-1.5" : ""}`} />
|
||||
<span className={`block h-px w-6 bg-current transition-all duration-200 ${isOpen ? "opacity-0" : ""}`} />
|
||||
<span className={`block h-px w-6 bg-current transition-all duration-200 ${isOpen ? "-rotate-45 -translate-y-1.5" : ""}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<HeaderSpacer />
|
||||
|
||||
{/* Modern Mobile Menu Drawer (slide-in, animated, high-contrast, touch-friendly) - high z to always appear in front of hero banner video etc. */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-[9999]">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[#101724]/70 backdrop-blur-md"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={closeMenu}
|
||||
/>
|
||||
|
||||
{/* Sliding Panel - modern, full-bleed on small phones, elegant on larger */}
|
||||
<motion.div
|
||||
className="absolute right-0 top-0 bottom-0 w-[82%] max-w-[340px] bg-[#fbf7ef] shadow-2xl border-l border-[#c99a2e]/10 flex flex-col overflow-y-auto z-[10000]"
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[#EDE6D9]">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={logoUrl()}
|
||||
alt="Shahi Kitchen"
|
||||
className="h-10 w-10 rounded-xl object-contain"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-serif text-[19px] leading-none text-[#101724]">Shahi Kitchen</div>
|
||||
<div className="text-[10px] uppercase tracking-[1.5px] text-[#8a6a25] -mt-0.5">Gothenburg</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeMenu}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/70 active:bg-[#EDE6D9] text-[#101724] transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Primary Nav Links - modern list with icons + active state for visibility */}
|
||||
<div className="px-3 py-4">
|
||||
{navLinks.map((link) => {
|
||||
const active = isActive(link.href);
|
||||
const Icon =
|
||||
link.href === '/' ? Home :
|
||||
link.href === '/menu' ? UtensilsCrossed :
|
||||
link.href === '/catering' ? ChefHat :
|
||||
link.href === '/locations' ? MapPin :
|
||||
link.href === '/login' ? LogIn :
|
||||
link.href.includes('experience') ? Star : Phone;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={closeMenu}
|
||||
className={`flex items-center gap-4 px-4 py-3.5 mx-1 my-0.5 rounded-2xl text-[17px] font-medium transition-all active:scale-[0.985] ${
|
||||
active
|
||||
? 'bg-[#101724] text-white shadow-sm'
|
||||
: 'text-[#101724] hover:bg-white active:bg-[#EDE6D9]'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 flex-shrink-0 ${active ? 'text-[#c99a2e]' : 'text-[#B38B4D]'}`} />
|
||||
<span className="whitespace-nowrap">{link.label}</span>
|
||||
{active && (
|
||||
<span className="ml-auto text-xs tracking-widest opacity-70">CURRENT</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Secondary actions */}
|
||||
<div className="mt-auto px-5 pb-8 pt-4 border-t border-[#EDE6D9] bg-white/40 space-y-3">
|
||||
<Link
|
||||
href="/reserve"
|
||||
onClick={closeMenu}
|
||||
className="block w-full rounded-2xl bg-gradient-to-r from-[#c99a2e] via-[#d4a73d] to-[#c99a2e] py-4 text-center text-[15px] font-bold text-[#241806] shadow active:scale-[0.985] transition-transform"
|
||||
>
|
||||
{t.reserve}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => { openWishlist(); closeMenu(); }}
|
||||
className="block w-full rounded-2xl border-2 border-[#c99a2e]/30 bg-white py-4 text-[15px] font-semibold text-[#101724] active:bg-[#F5F1E9] active:border-[#c99a2e] transition-all"
|
||||
>
|
||||
{t.wishlist} {wishlistCount > 0 && `(${wishlistCount})`}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { openCart(); closeMenu(); }}
|
||||
className="block w-full rounded-2xl border-2 border-[#c99a2e]/40 bg-white py-4 text-[15px] font-semibold text-[#101724] active:bg-[#F5F1E9] active:border-[#c99a2e] transition-all"
|
||||
>
|
||||
{t.cart} {totalItems > 0 && `(${totalItems})`}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-[11px] text-[#8A8478] pt-1">Tap to open WhatsApp for orders & bookings</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Clock, Users, Bike, Package, UtensilsCrossed, CheckCircle,
|
||||
AlertCircle, Timer, ArrowRight
|
||||
} from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/language-context';
|
||||
import { getTranslation } from '@/lib/translations';
|
||||
|
||||
import type {
|
||||
KitchenOrder as Order,
|
||||
KitchenOrderItem as OrderItem,
|
||||
OrderStatus,
|
||||
} from '@/domain/kitchen-order/entities';
|
||||
import { ORDER_STATUS_PIPELINE } from '@/domain/kitchen-order/entities';
|
||||
|
||||
export type { Order, OrderItem };
|
||||
|
||||
const STATUS_ORDER = ORDER_STATUS_PIPELINE;
|
||||
|
||||
function getStatusMeta(status: OrderStatus, t: any) {
|
||||
const statuses = t.screen?.statuses || {
|
||||
received: 'Order Received',
|
||||
review: 'In Review',
|
||||
preparing: 'Preparing',
|
||||
packing: 'Packing / Plating',
|
||||
ready: 'Ready',
|
||||
};
|
||||
const colors: Record<OrderStatus, string> = {
|
||||
received: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
review: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
preparing: 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
packing: 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
ready: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
||||
};
|
||||
const icons: Record<OrderStatus, React.ReactNode> = {
|
||||
received: <AlertCircle className="w-4 h-4" />,
|
||||
review: <Clock className="w-4 h-4" />,
|
||||
preparing: <UtensilsCrossed className="w-4 h-4" />,
|
||||
packing: <Package className="w-4 h-4" />,
|
||||
ready: <CheckCircle className="w-4 h-4" />,
|
||||
};
|
||||
return {
|
||||
label: statuses[status] || status,
|
||||
color: colors[status],
|
||||
icon: icons[status],
|
||||
progress: STATUS_ORDER.indexOf(status) / (STATUS_ORDER.length - 1),
|
||||
};
|
||||
}
|
||||
|
||||
function getSourceMeta(source: Order['source']) {
|
||||
if (source === 'table') return { icon: <Users className="w-4 h-4" />, label: 'Restaurant', color: '#B38B4D' };
|
||||
if (source === 'foodora') return { icon: <Bike className="w-4 h-4" />, label: 'Foodora', color: '#E30613' };
|
||||
if (source === 'uber') return { icon: <Bike className="w-4 h-4" />, label: 'Uber Eats', color: '#000000' };
|
||||
if (source === 'wolt') return { icon: <Bike className="w-4 h-4" />, label: 'Wolt', color: '#00C2E0' };
|
||||
if (source === 'pickup') return { icon: <Package className="w-4 h-4" />, label: 'Pickup', color: '#B38B4D' };
|
||||
return { icon: <Bike className="w-4 h-4" />, label: 'Delivery', color: '#B38B4D' };
|
||||
}
|
||||
|
||||
// Beautiful subtle particles (adapted from ScreenDisplay for order theme - gold "steam" specks)
|
||||
function OrderParticles() {
|
||||
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: 28 }, () => ({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height * 0.85,
|
||||
vx: (Math.random() - 0.5) * 0.12,
|
||||
vy: -0.08 - Math.random() * 0.12,
|
||||
size: 1.0 + Math.random() * 1.6,
|
||||
alpha: 0.08 + Math.random() * 0.16,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.012 + Math.random() * 0.009,
|
||||
}));
|
||||
|
||||
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.04;
|
||||
p.y += p.vy;
|
||||
p.phase += p.speed;
|
||||
|
||||
if (p.y < -8) {
|
||||
p.y = height * 0.88 + Math.random() * 30;
|
||||
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();
|
||||
}
|
||||
rafId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="fixed inset-0 pointer-events-none z-[1] opacity-60 mix-blend-multiply"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrderDisplay() {
|
||||
const { language } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
|
||||
const [orders, setOrders] = useState<Order[]>(() => {
|
||||
const now = new Date();
|
||||
return [
|
||||
// Restaurant orders (tables) - 8 orders
|
||||
{ id: 'T-05', source: 'table', label: 'Table 5', customer: 'Table 5', items: [{name: 'Butter Chicken', qty: 1}, {name: 'Garlic Naan', qty: 2}], status: 'preparing', receivedAt: new Date(now.getTime() - 1000*60*14), etaMinutes: 6 },
|
||||
{ id: 'T-12', source: 'table', label: 'Table 12', customer: 'Table 12', items: [{name: 'Lamb Rogan Josh', qty: 2}, {name: 'Biryani', qty: 1}], status: 'ready', receivedAt: new Date(now.getTime() - 1000*60*22), etaMinutes: 0 },
|
||||
{ id: 'T-03', source: 'table', label: 'Table 3', customer: 'Table 3', items: [{name: 'Samosa Chat', qty: 2}, {name: 'Chana Chat', qty: 1}], status: 'packing', receivedAt: new Date(now.getTime() - 1000*60*11), etaMinutes: 4 },
|
||||
{ id: 'T-08', source: 'table', label: 'Table 8', customer: 'Table 8', items: [{name: 'Daal Makhani', qty: 1}, {name: 'Rice', qty: 2}], status: 'ready', receivedAt: new Date(now.getTime() - 1000*60*18), etaMinutes: 0 },
|
||||
{ id: 'T-01', source: 'table', label: 'Table 1', customer: 'Table 1', items: [{name: 'Shahi Paneer', qty: 1}, {name: 'Naan', qty: 3}], status: 'preparing', receivedAt: new Date(now.getTime() - 1000*60*8), etaMinutes: 7 },
|
||||
{ id: 'T-04', source: 'table', label: 'Table 4', customer: 'Table 4', items: [{name: 'Chicken Tikka', qty: 2}, {name: 'Rice', qty: 1}], status: 'review', receivedAt: new Date(now.getTime() - 1000*60*5), etaMinutes: 10 },
|
||||
{ id: 'T-06', source: 'table', label: 'Table 6', customer: 'Table 6', items: [{name: 'Lahore Sizzler', qty: 1}, {name: 'Naan', qty: 2}], status: 'packing', receivedAt: new Date(now.getTime() - 1000*60*3), etaMinutes: 5 },
|
||||
{ id: 'T-07', source: 'table', label: 'Table 7', customer: 'Table 7', items: [{name: 'Tikka Boti', qty: 1}, {name: 'Salad', qty: 1}], status: 'received', receivedAt: new Date(now.getTime() - 1000*60*1), etaMinutes: 12 },
|
||||
|
||||
// Delivery Partners (online) - 8 orders (mix of platforms)
|
||||
{ id: 'FD-3921', source: 'foodora', label: 'Foodora #FD-3921', customer: 'Aisha K.', items: [{name: 'Lahore Pizza', qty: 1}, {name: 'Mango Lassi', qty: 2}], status: 'packing', receivedAt: new Date(now.getTime() - 1000*60*9), etaMinutes: 3 },
|
||||
{ id: 'W-884', source: 'wolt', label: 'Wolt #W-884', customer: 'Erik S.', items: [{name: 'Paneer Roll', qty: 3}], status: 'review', receivedAt: new Date(now.getTime() - 1000*60*4), etaMinutes: 11 },
|
||||
{ id: 'FD-3928', source: 'foodora', label: 'Foodora #FD-3928', customer: 'Maria L.', items: [{name: 'Shahi Burger', qty: 2}, {name: 'Coca-Cola', qty: 2}], status: 'preparing', receivedAt: new Date(now.getTime() - 1000*60*7), etaMinutes: 8 },
|
||||
{ id: 'UB-551', source: 'uber', label: 'Uber Eats #UB-551', customer: 'Johan P.', items: [{name: 'Chicken Tikka', qty: 1}, {name: 'Keema Naan', qty: 1}], status: 'received', receivedAt: new Date(now.getTime() - 1000*60*2), etaMinutes: 14 },
|
||||
{ id: 'FD-3935', source: 'foodora', label: 'Foodora #FD-3935', customer: 'Omar B.', items: [{name: 'Butter Chicken', qty: 1}, {name: 'Naan', qty: 2}], status: 'preparing', receivedAt: new Date(now.getTime() - 1000*60*10), etaMinutes: 4 },
|
||||
{ id: 'W-892', source: 'wolt', label: 'Wolt #W-892', customer: 'Lina T.', items: [{name: 'Veg Pizza', qty: 1}, {name: 'Lassi', qty: 1}], status: 'packing', receivedAt: new Date(now.getTime() - 1000*60*6), etaMinutes: 6 },
|
||||
{ id: 'UB-562', source: 'uber', label: 'Uber Eats #UB-562', customer: 'Peter H.', items: [{name: 'Lamb Rogan Josh', qty: 1}, {name: 'Rice', qty: 2}], status: 'review', receivedAt: new Date(now.getTime() - 1000*60*12), etaMinutes: 9 },
|
||||
{ id: 'FD-3940', source: 'foodora', label: 'Foodora #FD-3940', customer: 'Sara N.', items: [{name: 'Tikka Boti Roll', qty: 2}], status: 'packing', receivedAt: new Date(now.getTime() - 1000*60*15), etaMinutes: 2 },
|
||||
|
||||
// Pickup orders (whatsapp/phone) - 8 orders
|
||||
{ id: 'WA-4521', source: 'pickup', label: 'Pickup WA-4521', customer: 'Ahmed R.', items: [{name: 'Chicken Biryani', qty: 2}, {name: 'Raita', qty: 1}], status: 'preparing', receivedAt: new Date(now.getTime() - 1000*60*13), etaMinutes: 5 },
|
||||
{ id: 'PH-783', source: 'pickup', label: 'Pickup PH-783', customer: 'Sara M.', items: [{name: 'Veg Pizza', qty: 1}, {name: 'Garlic Naan', qty: 2}], status: 'packing', receivedAt: new Date(now.getTime() - 1000*60*6), etaMinutes: 2 },
|
||||
{ id: 'WA-4530', source: 'pickup', label: 'Pickup WA-4530', customer: 'Omar K.', items: [{name: 'Tikka Boti', qty: 1}], status: 'ready', receivedAt: new Date(now.getTime() - 1000*60*20), etaMinutes: 0 },
|
||||
{ id: 'WA-4535', source: 'pickup', label: 'Pickup WA-4535', customer: 'Fatima L.', items: [{name: 'Shahi Paneer', qty: 1}, {name: 'Naan', qty: 1}], status: 'preparing', receivedAt: new Date(now.getTime() - 1000*60*8), etaMinutes: 7 },
|
||||
{ id: 'PH-791', source: 'pickup', label: 'Pickup PH-791', customer: 'Hassan Q.', items: [{name: 'Daal Makhani', qty: 1}, {name: 'Rice', qty: 2}], status: 'review', receivedAt: new Date(now.getTime() - 1000*60*11), etaMinutes: 8 },
|
||||
{ id: 'WA-4542', source: 'pickup', label: 'Pickup WA-4542', customer: 'Ayesha S.', items: [{name: 'Samosa Keema', qty: 3}], status: 'packing', receivedAt: new Date(now.getTime() - 1000*60*4), etaMinutes: 3 },
|
||||
{ id: 'PH-802', source: 'pickup', label: 'Pickup PH-802', customer: 'Bilal M.', items: [{name: 'Lamm Vindaloo', qty: 1}, {name: 'Naan', qty: 2}], status: 'preparing', receivedAt: new Date(now.getTime() - 1000*60*9), etaMinutes: 6 },
|
||||
{ id: 'WA-4550', source: 'pickup', label: 'Pickup WA-4550', customer: 'Zainab P.', items: [{name: 'Jalebi', qty: 2}, {name: 'Kulfi', qty: 1}], status: 'received', receivedAt: new Date(now.getTime() - 1000*60*1), etaMinutes: 13 },
|
||||
];
|
||||
});
|
||||
|
||||
const [lastUpdate, setLastUpdate] = useState(new Date());
|
||||
const [liveClock, setLiveClock] = useState(new Date());
|
||||
|
||||
// Live clock
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setLiveClock(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Simulate live backend updates (replace with real fetch / WebSocket / polling when ready)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setOrders(prev => {
|
||||
let updated = [...prev];
|
||||
const now = new Date();
|
||||
|
||||
// Occasionally advance a random non-ready order
|
||||
if (Math.random() < 0.65) {
|
||||
const candidates = updated
|
||||
.map((o, i) => ({o, i}))
|
||||
.filter(({o}) => o.status !== 'ready');
|
||||
if (candidates.length > 0) {
|
||||
const pick = candidates[Math.floor(Math.random() * candidates.length)];
|
||||
const currIdx = STATUS_ORDER.indexOf(pick.o.status);
|
||||
if (currIdx < STATUS_ORDER.length - 1) {
|
||||
updated[pick.i] = {
|
||||
...pick.o,
|
||||
status: STATUS_ORDER[currIdx + 1],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Occasionally add a new incoming order (allow up to ~30 total so each category can have 6-10+ for reels)
|
||||
if (Math.random() < 0.28 && updated.length < 30) {
|
||||
const sources: Order['source'][] = ['table', 'foodora', 'wolt', 'uber', 'pickup'];
|
||||
const src = sources[Math.floor(Math.random() * sources.length)];
|
||||
const isTable = src === 'table';
|
||||
const isPickup = src === 'pickup';
|
||||
const num = Math.floor(1000 + Math.random() * 9000);
|
||||
const newOrder: Order = {
|
||||
id: isTable ? `T-${Math.floor(1+Math.random()*14)}` : isPickup ? `WA-${num}` : `${src.toUpperCase().slice(0,2)}-${num}`,
|
||||
source: src,
|
||||
label: isTable ? `Table ${Math.floor(1+Math.random()*14)}` : isPickup ? `Pickup WA-${num}` : `${src.charAt(0).toUpperCase() + src.slice(1)} #${src.slice(0,2).toUpperCase()}-${num}`,
|
||||
customer: isTable ? `Table ${Math.floor(1+Math.random()*14)}` : isPickup ? ['Ahmed R.', 'Sara M.', 'Omar K.', 'Fatima L.'][Math.floor(Math.random()*4)] : ['A. Khan', 'S. Patel', 'L. Berg', 'M. Lind'][Math.floor(Math.random()*4)],
|
||||
items: [
|
||||
{ name: ['Butter Chicken','Lahore Pizza','Lamb Rogan Josh','Shahi Paneer','Tikka Boti Roll'][Math.floor(Math.random()*5)], qty: 1 + Math.floor(Math.random()*2) },
|
||||
{ name: ['Naan','Rice','Lassi','Samosa','Salad'][Math.floor(Math.random()*5)], qty: 1 },
|
||||
],
|
||||
status: 'received',
|
||||
receivedAt: now,
|
||||
etaMinutes: 10 + Math.floor(Math.random() * 8),
|
||||
};
|
||||
updated = [newOrder, ...updated].slice(0, 30);
|
||||
}
|
||||
|
||||
// Occasionally remove a very old ready order (simulates pickup / serve)
|
||||
if (Math.random() < 0.22) {
|
||||
const readyIdx = updated.findIndex(o => o.status === 'ready' && (now.getTime() - o.receivedAt.getTime()) > 1000*60*3);
|
||||
if (readyIdx !== -1) updated.splice(readyIdx, 1);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
setLastUpdate(new Date());
|
||||
}, 6200); // ~every 6s for mesmerizing live feel
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Group into the three required sections
|
||||
const restaurantOrders = orders.filter(o => o.source === 'table').sort((a,b) => b.receivedAt.getTime() - a.receivedAt.getTime());
|
||||
const deliveryOrders = orders.filter(o => ['foodora', 'uber', 'wolt', 'other'].includes(o.source)).sort((a,b) => b.receivedAt.getTime() - a.receivedAt.getTime());
|
||||
const pickupOrders = orders.filter(o => o.source === 'pickup').sort((a,b) => b.receivedAt.getTime() - a.receivedAt.getTime());
|
||||
const readyOrders = orders.filter(o => o.status === 'ready');
|
||||
|
||||
const formatTime = (date: Date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const getElapsed = (date: Date) => {
|
||||
const mins = Math.floor((Date.now() - date.getTime()) / 60000);
|
||||
return `${mins} ${t.screen?.elapsed || 'min'}`;
|
||||
};
|
||||
|
||||
const screenLabel = t.screen?.display3 || 'DISPLAY 3';
|
||||
|
||||
// Helper to render a vertical looping reel for a list of orders.
|
||||
// Viewport h-full (fills remaining screen height after headers).
|
||||
// Cards fixed px height => browser zoom changes # visible (fewer on zoom-in, more on zoom-out), like screen2.
|
||||
// Duplicates list for seamless upward CSS loop when count > visible.
|
||||
// Always fills screen top-to-bottom with no whitespace.
|
||||
const renderOrderReel = (ordersList: Order[], sectionLabel: string, sectionIcon: React.ReactNode) => {
|
||||
const count = ordersList.length;
|
||||
const shouldLoop = count > 3;
|
||||
const displayList = shouldLoop ? [...ordersList, ...ordersList] : ordersList;
|
||||
const duration = shouldLoop ? `${count * REEL_TIME_PER_ITEM}s` : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-3 mb-3 px-1 flex-none">
|
||||
{sectionIcon}
|
||||
<div className="font-medium tracking-[2px] text-sm text-[#B38B4D]">{sectionLabel}</div>
|
||||
<div className="flex-1 h-px bg-[#EDE6D9]" />
|
||||
<div className="text-xs text-[#6B665F] tabular-nums">{count} ACTIVE</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="reel-viewport h-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="order-reel-track"
|
||||
style={{
|
||||
gap: '8px',
|
||||
animationDuration: duration,
|
||||
// If not looping, no animation
|
||||
animation: shouldLoop ? undefined : 'none'
|
||||
}}
|
||||
>
|
||||
{displayList.map((order, idx) => {
|
||||
const meta = getStatusMeta(order.status, t);
|
||||
const srcMeta = getSourceMeta(order.source);
|
||||
const elapsed = getElapsed(order.receivedAt);
|
||||
const isReady = order.status === 'ready';
|
||||
|
||||
// Card with FIXED height in px. + h-full viewport (fills screen) means zoom in: fewer visible/column, zoom out: more visible. No whitespace, max top-bottom.
|
||||
return (
|
||||
<div
|
||||
key={`${order.id}-${idx}`}
|
||||
className={`group relative bg-white rounded-2xl border shadow-md overflow-hidden flex flex-col ${isReady ? 'border-emerald-300 ring-1 ring-emerald-200' : 'border-[#EDE6D9]'}`}
|
||||
style={{ height: `${REEL_CARD_HEIGHT}px` }}
|
||||
>
|
||||
<div className="px-3 pt-2 pb-1 flex items-start justify-between gap-2 flex-none">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-lg tracking-[-0.5px] font-semibold text-[#2C2A26] leading-none truncate">
|
||||
{order.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#6B665F] mt-0.5 tracking-[0.3px] truncate">{order.customer} • {elapsed}</div>
|
||||
</div>
|
||||
|
||||
<div className={`px-2 py-0.5 rounded-full text-[10px] font-medium tracking-wider border flex items-center gap-1 whitespace-nowrap flex-none ${meta.color}`}>
|
||||
{meta.icon} {meta.label.toUpperCase().slice(0,6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compact items */}
|
||||
<div className="px-3 pb-1 text-xs text-[#2C2A26]/90 flex-1 overflow-hidden">
|
||||
{order.items.slice(0,2).map((it, i) => (
|
||||
<div key={i} className="flex justify-between leading-tight py-px">
|
||||
<span className="truncate">{it.qty}× {it.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{order.items.length > 2 && <div className="text-[10px] text-[#6B665F]">+{order.items.length-2} more</div>}
|
||||
</div>
|
||||
|
||||
{/* Compact progress */}
|
||||
<div className="px-3 pb-2 pt-0.5 border-t border-[#EDE6D9]/60 bg-[#F8F5F0]/50 flex items-center gap-2 flex-none">
|
||||
<div className="flex-1 h-1 bg-[#EDE6D9] rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${meta.progress * 100}%`,
|
||||
background: srcMeta.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] font-medium whitespace-nowrap tabular-nums" style={{ color: srcMeta.color }}>
|
||||
{isReady ? 'READY' : `${order.etaMinutes}m`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isReady && (
|
||||
<div className="absolute top-1 right-1 px-2 py-0.5 bg-emerald-600 text-white text-[9px] tracking-widest rounded font-semibold">READY</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Reel settings for screen3 vertical marquees - dynamic with zoom like screen2
|
||||
// Fixed card height in px so that browser zoom in/out changes how many cards fit vertically in the column (less when zoom in, more when zoom out).
|
||||
// The reel viewport uses 100% of available column height (after section header) to fill the screen with no whitespace.
|
||||
const REEL_CARD_HEIGHT = 120; // px per order card - tune for ~3-5 at 100% on 43" TV. Zoom affects visible count.
|
||||
const REEL_TIME_PER_ITEM = 6; // seconds per order in the animation - slow upward movement
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-[#F8F5F0] text-[#2C2A26] overflow-hidden select-none flex flex-col screen-root">
|
||||
<OrderParticles />
|
||||
|
||||
{/* Mesmerizing top bar - luxurious, welcoming, eye-catching for customers & riders. Taller, pulsing LIVE, centered tagline, elegant depth for 43" kiosk appeal. */}
|
||||
<div className="relative z-20 h-20 border-b border-[#EDE6D9]/50 bg-gradient-to-b from-[#F8F5F0] via-[#F8F5F0]/98 to-[#F8F5F0]/95 backdrop-blur-xl flex items-center px-10 text-sm flex-none shadow-[0_2px_8px_rgba(0,0,0,0.03)]">
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Brand with depth and elegance */}
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<div className="font-serif text-[28px] tracking-[-2.2px] text-[#2C2A26] drop-shadow-sm">SHAHI KITCHEN</div>
|
||||
<div className="text-[9px] text-[#B38B4D] tracking-[2px] font-medium -mb-1">EST. 2016</div>
|
||||
</div>
|
||||
|
||||
{/* Elegant status badges */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-[11px] px-3.5 py-1 rounded-full border border-[#B38B4D]/60 text-[#B38B4D] tracking-[3px] font-medium bg-white/60 backdrop-blur-sm">
|
||||
{screenLabel}
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.08, 1], opacity: [0.9, 1, 0.9] }}
|
||||
transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-[#B38B4D] text-white text-[10px] tracking-[2.5px] font-semibold shadow-sm"
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
|
||||
LIVE
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Centered mesmerizing tagline */}
|
||||
<div className="hidden md:block text-center">
|
||||
<div className="text-[11px] tracking-[4.5px] text-[#B38B4D]/70 font-medium">YOUR JOURNEY TO FLAVOR • REAL-TIME MAGIC</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right info - elegant and clear */}
|
||||
<div className="flex items-center gap-7 text-[#6B665F] tracking-[1.5px] text-xs">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div>ASKIM</div>
|
||||
<div className="w-px h-2.5 bg-[#EDE6D9]" />
|
||||
<div>BACKAPLAN</div>
|
||||
</div>
|
||||
<div className="h-3 w-px bg-[#EDE6D9]/70" />
|
||||
<div className="font-mono text-[#B38B4D] text-sm tracking-widest tabular-nums">
|
||||
{liveClock.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}
|
||||
</div>
|
||||
<div className="text-[10px] px-2.5 py-0.5 rounded bg-[#B38B4D]/10 text-[#B38B4D] tracking-[1.5px]">
|
||||
{t.screen?.prepared?.toUpperCase() || 'FRESHLY PREPARED'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero header with beautiful typography and live feel */}
|
||||
<div className="relative z-10 bg-[#101724] text-white flex-none py-5 px-9 border-b border-white/10">
|
||||
<div className="max-w-[1680px] mx-auto flex items-end justify-between">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-1 text-xs tracking-[3.5px] mb-2 border border-white/20">
|
||||
{t.screen?.orderBoard || 'LIVE ORDER BOARD'}
|
||||
</div>
|
||||
<h1 className="font-serif text-[42px] md:text-[52px] leading-[0.9] tracking-[-2.2px] text-white">
|
||||
{t.screen?.orderSubtitle || 'Delivery • Pickup • Restaurant — live for riders & guests'}
|
||||
</h1>
|
||||
<p className="mt-1.5 text-white/60 tracking-[0.5px] text-sm">
|
||||
Delivery Partners • Pickup (WhatsApp/Phone) • Restaurant Tables • Real-time kitchen progress
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right text-sm">
|
||||
<div className="text-[#B38B4D] tracking-[2px] text-xs mb-0.5">LAST SYNC</div>
|
||||
<div className="font-mono text-lg text-white/90">{formatTime(lastUpdate)}</div>
|
||||
<div className="text-[10px] text-white/50 mt-0.5">{t.screen?.liveUpdates || 'Live updates • Backend integration coming soon'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats bar - beautiful metrics with motion - three sections */}
|
||||
<div className="relative z-10 bg-[#F8F5F0] border-b border-[#EDE6D9]/60 py-3 px-9 flex-none">
|
||||
<div className="max-w-[1680px] mx-auto grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-[#B38B4D]"><Users className="w-5 h-5" /></div>
|
||||
<div>
|
||||
<div className="font-medium text-lg leading-none">{restaurantOrders.length}</div>
|
||||
<div className="text-[#6B665F] text-xs tracking-[1px]">RESTAURANT ORDERS</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-[#B38B4D]"><Bike className="w-5 h-5" /></div>
|
||||
<div>
|
||||
<div className="font-medium text-lg leading-none">{deliveryOrders.length}</div>
|
||||
<div className="text-[#6B665F] text-xs tracking-[1px]">DELIVERY PARTNERS</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-[#B38B4D]"><Package className="w-5 h-5" /></div>
|
||||
<div>
|
||||
<div className="font-medium text-lg leading-none">{pickupOrders.length}</div>
|
||||
<div className="text-[#6B665F] text-xs tracking-[1px]">PICKUP ORDERS (WA/PHONE)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[#6B665F]">
|
||||
<Timer className="w-5 h-5 text-[#B38B4D]" />
|
||||
<div className="text-xs tracking-[1.5px] leading-tight">
|
||||
AVERAGE PREP TIME<br />
|
||||
<span className="text-[#2C2A26] font-medium">12-16 MIN</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main beautiful content area - THREE COLUMNS with vertical reels filling entire remaining screen height */}
|
||||
{/* Reels use h-full after fixed headers; cards fixed px height => zoom in: fewer visible per column, zoom out: more visible. No white space, max top to bottom like screen2 max left to right. */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden px-4 py-2 md:px-6 md:py-3 bg-[#F8F5F0]">
|
||||
<div className="max-w-[1680px] mx-auto h-full grid grid-cols-1 lg:grid-cols-3 gap-4" style={{ minHeight: 0 }}>
|
||||
{/* 1. DELIVERY PARTNERS - reel column */}
|
||||
<div className="flex flex-col h-full">
|
||||
{renderOrderReel(deliveryOrders, 'DELIVERY PARTNERS', <Bike className="w-5 h-5 text-[#B38B4D]" />)}
|
||||
</div>
|
||||
|
||||
{/* 2. PICKUP ORDERS (WHATSAPP/PHONE) - reel column */}
|
||||
<div className="flex flex-col h-full">
|
||||
{renderOrderReel(pickupOrders, 'PICKUP ORDERS (WHATSAPP/PHONE)', <Package className="w-5 h-5 text-[#B38B4D]" />)}
|
||||
</div>
|
||||
|
||||
{/* 3. RESTAURANT ORDERS - reel column */}
|
||||
<div className="flex flex-col h-full">
|
||||
{renderOrderReel(restaurantOrders, 'RESTAURANT ORDERS', <Users className="w-5 h-5 text-[#B38B4D]" />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom elegant bar / marquee for future rider messages */}
|
||||
<div className="relative z-10 py-2.5 text-center text-[11px] tracking-[3px] text-[#8A8478] border-t border-[#EDE6D9]/60 bg-[#F8F5F0] flex-none flex items-center justify-center gap-4">
|
||||
<div>SHAHI KITCHEN • GOTHENBURG • FRESH EVERY DAY</div>
|
||||
<div className="h-px w-6 bg-[#EDE6D9]" />
|
||||
<div className="text-[#B38B4D] font-medium">INTEGRATING WITH FOODORA • WOLT • UBER EATS</div>
|
||||
<div className="h-px w-6 bg-[#EDE6D9]" />
|
||||
<div>BACKEND API READY SOON — LIVE DATA FEED</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
centered?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
centered = false,
|
||||
className = '',
|
||||
children,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={`mx-auto max-w-7xl px-6 pt-5 pb-6 ${centered ? 'text-center' : ''} ${className}`}
|
||||
>
|
||||
<div className={`${centered ? 'max-w-3xl mx-auto' : 'max-w-3xl'}`}>
|
||||
{eyebrow && (
|
||||
<div className="text-[#B38B4D] text-[11px] tracking-[2.5px] mb-2 font-medium uppercase">
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-2xl sm:text-[1.75rem] md:text-3xl tracking-[-0.8px] leading-[1.15] mb-2 text-[#101724] break-words">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p
|
||||
className={`text-sm md:text-[15px] text-[#4b5563] leading-relaxed ${
|
||||
centered ? 'mx-auto max-w-xl' : 'max-w-2xl'
|
||||
}`}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { chefExpressionUrl } from "@/lib/assets";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Shahi Kitchen Hero - Focused on the Chef Logo
|
||||
* - Large central logo with life (wink + smile cycle)
|
||||
* - Cream background circle so the logo blends/absorbs into the site color
|
||||
* - No more small floating dishes
|
||||
*/
|
||||
|
||||
export default function PlayfulHeroScene() {
|
||||
const [expression, setExpression] = useState<'normal' | 'wink' | 'smile'>('normal');
|
||||
|
||||
// Cycle expressions for life (wink and smile)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setExpression(prev => {
|
||||
const rand = Math.random();
|
||||
if (rand < 0.45) return 'wink';
|
||||
if (rand < 0.9) return 'smile';
|
||||
return 'normal';
|
||||
});
|
||||
}, 2400);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const getLogoSrc = () => {
|
||||
return chefExpressionUrl(expression);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full min-h-[520px] md:min-h-[620px] flex items-center justify-center overflow-hidden">
|
||||
|
||||
{/* Large cream background circle - matches website exactly so logo absorbs */}
|
||||
<div className="absolute left-1/2 top-[58%] -translate-x-1/2 -translate-y-1/2
|
||||
w-[440px] h-[440px] md:w-[540px] md:h-[540px] lg:w-[620px] lg:h-[620px]
|
||||
bg-[#fbf7ef] rounded-full blur-[130px] opacity-95" />
|
||||
|
||||
{/* Central Chef Logo with life */}
|
||||
<div className="relative z-30">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={expression}
|
||||
initial={{ opacity: 0.7, scale: 0.97 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: [0, -24, 0],
|
||||
rotate: [-3.5, 3.5, -3.5]
|
||||
}}
|
||||
exit={{ opacity: 0.7, scale: 0.97 }}
|
||||
transition={{
|
||||
y: { duration: 6.6, repeat: Infinity, ease: "easeInOut" },
|
||||
rotate: { duration: 6.6, repeat: Infinity, ease: "easeInOut" },
|
||||
opacity: { duration: 0.4 },
|
||||
scale: { duration: 0.4 }
|
||||
}}
|
||||
className="w-[300px] h-[300px] md:w-[380px] md:h-[380px] lg:w-[440px] lg:h-[440px]"
|
||||
>
|
||||
<img
|
||||
src={getLogoSrc()}
|
||||
alt="Shahi Kitchen Chef"
|
||||
className="w-full h-full object-contain drop-shadow-[0_25px_55px_rgba(0,0,0,0.35)]"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Enhanced Shahi glows */}
|
||||
<div className="absolute -top-14 left-1/2 -translate-x-1/2 w-44 h-44 bg-[#f4d47f] rounded-full blur-3xl opacity-48" />
|
||||
<div className="absolute -top-7 left-1/2 -translate-x-1/2 w-24 h-24 bg-[#c99a2e] rounded-full blur-2xl opacity-32" />
|
||||
<div className="absolute -bottom-9 left-1/2 -translate-x-1/2 w-32 h-16 bg-[#c99a2e] rounded-full blur-3xl opacity-22" />
|
||||
</div>
|
||||
|
||||
{/* Subtle steam for life */}
|
||||
<motion.div
|
||||
className="absolute left-[41%] top-[36%] w-4 h-11 opacity-32"
|
||||
animate={{ y: [0, -36, 0], opacity: [0.28, 0.52, 0.28] }}
|
||||
transition={{ duration: 3.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="w-full h-full bg-gradient-to-t from-[#f4d47f] to-transparent rounded-full blur-md" />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="absolute right-[40%] top-[41%] w-3 h-9 opacity-27"
|
||||
animate={{ y: [0, -30, 0], opacity: [0.22, 0.48, 0.22] }}
|
||||
transition={{ duration: 4.2, repeat: Infinity, ease: "easeInOut", delay: 1.5 }}
|
||||
>
|
||||
<div className="w-full h-full bg-gradient-to-t from-[#c99a2e] to-transparent rounded-full blur-md" />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
'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';
|
||||
|
||||
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, t }: { item: MenuItem; isHighlighted?: boolean; t: any }) {
|
||||
const poster = getMenuPosterSrc(item, 'optimized');
|
||||
const desc = (t as any).menuDescriptions?.[item.id] || item.description || '';
|
||||
|
||||
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={item.name}
|
||||
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">
|
||||
{item.name}
|
||||
</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 translatedDesc = current ? ((t as any).menuDescriptions?.[current.id] || current.description) : '';
|
||||
|
||||
// =====================================================
|
||||
// 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');
|
||||
|
||||
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={item.name}
|
||||
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}. {item.name}
|
||||
</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={current.name}
|
||||
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)]">
|
||||
{current.name}
|
||||
</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 (11am–3pm)</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 • Mon–Fri 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>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { MenuItem } from '@/domain/menu/entities';
|
||||
import WishlistButton from '@/components/WishlistButton';
|
||||
import {
|
||||
applyNextImageFallback,
|
||||
getMenuPosterCandidates,
|
||||
getMenuPosterSrc,
|
||||
} from '@/lib/assets';
|
||||
|
||||
export type SignatureDish = MenuItem & {
|
||||
desc: string;
|
||||
};
|
||||
|
||||
interface SignatureMenuMarqueeProps {
|
||||
dishes: SignatureDish[];
|
||||
addToCartLabel: string;
|
||||
perHalfKgLabel: string;
|
||||
perKgLabel: string;
|
||||
onAdd: (dish: SignatureDish) => void;
|
||||
}
|
||||
|
||||
function renderMarqueePrice(
|
||||
item: MenuItem,
|
||||
perHalfKgLabel: string,
|
||||
perKgLabel: string
|
||||
) {
|
||||
if (item.pricing === 'weight') {
|
||||
return (
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-[#B38B4D] text-lg font-medium tracking-[-0.5px] tabular-nums leading-tight">
|
||||
{item.pricePerHalfKg ?? item.price}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#8A8478] tracking-wide">{perHalfKgLabel}</div>
|
||||
<div className="text-[#B38B4D] text-sm font-medium tabular-nums mt-0.5">
|
||||
{item.pricePerKg} {perKgLabel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-[#B38B4D] text-xl font-medium tracking-[-0.5px] tabular-nums">
|
||||
{item.price}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#8A8478] tracking-widest -mt-1">KR</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CARD_WIDTH = 240;
|
||||
const CARD_GAP = 20;
|
||||
|
||||
export default function SignatureMenuMarquee({
|
||||
dishes,
|
||||
addToCartLabel,
|
||||
perHalfKgLabel,
|
||||
perKgLabel,
|
||||
onAdd,
|
||||
}: SignatureMenuMarqueeProps) {
|
||||
if (dishes.length === 0) return null;
|
||||
|
||||
const reelItems = [...dishes, ...dishes];
|
||||
const trackWidth = reelItems.length * (CARD_WIDTH + CARD_GAP);
|
||||
const durationSeconds = Math.max(50, dishes.length * 2.8);
|
||||
|
||||
return (
|
||||
<div className="signature-menu-reel relative -mx-6 overflow-hidden lg:-mx-8">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-16 bg-gradient-to-r from-[#fffdf8] to-transparent sm:w-24"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-16 bg-gradient-to-l from-[#fffdf8] to-transparent sm:w-24"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="signature-menu-reel-track flex w-max gap-5 py-2 pl-6 lg:pl-8"
|
||||
style={{
|
||||
width: `${trackWidth}px`,
|
||||
animationDuration: `${durationSeconds}s`,
|
||||
}}
|
||||
>
|
||||
{reelItems.map((dish, index) => (
|
||||
<article
|
||||
key={`${dish.id}-${index}`}
|
||||
className="signature-card group flex w-[240px] shrink-0 cursor-pointer flex-col overflow-hidden rounded-[1.75rem] border border-[#EDE6D9] bg-white shadow-sm transition-shadow hover:border-[#c99a2e]/45 hover:shadow-md"
|
||||
onClick={() => onAdd(dish)}
|
||||
>
|
||||
<div className="relative h-40 overflow-hidden bg-[#F2EDE4]">
|
||||
<img
|
||||
src={getMenuPosterSrc(dish)}
|
||||
alt={dish.name}
|
||||
className="signature-image h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
applyNextImageFallback(
|
||||
e.target as HTMLImageElement,
|
||||
getMenuPosterCandidates(dish),
|
||||
(e.target as HTMLImageElement).src
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/5 via-transparent to-black/20" />
|
||||
<div className="absolute top-3 left-3 z-10">
|
||||
<WishlistButton
|
||||
item={{ id: dish.id, name: dish.name, price: dish.price, image: dish.image }}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<h4 className="min-w-0 line-clamp-2 text-[17px] leading-tight tracking-[-0.3px] text-[#101724] group-hover:text-[#B38B4D]">
|
||||
{dish.name}
|
||||
</h4>
|
||||
{renderMarqueePrice(dish, perHalfKgLabel, perKgLabel)}
|
||||
</div>
|
||||
<p className="mb-3 line-clamp-2 flex-1 text-[12px] leading-snug text-[#6B665F]">
|
||||
{dish.desc}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAdd(dish);
|
||||
}}
|
||||
className="w-full rounded-full border border-[#B38B4D] py-2.5 text-xs font-medium tracking-wide text-[#B38B4D] transition-all hover:bg-[#B38B4D] hover:text-white active:scale-[0.98]"
|
||||
>
|
||||
{addToCartLabel}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Heart } from 'lucide-react';
|
||||
import { useWishlist } from '@/presentation/providers/wishlist-provider';
|
||||
import type { NewWishlistItem } from '@/domain/wishlist/entities';
|
||||
|
||||
interface WishlistButtonProps {
|
||||
item: NewWishlistItem;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function WishlistButton({ item, size = 'md', className = '' }: WishlistButtonProps) {
|
||||
const { toggleWishlist, isWishlisted } = useWishlist();
|
||||
const saved = isWishlisted(item.id);
|
||||
|
||||
const sizeClasses = size === 'sm'
|
||||
? 'h-8 w-8'
|
||||
: 'h-10 w-10';
|
||||
|
||||
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleWishlist(item);
|
||||
}}
|
||||
aria-label={saved ? 'Remove from wishlist' : 'Add to wishlist'}
|
||||
aria-pressed={saved}
|
||||
className={`group/wish flex items-center justify-center rounded-full border transition-all active:scale-95 ${sizeClasses} ${
|
||||
saved
|
||||
? 'border-[#c99a2e] bg-[#c99a2e]/15 text-[#c99a2e] shadow-sm'
|
||||
: 'border-[#EDE6D9] bg-white/95 text-[#8A8478] hover:border-[#c99a2e]/50 hover:text-[#c99a2e]'
|
||||
} ${className}`}
|
||||
>
|
||||
<Heart
|
||||
className={`${iconSize} transition-transform group-hover/wish:scale-110 ${saved ? 'fill-[#c99a2e]' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* @deprecated Import from `@/presentation/providers/wishlist-provider`.
|
||||
* Kept for backward compatibility during Clean Architecture migration.
|
||||
*/
|
||||
export {
|
||||
WishlistProvider,
|
||||
useWishlist,
|
||||
type WishlistItem,
|
||||
} from '@/presentation/providers/wishlist-provider';
|
||||
@@ -1,172 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Heart, ShoppingBag } from 'lucide-react';
|
||||
import { useWishlist } from '@/presentation/providers/wishlist-provider';
|
||||
import { useCart } from '@/presentation/providers/cart-provider';
|
||||
import { useLanguage } from '@/presentation/providers/language-provider';
|
||||
import { getTranslation } from '@/presentation/i18n/translations';
|
||||
import { getMenuPosterSrc, applyNextImageFallback, getMenuPosterCandidates } from '@/lib/assets';
|
||||
import { getMenuItemById } from '@/lib/menu-data';
|
||||
import { buildCartLineFromMenuItem } from '@/application/cart/cart-line-builder';
|
||||
|
||||
export default function WishlistDrawer() {
|
||||
const {
|
||||
items,
|
||||
isOpen,
|
||||
closeWishlist,
|
||||
removeFromWishlist,
|
||||
clearWishlist,
|
||||
} = useWishlist();
|
||||
|
||||
const { addToCart } = useCart();
|
||||
const { language } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAddToCart = (item: (typeof items)[number]) => {
|
||||
const menuItem = getMenuItemById(item.id);
|
||||
addToCart(menuItem ? buildCartLineFromMenuItem(menuItem) : {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
image: item.image,
|
||||
pricingMode: 'standard',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-[985]"
|
||||
onClick={closeWishlist}
|
||||
/>
|
||||
|
||||
<div className="fixed top-0 right-0 h-full w-full max-w-md bg-[#F8F5F0] z-[985] shadow-2xl flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-[#EDE6D9]">
|
||||
<div className="flex items-center gap-3">
|
||||
<Heart className="h-5 w-5 text-[#c99a2e] fill-[#c99a2e]/20" />
|
||||
<h2 className="text-2xl tracking-[-0.5px] text-[#101724]">{t.wishlistDrawer.title}</h2>
|
||||
{items.length > 0 && (
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-[#EDE6D9] text-[#6B665F]">
|
||||
{items.length} {items.length === 1 ? t.wishlistDrawer.item : t.wishlistDrawer.items}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
onClick={clearWishlist}
|
||||
className="text-xs text-[#B38B4D] hover:underline"
|
||||
>
|
||||
{t.wishlistDrawer.clear}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={closeWishlist}
|
||||
className="text-[#6B665F] hover:text-[#2C2A26] text-2xl leading-none pl-1"
|
||||
aria-label="Close wishlist"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Heart className="h-16 w-16 text-[#c99a2e]/30 mb-4" />
|
||||
<p className="text-lg text-[#6B665F]">{t.wishlistDrawer.empty}</p>
|
||||
<Link
|
||||
href="/menu"
|
||||
onClick={closeWishlist}
|
||||
className="mt-6 text-[#B38B4D] hover:underline"
|
||||
>
|
||||
{t.wishlistDrawer.browseMenu}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex gap-4 rounded-2xl border border-[#EDE6D9] bg-white p-4"
|
||||
>
|
||||
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-[#F2EDE4]">
|
||||
<img
|
||||
src={getMenuPosterSrc(item)}
|
||||
alt={item.name}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
applyNextImageFallback(
|
||||
e.target as HTMLImageElement,
|
||||
getMenuPosterCandidates(item),
|
||||
(e.target as HTMLImageElement).src
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between gap-2">
|
||||
<h4 className="font-medium tracking-[-0.3px] text-[#101724] truncate">
|
||||
{item.name}
|
||||
</h4>
|
||||
<span className="shrink-0 text-right font-medium text-[#B38B4D] tabular-nums text-sm leading-tight">
|
||||
{(() => {
|
||||
const menuItem = getMenuItemById(item.id);
|
||||
if (menuItem?.pricing === 'weight') {
|
||||
return (
|
||||
<>
|
||||
<span className="block">{menuItem.pricePerHalfKg} kr / ½ kg</span>
|
||||
<span className="block text-xs text-[#8A8478]">{menuItem.pricePerKg} {t.menu.perKg}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <span>{item.price} kr</span>;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => handleAddToCart(item)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-[#c99a2e] to-[#d4a73d] px-4 py-2 text-xs font-bold text-[#241806] transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<ShoppingBag className="h-3.5 w-3.5" />
|
||||
{t.wishlistDrawer.addToCart}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeFromWishlist(item.id)}
|
||||
className="text-xs text-[#B38B4D] hover:underline ml-auto"
|
||||
>
|
||||
{t.wishlistDrawer.remove}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="p-6 border-t border-[#EDE6D9] bg-white">
|
||||
<p className="text-xs text-center text-[#6B665F] mb-4">
|
||||
{t.wishlistDrawer.hint}
|
||||
</p>
|
||||
<Link
|
||||
href="/menu"
|
||||
onClick={closeWishlist}
|
||||
className="block w-full text-center text-sm text-[#B38B4D] hover:underline"
|
||||
>
|
||||
{t.wishlistDrawer.continueBrowsing}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user