Replace entire repo content with code from /root/shahikitchen-google/
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 “Add dish” 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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 & 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 & 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
@@ -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
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user