Replace entire repo content with code from /root/shahikitchen-google/

This commit is contained in:
root
2026-07-03 02:51:15 +00:00
parent c8b7dc95e4
commit 8ccf033329
79 changed files with 7611 additions and 439 deletions
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { LogOut, Mail } from 'lucide-react';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
import { logoUrl } from '@/lib/assets';
import { useLanguage } from '@/lib/language-context';
import { getTranslation } from '@/lib/translations';
export default function AccountPage() {
const router = useRouter();
const { language } = useLanguage();
const t = getTranslation(language);
const [email, setEmail] = useState<string | null>(null);
const [name, setName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadSession = async () => {
try {
const response = await fetch('/api/auth/customer/session', { cache: 'no-store' });
const data = (await response.json()) as {
authenticated: boolean;
email: string | null;
name: string | null;
};
if (!data.authenticated || !data.email) {
router.replace('/login?tab=customer');
return;
}
setEmail(data.email);
setName(data.name);
window.dispatchEvent(new Event('shahi-customer-auth-changed'));
} catch {
router.replace('/login?tab=customer');
} finally {
setLoading(false);
}
};
void loadSession();
}, [router]);
const handleLogout = async () => {
await fetch('/api/auth/customer/logout', { method: 'POST' });
window.dispatchEvent(new Event('shahi-customer-auth-changed'));
router.replace('/login?tab=customer');
};
if (loading) {
return (
<div className="min-h-screen bg-[#F8F5F0] flex items-center justify-center text-[#6B665F]">
{t.auth.customer.loading}
</div>
);
}
return (
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
<Navbar />
<main className="max-w-2xl mx-auto px-4 sm:px-6 py-12 sm:py-16 text-center">
<div className="mx-auto mb-8 flex h-20 w-20 items-center justify-center rounded-3xl border border-[#c99a2e]/30 bg-white p-4 shadow-lg">
<img src={logoUrl()} alt="Shahi Kitchen" className="h-full w-full object-contain" />
</div>
<p className="text-xs tracking-[3px] text-[#c99a2e] mb-3">{t.auth.customer.badge}</p>
<h1 className="font-serif text-3xl sm:text-4xl tracking-[-0.8px] text-[#101724] mb-4">
{t.auth.customer.welcomeTitle}
</h1>
<p className="text-[15px] leading-relaxed text-[#6B665F] mb-8">
{t.auth.customer.welcomeSubtitle}
</p>
<div className="rounded-3xl border border-[#EDE6D9] bg-white p-5 sm:p-8 shadow-xl">
<p className="text-sm text-[#6B665F] mb-2">{t.auth.customer.loggedInAs}</p>
{name && (
<p className="font-serif text-lg sm:text-xl text-[#101724] mb-1 break-words">{name}</p>
)}
<div className="inline-flex max-w-full items-center gap-2 rounded-full bg-[#FFF6DC] px-4 sm:px-5 py-2.5 text-sm font-medium text-[#60420d]">
<Mail className="h-4 w-4 shrink-0" />
<span className="truncate" title={email ?? ''}>{email}</span>
</div>
</div>
<button
type="button"
onClick={() => void handleLogout()}
className="mt-8 inline-flex items-center gap-2 rounded-full border border-[#EDE6D9] bg-white px-6 py-3 text-sm font-medium text-[#6B665F] hover:text-[#101724] transition"
>
<LogOut className="h-4 w-4" />
{t.auth.customer.logout}
</button>
</main>
<Footer />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { redirect } from 'next/navigation';
import type { ReactNode } from 'react';
import { getMenuManagerSession } from '@/infrastructure/auth/require-menu-manager';
export default async function AdminLayout({ children }: { children: ReactNode }) {
const session = await getMenuManagerSession();
if (!session) {
redirect('/');
}
return children;
}
+872
View File
@@ -0,0 +1,872 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
import { FolderPlus, ImagePlus, LogOut, Plus, Save, Search, Trash2 } from 'lucide-react';
import type { MenuCategory, MenuItem } from '@/domain/menu/entities';
import { dishImageUrl, getMenuPosterSrc } from '@/lib/assets';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
import MenuVersionTimeline from '@/components/admin/MenuVersionTimeline';
interface EditableItemState {
name: string;
description: string;
price: string;
pricePerHalfKg: string;
pricePerKg: string;
image: string;
video: string;
isVegetarian: boolean;
pricing: 'standard' | 'weight';
}
const EMPTY_FORM: EditableItemState = {
name: '',
description: '',
price: '0',
pricePerHalfKg: '90',
pricePerKg: '179',
image: '',
video: '',
isVegetarian: false,
pricing: 'standard',
};
function toEditableState(item: MenuItem): EditableItemState {
return {
name: item.name,
description: item.description ?? '',
price: String(item.price ?? ''),
pricePerHalfKg: String(item.pricePerHalfKg ?? ''),
pricePerKg: String(item.pricePerKg ?? ''),
image: item.image ?? '',
video: item.video ?? '',
isVegetarian: item.isVegetarian ?? false,
pricing: item.pricing === 'weight' ? 'weight' : 'standard',
};
}
function notifyMenuUpdated() {
window.dispatchEvent(new Event('shahi-menu-updated'));
}
function formToMenuItem(form: EditableItemState): Partial<MenuItem> {
const item: Partial<MenuItem> = {
name: form.name.trim(),
description: form.description.trim() || undefined,
image: form.image.trim() || undefined,
video: form.video.trim() || undefined,
isVegetarian: form.isVegetarian || undefined,
price: Number(form.price) || 0,
};
if (form.pricing === 'weight') {
item.pricing = 'weight';
item.pricePerHalfKg = Number(form.pricePerHalfKg) || 0;
item.pricePerKg = Number(form.pricePerKg) || 0;
} else {
item.pricing = 'standard';
}
return item;
}
export default function AdminMenuPage() {
const router = useRouter();
const { isMenuManager, isLoading: isAuthLoading, logout } = useCustomerAuth();
const [categories, setCategories] = useState<MenuCategory[]>([]);
const [activeCategoryId, setActiveCategoryId] = useState('');
const [selectedItemId, setSelectedItemId] = useState('');
const [form, setForm] = useState<EditableItemState | null>(null);
const [isCreatingItem, setIsCreatingItem] = useState(false);
const [search, setSearch] = useState('');
const [status, setStatus] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [showAddCategory, setShowAddCategory] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [mobilePanel, setMobilePanel] = useState<'categories' | 'dishes' | 'editor'>('categories');
const [versionRefreshKey, setVersionRefreshKey] = useState(0);
const adminCategoryListRef = useRef<HTMLDivElement>(null);
const bumpVersionHistory = useCallback(() => {
setVersionRefreshKey((key) => key + 1);
}, []);
const loadMenu = useCallback(async () => {
const response = await fetch('/api/admin/menu', { cache: 'no-store' });
if (!response.ok) throw new Error('Failed to load menu');
const data = (await response.json()) as { categories: MenuCategory[] };
setCategories(data.categories);
setActiveCategoryId((current) => {
if (current && data.categories.some((c) => c.id === current)) return current;
return data.categories[0]?.id || '';
});
}, []);
useEffect(() => {
if (isAuthLoading) return;
if (!isMenuManager) {
router.replace('/');
return;
}
void loadMenu().catch(() => setStatus('Could not load menu data.'));
}, [isAuthLoading, isMenuManager, router, loadMenu]);
useEffect(() => {
const onAuthChanged = () => {
if (!isMenuManager) {
router.replace('/');
}
};
window.addEventListener('shahi-customer-auth-changed', onAuthChanged);
return () => window.removeEventListener('shahi-customer-auth-changed', onAuthChanged);
}, [isMenuManager, router]);
const activeCategory = useMemo(
() => categories.find((category) => category.id === activeCategoryId) ?? categories[0],
[categories, activeCategoryId],
);
useEffect(() => {
const container = adminCategoryListRef.current;
if (!container || !activeCategoryId) return;
const activeButton = container.querySelector<HTMLElement>(
`[data-admin-category-id="${activeCategoryId}"]`,
);
if (!activeButton) return;
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
activeButton.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'nearest',
});
}, [activeCategoryId, categories.length]);
const filteredItems = useMemo(() => {
if (!activeCategory) return [];
const query = search.trim().toLowerCase();
if (!query) return activeCategory.items;
return activeCategory.items.filter((item) => {
return (
item.name.toLowerCase().includes(query) ||
item.id.toLowerCase().includes(query) ||
(item.description ?? '').toLowerCase().includes(query)
);
});
}, [activeCategory, search]);
const selectedItem = useMemo(() => {
if (!activeCategory || !selectedItemId || isCreatingItem) return null;
return activeCategory.items.find((item) => item.id === selectedItemId) ?? null;
}, [activeCategory, selectedItemId, isCreatingItem]);
const selectItem = (item: MenuItem) => {
setIsCreatingItem(false);
setSelectedItemId(item.id);
setForm(toEditableState(item));
setStatus('');
setMobilePanel('editor');
};
const startCreateItem = () => {
setIsCreatingItem(true);
setSelectedItemId('');
setForm({ ...EMPTY_FORM });
setStatus('');
setMobilePanel('editor');
};
const clearSelection = () => {
setIsCreatingItem(false);
setSelectedItemId('');
setForm(null);
setStatus('');
};
const handleLogout = async () => {
await logout();
};
const handleImageUpload = async (file: File) => {
setIsUploading(true);
setStatus('');
try {
const body = new FormData();
body.append('file', file);
const response = await fetch('/api/admin/upload', { method: 'POST', body });
const data = (await response.json()) as { filename?: string; error?: string };
if (!response.ok || !data.filename) {
throw new Error(data.error ?? 'Upload failed');
}
setForm((current) => (current ? { ...current, image: data.filename! } : current));
setStatus(`Image uploaded: ${data.filename}`);
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Upload failed');
} finally {
setIsUploading(false);
}
};
const handleSave = async () => {
if (!activeCategory || !form) return;
if (!form.name.trim()) {
setStatus('Dish name is required.');
return;
}
setIsSaving(true);
setStatus('');
const itemData = formToMenuItem(form);
try {
if (isCreatingItem) {
const response = await fetch('/api/admin/menu', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'addItem',
categoryId: activeCategory.id,
item: itemData,
}),
});
const data = (await response.json()) as { item?: MenuItem; error?: string };
if (!response.ok || !data.item) {
throw new Error(data.error ?? 'Could not add dish');
}
await loadMenu();
setIsCreatingItem(false);
setSelectedItemId(data.item.id);
setForm(toEditableState(data.item));
notifyMenuUpdated();
bumpVersionHistory();
setStatus('Dish added. The live menu updates immediately.');
} else {
if (!selectedItem) return;
const response = await fetch('/api/admin/menu', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
categoryId: activeCategory.id,
itemId: selectedItem.id,
updates: itemData,
}),
});
const data = (await response.json()) as { item?: MenuItem; error?: string };
if (!response.ok || !data.item) {
throw new Error(data.error ?? 'Save failed');
}
await loadMenu();
setForm(toEditableState(data.item));
notifyMenuUpdated();
bumpVersionHistory();
setStatus('Changes saved. The live menu updates immediately.');
}
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Save failed');
} finally {
setIsSaving(false);
}
};
const handleDeleteItem = async () => {
if (!activeCategory || !selectedItem) return;
const confirmed = window.confirm(
`Delete "${selectedItem.name}" from ${activeCategory.name}? This cannot be undone.`,
);
if (!confirmed) return;
setIsSaving(true);
setStatus('');
try {
const response = await fetch('/api/admin/menu', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'deleteItem',
categoryId: activeCategory.id,
itemId: selectedItem.id,
}),
});
const data = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(data.error ?? 'Delete failed');
}
await loadMenu();
clearSelection();
notifyMenuUpdated();
bumpVersionHistory();
setStatus('Dish removed from the menu.');
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Delete failed');
} finally {
setIsSaving(false);
}
};
const handleAddCategory = async () => {
const name = newCategoryName.trim();
if (!name) {
setStatus('Category name is required.');
return;
}
setIsSaving(true);
setStatus('');
try {
const response = await fetch('/api/admin/menu', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'addCategory',
category: { name },
}),
});
const data = (await response.json()) as { category?: MenuCategory; error?: string };
if (!response.ok || !data.category) {
throw new Error(data.error ?? 'Could not add category');
}
await loadMenu();
setActiveCategoryId(data.category.id);
clearSelection();
setNewCategoryName('');
setShowAddCategory(false);
notifyMenuUpdated();
bumpVersionHistory();
setStatus(`Category "${data.category.name}" added.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Could not add category');
} finally {
setIsSaving(false);
}
};
const handleDeleteCategory = async () => {
if (!activeCategory) return;
const dishCount = activeCategory.items.length;
const confirmed = window.confirm(
dishCount > 0
? `Delete "${activeCategory.name}" and all ${dishCount} dish${dishCount === 1 ? '' : 'es'} inside it? This cannot be undone.`
: `Delete empty category "${activeCategory.name}"? This cannot be undone.`,
);
if (!confirmed) return;
setIsSaving(true);
setStatus('');
try {
const response = await fetch('/api/admin/menu', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'deleteCategory',
categoryId: activeCategory.id,
}),
});
const data = (await response.json()) as { error?: string };
if (!response.ok) {
throw new Error(data.error ?? 'Delete failed');
}
await loadMenu();
clearSelection();
notifyMenuUpdated();
bumpVersionHistory();
setStatus('Category removed.');
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Delete failed');
} finally {
setIsSaving(false);
}
};
if (isAuthLoading || !isMenuManager) {
return (
<div className="min-h-screen bg-[#F8F5F0] flex items-center justify-center text-[#6B665F]">
{isAuthLoading ? 'Checking access…' : 'Redirecting…'}
</div>
);
}
const previewItem: MenuItem | null =
form
? {
id: selectedItem?.id ?? 'new-dish',
name: form.name || 'New dish',
description: form.description,
price: Number(form.price) || 0,
image: form.image || undefined,
video: form.video || undefined,
isVegetarian: form.isVegetarian,
pricing: form.pricing === 'weight' ? 'weight' : undefined,
pricePerHalfKg: form.pricing === 'weight' ? Number(form.pricePerHalfKg) || 0 : undefined,
pricePerKg: form.pricing === 'weight' ? Number(form.pricePerKg) || 0 : undefined,
}
: null;
const inputClass =
'mt-1.5 w-full rounded-xl border border-[#EDE6D9] bg-[#FFFCF7] px-3 py-3 text-base sm:text-sm text-[#2C2A26] placeholder:text-[#8A8478] focus:border-[#c99a2e] focus:ring-2 focus:ring-[#c99a2e]/20';
return (
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-8 sm:py-10">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs tracking-[3px] text-[#c99a2e] mb-2">ADMIN</p>
<h1 className="font-serif text-3xl tracking-[-0.5px] text-[#101724]">Menu Management</h1>
<p className="mt-2 text-sm text-[#6B665F]">
Add, edit, or remove dishes and categories. Changes apply to the public menu right away.
</p>
</div>
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center justify-center gap-2 rounded-full border border-[#EDE6D9] bg-white px-5 py-3 text-sm font-medium text-[#6B665F] hover:text-[#101724]"
>
<LogOut className="h-4 w-4" />
Log out
</button>
</div>
<div className="mb-6">
<MenuVersionTimeline
refreshTrigger={versionRefreshKey}
onMenuRestored={(restoredCategories) => {
setCategories(restoredCategories);
setActiveCategoryId((current) => {
if (current && restoredCategories.some((c) => c.id === current)) return current;
return restoredCategories[0]?.id || '';
});
clearSelection();
bumpVersionHistory();
}}
onNotifyMenuUpdated={notifyMenuUpdated}
/>
</div>
<div className="mb-4 flex gap-1 rounded-2xl border border-[#EDE6D9] bg-white p-1 lg:hidden">
{([
{ id: 'categories' as const, label: 'Categories' },
{ id: 'dishes' as const, label: 'Dishes' },
{ id: 'editor' as const, label: 'Edit' },
]).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setMobilePanel(tab.id)}
className={`flex-1 rounded-xl px-2 py-3 text-xs font-semibold transition min-h-[44px] touch-manipulation ${
mobilePanel === tab.id
? 'bg-[#101724] text-white'
: 'text-[#6B665F] hover:bg-[#F8F5F0]'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="grid gap-6 lg:grid-cols-[240px_320px_minmax(0,1fr)]">
<aside className={`rounded-2xl border border-[#EDE6D9] bg-white p-4 ${mobilePanel !== 'categories' ? 'hidden lg:block' : ''}`}>
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-[#c99a2e]">
Categories
</h2>
<button
type="button"
onClick={() => {
setShowAddCategory((v) => !v);
setNewCategoryName('');
}}
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-[#8f6b22] hover:bg-[#FFF6DC] min-h-[40px] touch-manipulation"
title="Add category"
>
<FolderPlus className="h-3.5 w-3.5" />
Add
</button>
</div>
{showAddCategory && (
<div className="mb-3 space-y-2 rounded-xl border border-[#EDE6D9] bg-[#F8F5F0] p-3">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
placeholder="Category name…"
className="w-full rounded-lg border border-[#EDE6D9] bg-white px-3 py-2 text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') void handleAddCategory();
}}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleAddCategory()}
disabled={isSaving}
className="flex-1 rounded-lg bg-[#101724] px-3 py-2 text-xs font-semibold text-white disabled:opacity-60"
>
Create
</button>
<button
type="button"
onClick={() => setShowAddCategory(false)}
className="rounded-lg border border-[#EDE6D9] px-3 py-2 text-xs text-[#6B665F]"
>
Cancel
</button>
</div>
</div>
)}
<div
ref={adminCategoryListRef}
className="max-h-[min(52dvh,28rem)] space-y-2 overflow-y-auto overscroll-y-contain scroll-smooth pe-1 lg:max-h-[calc(100dvh-var(--header-height)-14rem)]"
>
{categories.map((category) => (
<button
key={category.id}
data-admin-category-id={category.id}
type="button"
onClick={() => {
setActiveCategoryId(category.id);
clearSelection();
setMobilePanel('dishes');
}}
className={`w-full rounded-xl px-4 py-3 text-left text-sm font-medium transition ${
activeCategory?.id === category.id
? 'bg-[#101724] text-white'
: 'bg-[#F8F5F0] text-[#2C2A26] hover:bg-[#EDE6D9]'
}`}
>
{category.name}
<span className="mt-1 block text-xs opacity-70">{category.items.length} dishes</span>
</button>
))}
</div>
{activeCategory && (
<button
type="button"
onClick={() => void handleDeleteCategory()}
disabled={isSaving || categories.length <= 1}
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-xs font-semibold text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-40"
title={categories.length <= 1 ? 'At least one category must remain' : undefined}
>
<Trash2 className="h-3.5 w-3.5" />
Delete category
</button>
)}
</aside>
<section className={`rounded-2xl border border-[#EDE6D9] bg-white p-4 ${mobilePanel !== 'dishes' ? 'hidden lg:block' : ''}`}>
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-[#c99a2e]">
Dishes
</h2>
<button
type="button"
onClick={startCreateItem}
disabled={!activeCategory}
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-[#8f6b22] hover:bg-[#FFF6DC] disabled:opacity-40 min-h-[40px] touch-manipulation"
>
<Plus className="h-3.5 w-3.5" />
Add dish
</button>
</div>
<div className="relative mb-4">
<Search className="pointer-events-none absolute start-3 top-3 h-4 w-4 text-[#B38B4D]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search dishes…"
className={`${inputClass} ps-10`}
/>
</div>
<div className="max-h-[45dvh] space-y-2 overflow-y-auto lg:max-h-[60vh]">
{filteredItems.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-[#8A8478]">
{search ? 'No dishes match your search.' : 'No dishes in this category yet.'}
</p>
) : (
filteredItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => selectItem(item)}
className={`w-full rounded-xl border px-4 py-3 text-left transition ${
selectedItemId === item.id && !isCreatingItem
? 'border-[#B38B4D] bg-[#FFF6DC]'
: 'border-[#EDE6D9] hover:border-[#c99a2e]/40'
}`}
>
<div className="font-medium text-sm text-[#101724]">{item.name}</div>
<div className="mt-1 text-xs text-[#6B665F]">
{item.pricing === 'weight'
? `${item.pricePerHalfKg ?? item.price} kr / ½ kg`
: `${item.price} kr`}
</div>
</button>
))
)}
</div>
</section>
<section className={`rounded-2xl border border-[#EDE6D9] bg-white p-4 sm:p-6 ${mobilePanel !== 'editor' ? 'hidden lg:block' : ''}`}>
{!form ? (
<p className="text-sm text-[#6B665F]">
Select a dish to edit, or click &ldquo;Add dish&rdquo; to create a new one.
</p>
) : (
<div className="space-y-5">
<button
type="button"
onClick={() => {
clearSelection();
setMobilePanel('dishes');
}}
className="mb-2 inline-flex items-center text-sm font-medium text-[#B38B4D] hover:underline lg:hidden touch-manipulation min-h-[44px]"
>
Back to dishes
</button>
<div>
<h2 className="font-serif text-xl sm:text-2xl tracking-[-0.3px] text-[#101724]">
{isCreatingItem ? 'New dish' : selectedItem?.name}
</h2>
{!isCreatingItem && selectedItem && (
<p className="mt-1 text-xs text-[#8A8478]">ID: {selectedItem.id}</p>
)}
</div>
{previewItem?.image && (
<div className="overflow-hidden rounded-2xl border border-[#EDE6D9] bg-[#F8F5F0]">
<img
src={dishImageUrl(previewItem.image)}
alt={previewItem.name}
className="h-48 w-full object-cover"
/>
</div>
)}
{previewItem && !previewItem.image && !isCreatingItem && selectedItem && (
<div className="overflow-hidden rounded-2xl border border-[#EDE6D9] bg-[#F8F5F0]">
<img
src={getMenuPosterSrc(selectedItem)}
alt={previewItem.name}
className="h-48 w-full object-cover"
/>
</div>
)}
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">Name</label>
<input
className={inputClass}
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">Description</label>
<textarea
className={`${inputClass} min-h-[110px] resize-y`}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">Pricing type</label>
<div className="mt-1.5 flex gap-2">
<button
type="button"
onClick={() => setForm({ ...form, pricing: 'standard' })}
className={`rounded-full px-4 py-2 text-xs font-semibold transition ${
form.pricing === 'standard'
? 'bg-[#101724] text-white'
: 'border border-[#EDE6D9] bg-white text-[#6B665F]'
}`}
>
Fixed price
</button>
<button
type="button"
onClick={() => setForm({ ...form, pricing: 'weight' })}
className={`rounded-full px-4 py-2 text-xs font-semibold transition ${
form.pricing === 'weight'
? 'bg-[#101724] text-white'
: 'border border-[#EDE6D9] bg-white text-[#6B665F]'
}`}
>
Per weight (sweets)
</button>
</div>
</div>
{form.pricing === 'weight' ? (
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">Price / ½ kg (kr)</label>
<input
type="number"
min={0}
className={inputClass}
value={form.pricePerHalfKg}
onChange={(e) => setForm({ ...form, pricePerHalfKg: e.target.value })}
/>
</div>
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">Price / kg (kr)</label>
<input
type="number"
min={0}
className={inputClass}
value={form.pricePerKg}
onChange={(e) => setForm({ ...form, pricePerKg: e.target.value })}
/>
</div>
</div>
) : (
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">Price (kr)</label>
<input
type="number"
min={0}
className={inputClass}
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
/>
</div>
)}
<label className="flex items-center gap-2 text-sm text-[#2C2A26]">
<input
type="checkbox"
checked={form.isVegetarian}
onChange={(e) => setForm({ ...form, isVegetarian: e.target.checked })}
className="h-4 w-4 rounded border-[#EDE6D9] accent-[#c99a2e]"
/>
Vegetarian
</label>
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">Image filename</label>
<input
className={inputClass}
placeholder="butter-chicken.jpg"
value={form.image}
onChange={(e) => setForm({ ...form, image: e.target.value })}
/>
<p className="mt-1.5 text-xs text-[#8A8478]">
Stored in <code>/public/images/dishes/</code>
</p>
</div>
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">Upload new image</label>
<label className="mt-1.5 flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed border-[#B38B4D]/50 bg-[#FFF6DC]/40 px-4 py-4 text-sm font-medium text-[#8f6b22] hover:bg-[#FFF6DC]">
<ImagePlus className="h-4 w-4" />
{isUploading ? 'Uploading…' : 'Choose image file'}
<input
type="file"
accept="image/*"
className="hidden"
disabled={isUploading}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleImageUpload(file);
e.currentTarget.value = '';
}}
/>
</label>
</div>
<div>
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">Video filename (optional)</label>
<input
className={inputClass}
placeholder="butter-chicken.mp4"
value={form.video}
onChange={(e) => setForm({ ...form, video: e.target.value })}
/>
<p className="mt-1.5 text-xs text-[#8A8478]">
Stored in <code>/public/videos/</code>
</p>
</div>
{status && (
<p className="text-sm text-[#6B665F]">{status}</p>
)}
<div className="flex flex-col gap-3 sm:flex-row">
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="btn-primary inline-flex flex-1 items-center justify-center gap-2 rounded-full py-3.5 text-sm font-medium tracking-wide disabled:opacity-60"
>
<Save className="h-4 w-4" />
{isSaving ? 'Saving…' : isCreatingItem ? 'Add dish' : 'Save changes'}
</button>
{!isCreatingItem && selectedItem && (
<button
type="button"
onClick={() => void handleDeleteItem()}
disabled={isSaving}
className="inline-flex items-center justify-center gap-2 rounded-full border border-red-200 bg-red-50 px-5 py-3.5 text-sm font-semibold text-red-700 hover:bg-red-100 disabled:opacity-60"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
)}
{isCreatingItem && (
<button
type="button"
onClick={clearSelection}
className="inline-flex items-center justify-center rounded-full border border-[#EDE6D9] px-5 py-3.5 text-sm font-medium text-[#6B665F]"
>
Cancel
</button>
)}
</div>
</div>
)}
</section>
</div>
</main>
<Footer />
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { isValidAdminCredentials } from '@/application/admin/validate-credentials';
import {
ADMIN_SESSION_COOKIE,
createAdminSessionToken,
getAdminSessionCookieOptions,
} from '@/infrastructure/auth/admin-session';
export async function POST(request: Request) {
const body = (await request.json()) as { username?: string; password?: string };
const username = body.username?.trim() ?? '';
const password = body.password ?? '';
if (!isValidAdminCredentials(username, password)) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const cookieStore = await cookies();
cookieStore.set(ADMIN_SESSION_COOKIE, createAdminSessionToken(), getAdminSessionCookieOptions());
return NextResponse.json({ ok: true });
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { ADMIN_SESSION_COOKIE } from '@/infrastructure/auth/admin-session';
export async function POST() {
const cookieStore = await cookies();
cookieStore.delete(ADMIN_SESSION_COOKIE);
return NextResponse.json({ ok: true });
}
+208
View File
@@ -0,0 +1,208 @@
import { NextResponse } from 'next/server';
import type { MenuCategory, MenuItem } from '@/domain/menu/entities';
import { requireAdmin } from '@/infrastructure/auth/require-admin';
import {
addMenuCategory,
addMenuItem,
readMenuCategories,
removeMenuCategory,
removeMenuItem,
updateMenuItem,
} from '@/infrastructure/menu/menu-persistence';
export async function GET() {
const unauthorized = await requireAdmin();
if (unauthorized) return unauthorized;
const categories = await readMenuCategories();
return NextResponse.json({ categories });
}
interface MenuItemUpdateBody {
categoryId?: string;
itemId?: string;
updates?: Partial<MenuItem>;
}
export async function PATCH(request: Request) {
const unauthorized = await requireAdmin();
if (unauthorized) return unauthorized;
const body = (await request.json()) as MenuItemUpdateBody;
const { categoryId, itemId, updates } = body;
if (!categoryId || !itemId || !updates || typeof updates !== 'object') {
return NextResponse.json({ error: 'categoryId, itemId and updates are required' }, { status: 400 });
}
const sanitizedUpdates: Partial<MenuItem> = {};
if (typeof updates.name === 'string') sanitizedUpdates.name = updates.name.trim();
if (typeof updates.description === 'string') sanitizedUpdates.description = updates.description.trim();
if (typeof updates.image === 'string') sanitizedUpdates.image = updates.image.trim() || undefined;
if (typeof updates.video === 'string') sanitizedUpdates.video = updates.video.trim() || undefined;
if (typeof updates.isVegetarian === 'boolean') sanitizedUpdates.isVegetarian = updates.isVegetarian;
if (updates.pricing === 'weight') {
sanitizedUpdates.pricing = 'weight';
} else if (updates.pricing === 'standard') {
sanitizedUpdates.pricing = 'standard';
sanitizedUpdates.pricePerHalfKg = undefined;
sanitizedUpdates.pricePerKg = undefined;
}
if (typeof updates.price === 'number' && Number.isFinite(updates.price)) {
sanitizedUpdates.price = Math.max(0, Math.round(updates.price));
}
if (typeof updates.pricePerHalfKg === 'number' && Number.isFinite(updates.pricePerHalfKg)) {
sanitizedUpdates.pricePerHalfKg = Math.max(0, Math.round(updates.pricePerHalfKg));
}
if (typeof updates.pricePerKg === 'number' && Number.isFinite(updates.pricePerKg)) {
sanitizedUpdates.pricePerKg = Math.max(0, Math.round(updates.pricePerKg));
}
const updatedItem = await updateMenuItem(categoryId, itemId, sanitizedUpdates);
if (!updatedItem) {
return NextResponse.json({ error: 'Menu item not found' }, { status: 404 });
}
return NextResponse.json({ item: updatedItem });
}
interface AddItemBody {
action: 'addItem';
categoryId: string;
item: MenuItem;
}
interface AddCategoryBody {
action: 'addCategory';
category: Pick<MenuCategory, 'name' | 'id'>;
}
type PostBody = AddItemBody | AddCategoryBody;
function sanitizeMenuItem(item: MenuItem): MenuItem | null {
const name = item.name?.trim();
if (!name) return null;
const sanitized: MenuItem = {
id: item.id?.trim() || name,
name,
price: Math.max(0, Math.round(Number(item.price) || 0)),
};
if (typeof item.description === 'string') {
sanitized.description = item.description.trim() || undefined;
}
if (typeof item.image === 'string') {
sanitized.image = item.image.trim() || undefined;
}
if (typeof item.video === 'string') {
sanitized.video = item.video.trim() || undefined;
}
if (item.isVegetarian === true) sanitized.isVegetarian = true;
if (item.pricing === 'weight') {
sanitized.pricing = 'weight';
sanitized.pricePerHalfKg = Math.max(0, Math.round(Number(item.pricePerHalfKg) || 0));
sanitized.pricePerKg = Math.max(0, Math.round(Number(item.pricePerKg) || 0));
}
return sanitized;
}
export async function POST(request: Request) {
const unauthorized = await requireAdmin();
if (unauthorized) return unauthorized;
const body = (await request.json()) as PostBody;
if (body.action === 'addItem') {
const { categoryId, item } = body;
if (!categoryId || !item) {
return NextResponse.json({ error: 'categoryId and item are required' }, { status: 400 });
}
const sanitized = sanitizeMenuItem(item);
if (!sanitized) {
return NextResponse.json({ error: 'A valid dish name is required' }, { status: 400 });
}
const created = await addMenuItem(categoryId, sanitized);
if (!created) {
return NextResponse.json({ error: 'Category not found or item could not be created' }, { status: 404 });
}
return NextResponse.json({ item: created }, { status: 201 });
}
if (body.action === 'addCategory') {
const { category } = body;
if (!category?.name?.trim()) {
return NextResponse.json({ error: 'Category name is required' }, { status: 400 });
}
const created = await addMenuCategory({
id: category.id?.trim() || category.name,
name: category.name.trim(),
items: [],
});
if (!created) {
return NextResponse.json({ error: 'Category already exists or could not be created' }, { status: 409 });
}
return NextResponse.json({ category: created }, { status: 201 });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
}
interface DeleteItemBody {
action: 'deleteItem';
categoryId: string;
itemId: string;
}
interface DeleteCategoryBody {
action: 'deleteCategory';
categoryId: string;
}
type DeleteBody = DeleteItemBody | DeleteCategoryBody;
export async function DELETE(request: Request) {
const unauthorized = await requireAdmin();
if (unauthorized) return unauthorized;
const body = (await request.json()) as DeleteBody;
if (body.action === 'deleteItem') {
const { categoryId, itemId } = body;
if (!categoryId || !itemId) {
return NextResponse.json({ error: 'categoryId and itemId are required' }, { status: 400 });
}
const removed = await removeMenuItem(categoryId, itemId);
if (!removed) {
return NextResponse.json({ error: 'Menu item not found' }, { status: 404 });
}
return NextResponse.json({ ok: true });
}
if (body.action === 'deleteCategory') {
const { categoryId } = body;
if (!categoryId) {
return NextResponse.json({ error: 'categoryId is required' }, { status: 400 });
}
const removed = await removeMenuCategory(categoryId);
if (!removed) {
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
}
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/infrastructure/auth/require-admin';
import { readMenuCategories } from '@/infrastructure/menu/menu-persistence';
import { deleteMenuVersion, listMenuVersions } from '@/infrastructure/menu/menu-versioning';
interface RouteContext {
params: Promise<{ id: string }>;
}
export async function DELETE(_request: Request, context: RouteContext) {
const unauthorized = await requireAdmin();
if (unauthorized) return unauthorized;
const { id } = await context.params;
try {
const removed = await deleteMenuVersion(id);
if (!removed) {
return NextResponse.json({ error: 'Version not found' }, { status: 404 });
}
const categories = await readMenuCategories();
const versions = await listMenuVersions(categories);
return NextResponse.json({ ok: true, versions });
} catch (error) {
const message = error instanceof Error ? error.message : 'Delete failed';
return NextResponse.json({ error: message }, { status: 400 });
}
}
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/infrastructure/auth/require-admin';
import {
clearMenuCache,
persistMenuCategories,
readMenuCategories,
} from '@/infrastructure/menu/menu-persistence';
import {
createMenuSnapshot,
listMenuVersions,
resetMenuVersionHistory,
restoreMenuVersion,
} from '@/infrastructure/menu/menu-versioning';
export async function GET() {
const unauthorized = await requireAdmin();
if (unauthorized) return unauthorized;
const categories = await readMenuCategories();
const versions = await listMenuVersions(categories);
return NextResponse.json({ versions });
}
interface CreateCheckpointBody {
label?: string;
}
export async function POST(request: Request) {
const unauthorized = await requireAdmin();
if (unauthorized) return unauthorized;
const body = (await request.json()) as CreateCheckpointBody & { action?: string; versionId?: string };
if (body.action === 'reset-baseline') {
try {
const categories = await readMenuCategories();
const baseline = await resetMenuVersionHistory(categories);
const versions = await listMenuVersions(categories);
return NextResponse.json({ baseline, categories, versions });
} catch (error) {
const message = error instanceof Error ? error.message : 'Reset failed';
return NextResponse.json({ error: message }, { status: 400 });
}
}
if (body.action === 'restore') {
const versionId = body.versionId?.trim();
if (!versionId) {
return NextResponse.json({ error: 'versionId is required' }, { status: 400 });
}
try {
const restored = await restoreMenuVersion(
versionId,
persistMenuCategories,
readMenuCategories,
);
clearMenuCache();
const categories = await readMenuCategories();
const versions = await listMenuVersions(categories);
return NextResponse.json({ restored, categories, versions });
} catch (error) {
const message = error instanceof Error ? error.message : 'Restore failed';
const status = message === 'Version not found.' ? 404 : 400;
return NextResponse.json({ error: message }, { status });
}
}
const label = body.label?.trim() || 'Manual checkpoint';
const categories = await readMenuCategories();
const snapshot = await createMenuSnapshot(categories, label, 'manual');
const versions = await listMenuVersions(categories);
return NextResponse.json({ snapshot, versions }, { status: 201 });
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { isMenuManagerEmail } from '@/domain/auth/menu-managers';
import { getCustomerSession } from '@/infrastructure/auth/customer-session';
export const dynamic = 'force-dynamic';
export async function GET() {
const session = await getCustomerSession();
const authenticated = session !== null && isMenuManagerEmail(session.email);
return NextResponse.json({
authenticated,
email: authenticated ? session!.email : null,
});
}
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { mkdir, writeFile } from 'fs/promises';
import path from 'path';
import { requireAdmin } from '@/infrastructure/auth/require-admin';
const DISHES_DIR = path.join(process.cwd(), 'public', 'images', 'dishes');
const MAX_FILE_SIZE = 5 * 1024 * 1024;
function sanitizeFilename(filename: string): string {
return filename
.toLowerCase()
.replace(/[^a-z0-9._-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export async function POST(request: Request) {
const unauthorized = await requireAdmin();
if (unauthorized) return unauthorized;
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
}
if (!file.type.startsWith('image/')) {
return NextResponse.json({ error: 'Only image files are allowed' }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'File must be 5 MB or smaller' }, { status: 400 });
}
const originalName = file.name || 'dish-image.jpg';
const extension = path.extname(originalName).toLowerCase() || '.jpg';
const baseName = sanitizeFilename(path.basename(originalName, extension)) || 'dish-image';
const filename = `${baseName}${extension}`;
await mkdir(DISHES_DIR, { recursive: true });
const bytes = Buffer.from(await file.arrayBuffer());
await writeFile(path.join(DISHES_DIR, filename), bytes);
return NextResponse.json({ filename });
}
+79
View File
@@ -0,0 +1,79 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import {
createCustomerSessionToken,
CUSTOMER_SESSION_COOKIE,
getCustomerSessionCookieOptions,
} from '@/infrastructure/auth/customer-session';
import {
fetchGoogleUserFromCode,
isGoogleOAuthConfigured,
verifyOAuthState,
} from '@/infrastructure/auth/google-oauth';
import {
getOAuthReturnCookieName,
sanitizeOAuthReturnPath,
} from '@/infrastructure/auth/oauth-return';
export const dynamic = 'force-dynamic';
const OAUTH_STATE_COOKIE = 'shahi_google_oauth_state';
function redirectToLogin(error: string, returnTo?: string) {
const loginUrl = new URL('/login', getOrigin());
loginUrl.searchParams.set('tab', 'customer');
loginUrl.searchParams.set('error', error);
if (returnTo && returnTo !== '/account') {
loginUrl.searchParams.set('returnTo', returnTo);
}
return NextResponse.redirect(loginUrl);
}
function getOrigin(): string {
return process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
}
export async function GET(request: Request) {
if (!isGoogleOAuthConfigured()) {
return redirectToLogin('google_not_configured');
}
const url = new URL(request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const oauthError = url.searchParams.get('error');
const cookieStore = await cookies();
const returnTo = sanitizeOAuthReturnPath(cookieStore.get(getOAuthReturnCookieName())?.value);
cookieStore.delete(getOAuthReturnCookieName());
if (oauthError) {
return redirectToLogin('google_denied', returnTo);
}
const storedState = cookieStore.get(OAUTH_STATE_COOKIE)?.value;
cookieStore.delete(OAUTH_STATE_COOKIE);
if (!verifyOAuthState(state) || state !== storedState) {
return redirectToLogin('invalid_state', returnTo);
}
if (!code) {
return redirectToLogin('missing_code', returnTo);
}
const user = await fetchGoogleUserFromCode(code);
if (!user) {
return redirectToLogin('google_failed', returnTo);
}
cookieStore.set(
CUSTOMER_SESSION_COOKIE,
createCustomerSessionToken(user),
getCustomerSessionCookieOptions(),
);
const response = NextResponse.redirect(new URL(returnTo, getOrigin()));
response.headers.set('Cache-Control', 'no-store');
return response;
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import {
CUSTOMER_SESSION_COOKIE,
getClearedCustomerSessionCookieOptions,
} from '@/infrastructure/auth/customer-session';
import {
ADMIN_SESSION_COOKIE,
getAdminSessionCookieOptions,
} from '@/infrastructure/auth/admin-session';
export async function POST() {
const cookieStore = await cookies();
const clearedCustomer = getClearedCustomerSessionCookieOptions();
const clearedAdmin = { ...getAdminSessionCookieOptions(), maxAge: 0, expires: new Date(0) };
cookieStore.set(CUSTOMER_SESSION_COOKIE, '', clearedCustomer);
cookieStore.delete({ name: CUSTOMER_SESSION_COOKIE, path: '/' });
cookieStore.set(ADMIN_SESSION_COOKIE, '', clearedAdmin);
cookieStore.delete({ name: ADMIN_SESSION_COOKIE, path: '/' });
const response = NextResponse.json({ ok: true });
response.cookies.set(CUSTOMER_SESSION_COOKIE, '', clearedCustomer);
response.cookies.set(ADMIN_SESSION_COOKIE, '', clearedAdmin);
response.headers.set('Cache-Control', 'no-store');
return response;
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { isMenuManagerEmail } from '@/domain/auth/menu-managers';
import { getCustomerSession } from '@/infrastructure/auth/customer-session';
export const dynamic = 'force-dynamic';
export async function GET() {
const session = await getCustomerSession();
const email = session?.email ?? null;
return NextResponse.json({
authenticated: Boolean(session),
email,
name: session?.name ?? null,
isMenuManager: isMenuManagerEmail(email),
});
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import {
buildGoogleAuthUrl,
createOAuthState,
isGoogleOAuthConfigured,
} from '@/infrastructure/auth/google-oauth';
import {
getOAuthReturnCookieName,
sanitizeOAuthReturnPath,
} from '@/infrastructure/auth/oauth-return';
export const dynamic = 'force-dynamic';
const OAUTH_STATE_COOKIE = 'shahi_google_oauth_state';
export async function GET(request: Request) {
if (!isGoogleOAuthConfigured()) {
return NextResponse.redirect(new URL('/login?tab=customer&error=google_not_configured', getOrigin()));
}
const url = new URL(request.url);
const returnTo = sanitizeOAuthReturnPath(url.searchParams.get('returnTo'));
const state = createOAuthState();
const cookieStore = await cookies();
cookieStore.set(OAUTH_STATE_COOKIE, state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 600,
});
cookieStore.set(getOAuthReturnCookieName(), returnTo, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 600,
});
return NextResponse.redirect(buildGoogleAuthUrl(state));
}
function getOrigin(): string {
return process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';
import { readMenuCategories } from '@/infrastructure/menu/menu-persistence';
export const dynamic = 'force-dynamic';
export async function GET() {
const categories = await readMenuCategories();
return NextResponse.json({ categories });
}
+4 -2
View File
@@ -12,6 +12,7 @@ import {
import { useLanguage } from '@/lib/language-context';
import { getTranslation } from '@/lib/translations';
import { container } from '@/infrastructure/di/container';
import { getWhatsAppInquiryTranslation } from '@/application/messaging/inquiry-language';
import { Sparkles } from 'lucide-react';
export default function CateringPage() {
@@ -19,7 +20,8 @@ export default function CateringPage() {
const t = getTranslation(language);
const handleSendInquiry = () => {
container.messagingGateway.openWhatsApp(t.catering.inquiryMessage);
const inquiryT = getWhatsAppInquiryTranslation(language);
container.messagingGateway.openWhatsApp(inquiryT.catering.inquiryMessage);
};
return (
@@ -62,7 +64,7 @@ export default function CateringPage() {
<button
type="button"
onClick={handleSendInquiry}
className="btn-primary inline-flex shrink-0 items-center justify-center self-start rounded-full px-7 py-3 text-sm font-bold tracking-wide sm:self-center"
className="btn-primary inline-flex w-full sm:w-auto shrink-0 items-center justify-center self-stretch sm:self-start rounded-full px-7 py-3.5 text-sm font-bold tracking-wide sm:self-center min-h-[48px] touch-manipulation"
>
{t.catering.sendInquiry}
</button>
+9 -1
View File
@@ -45,11 +45,19 @@
/* Motion */
--ease: cubic-bezier(0.25, 1, 0.5, 1);
/* Layout — language banner (48px) + navbar (68px) */
/* Layout — synced by HeaderHeightSync (guest 116px, logged-in 140px) */
--header-height: 116px;
--page-top-gap: 1rem;
}
@media (max-width: 639px) {
input,
select,
textarea {
font-size: 16px;
}
}
/* Anchor links (/#menu, /#experience) land below the fixed header */
.scroll-mt-header {
scroll-margin-top: var(--header-height);
+17 -9
View File
@@ -30,12 +30,15 @@
import type { Metadata } from "next";
import { Playfair_Display, Geist, Geist_Mono, Noto_Sans_Arabic } from "next/font/google";
import "./globals.css";
import { Toaster } from "sonner";
import DynamicToaster from "@/components/DynamicToaster";
import CartDrawer from "@/components/CartDrawer";
import WishlistDrawer from "@/components/WishlistDrawer";
import { CartProvider } from "@/presentation/providers/cart-provider";
import { WishlistProvider } from "@/presentation/providers/wishlist-provider";
import { LanguageProvider } from "@/presentation/providers/language-provider";
import { MenuProvider } from "@/presentation/providers/menu-provider";
import { CustomerAuthProvider } from "@/presentation/providers/customer-auth-provider";
import HeaderHeightSync from "@/components/HeaderHeightSync";
const playfair = Playfair_Display({
variable: "--font-playfair",
@@ -108,14 +111,19 @@ export default function RootLayout({
className="min-h-full flex flex-col shahi-body text-[#2C2A26]"
>
<LanguageProvider>
<CartProvider>
<WishlistProvider>
{children}
<CartDrawer />
<WishlistDrawer />
<Toaster position="top-center" richColors closeButton offset={124} />
</WishlistProvider>
</CartProvider>
<CustomerAuthProvider>
<HeaderHeightSync />
<MenuProvider>
<CartProvider>
<WishlistProvider>
{children}
<CartDrawer />
<WishlistDrawer />
<DynamicToaster />
</WishlistProvider>
</CartProvider>
</MenuProvider>
</CustomerAuthProvider>
</LanguageProvider>
</body>
</html>
+4 -4
View File
@@ -53,9 +53,9 @@ export default function LocationsPage() {
<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="bg-white border border-[#EDE6D9] rounded-2xl p-5 sm:p-8">
<div className="uppercase text-[#B38B4D] text-xs tracking-[2px] mb-2">RESTAURANT &amp; BUFFET</div>
<h2 className="text-2xl sm:text-3xl md:text-4xl tracking-[-1.5px] mb-4 break-words">Shahi Kitchen Askim (Sisjön)</h2>
<h2 className="text-xl sm:text-3xl md:text-4xl tracking-[-1.5px] mb-4 break-words">Shahi Kitchen Askim (Sisjön)</h2>
<div className="space-y-5 text-[15px]">
<div>
@@ -93,9 +93,9 @@ export default function LocationsPage() {
</div>
{/* Branch 2: Backaplan */}
<div className="bg-white border border-[#EDE6D9] rounded-2xl p-8">
<div className="bg-white border border-[#EDE6D9] rounded-2xl p-5 sm:p-8">
<div className="uppercase text-[#B38B4D] text-xs tracking-[2px] mb-2">SWEETS, SNACKS &amp; CAFÉ</div>
<h2 className="text-2xl sm:text-3xl md:text-4xl tracking-[-1.5px] mb-4 break-words">Shahi Sweets Backaplan</h2>
<h2 className="text-xl sm:text-3xl md:text-4xl tracking-[-1.5px] mb-4 break-words">Shahi Sweets Backaplan</h2>
<div className="space-y-5 text-[15px]">
<div>
+204 -66
View File
@@ -1,101 +1,181 @@
'use client';
import { logoUrl } from "@/lib/assets";
import Navbar from "@/components/Navbar";
import { Suspense, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { logoUrl } from '@/lib/assets';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
import { useLanguage } from '@/lib/language-context';
import { getTranslation } from '@/lib/translations';
import { motion, AnimatePresence } from 'framer-motion';
import { User, Lock, MapPin, LogIn, Phone, MessageCircle } from 'lucide-react';
import Footer from "@/components/Footer";
import { useLanguage } from "@/lib/language-context";
import { getTranslation } from "@/lib/translations";
import { useState } from 'react';
import { motion, AnimatePresence } from "framer-motion";
import { User, Lock, MapPin, LogIn, Phone, MessageCircle } from "lucide-react";
type LoginTab = 'customer' | 'staff';
/**
* =============================================================================
* STAFF LOGIN / AUTHENTICATION PAGE
* =============================================================================
*
* Beautiful, theme-matched placeholder login experience.
* - Fully language compatible (sv default + en/hi/ur)
* - No real backend yet — always shows friendly "under construction" message
* - Mobile-first: large tap targets, excellent spacing, easy form on phones
* - Desktop: elegant split layout with dark luxury graphic panel + form
* - Uses existing design tokens (gold accents, cream bg, rounded cards, serif titles)
*
* Future: when real auth is added, this will become the entry to a protected staff area
* (per-site menus, orders, inventory etc. for Askim vs Backaplan).
*/
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
);
}
export default function LoginPage() {
function LoginPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { language } = useLanguage();
const t = getTranslation(language);
const [formData, setFormData] = useState({
username: '',
password: '',
site: '',
});
const initialTab = searchParams.get('tab') === 'staff' ? 'staff' : 'customer';
const [activeTab, setActiveTab] = useState<LoginTab>(initialTab);
const [formData, setFormData] = useState({ username: '', password: '', site: '' });
const [isSubmitted, setIsSubmitted] = useState(false);
const [loginError, setLoginError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [customerError, setCustomerError] = useState('');
useEffect(() => {
const tab = searchParams.get('tab');
if (tab === 'staff' || tab === 'customer') {
setActiveTab(tab);
}
const error = searchParams.get('error');
if (error) {
const errorMessages: Record<string, string> = {
google_not_configured: t.auth.customer.notConfigured,
google_denied: t.auth.customer.googleDenied,
google_failed: t.auth.customer.googleFailed,
invalid_state: t.auth.customer.googleFailed,
missing_code: t.auth.customer.googleFailed,
};
setCustomerError(errorMessages[error] ?? t.auth.customer.googleFailed);
setActiveTab('customer');
}
}, [searchParams, t.auth.customer]);
useEffect(() => {
const checkCustomerSession = async () => {
try {
const response = await fetch('/api/auth/customer/session', { cache: 'no-store' });
const data = (await response.json()) as { authenticated: boolean };
if (data.authenticated) {
router.replace('/account');
}
} catch {
// Stay on login page.
}
};
void checkCustomerSession();
}, [router]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleStaffSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Placeholder behaviour: always show the construction notice.
// No validation beyond native required fields — real auth coming later.
setIsSubmitted(true);
setLoginError('');
setIsSubmitting(true);
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: formData.username,
password: formData.password,
}),
});
if (response.ok) {
setLoginError(t.auth.accessDenied);
return;
}
if (formData.username.trim() === 'admin') {
setLoginError(t.auth.accessDenied);
return;
}
setIsSubmitted(true);
} catch {
setLoginError('Could not sign in right now. Please try again.');
} finally {
setIsSubmitting(false);
}
};
const resetForm = () => {
const resetStaffForm = () => {
setFormData({ username: '', password: '', site: '' });
setIsSubmitted(false);
setLoginError('');
};
const handleGoogleSignIn = () => {
setCustomerError('');
const returnTo = searchParams.get('returnTo');
const authUrl =
returnTo && returnTo.startsWith('/') && !returnTo.startsWith('//')
? `/api/auth/google?returnTo=${encodeURIComponent(returnTo)}`
: '/api/auth/google';
window.location.href = authUrl;
};
const panelTitle = activeTab === 'customer' ? t.auth.customer.title : t.auth.title;
const panelSubtitle = activeTab === 'customer' ? t.auth.customer.subtitle : t.auth.subtitle;
const panelBadge = activeTab === 'customer' ? t.auth.customer.badge : 'STAFF PORTAL';
return (
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
<Navbar />
<main className="pb-20">
{/* Elegant header */}
<div className="max-w-5xl mx-auto px-6 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-[#B38B4D]/10 px-4 py-1 text-[#B38B4D] text-[10px] tracking-[3.5px] font-medium mb-3">
STAFF PORTAL
{panelBadge}
</div>
<h1 className="text-2xl sm:text-[1.75rem] md:text-3xl tracking-[-0.8px] leading-[1.15] mb-2 text-[#101724] break-words">
{t.auth.title}
{panelTitle}
</h1>
<p className="mx-auto max-w-md text-sm md:text-[15px] text-[#6B665F] leading-relaxed">
{t.auth.subtitle}
{panelSubtitle}
</p>
</div>
{/* Content area — beautiful split layout on desktop, clean single column on mobile */}
<div className="mx-auto max-w-5xl px-6 mt-10">
<div className="mx-auto max-w-5xl px-4 sm:px-6 mt-8 sm:mt-10">
<div className="grid items-start gap-8 md:grid-cols-5">
{/* LEFT: Premium dark graphic panel (desktop only). Matches Shahi theme with gold details. */}
<div className="hidden md:col-span-2 md:block">
<div className="sticky top-[116px] flex h-[520px] flex-col justify-between overflow-hidden rounded-3xl border border-[#c99a2e]/20 bg-gradient-to-br from-[#101724] via-[#1a1816] to-[#2C2A26] p-10 text-white shadow-2xl">
{/* Subtle gold frame lines */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#c99a2e] to-transparent" />
<div className="absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-[#c99a2e]/50 to-transparent" />
<div>
<div className="mb-8 inline-flex h-16 w-16 items-center justify-center rounded-2xl border border-[#c99a2e]/30 bg-white/5 p-3 backdrop-blur">
<img
src={logoUrl()}
alt="Shahi Kitchen"
className="h-full w-full object-contain"
/>
<img src={logoUrl()} alt="Shahi Kitchen" className="h-full w-full object-contain" />
</div>
<div className="font-serif text-[42px] leading-[0.95] tracking-[-1.8px]">
Shahi<br />Kitchen
</div>
<div className="mt-1 text-sm font-medium tracking-[2.5px] text-[#c99a2e]">
STAFF ACCESS
{activeTab === 'customer' ? 'CUSTOMER ACCESS' : 'STAFF ACCESS'}
</div>
</div>
@@ -111,27 +191,73 @@ export default function LoginPage() {
</div>
</div>
{/* RIGHT / MOBILE: The actual elegant login card */}
<div className="md:col-span-3">
<div className="rounded-3xl border border-[#EDE6D9] bg-white p-8 shadow-xl md:p-10">
{/* Mobile-only logo accent for visual continuity */}
<div className="rounded-3xl border border-[#EDE6D9] bg-white p-5 sm:p-8 shadow-xl md:p-10">
<div className="mb-6 flex justify-center md:hidden">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-[#c99a2e]/30 bg-[#F8F5F0] p-2.5">
<img src={logoUrl()} alt="Shahi Kitchen" className="h-full w-full object-contain" />
</div>
</div>
<div className="mb-6 sm:mb-8 flex rounded-2xl border border-[#EDE6D9] bg-[#F8F5F0] p-1">
{(['customer', 'staff'] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => {
setActiveTab(tab);
setCustomerError('');
setLoginError('');
}}
className={`flex-1 rounded-xl px-3 py-3.5 text-xs sm:text-sm font-semibold transition min-h-[44px] touch-manipulation ${
activeTab === tab
? 'bg-[#101724] text-white shadow-sm'
: 'text-[#6B665F] hover:text-[#101724]'
}`}
>
{t.auth.tabs[tab]}
</button>
))}
</div>
<AnimatePresence mode="wait">
{!isSubmitted ? (
{activeTab === 'customer' ? (
<motion.div
key="login-form"
key="customer-login"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
className="text-center"
>
{customerError && (
<p className="mb-4 text-sm text-red-600" role="alert">
{customerError}
</p>
)}
<button
type="button"
onClick={handleGoogleSignIn}
className="flex w-full items-center justify-center gap-3 rounded-2xl border border-[#EDE6D9] bg-white px-5 py-4 text-base font-semibold text-[#101724] shadow-sm transition hover:border-[#c99a2e]/40 hover:bg-[#FFFCF7] active:scale-[0.985] min-h-[52px] touch-manipulation"
>
<GoogleIcon className="h-5 w-5" />
{t.auth.customer.signInWithGoogle}
</button>
<p className="mt-8 text-center text-[11px] tracking-wide text-[#8A8478]">
{t.auth.customer.footerNote}
</p>
</motion.div>
) : !isSubmitted ? (
<motion.div
key="staff-login-form"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Username */}
<form onSubmit={handleStaffSubmit} className="space-y-6">
<div>
<label htmlFor="username" className="mb-2 block text-sm font-semibold tracking-wide text-[#6B665F]">
{t.auth.usernameLabel}
@@ -151,7 +277,6 @@ export default function LoginPage() {
</div>
</div>
{/* Password */}
<div>
<label htmlFor="password" className="mb-2 block text-sm font-semibold tracking-wide text-[#6B665F]">
{t.auth.passwordLabel}
@@ -171,7 +296,6 @@ export default function LoginPage() {
</div>
</div>
{/* Site / Location dropdown — Askim or Backaplan */}
<div>
<label htmlFor="site" className="mb-2 block text-sm font-semibold tracking-wide text-[#6B665F]">
{t.auth.siteLabel}
@@ -190,19 +314,22 @@ export default function LoginPage() {
<option value="askim">{t.auth.askim}</option>
<option value="backaplan">{t.auth.backaplan}</option>
</select>
{/* Custom dropdown arrow for polish */}
<div className="pointer-events-none absolute end-4 top-4 text-[#B38B4D]"></div>
</div>
<p className="mt-1.5 text-[11px] text-[#8A8478]">Select the location you are working at today.</p>
</div>
{/* Primary gold action button — large & mobile friendly */}
{loginError && (
<p className="text-sm text-red-600" role="alert">{loginError}</p>
)}
<button
type="submit"
className="btn-primary mt-2 flex w-full items-center justify-center gap-3 rounded-2xl py-4 text-base font-semibold tracking-[0.5px] active:scale-[0.985] transition"
disabled={isSubmitting}
className="btn-primary mt-2 flex w-full items-center justify-center gap-3 rounded-2xl py-4 text-base font-semibold tracking-[0.5px] active:scale-[0.985] transition disabled:opacity-60"
>
<LogIn className="h-5 w-5" />
{t.auth.submit}
{isSubmitting ? t.auth.submitting : t.auth.submit}
</button>
</form>
@@ -211,7 +338,6 @@ export default function LoginPage() {
</p>
</motion.div>
) : (
/* Beautiful result state — always shows the construction notice */
<motion.div
key="construction-result"
initial={{ opacity: 0, y: 10 }}
@@ -230,7 +356,6 @@ export default function LoginPage() {
{t.auth.construction.message}
</p>
{/* Helpful contact actions (real numbers from the site) */}
<div className="mt-8 space-y-3">
<a
href="tel:031288910"
@@ -251,7 +376,7 @@ export default function LoginPage() {
</a>
<button
onClick={resetForm}
onClick={resetStaffForm}
className="mt-2 w-full rounded-2xl bg-[#101724] py-3.5 text-sm font-semibold text-white active:bg-black transition"
>
{t.auth.construction.tryAgain}
@@ -266,7 +391,6 @@ export default function LoginPage() {
</AnimatePresence>
</div>
{/* Small trust line */}
<p className="mt-5 text-center text-[10px] text-[#8A8478] tracking-[1px]">
SHAHI KITCHEN GOTHENBURG EST 2016
</p>
@@ -279,3 +403,17 @@ export default function LoginPage() {
</div>
);
}
export default function LoginPage() {
return (
<Suspense
fallback={
<div className="min-h-screen bg-[#F8F5F0] flex items-center justify-center text-[#6B665F]">
Loading
</div>
}
>
<LoginPageContent />
</Suspense>
);
}
+149 -33
View File
@@ -4,8 +4,8 @@
* MENU PAGE — Premium Sidebar Navigation
*/
import { useEffect, useRef, useState, useMemo } from "react";
import { menuCategories } from "@/lib/menu-data";
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { useMenu } from "@/presentation/providers/menu-provider";
import { buildCartLineFromMenuItem } from "@/application/cart/cart-line-builder";
import type { MenuItem } from "@/domain/menu/entities";
import { gsap } from "gsap";
@@ -20,6 +20,7 @@ import { useLanguage } from "@/lib/language-context";
import { getTranslation } from "@/lib/translations";
import {
getCategoryName,
getMenuItemInclusionNote,
getMenuItemDescription,
getMenuItemName,
} from "@/application/i18n/menu-localization";
@@ -41,12 +42,24 @@ export default function MenuPage() {
const [showVegetarianOnly, setShowVegetarianOnly] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [detailItem, setDetailItem] = useState<MenuItem | null>(null);
const [categoryScrollHints, setCategoryScrollHints] = useState({
top: false,
bottom: false,
left: false,
right: false,
});
const categoryListRef = useRef<HTMLDivElement>(null);
const prevCategoryCountRef = useRef(0);
const { addToCart } = useCart();
const { categories: menuCategories, refreshMenu } = useMenu();
const { language } = useLanguage();
const t = getTranslation(language);
useEffect(() => {
void refreshMenu();
}, [refreshMenu]);
// Sidebar categories
const sidebarCategories = [
{ id: "All", name: t.menu.allDishes || "All Dishes" },
@@ -88,12 +101,22 @@ export default function MenuPage() {
return { ...category, items };
})
.filter((category) => category.items.length > 0);
}, [searchQuery, showVegetarianOnly, activeCategory, language, t]);
.filter((category) => {
if (activeCategory !== 'All' && category.id !== activeCategory) return false;
if (searchQuery.trim() || showVegetarianOnly) {
return category.items.length > 0;
}
return true;
});
}, [searchQuery, showVegetarianOnly, activeCategory, language, t, menuCategories]);
const handleCategorySelect = (id: string) => {
setActiveCategory(id);
window.scrollTo({ top: 220, behavior: "smooth" });
const headerHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--header-height'),
10,
) || 116;
window.scrollTo({ top: headerHeight + 80, behavior: 'smooth' });
};
const getItemDescription = (item: MenuItem) =>
@@ -149,6 +172,40 @@ export default function MenuPage() {
return () => window.removeEventListener("resize", updateMobile);
}, []);
const updateCategoryScrollHints = useCallback(() => {
const container = categoryListRef.current;
if (!container) return;
const threshold = 6;
const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth } =
container;
setCategoryScrollHints({
top: scrollTop > threshold,
bottom: scrollTop + clientHeight < scrollHeight - threshold,
left: scrollLeft > threshold,
right: scrollLeft + clientWidth < scrollWidth - threshold,
});
}, []);
useEffect(() => {
const container = categoryListRef.current;
if (!container) return;
updateCategoryScrollHints();
const onScroll = () => updateCategoryScrollHints();
container.addEventListener("scroll", onScroll, { passive: true });
const resizeObserver = new ResizeObserver(() => updateCategoryScrollHints());
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", onScroll);
resizeObserver.disconnect();
};
}, [updateCategoryScrollHints, sidebarCategories.length]);
useEffect(() => {
const container = categoryListRef.current;
if (!container) return;
@@ -166,6 +223,32 @@ export default function MenuPage() {
});
}, [activeCategory]);
useEffect(() => {
if (menuCategories.length <= prevCategoryCountRef.current) {
prevCategoryCountRef.current = menuCategories.length;
return;
}
const newestCategory = menuCategories[menuCategories.length - 1];
prevCategoryCountRef.current = menuCategories.length;
requestAnimationFrame(() => {
const container = categoryListRef.current;
const newestButton = container?.querySelector<HTMLElement>(
`[data-category-id="${newestCategory.id}"]`,
);
if (!newestButton) return;
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
newestButton.scrollIntoView({
behavior: prefersReducedMotion ? "auto" : "smooth",
block: "nearest",
inline: "center",
});
updateCategoryScrollHints();
});
}, [menuCategories, updateCategoryScrollHints]);
return (
<div className="min-h-screen bg-[#F8F5F0] text-[#2C2A26]">
<Navbar />
@@ -182,17 +265,17 @@ export default function MenuPage() {
{/* BEAUTIFUL SIDEBAR */}
<div className="lg:w-72 flex-shrink-0">
<div className="sticky top-[116px]">
<div className="mb-6">
<div className="sticky top-[var(--header-height)] z-10 flex flex-col lg:max-h-[calc(100dvh-var(--header-height)-1.5rem)]">
<div className="mb-4 shrink-0">
<div className="text-xs tracking-[3px] text-[#c99a2e] mb-2">EXPLORE OUR</div>
<h3 className="font-serif text-2xl sm:text-3xl tracking-tight">Signature Categories</h3>
</div>
{/* Smooth scroll: horizontal on mobile, vertical on desktop when list exceeds viewport */}
<div className="relative">
<div className="relative min-h-0 lg:flex-1">
<div
ref={categoryListRef}
className="menu-category-scroll flex gap-2 overflow-x-auto overflow-y-hidden scroll-smooth overscroll-x-contain overscroll-y-contain pb-3 pr-1 snap-x snap-mandatory scroll-px-1 [-webkit-overflow-scrolling:touch] lg:flex-col lg:overflow-x-hidden lg:overflow-y-auto lg:max-h-[calc(100dvh-13.5rem)] lg:snap-none lg:scroll-px-0 lg:pb-2 lg:pr-2"
className="menu-category-scroll flex gap-2 overflow-x-auto overflow-y-hidden scroll-smooth overscroll-x-contain overscroll-y-contain pb-3 pe-4 ps-1 snap-x snap-mandatory scroll-px-1 [-webkit-overflow-scrolling:touch] lg:h-full lg:min-h-0 lg:flex-col lg:overflow-x-hidden lg:overflow-y-auto lg:snap-none lg:scroll-px-0 lg:pb-4 lg:pe-2 lg:ps-0"
>
{sidebarCategories.map((cat) => {
const isActive = activeCategory === cat.id;
@@ -223,29 +306,59 @@ export default function MenuPage() {
);
})}
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-[#F8F5F0] to-transparent lg:hidden"
/>
{categoryScrollHints.left && (
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-[#F8F5F0] via-[#F8F5F0]/80 to-transparent lg:hidden"
/>
)}
{categoryScrollHints.right && (
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-[#F8F5F0] via-[#F8F5F0]/80 to-transparent lg:hidden"
/>
)}
{categoryScrollHints.top && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 z-10 hidden h-7 bg-gradient-to-b from-[#F8F5F0] via-[#F8F5F0]/80 to-transparent lg:block"
/>
)}
{categoryScrollHints.bottom && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 hidden h-10 bg-gradient-to-t from-[#F8F5F0] via-[#F8F5F0]/90 to-transparent lg:block"
/>
)}
</div>
{categoryScrollHints.bottom && (
<p className="mt-2 hidden text-center text-[10px] font-medium tracking-wide text-[#8A8478] lg:block">
Scroll for more categories
</p>
)}
{categoryScrollHints.right && (
<p className="mt-2 text-center text-[10px] font-medium tracking-wide text-[#8A8478] lg:hidden">
Swipe for more categories
</p>
)}
</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">
<div className="mb-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<input
type="text"
placeholder={t.menu.searchPlaceholder}
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"
className="w-full sm:max-w-80 rounded-2xl border border-[#e5e1d7] bg-white px-5 py-3 text-base sm: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 active:scale-[0.985] ${
className={`w-full sm:w-auto px-6 py-3.5 rounded-2xl text-sm font-medium border transition-all active:scale-[0.985] touch-manipulation ${
showVegetarianOnly
? "bg-[#0f5a4a] text-white border-[#0f5a4a]"
: "border-[#e5e1d7] hover:border-[#c99a2e] text-[#101724] bg-white"
@@ -286,28 +399,26 @@ export default function MenuPage() {
</div>
</div>
{category.items.length === 0 ? (
<p className="rounded-2xl border border-dashed border-[#EDE6D9] bg-white/60 px-6 py-10 text-center text-sm text-[#8A8478]">
Dishes coming soon to this category.
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{category.items.map((item) => {
const usesDetailModal = hasDishDetailModal(item.id, category.id);
const inclusionNote = getMenuItemInclusionNote(language, category.id);
return (
<motion.div
key={item.id}
data-id={item.id}
className={`menu-card group bg-white border border-[#EDE6D9] rounded-2xl overflow-hidden flex flex-col hover:border-[#c99a2e]/40 active:border-[#c99a2e] active:scale-[0.985] transition-all duration-150 touch-manipulation ${
usesDetailModal ? "" : "cursor-pointer"
}`}
onClick={
usesDetailModal
? undefined
: () => addToCart(buildCartLineFromMenuItem(item))
}
className="menu-card group bg-white border border-[#EDE6D9] rounded-2xl overflow-hidden flex flex-col hover:border-[#c99a2e]/40 transition-all duration-150 touch-manipulation"
whileHover={!isMobile ? {
y: -4,
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
transition: { type: "spring", stiffness: 300, damping: 20 }
} : {}}
whileTap={{ scale: 0.985 }}
>
{/* Media - Always poster for performance. Advanced desktop hover effect (scale + lift + glow) + subtle sound. Mobile uses simple scale. */}
<div
@@ -358,10 +469,17 @@ export default function MenuPage() {
</div>
<div className="p-6 flex flex-col flex-1">
<div className="flex justify-between items-start gap-3 mb-4 min-w-0">
<h3 className="min-w-0 flex-1 text-lg sm:text-[22px] leading-tight tracking-[-0.4px] text-[#2C2A26] break-words">
{getItemName(item)}
</h3>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-between sm:items-start mb-4 min-w-0">
<div className="min-w-0 flex-1">
<h3 className="text-lg sm:text-[22px] leading-tight tracking-[-0.4px] text-[#2C2A26] break-words">
{getItemName(item)}
</h3>
{inclusionNote && (
<p className="mt-1 text-xs font-medium text-[#8f6b22]">
{inclusionNote}
</p>
)}
</div>
<div className="shrink-0">{renderMenuPrice(item)}</div>
</div>
@@ -392,10 +510,7 @@ export default function MenuPage() {
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
addToCart(buildCartLineFromMenuItem(item));
}}
onClick={() => addToCart(buildCartLineFromMenuItem(item))}
className="w-full py-3.5 text-sm tracking-[0.6px] border border-[#B38B4D] text-[#B38B4D] rounded-full hover:bg-[#B38B4D] hover:text-white active:bg-[#8C6B3A] active:text-white active:scale-[0.985] font-medium transition-all touch-manipulation"
>
{t.wishlistDrawer.addToCart}
@@ -406,6 +521,7 @@ export default function MenuPage() {
);
})}
</div>
)}
</div>
))
)}
+2 -2
View File
@@ -29,7 +29,7 @@ import {
type OrderLine,
} from '@/domain/shared/order-line';
import { buildCartLineFromMenuItem } from '@/application/cart/cart-line-builder';
import { container } from '@/infrastructure/di/container';
import { useMenu } from '@/presentation/providers/menu-provider';
import type { MenuItem } from '@/domain/menu/entities';
import { playAddSound, playSuccessSound } from '@/infrastructure/audio/web-audio-sound-adapter';
import { useLanguage } from '@/presentation/providers/language-provider';
@@ -53,7 +53,7 @@ export default function OrderFromTablePage() {
const to = t.tableOrder;
const parsed = useMemo(() => parseTableId(tableParam), [tableParam]);
const allMenuItems = container.menuRepository.getAllItems();
const { allItems: allMenuItems } = useMenu();
// Local order state (NOT the global cart)
const [selected, setSelected] = useState<OrderLine[]>([]);
+8 -3
View File
@@ -13,7 +13,7 @@ import Footer from "@/components/Footer";
import { useCart } from "@/components/CartContext";
import { useLanguage } from "@/lib/language-context";
import { heroBannerSources } from "@/lib/assets";
import { menuCategories } from "@/lib/menu-data";
import { useMenu } from "@/presentation/providers/menu-provider";
import { buildCartLineFromMenuItem } from "@/application/cart/cart-line-builder";
import type { MenuItem } from "@/domain/menu/entities";
import { getTranslation } from "@/lib/translations";
@@ -28,12 +28,17 @@ type HomepageDish = SignatureDish & { filterTag: SignatureFilter | null };
export default function ShahiKitchenHomepage() {
const { addToCart } = useCart();
const { categories: menuCategories } = useMenu();
const { language } = useLanguage();
const t = getTranslation(language);
const [menuFilter, setMenuFilter] = useState<SignatureFilter>("All");
// Lenis smooth scroll
// Lenis smooth scroll — desktop only (native scroll feels better on touch devices)
useEffect(() => {
const isMobile = window.matchMedia('(max-width: 767px)').matches;
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (isMobile || prefersReducedMotion) return;
const lenis = new Lenis({
duration: 1.1,
easing: (t: number) => Math.min(1, 1.001 * (-Math.pow(2, -10 * t) + 1)),
@@ -64,7 +69,7 @@ export default function ShahiKitchenHomepage() {
filterTag: getDishFilterTag(item, category.id),
}))
),
[language, t]
[language, t, menuCategories]
);
const filtered = useMemo(() => {
+306 -52
View File
@@ -19,7 +19,6 @@ import type { MenuItem } from '@/domain/menu/entities';
import {
getMenuPosterSrc,
getMenuPosterCandidates,
logoUrl,
applyNextImageFallback,
SITE_ASSETS,
} from '@/lib/assets';
@@ -31,11 +30,49 @@ import {
getMenuItemName,
localizeOrderLineName,
} from '@/application/i18n/menu-localization';
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from "framer-motion";
import { MapPin, Calendar, Clock, Users, User, Phone, Mail, MessageCircle, Plus, Minus, X, ArrowRight, Search, CheckCircle } from "lucide-react";
import { menuCategories } from "@/lib/menu-data";
import { MapPin, Calendar, Clock, Users, User, Phone, Mail, MessageCircle, Plus, Minus, X, ArrowRight, Search, CheckCircle, UtensilsCrossed, PartyPopper, Building2, Sparkles } from "lucide-react";
import { useMenu } from "@/presentation/providers/menu-provider";
import { playAddSound, playHoverSound, playSuccessSound } from "@/lib/sounds";
import type { BookingMode, EventTypeId } from '@/domain/booking/event-types';
import { EVENT_GUEST_OPTIONS, TABLE_GUEST_OPTIONS } from '@/domain/booking/event-types';
import {
getBookingEventsCopy,
getEventTypeLabel,
toBookingMessageCopy,
} from '@/presentation/i18n/booking-events';
import {
getWhatsAppInquiryLanguage,
getWhatsAppInquiryTranslation,
} from '@/application/messaging/inquiry-language';
import EventTypePicker from '@/components/reserve/EventTypePicker';
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
const RESERVE_DRAFT_STORAGE_KEY = 'shahi-reserve-draft';
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
);
}
/**
* =============================================================================
@@ -55,20 +92,30 @@ import { playAddSound, playHoverSound, playSuccessSound } from "@/lib/sounds";
* Linked from navbar (after swap) and locations.
*/
const EMPTY_BOOKING: BookingDetails = {
bookingMode: 'table',
eventType: '',
eventTypeOther: '',
location: '',
date: '',
time: '',
guests: '',
name: '',
phone: '',
email: '',
notes: '',
};
export default function ReservePage() {
const { categories: menuCategories } = useMenu();
const { language } = useLanguage();
const t = getTranslation(language);
const eventsCopy = getBookingEventsCopy(language);
const { email: customerEmail, name: customerName, isAuthenticated, isLoading: isAuthLoading } =
useCustomerAuth();
const [booking, setBooking] = useState<BookingDetails>({
location: '',
date: '',
time: '',
guests: '',
name: '',
phone: '',
email: '',
notes: '',
});
const [booking, setBooking] = useState<BookingDetails>(EMPTY_BOOKING);
const [draftRestored, setDraftRestored] = useState(false);
const [preOrder, setPreOrder] = useState<PreOrderLine[]>([]);
const [showMenu, setShowMenu] = useState(false);
const [search, setSearch] = useState('');
@@ -79,13 +126,93 @@ export default function ReservePage() {
setBooking((prev) => ({ ...prev, [name]: value }));
};
const persistReserveDraft = useCallback((draft: BookingDetails) => {
try {
sessionStorage.setItem(RESERVE_DRAFT_STORAGE_KEY, JSON.stringify(draft));
} catch {
// Ignore storage errors.
}
}, []);
const handleGoogleSignInForBooking = () => {
persistReserveDraft(booking);
window.location.href = '/api/auth/google?returnTo=%2Freserve';
};
useEffect(() => {
if (draftRestored) return;
try {
const raw = sessionStorage.getItem(RESERVE_DRAFT_STORAGE_KEY);
if (!raw) {
setDraftRestored(true);
return;
}
const parsed = JSON.parse(raw) as Partial<BookingDetails>;
setBooking((prev) => ({
...prev,
bookingMode: parsed.bookingMode === 'event' ? 'event' : 'table',
eventType: typeof parsed.eventType === 'string' ? parsed.eventType : '',
eventTypeOther: typeof parsed.eventTypeOther === 'string' ? parsed.eventTypeOther : '',
location: typeof parsed.location === 'string' ? parsed.location : '',
date: typeof parsed.date === 'string' ? parsed.date : '',
time: typeof parsed.time === 'string' ? parsed.time : '',
guests: typeof parsed.guests === 'string' ? parsed.guests : '',
name: typeof parsed.name === 'string' ? parsed.name : '',
phone: typeof parsed.phone === 'string' ? parsed.phone : '',
notes: typeof parsed.notes === 'string' ? parsed.notes : '',
}));
sessionStorage.removeItem(RESERVE_DRAFT_STORAGE_KEY);
} catch {
sessionStorage.removeItem(RESERVE_DRAFT_STORAGE_KEY);
} finally {
setDraftRestored(true);
}
}, [draftRestored]);
useEffect(() => {
if (!isAuthenticated || !customerEmail) return;
setBooking((prev) => ({
...prev,
email: customerEmail,
name: prev.name.trim() ? prev.name : customerName?.trim() ?? prev.name,
}));
}, [isAuthenticated, customerEmail, customerName]);
const validateForm = (): boolean => isBookingComplete(booking);
const setBookingMode = (mode: BookingMode) => {
setBooking((prev) => ({
...prev,
bookingMode: mode,
eventType: mode === 'table' ? '' : prev.eventType,
eventTypeOther: mode === 'table' ? '' : prev.eventTypeOther,
guests: '',
}));
};
const selectEventType = (eventType: EventTypeId) => {
setBooking((prev) => ({
...prev,
eventType,
eventTypeOther: eventType === 'other' ? prev.eventTypeOther : '',
}));
};
const handleContinue = (e: React.FormEvent) => {
e.preventDefault();
if (booking.bookingMode === 'event' && !booking.eventType) {
alert(eventsCopy.eventTypeRequired);
return;
}
if (booking.bookingMode === 'event' && booking.eventType === 'other' && !booking.eventTypeOther.trim()) {
alert(eventsCopy.eventTypeOtherRequired);
return;
}
if (validateForm()) {
setShowMenu(true);
// Smooth scroll to menu on mobile
setTimeout(() => {
const menuEl = document.getElementById('preorder-menu');
if (menuEl) menuEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -131,23 +258,35 @@ export default function ReservePage() {
playSuccessSound();
const message = buildBookingWhatsAppMessage(booking, preOrder, {
askim: t.booking.askim,
backaplan: t.booking.backaplan,
});
const inquiryLang = getWhatsAppInquiryLanguage(language);
const inquiryT = getWhatsAppInquiryTranslation(language);
const inquiryEventsCopy = getBookingEventsCopy(inquiryLang);
const message = buildBookingWhatsAppMessage(
booking,
preOrder,
toBookingMessageCopy(inquiryEventsCopy, {
askim: inquiryT.booking.askim,
backaplan: inquiryT.booking.backaplan,
}),
booking.eventType ? getEventTypeLabel(inquiryLang, booking.eventType) : '',
inquiryLang,
);
if (!message) return;
container.messagingGateway.openWhatsApp(message);
setSent(true);
};
const resetAll = () => {
setBooking({ location: '', date: '', time: '', guests: '', name: '', phone: '', email: '', notes: '' });
setBooking(EMPTY_BOOKING);
setPreOrder([]);
setShowMenu(false);
setSearch('');
setSent(false);
};
const guestOptions =
booking.bookingMode === 'event' ? EVENT_GUEST_OPTIONS : TABLE_GUEST_OPTIONS;
const editBooking = () => {
setShowMenu(false);
setSent(false);
@@ -164,42 +303,148 @@ export default function ReservePage() {
<Navbar />
<main className="pb-20">
{/* Elegant Header */}
<div className="max-w-5xl mx-auto px-6 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-[#B38B4D]/10 px-4 py-1 text-[#B38B4D] text-[10px] tracking-[3.5px] font-medium mb-3">
EXCLUSIVE EXPERIENCE
{/* Hero */}
<div className="relative overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(201,154,46,0.12),transparent_55%)]" />
<div className="relative mx-auto max-w-5xl px-4 py-8 text-center sm:px-6 sm:py-14">
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-[#c99a2e]/25 bg-[#fff6dc] px-4 py-1.5 text-[10px] font-bold uppercase tracking-[0.28em] text-[#8a6a25]">
<Sparkles className="h-3.5 w-3.5" />
{eventsCopy.heroBadge}
</div>
<h1 className="mb-4 font-serif text-3xl leading-[1.1] tracking-[-0.8px] text-[#101724] sm:text-4xl md:text-5xl">
{eventsCopy.heroTitle}
</h1>
<p className="mx-auto max-w-2xl text-sm leading-relaxed text-[#6B665F] md:text-base">
{eventsCopy.heroSubtitle}
</p>
</div>
<h1 className="text-2xl sm:text-[1.75rem] md:text-3xl tracking-[-0.8px] leading-[1.15] mb-2 text-[#101724] break-words">
{t.booking.title}
</h1>
<p className="mx-auto max-w-xl text-sm md:text-[15px] text-[#6B665F] leading-relaxed">
{t.booking.subtitle}
</p>
</div>
<div className="max-w-5xl mx-auto px-6 mt-10">
{/* Decorative header info - now ABOVE the form (not on side) so form can be wider */}
<div className="mb-10 flex justify-center">
<div className="w-full max-w-2xl rounded-3xl border border-[#c99a2e]/20 bg-gradient-to-br from-[#101724] via-[#1a1816] to-[#2C2A26] p-6 sm:p-8 text-white shadow-2xl text-center">
<div className="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-2xl border border-[#c99a2e]/30 bg-white/5 p-3 backdrop-blur mx-auto">
<img src={logoUrl()} alt="Shahi Kitchen" className="h-full w-full object-contain" />
<div className="mx-auto max-w-5xl px-4 sm:px-6">
{/* Experience pillars */}
<div className="mb-10 grid gap-4 sm:grid-cols-3">
{[
{ icon: UtensilsCrossed, title: eventsCopy.experienceDiningTitle, desc: eventsCopy.experienceDiningDesc, accent: 'from-[#0f5a4a]/10 to-[#0f5a4a]/5' },
{ icon: PartyPopper, title: eventsCopy.experienceCelebrateTitle, desc: eventsCopy.experienceCelebrateDesc, accent: 'from-[#c99a2e]/15 to-[#fff6dc]' },
{ icon: Building2, title: eventsCopy.experienceCorporateTitle, desc: eventsCopy.experienceCorporateDesc, accent: 'from-[#101724]/8 to-[#101724]/3' },
].map((item) => (
<div
key={item.title}
className={`rounded-3xl border border-[#EDE6D9] bg-gradient-to-br ${item.accent} p-5 shadow-sm`}
>
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-2xl bg-white shadow-sm">
<item.icon className="h-5 w-5 text-[#B38B4D]" />
</div>
<h2 className="mb-1 font-serif text-lg tracking-tight text-[#101724]">{item.title}</h2>
<p className="text-sm leading-relaxed text-[#6B665F]">{item.desc}</p>
</div>
<div className="font-serif text-2xl sm:text-3xl md:text-4xl tracking-[-1.5px] mb-1">Shahi Kitchen</div>
<div className="text-sm font-medium tracking-[2.5px] text-[#c99a2e] mb-4">TABLE RESERVATIONS</div>
<p className="text-sm leading-relaxed text-white/70 max-w-xs mx-auto mb-4">
Two locations. Unforgettable evenings. Pre-order your favorites for a perfect arrival.
</p>
<div className="text-[10px] uppercase tracking-[2.5px] text-[#c99a2e]/60">ASKIM BACKAPLAN</div>
</div>
))}
</div>
<AnimatePresence mode="wait">
{!sent ? (
<div className="max-w-4xl mx-auto space-y-8">
{/* Booking Form Card - now wider (full in max-w-4xl) */}
<div className="rounded-3xl border border-[#EDE6D9] bg-white p-6 sm:p-8 shadow-xl md:p-10">
<h2 className="text-2xl sm:text-3xl tracking-tight font-serif mb-2">{t.booking.formTitle}</h2>
<p className="text-[#6B665F] mb-8">Fill in your details to secure your table.</p>
<div className="rounded-3xl border border-[#EDE6D9] bg-white p-4 shadow-xl sm:p-8 md:p-10">
{/* Booking mode toggle */}
<div className="mb-6 grid grid-cols-1 gap-3 sm:mb-8 sm:grid-cols-2">
{([
{ mode: 'table' as const, label: eventsCopy.modeTable, desc: eventsCopy.modeTableDesc, icon: UtensilsCrossed },
{ mode: 'event' as const, label: eventsCopy.modeEvent, desc: eventsCopy.modeEventDesc, icon: PartyPopper },
]).map((option) => {
const active = booking.bookingMode === option.mode;
return (
<button
key={option.mode}
type="button"
onClick={() => setBookingMode(option.mode)}
className={`flex min-h-[72px] touch-manipulation items-start gap-3 rounded-2xl border p-3.5 text-left transition-all active:scale-[0.99] sm:gap-4 sm:p-4 ${
active
? 'border-[#c99a2e] bg-gradient-to-br from-[#fff6dc] to-white shadow-md ring-2 ring-[#c99a2e]/25'
: 'border-[#EDE6D9] bg-[#FFFCF7] hover:border-[#c99a2e]/40'
}`}
>
<div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl ${active ? 'bg-[#c99a2e] text-[#241806]' : 'bg-[#F8F5F0] text-[#B38B4D]'}`}>
<option.icon className="h-5 w-5" />
</div>
<div>
<div className="font-semibold text-[#101724]">{option.label}</div>
<div className="text-sm text-[#6B665F]">{option.desc}</div>
</div>
</button>
);
})}
</div>
{booking.bookingMode === 'event' && (
<>
<EventTypePicker
language={language}
selected={booking.eventType}
onSelect={selectEventType}
title={eventsCopy.eventPickerTitle}
subtitle={eventsCopy.eventPickerSubtitle}
/>
{booking.eventType === 'other' && (
<div className="mb-8">
<label htmlFor="eventTypeOther" className="mb-2 block text-sm font-semibold tracking-wide text-[#6B665F]">
{eventsCopy.eventTypeOtherLabel}
</label>
<input
id="eventTypeOther"
name="eventTypeOther"
type="text"
value={booking.eventTypeOther}
onChange={handleBookingChange}
placeholder={eventsCopy.eventTypeOtherPlaceholder}
className="w-full rounded-2xl border border-[#EDE6D9] bg-[#F8F5F0] px-4 py-4 text-[17px] placeholder:text-[#8A8478] transition-all outline-none focus:border-[#B38B4D] focus:bg-white focus:ring-1 focus:ring-[#B38B4D]/20"
/>
</div>
)}
</>
)}
{isAuthLoading ? (
<div className="rounded-2xl border border-[#EDE6D9] bg-[#FFFCF7] px-4 py-8 text-center text-sm text-[#6B665F] sm:px-6 sm:py-10">
Checking your account
</div>
) : !isAuthenticated ? (
<div className="rounded-2xl border border-[#EDE6D9] bg-gradient-to-br from-[#FFFCF7] to-[#FFF6DC]/40 px-4 py-8 text-center sm:px-8 sm:py-10">
<h2 className="mb-2 font-serif text-xl tracking-tight text-[#101724] sm:text-3xl">
{t.booking.signInRequiredTitle}
</h2>
<p className="mx-auto mb-6 max-w-md text-sm leading-relaxed text-[#6B665F] sm:mb-8 sm:text-base">
{t.booking.signInRequiredSubtitle}
</p>
<button
type="button"
onClick={handleGoogleSignInForBooking}
className="mx-auto flex w-full max-w-md min-h-[52px] touch-manipulation items-center justify-center gap-3 rounded-2xl border border-[#EDE6D9] bg-white px-4 py-4 text-base font-semibold text-[#101724] shadow-sm transition hover:border-[#c99a2e]/40 hover:bg-[#FFFCF7] active:scale-[0.985] sm:px-5"
>
<GoogleIcon className="h-5 w-5 shrink-0" />
<span className="text-left leading-snug">{t.auth.customer.signInWithGoogle}</span>
</button>
</div>
) : (
<>
<div className="mb-5 flex items-center gap-3 rounded-2xl border border-[#c99a2e]/25 bg-gradient-to-r from-[#FFF6DC] to-[#FFFCF7] px-3 py-3 sm:mb-6 sm:px-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#101724] text-sm font-bold text-white">
{(customerEmail ?? '?').charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-[#8f6b22]">
{t.booking.signedInAs}
</p>
<p className="truncate text-sm font-medium text-[#101724]">{customerEmail ?? ''}</p>
</div>
</div>
<h2 className="mb-2 font-serif text-xl tracking-tight sm:text-3xl">{t.booking.formTitle}</h2>
<p className="mb-6 text-sm text-[#6B665F] sm:mb-8 sm:text-base">
{booking.bookingMode === 'event'
? eventsCopy.formSubtitleEvent
: eventsCopy.formSubtitleTable}
</p>
<form onSubmit={handleContinue} className="space-y-6">
{/* Location */}
@@ -275,7 +520,11 @@ export default function ReservePage() {
className="w-full cursor-pointer appearance-none rounded-2xl border border-[#EDE6D9] bg-[#F8F5F0] py-4 ps-12 pe-10 text-[17px] transition-all outline-none focus:border-[#B38B4D] focus:bg-white focus:ring-1 focus:ring-[#B38B4D]/20"
>
<option value="">Select</option>
{[1,2,3,4,5,6,7,8,9,10].map(n => <option key={n} value={n}>{n}</option>)}
{guestOptions.map((n) => (
<option key={n} value={n}>
{booking.bookingMode === 'event' ? `${n} guests` : n}
</option>
))}
</select>
<div className="pointer-events-none absolute end-4 top-4 text-[#B38B4D]"></div>
</div>
@@ -329,7 +578,10 @@ export default function ReservePage() {
value={booking.email}
onChange={handleBookingChange}
placeholder={t.booking.emailPlaceholder}
className="w-full rounded-2xl border border-[#EDE6D9] bg-[#F8F5F0] py-4 ps-12 pe-4 text-[17px] placeholder:text-[#8A8478] transition-all outline-none focus:border-[#B38B4D] focus:bg-white focus:ring-1 focus:ring-[#B38B4D]/20"
readOnly={Boolean(customerEmail)}
className={`w-full rounded-2xl border border-[#EDE6D9] py-4 ps-12 pe-4 text-[17px] placeholder:text-[#8A8478] transition-all outline-none focus:border-[#B38B4D] focus:bg-white focus:ring-1 focus:ring-[#B38B4D]/20 ${
customerEmail ? 'bg-[#F8F5F0]/80 text-[#6B665F] cursor-default' : 'bg-[#F8F5F0]'
}`}
/>
</div>
</div>
@@ -359,11 +611,13 @@ export default function ReservePage() {
<ArrowRight className="h-5 w-5" />
</button>
</form>
</>
)}
</div>
{/* Pre-Order Menu Section - revealed after form */}
<AnimatePresence>
{showMenu && (
{showMenu && isAuthenticated && (
<motion.div
id="preorder-menu"
initial={{ opacity: 0, y: 20 }}
@@ -445,7 +699,7 @@ export default function ReservePage() {
playAddSound();
addToPreOrder(dish);
}}
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold shadow-sm active:scale-[0.96] transition-all ${
className={`flex items-center gap-1.5 rounded-full px-4 py-2.5 text-xs font-semibold shadow-sm active:scale-[0.96] transition-all min-h-[44px] touch-manipulation ${
isAdded
? "bg-[#3F5C4A] text-white hover:bg-[#2a4033]"
: "bg-[#B38B4D] text-white hover:bg-[#8f6b22] hover:shadow-md"
@@ -492,11 +746,11 @@ export default function ReservePage() {
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button onClick={() => updatePreOrderQty(item.id, item.quantity - 1)} className="w-7 h-7 flex items-center justify-center border border-[#EDE6D9] rounded text-sm hover:bg-white active:bg-white active:scale-95">-</button>
<button onClick={() => updatePreOrderQty(item.id, item.quantity - 1)} className="min-w-11 min-h-11 flex items-center justify-center border border-[#EDE6D9] rounded-lg text-sm hover:bg-white active:bg-white active:scale-95 touch-manipulation">-</button>
<span className="min-w-10 text-center font-medium text-sm tabular-nums">
{formatLineQuantityLabel(item)}
</span>
<button onClick={() => updatePreOrderQty(item.id, item.quantity + 1)} className="w-7 h-7 flex items-center justify-center border border-[#EDE6D9] rounded text-sm hover:bg-white active:bg-white active:scale-95">+</button>
<button onClick={() => updatePreOrderQty(item.id, item.quantity + 1)} className="min-w-11 min-h-11 flex items-center justify-center border border-[#EDE6D9] rounded-lg text-sm hover:bg-white active:bg-white active:scale-95 touch-manipulation">+</button>
<button onClick={() => removeFromPreOrder(item.id)} className="ml-1 text-[#B38B4D] hover:text-red-600 p-0.5 active:scale-95"><X className="h-4 w-4" /></button>
</div>
</div>