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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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> Mon–Sun 11:00–21: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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user