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
@@ -1,36 +1,98 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Shahikitchen — Animated Restaurant Website
|
||||
|
||||
## Getting Started
|
||||
This directory contains the official website for **Shahi Kitchen**, a popular Indian & Pakistani restaurant in Gothenburg (Göteborg), Sweden.
|
||||
|
||||
First, run the development server:
|
||||
**Domain**: [shahikitchen.se](https://shahikitchen.se)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
---
|
||||
|
||||
## About Shahi Kitchen
|
||||
|
||||
Shahi Kitchen is an authentic Indian and Pakistani restaurant specializing in flavorful curries, tandoori dishes, biryanis, haleem, fresh naan, and traditional sweets. The restaurant is particularly well-known for its generous **lunch buffet**, which offers excellent value and is popular among locals in the Gothenburg area.
|
||||
|
||||
### Key Information
|
||||
|
||||
| Detail | Information |
|
||||
|---------------------|--------------------------------------------------|
|
||||
| **Full Name** | Shahi Kitchen (Shahikitchen / Shahi Kitchen Sisjön) |
|
||||
| **Cuisine** | Indian & Pakistani (Halal options available) |
|
||||
| **Address** | Datavägen 10A, 436 32 Askim, Sweden |
|
||||
| **Area** | Sisjön / Askim (suburb of Gothenburg) |
|
||||
| **Phone** | 031-28 89 10 / 0739-381089 (Imran) |
|
||||
| **Email** | hello@shahikitchen.se |
|
||||
| **Opened** | Around 2016 |
|
||||
| **Specialties** | Lunch buffet, Biryani, Lamb Karahi, Curries, Sweets (Shahi Sweets) |
|
||||
|
||||
### Current Online Presence
|
||||
|
||||
- **Instagram**: [@shahikitchen](https://www.instagram.com/Shahikitchen/)
|
||||
- **Facebook**: [facebook.com/shahikitchengbg](https://www.facebook.com/shahikitchengbg/)
|
||||
- **Reviews**: ~4.1/5 on Google (600+ reviews)
|
||||
- **Note**: As of 2026, the domain `shahikitchen.se` is parked. This project will give the restaurant a proper modern website.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Next.js 15** (App Router) + TypeScript
|
||||
- **Tailwind CSS**
|
||||
- **GSAP** — for advanced scroll and UI animations
|
||||
- **React Three Fiber** — for 3D experiences (dishes)
|
||||
- **Spline** (optional) — for high-quality hosted 3D scenes
|
||||
- **Framer Motion** (optional alternative to GSAP for simpler animations)
|
||||
|
||||
---
|
||||
|
||||
## Project Structure (Planned)
|
||||
|
||||
```
|
||||
shahikitchen/
|
||||
├── app/
|
||||
│ ├── layout.tsx
|
||||
│ ├── page.tsx # Homepage (Hero + Menu + About + Contact)
|
||||
│ └── globals.css
|
||||
├── components/
|
||||
│ ├── MenuSection.tsx
|
||||
│ ├── Dish3D.tsx # React Three Fiber or Spline component
|
||||
│ ├── ReservationForm.tsx
|
||||
│ └── ...
|
||||
├── lib/
|
||||
│ └── menu-data.ts # Menu items
|
||||
├── public/
|
||||
│ └── images/
|
||||
└── ...
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
---
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
## Development
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
### Getting Started
|
||||
|
||||
## Learn More
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
# Run development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
### Planned Features (in order)
|
||||
|
||||
## Deploy on Vercel
|
||||
1. ✅ Create Next.js project
|
||||
2. ⏳ Build normal restaurant homepage (Hero, Navigation, About)
|
||||
3. ⏳ Add menu items (name, description, price)
|
||||
4. ⏳ Style with Tailwind CSS
|
||||
5. ⏳ Add animations (GSAP / Framer Motion)
|
||||
6. ⏳ Add 3D dishes (React Three Fiber + Spline)
|
||||
7. ⏳ Reservation / Contact form
|
||||
8. ⏳ Mobile responsive
|
||||
9. ⏳ Deploy to Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
---
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
## License
|
||||
|
||||
This project is being built for Shahi Kitchen. All rights reserved.
|
||||
|
||||
@@ -1,26 +1,107 @@
|
||||
/**
|
||||
* =============================================================================
|
||||
* GLOBAL DESIGN SYSTEM — Shahi Kitchen
|
||||
* =============================================================================
|
||||
*
|
||||
* Royal cream + gold visual identity for Shahi Kitchen.
|
||||
* Bright, warm, appetizing, and luxurious without being dark.
|
||||
*/
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
/* === Refined Royal Light Palette - Warm Cream + Gold === */
|
||||
|
||||
/* Backgrounds */
|
||||
--bg: #F8F5F0;
|
||||
--bg-elevated: #F5F1E9;
|
||||
--surface: #FFFFFF;
|
||||
--surface-subtle: #F2EDE4;
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
/* Gold System */
|
||||
--gold: #B38B4D;
|
||||
--gold-dark: #8C6B3A;
|
||||
--gold-light: #C9A46B;
|
||||
--gold-muted: #d4a73d;
|
||||
|
||||
/* Emerald for contrast (kept from recent improvements) */
|
||||
--emerald: #0f5a4a;
|
||||
|
||||
/* Text */
|
||||
--text: #2C2520;
|
||||
--text-muted: #6B665F;
|
||||
--text-light: #8A8478;
|
||||
|
||||
/* Borders */
|
||||
--border: #EDE6D9;
|
||||
|
||||
/* Typography */
|
||||
--font-display: var(--font-playfair);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
|
||||
/* Motion */
|
||||
--ease: cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
/* Base */
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Premium Buttons */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--gold), var(--gold-muted));
|
||||
color: #241806;
|
||||
font-weight: 700;
|
||||
transition: all 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
border: 1px solid var(--gold);
|
||||
color: var(--gold-dark);
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--gold);
|
||||
color: #241806;
|
||||
}
|
||||
|
||||
/* Glass effect helper */
|
||||
.glass {
|
||||
background: rgba(255, 253, 248, 0.72);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
/* Luxury card style */
|
||||
.luxury-card {
|
||||
border-radius: 2rem;
|
||||
transition: transform 0.4s var(--ease), box-shadow 0.4s var(--ease);
|
||||
}
|
||||
|
||||
.luxury-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.12);
|
||||
}
|
||||
|
||||
/* Mesmerizing shimmer animation for premium buttons (used in Navbar) */
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(200%); }
|
||||
}
|
||||
|
||||
@@ -1,22 +1,89 @@
|
||||
/**
|
||||
* ROOT LAYOUT — Shahi Kitchen (shahikitchen.se)
|
||||
*
|
||||
* This is the single root layout for the entire Next.js App Router application.
|
||||
*
|
||||
* RESPONSIBILITIES:
|
||||
* - Set up global metadata (title, description, favicon) for SEO and social sharing
|
||||
* - Load the premium typography system:
|
||||
* • Playfair Display (display/serif) → used for headings via --font-playfair
|
||||
* • Geist Sans (system UI) → body text
|
||||
* • Geist Mono → code / tabular data
|
||||
* - Initialize the GLOBAL CART SYSTEM:
|
||||
* • CartProvider wraps the whole tree so any page/component can use `useCart()`
|
||||
* • CartDrawer is rendered once at the root (slide-in basket panel)
|
||||
* • Sonner <Toaster> provides beautiful toast notifications used by the cart
|
||||
*
|
||||
* ARCHITECTURAL NOTES:
|
||||
* - We deliberately render <CartDrawer /> and <Toaster /> at the root level instead of
|
||||
* inside individual pages. This guarantees only ONE instance exists and prevents
|
||||
* duplicate drawers/toasts when navigating.
|
||||
* - The cream/gold royal theme tokens live in globals.css and are referenced via
|
||||
* Tailwind arbitrary values (e.g. bg-[#F8F5F0]).
|
||||
*
|
||||
* FUTURE DEVELOPERS:
|
||||
* - To add a new global provider (theme, analytics, etc.), wrap it here.
|
||||
* - WhatsApp number for orders is currently hardcoded in CartDrawer.tsx (46709864995).
|
||||
* - If you ever split the cart into a separate modal library, keep the provider here.
|
||||
*/
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Playfair_Display, Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "sonner";
|
||||
import { CartProvider } from "@/components/CartContext";
|
||||
import CartDrawer from "@/components/CartDrawer";
|
||||
import { LanguageProvider } from "@/lib/language-context";
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
variable: "--font-playfair",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
style: ["normal", "italic"],
|
||||
});
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Next.js Metadata API (static)
|
||||
* These values power:
|
||||
* - Browser tab title
|
||||
* - Search engine results
|
||||
* - Social cards (Open Graph / Twitter) when shared
|
||||
*
|
||||
* For dynamic per-page metadata, use `generateMetadata()` in page files.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Shahi Kitchen | Authentic Indian & Pakistani Restaurant in Gothenburg",
|
||||
description: "Experience royal flavors at Shahi Kitchen in Askim, Gothenburg. Authentic Indian & Pakistani cuisine, famous lunch buffet, and traditional sweets since 2016.",
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* RootLayout
|
||||
*
|
||||
* The ONLY place in the app where we initialize:
|
||||
* 1. CartProvider → gives every descendant access to `useCart()` hook
|
||||
* 2. CartDrawer → the actual slide-in basket UI (controlled via context)
|
||||
* 3. Toaster → global toast surface used by cart (Sonner library)
|
||||
*
|
||||
* IMPORTANT:
|
||||
* - Never wrap the entire app in another CartProvider — it will break the singleton.
|
||||
* - The body uses the royal cream background (#F8F5F0) as the base canvas.
|
||||
* - `antialiased` + font variables are applied once at the root for consistency.
|
||||
*/
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -25,9 +92,17 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
className={`${playfair.variable} ${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col bg-[#F8F5F0] text-[#2C2A26]">
|
||||
<LanguageProvider>
|
||||
<CartProvider>
|
||||
{children}
|
||||
<CartDrawer />
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
</CartProvider>
|
||||
</LanguageProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* LOCATIONS PAGE
|
||||
*
|
||||
* Dedicated page for the restaurant's two physical branches.
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* - Two branches are modeled as distinct concepts:
|
||||
* 1. Shahi Kitchen Askim (Sisjön) → Full restaurant + famous lunch buffet
|
||||
* 2. Shahi Sweets Backaplan → Sweets, snacks, café, halwa puri etc.
|
||||
* - Both are operated by the same team (explicitly stated at the bottom)
|
||||
* - Google Maps embeds are intentionally simple (lazy loaded)
|
||||
*
|
||||
* FUTURE:
|
||||
* - When real online ordering launches, this page could show "Order from Askim"
|
||||
* vs "Order from Backaplan" with different delivery radii.
|
||||
* - Instagram handles for each location are mentioned where relevant.
|
||||
*/
|
||||
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export default function LocationsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-5xl mx-auto px-6 pt-20 pb-16">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[#B38B4D] text-xs tracking-[3px] mb-3">WHERE TO FIND US</div>
|
||||
<h1 className="text-6xl md:text-7xl tracking-[-2.5px] leading-none mb-4">Our Locations</h1>
|
||||
<p className="text-xl text-[#6B665F] max-w-md mx-auto">
|
||||
Two branches in Gothenburg — both serving authentic flavors with the same royal hospitality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-8">
|
||||
<a href="/#contact" className="btn-primary px-8 py-3 rounded-full text-sm tracking-[0.5px]">
|
||||
Make a Reservation
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Branch 1: Askim */}
|
||||
<div className="bg-white border border-[#EDE6D9] rounded-2xl p-8">
|
||||
<div className="uppercase text-[#B38B4D] text-xs tracking-[2px] mb-2">RESTAURANT & BUFFET</div>
|
||||
<h2 className="text-4xl tracking-[-1.5px] mb-4">Shahi Kitchen – Askim (Sisjön)</h2>
|
||||
|
||||
<div className="space-y-5 text-[15px]">
|
||||
<div>
|
||||
<div className="text-[#B38B4D] text-xs tracking-widest mb-1">ADDRESS</div>
|
||||
<div>Datavägen 10A<br />436 32 Askim, Gothenburg</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[#B38B4D] text-xs tracking-widest mb-1">PHONE</div>
|
||||
<a href="tel:031288910" className="block hover:text-[#B38B4D]">031-28 89 10</a>
|
||||
<a href="tel:0739381089" className="block hover:text-[#B38B4D]">0739-381089</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[#B38B4D] text-xs tracking-widest mb-1">OPENING HOURS</div>
|
||||
<div>Monday – Sunday • 11:00 – 21:00</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[#B38B4D] text-xs tracking-widest mb-1">WHAT TO EXPECT</div>
|
||||
<div className="text-[#6B665F]">Full restaurant experience with our popular lunch buffet, à la carte, and traditional Pakistani & Indian dishes.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 aspect-video rounded-xl overflow-hidden border border-[#EDE6D9]">
|
||||
<iframe
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2130.5!2d11.95!3d57.65!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x464ff3c8b8b8b8b8%3A0x1234567890abcdef!2sDatav%C3%A4gen%2010A%2C%20436%2032%20Askim!5e0!3m2!1sen!2sse!4v1700000000000"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branch 2: Backaplan */}
|
||||
<div className="bg-white border border-[#EDE6D9] rounded-2xl p-8">
|
||||
<div className="uppercase text-[#B38B4D] text-xs tracking-[2px] mb-2">SWEETS, SNACKS & CAFÉ</div>
|
||||
<h2 className="text-4xl tracking-[-1.5px] mb-4">Shahi Sweets – Backaplan</h2>
|
||||
|
||||
<div className="space-y-5 text-[15px]">
|
||||
<div>
|
||||
<div className="text-[#B38B4D] text-xs tracking-widest mb-1">ADDRESS</div>
|
||||
<div>Krokegårdsgatan 5<br />417 30 Göteborg (Backaplan)</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[#B38B4D] text-xs tracking-widest mb-1">PHONE</div>
|
||||
<a href="tel:0739381089" className="block hover:text-[#B38B4D]">0739-381089</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[#B38B4D] text-xs tracking-widest mb-1">OPENING HOURS</div>
|
||||
<div>Varies — please check our Instagram <span className="text-[#B38B4D]">@shahisweets_bp</span> for current hours.</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[#B38B4D] text-xs tracking-widest mb-1">WHAT TO EXPECT</div>
|
||||
<div className="text-[#6B665F]">Fresh mithai (sweets), savory snacks, halwa puri, biryani, nihari, and café-style seating.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 aspect-video rounded-xl overflow-hidden border border-[#EDE6D9]">
|
||||
<iframe
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2130!2d11.92!3d57.72!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x464ff3c8b8b8b8b8%3A0x1234567890abcdef!2sKrokeg%C3%A5rdsgatan%205%2C%20417%2030%20G%C3%B6teborg!5e0!3m2!1sen!2sse!4v1700000000000"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center text-sm text-[#6B665F]">
|
||||
Both branches are operated by the same team and share the same passion for authentic flavors.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MENU PAGE — Premium Sidebar + Beautiful Loading Experience
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { menuCategories } from "@/lib/menu-data";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Footer from "@/components/Footer";
|
||||
import { useCart } from "@/components/CartContext";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
export default function MenuPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeCategory, setActiveCategory] = useState("All");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showVegetarianOnly, setShowVegetarianOnly] = useState(false);
|
||||
|
||||
const { addToCart } = useCart();
|
||||
|
||||
// Beautiful loading screen on initial load
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 1350);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Sidebar categories
|
||||
const sidebarCategories = [
|
||||
{ id: "All", name: "All Dishes" },
|
||||
...menuCategories.map((cat) => ({ id: cat.id, name: cat.name })),
|
||||
];
|
||||
|
||||
// Filtering logic
|
||||
const filteredCategories = useMemo(() => {
|
||||
return menuCategories
|
||||
.map((category) => {
|
||||
let items = category.items;
|
||||
|
||||
// Sidebar filter
|
||||
if (activeCategory !== "All" && category.id !== activeCategory) {
|
||||
items = [];
|
||||
}
|
||||
|
||||
// Search
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(q) ||
|
||||
(item.description && item.description.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
// Vegetarian
|
||||
if (showVegetarianOnly) {
|
||||
items = items.filter((item) => item.isVegetarian);
|
||||
}
|
||||
|
||||
return { ...category, items };
|
||||
})
|
||||
.filter((category) => category.items.length > 0);
|
||||
}, [searchQuery, showVegetarianOnly, activeCategory]);
|
||||
|
||||
const handleCategorySelect = (id: string) => {
|
||||
setActiveCategory(id);
|
||||
window.scrollTo({ top: 220, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// === BEAUTIFUL LOADING SCREEN ===
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fbf7ef] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="relative mx-auto mb-8 w-20 h-20">
|
||||
<div className="absolute inset-0 rounded-full border-2 border-[#c99a2e]/30" />
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border-2 border-transparent border-t-[#c99a2e] border-r-[#d4a73d]"
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1.3, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
<div className="absolute inset-[6px] rounded-full border border-[#0f5a4a]/20" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-serif text-3xl tracking-tight text-[#101724]">
|
||||
Loading the Menu
|
||||
</h2>
|
||||
<p className="text-[#68717f] text-sm tracking-[1.5px]">Preparing the Royal Table...</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-2 mt-6">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-[#c99a2e]"
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.1, repeat: Infinity, delay: i * 0.18 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
|
||||
<Navbar />
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="max-w-7xl mx-auto px-6 pt-14 pb-10">
|
||||
<div className="max-w-3xl">
|
||||
<div className="text-[#B38B4D] text-xs tracking-[3.5px] mb-4 font-medium">AUTHENTIC • GENEROUS • ROYAL</div>
|
||||
<h1 className="text-6xl md:text-7xl tracking-[-2.8px] leading-none mb-5 text-[#101724]">Our Menu</h1>
|
||||
<p className="text-2xl text-[#4b5563] max-w-2xl">
|
||||
Traditional recipes. Generous portions. Made with heart.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Layout */}
|
||||
<div className="max-w-7xl mx-auto px-6 pb-20">
|
||||
<div className="flex flex-col lg:flex-row gap-10">
|
||||
|
||||
{/* BEAUTIFUL SIDEBAR */}
|
||||
<div className="lg:w-72 flex-shrink-0">
|
||||
<div className="sticky top-24">
|
||||
<div className="mb-6">
|
||||
<div className="text-xs tracking-[3px] text-[#c99a2e] mb-2">EXPLORE OUR</div>
|
||||
<h3 className="font-serif text-3xl tracking-tight">Signature Categories</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 pr-4">
|
||||
{sidebarCategories.map((cat) => {
|
||||
const isActive = activeCategory === cat.id;
|
||||
const itemCount = cat.id === "All"
|
||||
? menuCategories.reduce((sum, c) => sum + c.items.length, 0)
|
||||
: menuCategories.find(c => c.id === cat.id)?.items.length || 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => handleCategorySelect(cat.id)}
|
||||
className={`w-full flex items-center justify-between px-5 py-3.5 rounded-2xl text-left transition-all group ${
|
||||
isActive
|
||||
? "bg-[#101724] text-white shadow-lg"
|
||||
: "hover:bg-white hover:shadow-sm text-[#101724] border border-transparent hover:border-[#e5e1d7]"
|
||||
}`}
|
||||
>
|
||||
<span className={`font-medium tracking-[-0.1px] ${isActive ? "" : "group-hover:text-[#0f5a4a]"}`}>
|
||||
{cat.name}
|
||||
</span>
|
||||
<span className={`text-xs px-2.5 py-0.5 rounded-full font-mono tabular-nums ${
|
||||
isActive ? "bg-white/20" : "bg-[#e5e1d7] text-[#68717f]"
|
||||
}`}>
|
||||
{itemCount}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MAIN CONTENT */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Search + Vegetarian Filter */}
|
||||
<div className="mb-8 flex flex-col md:flex-row gap-4 items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search dishes..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full md:w-80 rounded-2xl border border-[#e5e1d7] bg-white px-5 py-3 text-sm placeholder:text-[#8A8478] focus:border-[#c99a2e] focus:ring-2 focus:ring-[#c99a2e]/20 transition-all"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => setShowVegetarianOnly(!showVegetarianOnly)}
|
||||
className={`px-6 py-3 rounded-2xl text-sm font-medium border transition-all ${
|
||||
showVegetarianOnly
|
||||
? "bg-[#0f5a4a] text-white border-[#0f5a4a]"
|
||||
: "border-[#e5e1d7] hover:border-[#c99a2e] text-[#101724] bg-white"
|
||||
}`}
|
||||
>
|
||||
{showVegetarianOnly ? "Vegetarian Only" : "Show Vegetarian"}
|
||||
</button>
|
||||
|
||||
{(searchQuery || showVegetarianOnly || activeCategory !== "All") && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setShowVegetarianOnly(false);
|
||||
setActiveCategory("All");
|
||||
}}
|
||||
className="text-sm font-medium text-[#c99a2e] hover:text-[#8f6b22]"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
{filteredCategories.length === 0 ? (
|
||||
<div className="text-center py-16 text-[#6B665F]">
|
||||
No dishes found matching your filters.
|
||||
</div>
|
||||
) : (
|
||||
filteredCategories.map((category) => (
|
||||
<div key={category.id} className="mb-16">
|
||||
<div className="flex items-center gap-4 mb-7">
|
||||
<div className="text-3xl tracking-[-1px] text-[#101724]">{category.name}</div>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-[#e5e1d7] to-transparent" />
|
||||
<div className="text-sm font-medium text-[#68717f] tracking-widest">
|
||||
{category.items.length} dishes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{category.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="menu-card group bg-white border border-[#EDE6D9] rounded-2xl overflow-hidden flex flex-col hover:border-[#c99a2e]/40 transition-all duration-300 cursor-pointer"
|
||||
onClick={() => addToCart({ id: item.id, name: item.name, price: item.price, image: item.image })}
|
||||
>
|
||||
{/* Media - Restored Premium Video Hover + Poster Logic */}
|
||||
{item.video ? (
|
||||
<div
|
||||
className="relative h-52 overflow-hidden bg-[#F2EDE4] cursor-pointer"
|
||||
onMouseEnter={(e) => {
|
||||
const video = e.currentTarget.querySelector("video");
|
||||
if (video) video.play().catch(() => {});
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const video = e.currentTarget.querySelector("video");
|
||||
if (video) {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
}
|
||||
}}
|
||||
onClick={() => addToCart({ id: item.id, name: item.name, price: item.price, image: item.image })}
|
||||
>
|
||||
{/* Static Poster (first frame) */}
|
||||
<img
|
||||
src={`/images/dishes/${item.video.replace(".mp4", "-poster.jpg")}`}
|
||||
alt={item.name}
|
||||
className="absolute inset-0 w-full h-full object-cover group-hover:opacity-0 transition-opacity duration-150"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
// Fallback poster logic
|
||||
const base = (item.video || item.image || "").replace(".mp4", "").replace(/-optimized/g, "");
|
||||
const fallbacks = [
|
||||
`/images/dishes/${base}-poster.jpg`,
|
||||
`/images/dishes/${base}-optimized-poster.jpg`,
|
||||
`/images/dishes/${item.image}`,
|
||||
].filter(Boolean);
|
||||
let i = 0;
|
||||
const tryNext = () => {
|
||||
if (i < fallbacks.length) {
|
||||
(e.target as HTMLImageElement).src = fallbacks[i++];
|
||||
}
|
||||
};
|
||||
tryNext();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Video on Hover */}
|
||||
<video
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
<source src={`/videos/${item.video.replace(".mp4", ".webm")}`} type="video/webm" />
|
||||
<source src={`/videos/${item.video}`} type="video/mp4" />
|
||||
</video>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/5 via-black/10 to-black/25 group-hover:opacity-0 transition-opacity" />
|
||||
</div>
|
||||
) : (
|
||||
/* Static Image for items without video */
|
||||
<div
|
||||
className="relative h-52 overflow-hidden bg-[#F2EDE4] cursor-pointer"
|
||||
onClick={() => addToCart({ id: item.id, name: item.name, price: item.price, image: item.image })}
|
||||
>
|
||||
<img
|
||||
src={`/images/dishes/${item.image}`}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover group-hover:scale-[1.03] transition-transform duration-700"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/5 via-black/10 to-black/25" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex justify-between items-start gap-4 mb-4">
|
||||
<h3 className="text-[22px] leading-tight tracking-[-0.4px] text-[#2C2A26]">
|
||||
{item.name}
|
||||
</h3>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-[#B38B4D] text-2xl font-medium tracking-[-0.5px] tabular-nums">
|
||||
{item.price}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#8A8478] tracking-widest -mt-1">KR</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<p className="text-[#6B665F] text-[15px] leading-relaxed mb-5">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto">
|
||||
{item.isVegetarian && (
|
||||
<span className="text-xs px-3 py-1 rounded-full bg-[#3F5C4A]/10 text-[#3F5C4A] tracking-wider">
|
||||
VEGETARIAN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,311 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SHAHI KITCHEN HOMEPAGE
|
||||
* Version with advanced Signature Menu + elegant hero (pre full Sultan redesign)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Lenis from "lenis";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { Crown, Clock, ArrowRight, UtensilsCrossed } from "lucide-react";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Footer from "@/components/Footer";
|
||||
import { useCart } from "@/components/CartContext";
|
||||
import { useLanguage } from "@/lib/language-context";
|
||||
import { getTranslation } from "@/lib/translations";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
export default function ShahiKitchenHomepage() {
|
||||
const { addToCart } = useCart();
|
||||
const { language } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
|
||||
const [menuFilter, setMenuFilter] = useState<"All" | "Rice" | "Curry" | "Meat" | "Street" | "Roll" | "Sweet">("All");
|
||||
|
||||
// Lenis smooth scroll
|
||||
useEffect(() => {
|
||||
const lenis = new Lenis({
|
||||
duration: 1.1,
|
||||
easing: (t: number) => Math.min(1, 1.001 * (-Math.pow(2, -10 * t) + 1)),
|
||||
smoothWheel: true,
|
||||
});
|
||||
const raf = (time: number) => { lenis.raf(time); requestAnimationFrame(raf); };
|
||||
requestAnimationFrame(raf);
|
||||
return () => lenis.destroy();
|
||||
}, []);
|
||||
|
||||
const signatureDishes = [
|
||||
{
|
||||
id: "chicken-biryani",
|
||||
name: "Chicken Biryani",
|
||||
category: "Rice",
|
||||
price: 149,
|
||||
time: "32 min",
|
||||
desc: "Fragrant aged basmati layered with spiced chicken, saffron & caramelized onions",
|
||||
image: "chicken-biryani-poster.jpg"
|
||||
},
|
||||
{
|
||||
id: "bong-nihari",
|
||||
name: "Bong Nihari",
|
||||
category: "Curry",
|
||||
price: 199,
|
||||
time: "45 min",
|
||||
desc: "Slow-cooked beef shank in rich aromatic gravy with ginger, lemon & fresh naan",
|
||||
image: "bong-nihari-poster.jpg"
|
||||
},
|
||||
{
|
||||
id: "panipuri",
|
||||
name: "Golgappy / Panipuri",
|
||||
category: "Street",
|
||||
price: 69,
|
||||
time: "15 min",
|
||||
desc: "Crispy hollow puris filled with spiced chickpeas, potatoes & tangy tamarind water",
|
||||
image: "panipuri-poster.jpg"
|
||||
},
|
||||
{
|
||||
id: "lamm-palak",
|
||||
name: "Lamm Palak",
|
||||
category: "Meat",
|
||||
price: 179,
|
||||
time: "30 min",
|
||||
desc: "Tender lamb cooked with fresh spinach in a flavorful mild gravy",
|
||||
image: "lamm-palak-poster.jpg"
|
||||
},
|
||||
{
|
||||
id: "chicken-karahi",
|
||||
name: "Chicken Karahi",
|
||||
category: "Curry",
|
||||
price: 149,
|
||||
time: "28 min",
|
||||
desc: "Wok-tossed chicken in a robust tomato, chili & ginger gravy",
|
||||
image: "chicken-karahi-poster.jpg"
|
||||
},
|
||||
{
|
||||
id: "chicken-haleem",
|
||||
name: "Chicken Haleem",
|
||||
category: "Curry",
|
||||
price: 149,
|
||||
time: "35 min",
|
||||
desc: "Slow-cooked shredded chicken with lentils, wheat & aromatic spices",
|
||||
image: "chicken-haleem-poster.jpg"
|
||||
},
|
||||
{
|
||||
id: "tikka-boti-roll",
|
||||
name: "Tikka Boti Roll",
|
||||
category: "Roll",
|
||||
price: 99,
|
||||
time: "20 min",
|
||||
desc: "Juicy chicken tikka wrapped in soft naan with mint chutney & onions",
|
||||
image: "tikka-boti-roll-poster.jpg"
|
||||
},
|
||||
{
|
||||
id: "jalebi",
|
||||
name: "Jalebi",
|
||||
category: "Sweet",
|
||||
price: 119,
|
||||
time: "12 min",
|
||||
desc: "Crispy golden saffron spirals soaked in fragrant sugar syrup",
|
||||
image: "jalebi-poster.jpg"
|
||||
},
|
||||
{
|
||||
id: "kulfi",
|
||||
name: "Kulfi",
|
||||
category: "Sweet",
|
||||
price: 39,
|
||||
time: "10 min",
|
||||
desc: "Traditional frozen milk dessert with cardamom, pistachio & saffron",
|
||||
image: "kulfi-poster.jpg"
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = menuFilter === "All" ? signatureDishes : signatureDishes.filter(d => d.category === menuFilter);
|
||||
|
||||
const addDish = (d: any) => {
|
||||
addToCart({ id: d.id, name: d.name, price: d.price, image: d.image });
|
||||
};
|
||||
|
||||
// Advanced GSAP effects for signature cards
|
||||
useEffect(() => {
|
||||
const cards = document.querySelectorAll<HTMLElement>(".signature-card");
|
||||
|
||||
gsap.fromTo(cards,
|
||||
{ opacity: 0, y: 45 },
|
||||
{
|
||||
opacity: 1, y: 0, duration: 0.9, ease: "power3.out", stagger: 0.06,
|
||||
scrollTrigger: { trigger: ".signature-menu-grid", start: "top 82%", once: true }
|
||||
}
|
||||
);
|
||||
|
||||
cards.forEach((card) => {
|
||||
const image = card.querySelector(".signature-image") as HTMLElement | null;
|
||||
let bounds: DOMRect;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!bounds) bounds = card.getBoundingClientRect();
|
||||
const x = ((e.clientX - bounds.left) / bounds.width - 0.5) * 2;
|
||||
const y = ((e.clientY - bounds.top) / bounds.height - 0.5) * 2;
|
||||
|
||||
gsap.to(card, {
|
||||
rotationY: x * 11, rotationX: -y * 8, transformPerspective: 1400, duration: 0.35, ease: "power2.out"
|
||||
});
|
||||
if (image) gsap.to(image, { x: x * 8, y: y * 6, duration: 0.45, ease: "power2.out" });
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
gsap.to(card, { rotationY: 0, rotationX: 0, duration: 1.1, ease: "elastic.out(1, 0.45)" });
|
||||
if (image) gsap.to(image, { x: 0, y: 0, scale: 1, duration: 0.9, ease: "power2.out" });
|
||||
};
|
||||
|
||||
const onMouseEnter = () => {
|
||||
if (image) gsap.to(image, { scale: 1.12, duration: 1.1, ease: "power2.out" });
|
||||
};
|
||||
|
||||
card.addEventListener("mousemove", onMouseMove);
|
||||
card.addEventListener("mouseleave", onMouseLeave);
|
||||
card.addEventListener("mouseenter", onMouseEnter);
|
||||
|
||||
(card as any)._cleanup = () => {
|
||||
card.removeEventListener("mousemove", onMouseMove);
|
||||
card.removeEventListener("mouseleave", onMouseLeave);
|
||||
card.removeEventListener("mouseenter", onMouseEnter);
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
cards.forEach(card => (card as any)._cleanup?.());
|
||||
};
|
||||
}, [filtered]);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
|
||||
<Navbar />
|
||||
|
||||
{/* HERO - Full Banner Video with responsive framing */}
|
||||
<section className="relative min-h-[70dvh] sm:min-h-[78dvh] md:min-h-[85dvh] lg:min-h-[92dvh] xl:min-h-[100dvh]
|
||||
flex items-center justify-center pt-20 lg:pt-[88px] overflow-hidden bg-[#fbf7ef]">
|
||||
|
||||
{/* Banner Video - Optimized positioning per device */}
|
||||
<video
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 z-10 w-full h-full object-cover
|
||||
object-[50%_22%] sm:object-[50%_26%] md:object-[50%_30%]
|
||||
lg:object-[50%_35%] xl:object-[50%_38%]"
|
||||
>
|
||||
<source src="/videos/banner.webm" type="video/webm" />
|
||||
<source src="/videos/banner.mp4" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
{/* Subtle gradient to improve visibility of baked-in text */}
|
||||
<div className="absolute inset-0 z-20 bg-gradient-to-b from-black/5 via-transparent to-black/30" />
|
||||
</section>
|
||||
|
||||
{/* ADVANCED SIGNATURE MENU */}
|
||||
<section id="menu" className="bg-[#fffdf8] px-6 py-20 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-10">
|
||||
<div>
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-[#fff6dc] px-4 py-1.5 text-sm font-semibold text-[#60420d]">
|
||||
<UtensilsCrossed className="h-4 w-4" /> {t.signatureMenu.title}
|
||||
</div>
|
||||
<h2 className="font-serif text-6xl leading-none tracking-[-0.055em] md:text-7xl">{t.signatureMenu.title}</h2>
|
||||
</div>
|
||||
<p className="max-w-md text-lg font-medium text-[#4b5563]">{t.signatureMenu.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-8 flex flex-wrap gap-2">
|
||||
{["All", "Rice", "Curry", "Meat", "Street", "Roll", "Sweet"].map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setMenuFilter(cat as any)}
|
||||
className={`rounded-full px-6 py-3 text-sm font-bold transition-all ${menuFilter === cat ? "bg-[#101724] text-white" : "border border-[#e5e1d7] bg-white text-[#182235] hover:border-[#c99a2e] hover:bg-[#fff6dc]"}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Menu Grid with Advanced Effects */}
|
||||
<div className="signature-menu-grid grid gap-5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<AnimatePresence>
|
||||
{filtered.map((dish, index) => (
|
||||
<motion.div
|
||||
key={dish.id}
|
||||
layout
|
||||
onClick={() => addDish(dish)}
|
||||
className="signature-card group flex cursor-pointer flex-col overflow-hidden rounded-[2rem] border border-[#EDE6D9] bg-white"
|
||||
>
|
||||
<div className="relative h-48 overflow-hidden bg-[#F2EDE4]">
|
||||
<img
|
||||
src={`/images/dishes/${dish.image}`}
|
||||
alt={dish.name}
|
||||
className="signature-image absolute inset-0 w-full h-full object-cover transition-transform duration-700"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/5 via-transparent to-black/25" />
|
||||
|
||||
<div className="absolute top-4 right-4 px-4 py-1 rounded-full bg-white/95 text-[#B38B4D] text-sm font-medium tracking-tight shadow-sm border border-[#EDE6D9]">
|
||||
{dish.price} kr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 flex-1 flex flex-col">
|
||||
<h4 className="text-[21px] leading-tight tracking-[-0.5px] mb-2 group-hover:text-[#B38B4D] transition-colors">
|
||||
{dish.name}
|
||||
</h4>
|
||||
<p className="text-[#6B665F] text-[13.5px] leading-snug flex-1">{dish.desc}</p>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); addDish(dish); }}
|
||||
className="mt-5 w-full py-3 text-sm tracking-[0.6px] border border-[#B38B4D] text-[#B38B4D] rounded-full hover:bg-[#B38B4D] hover:text-white font-medium transition-all"
|
||||
>
|
||||
{t.signatureMenu.addToTable}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 text-center">
|
||||
<a href="/menu" className="inline-flex items-center gap-2 text-sm font-bold tracking-wider text-[#B38B4D] hover:text-[#8C6B3A]">
|
||||
{t.signatureMenu.viewFullMenu} <ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</section>
|
||||
|
||||
{/* THE SHAHI EXPERIENCE */}
|
||||
<section id="experience" className="section bg-white border-y border-[#EDE6D9]">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="text-center mb-14">
|
||||
<div className="text-[#B38B4D] text-xs tracking-[3px] mb-3">THE SHAHI WAY</div>
|
||||
<h3 className="text-5xl md:text-6xl tracking-[-2px]">More than a meal.<br />A moment of royalty.</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ title: "The Legendary Buffet", desc: "Our famous lunch buffet features over 20 rotating dishes — curries, biryanis, fresh naan, and sweets." },
|
||||
{ title: "Shahi Sweets", desc: "Homemade mithai made daily. From fresh Jalebi to Rasmalai — the perfect sweet ending." },
|
||||
{ title: "Warm Hospitality", desc: "Whether you're here for a quick lunch or a family celebration, you will always be treated like royalty." },
|
||||
].map((item, index) => (
|
||||
<div key={index} className="experience-card group relative border border-[#EDE6D9] p-9 rounded-2xl bg-[#F8F5F0] overflow-hidden">
|
||||
<div className="text-[#B38B4D] text-6xl font-light mb-9 tracking-[-2px]">0{index + 1}</div>
|
||||
<h4 className="text-[29px] tracking-[-0.8px] mb-5 text-[#2C2A26] leading-tight">{item.title}</h4>
|
||||
<p className="text-[#6B665F] text-[15.5px] leading-relaxed">{item.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# Shahi Kitchen - High Quality Video Prompts (Realistic + Web Optimized)
|
||||
|
||||
Use these prompts in Grok's video generation (or similar tools).
|
||||
|
||||
**Recommended settings for all videos:**
|
||||
- Duration: 5 seconds
|
||||
- Resolution: 480p or 540p
|
||||
- Style: Photorealistic, professional food photography
|
||||
- Motion: Natural, subtle, loop-friendly
|
||||
- Target file size after optimization: 500–900 KB
|
||||
|
||||
---
|
||||
|
||||
## Butter Chicken
|
||||
|
||||
Photorealistic close-up of authentic Indian Butter Chicken in a traditional dark bowl. Rich, glossy, creamy orange-red tomato gravy with tender chicken pieces. Delicate natural steam gently rising from the hot dish. Warm, soft restaurant lighting with beautiful golden highlights on the sauce. Shot from a 3/4 angle, highly appetizing and luxurious. Shallow depth of field. Smooth 5-second loop, web optimized.
|
||||
|
||||
## Samosa Chat
|
||||
|
||||
Photorealistic close-up of Indian Samosa Chaat. Crispy broken samosas topped with chickpeas, yogurt, colorful chutneys, sev, onions and pomegranate. Fresh, vibrant street food presentation. Natural daylight or warm restaurant lighting. Shallow depth of field, highly detailed and appetizing. Smooth 5-second loop, web optimized.
|
||||
|
||||
## Palak Paneer
|
||||
|
||||
Photorealistic close-up of Palak Paneer. Fresh vibrant green spinach gravy with soft white paneer cubes, garnished with cream and ginger. Subtle natural steam rising. Warm elegant restaurant lighting. Professional food photography, shallow depth of field. Smooth 5-second loop, web optimized.
|
||||
|
||||
## Malai Kofta
|
||||
|
||||
Photorealistic close-up of Malai Kofta in rich creamy cashew gravy. Soft golden vegetable dumplings, garnished with cream and nuts. Gentle steam rising. Luxurious warm lighting. High-end Indian restaurant food photography style. Shallow depth of field. Smooth 5-second loop, web optimized.
|
||||
|
||||
## Daal Makhani
|
||||
|
||||
Photorealistic close-up of Daal Makhani. Thick, creamy, buttery black lentils with rich tomato gravy. Glossy surface with subtle steam. Warm cozy restaurant lighting. Professional appetizing food photography. Shallow depth of field. Smooth 5-second loop, web optimized.
|
||||
|
||||
## Lahore Chana
|
||||
|
||||
Photorealistic close-up of Lahore Chana (spiced chickpeas). Tangy onion-tomato gravy with chickpeas, garnished with green chilies, ginger and cilantro. Natural steam rising. Warm vibrant lighting. Traditional Punjabi restaurant style. Shallow depth of field. Smooth 5-second loop, web optimized.
|
||||
|
||||
## Lamm Palak
|
||||
|
||||
Photorealistic close-up of Lamm Palak. Tender lamb pieces in creamy spinach gravy, garnished with cream and ginger. Subtle steam. Warm rich lighting. Professional Indian restaurant food photography. Shallow depth of field. Smooth 5-second loop, web optimized.
|
||||
|
||||
## Bong Nihari
|
||||
|
||||
Photorealistic close-up of traditional Bong Nihari (slow-cooked shank). Rich aromatic gravy with tender meat, garnished with fresh ginger, green chilies and cilantro. Natural steam. Warm, moody, authentic Pakistani restaurant lighting. Highly detailed and appetizing. Smooth 5-second loop, web optimized.
|
||||
|
||||
## Shami Sandwich
|
||||
|
||||
Photorealistic close-up of a Shami Sandwich cut in half. Spiced shami kebab in soft bread with onions, chutney and fresh herbs. Appetizing cross-section view. Warm natural lighting. Professional street food / cafe photography style. Short 5-second loop, web optimized.
|
||||
|
||||
## Kebab Roll
|
||||
|
||||
Photorealistic close-up of a Kebab Roll cut open. Juicy seekh kebab inside soft naan with onions, chutney and salad. Slight steam and fresh ingredients visible. Warm appetizing lighting. Professional fast-casual food photography. Smooth 5-second loop, web optimized.
|
||||
|
||||
## Falafel Roll
|
||||
|
||||
Photorealistic close-up of a Falafel Roll cut in half. Crispy falafel with fresh vegetables, tahini and chutney inside soft flatbread. Vibrant and fresh presentation. Warm natural lighting. Professional street food photography style. Short 5-second loop, web optimized.
|
||||
|
||||
## Paneer Roll
|
||||
|
||||
Photorealistic close-up of a Paneer Roll cut open. Grilled paneer tikka with vegetables and sauces inside soft naan. Appetizing cross-section. Warm restaurant lighting. Professional food photography. Smooth 5-second loop, web optimized.
|
||||
|
||||
## Namakpare
|
||||
|
||||
Photorealistic close-up of traditional Namakpare (savory fried snacks). Golden-brown crunchy diamond-shaped pieces. Fresh, crispy texture. Warm traditional Indian lighting. Professional sweet shop photography style. Short 5-second loop, web optimized.
|
||||
|
||||
## Rasmalai
|
||||
|
||||
Photorealistic close-up of Rasmalai. Soft spongy cottage cheese dumplings floating in rich saffron and cardamom sweet milk. Garnished with pistachios and saffron strands. Creamy luxurious texture. Elegant warm lighting. High-end Indian dessert photography. Smooth 5-second loop, web optimized.
|
||||
|
||||
---
|
||||
|
||||
## Notes for Generation
|
||||
|
||||
- Always mention "photorealistic, professional food photography, shallow depth of field".
|
||||
- For dishes with steam/sizzle/pouring, explicitly mention "natural delicate steam rising" or "subtle motion".
|
||||
- Keep prompts focused on the dish itself (minimal background).
|
||||
- After generation, optimize heavily using the optimization script or ffmpeg (see `optimize-videos.sh`).
|
||||
|
||||
Use these prompts when regenerating videos for better realism.
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ======================================================
|
||||
# Extract First Frame as Poster for All Videos
|
||||
# ======================================================
|
||||
# This script goes through all .mp4 and .webm files in the current
|
||||
# folder and extracts the very first frame as a high-quality poster image.
|
||||
#
|
||||
# Usage:
|
||||
# cd /home/khan/code/shahikitchen/public/videos
|
||||
# bash /home/khan/code/shahikitchen/extract-video-posters.sh
|
||||
#
|
||||
# Requirements:
|
||||
# - ffmpeg installed
|
||||
#
|
||||
# Output:
|
||||
# For butter-chicken-steam.mp4 → butter-chicken-steam-poster.jpg
|
||||
# (You can then update your menu data or code to use these posters)
|
||||
# ======================================================
|
||||
|
||||
set -e
|
||||
|
||||
echo "Extracting first frame posters from videos..."
|
||||
echo ""
|
||||
|
||||
for file in *.mp4 *.webm; do
|
||||
if [ ! -f "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
base="${file%.*}"
|
||||
output="${base}-poster.jpg"
|
||||
|
||||
# Skip if poster already exists
|
||||
if [ -f "$output" ]; then
|
||||
echo "→ Skipping $file (poster already exists)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "→ Extracting poster from: $file"
|
||||
|
||||
ffmpeg -y -i "$file" -ss 0.1 -vframes 1 -q:v 2 "$output" 2>/dev/null
|
||||
|
||||
if [ -f "$output" ]; then
|
||||
echo " Created: $output"
|
||||
else
|
||||
echo " Failed to create poster for $file"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✅ Done extracting posters!"
|
||||
echo ""
|
||||
echo "You can now update your menu cards to use *-poster.jpg instead of the original dish photos"
|
||||
echo "for better visual consistency between static state and video."
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { Language } from './translations';
|
||||
|
||||
interface LanguageContextType {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = 'shahi-kitchen-language';
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguageState] = useState<Language>('en');
|
||||
|
||||
// Load language from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedLang = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Language | null;
|
||||
if (savedLang && ['en', 'sv', 'hi', 'ur'].includes(savedLang)) {
|
||||
setLanguageState(savedLang);
|
||||
} else {
|
||||
// Optional: Try to detect browser language
|
||||
const browserLang = navigator.language.toLowerCase();
|
||||
if (browserLang.startsWith('sv')) setLanguageState('sv');
|
||||
else if (browserLang.startsWith('hi')) setLanguageState('hi');
|
||||
else if (browserLang.startsWith('ur')) setLanguageState('ur');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setLanguage = (lang: Language) => {
|
||||
setLanguageState(lang);
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* =============================================================================
|
||||
* CENTRAL MENU DATA — Shahi Kitchen
|
||||
* =============================================================================
|
||||
*
|
||||
* This is the SINGLE SOURCE OF TRUTH for every dish shown on the website.
|
||||
*
|
||||
* WHY THIS FILE EXISTS:
|
||||
* - Keeps menu content decoupled from UI components (easy for restaurant staff
|
||||
* or future devs to update prices/descriptions without touching React code)
|
||||
* - Powers BOTH the public menu page AND the cart system
|
||||
* - Enables future features: search, filters, online ordering, admin CMS, etc.
|
||||
*
|
||||
* DATA MODEL:
|
||||
* - MenuCategory: A logical section (Street Food, Vegetarian, Chicken, Sweets...)
|
||||
* - MenuItem: One dish
|
||||
* id → stable unique key (used in cart, URLs, analytics). NEVER change.
|
||||
* name → displayed title
|
||||
* price → integer in Swedish Krona (kr). No decimals in current design.
|
||||
* description → optional rich text shown under the name
|
||||
* image → filename inside /public/images/dishes/ (fallback when no video)
|
||||
* video → filename inside /public/videos/ (MP4 or WebM)
|
||||
* The menu page automatically looks for a matching
|
||||
* `-poster.jpg` first frame in /public/images/dishes/
|
||||
* isVegetarian → boolean flag. Powers the green "VEGETARIAN" pill + filter toggle
|
||||
*
|
||||
* VIDEO + POSTER CONTRACT (critical):
|
||||
* When `video: "butter-chicken-steam.mp4"` exists:
|
||||
* 1. The card shows the static poster image first (performance + first-frame accuracy)
|
||||
* 2. On hover the actual video plays (only while hovering)
|
||||
* 3. On mouse leave → video pauses + resets to time 0
|
||||
* Poster lookup order (see menu/page.tsx for the exact fallback logic):
|
||||
* butter-chicken-steam-poster.jpg
|
||||
* butter-chicken-steam-optimized-poster.jpg
|
||||
* (and a few legacy variants for safety)
|
||||
*
|
||||
* HOW TO ADD A NEW DISH (future-proof instructions):
|
||||
* 1. Add a high-quality image to /public/images/dishes/your-dish.jpg
|
||||
* 2. (Optional but recommended) Generate a short 4–8s video → optimize with ffmpeg
|
||||
* and place in /public/videos/your-dish.webm + .mp4
|
||||
* 3. Extract first frame as your-dish-poster.jpg (see extract-video-posters.sh)
|
||||
* 4. Add the item object below in the correct category
|
||||
* 5. If vegetarian → set isVegetarian: true
|
||||
* 6. Update price in both places if the restaurant changes pricing
|
||||
*
|
||||
* IMPORTANT GOTCHAS:
|
||||
* - The `id` must be kebab-case and globally unique across all categories.
|
||||
* - Price is stored as number (not string) so cart math works.
|
||||
* - Do NOT delete items that have already been ordered by real customers
|
||||
* (the cart uses the id as primary key).
|
||||
*
|
||||
* RELATED FILES:
|
||||
* - app/menu/page.tsx → consumes this data + adds cart buttons + video hover
|
||||
* - components/CartContext.tsx → stores references by id + name + price
|
||||
*/
|
||||
|
||||
export type MenuItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
image?: string; // filename from /public/images/dishes/
|
||||
video?: string; // filename from /public/videos/ (for animated dishes)
|
||||
isVegetarian?: boolean;
|
||||
};
|
||||
|
||||
export type MenuCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
items: MenuItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* THE ACTUAL MENU DATA
|
||||
*
|
||||
* Categories are rendered in the exact order they appear here on /menu.
|
||||
* Each category has a stable `id` used for:
|
||||
* - URL hash navigation (#street-food)
|
||||
* - IntersectionObserver active state
|
||||
* - Category filter pills (the beautiful sliding gold indicator)
|
||||
*
|
||||
* Keep descriptions concise (1–2 lines max) — the design is generous but not verbose.
|
||||
*/
|
||||
export const menuCategories: MenuCategory[] = [
|
||||
{
|
||||
id: "street-food",
|
||||
name: "Street Food & Starters",
|
||||
items: [
|
||||
{ id: "samosa-aloo", name: "Samosa Aloo Veg", price: 34, image: "aloo-samosa.jpg", video: "samosa-aloo.mp4" },
|
||||
{ id: "samosa-keema", name: "Samosa Keema", price: 39, image: "keema-samosa.jpg", video: "samosa-keema.mp4" },
|
||||
{ id: "samosa-chat", name: "Samosa Chat", price: 89, image: "samosa-chaat.jpg", video: "samosa-chaat.mp4" },
|
||||
{ id: "chana-chat", name: "Chana Chat", price: 69, image: "chana-chaat.jpg", video: "chana-chaat.mp4" },
|
||||
{ id: "panipuri", name: "Panipuri / Golgappe", price: 69, image: "panipuri.jpg", video: "panipuri.mp4" },
|
||||
{ id: "keema-naan-starter", name: "Keema Naan", price: 75, image: "keema-naan.jpg", video: "keema-naan.mp4" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "vegetarian",
|
||||
name: "Vegetarian",
|
||||
items: [
|
||||
{ id: "palak-paneer", name: "Palak Paneer", description: "Cottage cheese cooked in a creamy spinach gravy with mild spices and aromatic herbs.", price: 139, image: "palak-paneer.jpg", video: "palak-paneer.mp4", isVegetarian: true },
|
||||
{ id: "shahi-paneer", name: "Shahi Paneer", description: "Soft cottage cheese in a rich, creamy cashew and tomato gravy with Indian spices.", price: 139, image: "shahi-paneer.jpg", video: "shahi-paneer.mp4", isVegetarian: true },
|
||||
{ id: "malai-kofta", name: "Malai Kofta", description: "Soft vegetable koftas simmered in a rich and creamy onion-tomato gravy with mild spices.", price: 139, image: "malai-kofta.jpg", video: "malai-kofta.mp4", isVegetarian: true },
|
||||
{ id: "daal-makhani", name: "Daal Makhani", description: "Slow-cooked black lentils in a buttery, creamy tomato gravy with aromatic spices.", price: 139, image: "daal-makhani.jpg", video: "daal-makhani.mp4", isVegetarian: true },
|
||||
{ id: "lahore-chana", name: "Lahore Chana", description: "Spiced chickpeas cooked in a tangy onion-tomato gravy with traditional Punjabi spices.", price: 139, image: "lahore-chana.jpg", isVegetarian: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "meat",
|
||||
name: "Meat",
|
||||
items: [
|
||||
{ id: "lamm-palak", name: "Lamm Palak", price: 179, image: "lamm-palak.jpg", video: "lamm-palak.mp4" },
|
||||
{ id: "lamm-vindaloo", name: "Lamm Vindaloo", price: 179, image: "lamm-vindaloo.jpg", video: "lamm-vindaloo.mp4" },
|
||||
{ id: "lamm-rogan-josh", name: "Lamm Rogan Josh", price: 199, image: "lamm-rogan-josh.jpg", video: "lamm-rogan-josh.mp4" },
|
||||
{ id: "lamm-karahi", name: "Lamm Karahi", price: 179, image: "lamm-karahi.jpg", video: "lamm-karahi.mp4" },
|
||||
{ id: "bong-nihari", name: "Bong Nihari", price: 199, image: "bong-nihari.jpg", video: "bong-nihari.mp4" },
|
||||
{ id: "paye", name: "Paye", price: 149, image: "paye.jpg", video: "paye.mp4" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "burger-sandwich",
|
||||
name: "Burger & Sandwich",
|
||||
items: [
|
||||
{ id: "shahi-burger", name: "Shahi Burger", price: 119, image: "shahi-burger.jpg", video: "shahi-burger.mp4" },
|
||||
{ id: "shami-sandwich", name: "Shami Sandwich Menu", price: 99, image: "shami-sandwich.jpg", video: "shami-sandwich.mp4" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chicken",
|
||||
name: "Chicken",
|
||||
items: [
|
||||
{ id: "chicken-biryani", name: "Chicken Biryani", price: 149, image: "chicken-biryani.jpg", video: "chicken-biryani.mp4" },
|
||||
{ id: "chicken-tikka", name: "Chicken Tikka", price: 149, image: "chicken-tikka.jpg", video: "chicken-tikka.mp4" },
|
||||
{ id: "tikka-boti", name: "Tikka Boti", price: 149, image: "chicken-tikka.jpg", video: "tikka-boti.mp4" },
|
||||
{ id: "chicken-karahi", name: "Chicken Karahi", price: 149, image: "chicken-karahi.jpg", video: "chicken-karahi.mp4" },
|
||||
{ id: "lahore-sizzler", name: "Lahore Sizzler", price: 169, image: "lahore-sizzler.jpg", video: "lahore-sizzler.mp4" },
|
||||
{ id: "butter-chicken", name: "Butter Chicken", price: 149, image: "butter-chicken.jpg" },
|
||||
{ id: "chicken-haleem", name: "Chicken Haleem", price: 149, image: "chicken-haleem.jpg", video: "chicken-haleem.mp4" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pizza",
|
||||
name: "Pizza",
|
||||
items: [
|
||||
{ id: "lahore-pizza", name: "Lahore Pizza", price: 119, image: "lahore-pizza.jpg", video: "lahore-pizza.mp4" },
|
||||
{ id: "kebab-pizza", name: "Kebab Pizza", price: 119, image: "kebab-pizza.jpg", video: "kebab-pizza.mp4" },
|
||||
{ id: "tikka-boti-pizza", name: "Tikka Boti Pizza", price: 119, image: "tikka-boti-pizza.jpg", video: "tikka-boti-pizza.mp4" },
|
||||
{ id: "peshawari-pizza", name: "Peshawari Pizza", price: 119, image: "peshawari-pizza.jpg", video: "peshawari-pizza.mp4" },
|
||||
{ id: "veg-pizza", name: "Veg Pizza", price: 109, image: "veg-pizza.jpg", isVegetarian: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "naan-roll",
|
||||
name: "Naan Roll",
|
||||
items: [
|
||||
{ id: "tikka-boti-roll", name: "Tikka Boti Roll", price: 99, image: "tikka-boti-roll.jpg", video: "tikka-boti-roll.mp4" },
|
||||
{ id: "kebab-roll", name: "Kebab Roll", price: 99, image: "kebab-roll.jpg", video: "kebab-roll.mp4" },
|
||||
{ id: "falafel-roll", name: "Falafel Roll", price: 99, image: "falafel-roll.jpg", video: "falafel-roll.mp4", isVegetarian: true },
|
||||
{ id: "paneer-roll", name: "Paneer Roll", price: 99, image: "paneer-roll.jpg", video: "paneer-roll.mp4", isVegetarian: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sweets",
|
||||
name: "Sweets / Mithai",
|
||||
items: [
|
||||
{ id: "namakpare", name: "Namakpare", price: 135, image: "namakpare.jpg", video: "namakpare.mp4" },
|
||||
{ id: "jalebi", name: "Jalebi", price: 119, image: "jalebi.jpg", video: "jalebi.mp4" },
|
||||
{ id: "gajar-halwa", name: "Gajar Halwa", price: 149, image: "gajar-halwa.jpg", video: "gajar-halwa.mp4" },
|
||||
{ id: "rasmalai", name: "Rasmalai", price: 45, image: "rasmalai.jpg", video: "rasmalai.mp4" },
|
||||
{ id: "kulfi", name: "Kulfi", price: 39, image: "kulfi.jpg", video: "kulfi.mp4" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "drinks",
|
||||
name: "Drinks",
|
||||
items: [
|
||||
{ id: "masala-chai", name: "Masala Chai", price: 39, image: "masala-chai.jpg" },
|
||||
{ id: "mango-lassi", name: "Mango Lassi", price: 45, image: "mango-lassi.jpg", video: "mango-lassi.mp4" },
|
||||
{ id: "coca-cola", name: "Coca-Cola", price: 29 },
|
||||
{ id: "pepsi-fanta", name: "Pepsi / Fanta", price: 29 },
|
||||
{ id: "sprite-ramlosa", name: "Sprite / Ramlösa", price: 29 },
|
||||
{ id: "energy-drink", name: "Energy Drink", price: 39 },
|
||||
{ id: "juice", name: "Juice", price: 20, image: "mango-juice.jpg" },
|
||||
{ id: "coffee", name: "Coffee", price: 39, image: "black-coffee.jpg" },
|
||||
{ id: "latte", name: "Latte", price: 49, image: "latte.jpg", video: "latte.mp4" },
|
||||
{ id: "cappuccino", name: "Cappuccino", price: 49, image: "cappuccino.jpg" },
|
||||
{ id: "tea", name: "Tea", price: 30 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* UTILITY EXPORT
|
||||
*
|
||||
* Flattened list of every single menu item across all categories.
|
||||
*
|
||||
* Current use cases:
|
||||
* - Future global search / command palette
|
||||
* - Admin tools that need to iterate over everything
|
||||
* - Analytics or sitemap generation
|
||||
*
|
||||
* Example:
|
||||
* const butterChicken = allMenuItems.find(i => i.id === "butter-chicken");
|
||||
*/
|
||||
export const allMenuItems = menuCategories.flatMap((category) => category.items);
|
||||
@@ -0,0 +1,281 @@
|
||||
export type Language = 'en' | 'sv' | 'hi' | 'ur';
|
||||
|
||||
export const languages: { code: Language; name: string; native: string; flag: string }[] = [
|
||||
{ code: 'en', name: 'English', native: 'English', flag: '🇬🇧' },
|
||||
{ code: 'sv', name: 'Swedish', native: 'Svenska', flag: '🇸🇪' },
|
||||
{ code: 'hi', name: 'Hindi', native: 'हिंदी', flag: '🇮🇳' },
|
||||
{ code: 'ur', name: 'Urdu', native: 'اردو', flag: '🇵🇰' },
|
||||
];
|
||||
|
||||
export const translations = {
|
||||
en: {
|
||||
// Navbar
|
||||
nav: {
|
||||
home: 'Home',
|
||||
menu: 'Menu',
|
||||
locations: 'Locations',
|
||||
experience: 'Experience',
|
||||
contact: 'Contact',
|
||||
},
|
||||
reserve: 'Reserve Table',
|
||||
cart: 'Cart',
|
||||
|
||||
// Hero
|
||||
hero: {
|
||||
badge: 'Royal Indian & Pakistani Since 2016',
|
||||
welcome: 'Welcome to',
|
||||
title: 'ShahiKitchen Online',
|
||||
subtitle: 'Experience the warmth of royal hospitality and the richness of authentic Indian and Pakistani flavors — now brought to you with elegance, from the heart of Gothenburg.',
|
||||
exploreMenu: 'Explore the Menu',
|
||||
viewExperience: 'The Shahi Experience',
|
||||
},
|
||||
|
||||
// Signature Menu
|
||||
signatureMenu: {
|
||||
title: 'Signature Menu',
|
||||
subtitle: 'A curated selection of our most beloved dishes.',
|
||||
filterAll: 'All',
|
||||
filterCurry: 'Curry',
|
||||
filterRice: 'Rice',
|
||||
filterGrill: 'Grill',
|
||||
filterSweet: 'Sweet',
|
||||
addToTable: 'Add to Table',
|
||||
viewFullMenu: 'VIEW THE COMPLETE MENU — 40+ DISHES',
|
||||
},
|
||||
|
||||
// Experience
|
||||
experience: {
|
||||
badge: 'THE SHAHI WAY',
|
||||
title: 'Warmth like a feast.\nCalm like a palace.',
|
||||
heritage: {
|
||||
title: 'Royal Heritage',
|
||||
text: 'Recipes passed through generations. Every dish tells a story of Punjab and the royal kitchens of the subcontinent.',
|
||||
},
|
||||
generous: {
|
||||
title: 'Generous & Honest',
|
||||
text: 'Large portions. No shortcuts. We treat every guest like family — the way hospitality was meant to be.',
|
||||
},
|
||||
grace: {
|
||||
title: 'Modern Grace',
|
||||
text: 'Beautiful spaces in Askim & Backaplan. Fast, thoughtful service. Packaging worthy of a gift.',
|
||||
},
|
||||
},
|
||||
|
||||
// Locations
|
||||
locations: {
|
||||
badge: 'TWO HOMES IN GOTHENBURG',
|
||||
title: 'Come as guests.\nLeave as family.',
|
||||
},
|
||||
|
||||
// Footer
|
||||
footer: {
|
||||
tagline: 'Authentic Indian & Pakistani cuisine in Gothenburg since 2016.',
|
||||
locations: 'OUR LOCATIONS',
|
||||
explore: 'EXPLORE',
|
||||
follow: 'FOLLOW US',
|
||||
},
|
||||
|
||||
// Common
|
||||
add: 'Add',
|
||||
viewCart: 'View Cart',
|
||||
},
|
||||
|
||||
sv: {
|
||||
nav: {
|
||||
home: 'Hem',
|
||||
menu: 'Meny',
|
||||
locations: 'Platser',
|
||||
experience: 'Upplevelse',
|
||||
contact: 'Kontakt',
|
||||
},
|
||||
reserve: 'Boka Bord',
|
||||
cart: 'Varukorg',
|
||||
|
||||
hero: {
|
||||
badge: 'Kunglig Indisk & Pakistansk Sedan 2016',
|
||||
welcome: 'Välkommen till',
|
||||
title: 'ShahiKitchen Online',
|
||||
subtitle: 'Upplev värmen från kunglig gästfrihet och rikedomen av autentiska indiska och pakistanska smaker — nu med elegans, från Göteborgs hjärta.',
|
||||
exploreMenu: 'Utforska Menyn',
|
||||
viewExperience: 'Shahi-upplevelsen',
|
||||
},
|
||||
|
||||
signatureMenu: {
|
||||
title: 'Signaturmeny',
|
||||
subtitle: 'Ett noga utvalt urval av våra mest älskade rätter.',
|
||||
filterAll: 'Alla',
|
||||
filterCurry: 'Curry',
|
||||
filterRice: 'Ris',
|
||||
filterGrill: 'Grill',
|
||||
filterSweet: 'Sött',
|
||||
addToTable: 'Lägg till',
|
||||
viewFullMenu: 'SE HELA MENYN — 40+ RÄTTER',
|
||||
},
|
||||
|
||||
experience: {
|
||||
badge: 'SHAHI-SÄTTET',
|
||||
title: 'Värme som en fest.\nLugn som ett palats.',
|
||||
heritage: {
|
||||
title: 'Kungligt Arv',
|
||||
text: 'Recept som gått i arv i generationer. Varje rätt berättar en historia från Punjab och de kungliga köken på subkontinenten.',
|
||||
},
|
||||
generous: {
|
||||
title: 'Generöst & Ärligt',
|
||||
text: 'Stora portioner. Inga genvägar. Vi behandlar varje gäst som familj — så som gästfrihet ska vara.',
|
||||
},
|
||||
grace: {
|
||||
title: 'Modern Elegans',
|
||||
text: 'Vackra lokaler i Askim & Backaplan. Snabb och omtänksam service. Förpackning värdig en gåva.',
|
||||
},
|
||||
},
|
||||
|
||||
locations: {
|
||||
badge: 'TVÅ HEM I GÖTEBORG',
|
||||
title: 'Kom som gäst.\nLämna som familj.',
|
||||
},
|
||||
|
||||
footer: {
|
||||
tagline: 'Autentisk indisk och pakistansk mat i Göteborg sedan 2016.',
|
||||
locations: 'VÅRA PLATSER',
|
||||
explore: 'UPPTÄCK',
|
||||
follow: 'FÖLJ OSS',
|
||||
},
|
||||
|
||||
add: 'Lägg till',
|
||||
viewCart: 'Visa Varukorg',
|
||||
},
|
||||
|
||||
hi: {
|
||||
nav: {
|
||||
home: 'होम',
|
||||
menu: 'मेन्यू',
|
||||
locations: 'स्थान',
|
||||
experience: 'अनुभव',
|
||||
contact: 'संपर्क',
|
||||
},
|
||||
reserve: 'टेबल बुक करें',
|
||||
cart: 'कार्ट',
|
||||
|
||||
hero: {
|
||||
badge: '2016 से शाही भारतीय और पाकिस्तानी',
|
||||
welcome: 'स्वागत है',
|
||||
title: 'ShahiKitchen Online',
|
||||
subtitle: 'शाही आतिथ्य की गर्माहट और असली भारतीय-पाकिस्तानी स्वादों की समृद्धि का अनुभव करें — अब गॉथेनबर्ग के दिल से, शान के साथ।',
|
||||
exploreMenu: 'मेन्यू देखें',
|
||||
viewExperience: 'शाही अनुभव',
|
||||
},
|
||||
|
||||
signatureMenu: {
|
||||
title: 'सिग्नेचर मेन्यू',
|
||||
subtitle: 'हमारी सबसे पसंदीदा व्यंजनों का विशेष चयन।',
|
||||
filterAll: 'सभी',
|
||||
filterCurry: 'करी',
|
||||
filterRice: 'चावल',
|
||||
filterGrill: 'ग्रिल',
|
||||
filterSweet: 'मिठाई',
|
||||
addToTable: 'ऐड करें',
|
||||
viewFullMenu: 'पूरी मेन्यू देखें — 40+ व्यंजन',
|
||||
},
|
||||
|
||||
experience: {
|
||||
badge: 'शाही तरीका',
|
||||
title: 'दावत जैसी गर्माहट।\nमहल जैसा सुकून।',
|
||||
heritage: {
|
||||
title: 'शाही विरासत',
|
||||
text: 'पीढ़ियों से चले आ रहे रेसिपी। हर व्यंजन पंजाब और उपमहाद्वीप के शाही रसोईघरों की कहानी कहता है।',
|
||||
},
|
||||
generous: {
|
||||
title: 'उदार और ईमानदार',
|
||||
text: 'बड़ी सर्विंग्स। कोई शॉर्टकट नहीं। हम हर मेहमान को परिवार की तरह मानते हैं — जैसी आतिथ्य होनी चाहिए।',
|
||||
},
|
||||
grace: {
|
||||
title: 'आधुनिक शान',
|
||||
text: 'अस्किम और बैकाप्लान में खूबसूरत जगहें। तेज़ और सोच-समझकर की गई सेवा। तोहफे जैसी पैकेजिंग।',
|
||||
},
|
||||
},
|
||||
|
||||
locations: {
|
||||
badge: 'गॉथेनबर्ग में दो घर',
|
||||
title: 'मेहमान बनकर आएं।\nपरिवार बनकर जाएं।',
|
||||
},
|
||||
|
||||
footer: {
|
||||
tagline: '2016 से गॉथेनबर्ग में असली भारतीय और पाकिस्तानी खाना।',
|
||||
locations: 'हमारे स्थान',
|
||||
explore: 'एक्सप्लोर करें',
|
||||
follow: 'हमें फॉलो करें',
|
||||
},
|
||||
|
||||
add: 'ऐड करें',
|
||||
viewCart: 'कार्ट देखें',
|
||||
},
|
||||
|
||||
ur: {
|
||||
nav: {
|
||||
home: 'ہوم',
|
||||
menu: 'مینو',
|
||||
locations: 'مقامات',
|
||||
experience: 'تجربہ',
|
||||
contact: 'رابطہ',
|
||||
},
|
||||
reserve: 'ٹیبل بک کریں',
|
||||
cart: 'کارٹ',
|
||||
|
||||
hero: {
|
||||
badge: '2016 سے شاہی انڈین اور پاکستانی',
|
||||
welcome: 'خوش آمدید',
|
||||
title: 'ShahiKitchen Online',
|
||||
subtitle: 'شاہی مہمان نوازی کی گرمی اور اصلی انڈین پاکستانی ذائقوں کی دولت کا تجربہ کریں — اب گوٹنبرگ کے دل سے، شان و شوکت کے ساتھ۔',
|
||||
exploreMenu: 'مینو دیکھیں',
|
||||
viewExperience: 'شاہی تجربہ',
|
||||
},
|
||||
|
||||
signatureMenu: {
|
||||
title: 'سگنیچر مینو',
|
||||
subtitle: 'ہمارے سب سے پسندیدہ پکوانوں کا منتخب انتخاب۔',
|
||||
filterAll: 'سب',
|
||||
filterCurry: 'کری',
|
||||
filterRice: 'چاول',
|
||||
filterGrill: 'گرل',
|
||||
filterSweet: 'میٹھی',
|
||||
addToTable: 'شامل کریں',
|
||||
viewFullMenu: 'مکمل مینو دیکھیں — 40+ پکوان',
|
||||
},
|
||||
|
||||
experience: {
|
||||
badge: 'شاہی طریقہ',
|
||||
title: 'دعوت جیسی گرمی۔\nمحل جیسا سکون۔',
|
||||
heritage: {
|
||||
title: 'شاہی وراثت',
|
||||
text: 'نسلوں سے چلے آ رہے نسخے۔ ہر پکوان پنجاب اور برصغیر کی شاہی رasoئیوں کی کہانی سناتا ہے۔',
|
||||
},
|
||||
generous: {
|
||||
title: 'سخی اور ایماندار',
|
||||
text: 'بڑی سرونگ۔ کوئی شارٹ کٹ نہیں۔ ہم ہر مہمان کو خاندان کی طرح مانتے ہیں — جیسے مہمان نوازی ہونی چاہیے۔',
|
||||
},
|
||||
grace: {
|
||||
title: 'جدید شان',
|
||||
text: 'اسکیم اور بیکاپلان میں خوبصورت جگہیں۔ تیز اور سوچ سمجھ کر کی گئی سروس۔ تحفے جیسی پیکنگ۔',
|
||||
},
|
||||
},
|
||||
|
||||
locations: {
|
||||
badge: 'گوٹنبرگ میں دو گھر',
|
||||
title: 'مہمان بن کر آئیں۔\nخاندان بن کر جائیں۔',
|
||||
},
|
||||
|
||||
footer: {
|
||||
tagline: '2016 سے گوٹنبرگ میں اصلی انڈین اور پاکستانی کھانا۔',
|
||||
locations: 'ہمارے مقامات',
|
||||
explore: 'دریافت کریں',
|
||||
follow: 'ہمیں فالو کریں',
|
||||
},
|
||||
|
||||
add: 'شامل کریں',
|
||||
viewCart: 'کارٹ دیکھیں',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function getTranslation(lang: Language) {
|
||||
return translations[lang];
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================
|
||||
# Advanced Video Optimization Script for Shahi Kitchen
|
||||
# Optimized for realistic food videos (short loops)
|
||||
# ============================================================
|
||||
#
|
||||
# Target per video:
|
||||
# - Duration: 4-5 seconds
|
||||
# - Resolution: 480p (854x480)
|
||||
# - WebM (VP9): 500-800 KB
|
||||
# - MP4 fallback: 700-1100 KB
|
||||
#
|
||||
# Usage:
|
||||
# cd /home/khan/code/shahikitchen/public/videos
|
||||
# bash /home/khan/code/shahikitchen/optimize-food-videos.sh
|
||||
#
|
||||
# Requirements: ffmpeg with libvpx-vp9 and libx264
|
||||
# ============================================================
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Shahi Kitchen Food Video Optimizer ==="
|
||||
echo "Creating heavily optimized WebM + MP4 versions..."
|
||||
echo "Original .mp4 files will NOT be deleted."
|
||||
echo ""
|
||||
|
||||
for file in *.mp4; do
|
||||
if [ ! -f "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
base="${file%.mp4}"
|
||||
|
||||
# Skip if already optimized
|
||||
if [ -f "${base}-optimized.webm" ]; then
|
||||
echo "→ Skipping $file (already optimized)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "→ Optimizing: $file"
|
||||
|
||||
# === WebM (VP9) - Primary (Best size/quality) ===
|
||||
# === WebM (VP9) - Primary (Best size/quality for realistic food videos) ===
|
||||
# 2-pass for best quality at low bitrate
|
||||
ffmpeg -y -i "$file" \
|
||||
-c:v libvpx-vp9 -b:v 550k -crf 35 -deadline good -cpu-used 4 \
|
||||
-vf "scale=854:480,unsharp=5:5:0.5:5:5:0.0" \
|
||||
-an \
|
||||
-r 24 \
|
||||
-pass 1 -f null /dev/null 2>/dev/null
|
||||
|
||||
ffmpeg -y -i "$file" \
|
||||
-c:v libvpx-vp9 -b:v 550k -crf 35 -deadline good -cpu-used 4 \
|
||||
-vf "scale=854:480,unsharp=5:5:0.5:5:5:0.0" \
|
||||
-an \
|
||||
-r 24 \
|
||||
-pass 2 \
|
||||
"${base}-optimized.webm" 2>/dev/null
|
||||
|
||||
# === MP4 Fallback (H.264) - Good compatibility ===
|
||||
ffmpeg -y -i "$file" \
|
||||
-c:v libx264 -b:v 750k -crf 28 -preset slow \
|
||||
-vf "scale=854:480" \
|
||||
-c:a aac -b:a 48k \
|
||||
-r 24 \
|
||||
-movflags +faststart \
|
||||
"${base}-optimized.mp4" 2>/dev/null
|
||||
|
||||
echo " Created: ${base}-optimized.webm + ${base}-optimized.mp4"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✅ All videos optimized!"
|
||||
echo ""
|
||||
echo "Recommended next steps:"
|
||||
echo "1. Test a few -optimized.webm files in browser"
|
||||
echo "2. Update menu data to point to the new optimized files (optional)"
|
||||
echo "3. When happy, you can replace the old files or keep both versions"
|
||||
echo ""
|
||||
echo "Tip: WebM files are usually 30-50% smaller than MP4 at similar quality."
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ======================================================
|
||||
# Video Optimization Script for Shahi Kitchen Website
|
||||
# ======================================================
|
||||
#
|
||||
# This script creates optimized .webm versions of your MP4 videos.
|
||||
#
|
||||
# IMPORTANT:
|
||||
# - It will NOT delete or modify any of your original .mp4 files.
|
||||
# - It only creates new .webm files next to them.
|
||||
# - Keep both versions for now. We can decide later.
|
||||
#
|
||||
# Target: 480p, ~550-700 kbps, VP9
|
||||
#
|
||||
# Requirements:
|
||||
# Ubuntu/Debian: sudo apt update && sudo apt install ffmpeg
|
||||
# macOS: brew install ffmpeg
|
||||
#
|
||||
# Usage:
|
||||
# cd /home/khan/code/shahikitchen/public/videos
|
||||
# bash /home/khan/code/shahikitchen/optimize-videos.sh
|
||||
# ======================================================
|
||||
|
||||
set -e
|
||||
|
||||
echo "Starting video optimization (WebM only)..."
|
||||
echo "Your original .mp4 files will NOT be touched or deleted."
|
||||
echo ""
|
||||
|
||||
for file in *.mp4; do
|
||||
if [ ! -f "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
base="${file%.mp4}"
|
||||
|
||||
# Skip if .webm already exists
|
||||
if [ -f "${base}.webm" ]; then
|
||||
echo "→ Skipping ${file} (webm already exists)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "→ Processing: $file → ${base}.webm"
|
||||
|
||||
ffmpeg -y -i "$file" \
|
||||
-c:v libvpx-vp9 -b:v 600k -crf 34 -deadline good -cpu-used 3 \
|
||||
-vf "scale=854:480" \
|
||||
-an \
|
||||
-r 24 \
|
||||
"${base}.webm"
|
||||
|
||||
echo " Created: ${base}.webm"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✅ Optimization complete!"
|
||||
echo ""
|
||||
echo "New .webm files have been created next to your original .mp4 files."
|
||||
echo "Your website code is already set up to prefer .webm when available."
|
||||
echo ""
|
||||
echo "Next steps when you're ready:"
|
||||
echo "1. Test the site locally (WebM should load much faster and use less data)."
|
||||
echo "2. Let me know when you want to decide what to do with the old MP4s."
|
||||
@@ -9,9 +9,19 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"@splinetool/react-spline": "^4.1.0",
|
||||
"@types/three": "^0.184.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"gsap": "^3.15.0",
|
||||
"lenis": "^1.3.23",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"sonner": "^2.0.7",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 507 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 369 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 379 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 369 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 64 KiB |