Replace entire repo content with correct code from /root/shahikitchen-website/

This commit is contained in:
root
2026-06-29 16:22:09 +00:00
parent 57cc67d2d3
commit 50e3a34895
723 changed files with 20055 additions and 26101 deletions
+320
View File
@@ -0,0 +1,320 @@
'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>
);
}
+9
View File
@@ -0,0 +1,9 @@
/**
* @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';
+247
View File
@@ -0,0 +1,247 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { MessageCircle } from 'lucide-react';
import {
buildDeliveryInquiryMessage,
buildPickupInquiryMessage,
} from '@/application/messaging/whatsapp-message-builder';
import type { FulfillmentMode } from '@/domain/cart/inquiry';
import type { DeliveryInquiryDetails, PickupInquiryDetails } from '@/domain/cart/inquiry';
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';
import CartInquiryModal from '@/components/CartInquiryModal';
export default function CartDrawer() {
const {
items,
isOpen,
closeCart,
totalPrice,
removeFromCart,
updateQuantity,
clearCart,
} = useCart();
const { language } = useLanguage();
const t = getTranslation(language);
const [inquiryMode, setInquiryMode] = useState<FulfillmentMode | null>(null);
const inquiryCopy = {
pickupIntro: t.cartDrawer.inquiryPickupIntro,
deliveryIntro: t.cartDrawer.inquiryDeliveryIntro,
messageTotal: t.cartDrawer.messageTotal,
messageName: t.cartDrawer.messageName,
messagePhone: t.cartDrawer.messagePhone,
messageAddress: t.cartDrawer.messageAddress,
messagePreferredTime: t.cartDrawer.messagePreferredTime,
deliverySwishNote: t.cartDrawer.deliverySwishNote,
};
const openWhatsApp = (message: string | null) => {
if (!message) return;
container.messagingGateway.openWhatsApp(message);
setInquiryMode(null);
};
const handlePickupSubmit = (details: PickupInquiryDetails) => {
openWhatsApp(buildPickupInquiryMessage(items, details, inquiryCopy));
};
const handleDeliverySubmit = (details: DeliveryInquiryDetails) => {
openWhatsApp(buildDeliveryInquiryMessage(items, details, inquiryCopy));
};
if (!isOpen) return null;
return (
<>
<div
className="fixed inset-0 bg-black/40 z-[980]"
onClick={closeCart}
/>
<div className="fixed top-0 right-0 h-full w-full max-w-md bg-[#F8F5F0] z-[990] 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">
<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>
<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>
<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>
{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-4">
{t.cartDrawer.inquiryHint}
</p>
<button
type="button"
onClick={() => setInquiryMode('pickup')}
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"
>
<MessageCircle className="h-5 w-5" aria-hidden />
{t.cartDrawer.pickupInquiry}
</button>
<button
type="button"
onClick={() => setInquiryMode('delivery')}
className="btn-outline w-full py-4 rounded-full text-base tracking-[0.5px] font-medium mb-2 flex items-center justify-center gap-2 border-[#25D366] text-[#128C7E] hover:bg-[#25D366]/10"
>
<MessageCircle className="h-5 w-5" aria-hidden />
{t.cartDrawer.deliveryInquiry}
</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>
</div>
)}
</div>
{inquiryMode && (
<CartInquiryModal
mode={inquiryMode}
isOpen={!!inquiryMode}
onClose={() => setInquiryMode(null)}
onSubmitPickup={handlePickupSubmit}
onSubmitDelivery={handleDeliverySubmit}
labels={{
titlePickup: t.cartDrawer.inquiryModalTitlePickup,
titleDelivery: t.cartDrawer.inquiryModalTitleDelivery,
nameLabel: t.cartDrawer.nameLabel,
namePlaceholder: t.cartDrawer.namePlaceholder,
phoneLabel: t.cartDrawer.phoneLabel,
phonePlaceholder: t.cartDrawer.phonePlaceholder,
addressLabel: t.cartDrawer.addressLabel,
addressPlaceholder: t.cartDrawer.addressPlaceholder,
preferredTimeLabel: t.cartDrawer.preferredTimeLabel,
preferredTimePlaceholder: t.cartDrawer.preferredTimePlaceholder,
submitPickup: t.cartDrawer.submitPickup,
submitDelivery: t.cartDrawer.submitDelivery,
cancel: t.cartDrawer.inquiryCancel,
close: t.cartDrawer.inquiryClose,
nameRequired: t.cartDrawer.nameRequired,
phoneRequired: t.cartDrawer.phoneRequired,
addressRequired: t.cartDrawer.addressRequired,
timeRequired: t.cartDrawer.timeRequired,
}}
/>
)}
</>
);
}
+254
View File
@@ -0,0 +1,254 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { MessageCircle, X } from 'lucide-react';
import type { FulfillmentMode } from '@/domain/cart/inquiry';
import type { DeliveryInquiryDetails, PickupInquiryDetails } from '@/domain/cart/inquiry';
export interface CartInquiryModalLabels {
titlePickup: string;
titleDelivery: string;
nameLabel: string;
namePlaceholder: string;
phoneLabel: string;
phonePlaceholder: string;
addressLabel: string;
addressPlaceholder: string;
preferredTimeLabel: string;
preferredTimePlaceholder: string;
submitPickup: string;
submitDelivery: string;
cancel: string;
close: string;
nameRequired: string;
phoneRequired: string;
addressRequired: string;
timeRequired: string;
}
interface CartInquiryModalProps {
mode: FulfillmentMode;
isOpen: boolean;
onClose: () => void;
onSubmitPickup: (details: PickupInquiryDetails) => void;
onSubmitDelivery: (details: DeliveryInquiryDetails) => void;
labels: CartInquiryModalLabels;
}
const emptyPickup = (): PickupInquiryDetails => ({ name: '', phone: '' });
const emptyDelivery = (): DeliveryInquiryDetails => ({
name: '',
phone: '',
address: '',
preferredTime: '',
});
export default function CartInquiryModal({
mode,
isOpen,
onClose,
onSubmitPickup,
onSubmitDelivery,
labels,
}: CartInquiryModalProps) {
const [pickup, setPickup] = useState(emptyPickup);
const [delivery, setDelivery] = useState(emptyDelivery);
const [errors, setErrors] = useState<Record<string, string>>({});
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);
useEffect(() => {
if (!isOpen) return;
setErrors({});
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleKeyDown, mode]);
useEffect(() => {
if (!isOpen) {
setPickup(emptyPickup());
setDelivery(emptyDelivery());
setErrors({});
}
}, [isOpen]);
if (!isOpen) return null;
const title = mode === 'pickup' ? labels.titlePickup : labels.titleDelivery;
const submitLabel = mode === 'pickup' ? labels.submitPickup : labels.submitDelivery;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const nextErrors: Record<string, string> = {};
if (mode === 'pickup') {
if (!pickup.name.trim()) nextErrors.name = labels.nameRequired;
if (!pickup.phone.trim()) nextErrors.phone = labels.phoneRequired;
if (Object.keys(nextErrors).length) {
setErrors(nextErrors);
return;
}
onSubmitPickup(pickup);
return;
}
if (!delivery.name.trim()) nextErrors.name = labels.nameRequired;
if (!delivery.phone.trim()) nextErrors.phone = labels.phoneRequired;
if (!delivery.address.trim()) nextErrors.address = labels.addressRequired;
if (!delivery.preferredTime.trim()) nextErrors.preferredTime = labels.timeRequired;
if (Object.keys(nextErrors).length) {
setErrors(nextErrors);
return;
}
onSubmitDelivery(delivery);
};
const inputClass =
'mt-1.5 w-full rounded-xl border border-[#EDE6D9] bg-[#FFFCF7] px-3 py-2.5 text-sm text-[#2C2A26] placeholder:text-[#8A8478] focus:border-[#c99a2e] focus:ring-2 focus:ring-[#c99a2e]/20';
return (
<>
<div
className="fixed inset-0 z-[1000] bg-black/50 backdrop-blur-[2px]"
onClick={onClose}
aria-hidden
/>
<div
className="fixed inset-0 z-[1010] flex items-end sm:items-center justify-center p-0 sm:p-6 pointer-events-none"
role="dialog"
aria-modal="true"
aria-labelledby="cart-inquiry-title"
>
<div
className="pointer-events-auto w-full sm:max-w-md max-h-[90dvh] overflow-y-auto rounded-t-3xl sm:rounded-2xl bg-[#FFFCF7] border border-[#EDE6D9] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[#EDE6D9] px-6 py-4">
<h2 id="cart-inquiry-title" className="font-serif text-xl tracking-[-0.3px] text-[#101724]">
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label={labels.close}
className="flex h-9 w-9 items-center justify-center rounded-full border border-[#EDE6D9] bg-white text-[#6B665F] hover:text-[#101724]"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-6">
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
{labels.nameLabel}
</label>
<input
type="text"
value={mode === 'pickup' ? pickup.name : delivery.name}
onChange={(e) => {
const v = e.target.value;
if (mode === 'pickup') setPickup((p) => ({ ...p, name: v }));
else setDelivery((d) => ({ ...d, name: v }));
if (errors.name) setErrors((err) => ({ ...err, name: '' }));
}}
placeholder={labels.namePlaceholder}
className={inputClass}
autoComplete="name"
/>
{errors.name && (
<p className="mt-1 text-xs text-[#B45309]" role="alert">{errors.name}</p>
)}
</div>
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
{labels.phoneLabel}
</label>
<input
type="tel"
value={mode === 'pickup' ? pickup.phone : delivery.phone}
onChange={(e) => {
const v = e.target.value;
if (mode === 'pickup') setPickup((p) => ({ ...p, phone: v }));
else setDelivery((d) => ({ ...d, phone: v }));
if (errors.phone) setErrors((err) => ({ ...err, phone: '' }));
}}
placeholder={labels.phonePlaceholder}
className={inputClass}
autoComplete="tel"
/>
{errors.phone && (
<p className="mt-1 text-xs text-[#B45309]" role="alert">{errors.phone}</p>
)}
</div>
{mode === 'delivery' && (
<>
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
{labels.addressLabel}
</label>
<textarea
value={delivery.address}
onChange={(e) => {
setDelivery((d) => ({ ...d, address: e.target.value }));
if (errors.address) setErrors((err) => ({ ...err, address: '' }));
}}
placeholder={labels.addressPlaceholder}
rows={3}
className={`${inputClass} resize-none`}
/>
{errors.address && (
<p className="mt-1 text-xs text-[#B45309]" role="alert">{errors.address}</p>
)}
</div>
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
{labels.preferredTimeLabel}
</label>
<input
type="text"
value={delivery.preferredTime}
onChange={(e) => {
setDelivery((d) => ({ ...d, preferredTime: e.target.value }));
if (errors.preferredTime) setErrors((err) => ({ ...err, preferredTime: '' }));
}}
placeholder={labels.preferredTimePlaceholder}
className={inputClass}
/>
{errors.preferredTime && (
<p className="mt-1 text-xs text-[#B45309]" role="alert">{errors.preferredTime}</p>
)}
</div>
</>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-full border border-[#EDE6D9] py-3 text-sm font-medium text-[#6B665F] hover:bg-[#F8F5F0]"
>
{labels.cancel}
</button>
<button
type="submit"
className="btn-primary flex flex-1 items-center justify-center gap-2 rounded-full py-3 text-sm font-medium tracking-wide"
>
<MessageCircle className="h-4 w-4" aria-hidden />
{submitLabel}
</button>
</div>
</form>
</div>
</div>
</>
);
}
+122
View File
@@ -0,0 +1,122 @@
'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>
);
}
+152
View File
@@ -0,0 +1,152 @@
'use client';
import { useEffect, useCallback } from 'react';
import { X } from 'lucide-react';
import type { MenuItem } from '@/domain/menu/entities';
import { getDishIngredients } from '@/domain/menu/dish-details';
import {
applyNextImageFallback,
dishImageUrl,
getMenuPosterCandidates,
getMenuVideoSrc,
} from '@/lib/assets';
interface DishDetailsModalProps {
item: MenuItem;
description: string;
isOpen: boolean;
onClose: () => void;
labels: {
ingredients: string;
close: string;
};
}
export default function DishDetailsModal({
item,
description,
isOpen,
onClose,
labels,
}: DishDetailsModalProps) {
const videoSrc = getMenuVideoSrc(item);
const imageSrc = item.image ? dishImageUrl(item.image) : null;
const ingredients = getDishIngredients(item.id);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);
useEffect(() => {
if (!isOpen) return;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = '';
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return (
<>
<div
className="fixed inset-0 z-[1000] bg-black/45 backdrop-blur-[2px]"
onClick={onClose}
aria-hidden
/>
<div
className="fixed inset-0 z-[1010] flex items-end sm:items-center justify-center p-0 sm:p-6 pointer-events-none"
role="dialog"
aria-modal="true"
aria-labelledby="dish-detail-title"
>
<div
className="pointer-events-auto w-full sm:max-w-lg max-h-[92dvh] sm:max-h-[88dvh] overflow-y-auto rounded-t-3xl sm:rounded-2xl bg-[#FFFCF7] border border-[#EDE6D9] shadow-2xl shadow-[#101724]/15"
onClick={(e) => e.stopPropagation()}
>
<div className="relative bg-[#F2EDE4]">
{videoSrc ? (
<video
src={videoSrc}
autoPlay
loop
muted
playsInline
className="w-full h-[240px] sm:h-[280px] object-cover bg-[#F2EDE4]"
/>
) : imageSrc ? (
<img
src={imageSrc}
alt={item.name}
className="w-full h-[240px] sm:h-[280px] object-cover bg-[#F2EDE4]"
onError={(e) => {
applyNextImageFallback(
e.target as HTMLImageElement,
getMenuPosterCandidates(item),
(e.target as HTMLImageElement).src,
);
}}
/>
) : (
<div className="w-full aspect-[4/3] flex items-center justify-center text-sm text-[#8A8478]">
No media available
</div>
)}
<button
type="button"
onClick={onClose}
aria-label={labels.close}
className="absolute top-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-white/90 text-[#2C2A26] shadow-md border border-[#EDE6D9] hover:bg-white hover:text-[#0f5a4a] transition-colors"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<div className="p-6 sm:p-7 space-y-5">
<h2
id="dish-detail-title"
className="font-serif text-2xl sm:text-[26px] tracking-[-0.4px] text-[#2C2A26]"
>
{item.name}
</h2>
{description && (
<p className="text-[#6B665F] text-[15px] leading-relaxed">
{description}
</p>
)}
{ingredients.length > 0 && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-[0.2em] text-[#c99a2e] mb-3">
{labels.ingredients}
</h3>
<ul className="space-y-2">
{ingredients.map((ingredient) => (
<li
key={ingredient}
className="flex items-start gap-2.5 text-[15px] text-[#2C2A26] leading-snug"
>
<span
className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-[#B38B4D]"
aria-hidden
/>
{ingredient}
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</>
);
}
+261
View File
@@ -0,0 +1,261 @@
'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>
);
}
+12
View File
@@ -0,0 +1,12 @@
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 }}
/>
);
}
+59
View File
@@ -0,0 +1,59 @@
'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>
);
}
+328
View File
@@ -0,0 +1,328 @@
"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 &amp; bookings</p>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}
+507
View File
@@ -0,0 +1,507 @@
'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>
);
}
+46
View File
@@ -0,0 +1,46 @@
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>
);
}
+96
View File
@@ -0,0 +1,96 @@
"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>
);
}
+564
View File
@@ -0,0 +1,564 @@
'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 (11am3pm)</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-semibold text-[#B38B4D]">149 kr</span>
<span className="text-base line-through text-[#8A8478]">189 kr</span>
</div>
<div className="text-[10px] text-[#B38B4D] font-medium">SAVE 40 KR MonFri only</div>
</div>
{/* Offer 3 */}
<div className="bg-white rounded-3xl border border-[#EDE6D9] shadow-xl p-4 flex flex-col">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[#B38B4D] text-xs tracking-[2px] font-semibold">OFFER 3</div>
</div>
<div className="font-serif text-xl tracking-tight text-[#101724] leading-tight">PIZZA & ROLL DEAL</div>
<div className="text-xs text-[#6B665F] mt-0.5 flex-1">Any 2 Pizzas + Any 2 Rolls + 2 Drinks</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-semibold text-[#B38B4D]">229 kr</span>
<span className="text-base line-through text-[#8A8478]">299 kr</span>
</div>
<div className="text-[10px] text-[#B38B4D] font-medium">SAVE 70 KR Great for sharing</div>
</div>
{/* Offer 4 */}
<div className="bg-white rounded-3xl border border-[#EDE6D9] shadow-xl p-4 flex flex-col">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[#B38B4D] text-xs tracking-[2px] font-semibold">OFFER 4</div>
<div className="text-[10px] px-2 py-0.5 rounded-full bg-[#3F5C4A] text-white tracking-wider">VEG</div>
</div>
<div className="font-serif text-xl tracking-tight text-[#101724] leading-tight">VEG FEAST FOR 4</div>
<div className="text-xs text-[#6B665F] mt-0.5 flex-1">4 Veg Curries + 4 Naan + 2 Rice + 4 Drinks</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-semibold text-[#B38B4D]">599 kr</span>
<span className="text-base line-through text-[#8A8478]">749 kr</span>
</div>
<div className="text-[10px] text-[#B38B4D] font-medium">SAVE 150 KR Pure veg delight</div>
</div>
</div>
</div>
</div>
{/* Very subtle footer branding */}
<div className="relative z-10 py-2 text-center text-[10px] tracking-[2.5px] text-[#8A8478] border-t border-[#EDE6D9]/60 bg-[#F8F5F0] flex-none">
SHAHI KITCHEN GOTHENBURG FRESH EVERY DAY
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
'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 flex-col overflow-hidden rounded-[1.75rem] border border-[#EDE6D9] bg-white shadow-sm transition-shadow hover:border-[#c99a2e]/45 hover:shadow-md"
>
<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={() => 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>
);
}
+43
View File
@@ -0,0 +1,43 @@
'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>
);
}
+9
View File
@@ -0,0 +1,9 @@
/**
* @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';
+172
View File
@@ -0,0 +1,172 @@
'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>
</>
);
}