Initial commit: Shahi Kitchen premium website

- Royal cream + gold theme
- Playful animated hero with chef mascot
- Advanced menu with sidebar + video hover
- Multilingual support (EN, SV, HI, UR)
- Cart system with WhatsApp ordering
- Real restaurant photos integration
- Responsive design with proper navbar
This commit is contained in:
Zeeshan Khan
2026-06-01 15:14:19 +02:00
parent edd906d893
commit 56fe68eb48
314 changed files with 4129 additions and 111 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>
);
}
+310
View File
@@ -0,0 +1,310 @@
'use client';
/**
* =============================================================================
* GLOBAL CART SYSTEM — Shahi Kitchen
* =============================================================================
*
* This file implements a production-grade, persistent shopping cart using:
* • React Context + useReducer (predictable state machine)
* • localStorage persistence (survives page refresh / browser restart)
* • Sonner toasts with "View Cart" action
* • Derived totals (totalItems, totalPrice) computed on every render
*
* WHY THIS ARCHITECTURE?
* - useReducer gives us a single source of truth and easy debugging (time-travel capable)
* - Context lets ANY component (menu cards, navbar icon, drawer, future checkout)
* access cart state without prop drilling
* - localStorage key is namespaced ("shahi-kitchen-cart") so multiple sites on same
* domain won't collide
*
* IMPORTANT INTEGRATION POINTS:
* - CartProvider is mounted ONCE in app/layout.tsx (root of the tree)
* - useCart() hook is used in:
* • app/menu/page.tsx → addToCart() on every dish
* • components/Navbar.tsx → shows live count + opens drawer
* • components/CartDrawer.tsx → full management UI + WhatsApp ordering
*
* WHATSAPP ORDER FLOW (the "checkout" for this restaurant):
* The cart never talks to a real payment gateway. Instead, the drawer builds a
* beautifully formatted message and opens https://wa.me/46709864995 with it pre-filled.
* This matches exactly how the reservation form on the homepage works.
*
* FUTURE IMPROVEMENTS (documented for next developer):
* - Add "special instructions" per item or for the whole order
* - Persist cart across different devices via Supabase / Firebase (when real ordering launches)
* - Add estimated pickup time or "order for later" picker
* - Minimum order value enforcement
*
* GOTCHAS:
* - The localStorage restore logic runs only once on mount. It dispatches multiple
* actions on purpose so the reducer stays pure.
* - Toasts are intentionally fired ONLY from addToCart(), never from restore.
*/
import React, { createContext, useContext, useReducer, useEffect, ReactNode } from 'react';
import { toast } from 'sonner';
/**
* TYPE DEFINITIONS
*
* CartItem — the shape stored in state and localStorage.
* image is optional because some drinks don't have photos yet.
*
* CartState — minimal internal state. We derive totalItems/totalPrice in the provider
* so consumers never have to recalculate.
*
* CartAction — discriminated union. This is what makes the reducer easy to reason about.
*/
export interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
image?: string;
}
interface CartState {
items: CartItem[];
isOpen: boolean;
}
type CartAction =
| { type: 'ADD_ITEM'; payload: Omit<CartItem, 'quantity'> }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'CLEAR_CART' }
| { type: 'TOGGLE_CART' }
| { type: 'OPEN_CART' }
| { type: 'CLOSE_CART' };
interface CartContextType {
items: CartItem[];
isOpen: boolean;
totalItems: number;
totalPrice: number;
addToCart: (item: Omit<CartItem, 'quantity'>) => void;
removeFromCart: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
toggleCart: () => void;
openCart: () => void;
closeCart: () => void;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
/**
* THE REDUCER — Single Source of Truth for All Cart Mutations
*
* This is a classic Redux-style reducer: pure, predictable, easy to test.
*
* DESIGN DECISIONS:
* - ADD_ITEM automatically increments quantity if the dish already exists
* (very common restaurant UX — user taps the same samosa twice)
* - UPDATE_QUANTITY with quantity <= 0 removes the item (no negative quantities)
* - Only the drawer can toggle isOpen. Menu cards only ever call addToCart().
* - CLEAR_CART is exposed for the "Clear basket" buttons in the drawer header/footer.
*/
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM': {
const existingItem = state.items.find(item => item.id === action.payload.id);
if (existingItem) {
// Increment existing line item (most common case after first add)
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
} else {
// Brand new line item
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }],
};
}
}
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(item => item.id !== action.payload),
};
case 'UPDATE_QUANTITY':
if (action.payload.quantity <= 0) {
// Treat zero or negative as "remove this line"
return {
...state,
items: state.items.filter(item => item.id !== action.payload.id),
};
}
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
),
};
case 'CLEAR_CART':
return { ...state, items: [] };
case 'TOGGLE_CART':
return { ...state, isOpen: !state.isOpen };
case 'OPEN_CART':
return { ...state, isOpen: true };
case 'CLOSE_CART':
return { ...state, isOpen: false };
default:
return state;
}
}
/**
* PERSISTENCE LAYER
*
* We use a single well-namespaced localStorage key so the cart survives:
* - Page refresh
* - Browser restart
* - User navigating away and coming back days later
*
* The restore logic is intentionally verbose (multiple dispatches) because we want
* the reducer to remain the only place that understands "how to add an item".
* This keeps the restore path debuggable and future-proof.
*/
const CART_STORAGE_KEY = 'shahi-kitchen-cart';
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
isOpen: false,
});
// ONE-TIME HYDRATION FROM LOCALSTORAGE
// Runs only on initial mount. Restores previous session's cart silently (no toasts).
useEffect(() => {
const savedCart = localStorage.getItem(CART_STORAGE_KEY);
if (savedCart) {
try {
const parsedItems = JSON.parse(savedCart);
// We replay through the reducer so all business rules stay in one place
parsedItems.forEach((item: CartItem) => {
dispatch({ type: 'ADD_ITEM', payload: item });
// If the saved quantity was > 1 we need a second dispatch to correct it
if (item.quantity > 1) {
dispatch({
type: 'UPDATE_QUANTITY',
payload: { id: item.id, quantity: item.quantity }
});
}
});
} catch (error) {
console.error('Failed to load cart from localStorage');
}
}
}, []);
// AUTO-PERSIST ON EVERY CHANGE
// Extremely simple and reliable. In a future real ordering system you would
// debounce this and also sync to a backend.
useEffect(() => {
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(state.items));
}, [state.items]);
/**
* DERIVED STATE (recomputed on every render — cheap and always fresh)
*/
const totalItems = state.items.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
/**
* PUBLIC API — these functions are what the rest of the app calls.
*
* addToCart is special: it also triggers the beautiful Sonner toast with
* an action button that opens the drawer. This is the primary feedback mechanism.
*/
const addToCart = (item: Omit<CartItem, 'quantity'>) => {
dispatch({ type: 'ADD_ITEM', payload: item });
toast.success(`Added ${item.name} to cart`, {
description: `${item.price} kr`,
action: {
label: "View Cart",
onClick: () => dispatch({ type: 'OPEN_CART' }),
},
});
};
const removeFromCart = (id: string) => {
dispatch({ type: 'REMOVE_ITEM', payload: id });
};
const updateQuantity = (id: string, quantity: number) => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } });
};
const clearCart = () => {
dispatch({ type: 'CLEAR_CART' });
};
const toggleCart = () => {
dispatch({ type: 'TOGGLE_CART' });
};
const openCart = () => {
dispatch({ type: 'OPEN_CART' });
};
const closeCart = () => {
dispatch({ type: 'CLOSE_CART' });
};
/**
* CONTEXT PROVIDER VALUE
* Everything a consumer might need is exposed here.
* We intentionally do NOT expose dispatch directly — all mutations go through
* the named methods above (better encapsulation + future-proofing).
*/
return (
<CartContext.Provider
value={{
items: state.items,
isOpen: state.isOpen,
totalItems,
totalPrice,
addToCart,
removeFromCart,
updateQuantity,
clearCart,
toggleCart,
openCart,
closeCart,
}}
>
{children}
</CartContext.Provider>
);
}
/**
* useCart — The only way the rest of the app should consume cart state/actions.
*
* Throws a clear error if someone tries to use it outside the provider tree
* (catches 99% of integration mistakes during development).
*/
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}
+225
View File
@@ -0,0 +1,225 @@
'use client';
/**
* =============================================================================
* CART DRAWER (SLIDE-IN BASKET PANEL)
* =============================================================================
*
* This is the visual "basket" that slides in from the right when the user
* clicks the cart icon in the navbar or the "View Cart" toast action.
*
* KEY DESIGN CHOICES:
* - Fixed position, full-height, max-w-md (beautiful on both mobile and desktop)
* - Backdrop click closes it (standard mobile pattern)
* - z-[70] sits above almost everything (including the sticky category nav)
* - All cart mutations go through the context — this component is "dumb" UI only
*
* THE ORDERING FLOW (very important for restaurant context):
* Instead of a traditional Stripe checkout, we generate a pre-filled WhatsApp
* message containing every line item + quantities + grand total.
* This matches the restaurant's current real-world ordering process.
* The number 46709864995 is the same one used in the homepage reservation form.
*
* FUTURE ENHANCEMENTS (documented here so nothing is forgotten):
* - Add "Special requests" textarea per order
* - Show estimated preparation time
* - Allow "Order for later" date/time picker
* - Split "Pickup" vs "Delivery" with different messaging
* - After WhatsApp opens, optionally clear the cart (or keep it — current choice)
*/
import { useCart } from './CartContext';
import Link from 'next/link';
export default function CartDrawer() {
const {
items,
isOpen,
closeCart,
totalPrice,
removeFromCart,
updateQuantity,
clearCart
} = useCart();
/**
* WHATSAPP DEEP LINK ORDERING
*
* Builds a human-readable, copy-paste friendly message that the restaurant staff
* can immediately understand and action.
*
* The format deliberately mirrors how the restaurant currently receives orders
* over the phone or via Instagram DMs.
*/
const orderViaWhatsApp = () => {
if (items.length === 0) return;
const lines = items
.map((item) => `${item.quantity} × ${item.name}${item.price * item.quantity} kr`)
.join('\n');
const message =
`Hello Shahi Kitchen 👋
I would like to place an order:
${lines}
Total: ${totalPrice} kr
Thank you!`;
const encoded = encodeURIComponent(message);
// This is the same WhatsApp business number used for table reservations on the homepage
window.open(`https://wa.me/46709864995?text=${encoded}`, '_blank');
};
// Guard clause — drawer only renders when explicitly opened via context
if (!isOpen) return null;
return (
<>
{/* SEMI-TRANSPARENT BACKDROP */}
{/* Clicking anywhere outside the drawer closes it (standard mobile pattern) */}
<div
className="fixed inset-0 bg-black/40 z-[60]"
onClick={closeCart}
/>
{/* THE ACTUAL DRAWER — slides in from right */}
{/* z-[70] ensures it sits above sticky nav, category pills, and most other UI */}
<div className="fixed top-0 right-0 h-full w-full max-w-md bg-[#F8F5F0] z-[70] shadow-2xl flex flex-col">
{/* HEADER — Title + item count + quick clear + close button */}
<div className="flex items-center justify-between p-6 border-b border-[#EDE6D9]">
<div className="flex items-center gap-3">
<h2 className="text-2xl tracking-[-0.5px]">Your Basket</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 ? 'item' : 'items'}
</span>
)}
</div>
<div className="flex items-center gap-3">
{items.length > 0 && (
<button
onClick={clearCart}
className="text-xs text-[#B38B4D] hover:underline"
>
Clear
</button>
)}
<button
onClick={closeCart}
className="text-[#6B665F] hover:text-[#2C2A26] text-2xl leading-none pl-1"
>
×
</button>
</div>
</div>
{/* SCROLLABLE CONTENT AREA */}
{/* Empty state is friendly and routes the user back to the menu */}
<div className="flex-1 overflow-y-auto p-6">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="text-6xl mb-4">🛒</div>
<p className="text-lg text-[#6B665F]">Your basket is empty</p>
<Link
href="/menu"
onClick={closeCart}
className="mt-6 text-[#B38B4D] hover:underline"
>
Browse the menu
</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.price} kr × {item.quantity}</p>
</div>
<div className="text-right font-medium">
{(item.price * item.quantity).toFixed(0)} kr
</div>
</div>
{/* Quantity controls */}
<div className="flex items-center gap-3 mt-3">
<button
onClick={() => updateQuantity(item.id, item.quantity - 1)}
className="w-8 h-8 flex items-center justify-center border border-[#EDE6D9] rounded hover:bg-white transition"
>
</button>
<span className="w-6 text-center font-medium">{item.quantity}</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"
>
Remove
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* STICKY FOOTER — always visible when cart has items */}
{/* Contains the two primary actions: WhatsApp (primary) + Phone (secondary) */}
{items.length > 0 && (
<div className="p-6 border-t border-[#EDE6D9] bg-white">
<button
onClick={clearCart}
className="text-xs text-[#8A8478] hover:text-[#B38B4D] mb-3 underline"
>
Clear basket
</button>
<div className="flex justify-between text-lg font-medium mb-4">
<span>Total</span>
<span>{totalPrice.toFixed(0)} kr</span>
</div>
<button
onClick={orderViaWhatsApp}
className="btn-primary w-full py-4 rounded-full text-base tracking-[0.5px] font-medium mb-2 flex items-center justify-center gap-2"
>
Send order via WhatsApp
</button>
<button
onClick={() => window.open('tel:031288910', '_self')}
className="btn-outline w-full py-3 rounded-full text-sm tracking-[0.5px] font-medium mb-3"
>
Call 031-28 89 10
</button>
<button
onClick={closeCart}
className="w-full text-sm text-[#6B665F] hover:text-[#2C2A26] pt-1"
>
Continue browsing
</button>
<p className="text-[10px] text-center text-[#8A8478] mt-4">
WhatsApp opens with your order pre-filled. We will confirm availability.
</p>
</div>
)}
</div>
</>
);
}
+112
View File
@@ -0,0 +1,112 @@
/**
* GLOBAL FOOTER
*
* Appears at the bottom of every page.
*
* Contains:
* - Brand + short tagline
* - Both physical locations with full addresses + phone/email
* - Opening hours (different per branch — Backaplan is more variable)
* - Quick links + social profiles
*
* Note: The phone numbers and addresses here are the canonical source.
* If the restaurant ever changes them, update this file + the contact section
* on the homepage + the locations page.
*/
import Link from "next/link";
export default function Footer() {
return (
<footer className="bg-[#F5F1E9] border-t border-[#EDE6D9] pt-14 pb-10 text-[#6B665F]">
<div className="max-w-7xl mx-auto px-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-y-12 gap-x-8">
{/* Brand */}
<div>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl overflow-hidden border border-[#c99a2e]/30 bg-white p-0.5 shadow-sm">
<img
src="/images/logo-shahi-chef-icon.jpg"
alt="Shahi Kitchen Chef Logo"
className="w-full h-full object-contain"
/>
</div>
<span className="text-[#2C2A26] font-medium tracking-[-0.3px]">Shahi Kitchen</span>
</div>
<p className="text-sm leading-relaxed">
Authentic Indian &amp; Pakistani cuisine in Gothenburg since 2016.
</p>
</div>
{/* Contact - Both Locations */}
<div>
<div className="text-[#2C2A26] text-sm tracking-[1.5px] mb-4">OUR LOCATIONS</div>
<div className="space-y-4 text-sm">
<div>
<div className="font-medium text-[#2C2A26]">Shahi Kitchen (Askim / Sisjön)</div>
<div>Datavägen 10A, 436 32 Askim</div>
</div>
<div>
<div className="font-medium text-[#2C2A26]">Shahi Sweets (Backaplan)</div>
<div>Krokegårdsgatan 5, 417 30 Göteborg</div>
</div>
<div>
<a href="tel:0739381089" className="block hover:text-[#B38B4D] transition-colors">
0739-381089
</a>
<a href="mailto:hello@shahikitchen.se" className="block hover:text-[#B38B4D] transition-colors">
hello@shahikitchen.se
</a>
</div>
</div>
</div>
{/* Hours */}
<div>
<div className="text-[#2C2A26] text-sm tracking-[1.5px] mb-4">OPENING HOURS</div>
<div className="text-sm space-y-1">
<div><span className="font-medium">Askim:</span> MonSun 11:0021:00</div>
<div><span className="font-medium">Backaplan:</span> Check Instagram for current hours</div>
</div>
</div>
{/* Quick Links + Social */}
<div>
<div className="text-[#2C2A26] text-sm tracking-[1.5px] mb-4">EXPLORE</div>
<div className="flex flex-col gap-1.5 text-sm mb-8">
<Link href="/" className="hover:text-[#B38B4D] transition-colors">Home</Link>
<Link href="/menu" className="hover:text-[#B38B4D] transition-colors">Menu</Link>
<Link href="/#experience" className="hover:text-[#B38B4D] transition-colors">Our Experience</Link>
<Link href="/#contact" className="hover:text-[#B38B4D] transition-colors">Contact &amp; Reserve</Link>
</div>
<div className="text-[#2C2A26] text-sm tracking-[1.5px] mb-3">FOLLOW US</div>
<div className="flex gap-5 text-sm">
<a
href="https://www.instagram.com/Shahikitchen/"
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#B38B4D] transition-colors"
>
Instagram
</a>
<a
href="https://www.facebook.com/shahikitchengbg/"
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#B38B4D] transition-colors"
>
Facebook
</a>
</div>
</div>
</div>
<div className="mt-14 pt-8 border-t border-[#EDE6D9] text-xs tracking-widest flex flex-col md:flex-row md:items-center justify-between gap-y-2 text-[#8A8478]">
<div>© {new Date().getFullYear()} SHAHI KITCHEN GOTHENBURG. ALL RIGHTS RESERVED.</div>
<div>Made with tradition and heart.</div>
</div>
</div>
</footer>
);
}
+62
View File
@@ -0,0 +1,62 @@
'use client';
import { useState } from 'react';
import { useLanguage } from '@/lib/language-context';
import { languages, Language } from '@/lib/translations';
import { ChevronDown } from 'lucide-react';
export default function LanguageSwitcher() {
const { language, setLanguage } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const currentLang = languages.find(l => l.code === language)!;
const handleSelect = (lang: Language) => {
setLanguage(lang);
setIsOpen(false);
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1.5 rounded-full border border-[#c99a2e]/30 bg-white/70 px-3 py-1.5 text-sm font-medium text-[#101724] backdrop-blur-xl transition hover:border-[#c99a2e] hover:bg-white"
>
<span className="text-base">{currentLang.flag}</span>
<span className="hidden sm:inline text-xs font-semibold tracking-wider">{currentLang.native}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
{/* Dropdown */}
<div className="absolute right-0 top-full mt-2 z-50 w-44 rounded-2xl border border-[#c99a2e]/20 bg-[#fbf7ef] shadow-2xl overflow-hidden">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => handleSelect(lang.code)}
className={`w-full flex items-center gap-3 px-4 py-3 text-left text-sm transition hover:bg-[#fff6dc] ${
language === lang.code
? 'bg-[#fff6dc] text-[#0f5a4a] font-semibold'
: 'text-[#101724]'
}`}
>
<span className="text-xl">{lang.flag}</span>
<div className="flex flex-col">
<span>{lang.native}</span>
<span className="text-[10px] text-[#8a6a25] -mt-0.5">{lang.name}</span>
</div>
</button>
))}
</div>
</>
)}
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useCart } from "./CartContext";
import { ShoppingBag, ArrowRight } from "lucide-react";
import LanguageSwitcher from "./LanguageSwitcher";
import { useLanguage } from "@/lib/language-context";
import { getTranslation } from "@/lib/translations";
import { motion } 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 { 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: "/locations", label: t.nav.locations },
{ 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 (
<nav
className="fixed top-0 left-0 right-0 z-50 h-16 border-b border-[#c99a2e]/10 bg-[#fbf7ef]/80 backdrop-blur-3xl"
>
<div className="max-w-7xl mx-auto px-6 flex items-center justify-between h-full">
{/* Subtle gold accent line at the very bottom */}
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[#c99a2e]/30 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="/images/logo-shahi-chef-icon.jpg"
alt="Shahi Kitchen - Cute Chef with Large Mustaches"
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]">Royal Taste Gothenburg</span>
</span>
</Link>
{/* Desktop Navigation - Premium Mesmerizing Design */}
<div className="hidden md:block">
<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-5 py-2 rounded-full transition-colors hover:text-[#0f5a4a] z-10"
>
<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">
<LanguageSwitcher />
{/* 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="/#contact"
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 */}
<div className="md:hidden flex items-center gap-3">
{/* MOBILE CART ICON (always visible even when hamburger is closed) */}
<button
onClick={openCart}
className="relative flex items-center justify-center w-9 h-9 rounded-full hover:bg-[#F5F1E9] transition-colors"
aria-label="Open cart"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{totalItems > 0 && (
<span className="absolute -top-1 -right-1 bg-[#B38B4D] text-white text-[10px] font-medium min-w-[16px] h-[16px] rounded-full flex items-center justify-center px-1">
{totalItems}
</span>
)}
</button>
<button
onClick={() => setIsOpen(!isOpen)}
className="text-[#B38B4D] p-2 -mr-2"
aria-label="Toggle menu"
>
<div className="space-y-1.5">
<span className={`block h-px w-6 bg-current transition-all ${isOpen ? "rotate-45 translate-y-1.5" : ""}`} />
<span className={`block h-px w-6 bg-current transition-all ${isOpen ? "opacity-0" : ""}`} />
<span className={`block h-px w-6 bg-current transition-all ${isOpen ? "-rotate-45 -translate-y-1.5" : ""}`} />
</div>
</button>
</div>
</div>
{/* Mobile Menu - Stunning Elegant Drawer */}
{isOpen && (
<div className="md:hidden fixed inset-0 z-[60] bg-[#101724]/60 backdrop-blur-md">
<div className="ml-auto h-full w-[82%] max-w-[320px] bg-[#fbf7ef] p-8 shadow-2xl border-l border-[#c99a2e]/10">
<div className="flex items-center justify-between mb-10">
<div className="flex items-center gap-3">
<img
src="/images/logo-shahi-chef-icon.jpg"
alt="Shahi Kitchen"
className="h-12 w-12 rounded-xl object-contain"
/>
<span className="font-serif text-xl text-[#101724]">Shahi Kitchen</span>
</div>
<button onClick={closeMenu} className="grid h-10 w-10 place-items-center rounded-full bg-[#f3f5f7] text-[#101724]">
<span className="text-2xl leading-none">×</span>
</button>
</div>
<div className="flex flex-col gap-5 text-xl font-medium text-[#101724]">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={closeMenu}
className="py-1 border-b border-[#e5e1d7] pb-4 hover:text-[#0f5a4a] transition-colors"
>
{link.label}
</Link>
))}
</div>
<div className="mt-10 space-y-3">
<LanguageSwitcher />
<Link
href="/#contact"
onClick={closeMenu}
className="block w-full rounded-full bg-gradient-to-r from-[#c99a2e] to-[#d4a73d] py-4 text-center text-base font-bold text-[#241806] shadow-lg"
>
{t.reserve}
</Link>
<button
onClick={() => { openCart(); closeMenu(); }}
className="block w-full rounded-full border border-[#c99a2e]/40 bg-white py-4 text-base font-semibold text-[#101724]"
>
{t.cart} {totalItems > 0 && `(${totalItems})`}
</button>
</div>
</div>
</div>
)}
</nav>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
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 = () => {
if (expression === 'wink') return '/images/animation/chef-wink.jpg';
if (expression === 'smile') return '/images/animation/chef-smile.jpg';
return '/images/animation/chef-normal.jpg';
};
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-1/2 -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-[280px] h-[280px] md:w-[360px] md:h-[360px] lg:w-[420px] lg:h-[420px]"
>
<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 royal 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>
);
}