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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
const ADMIN_USERNAME = 'admin';
|
||||
const ADMIN_PASSWORD = 'bubbasen99';
|
||||
|
||||
export function isValidAdminCredentials(username: string, password: string): boolean {
|
||||
return username.trim() === ADMIN_USERNAME && password === ADMIN_PASSWORD;
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import type { Language } from '@/domain/language/entities';
|
||||
import type { MenuItem } from '@/domain/menu/entities';
|
||||
import type { OrderLine } from '@/domain/shared/order-line';
|
||||
import { getMenuItemById } from '@/infrastructure/menu/static-menu-data';
|
||||
import { getCategoryName as resolveCategoryName } from '@/presentation/i18n/menu/category-names';
|
||||
import {
|
||||
getCategoryName as resolveCategoryName,
|
||||
getMenuItemInclusionNote as resolveMenuItemInclusionNote,
|
||||
} from '@/presentation/i18n/menu/category-names';
|
||||
import { getMenuItemName as resolveItemName } from '@/presentation/i18n/menu/item-names';
|
||||
import type { TranslationBundle } from '@/presentation/i18n/translations';
|
||||
|
||||
@@ -39,6 +42,10 @@ export function getCategoryName(
|
||||
return resolveCategoryName(lang, categoryId, fallback);
|
||||
}
|
||||
|
||||
export function getMenuItemInclusionNote(lang: Language, categoryId: string): string | undefined {
|
||||
return resolveMenuItemInclusionNote(lang, categoryId);
|
||||
}
|
||||
|
||||
export function localizeMenuItem(
|
||||
lang: Language,
|
||||
t: TranslationBundle,
|
||||
|
||||
@@ -5,23 +5,11 @@ import {
|
||||
} from '@/domain/language/entities';
|
||||
import type { LanguageRepository } from '@/domain/language/repository';
|
||||
|
||||
export function detectBrowserLanguage(): Language {
|
||||
if (typeof navigator === 'undefined') return DEFAULT_LANGUAGE;
|
||||
|
||||
const browserLang = navigator.language.toLowerCase();
|
||||
if (browserLang.startsWith('sv')) return 'sv';
|
||||
if (browserLang.startsWith('ar')) return 'ar';
|
||||
if (browserLang.startsWith('tr')) return 'tr';
|
||||
if (browserLang.startsWith('hi')) return 'hi';
|
||||
if (browserLang.startsWith('ur')) return 'ur';
|
||||
if (browserLang.startsWith('en')) return 'en';
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
/** New visitors see Swedish unless they have saved a language preference. */
|
||||
export function resolveInitialLanguage(repository: LanguageRepository): Language {
|
||||
const saved = repository.load();
|
||||
if (saved && isLanguage(saved)) return saved;
|
||||
return detectBrowserLanguage();
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
export function persistLanguage(repository: LanguageRepository, language: Language): void {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Language } from '@/domain/language/entities';
|
||||
import { getTranslation, type TranslationBundle } from '@/presentation/i18n/translations';
|
||||
import type { CartInquiryCopy } from '@/application/messaging/whatsapp-message-builder';
|
||||
|
||||
/** WhatsApp inquiries are sent in Swedish or English only — never other UI languages. */
|
||||
export type WhatsAppInquiryLanguage = 'sv' | 'en';
|
||||
|
||||
export function getWhatsAppInquiryLanguage(language: Language): WhatsAppInquiryLanguage {
|
||||
return language === 'sv' ? 'sv' : 'en';
|
||||
}
|
||||
|
||||
export function getWhatsAppInquiryTranslation(language: Language): TranslationBundle {
|
||||
return getTranslation(getWhatsAppInquiryLanguage(language));
|
||||
}
|
||||
|
||||
export function buildCartInquiryCopy(t: TranslationBundle): CartInquiryCopy {
|
||||
return {
|
||||
pickupIntro: t.cartDrawer.inquiryPickupIntro,
|
||||
deliveryIntro: t.cartDrawer.inquiryDeliveryIntro,
|
||||
messageTotal: t.cartDrawer.messageTotal,
|
||||
messageName: t.cartDrawer.messageName,
|
||||
messageEmail: t.cartDrawer.messageEmail,
|
||||
messageBranch: t.cartDrawer.messageBranch,
|
||||
branchAskim: t.cartDrawer.branchAskim,
|
||||
branchBackaplan: t.cartDrawer.branchBackaplan,
|
||||
messageAddress: t.cartDrawer.messageAddress,
|
||||
messagePreferredDate: t.cartDrawer.messagePreferredDate,
|
||||
messagePreferredTime: t.cartDrawer.messagePreferredTime,
|
||||
scheduleDateToday: t.cartDrawer.dateToday,
|
||||
scheduleDateTomorrow: t.cartDrawer.dateTomorrow,
|
||||
deliverySwishNote: t.cartDrawer.deliverySwishNote,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DeliveryInquiryDetails, PickupInquiryDetails } from '@/domain/cart/inquiry';
|
||||
import type { DeliveryInquiryDetails, InquiryPreferredDate, PickupInquiryDetails } from '@/domain/cart/inquiry';
|
||||
import { hasInquirySchedule, isInquiryBranchOnlineEnabled } from '@/domain/cart/inquiry';
|
||||
import type { BookingDetails } from '@/domain/booking/entities';
|
||||
import { isBookingComplete } from '@/domain/booking/validation';
|
||||
import type { Language } from '@/domain/language/entities';
|
||||
@@ -16,9 +17,15 @@ export interface CartInquiryCopy {
|
||||
deliveryIntro: string;
|
||||
messageTotal: string;
|
||||
messageName: string;
|
||||
messagePhone: string;
|
||||
messageEmail: string;
|
||||
messageBranch: string;
|
||||
branchAskim: string;
|
||||
branchBackaplan: string;
|
||||
messageAddress: string;
|
||||
messagePreferredDate: string;
|
||||
messagePreferredTime: string;
|
||||
scheduleDateToday: string;
|
||||
scheduleDateTomorrow: string;
|
||||
deliverySwishNote: string;
|
||||
}
|
||||
|
||||
@@ -37,6 +44,26 @@ export interface BookingLocationCopy {
|
||||
backaplan: string;
|
||||
}
|
||||
|
||||
export interface BookingMessageCopy extends BookingLocationCopy {
|
||||
modeTable: string;
|
||||
modeEvent: string;
|
||||
eventLine: string;
|
||||
greeting: string;
|
||||
labelName: string;
|
||||
labelPhone: string;
|
||||
labelEmail: string;
|
||||
labelLocation: string;
|
||||
labelDate: string;
|
||||
labelTime: string;
|
||||
labelGuests: string;
|
||||
labelNotes: string;
|
||||
labelPreOrder: string;
|
||||
preOrderNone: string;
|
||||
preOrderTotal: string;
|
||||
confirmLine: string;
|
||||
thanksLine: string;
|
||||
}
|
||||
|
||||
export function getSuggestedPickupTime(language: Language, from: Date = new Date()): string {
|
||||
const suggested = new Date(from.getTime() + PICKUP_LEAD_TIME_MINUTES * 60 * 1000);
|
||||
const timeLocale =
|
||||
@@ -53,43 +80,71 @@ function localizeInquiryLines(lang: Language, items: OrderLine[]): string {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInquiryBranchLabel(
|
||||
branch: PickupInquiryDetails['branch'],
|
||||
copy: CartInquiryCopy,
|
||||
): string {
|
||||
return branch === 'askim' ? copy.branchAskim : copy.branchBackaplan;
|
||||
}
|
||||
|
||||
function resolveInquiryDateLabel(
|
||||
date: InquiryPreferredDate,
|
||||
copy: CartInquiryCopy,
|
||||
): string {
|
||||
return date === 'tomorrow' ? copy.scheduleDateTomorrow : copy.scheduleDateToday;
|
||||
}
|
||||
|
||||
function formatInquiryScheduleLine(details: PickupInquiryDetails, copy: CartInquiryCopy): string {
|
||||
if (!hasInquirySchedule(details)) return '';
|
||||
|
||||
return `${copy.messagePreferredDate}: ${resolveInquiryDateLabel(details.preferredDate, copy)}
|
||||
${copy.messagePreferredTime}: ${details.preferredTime.trim()}`;
|
||||
}
|
||||
|
||||
export function buildPickupInquiryMessage(
|
||||
items: OrderLine[],
|
||||
details: PickupInquiryDetails,
|
||||
copy: CartInquiryCopy,
|
||||
language: Language = 'en',
|
||||
language: Language = 'sv',
|
||||
): string | null {
|
||||
if (items.length === 0) return null;
|
||||
if (items.length === 0 || !isInquiryBranchOnlineEnabled(details.branch)) return null;
|
||||
|
||||
const lines = localizeInquiryLines(language, items);
|
||||
const total = calculateOrderTotal(items);
|
||||
|
||||
const scheduleLine = formatInquiryScheduleLine(details, copy);
|
||||
|
||||
return `${copy.pickupIntro}
|
||||
${lines}
|
||||
${copy.messageTotal}: ${total} kr
|
||||
${copy.messageBranch}: ${resolveInquiryBranchLabel(details.branch, copy)}
|
||||
${copy.messageName}: ${details.name.trim()}
|
||||
${copy.messagePhone}: ${details.phone.trim()}`;
|
||||
${copy.messageEmail}: ${details.email.trim()}${scheduleLine ? `\n${scheduleLine}` : ''}`;
|
||||
}
|
||||
|
||||
export function buildDeliveryInquiryMessage(
|
||||
items: OrderLine[],
|
||||
details: DeliveryInquiryDetails,
|
||||
copy: CartInquiryCopy,
|
||||
language: Language = 'en',
|
||||
language: Language = 'sv',
|
||||
): string | null {
|
||||
if (items.length === 0) return null;
|
||||
if (items.length === 0 || !isInquiryBranchOnlineEnabled(details.branch)) return null;
|
||||
|
||||
const lines = localizeInquiryLines(language, items);
|
||||
const total = calculateOrderTotal(items);
|
||||
|
||||
const scheduleLine = formatInquiryScheduleLine(details, copy);
|
||||
const addressLine = details.address.trim()
|
||||
? `${copy.messageAddress}: ${details.address.trim()}\n`
|
||||
: '';
|
||||
|
||||
return `${copy.deliveryIntro}
|
||||
${lines}
|
||||
${copy.messageTotal}: ${total} kr
|
||||
${copy.messageBranch}: ${resolveInquiryBranchLabel(details.branch, copy)}
|
||||
${copy.messageName}: ${details.name.trim()}
|
||||
${copy.messageAddress}: ${details.address.trim()}
|
||||
${copy.messagePhone}: ${details.phone.trim()}
|
||||
${copy.messagePreferredTime}: ${details.preferredTime.trim()}
|
||||
${copy.deliverySwishNote}`;
|
||||
${copy.messageEmail}: ${details.email.trim()}
|
||||
${addressLine}${scheduleLine ? `${scheduleLine}\n` : ''}${copy.deliverySwishNote}`;
|
||||
}
|
||||
|
||||
const PACKAGE_NAME_PLACEHOLDER = '{{package}}';
|
||||
@@ -101,40 +156,54 @@ export function buildCateringPackagePricingMessage(
|
||||
return template.split(PACKAGE_NAME_PLACEHOLDER).join(packageName);
|
||||
}
|
||||
|
||||
function resolveBookingEventLabel(booking: BookingDetails, eventLabel: string): string {
|
||||
if (booking.bookingMode !== 'event' || !booking.eventType) return '';
|
||||
if (booking.eventType === 'other') {
|
||||
return booking.eventTypeOther.trim() || eventLabel;
|
||||
}
|
||||
return eventLabel;
|
||||
}
|
||||
|
||||
export function buildBookingWhatsAppMessage(
|
||||
booking: BookingDetails,
|
||||
preOrder: OrderLine[],
|
||||
locationCopy: BookingLocationCopy
|
||||
copy: BookingMessageCopy,
|
||||
eventLabel = '',
|
||||
language: Language = 'sv',
|
||||
): string | null {
|
||||
if (!isBookingComplete(booking)) return null;
|
||||
|
||||
const locationLabel =
|
||||
booking.location === 'askim' ? locationCopy.askim : locationCopy.backaplan;
|
||||
booking.location === 'askim' ? copy.askim : copy.backaplan;
|
||||
const bookingModeLabel =
|
||||
booking.bookingMode === 'event' ? copy.modeEvent : copy.modeTable;
|
||||
const occasionLine = resolveBookingEventLabel(booking, eventLabel);
|
||||
const occasionText = occasionLine ? `\n${copy.eventLine}: ${occasionLine}` : '';
|
||||
|
||||
const itemsText =
|
||||
preOrder.length > 0
|
||||
? formatOrderLinesForMessage(preOrder).replace(/ — /g, ' — ')
|
||||
: 'None';
|
||||
? localizeInquiryLines(language, preOrder)
|
||||
: copy.preOrderNone;
|
||||
|
||||
const totalText =
|
||||
preOrder.length > 0 ? `\nPre-order total: ${calculateOrderTotal(preOrder)} kr\n` : '';
|
||||
preOrder.length > 0 ? `\n${copy.preOrderTotal}: ${calculateOrderTotal(preOrder)} kr\n` : '';
|
||||
|
||||
return `Hello Shahi Kitchen 👋
|
||||
return `${copy.greeting}
|
||||
|
||||
Table Reservation Inquiry:
|
||||
${bookingModeLabel}:
|
||||
|
||||
Name: ${booking.name}
|
||||
Phone: ${booking.phone}
|
||||
Email: ${booking.email || 'N/A'}
|
||||
Location: ${locationLabel}
|
||||
Date: ${booking.date}
|
||||
Time: ${booking.time}
|
||||
Guests: ${booking.guests}
|
||||
Special Requests: ${booking.notes || 'None'}
|
||||
${copy.labelName}: ${booking.name}
|
||||
${copy.labelPhone}: ${booking.phone}
|
||||
${copy.labelEmail}: ${booking.email || 'N/A'}
|
||||
${copy.labelLocation}: ${locationLabel}
|
||||
${copy.labelDate}: ${booking.date}
|
||||
${copy.labelTime}: ${booking.time}
|
||||
${copy.labelGuests}: ${booking.guests}${occasionText}
|
||||
${copy.labelNotes}: ${booking.notes || copy.preOrderNone}
|
||||
|
||||
Pre-ordered items:
|
||||
${copy.labelPreOrder}:
|
||||
${itemsText}${totalText}
|
||||
Please confirm table availability and pre-order.
|
||||
${copy.confirmLine}
|
||||
|
||||
Thank you!`;
|
||||
${copy.thanksLine}`;
|
||||
}
|
||||
+185
-46
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { MessageCircle } from 'lucide-react';
|
||||
import {
|
||||
@@ -19,14 +19,46 @@ import { useCart } from '@/presentation/providers/cart-provider';
|
||||
import { useLanguage } from '@/presentation/providers/language-provider';
|
||||
import { getTranslation } from '@/presentation/i18n/translations';
|
||||
import { getMenuItemName } from '@/application/i18n/menu-localization';
|
||||
import { getMenuItemById } from '@/lib/menu-data';
|
||||
import { useMenu } from '@/presentation/providers/menu-provider';
|
||||
import CartInquiryModal from '@/components/CartInquiryModal';
|
||||
import {
|
||||
buildCartInquiryCopy,
|
||||
getWhatsAppInquiryLanguage,
|
||||
getWhatsAppInquiryTranslation,
|
||||
} from '@/application/messaging/inquiry-language';
|
||||
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
|
||||
|
||||
const CART_INQUIRY_INTENT_KEY = 'shahi-cart-inquiry-intent';
|
||||
|
||||
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 CartDrawer() {
|
||||
const {
|
||||
items,
|
||||
isOpen,
|
||||
closeCart,
|
||||
openCart,
|
||||
totalPrice,
|
||||
removeFromCart,
|
||||
updateQuantity,
|
||||
@@ -34,19 +66,28 @@ export default function CartDrawer() {
|
||||
} = useCart();
|
||||
|
||||
const { language, isRtl } = useLanguage();
|
||||
const { getItemById } = useMenu();
|
||||
const {
|
||||
email: customerEmail,
|
||||
name: customerName,
|
||||
isAuthenticated,
|
||||
isLoading: isAuthLoading,
|
||||
} = useCustomerAuth();
|
||||
const t = getTranslation(language);
|
||||
const [inquiryMode, setInquiryMode] = useState<FulfillmentMode | null>(null);
|
||||
const [authGateMode, setAuthGateMode] = useState<FulfillmentMode | null>(null);
|
||||
|
||||
const inquiryCopy = {
|
||||
pickupIntro: t.cartDrawer.inquiryPickupIntro,
|
||||
deliveryIntro: t.cartDrawer.inquiryDeliveryIntro,
|
||||
messageTotal: t.cartDrawer.messageTotal,
|
||||
messageName: t.cartDrawer.messageName,
|
||||
messagePhone: t.cartDrawer.messagePhone,
|
||||
messageAddress: t.cartDrawer.messageAddress,
|
||||
messagePreferredTime: t.cartDrawer.messagePreferredTime,
|
||||
deliverySwishNote: t.cartDrawer.deliverySwishNote,
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isAuthLoading || !isAuthenticated) return;
|
||||
|
||||
const intent = sessionStorage.getItem(CART_INQUIRY_INTENT_KEY) as FulfillmentMode | null;
|
||||
if (intent !== 'pickup' && intent !== 'delivery') return;
|
||||
|
||||
sessionStorage.removeItem(CART_INQUIRY_INTENT_KEY);
|
||||
openCart();
|
||||
setAuthGateMode(null);
|
||||
setInquiryMode(intent);
|
||||
}, [isAuthenticated, isAuthLoading, openCart]);
|
||||
|
||||
const openWhatsApp = (message: string | null) => {
|
||||
if (!message) return;
|
||||
@@ -54,12 +95,42 @@ export default function CartDrawer() {
|
||||
setInquiryMode(null);
|
||||
};
|
||||
|
||||
const startInquiry = (mode: FulfillmentMode) => {
|
||||
if (isAuthLoading) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
setAuthGateMode(mode);
|
||||
setInquiryMode(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthGateMode(null);
|
||||
setInquiryMode(mode);
|
||||
};
|
||||
|
||||
const handleGoogleSignInForInquiry = () => {
|
||||
if (!authGateMode) return;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(CART_INQUIRY_INTENT_KEY, authGateMode);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
|
||||
const returnTo = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
|
||||
window.location.href = `/api/auth/google?returnTo=${returnTo}`;
|
||||
};
|
||||
|
||||
const handlePickupSubmit = (details: PickupInquiryDetails) => {
|
||||
openWhatsApp(buildPickupInquiryMessage(items, details, inquiryCopy, language));
|
||||
const inquiryLang = getWhatsAppInquiryLanguage(language);
|
||||
const inquiryCopy = buildCartInquiryCopy(getWhatsAppInquiryTranslation(language));
|
||||
openWhatsApp(buildPickupInquiryMessage(items, details, inquiryCopy, inquiryLang));
|
||||
};
|
||||
|
||||
const handleDeliverySubmit = (details: DeliveryInquiryDetails) => {
|
||||
openWhatsApp(buildDeliveryInquiryMessage(items, details, inquiryCopy, language));
|
||||
const inquiryLang = getWhatsAppInquiryLanguage(language);
|
||||
const inquiryCopy = buildCartInquiryCopy(getWhatsAppInquiryTranslation(language));
|
||||
openWhatsApp(buildDeliveryInquiryMessage(items, details, inquiryCopy, inquiryLang));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
@@ -72,11 +143,11 @@ export default function CartDrawer() {
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`fixed top-0 h-full w-full max-w-md bg-[#F8F5F0] z-[990] shadow-2xl flex flex-col ${
|
||||
className={`fixed top-0 h-[100dvh] w-full max-w-md bg-[#F8F5F0] z-[990] shadow-2xl flex flex-col ${
|
||||
isRtl ? 'left-0 border-r border-[#EDE6D9]' : 'right-0 border-l border-[#EDE6D9]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 border-b border-[#EDE6D9]">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[#EDE6D9] px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl tracking-[-0.5px]">{t.cartDrawer.title}</h2>
|
||||
{items.length > 0 && (
|
||||
@@ -96,14 +167,15 @@ export default function CartDrawer() {
|
||||
)}
|
||||
<button
|
||||
onClick={closeCart}
|
||||
className="text-[#6B665F] hover:text-[#2C2A26] text-2xl leading-none pl-1"
|
||||
aria-label="Close cart"
|
||||
className="flex min-h-11 min-w-11 items-center justify-center text-[#6B665F] hover:text-[#2C2A26] text-2xl leading-none touch-manipulation"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain px-4 py-4 sm:px-6 sm:py-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="text-6xl mb-4">🛒</div>
|
||||
@@ -126,7 +198,7 @@ export default function CartDrawer() {
|
||||
<h4 className="font-medium tracking-[-0.3px]">
|
||||
{getMenuItemName(language, {
|
||||
id: item.id,
|
||||
name: getMenuItemById(item.id)?.name ?? item.name,
|
||||
name: getItemById(item.id)?.name ?? item.name,
|
||||
})}
|
||||
</h4>
|
||||
<p className="text-sm text-[#6B665F]">
|
||||
@@ -172,7 +244,7 @@ export default function CartDrawer() {
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] border-t border-[#EDE6D9] bg-white">
|
||||
<div className="shrink-0 border-t border-[#EDE6D9] bg-white px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6">
|
||||
<button
|
||||
onClick={clearCart}
|
||||
className="text-xs text-[#8A8478] hover:text-[#B38B4D] mb-3 underline"
|
||||
@@ -185,27 +257,57 @@ export default function CartDrawer() {
|
||||
<span>{totalPrice.toFixed(0)} kr</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-[#6B665F] mb-4">
|
||||
{t.cartDrawer.inquiryHint}
|
||||
</p>
|
||||
{authGateMode ? (
|
||||
<div className="mb-3 rounded-2xl border border-[#EDE6D9] bg-gradient-to-br from-[#FFFCF7] to-[#FFF6DC]/50 p-4 text-center sm:p-5">
|
||||
<h3 className="mb-2 font-serif text-base tracking-tight text-[#101724] sm:text-lg">
|
||||
{t.cartDrawer.signInRequiredTitle}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm leading-relaxed text-[#6B665F]">
|
||||
{t.cartDrawer.signInRequiredSubtitle}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignInForInquiry}
|
||||
className="mb-3 flex w-full items-center justify-center gap-3 rounded-2xl border border-[#EDE6D9] bg-white px-4 py-3.5 text-sm 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 shrink-0" />
|
||||
<span className="text-left leading-snug">{t.auth.customer.signInWithGoogle}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthGateMode(null)}
|
||||
className="inline-flex min-h-[44px] w-full items-center justify-center text-sm font-medium text-[#6B665F] hover:text-[#101724] active:underline touch-manipulation"
|
||||
>
|
||||
{t.cartDrawer.inquiryCancel}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-center text-[#6B665F] mb-4">
|
||||
{t.cartDrawer.inquiryHint}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInquiryMode('pickup')}
|
||||
className="btn-primary w-full py-4 rounded-full text-base tracking-[0.5px] font-medium mb-2 flex items-center justify-center gap-2"
|
||||
>
|
||||
<MessageCircle className="h-5 w-5" aria-hidden />
|
||||
{t.cartDrawer.pickupInquiry}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startInquiry('pickup')}
|
||||
disabled={isAuthLoading}
|
||||
className="btn-primary mb-2 flex w-full min-h-[52px] items-center justify-center gap-2 rounded-full py-4 text-base font-medium tracking-[0.5px] touch-manipulation active:scale-[0.985] disabled:opacity-60"
|
||||
>
|
||||
<MessageCircle className="h-5 w-5" aria-hidden />
|
||||
{t.cartDrawer.pickupInquiry}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInquiryMode('delivery')}
|
||||
className="btn-outline w-full py-4 rounded-full text-base tracking-[0.5px] font-medium mb-2 flex items-center justify-center gap-2 border-[#25D366] text-[#128C7E] hover:bg-[#25D366]/10"
|
||||
>
|
||||
<MessageCircle className="h-5 w-5" aria-hidden />
|
||||
{t.cartDrawer.deliveryInquiry}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startInquiry('delivery')}
|
||||
disabled={isAuthLoading}
|
||||
className="btn-outline mb-2 flex w-full min-h-[52px] items-center justify-center gap-2 rounded-full border-[#25D366] py-4 text-base font-medium tracking-[0.5px] text-[#128C7E] hover:bg-[#25D366]/10 touch-manipulation active:scale-[0.985] disabled:opacity-60"
|
||||
>
|
||||
<MessageCircle className="h-5 w-5" aria-hidden />
|
||||
{t.cartDrawer.deliveryInquiry}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => window.open(`tel:${RESTAURANT_CONTACT.phonePrimary}`, '_self')}
|
||||
@@ -224,32 +326,69 @@ export default function CartDrawer() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{inquiryMode && (
|
||||
{inquiryMode && isAuthenticated && (
|
||||
<CartInquiryModal
|
||||
mode={inquiryMode}
|
||||
isOpen={!!inquiryMode}
|
||||
language={language}
|
||||
customerEmail={customerEmail}
|
||||
customerName={customerName}
|
||||
onClose={() => setInquiryMode(null)}
|
||||
onSubmitPickup={handlePickupSubmit}
|
||||
onSubmitDelivery={handleDeliverySubmit}
|
||||
labels={{
|
||||
titlePickup: t.cartDrawer.inquiryModalTitlePickup,
|
||||
titleDelivery: t.cartDrawer.inquiryModalTitleDelivery,
|
||||
signedInAs: t.cartDrawer.signedInAs,
|
||||
nameLabel: t.cartDrawer.nameLabel,
|
||||
namePlaceholder: t.cartDrawer.namePlaceholder,
|
||||
phoneLabel: t.cartDrawer.phoneLabel,
|
||||
phonePlaceholder: t.cartDrawer.phonePlaceholder,
|
||||
emailLabel: t.cartDrawer.emailLabel,
|
||||
emailPlaceholder: t.cartDrawer.emailPlaceholder,
|
||||
emailRequired: t.cartDrawer.emailRequired,
|
||||
branchLabel: t.cartDrawer.branchLabel,
|
||||
branchAskim: t.cartDrawer.branchAskim,
|
||||
branchBackaplan: t.cartDrawer.branchBackaplan,
|
||||
askimOnlineUnavailable: t.cartDrawer.askimOnlineUnavailable,
|
||||
addressLabel: t.cartDrawer.addressLabel,
|
||||
addressPlaceholder: t.cartDrawer.addressPlaceholder,
|
||||
addressHint:
|
||||
(t.cartDrawer as { addressHint?: string }).addressHint ??
|
||||
'Only Gothenburg addresses can be selected. Pick one from the suggestions.',
|
||||
addressFallbackPlaceholder:
|
||||
(t.cartDrawer as { addressFallbackPlaceholder?: string }).addressFallbackPlaceholder ??
|
||||
'Street, postcode, city…',
|
||||
addressFallbackHint:
|
||||
(t.cartDrawer as { addressFallbackHint?: string }).addressFallbackHint ??
|
||||
'Address search is temporarily unavailable. Type your full delivery address manually.',
|
||||
addressSearching:
|
||||
(t.cartDrawer as { addressSearching?: string }).addressSearching ??
|
||||
'Searching addresses…',
|
||||
addressNoResults:
|
||||
(t.cartDrawer as { addressNoResults?: string }).addressNoResults ??
|
||||
'No matching addresses. Try street name and number.',
|
||||
addressOutsideGothenburg:
|
||||
(t.cartDrawer as { addressOutsideGothenburg?: string }).addressOutsideGothenburg ??
|
||||
'This address is outside Gothenburg. We only deliver within the city.',
|
||||
addressSelectSuggestion:
|
||||
(t.cartDrawer as { addressSelectSuggestion?: string }).addressSelectSuggestion ??
|
||||
'Please pick an address from the suggestions.',
|
||||
addressInvalid:
|
||||
(t.cartDrawer as { addressInvalid?: string }).addressInvalid ??
|
||||
'Select a valid Gothenburg address from the list.',
|
||||
preferredDateLabel: t.cartDrawer.preferredDateLabel,
|
||||
preferredDatePlaceholder: t.cartDrawer.preferredDatePlaceholder,
|
||||
dateToday: t.cartDrawer.dateToday,
|
||||
dateTomorrow: t.cartDrawer.dateTomorrow,
|
||||
preferredTimeLabel: t.cartDrawer.preferredTimeLabel,
|
||||
preferredTimePlaceholder: t.cartDrawer.preferredTimePlaceholder,
|
||||
scheduleOptionalHint: t.cartDrawer.scheduleOptionalHint,
|
||||
submitPickup: t.cartDrawer.submitPickup,
|
||||
submitDelivery: t.cartDrawer.submitDelivery,
|
||||
cancel: t.cartDrawer.inquiryCancel,
|
||||
close: t.cartDrawer.inquiryClose,
|
||||
nameRequired: t.cartDrawer.nameRequired,
|
||||
phoneRequired: t.cartDrawer.phoneRequired,
|
||||
addressRequired: t.cartDrawer.addressRequired,
|
||||
timeRequired: t.cartDrawer.timeRequired,
|
||||
branchRequired: t.cartDrawer.branchRequired,
|
||||
scheduleIncomplete: t.cartDrawer.scheduleIncomplete,
|
||||
scheduleTooSoon: t.cartDrawer.scheduleTooSoon,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
+280
-105
@@ -2,58 +2,106 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { MessageCircle, X } from 'lucide-react';
|
||||
import type { FulfillmentMode } from '@/domain/cart/inquiry';
|
||||
import type {
|
||||
FulfillmentMode,
|
||||
InquiryBranch,
|
||||
InquiryPreferredDate,
|
||||
} from '@/domain/cart/inquiry';
|
||||
import type { DeliveryInquiryDetails, PickupInquiryDetails } from '@/domain/cart/inquiry';
|
||||
import {
|
||||
DEFAULT_INQUIRY_BRANCH,
|
||||
getMinInquiryTimeForToday,
|
||||
isInquiryBranchOnlineEnabled,
|
||||
validateInquirySchedule,
|
||||
} from '@/domain/cart/inquiry';
|
||||
import type { Language } from '@/domain/language/entities';
|
||||
import { getWhatsAppInquiryLanguage } from '@/application/messaging/inquiry-language';
|
||||
import DeliveryAddressAutocomplete from '@/components/DeliveryAddressAutocomplete';
|
||||
|
||||
export interface CartInquiryModalLabels {
|
||||
titlePickup: string;
|
||||
titleDelivery: string;
|
||||
signedInAs: string;
|
||||
nameLabel: string;
|
||||
namePlaceholder: string;
|
||||
phoneLabel: string;
|
||||
phonePlaceholder: string;
|
||||
emailLabel: string;
|
||||
emailPlaceholder: string;
|
||||
emailRequired: string;
|
||||
branchLabel: string;
|
||||
branchAskim: string;
|
||||
branchBackaplan: string;
|
||||
askimOnlineUnavailable: string;
|
||||
addressLabel: string;
|
||||
addressPlaceholder: string;
|
||||
addressHint: string;
|
||||
addressFallbackPlaceholder: string;
|
||||
addressFallbackHint: string;
|
||||
addressSearching: string;
|
||||
addressNoResults: string;
|
||||
addressOutsideGothenburg: string;
|
||||
addressSelectSuggestion: string;
|
||||
addressInvalid: string;
|
||||
preferredDateLabel: string;
|
||||
preferredDatePlaceholder: string;
|
||||
dateToday: string;
|
||||
dateTomorrow: string;
|
||||
preferredTimeLabel: string;
|
||||
preferredTimePlaceholder: string;
|
||||
scheduleOptionalHint: string;
|
||||
submitPickup: string;
|
||||
submitDelivery: string;
|
||||
cancel: string;
|
||||
close: string;
|
||||
nameRequired: string;
|
||||
phoneRequired: string;
|
||||
addressRequired: string;
|
||||
timeRequired: string;
|
||||
branchRequired: string;
|
||||
scheduleIncomplete: string;
|
||||
scheduleTooSoon: string;
|
||||
}
|
||||
|
||||
interface CartInquiryModalProps {
|
||||
mode: FulfillmentMode;
|
||||
isOpen: boolean;
|
||||
language: Language;
|
||||
customerEmail: string | null;
|
||||
customerName: string | null;
|
||||
onClose: () => void;
|
||||
onSubmitPickup: (details: PickupInquiryDetails) => void;
|
||||
onSubmitDelivery: (details: DeliveryInquiryDetails) => void;
|
||||
labels: CartInquiryModalLabels;
|
||||
}
|
||||
|
||||
const emptyPickup = (): PickupInquiryDetails => ({ name: '', phone: '' });
|
||||
const emptyDelivery = (): DeliveryInquiryDetails => ({
|
||||
name: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
preferredTime: '',
|
||||
});
|
||||
function createPickupDefaults(email = '', name = ''): PickupInquiryDetails {
|
||||
return {
|
||||
name: name.trim(),
|
||||
email,
|
||||
branch: DEFAULT_INQUIRY_BRANCH,
|
||||
preferredDate: '',
|
||||
preferredTime: '',
|
||||
};
|
||||
}
|
||||
|
||||
function createDeliveryDefaults(email = '', name = ''): DeliveryInquiryDetails {
|
||||
return {
|
||||
...createPickupDefaults(email, name),
|
||||
address: '',
|
||||
};
|
||||
}
|
||||
|
||||
export default function CartInquiryModal({
|
||||
mode,
|
||||
isOpen,
|
||||
language,
|
||||
customerEmail,
|
||||
customerName,
|
||||
onClose,
|
||||
onSubmitPickup,
|
||||
onSubmitDelivery,
|
||||
labels,
|
||||
}: CartInquiryModalProps) {
|
||||
const [pickup, setPickup] = useState(emptyPickup);
|
||||
const [delivery, setDelivery] = useState(emptyDelivery);
|
||||
const [pickup, setPickup] = useState(createPickupDefaults);
|
||||
const [delivery, setDelivery] = useState(createDeliveryDefaults);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [addressValid, setAddressValid] = useState(true);
|
||||
const autocompleteLang = getWhatsAppInquiryLanguage(language);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
@@ -67,20 +115,86 @@ export default function CartInquiryModal({
|
||||
setErrors({});
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, handleKeyDown, mode]);
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPickup(emptyPickup());
|
||||
setDelivery(emptyDelivery());
|
||||
setPickup(createPickupDefaults());
|
||||
setDelivery(createDeliveryDefaults());
|
||||
setErrors({});
|
||||
setAddressValid(true);
|
||||
return;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const email = customerEmail ?? '';
|
||||
const name = customerName ?? '';
|
||||
setPickup(createPickupDefaults(email, name));
|
||||
setDelivery(createDeliveryDefaults(email, name));
|
||||
setErrors({});
|
||||
setAddressValid(true);
|
||||
}, [isOpen, customerEmail, customerName]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const title = mode === 'pickup' ? labels.titlePickup : labels.titleDelivery;
|
||||
const submitLabel = mode === 'pickup' ? labels.submitPickup : labels.submitDelivery;
|
||||
const formState = mode === 'pickup' ? pickup : delivery;
|
||||
const branch = formState.branch;
|
||||
const preferredDate = formState.preferredDate;
|
||||
const preferredTime = formState.preferredTime;
|
||||
const isAskimBlocked = branch === 'askim';
|
||||
const isSubmitDisabled = !isInquiryBranchOnlineEnabled(branch);
|
||||
const minTimeToday = getMinInquiryTimeForToday();
|
||||
const emailLocked = Boolean(customerEmail);
|
||||
|
||||
const setName = (value: string) => {
|
||||
if (mode === 'pickup') setPickup((p) => ({ ...p, name: value }));
|
||||
else setDelivery((d) => ({ ...d, name: value }));
|
||||
if (errors.name) setErrors((err) => ({ ...err, name: '' }));
|
||||
};
|
||||
|
||||
const setEmail = (value: string) => {
|
||||
if (emailLocked) return;
|
||||
if (mode === 'pickup') setPickup((p) => ({ ...p, email: value }));
|
||||
else setDelivery((d) => ({ ...d, email: value }));
|
||||
if (errors.email) setErrors((err) => ({ ...err, email: '' }));
|
||||
};
|
||||
|
||||
const setBranch = (value: InquiryBranch) => {
|
||||
if (mode === 'pickup') setPickup((p) => ({ ...p, branch: value }));
|
||||
else setDelivery((d) => ({ ...d, branch: value }));
|
||||
if (errors.branch) setErrors((err) => ({ ...err, branch: '' }));
|
||||
};
|
||||
|
||||
const setPreferredDate = (value: InquiryPreferredDate) => {
|
||||
if (mode === 'pickup') {
|
||||
setPickup((p) => ({ ...p, preferredDate: value }));
|
||||
} else {
|
||||
setDelivery((d) => ({ ...d, preferredDate: value }));
|
||||
}
|
||||
if (errors.schedule) setErrors((err) => ({ ...err, schedule: '' }));
|
||||
};
|
||||
|
||||
const setPreferredTime = (value: string) => {
|
||||
if (mode === 'pickup') {
|
||||
setPickup((p) => ({ ...p, preferredTime: value }));
|
||||
} else {
|
||||
setDelivery((d) => ({ ...d, preferredTime: value }));
|
||||
}
|
||||
if (errors.schedule) setErrors((err) => ({ ...err, schedule: '' }));
|
||||
};
|
||||
|
||||
const validateSchedule = (state: PickupInquiryDetails): boolean => {
|
||||
const result = validateInquirySchedule(state);
|
||||
if (result === 'ok') return true;
|
||||
|
||||
if (result === 'incomplete') {
|
||||
setErrors((err) => ({ ...err, schedule: labels.scheduleIncomplete }));
|
||||
} else if (result === 'too_soon' || result === 'invalid_time') {
|
||||
setErrors((err) => ({ ...err, schedule: labels.scheduleTooSoon }));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -88,28 +202,35 @@ export default function CartInquiryModal({
|
||||
|
||||
if (mode === 'pickup') {
|
||||
if (!pickup.name.trim()) nextErrors.name = labels.nameRequired;
|
||||
if (!pickup.phone.trim()) nextErrors.phone = labels.phoneRequired;
|
||||
if (!pickup.email.trim()) nextErrors.email = labels.emailRequired;
|
||||
if (!pickup.branch) nextErrors.branch = labels.branchRequired;
|
||||
if (Object.keys(nextErrors).length) {
|
||||
setErrors(nextErrors);
|
||||
return;
|
||||
}
|
||||
if (!validateSchedule(pickup)) return;
|
||||
if (!isInquiryBranchOnlineEnabled(pickup.branch)) return;
|
||||
onSubmitPickup(pickup);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!delivery.name.trim()) nextErrors.name = labels.nameRequired;
|
||||
if (!delivery.phone.trim()) nextErrors.phone = labels.phoneRequired;
|
||||
if (!delivery.address.trim()) nextErrors.address = labels.addressRequired;
|
||||
if (!delivery.preferredTime.trim()) nextErrors.preferredTime = labels.timeRequired;
|
||||
if (!delivery.email.trim()) nextErrors.email = labels.emailRequired;
|
||||
if (!delivery.branch) nextErrors.branch = labels.branchRequired;
|
||||
if (delivery.address.trim() && !addressValid) {
|
||||
nextErrors.address = labels.addressInvalid;
|
||||
}
|
||||
if (Object.keys(nextErrors).length) {
|
||||
setErrors(nextErrors);
|
||||
return;
|
||||
}
|
||||
if (!validateSchedule(delivery)) return;
|
||||
if (!isInquiryBranchOnlineEnabled(delivery.branch)) return;
|
||||
onSubmitDelivery(delivery);
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
'mt-1.5 w-full rounded-xl border border-[#EDE6D9] bg-[#FFFCF7] px-3 py-2.5 text-sm text-[#2C2A26] placeholder:text-[#8A8478] focus:border-[#c99a2e] focus:ring-2 focus:ring-[#c99a2e]/20';
|
||||
'mt-1.5 w-full min-h-[48px] rounded-xl border border-[#EDE6D9] bg-[#FFFCF7] px-3 py-3 text-base text-[#2C2A26] placeholder:text-[#8A8478] focus:border-[#c99a2e] focus:ring-2 focus:ring-[#c99a2e]/20 touch-manipulation';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -126,37 +247,47 @@ export default function CartInquiryModal({
|
||||
aria-labelledby="cart-inquiry-title"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto w-full sm:max-w-md max-h-[90dvh] overflow-y-auto rounded-t-3xl sm:rounded-2xl bg-[#FFFCF7] border border-[#EDE6D9] shadow-2xl"
|
||||
className="pointer-events-auto flex max-h-[92dvh] w-full flex-col overflow-hidden rounded-t-3xl border border-[#EDE6D9] bg-[#FFFCF7] shadow-2xl sm:max-w-md sm:rounded-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[#EDE6D9] px-6 py-4">
|
||||
<h2 id="cart-inquiry-title" className="font-serif text-xl tracking-[-0.3px] text-[#101724]">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[#EDE6D9] px-4 py-3.5 sm:px-6 sm:py-4">
|
||||
<h2 id="cart-inquiry-title" className="pe-3 font-serif text-lg tracking-[-0.3px] text-[#101724] sm:text-xl">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={labels.close}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-[#EDE6D9] bg-white text-[#6B665F] hover:text-[#101724]"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-[#EDE6D9] bg-white text-[#6B665F] hover:text-[#101724] touch-manipulation"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-6">
|
||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-y-contain px-4 py-4 sm:px-6 sm:py-5">
|
||||
{customerEmail && (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-[#c99a2e]/25 bg-gradient-to-r from-[#FFF6DC] to-[#FFFCF7] px-3 py-3 sm:px-4">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#101724] text-xs font-bold text-white">
|
||||
{customerEmail.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#8f6b22]">
|
||||
{labels.signedInAs}
|
||||
</p>
|
||||
<p className="truncate text-sm font-medium text-[#101724]">{customerEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.nameLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mode === 'pickup' ? pickup.name : delivery.name}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (mode === 'pickup') setPickup((p) => ({ ...p, name: v }));
|
||||
else setDelivery((d) => ({ ...d, name: v }));
|
||||
if (errors.name) setErrors((err) => ({ ...err, name: '' }));
|
||||
}}
|
||||
value={formState.name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={labels.namePlaceholder}
|
||||
className={inputClass}
|
||||
autoComplete="name"
|
||||
@@ -168,83 +299,127 @@ export default function CartInquiryModal({
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.phoneLabel}
|
||||
{labels.emailLabel}
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={mode === 'pickup' ? pickup.phone : delivery.phone}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (mode === 'pickup') setPickup((p) => ({ ...p, phone: v }));
|
||||
else setDelivery((d) => ({ ...d, phone: v }));
|
||||
if (errors.phone) setErrors((err) => ({ ...err, phone: '' }));
|
||||
}}
|
||||
placeholder={labels.phonePlaceholder}
|
||||
className={inputClass}
|
||||
autoComplete="tel"
|
||||
type="email"
|
||||
value={formState.email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={labels.emailPlaceholder}
|
||||
readOnly={emailLocked}
|
||||
className={`${inputClass} ${emailLocked ? 'bg-[#F8F5F0]/80 text-[#6B665F] cursor-default' : ''}`}
|
||||
autoComplete="email"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="mt-1 text-xs text-[#B45309]" role="alert">{errors.phone}</p>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-xs text-[#B45309]" role="alert">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.branchLabel}
|
||||
</label>
|
||||
<select
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value as InquiryBranch)}
|
||||
className={`${inputClass} ${isAskimBlocked ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : ''}`}
|
||||
>
|
||||
<option value="backaplan">{labels.branchBackaplan}</option>
|
||||
<option value="askim">{labels.branchAskim}</option>
|
||||
</select>
|
||||
{errors.branch && (
|
||||
<p className="mt-1 text-xs text-red-600" role="alert">{errors.branch}</p>
|
||||
)}
|
||||
{isAskimBlocked && (
|
||||
<p className="mt-1 text-xs font-medium text-red-600" role="alert">
|
||||
{labels.askimOnlineUnavailable}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === 'delivery' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.addressLabel}
|
||||
</label>
|
||||
<textarea
|
||||
value={delivery.address}
|
||||
onChange={(e) => {
|
||||
setDelivery((d) => ({ ...d, address: e.target.value }));
|
||||
if (errors.address) setErrors((err) => ({ ...err, address: '' }));
|
||||
}}
|
||||
placeholder={labels.addressPlaceholder}
|
||||
rows={3}
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
{errors.address && (
|
||||
<p className="mt-1 text-xs text-[#B45309]" role="alert">{errors.address}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.preferredTimeLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={delivery.preferredTime}
|
||||
onChange={(e) => {
|
||||
setDelivery((d) => ({ ...d, preferredTime: e.target.value }));
|
||||
if (errors.preferredTime) setErrors((err) => ({ ...err, preferredTime: '' }));
|
||||
}}
|
||||
placeholder={labels.preferredTimePlaceholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
{errors.preferredTime && (
|
||||
<p className="mt-1 text-xs text-[#B45309]" role="alert">{errors.preferredTime}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.addressLabel}
|
||||
</label>
|
||||
<DeliveryAddressAutocomplete
|
||||
value={delivery.address}
|
||||
onChange={(address) => setDelivery((d) => ({ ...d, address }))}
|
||||
onValidationChange={setAddressValid}
|
||||
language={autocompleteLang}
|
||||
labels={{
|
||||
placeholder: labels.addressPlaceholder,
|
||||
hint: labels.addressHint,
|
||||
fallbackPlaceholder: labels.addressFallbackPlaceholder,
|
||||
fallbackHint: labels.addressFallbackHint,
|
||||
searching: labels.addressSearching,
|
||||
noResults: labels.addressNoResults,
|
||||
outsideGothenburg: labels.addressOutsideGothenburg,
|
||||
selectSuggestion: labels.addressSelectSuggestion,
|
||||
}}
|
||||
inputClassName={inputClass}
|
||||
/>
|
||||
{errors.address && (
|
||||
<p className="mt-1 text-xs text-red-600" role="alert">{errors.address}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-full border border-[#EDE6D9] py-3 text-sm font-medium text-[#6B665F] hover:bg-[#F8F5F0]"
|
||||
>
|
||||
{labels.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary flex flex-1 items-center justify-center gap-2 rounded-full py-3 text-sm font-medium tracking-wide"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" aria-hidden />
|
||||
{submitLabel}
|
||||
</button>
|
||||
<div className="rounded-2xl border border-[#EDE6D9] bg-[#FFFCF7]/80 p-4 space-y-4">
|
||||
<p className="text-xs text-[#6B665F]">{labels.scheduleOptionalHint}</p>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.preferredDateLabel}
|
||||
</label>
|
||||
<select
|
||||
value={preferredDate}
|
||||
onChange={(e) => setPreferredDate(e.target.value as InquiryPreferredDate)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">{labels.preferredDatePlaceholder}</option>
|
||||
<option value="today">{labels.dateToday}</option>
|
||||
<option value="tomorrow">{labels.dateTomorrow}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium tracking-wide text-[#2C2A26]">
|
||||
{labels.preferredTimeLabel}
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferredTime}
|
||||
onChange={(e) => setPreferredTime(e.target.value)}
|
||||
min={preferredDate === 'today' ? minTimeToday : undefined}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errors.schedule && (
|
||||
<p className="text-xs text-red-600" role="alert">{errors.schedule}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-[#EDE6D9] bg-[#FFFCF7]/95 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] backdrop-blur-sm sm:px-6">
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex min-h-[52px] flex-1 items-center justify-center rounded-full border border-[#EDE6D9] py-3.5 text-sm font-medium text-[#6B665F] hover:bg-[#F8F5F0] touch-manipulation active:scale-[0.985]"
|
||||
>
|
||||
{labels.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitDisabled}
|
||||
className="btn-primary flex min-h-[52px] flex-1 items-center justify-center gap-2 rounded-full py-3.5 text-sm font-medium tracking-wide touch-manipulation active:scale-[0.985] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/lib/catering-data';
|
||||
import { buildCateringPackagePricingMessage } from '@/application/messaging/whatsapp-message-builder';
|
||||
import { container } from '@/infrastructure/di/container';
|
||||
import { getWhatsAppInquiryTranslation } from '@/application/messaging/inquiry-language';
|
||||
import { useLanguage } from '@/lib/language-context';
|
||||
import { getTranslation } from '@/lib/translations';
|
||||
import { MessageCircle, Users, UtensilsCrossed } from 'lucide-react';
|
||||
@@ -29,9 +30,12 @@ export default function CateringPackageCard({ pkg, index = 0 }: CateringPackageC
|
||||
(t.catering.packageDescriptions as Record<string, string>)?.[pkg.id] ?? pkg.description;
|
||||
|
||||
const handlePricingInquiry = () => {
|
||||
const template = (t.catering as { packagePricingInquiry?: string }).packagePricingInquiry;
|
||||
const inquiryT = getWhatsAppInquiryTranslation(language);
|
||||
const template = (inquiryT.catering as { packagePricingInquiry?: string }).packagePricingInquiry;
|
||||
if (!template) return;
|
||||
const message = buildCateringPackagePricingMessage(packageName, template);
|
||||
const inquiryPackageName =
|
||||
(inquiryT.catering.packages as Record<string, string>)?.[pkg.id] ?? pkg.name;
|
||||
const message = buildCateringPackagePricingMessage(inquiryPackageName, template);
|
||||
container.messagingGateway.openWhatsApp(message);
|
||||
};
|
||||
|
||||
@@ -67,7 +71,7 @@ export default function CateringPackageCard({ pkg, index = 0 }: CateringPackageC
|
||||
<div className="flex flex-1 flex-col p-6 sm:p-7">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-[22px] leading-tight tracking-[-0.4px] text-[#101724]">
|
||||
<h3 className="text-lg sm:text-[22px] leading-tight tracking-[-0.4px] text-[#101724] break-words">
|
||||
{packageName}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[#6B665F]">
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { MapPin } from 'lucide-react';
|
||||
import {
|
||||
fetchGeoapifyAddressSuggestions,
|
||||
isDeliverableGeoapifyFeature,
|
||||
shouldUseGeoapifyAutocomplete,
|
||||
} from '@/infrastructure/geocoding/geoapify-autocomplete';
|
||||
import type { GeoapifyFeature } from '@/infrastructure/geocoding/geoapify-types';
|
||||
|
||||
export interface DeliveryAddressAutocompleteLabels {
|
||||
placeholder: string;
|
||||
hint: string;
|
||||
fallbackPlaceholder: string;
|
||||
fallbackHint: string;
|
||||
searching: string;
|
||||
noResults: string;
|
||||
outsideGothenburg: string;
|
||||
selectSuggestion: string;
|
||||
}
|
||||
|
||||
interface DeliveryAddressAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onValidationChange?: (isValid: boolean) => void;
|
||||
language: 'sv' | 'en';
|
||||
labels: DeliveryAddressAutocompleteLabels;
|
||||
inputClassName: string;
|
||||
}
|
||||
|
||||
function formatSuggestion(feature: GeoapifyFeature): string {
|
||||
return (
|
||||
feature.properties.formatted ??
|
||||
[feature.properties.address_line1, feature.properties.address_line2]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeliveryAddressAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
language,
|
||||
labels,
|
||||
inputClassName,
|
||||
}: DeliveryAddressAutocompleteProps) {
|
||||
const listboxId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const fetchGenerationRef = useRef(0);
|
||||
const [query, setQuery] = useState(value);
|
||||
const [suggestions, setSuggestions] = useState<GeoapifyFeature[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedFromList, setSelectedFromList] = useState(false);
|
||||
const [autocompleteActive, setAutocompleteActive] = useState<boolean | null>(null);
|
||||
|
||||
const deactivateAutocomplete = () => {
|
||||
setAutocompleteActive(false);
|
||||
setSuggestions([]);
|
||||
setIsOpen(false);
|
||||
setIsLoading(false);
|
||||
setSelectedFromList(false);
|
||||
setError('');
|
||||
onValidationChange?.(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const active = shouldUseGeoapifyAutocomplete();
|
||||
setAutocompleteActive(active);
|
||||
if (!active) onValidationChange?.(true);
|
||||
}, [onValidationChange]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(value);
|
||||
if (autocompleteActive) {
|
||||
setSelectedFromList(!!value.trim());
|
||||
}
|
||||
setError('');
|
||||
}, [value, autocompleteActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autocompleteActive !== true) return;
|
||||
|
||||
if (!query.trim()) {
|
||||
setSuggestions([]);
|
||||
setError('');
|
||||
setSelectedFromList(false);
|
||||
onValidationChange?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFromList) {
|
||||
onValidationChange?.(!error);
|
||||
return;
|
||||
}
|
||||
|
||||
onValidationChange?.(false);
|
||||
|
||||
const generation = ++fetchGenerationRef.current;
|
||||
|
||||
const handle = window.setTimeout(async () => {
|
||||
const trimmedQuery = query.trim();
|
||||
if (trimmedQuery.length < 3) {
|
||||
if (generation === fetchGenerationRef.current) {
|
||||
setSuggestions([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await fetchGeoapifyAddressSuggestions(trimmedQuery, language);
|
||||
if (generation !== fetchGenerationRef.current) return;
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
deactivateAutocomplete();
|
||||
return;
|
||||
}
|
||||
setSuggestions(result.features);
|
||||
setIsOpen(result.features.length > 0);
|
||||
} finally {
|
||||
if (generation === fetchGenerationRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, 600);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query, language, selectedFromList, error, onValidationChange, autocompleteActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autocompleteActive !== true) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [autocompleteActive]);
|
||||
|
||||
const applySuggestion = (feature: GeoapifyFeature) => {
|
||||
if (!isDeliverableGeoapifyFeature(feature)) {
|
||||
setError(labels.outsideGothenburg);
|
||||
setSelectedFromList(false);
|
||||
onValidationChange?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted = formatSuggestion(feature);
|
||||
setQuery(formatted);
|
||||
onChange(formatted);
|
||||
setSelectedFromList(true);
|
||||
setSuggestions([]);
|
||||
setIsOpen(false);
|
||||
setError('');
|
||||
onValidationChange?.(true);
|
||||
};
|
||||
|
||||
const handleInputChange = (next: string) => {
|
||||
setQuery(next);
|
||||
onChange(next);
|
||||
if (autocompleteActive) {
|
||||
setSelectedFromList(false);
|
||||
setError('');
|
||||
onValidationChange?.(!next.trim());
|
||||
} else {
|
||||
onValidationChange?.(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (autocompleteActive === false) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<MapPin className="pointer-events-none absolute start-4 top-4 z-10 h-5 w-5 text-[#B38B4D]" />
|
||||
<textarea
|
||||
value={query}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
placeholder={labels.fallbackPlaceholder}
|
||||
autoComplete="street-address"
|
||||
rows={3}
|
||||
className={`${inputClassName} min-h-[5.5rem] resize-y ps-12`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-[#6B665F]">{labels.fallbackHint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<div className="relative">
|
||||
<MapPin className="pointer-events-none absolute start-4 top-4 z-10 h-5 w-5 text-[#B38B4D]" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (suggestions.length > 0) setIsOpen(true);
|
||||
}}
|
||||
placeholder={labels.placeholder}
|
||||
autoComplete="street-address"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={listboxId}
|
||||
className={`${inputClassName} ps-12`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-1.5 text-xs text-[#6B665F]">{labels.hint}</p>
|
||||
|
||||
{isLoading && (
|
||||
<p className="mt-1 text-xs text-[#8A8478]">{labels.searching}</p>
|
||||
)}
|
||||
|
||||
{isOpen && suggestions.length > 0 && (
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-xl border border-[#EDE6D9] bg-white py-1 shadow-lg"
|
||||
>
|
||||
{suggestions.map((feature, index) => {
|
||||
const label = formatSuggestion(feature);
|
||||
const secondary = [feature.properties.postcode, feature.properties.city]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
return (
|
||||
<li key={`${label}-${index}`} role="option">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onTouchStart={(e) => e.preventDefault()}
|
||||
onClick={() => applySuggestion(feature)}
|
||||
className="flex w-full flex-col px-4 py-3.5 text-left hover:bg-[#F8F5F0] active:bg-[#EDE6D9] min-h-[48px] touch-manipulation"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-[#101724]" title={label}>{label}</span>
|
||||
{secondary && (
|
||||
<span className="text-xs text-[#6B665F]">{secondary}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!isLoading && query.trim().length >= 3 && suggestions.length === 0 && !selectedFromList && (
|
||||
<p className="mt-1 text-xs text-[#8A8478]">{labels.noResults}</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!selectedFromList && query.trim().length > 0 && !error && (
|
||||
<p className="mt-1 text-xs text-[#8A8478]">{labels.selectSuggestion}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import { getHeaderTotalHeight } from '@/components/LanguageSwitcher';
|
||||
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
|
||||
|
||||
export default function DynamicToaster() {
|
||||
const { isAuthenticated, isMenuManager, isLoading } = useCustomerAuth();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setIsMobile(window.innerWidth < 640);
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
const headerHeight = isLoading
|
||||
? getHeaderTotalHeight(true, isMobile, true)
|
||||
: getHeaderTotalHeight(isAuthenticated, isMobile, isMenuManager);
|
||||
|
||||
return (
|
||||
<Toaster
|
||||
position="top-center"
|
||||
richColors
|
||||
closeButton
|
||||
offset={headerHeight + 8}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ function FooterHeading({ children }: { children: ReactNode }) {
|
||||
export default function Footer() {
|
||||
const { language } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
const copy = FOOTER_COPY[language] ?? FOOTER_COPY.en;
|
||||
const copy = FOOTER_COPY[language] ?? FOOTER_COPY.sv;
|
||||
|
||||
return (
|
||||
<footer id="contact" className="scroll-mt-header shahi-footer relative mt-auto overflow-hidden">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getHeaderTotalHeight } from '@/components/LanguageSwitcher';
|
||||
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
|
||||
|
||||
/** Keeps `--header-height` in sync with the dynamic language bar + navbar. */
|
||||
export default function HeaderHeightSync() {
|
||||
const { isAuthenticated, isMenuManager, isLoading } = useCustomerAuth();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setIsMobile(window.innerWidth < 640);
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const height = isLoading
|
||||
? getHeaderTotalHeight(true, isMobile, true)
|
||||
: getHeaderTotalHeight(isAuthenticated, isMobile, isMenuManager);
|
||||
|
||||
document.documentElement.style.setProperty('--header-height', `${height}px`);
|
||||
document.documentElement.dataset.auth = isAuthenticated ? 'true' : 'false';
|
||||
}, [isAuthenticated, isMenuManager, isLoading, isMobile]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,12 +1,30 @@
|
||||
import { HEADER_TOTAL_HEIGHT } from '@/components/LanguageSwitcher';
|
||||
'use client';
|
||||
|
||||
/** Reserves vertical space for the fixed language bar + navbar (116px). */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getHeaderTotalHeight } from '@/components/LanguageSwitcher';
|
||||
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
|
||||
|
||||
/** Reserves vertical space for the fixed language bar + navbar. */
|
||||
export default function HeaderSpacer() {
|
||||
const { isAuthenticated, isMenuManager, isLoading } = useCustomerAuth();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setIsMobile(window.innerWidth < 640);
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
const height = isLoading
|
||||
? getHeaderTotalHeight(true, isMobile, true)
|
||||
: getHeaderTotalHeight(isAuthenticated, isMobile, isMenuManager);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
style={{ height: HEADER_TOTAL_HEIGHT }}
|
||||
className="shrink-0 transition-[height] duration-200"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+171
-11
@@ -1,37 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useLanguage } from '@/lib/language-context';
|
||||
import { languages, Language } from '@/lib/translations';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { languages, Language, getTranslation } from '@/lib/translations';
|
||||
import { useCustomerAuth } from '@/presentation/providers/customer-auth-provider';
|
||||
import { Globe, LogIn, LogOut, Sparkles, UtensilsCrossed } from 'lucide-react';
|
||||
|
||||
export const LANGUAGE_BANNER_HEIGHT = 48;
|
||||
export const LANGUAGE_BANNER_HEIGHT_LOGGED_IN_DESKTOP = 56;
|
||||
export const LANGUAGE_BANNER_HEIGHT_LOGGED_IN_MOBILE = 160;
|
||||
export const LANGUAGE_BANNER_HEIGHT_MENU_MANAGER_MOBILE = 160;
|
||||
export const NAVBAR_HEIGHT = 68;
|
||||
|
||||
export function getLanguageBannerHeight(
|
||||
isAuthenticated: boolean,
|
||||
isMobile = false,
|
||||
isMenuManager = false,
|
||||
): number {
|
||||
if (!isAuthenticated) return LANGUAGE_BANNER_HEIGHT;
|
||||
if (isMobile) {
|
||||
return isMenuManager
|
||||
? LANGUAGE_BANNER_HEIGHT_MENU_MANAGER_MOBILE
|
||||
: LANGUAGE_BANNER_HEIGHT_LOGGED_IN_MOBILE;
|
||||
}
|
||||
return LANGUAGE_BANNER_HEIGHT_LOGGED_IN_DESKTOP;
|
||||
}
|
||||
|
||||
export function getHeaderTotalHeight(
|
||||
isAuthenticated: boolean,
|
||||
isMobile = false,
|
||||
isMenuManager = false,
|
||||
): number {
|
||||
return getLanguageBannerHeight(isAuthenticated, isMobile, isMenuManager) + NAVBAR_HEIGHT;
|
||||
}
|
||||
|
||||
/** @deprecated Use getHeaderTotalHeight() for dynamic height. */
|
||||
export const HEADER_TOTAL_HEIGHT = LANGUAGE_BANNER_HEIGHT + NAVBAR_HEIGHT;
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const { language, setLanguage } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
const pathname = usePathname();
|
||||
const { email, isAuthenticated, isMenuManager, logout } = useCustomerAuth();
|
||||
const isLoginActive = !isAuthenticated && pathname === '/login';
|
||||
const isAdminActive = pathname === '/admin';
|
||||
|
||||
const emailInitial = email?.charAt(0).toUpperCase() ?? '?';
|
||||
|
||||
const handleSelect = (lang: Language) => {
|
||||
setLanguage(lang);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
const loginButton = (
|
||||
<Link
|
||||
href="/login"
|
||||
aria-current={isLoginActive ? 'page' : undefined}
|
||||
className={`group relative inline-flex shrink-0 items-center gap-1.5 overflow-hidden rounded-full px-3.5 py-2 text-[11px] font-bold uppercase tracking-[0.1em] transition-all duration-300 active:scale-[0.96] touch-manipulation min-h-[36px] sm:min-h-[38px] sm:px-4 ${
|
||||
isLoginActive
|
||||
? 'bg-white text-[#0f5a4a] shadow-lg shadow-black/25 ring-2 ring-[#f4d47f]/60'
|
||||
: 'border border-[#f4d47f]/45 bg-gradient-to-br from-[#c99a2e] via-[#e8c56a] to-[#b8892f] text-[#1a1206] shadow-md shadow-black/30 hover:scale-[1.03] hover:shadow-lg hover:shadow-[#c99a2e]/40'
|
||||
}`}
|
||||
>
|
||||
<span className="relative flex h-5 w-5 items-center justify-center rounded-full bg-[#1a1206]/10">
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
<Sparkles className="absolute -right-0.5 -top-0.5 h-2 w-2 text-[#fff8e7] opacity-80" />
|
||||
</span>
|
||||
<span className="whitespace-nowrap max-sm:hidden">{t.nav.login}</span>
|
||||
<span className="whitespace-nowrap sm:hidden">Login</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const menuManagementLink = (variant: 'desktop' | 'mobile' | 'mobile-full' = 'desktop') => (
|
||||
<Link
|
||||
href="/admin"
|
||||
aria-current={isAdminActive ? 'page' : undefined}
|
||||
className={`group relative inline-flex shrink-0 items-center justify-center gap-1.5 overflow-hidden rounded-full font-bold transition-all duration-300 active:scale-[0.96] touch-manipulation ${
|
||||
variant === 'mobile-full'
|
||||
? 'flex-1 min-h-[44px] border border-[#f4d47f]/60 bg-gradient-to-br from-[#c99a2e] via-[#e8c56a] to-[#b8892f] px-3 py-2.5 text-[11px] text-[#1a1206] shadow-md'
|
||||
: variant === 'mobile'
|
||||
? 'min-h-[40px] border border-[#f4d47f]/60 bg-gradient-to-br from-[#c99a2e] to-[#d4a73d] px-3 py-2 text-[10px] text-[#1a1206]'
|
||||
: 'min-h-[38px] border border-[#f4d47f]/50 bg-gradient-to-br from-[#c99a2e] via-[#e8c56a] to-[#b8892f] px-3 py-1.5 text-[10px] text-[#1a1206] shadow-md hover:scale-[1.03] sm:px-3.5 sm:text-[11px]'
|
||||
} ${isAdminActive ? 'ring-2 ring-white/70' : ''}`}
|
||||
>
|
||||
<UtensilsCrossed className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="whitespace-nowrap uppercase tracking-[0.06em]">
|
||||
{variant === 'mobile' ? 'Menu' : t.auth.customer.menuManagement}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const logoutButton = (variant: 'desktop' | 'mobile' | 'mobile-full' = 'desktop') => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLogout()}
|
||||
aria-label={t.auth.customer.logout}
|
||||
className={`group relative inline-flex shrink-0 items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/30 bg-white/10 font-bold text-white shadow-md transition-all duration-300 hover:bg-white/20 active:scale-[0.96] touch-manipulation ${
|
||||
variant === 'mobile-full'
|
||||
? 'flex-1 min-h-[44px] px-3 py-2.5 text-[11px]'
|
||||
: variant === 'mobile'
|
||||
? 'min-h-[40px] min-w-[40px] px-3 py-2'
|
||||
: 'min-h-[38px] px-3 py-1.5 text-[10px] sm:px-3.5 sm:text-[11px]'
|
||||
}`}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5 shrink-0" />
|
||||
{variant !== 'mobile' && (
|
||||
<span className="whitespace-nowrap uppercase tracking-[0.06em]">
|
||||
{t.auth.customer.logout}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lang-banner w-full border-b border-[#c99a2e]/25 bg-gradient-to-r from-[#0a4a3d] via-[#0f5a4a] to-[#0a4a3d]"
|
||||
style={{ height: LANGUAGE_BANNER_HEIGHT }}
|
||||
className={`lang-banner w-full overflow-visible border-b border-[#c99a2e]/25 bg-gradient-to-r from-[#0a4a3d] via-[#0f5a4a] to-[#0a4a3d] ${
|
||||
!isAuthenticated
|
||||
? 'h-12'
|
||||
: 'sm:h-14'
|
||||
}`}
|
||||
role="navigation"
|
||||
aria-label="Language selection"
|
||||
>
|
||||
<div className="mx-auto flex h-full max-w-7xl items-center gap-2 px-3 sm:px-6">
|
||||
<div className="hidden shrink-0 items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-[#d4a73d]/90 sm:flex">
|
||||
{/* ── Row 1: languages (+ desktop auth inline) ── */}
|
||||
<div
|
||||
className={`mx-auto flex max-w-7xl items-center gap-2 px-3 sm:px-6 ${
|
||||
isAuthenticated ? 'h-12 sm:h-14' : 'h-12'
|
||||
}`}
|
||||
>
|
||||
<div className="hidden shrink-0 items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-[#d4a73d]/90 lg:flex">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span>Language</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center gap-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:gap-1.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:gap-1.5">
|
||||
{languages.map((lang) => {
|
||||
const isActive = language === lang.code;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={lang.code}
|
||||
@@ -39,21 +147,73 @@ export default function LanguageSwitcher() {
|
||||
onClick={() => handleSelect(lang.code)}
|
||||
aria-pressed={isActive}
|
||||
aria-label={`${lang.name} (${lang.native})`}
|
||||
className={`flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-semibold transition-all sm:gap-1.5 sm:px-3 sm:py-1.5 sm:text-xs ${
|
||||
className={`flex shrink-0 items-center gap-1 rounded-full px-2.5 py-2 text-[11px] font-semibold transition-all min-h-[34px] touch-manipulation sm:min-h-[36px] sm:gap-1.5 sm:px-3 sm:py-1.5 sm:text-xs ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-[#c99a2e] to-[#e8c56a] text-[#1a1206] shadow-lg shadow-[#c99a2e]/35 ring-1 ring-[#f4d47f]/50'
|
||||
: 'text-white/80 hover:bg-white/12 hover:text-white'
|
||||
: 'text-white/80 hover:bg-white/12 hover:text-white active:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm sm:text-base" aria-hidden="true">
|
||||
{lang.flag}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{lang.native}</span>
|
||||
<span className="whitespace-nowrap max-[400px]:hidden min-[401px]:inline">
|
||||
{lang.native}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop: guest login OR logged-in account strip (single row, no clipping) */}
|
||||
<div className="hidden shrink-0 sm:flex sm:items-center">
|
||||
{!isAuthenticated ? (
|
||||
loginButton
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5 border-s border-white/15 ps-3">
|
||||
<div className="hidden min-w-0 max-w-[150px] md:block">
|
||||
<p className="text-[8px] font-medium uppercase tracking-[0.14em] text-[#d4a73d]/90 leading-none">
|
||||
{t.auth.customer.loggedInAs}
|
||||
</p>
|
||||
<p className="truncate text-[11px] font-semibold text-white leading-tight mt-0.5" title={email ?? ''}>
|
||||
{email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isMenuManager && menuManagementLink('desktop')}
|
||||
{logoutButton('desktop')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile guest: login icon only */}
|
||||
{!isAuthenticated && <div className="shrink-0 sm:hidden">{loginButton}</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Mobile logged-in: creative account card ── */}
|
||||
{isAuthenticated && (
|
||||
<div className="px-3 pb-2.5 sm:hidden">
|
||||
<div className="rounded-2xl border border-[#f4d47f]/20 bg-gradient-to-br from-white/[0.08] to-white/[0.03] p-2.5 shadow-inner backdrop-blur-sm">
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-[#c99a2e] to-[#e8c56a] text-sm font-bold text-[#1a1206] shadow-md ring-2 ring-white/20">
|
||||
{emailInitial}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[9px] font-semibold uppercase tracking-[0.14em] text-[#d4a73d]/90">
|
||||
{t.auth.customer.loggedInAs}
|
||||
</p>
|
||||
<p className="truncate text-sm font-semibold text-white" title={email ?? ''}>
|
||||
{email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isMenuManager && menuManagementLink('mobile-full')}
|
||||
{logoutButton('mobile-full')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "./CartContext";
|
||||
import { useWishlist } from "./WishlistContext";
|
||||
import { ShoppingBag, Heart, ArrowRight, Home, UtensilsCrossed, MapPin, Star, Phone, X, LogIn, ChefHat } from "lucide-react";
|
||||
import { ShoppingBag, Heart, ArrowRight, Home, UtensilsCrossed, MapPin, Star, Phone, X, ChefHat } from "lucide-react";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import HeaderSpacer from "./HeaderSpacer";
|
||||
import { logoUrl } from "@/lib/assets";
|
||||
@@ -44,7 +44,6 @@ export default function Navbar({ variant = "default" }: NavbarProps) {
|
||||
{ href: "/menu", label: t.nav.menu },
|
||||
{ href: "/catering", label: t.nav.catering },
|
||||
{ href: "/locations", label: t.nav.locations },
|
||||
{ href: "/login", label: t.nav.login },
|
||||
{ href: "/#experience", label: t.nav.experience },
|
||||
{ href: "/#contact", label: t.nav.contact },
|
||||
];
|
||||
@@ -232,7 +231,7 @@ export default function Navbar({ variant = "default" }: NavbarProps) {
|
||||
|
||||
{/* Sliding Panel - modern, full-bleed on small phones, elegant on larger */}
|
||||
<motion.div
|
||||
className={`absolute top-0 bottom-0 w-[82%] max-w-[340px] bg-[#fbf7ef] shadow-2xl flex flex-col overflow-y-auto z-[10000] ${
|
||||
className={`absolute top-0 bottom-0 w-[82%] max-w-[340px] bg-[#fbf7ef] shadow-2xl flex flex-col overflow-y-auto z-[10000] pt-[env(safe-area-inset-top)] ${
|
||||
isRtl
|
||||
? 'left-0 border-r border-[#c99a2e]/10'
|
||||
: 'right-0 border-l border-[#c99a2e]/10'
|
||||
@@ -257,7 +256,7 @@ export default function Navbar({ variant = "default" }: NavbarProps) {
|
||||
</div>
|
||||
<button
|
||||
onClick={closeMenu}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/70 active:bg-[#EDE6D9] text-[#101724] transition-colors"
|
||||
className="min-h-11 min-w-11 flex items-center justify-center rounded-full bg-white/70 active:bg-[#EDE6D9] text-[#101724] transition-colors touch-manipulation"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
@@ -273,7 +272,6 @@ export default function Navbar({ variant = "default" }: NavbarProps) {
|
||||
link.href === '/menu' ? UtensilsCrossed :
|
||||
link.href === '/catering' ? ChefHat :
|
||||
link.href === '/locations' ? MapPin :
|
||||
link.href === '/login' ? LogIn :
|
||||
link.href.includes('experience') ? Star : Phone;
|
||||
|
||||
return (
|
||||
@@ -281,14 +279,14 @@ export default function Navbar({ variant = "default" }: NavbarProps) {
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={closeMenu}
|
||||
className={`flex items-center gap-4 px-4 py-3.5 mx-1 my-0.5 rounded-2xl text-[17px] font-medium transition-all active:scale-[0.985] ${
|
||||
className={`flex items-center gap-4 px-4 py-3.5 mx-1 my-0.5 rounded-2xl text-[17px] font-medium transition-all active:scale-[0.985] min-h-[48px] touch-manipulation ${
|
||||
active
|
||||
? 'bg-[#101724] text-white shadow-sm'
|
||||
: 'text-[#101724] hover:bg-white active:bg-[#EDE6D9]'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 flex-shrink-0 ${active ? 'text-[#c99a2e]' : 'text-[#B38B4D]'}`} />
|
||||
<span className="whitespace-nowrap">{link.label}</span>
|
||||
<span className="min-w-0 truncate">{link.label}</span>
|
||||
{active && (
|
||||
<span className="ml-auto text-xs tracking-widest opacity-70">CURRENT</span>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCart } from '@/presentation/providers/cart-provider';
|
||||
import { useLanguage } from '@/presentation/providers/language-provider';
|
||||
import { getTranslation } from '@/presentation/i18n/translations';
|
||||
import { getMenuPosterSrc, applyNextImageFallback, getMenuPosterCandidates } from '@/lib/assets';
|
||||
import { getMenuItemById } from '@/lib/menu-data';
|
||||
import { useMenu } from '@/presentation/providers/menu-provider';
|
||||
import { getMenuItemName } from '@/application/i18n/menu-localization';
|
||||
import { buildCartLineFromMenuItem } from '@/application/cart/cart-line-builder';
|
||||
|
||||
@@ -21,13 +21,14 @@ export default function WishlistDrawer() {
|
||||
} = useWishlist();
|
||||
|
||||
const { addToCart } = useCart();
|
||||
const { getItemById } = useMenu();
|
||||
const { language, isRtl } = useLanguage();
|
||||
const t = getTranslation(language);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAddToCart = (item: (typeof items)[number]) => {
|
||||
const menuItem = getMenuItemById(item.id);
|
||||
const menuItem = getItemById(item.id);
|
||||
addToCart(menuItem ? buildCartLineFromMenuItem(menuItem) : {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
@@ -94,7 +95,7 @@ export default function WishlistDrawer() {
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{items.map((item) => {
|
||||
const menuItem = getMenuItemById(item.id);
|
||||
const menuItem = getItemById(item.id);
|
||||
const displayName = getMenuItemName(language, {
|
||||
id: item.id,
|
||||
name: menuItem?.name ?? item.name,
|
||||
@@ -128,7 +129,7 @@ export default function WishlistDrawer() {
|
||||
</h4>
|
||||
<span className="shrink-0 text-right font-medium text-[#B38B4D] tabular-nums text-sm leading-tight">
|
||||
{(() => {
|
||||
const menuItem = getMenuItemById(item.id);
|
||||
const menuItem = getItemById(item.id);
|
||||
if (menuItem?.pricing === 'weight') {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
BookmarkPlus,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
History,
|
||||
Lock,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import type { MenuCategory } from '@/domain/menu/entities';
|
||||
import type { MenuVersionListItem } from '@/domain/menu/versioning';
|
||||
|
||||
interface MenuVersionTimelineProps {
|
||||
onMenuRestored: (categories: MenuCategory[]) => void;
|
||||
onNotifyMenuUpdated: () => void;
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
function formatVersionDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function triggerBadge(trigger: MenuVersionListItem['trigger']): string {
|
||||
if (trigger === 'manual') return 'Checkpoint';
|
||||
if (trigger === 'restore') return 'Pre-rollback';
|
||||
return 'Auto-save';
|
||||
}
|
||||
|
||||
export default function MenuVersionTimeline({
|
||||
onMenuRestored,
|
||||
onNotifyMenuUpdated,
|
||||
refreshTrigger = 0,
|
||||
}: MenuVersionTimelineProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [versions, setVersions] = useState<MenuVersionListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [checkpointLabel, setCheckpointLabel] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
const response = await fetch('/api/admin/menu/versions', { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Could not load version history');
|
||||
const data = (await response.json()) as { versions: MenuVersionListItem[] };
|
||||
setVersions(data.versions);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadVersions()
|
||||
.catch(() => setStatus('Version history unavailable.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [loadVersions, refreshTrigger]);
|
||||
|
||||
const handleRestore = async (version: MenuVersionListItem) => {
|
||||
if (version.matchesLive) {
|
||||
setStatus('This version is already live.');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Restore "${version.label}"?\n\nThe live menu will switch to this snapshot. Later versions stay in history — nothing is deleted.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setBusyId(version.id);
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/menu/versions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'restore', versionId: version.id }),
|
||||
});
|
||||
const data = (await response.json()) as {
|
||||
categories?: MenuCategory[];
|
||||
versions?: MenuVersionListItem[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !data.categories) {
|
||||
throw new Error(data.error ?? 'Restore failed');
|
||||
}
|
||||
|
||||
setVersions(data.versions ?? []);
|
||||
onMenuRestored(data.categories);
|
||||
onNotifyMenuUpdated();
|
||||
setStatus(`Live menu restored to "${version.label}".`);
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Restore failed');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (version: MenuVersionListItem) => {
|
||||
if (version.isBaseline) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Delete snapshot "${version.label}" from history?\n\nThis only removes the saved copy — it does not change the live menu.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setBusyId(version.id);
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/menu/versions/${version.id}`, { method: 'DELETE' });
|
||||
const data = (await response.json()) as { versions?: MenuVersionListItem[]; error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error ?? 'Delete failed');
|
||||
}
|
||||
|
||||
setVersions(data.versions ?? []);
|
||||
setStatus('Snapshot removed from history.');
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Delete failed');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckpoint = async () => {
|
||||
setBusyId('checkpoint');
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/menu/versions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label: checkpointLabel.trim() || 'Manual checkpoint' }),
|
||||
});
|
||||
const data = (await response.json()) as {
|
||||
versions?: MenuVersionListItem[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error ?? 'Could not save checkpoint');
|
||||
}
|
||||
|
||||
setVersions(data.versions ?? []);
|
||||
setCheckpointLabel('');
|
||||
setStatus('Checkpoint saved.');
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Could not save checkpoint');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const liveVersion = versions.find((version) => version.matchesLive);
|
||||
const historyCount = versions.filter((version) => !version.isBaseline).length;
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-[#EDE6D9] bg-white overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className="flex w-full min-h-[56px] items-center justify-between gap-3 px-4 py-4 text-left transition hover:bg-[#FFFCF7] active:bg-[#FFFCF7] touch-manipulation sm:gap-4 sm:px-5"
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-[#FFF6DC] to-[#F8F5F0] border border-[#EDE6D9]">
|
||||
<History className="h-5 w-5 text-[#8f6b22]" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="font-serif text-base text-[#101724] sm:text-lg">Menu time machine</h2>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[#101724] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span className="sm:hidden">History</span>
|
||||
<span className="hidden sm:inline">Version history</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-relaxed text-[#6B665F] sm:text-sm">
|
||||
Every change is saved. Roll back anytime — later snapshots are kept unless you remove them.
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] font-medium text-[#8A8478] sm:hidden">
|
||||
{historyCount} snapshot{historyCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
{liveVersion && (
|
||||
<p className="mt-2 text-xs font-medium text-[#8f6b22] line-clamp-2 sm:truncate">
|
||||
Live now: {liveVersion.label}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-[#6B665F]">
|
||||
<span className="hidden text-xs font-medium sm:inline">
|
||||
{historyCount} snapshot{historyCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{expanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-[#EDE6D9] bg-[#FFFCF7]/60 px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={checkpointLabel}
|
||||
onChange={(e) => setCheckpointLabel(e.target.value)}
|
||||
placeholder="Name this checkpoint (optional)…"
|
||||
className="min-h-[48px] flex-1 rounded-xl border border-[#EDE6D9] bg-white px-3 py-2.5 text-base text-[#2C2A26] placeholder:text-[#8A8478] focus:border-[#c99a2e] focus:ring-2 focus:ring-[#c99a2e]/20 sm:text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void handleCheckpoint();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCheckpoint()}
|
||||
disabled={busyId !== null}
|
||||
className="inline-flex min-h-[48px] w-full items-center justify-center gap-2 rounded-xl bg-[#101724] px-4 py-2.5 text-sm font-semibold text-white touch-manipulation active:scale-[0.985] disabled:opacity-60 sm:w-auto"
|
||||
>
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
Save checkpoint
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status && <p className="mb-3 text-sm text-[#6B665F]">{status}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-[#8A8478] py-6 text-center">Loading version history…</p>
|
||||
) : versions.length === 0 ? (
|
||||
<p className="text-sm text-[#8A8478] py-6 text-center">No versions yet.</p>
|
||||
) : (
|
||||
<div className="relative max-h-[min(58dvh,420px)] overflow-y-auto overscroll-y-contain pr-1 sm:max-h-[min(52dvh,420px)]">
|
||||
<div className="absolute start-[1.125rem] top-3 bottom-3 w-px bg-gradient-to-b from-[#c99a2e]/50 via-[#EDE6D9] to-[#c99a2e]/30" />
|
||||
|
||||
<ul className="space-y-3">
|
||||
{versions.map((version) => {
|
||||
const isBusy = busyId === version.id;
|
||||
const isLive = version.matchesLive;
|
||||
|
||||
return (
|
||||
<li key={version.id} className="relative ps-10">
|
||||
<span
|
||||
className={`absolute start-2.5 top-4 h-4 w-4 rounded-full border-2 ${
|
||||
version.isBaseline
|
||||
? 'border-[#c99a2e] bg-[#FFF6DC]'
|
||||
: isLive
|
||||
? 'border-[#101724] bg-[#101724]'
|
||||
: 'border-[#B38B4D] bg-white'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-3 sm:p-4 transition ${
|
||||
version.isBaseline
|
||||
? 'border-[#c99a2e]/40 bg-gradient-to-r from-[#FFF6DC]/80 to-white'
|
||||
: isLive
|
||||
? 'border-[#101724]/20 bg-white shadow-sm ring-1 ring-[#101724]/10'
|
||||
: 'border-[#EDE6D9] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-medium text-sm text-[#101724] break-words sm:truncate">
|
||||
{version.label}
|
||||
</h3>
|
||||
{version.isBaseline && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-[#c99a2e]/40 bg-[#FFF6DC] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-[#8f6b22]">
|
||||
<Lock className="h-3 w-3" />
|
||||
Baseline
|
||||
</span>
|
||||
)}
|
||||
{isLive && (
|
||||
<span className="inline-flex items-center rounded-full bg-[#101724] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full bg-[#F8F5F0] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[#6B665F]">
|
||||
{triggerBadge(version.trigger)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[#8A8478]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatVersionDate(version.createdAt)}
|
||||
</span>
|
||||
<span>
|
||||
{version.categoryCount} categor{version.categoryCount === 1 ? 'y' : 'ies'}
|
||||
{' · '}
|
||||
{version.dishCount} dish{version.dishCount === 1 ? '' : 'es'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full shrink-0 flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
{!isLive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRestore(version)}
|
||||
disabled={busyId !== null}
|
||||
className="inline-flex min-h-[48px] flex-1 items-center justify-center gap-1.5 rounded-xl border border-[#B38B4D]/40 bg-[#FFF6DC] px-3 py-2.5 text-xs font-semibold text-[#8f6b22] hover:bg-[#FFF6DC]/80 touch-manipulation active:scale-[0.985] disabled:opacity-50 sm:min-h-[40px] sm:flex-none sm:rounded-lg"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 shrink-0" />
|
||||
{isBusy ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
)}
|
||||
{!version.isBaseline && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete(version)}
|
||||
disabled={busyId !== null}
|
||||
className="inline-flex min-h-[48px] flex-1 items-center justify-center gap-1.5 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 touch-manipulation active:scale-[0.985] disabled:opacity-50 sm:min-h-[40px] sm:flex-none sm:rounded-lg"
|
||||
title="Remove from history"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 shrink-0" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{version.isBaseline && (
|
||||
<p className="mt-2 text-xs text-[#8f6b22]/90">
|
||||
The original menu snapshot — permanently protected. You can always return here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Award,
|
||||
Baby,
|
||||
Briefcase,
|
||||
Cake,
|
||||
Gem,
|
||||
Gift,
|
||||
GraduationCap,
|
||||
Heart,
|
||||
MoreHorizontal,
|
||||
PartyPopper,
|
||||
Plane,
|
||||
Sparkles,
|
||||
TreePine,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
BOOKING_EVENT_TYPES,
|
||||
type BookingEventType,
|
||||
type EventTypeId,
|
||||
} from '@/domain/booking/event-types';
|
||||
import { getEventTypeLabel } from '@/presentation/i18n/booking-events';
|
||||
import type { Language } from '@/domain/language/entities';
|
||||
|
||||
const ICON_MAP: Record<BookingEventType['icon'], LucideIcon> = {
|
||||
cake: Cake,
|
||||
heart: Heart,
|
||||
gem: Gem,
|
||||
sparkles: Sparkles,
|
||||
baby: Baby,
|
||||
'graduation-cap': GraduationCap,
|
||||
briefcase: Briefcase,
|
||||
users: Users,
|
||||
'party-popper': PartyPopper,
|
||||
plane: Plane,
|
||||
award: Award,
|
||||
gift: Gift,
|
||||
'tree-pine': TreePine,
|
||||
'more-horizontal': MoreHorizontal,
|
||||
};
|
||||
|
||||
interface EventTypePickerProps {
|
||||
language: Language;
|
||||
selected: EventTypeId | '';
|
||||
onSelect: (id: EventTypeId) => void;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export default function EventTypePicker({
|
||||
language,
|
||||
selected,
|
||||
onSelect,
|
||||
title,
|
||||
subtitle,
|
||||
}: EventTypePickerProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="font-serif text-xl tracking-tight text-[#101724] sm:text-2xl">{title}</h3>
|
||||
<p className="mt-1 mb-5 text-sm text-[#6B665F]">{subtitle}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 sm:gap-3 lg:grid-cols-5">
|
||||
{BOOKING_EVENT_TYPES.map((event) => {
|
||||
const Icon = ICON_MAP[event.icon];
|
||||
const isActive = selected === event.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(event.id)}
|
||||
className={`group relative min-h-[5.25rem] touch-manipulation overflow-hidden rounded-2xl border p-3 text-left transition-all active:scale-[0.98] sm:min-h-[5.5rem] sm:p-4 ${
|
||||
isActive
|
||||
? 'border-[#c99a2e] bg-gradient-to-br from-[#fff6dc] to-[#fffcf7] shadow-lg shadow-[#c99a2e]/15 ring-2 ring-[#c99a2e]/30'
|
||||
: 'border-[#EDE6D9] bg-[#FFFCF7] hover:border-[#c99a2e]/50 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-3 flex h-10 w-10 items-center justify-center rounded-xl transition-colors ${
|
||||
isActive
|
||||
? 'bg-gradient-to-br from-[#c99a2e] to-[#d4a73d] text-[#241806]'
|
||||
: 'bg-[#F8F5F0] text-[#B38B4D] group-hover:bg-[#fff6dc]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<span className="block text-xs sm:text-sm font-semibold leading-snug text-[#101724] line-clamp-2">
|
||||
{getEventTypeLabel(language, event.id)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
{
|
||||
"id": "baseline",
|
||||
"label": "Current menu (baseline)",
|
||||
"createdAt": "2026-07-03T01:18:38.626Z",
|
||||
"trigger": "manual",
|
||||
"categories": [
|
||||
{
|
||||
"id": "street-food",
|
||||
"name": "Street Food & Starters",
|
||||
"items": [
|
||||
{
|
||||
"id": "samosa-aloo",
|
||||
"name": "Samosa Aloo Veg",
|
||||
"description": "Crispy fried triangular pastries filled with spiced potatoes and peas.",
|
||||
"price": 34,
|
||||
"image": "aloo-samosa.jpg",
|
||||
"video": "samosa-aloo.mp4"
|
||||
},
|
||||
{
|
||||
"id": "samosa-keema",
|
||||
"name": "Samosa Keema",
|
||||
"description": "Flaky pastries stuffed with spiced minced meat filling.",
|
||||
"price": 39,
|
||||
"image": "keema-samosa.jpg",
|
||||
"video": "samosa-keema.mp4"
|
||||
},
|
||||
{
|
||||
"id": "samosa-chat",
|
||||
"name": "Samosa Chat",
|
||||
"description": "Crispy samosas topped with spicy chickpeas, yogurt, chutneys and fresh herbs.",
|
||||
"price": 89,
|
||||
"image": "samosa-chaat.jpg",
|
||||
"video": "samosa-chaat.mp4"
|
||||
},
|
||||
{
|
||||
"id": "chana-chat",
|
||||
"name": "Chana Chat",
|
||||
"description": "Tangy spiced chickpeas mixed with potatoes, onions, tomatoes and chutneys.",
|
||||
"price": 69,
|
||||
"image": "chana-chaat.jpg",
|
||||
"video": "chana-chaat.mp4"
|
||||
},
|
||||
{
|
||||
"id": "panipuri",
|
||||
"name": "Panipuri / Golgappe",
|
||||
"description": "Crispy hollow puris filled with spiced chickpeas and potatoes, served with tangy tamarind water.",
|
||||
"price": 69,
|
||||
"image": "panipuri.jpg",
|
||||
"video": "panipuri.mp4"
|
||||
},
|
||||
{
|
||||
"id": "keema-naan-starter",
|
||||
"name": "Keema Naan",
|
||||
"description": "Soft naan bread stuffed with spiced minced meat, baked until golden.",
|
||||
"price": 75,
|
||||
"image": "keema-naan.jpg",
|
||||
"video": "keema-naan.mp4"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "vegetarian",
|
||||
"name": "Vegetarian",
|
||||
"items": [
|
||||
{
|
||||
"id": "palak-paneer",
|
||||
"name": "Palak Paneer",
|
||||
"description": "Cottage cheese cooked in a creamy spinach gravy with mild spices and aromatic herbs.",
|
||||
"price": 139,
|
||||
"image": "palak-paneer.jpg",
|
||||
"video": "palak-paneer.mp4",
|
||||
"isVegetarian": true
|
||||
},
|
||||
{
|
||||
"id": "shahi-paneer",
|
||||
"name": "Shahi Paneer",
|
||||
"description": "Soft cottage cheese in a rich, creamy cashew and tomato gravy with Indian spices.",
|
||||
"price": 139,
|
||||
"image": "shahi-paneer.jpg",
|
||||
"video": "shahi-paneer.mp4",
|
||||
"isVegetarian": true
|
||||
},
|
||||
{
|
||||
"id": "malai-kofta",
|
||||
"name": "Malai Kofta",
|
||||
"description": "Soft vegetable koftas simmered in a rich and creamy onion-tomato gravy with mild spices.",
|
||||
"price": 139,
|
||||
"image": "malai-kofta.jpg",
|
||||
"video": "malai-kofta.mp4",
|
||||
"isVegetarian": true
|
||||
},
|
||||
{
|
||||
"id": "daal-makhani",
|
||||
"name": "Daal Makhani",
|
||||
"description": "Slow-cooked black lentils in a buttery, creamy tomato gravy with aromatic spices.",
|
||||
"price": 139,
|
||||
"image": "daal-makhani.jpg",
|
||||
"video": "daal-makhani.mp4",
|
||||
"isVegetarian": true
|
||||
},
|
||||
{
|
||||
"id": "lahore-chana",
|
||||
"name": "Lahore Chana",
|
||||
"description": "Spiced chickpeas cooked in a tangy onion-tomato gravy with traditional Punjabi spices.",
|
||||
"price": 139,
|
||||
"image": "lahore-chana.jpg",
|
||||
"video": "lahore-chana.mp4",
|
||||
"isVegetarian": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "meat",
|
||||
"name": "Meat",
|
||||
"items": [
|
||||
{
|
||||
"id": "lamm-palak",
|
||||
"name": "Lamm Palak",
|
||||
"description": "Tender lamb cooked with fresh spinach in a mild, flavorful gravy.",
|
||||
"price": 179,
|
||||
"image": "lamm-palak.jpg",
|
||||
"video": "lamm-palak.mp4",
|
||||
"pricing": "standard"
|
||||
},
|
||||
{
|
||||
"id": "lamm-vindaloo",
|
||||
"name": "Lamm Vindaloo",
|
||||
"description": "Spicy and tangy lamb curry in a vinegar and chili-based sauce.",
|
||||
"price": 179,
|
||||
"image": "lamm-vindaloo.jpg",
|
||||
"video": "lamm-vindaloo.mp4"
|
||||
},
|
||||
{
|
||||
"id": "lamm-rogan-josh",
|
||||
"name": "Lamm Rogan Josh",
|
||||
"description": "Aromatic lamb curry simmered in a rich yogurt and Kashmiri spice gravy.",
|
||||
"price": 199,
|
||||
"image": "lamm-rogan-josh.jpg",
|
||||
"video": "lamm-rogan-josh.mp4"
|
||||
},
|
||||
{
|
||||
"id": "lamm-karahi",
|
||||
"name": "Lamm Karahi",
|
||||
"description": "Lamb pieces stir-fried in a wok with tomatoes, ginger, garlic and spices.",
|
||||
"price": 179,
|
||||
"image": "lamm-karahi.jpg",
|
||||
"video": "lamm-karahi.mp4"
|
||||
},
|
||||
{
|
||||
"id": "bong-nihari",
|
||||
"name": "Bong Nihari",
|
||||
"description": "Slow-cooked beef shank in a rich, aromatic gravy, traditionally served with naan.",
|
||||
"price": 199,
|
||||
"image": "bong-nihari.jpg",
|
||||
"video": "bong-nihari.mp4"
|
||||
},
|
||||
{
|
||||
"id": "paye",
|
||||
"name": "Paye",
|
||||
"description": "Slow-simmered lamb trotters in a thick, spicy and flavorful gravy.",
|
||||
"price": 149,
|
||||
"image": "paye.jpg",
|
||||
"video": "paye.mp4"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "burger-sandwich",
|
||||
"name": "Burger & Sandwich",
|
||||
"items": [
|
||||
{
|
||||
"id": "shahi-burger",
|
||||
"name": "Shahi Burger",
|
||||
"description": "Juicy spiced meat patty in a soft bun with special sauces, lettuce and tomatoes.",
|
||||
"price": 119,
|
||||
"image": "burger.jpg",
|
||||
"video": "shahi-burger.mp4",
|
||||
"pricing": "standard"
|
||||
},
|
||||
{
|
||||
"id": "shami-sandwich",
|
||||
"name": "Shami Sandwich Menu",
|
||||
"description": "Spiced minced meat shami kebab patties served in bread with chutney and onions.",
|
||||
"price": 99,
|
||||
"image": "sandwich.jpg",
|
||||
"video": "shami-sandwich.mp4",
|
||||
"pricing": "standard"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chicken",
|
||||
"name": "Chicken",
|
||||
"items": [
|
||||
{
|
||||
"id": "chicken-biryani",
|
||||
"name": "Chicken Biryani",
|
||||
"description": "Fragrant aged basmati rice layered with tender spiced chicken, saffron and caramelized onions.",
|
||||
"price": 149,
|
||||
"image": "chicken-biryani.jpg",
|
||||
"video": "chicken-biryani.mp4"
|
||||
},
|
||||
{
|
||||
"id": "chicken-tikka",
|
||||
"name": "Chicken Tikka",
|
||||
"description": "Boneless chicken pieces marinated in yogurt and spices, grilled in a tandoor.",
|
||||
"price": 149,
|
||||
"image": "chicken-tikka.jpg",
|
||||
"video": "chicken-tikka.mp4"
|
||||
},
|
||||
{
|
||||
"id": "chicken-karahi",
|
||||
"name": "Chicken Karahi",
|
||||
"description": "Wok-tossed chicken in a robust tomato, chili and ginger gravy.",
|
||||
"price": 149,
|
||||
"image": "chicken-karahi.jpg",
|
||||
"video": "chicken-karahi.mp4"
|
||||
},
|
||||
{
|
||||
"id": "lahore-sizzler",
|
||||
"name": "Lahore Sizzler",
|
||||
"description": "Sizzling platter of marinated chicken with vegetables and spicy sauces.",
|
||||
"price": 169,
|
||||
"image": "lahore-sizzler.jpg",
|
||||
"video": "lahore-sizzler.mp4"
|
||||
},
|
||||
{
|
||||
"id": "butter-chicken",
|
||||
"name": "Butter Chicken",
|
||||
"description": "Tender chicken in a creamy tomato and butter gravy with mild spices.",
|
||||
"price": 149,
|
||||
"image": "butter-chicken.jpg"
|
||||
},
|
||||
{
|
||||
"id": "chicken-haleem",
|
||||
"name": "Chicken Haleem",
|
||||
"description": "Slow-cooked shredded chicken with lentils, wheat and aromatic spices.",
|
||||
"price": 139,
|
||||
"image": "chicken-haleem.jpg",
|
||||
"video": "chicken-haleem.mp4"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pizza",
|
||||
"name": "Pizza",
|
||||
"items": [
|
||||
{
|
||||
"id": "lahore-pizza",
|
||||
"name": "Lahore Pizza",
|
||||
"description": "Pizza topped with spiced chicken, onions and special Lahori sauces on a crispy base.",
|
||||
"price": 119,
|
||||
"image": "lahore-pizza.jpg",
|
||||
"video": "lahore-pizza.mp4"
|
||||
},
|
||||
{
|
||||
"id": "kebab-pizza",
|
||||
"name": "Kebab Pizza",
|
||||
"description": "Pizza with minced meat kebab topping, cheese, onions and aromatic spices.",
|
||||
"price": 119,
|
||||
"image": "kebab-pizza.jpg",
|
||||
"video": "kebab-pizza.mp4"
|
||||
},
|
||||
{
|
||||
"id": "tikka-boti-pizza",
|
||||
"name": "Tikka Boti Pizza",
|
||||
"description": "Pizza featuring grilled chicken tikka, cheese, tomatoes and fresh herbs.",
|
||||
"price": 119,
|
||||
"image": "tikka-boti-pizza.jpg",
|
||||
"video": "tikka-boti-pizza.mp4"
|
||||
},
|
||||
{
|
||||
"id": "peshawari-pizza",
|
||||
"name": "Peshawari Pizza",
|
||||
"description": "Naan-style pizza with tender meat, nuts, raisins and Peshawari spices.",
|
||||
"price": 119,
|
||||
"image": "peshawari-pizza.jpg",
|
||||
"video": "peshawari-pizza.mp4"
|
||||
},
|
||||
{
|
||||
"id": "veg-pizza",
|
||||
"name": "Veg Pizza",
|
||||
"description": "Vegetarian pizza loaded with fresh vegetables, cheese and tomato sauce.",
|
||||
"price": 109,
|
||||
"image": "veg-pizza.jpg",
|
||||
"isVegetarian": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "naan-roll",
|
||||
"name": "Naan Roll",
|
||||
"items": [
|
||||
{
|
||||
"id": "tikka-boti-roll",
|
||||
"name": "Tikka Boti Roll",
|
||||
"description": "Grilled chicken tikka wrapped in soft naan with mint chutney and onions.",
|
||||
"price": 99,
|
||||
"image": "tikka-boti-roll.jpg",
|
||||
"video": "tikka-boti-roll.mp4"
|
||||
},
|
||||
{
|
||||
"id": "kebab-roll",
|
||||
"name": "Kebab Roll",
|
||||
"description": "Spiced minced meat kebab wrapped in naan with chutney, salad and sauces.",
|
||||
"price": 99,
|
||||
"image": "kebab-roll.jpg",
|
||||
"video": "kebab-roll.mp4"
|
||||
},
|
||||
{
|
||||
"id": "falafel-roll",
|
||||
"name": "Falafel Roll",
|
||||
"description": "Crispy falafel wrapped in naan with vegetables, hummus and tangy sauces.",
|
||||
"price": 99,
|
||||
"image": "falafel-roll.jpg",
|
||||
"video": "falafel-roll.mp4",
|
||||
"isVegetarian": true
|
||||
},
|
||||
{
|
||||
"id": "paneer-roll",
|
||||
"name": "Paneer Roll",
|
||||
"description": "Grilled paneer cubes wrapped in naan with spices, chutney and fresh vegetables.",
|
||||
"price": 99,
|
||||
"image": "paneer-roll.jpg",
|
||||
"video": "paneer-roll.mp4",
|
||||
"isVegetarian": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sweets",
|
||||
"name": "Sweets / Mithai",
|
||||
"items": [
|
||||
{
|
||||
"id": "namakpare",
|
||||
"name": "Namak Paray",
|
||||
"description": "Crispy, savory fried flour snacks seasoned with carom seeds and salt.",
|
||||
"image": "namakpare.jpg",
|
||||
"video": "namakpare.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "shakar-paray",
|
||||
"name": "Shakar Paray",
|
||||
"description": "Sweet crispy flour bites coated in sugar syrup — a classic mithai snack.",
|
||||
"image": "shakar-paray.jpg",
|
||||
"video": "shakar-paray.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "jalebi",
|
||||
"name": "Jalebi",
|
||||
"description": "Crispy golden saffron spirals soaked in fragrant sugar syrup.",
|
||||
"image": "jalebi.jpg",
|
||||
"video": "jalebi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "gajar-halwa",
|
||||
"name": "Gajar Halwa",
|
||||
"description": "Sweet carrot pudding cooked slowly with milk, sugar, ghee and nuts.",
|
||||
"image": "gajar-halwa.jpg",
|
||||
"video": "gajar-halwa.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "gajar-barfi",
|
||||
"name": "Gajar Barfi",
|
||||
"description": "Rich carrot fudge made with milk, ghee and nuts — dense and aromatic.",
|
||||
"image": "gajar-barfi.jpg",
|
||||
"video": "gajar-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "habshi-halwa",
|
||||
"name": "Habshi Halwa",
|
||||
"description": "Dark, caramelised semolina halwa slow-cooked with ghee, milk and nuts.",
|
||||
"image": "habshi-halwa.jpg",
|
||||
"video": "habshi-halwa.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "gulab-jaman",
|
||||
"name": "Gulab Jamun",
|
||||
"description": "Soft milk-solid dumplings soaked in rose-cardamom sugar syrup.",
|
||||
"image": "gulab-jaman.jpg",
|
||||
"video": "gulab-jaman.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "lambay-gulab-jaman",
|
||||
"name": "Lambay Gulab Jamun",
|
||||
"description": "Elongated gulab jamun with extra syrup — a Shahi Sweets favourite.",
|
||||
"image": "lambay-gulab-jaman.jpg",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "cream-gulab-jaman",
|
||||
"name": "Cream Gulab Jamun",
|
||||
"description": "Gulab jamun filled with creamy centre, finished in fragrant syrup.",
|
||||
"image": "cream-gulab-jaman.jpg",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "ras-gulay",
|
||||
"name": "Ras Gulay",
|
||||
"description": "Spongy cottage-cheese balls in light sugar syrup — chilled and refreshing.",
|
||||
"image": "ras-gulay.jpg",
|
||||
"video": "ras-gulay.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "rasmalai",
|
||||
"name": "Rasmalai",
|
||||
"description": "Soft cheese dumplings soaked in chilled sweetened milk with cardamom and saffron.",
|
||||
"price": 45,
|
||||
"image": "rasmalai.jpg",
|
||||
"video": "rasmalai.mp4"
|
||||
},
|
||||
{
|
||||
"id": "cham-cham",
|
||||
"name": "Cham Cham",
|
||||
"description": "Oval Bengali sweet coated in coconut or pistachio — soft and milky.",
|
||||
"image": "cham-cham.jpg",
|
||||
"video": "cham-cham.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "paira",
|
||||
"name": "Paira",
|
||||
"description": "Traditional milk fudge sweet with a smooth, grainy texture.",
|
||||
"image": "paira.jpg",
|
||||
"video": "paira.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "laddu",
|
||||
"name": "Laddu",
|
||||
"description": "Round gram-flour and ghee sweet balls — festive and aromatic.",
|
||||
"image": "laddu.jpg",
|
||||
"video": "laddu.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "qalakand",
|
||||
"name": "Kalakand",
|
||||
"description": "Grainy milk cake sweet flavoured with cardamom — fresh and delicate.",
|
||||
"image": "qalakand.jpg",
|
||||
"video": "qalakand.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "patisa",
|
||||
"name": "Patisa",
|
||||
"description": "Flaky, layered soan papdi-style sweet that crumbles and melts on the tongue.",
|
||||
"image": "patisa.jpg",
|
||||
"video": "patisa.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "baisan-patisa",
|
||||
"name": "Besan Patisa",
|
||||
"description": "Gram-flour patisa with crisp layers and a nutty, buttery finish.",
|
||||
"image": "baisan-patisa.jpg",
|
||||
"video": "baisan-patisa.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "plain-barfi",
|
||||
"name": "Plain Barfi",
|
||||
"description": "Classic milk barfi set with sugar and cardamom — simple and elegant.",
|
||||
"image": "plain-barfi.jpg",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "badam-barfi",
|
||||
"name": "Badam Barfi",
|
||||
"description": "Rich almond barfi with ground nuts and a smooth, luxurious bite.",
|
||||
"image": "badam-barfi.jpg",
|
||||
"video": "badam-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "pistachio-barfi",
|
||||
"name": "Pistachio Barfi",
|
||||
"description": "Vibrant pistachio barfi — nutty, fragrant and beautifully green.",
|
||||
"image": "pistachio-barfi.jpg",
|
||||
"video": "pistachio-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "pink-barfi",
|
||||
"name": "Pink Barfi",
|
||||
"description": "Festive pink milk barfi with a soft texture and delicate sweetness.",
|
||||
"image": "pink-barfi.jpg",
|
||||
"video": "pink-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "coconut-barfi",
|
||||
"name": "Coconut Barfi",
|
||||
"description": "Coconut-forward barfi with tropical aroma and a chewy-soft bite.",
|
||||
"image": "coconut-barfi.jpg",
|
||||
"video": "coconut-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "chocolate-barfi",
|
||||
"name": "Chocolate Barfi",
|
||||
"description": "Milk barfi blended with cocoa — a modern twist on a classic sweet.",
|
||||
"image": "chocolate-barfi.jpg",
|
||||
"video": "chocolate-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "baisan-barfi",
|
||||
"name": "Besan Barfi",
|
||||
"description": "Roasted gram-flour barfi with ghee and sugar — warm and nutty.",
|
||||
"image": "baisan-barfi.jpg",
|
||||
"video": "baisan-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "milk-cake-plain",
|
||||
"name": "Milk Cake",
|
||||
"description": "Caramelised milk cake with a dense, fudgy centre and golden crust.",
|
||||
"image": "milk-cake-plain.jpg",
|
||||
"video": "milk-cake-plain.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "milk-cake-khajoor",
|
||||
"name": "Milk Cake Khajoor",
|
||||
"description": "Milk cake with dates — rich, chewy and naturally sweet.",
|
||||
"image": "milk-cake-khajoor.jpg",
|
||||
"video": "milk-cake-khajoor.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "milk-cake-akhrot",
|
||||
"name": "Milk Cake Akhrot",
|
||||
"description": "Milk cake studded with walnuts for extra crunch and depth.",
|
||||
"image": "milk-cake-akhrot.jpg",
|
||||
"video": "milk-cake-akhrot.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "kulfi",
|
||||
"name": "Kulfi",
|
||||
"description": "Creamy traditional frozen milk dessert with cardamom, pistachios and saffron.",
|
||||
"price": 39,
|
||||
"image": "kulfi.jpg",
|
||||
"video": "kulfi.mp4"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "drinks",
|
||||
"name": "Drinks",
|
||||
"items": [
|
||||
{
|
||||
"id": "masala-chai",
|
||||
"name": "Masala Chai",
|
||||
"description": "Traditional spiced tea brewed with milk, cardamom, ginger and aromatic spices.",
|
||||
"price": 39,
|
||||
"image": "masala-chai.jpg"
|
||||
},
|
||||
{
|
||||
"id": "mango-lassi",
|
||||
"name": "Mango Lassi",
|
||||
"description": "Refreshing sweet yogurt drink blended with ripe mango and cardamom.",
|
||||
"price": 45,
|
||||
"image": "mango-lassi.jpg",
|
||||
"video": "mango-lassi.mp4"
|
||||
},
|
||||
{
|
||||
"id": "coca-cola",
|
||||
"name": "Coca-Cola",
|
||||
"description": "Classic chilled cola soft drink.",
|
||||
"price": 25,
|
||||
"video": "coca-cola.mp4"
|
||||
},
|
||||
{
|
||||
"id": "pepsi-fanta",
|
||||
"name": "Pepsi / Fanta",
|
||||
"description": "Refreshing cola or orange flavored carbonated beverage.",
|
||||
"price": 25,
|
||||
"video": "pepsi-fanta.mp4"
|
||||
},
|
||||
{
|
||||
"id": "sprite-ramlosa",
|
||||
"name": "Sprite / Ramlösa",
|
||||
"description": "Crisp lemon-lime soda or sparkling mineral water.",
|
||||
"price": 25,
|
||||
"video": "sprite-ramlosa.mp4"
|
||||
},
|
||||
{
|
||||
"id": "energy-drink",
|
||||
"name": "Energy Drink",
|
||||
"description": "Caffeinated beverage for an instant energy boost.",
|
||||
"price": 29,
|
||||
"video": "energy-drink.mp4"
|
||||
},
|
||||
{
|
||||
"id": "juice",
|
||||
"name": "Juice",
|
||||
"description": "Fresh fruit juice, typically mango or other seasonal flavors.",
|
||||
"price": 20,
|
||||
"image": "mango-juice.jpg"
|
||||
},
|
||||
{
|
||||
"id": "coffee",
|
||||
"name": "Coffee",
|
||||
"description": "Freshly brewed hot coffee.",
|
||||
"price": 39,
|
||||
"image": "black-coffee.jpg"
|
||||
},
|
||||
{
|
||||
"id": "latte",
|
||||
"name": "Latte",
|
||||
"description": "Espresso coffee with steamed milk and a light layer of foam.",
|
||||
"price": 49,
|
||||
"image": "latte.jpg",
|
||||
"video": "latte.mp4"
|
||||
},
|
||||
{
|
||||
"id": "cappuccino",
|
||||
"name": "Cappuccino",
|
||||
"description": "Espresso topped with steamed milk and thick foam.",
|
||||
"price": 49,
|
||||
"image": "cappuccino.jpg"
|
||||
},
|
||||
{
|
||||
"id": "tea",
|
||||
"name": "Tea",
|
||||
"description": "Traditional hot black tea, served plain or with milk.",
|
||||
"price": 30,
|
||||
"video": "tea.mp4"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"isBaseline": true
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"id": "baseline",
|
||||
"label": "Current menu (baseline)",
|
||||
"createdAt": "2026-07-03T01:18:38.626Z",
|
||||
"trigger": "manual",
|
||||
"isBaseline": true,
|
||||
"categoryCount": 9,
|
||||
"dishCount": 73
|
||||
}
|
||||
]
|
||||
}
|
||||
+715
@@ -0,0 +1,715 @@
|
||||
[
|
||||
{
|
||||
"id": "street-food",
|
||||
"name": "Street Food & Starters",
|
||||
"items": [
|
||||
{
|
||||
"id": "samosa-aloo",
|
||||
"name": "Samosa Aloo Veg",
|
||||
"description": "Crispy fried triangular pastries filled with spiced potatoes and peas.",
|
||||
"price": 34,
|
||||
"image": "aloo-samosa.jpg",
|
||||
"video": "samosa-aloo.mp4"
|
||||
},
|
||||
{
|
||||
"id": "samosa-keema",
|
||||
"name": "Samosa Keema",
|
||||
"description": "Flaky pastries stuffed with spiced minced meat filling.",
|
||||
"price": 39,
|
||||
"image": "keema-samosa.jpg",
|
||||
"video": "samosa-keema.mp4"
|
||||
},
|
||||
{
|
||||
"id": "samosa-chat",
|
||||
"name": "Samosa Chat",
|
||||
"description": "Crispy samosas topped with spicy chickpeas, yogurt, chutneys and fresh herbs.",
|
||||
"price": 89,
|
||||
"image": "samosa-chaat.jpg",
|
||||
"video": "samosa-chaat.mp4"
|
||||
},
|
||||
{
|
||||
"id": "chana-chat",
|
||||
"name": "Chana Chat",
|
||||
"description": "Tangy spiced chickpeas mixed with potatoes, onions, tomatoes and chutneys.",
|
||||
"price": 69,
|
||||
"image": "chana-chaat.jpg",
|
||||
"video": "chana-chaat.mp4"
|
||||
},
|
||||
{
|
||||
"id": "panipuri",
|
||||
"name": "Panipuri / Golgappe",
|
||||
"description": "Crispy hollow puris filled with spiced chickpeas and potatoes, served with tangy tamarind water.",
|
||||
"price": 69,
|
||||
"image": "panipuri.jpg",
|
||||
"video": "panipuri.mp4"
|
||||
},
|
||||
{
|
||||
"id": "keema-naan-starter",
|
||||
"name": "Keema Naan",
|
||||
"description": "Soft naan bread stuffed with spiced minced meat, baked until golden.",
|
||||
"price": 75,
|
||||
"image": "keema-naan.jpg",
|
||||
"video": "keema-naan.mp4"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "vegetarian",
|
||||
"name": "Vegetarian",
|
||||
"items": [
|
||||
{
|
||||
"id": "palak-paneer",
|
||||
"name": "Palak Paneer",
|
||||
"description": "Cottage cheese cooked in a creamy spinach gravy with mild spices and aromatic herbs.",
|
||||
"price": 139,
|
||||
"image": "palak-paneer.jpg",
|
||||
"video": "palak-paneer.mp4",
|
||||
"isVegetarian": true
|
||||
},
|
||||
{
|
||||
"id": "shahi-paneer",
|
||||
"name": "Shahi Paneer",
|
||||
"description": "Soft cottage cheese in a rich, creamy cashew and tomato gravy with Indian spices.",
|
||||
"price": 139,
|
||||
"image": "shahi-paneer.jpg",
|
||||
"video": "shahi-paneer.mp4",
|
||||
"isVegetarian": true
|
||||
},
|
||||
{
|
||||
"id": "malai-kofta",
|
||||
"name": "Malai Kofta",
|
||||
"description": "Soft vegetable koftas simmered in a rich and creamy onion-tomato gravy with mild spices.",
|
||||
"price": 139,
|
||||
"image": "malai-kofta.jpg",
|
||||
"video": "malai-kofta.mp4",
|
||||
"isVegetarian": true
|
||||
},
|
||||
{
|
||||
"id": "daal-makhani",
|
||||
"name": "Daal Makhani",
|
||||
"description": "Slow-cooked black lentils in a buttery, creamy tomato gravy with aromatic spices.",
|
||||
"price": 139,
|
||||
"image": "daal-makhani.jpg",
|
||||
"video": "daal-makhani.mp4",
|
||||
"isVegetarian": true
|
||||
},
|
||||
{
|
||||
"id": "lahore-chana",
|
||||
"name": "Lahore Chana",
|
||||
"description": "Spiced chickpeas cooked in a tangy onion-tomato gravy with traditional Punjabi spices.",
|
||||
"price": 139,
|
||||
"image": "lahore-chana.jpg",
|
||||
"video": "lahore-chana.mp4",
|
||||
"isVegetarian": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "meat",
|
||||
"name": "Meat",
|
||||
"items": [
|
||||
{
|
||||
"id": "lamm-palak",
|
||||
"name": "Lamm Palak",
|
||||
"description": "Tender lamb cooked with fresh spinach in a mild, flavorful gravy.",
|
||||
"price": 179,
|
||||
"image": "lamm-palak.jpg",
|
||||
"video": "lamm-palak.mp4",
|
||||
"pricing": "standard"
|
||||
},
|
||||
{
|
||||
"id": "lamm-vindaloo",
|
||||
"name": "Lamm Vindaloo",
|
||||
"description": "Spicy and tangy lamb curry in a vinegar and chili-based sauce.",
|
||||
"price": 179,
|
||||
"image": "lamm-vindaloo.jpg",
|
||||
"video": "lamm-vindaloo.mp4"
|
||||
},
|
||||
{
|
||||
"id": "lamm-rogan-josh",
|
||||
"name": "Lamm Rogan Josh",
|
||||
"description": "Aromatic lamb curry simmered in a rich yogurt and Kashmiri spice gravy.",
|
||||
"price": 199,
|
||||
"image": "lamm-rogan-josh.jpg",
|
||||
"video": "lamm-rogan-josh.mp4"
|
||||
},
|
||||
{
|
||||
"id": "lamm-karahi",
|
||||
"name": "Lamm Karahi",
|
||||
"description": "Lamb pieces stir-fried in a wok with tomatoes, ginger, garlic and spices.",
|
||||
"price": 179,
|
||||
"image": "lamm-karahi.jpg",
|
||||
"video": "lamm-karahi.mp4"
|
||||
},
|
||||
{
|
||||
"id": "bong-nihari",
|
||||
"name": "Bong Nihari",
|
||||
"description": "Slow-cooked beef shank in a rich, aromatic gravy, traditionally served with naan.",
|
||||
"price": 199,
|
||||
"image": "bong-nihari.jpg",
|
||||
"video": "bong-nihari.mp4"
|
||||
},
|
||||
{
|
||||
"id": "paye",
|
||||
"name": "Paye",
|
||||
"description": "Slow-simmered lamb trotters in a thick, spicy and flavorful gravy.",
|
||||
"price": 149,
|
||||
"image": "paye.jpg",
|
||||
"video": "paye.mp4"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "burger-sandwich",
|
||||
"name": "Burger & Sandwich",
|
||||
"items": [
|
||||
{
|
||||
"id": "shahi-burger",
|
||||
"name": "Shahi Burger",
|
||||
"description": "Juicy spiced meat patty in a soft bun with special sauces, lettuce and tomatoes.",
|
||||
"price": 119,
|
||||
"image": "burger.jpg",
|
||||
"video": "shahi-burger.mp4",
|
||||
"pricing": "standard"
|
||||
},
|
||||
{
|
||||
"id": "shami-sandwich",
|
||||
"name": "Shami Sandwich Menu",
|
||||
"description": "Spiced minced meat shami kebab patties served in bread with chutney and onions.",
|
||||
"price": 99,
|
||||
"image": "sandwich.jpg",
|
||||
"video": "shami-sandwich.mp4",
|
||||
"pricing": "standard"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chicken",
|
||||
"name": "Chicken",
|
||||
"items": [
|
||||
{
|
||||
"id": "chicken-biryani",
|
||||
"name": "Chicken Biryani",
|
||||
"description": "Fragrant aged basmati rice layered with tender spiced chicken, saffron and caramelized onions.",
|
||||
"price": 149,
|
||||
"image": "chicken-biryani.jpg",
|
||||
"video": "chicken-biryani.mp4"
|
||||
},
|
||||
{
|
||||
"id": "chicken-tikka",
|
||||
"name": "Chicken Tikka",
|
||||
"description": "Boneless chicken pieces marinated in yogurt and spices, grilled in a tandoor.",
|
||||
"price": 149,
|
||||
"image": "chicken-tikka.jpg",
|
||||
"video": "chicken-tikka.mp4"
|
||||
},
|
||||
{
|
||||
"id": "chicken-karahi",
|
||||
"name": "Chicken Karahi",
|
||||
"description": "Wok-tossed chicken in a robust tomato, chili and ginger gravy.",
|
||||
"price": 149,
|
||||
"image": "chicken-karahi.jpg",
|
||||
"video": "chicken-karahi.mp4"
|
||||
},
|
||||
{
|
||||
"id": "lahore-sizzler",
|
||||
"name": "Lahore Sizzler",
|
||||
"description": "Sizzling platter of marinated chicken with vegetables and spicy sauces.",
|
||||
"price": 169,
|
||||
"image": "lahore-sizzler.jpg",
|
||||
"video": "lahore-sizzler.mp4"
|
||||
},
|
||||
{
|
||||
"id": "butter-chicken",
|
||||
"name": "Butter Chicken",
|
||||
"description": "Tender chicken in a creamy tomato and butter gravy with mild spices.",
|
||||
"price": 149,
|
||||
"image": "butter-chicken.jpg"
|
||||
},
|
||||
{
|
||||
"id": "chicken-haleem",
|
||||
"name": "Chicken Haleem",
|
||||
"description": "Slow-cooked shredded chicken with lentils, wheat and aromatic spices.",
|
||||
"price": 139,
|
||||
"image": "chicken-haleem.jpg",
|
||||
"video": "chicken-haleem.mp4"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pizza",
|
||||
"name": "Pizza",
|
||||
"items": [
|
||||
{
|
||||
"id": "lahore-pizza",
|
||||
"name": "Lahore Pizza",
|
||||
"description": "Pizza topped with spiced chicken, onions and special Lahori sauces on a crispy base.",
|
||||
"price": 119,
|
||||
"image": "lahore-pizza.jpg",
|
||||
"video": "lahore-pizza.mp4"
|
||||
},
|
||||
{
|
||||
"id": "kebab-pizza",
|
||||
"name": "Kebab Pizza",
|
||||
"description": "Pizza with minced meat kebab topping, cheese, onions and aromatic spices.",
|
||||
"price": 119,
|
||||
"image": "kebab-pizza.jpg",
|
||||
"video": "kebab-pizza.mp4"
|
||||
},
|
||||
{
|
||||
"id": "tikka-boti-pizza",
|
||||
"name": "Tikka Boti Pizza",
|
||||
"description": "Pizza featuring grilled chicken tikka, cheese, tomatoes and fresh herbs.",
|
||||
"price": 119,
|
||||
"image": "tikka-boti-pizza.jpg",
|
||||
"video": "tikka-boti-pizza.mp4"
|
||||
},
|
||||
{
|
||||
"id": "peshawari-pizza",
|
||||
"name": "Peshawari Pizza",
|
||||
"description": "Naan-style pizza with tender meat, nuts, raisins and Peshawari spices.",
|
||||
"price": 119,
|
||||
"image": "peshawari-pizza.jpg",
|
||||
"video": "peshawari-pizza.mp4"
|
||||
},
|
||||
{
|
||||
"id": "veg-pizza",
|
||||
"name": "Veg Pizza",
|
||||
"description": "Vegetarian pizza loaded with fresh vegetables, cheese and tomato sauce.",
|
||||
"price": 109,
|
||||
"image": "veg-pizza.jpg",
|
||||
"isVegetarian": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "naan-roll",
|
||||
"name": "Naan Roll",
|
||||
"items": [
|
||||
{
|
||||
"id": "tikka-boti-roll",
|
||||
"name": "Tikka Boti Roll",
|
||||
"description": "Grilled chicken tikka wrapped in soft naan with mint chutney and onions.",
|
||||
"price": 99,
|
||||
"image": "tikka-boti-roll.jpg",
|
||||
"video": "tikka-boti-roll.mp4"
|
||||
},
|
||||
{
|
||||
"id": "kebab-roll",
|
||||
"name": "Kebab Roll",
|
||||
"description": "Spiced minced meat kebab wrapped in naan with chutney, salad and sauces.",
|
||||
"price": 99,
|
||||
"image": "kebab-roll.jpg",
|
||||
"video": "kebab-roll.mp4"
|
||||
},
|
||||
{
|
||||
"id": "falafel-roll",
|
||||
"name": "Falafel Roll",
|
||||
"description": "Crispy falafel wrapped in naan with vegetables, hummus and tangy sauces.",
|
||||
"price": 99,
|
||||
"image": "falafel-roll.jpg",
|
||||
"video": "falafel-roll.mp4",
|
||||
"isVegetarian": true
|
||||
},
|
||||
{
|
||||
"id": "paneer-roll",
|
||||
"name": "Paneer Roll",
|
||||
"description": "Grilled paneer cubes wrapped in naan with spices, chutney and fresh vegetables.",
|
||||
"price": 99,
|
||||
"image": "paneer-roll.jpg",
|
||||
"video": "paneer-roll.mp4",
|
||||
"isVegetarian": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sweets",
|
||||
"name": "Sweets / Mithai",
|
||||
"items": [
|
||||
{
|
||||
"id": "namakpare",
|
||||
"name": "Namak Paray",
|
||||
"description": "Crispy, savory fried flour snacks seasoned with carom seeds and salt.",
|
||||
"image": "namakpare.jpg",
|
||||
"video": "namakpare.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "shakar-paray",
|
||||
"name": "Shakar Paray",
|
||||
"description": "Sweet crispy flour bites coated in sugar syrup — a classic mithai snack.",
|
||||
"image": "shakar-paray.jpg",
|
||||
"video": "shakar-paray.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "jalebi",
|
||||
"name": "Jalebi",
|
||||
"description": "Crispy golden saffron spirals soaked in fragrant sugar syrup.",
|
||||
"image": "jalebi.jpg",
|
||||
"video": "jalebi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "gajar-halwa",
|
||||
"name": "Gajar Halwa",
|
||||
"description": "Sweet carrot pudding cooked slowly with milk, sugar, ghee and nuts.",
|
||||
"image": "gajar-halwa.jpg",
|
||||
"video": "gajar-halwa.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "gajar-barfi",
|
||||
"name": "Gajar Barfi",
|
||||
"description": "Rich carrot fudge made with milk, ghee and nuts — dense and aromatic.",
|
||||
"image": "gajar-barfi.jpg",
|
||||
"video": "gajar-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "habshi-halwa",
|
||||
"name": "Habshi Halwa",
|
||||
"description": "Dark, caramelised semolina halwa slow-cooked with ghee, milk and nuts.",
|
||||
"image": "habshi-halwa.jpg",
|
||||
"video": "habshi-halwa.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "gulab-jaman",
|
||||
"name": "Gulab Jamun",
|
||||
"description": "Soft milk-solid dumplings soaked in rose-cardamom sugar syrup.",
|
||||
"image": "gulab-jaman.jpg",
|
||||
"video": "gulab-jaman.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "lambay-gulab-jaman",
|
||||
"name": "Lambay Gulab Jamun",
|
||||
"description": "Elongated gulab jamun with extra syrup — a Shahi Sweets favourite.",
|
||||
"image": "lambay-gulab-jaman.jpg",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "cream-gulab-jaman",
|
||||
"name": "Cream Gulab Jamun",
|
||||
"description": "Gulab jamun filled with creamy centre, finished in fragrant syrup.",
|
||||
"image": "cream-gulab-jaman.jpg",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "ras-gulay",
|
||||
"name": "Ras Gulay",
|
||||
"description": "Spongy cottage-cheese balls in light sugar syrup — chilled and refreshing.",
|
||||
"image": "ras-gulay.jpg",
|
||||
"video": "ras-gulay.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "rasmalai",
|
||||
"name": "Rasmalai",
|
||||
"description": "Soft cheese dumplings soaked in chilled sweetened milk with cardamom and saffron.",
|
||||
"price": 45,
|
||||
"image": "rasmalai.jpg",
|
||||
"video": "rasmalai.mp4"
|
||||
},
|
||||
{
|
||||
"id": "cham-cham",
|
||||
"name": "Cham Cham",
|
||||
"description": "Oval Bengali sweet coated in coconut or pistachio — soft and milky.",
|
||||
"image": "cham-cham.jpg",
|
||||
"video": "cham-cham.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "paira",
|
||||
"name": "Paira",
|
||||
"description": "Traditional milk fudge sweet with a smooth, grainy texture.",
|
||||
"image": "paira.jpg",
|
||||
"video": "paira.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "laddu",
|
||||
"name": "Laddu",
|
||||
"description": "Round gram-flour and ghee sweet balls — festive and aromatic.",
|
||||
"image": "laddu.jpg",
|
||||
"video": "laddu.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "qalakand",
|
||||
"name": "Kalakand",
|
||||
"description": "Grainy milk cake sweet flavoured with cardamom — fresh and delicate.",
|
||||
"image": "qalakand.jpg",
|
||||
"video": "qalakand.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "patisa",
|
||||
"name": "Patisa",
|
||||
"description": "Flaky, layered soan papdi-style sweet that crumbles and melts on the tongue.",
|
||||
"image": "patisa.jpg",
|
||||
"video": "patisa.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "baisan-patisa",
|
||||
"name": "Besan Patisa",
|
||||
"description": "Gram-flour patisa with crisp layers and a nutty, buttery finish.",
|
||||
"image": "baisan-patisa.jpg",
|
||||
"video": "baisan-patisa.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "plain-barfi",
|
||||
"name": "Plain Barfi",
|
||||
"description": "Classic milk barfi set with sugar and cardamom — simple and elegant.",
|
||||
"image": "plain-barfi.jpg",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "badam-barfi",
|
||||
"name": "Badam Barfi",
|
||||
"description": "Rich almond barfi with ground nuts and a smooth, luxurious bite.",
|
||||
"image": "badam-barfi.jpg",
|
||||
"video": "badam-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "pistachio-barfi",
|
||||
"name": "Pistachio Barfi",
|
||||
"description": "Vibrant pistachio barfi — nutty, fragrant and beautifully green.",
|
||||
"image": "pistachio-barfi.jpg",
|
||||
"video": "pistachio-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "pink-barfi",
|
||||
"name": "Pink Barfi",
|
||||
"description": "Festive pink milk barfi with a soft texture and delicate sweetness.",
|
||||
"image": "pink-barfi.jpg",
|
||||
"video": "pink-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "coconut-barfi",
|
||||
"name": "Coconut Barfi",
|
||||
"description": "Coconut-forward barfi with tropical aroma and a chewy-soft bite.",
|
||||
"image": "coconut-barfi.jpg",
|
||||
"video": "coconut-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "chocolate-barfi",
|
||||
"name": "Chocolate Barfi",
|
||||
"description": "Milk barfi blended with cocoa — a modern twist on a classic sweet.",
|
||||
"image": "chocolate-barfi.jpg",
|
||||
"video": "chocolate-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "baisan-barfi",
|
||||
"name": "Besan Barfi",
|
||||
"description": "Roasted gram-flour barfi with ghee and sugar — warm and nutty.",
|
||||
"image": "baisan-barfi.jpg",
|
||||
"video": "baisan-barfi.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "milk-cake-plain",
|
||||
"name": "Milk Cake",
|
||||
"description": "Caramelised milk cake with a dense, fudgy centre and golden crust.",
|
||||
"image": "milk-cake-plain.jpg",
|
||||
"video": "milk-cake-plain.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "milk-cake-khajoor",
|
||||
"name": "Milk Cake Khajoor",
|
||||
"description": "Milk cake with dates — rich, chewy and naturally sweet.",
|
||||
"image": "milk-cake-khajoor.jpg",
|
||||
"video": "milk-cake-khajoor.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "milk-cake-akhrot",
|
||||
"name": "Milk Cake Akhrot",
|
||||
"description": "Milk cake studded with walnuts for extra crunch and depth.",
|
||||
"image": "milk-cake-akhrot.jpg",
|
||||
"video": "milk-cake-akhrot.mp4",
|
||||
"pricing": "weight",
|
||||
"price": 90,
|
||||
"pricePerHalfKg": 90,
|
||||
"pricePerKg": 179
|
||||
},
|
||||
{
|
||||
"id": "kulfi",
|
||||
"name": "Kulfi",
|
||||
"description": "Creamy traditional frozen milk dessert with cardamom, pistachios and saffron.",
|
||||
"price": 39,
|
||||
"image": "kulfi.jpg",
|
||||
"video": "kulfi.mp4"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "drinks",
|
||||
"name": "Drinks",
|
||||
"items": [
|
||||
{
|
||||
"id": "masala-chai",
|
||||
"name": "Masala Chai",
|
||||
"description": "Traditional spiced tea brewed with milk, cardamom, ginger and aromatic spices.",
|
||||
"price": 39,
|
||||
"image": "masala-chai.jpg"
|
||||
},
|
||||
{
|
||||
"id": "mango-lassi",
|
||||
"name": "Mango Lassi",
|
||||
"description": "Refreshing sweet yogurt drink blended with ripe mango and cardamom.",
|
||||
"price": 45,
|
||||
"image": "mango-lassi.jpg",
|
||||
"video": "mango-lassi.mp4"
|
||||
},
|
||||
{
|
||||
"id": "coca-cola",
|
||||
"name": "Coca-Cola",
|
||||
"description": "Classic chilled cola soft drink.",
|
||||
"price": 25,
|
||||
"video": "coca-cola.mp4"
|
||||
},
|
||||
{
|
||||
"id": "pepsi-fanta",
|
||||
"name": "Pepsi / Fanta",
|
||||
"description": "Refreshing cola or orange flavored carbonated beverage.",
|
||||
"price": 25,
|
||||
"video": "pepsi-fanta.mp4"
|
||||
},
|
||||
{
|
||||
"id": "sprite-ramlosa",
|
||||
"name": "Sprite / Ramlösa",
|
||||
"description": "Crisp lemon-lime soda or sparkling mineral water.",
|
||||
"price": 25,
|
||||
"video": "sprite-ramlosa.mp4"
|
||||
},
|
||||
{
|
||||
"id": "energy-drink",
|
||||
"name": "Energy Drink",
|
||||
"description": "Caffeinated beverage for an instant energy boost.",
|
||||
"price": 29,
|
||||
"video": "energy-drink.mp4"
|
||||
},
|
||||
{
|
||||
"id": "juice",
|
||||
"name": "Juice",
|
||||
"description": "Fresh fruit juice, typically mango or other seasonal flavors.",
|
||||
"price": 20,
|
||||
"image": "mango-juice.jpg"
|
||||
},
|
||||
{
|
||||
"id": "coffee",
|
||||
"name": "Coffee",
|
||||
"description": "Freshly brewed hot coffee.",
|
||||
"price": 39,
|
||||
"image": "black-coffee.jpg"
|
||||
},
|
||||
{
|
||||
"id": "latte",
|
||||
"name": "Latte",
|
||||
"description": "Espresso coffee with steamed milk and a light layer of foam.",
|
||||
"price": 49,
|
||||
"image": "latte.jpg",
|
||||
"video": "latte.mp4"
|
||||
},
|
||||
{
|
||||
"id": "cappuccino",
|
||||
"name": "Cappuccino",
|
||||
"description": "Espresso topped with steamed milk and thick foam.",
|
||||
"price": 49,
|
||||
"image": "cappuccino.jpg"
|
||||
},
|
||||
{
|
||||
"id": "tea",
|
||||
"name": "Tea",
|
||||
"description": "Traditional hot black tea, served plain or with milk.",
|
||||
"price": 30,
|
||||
"video": "tea.mp4"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Google accounts allowed to access Menu Management (/admin). */
|
||||
|
||||
const MENU_MANAGER_EMAILS = new Set([
|
||||
'imraswe@gmail.com',
|
||||
'zeeshanatnorth@gmail.com',
|
||||
]);
|
||||
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isMenuManagerEmail(email: string | null | undefined): boolean {
|
||||
if (!email) return false;
|
||||
return MENU_MANAGER_EMAILS.has(normalizeEmail(email));
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { OrderLine } from '../shared/order-line';
|
||||
import type { BookingMode, EventTypeId } from './event-types';
|
||||
|
||||
export type BranchLocation = 'askim' | 'backaplan' | '';
|
||||
|
||||
export interface BookingDetails {
|
||||
bookingMode: BookingMode;
|
||||
eventType: EventTypeId | '';
|
||||
eventTypeOther: string;
|
||||
location: BranchLocation;
|
||||
date: string;
|
||||
time: string;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
export type BookingMode = 'table' | 'event';
|
||||
|
||||
export type EventTypeId =
|
||||
| 'birthday'
|
||||
| 'wedding'
|
||||
| 'engagement'
|
||||
| 'anniversary'
|
||||
| 'baby-shower'
|
||||
| 'graduation'
|
||||
| 'office-party'
|
||||
| 'corporate-lunch'
|
||||
| 'family-gathering'
|
||||
| 'religious-celebration'
|
||||
| 'farewell'
|
||||
| 'retirement'
|
||||
| 'bridal-shower'
|
||||
| 'holiday-party'
|
||||
| 'other';
|
||||
|
||||
export interface BookingEventType {
|
||||
id: EventTypeId;
|
||||
icon:
|
||||
| 'cake'
|
||||
| 'heart'
|
||||
| 'gem'
|
||||
| 'sparkles'
|
||||
| 'baby'
|
||||
| 'graduation-cap'
|
||||
| 'briefcase'
|
||||
| 'users'
|
||||
| 'party-popper'
|
||||
| 'plane'
|
||||
| 'award'
|
||||
| 'gift'
|
||||
| 'tree-pine'
|
||||
| 'more-horizontal';
|
||||
}
|
||||
|
||||
export const BOOKING_EVENT_TYPES: readonly BookingEventType[] = [
|
||||
{ id: 'birthday', icon: 'cake' },
|
||||
{ id: 'wedding', icon: 'heart' },
|
||||
{ id: 'engagement', icon: 'gem' },
|
||||
{ id: 'anniversary', icon: 'sparkles' },
|
||||
{ id: 'baby-shower', icon: 'baby' },
|
||||
{ id: 'graduation', icon: 'graduation-cap' },
|
||||
{ id: 'office-party', icon: 'briefcase' },
|
||||
{ id: 'corporate-lunch', icon: 'users' },
|
||||
{ id: 'family-gathering', icon: 'users' },
|
||||
{ id: 'religious-celebration', icon: 'sparkles' },
|
||||
{ id: 'farewell', icon: 'plane' },
|
||||
{ id: 'retirement', icon: 'award' },
|
||||
{ id: 'bridal-shower', icon: 'gift' },
|
||||
{ id: 'holiday-party', icon: 'tree-pine' },
|
||||
{ id: 'other', icon: 'more-horizontal' },
|
||||
] as const;
|
||||
|
||||
export const TABLE_GUEST_OPTIONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'] as const;
|
||||
|
||||
export const EVENT_GUEST_OPTIONS = [
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'10',
|
||||
'10-15',
|
||||
'16-25',
|
||||
'26-40',
|
||||
'41-60',
|
||||
'61-80',
|
||||
'81-100',
|
||||
'100+',
|
||||
] as const;
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { BookingDetails } from './entities';
|
||||
|
||||
export function isBookingComplete(booking: BookingDetails): boolean {
|
||||
const hasEventType =
|
||||
booking.bookingMode === 'table' ||
|
||||
(booking.eventType !== '' &&
|
||||
(booking.eventType !== 'other' || booking.eventTypeOther.trim().length > 0));
|
||||
|
||||
return !!(
|
||||
booking.location &&
|
||||
booking.date &&
|
||||
booking.time &&
|
||||
booking.guests &&
|
||||
booking.name &&
|
||||
booking.phone
|
||||
booking.phone &&
|
||||
hasEventType
|
||||
);
|
||||
}
|
||||
+62
-3
@@ -1,11 +1,70 @@
|
||||
import { PICKUP_LEAD_TIME_MINUTES } from '@/domain/shared/constants';
|
||||
|
||||
export type FulfillmentMode = 'pickup' | 'delivery';
|
||||
|
||||
export interface PickupInquiryDetails {
|
||||
export type InquiryBranch = 'askim' | 'backaplan' | '';
|
||||
|
||||
export const DEFAULT_INQUIRY_BRANCH: InquiryBranch = 'backaplan';
|
||||
|
||||
export type InquiryPreferredDate = 'today' | 'tomorrow' | '';
|
||||
|
||||
export type InquiryScheduleValidation =
|
||||
| 'ok'
|
||||
| 'incomplete'
|
||||
| 'too_soon'
|
||||
| 'invalid_time';
|
||||
|
||||
export interface InquirySchedule {
|
||||
preferredDate: InquiryPreferredDate;
|
||||
preferredTime: string;
|
||||
}
|
||||
|
||||
export interface PickupInquiryDetails extends InquirySchedule {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
branch: InquiryBranch;
|
||||
}
|
||||
|
||||
export interface DeliveryInquiryDetails extends PickupInquiryDetails {
|
||||
address: string;
|
||||
preferredTime: string;
|
||||
}
|
||||
|
||||
export function isInquiryBranchOnlineEnabled(branch: InquiryBranch): boolean {
|
||||
return branch === 'backaplan';
|
||||
}
|
||||
|
||||
export function getMinInquiryTimeForToday(now: Date = new Date()): string {
|
||||
const min = new Date(now.getTime() + PICKUP_LEAD_TIME_MINUTES * 60 * 1000);
|
||||
return `${String(min.getHours()).padStart(2, '0')}:${String(min.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function validateInquirySchedule(
|
||||
schedule: InquirySchedule,
|
||||
now: Date = new Date(),
|
||||
): InquiryScheduleValidation {
|
||||
const hasDate = schedule.preferredDate === 'today' || schedule.preferredDate === 'tomorrow';
|
||||
const hasTime = schedule.preferredTime.trim().length > 0;
|
||||
|
||||
if (!hasDate && !hasTime) return 'ok';
|
||||
if (hasDate !== hasTime) return 'incomplete';
|
||||
|
||||
const [hours, minutes] = schedule.preferredTime.split(':').map(Number);
|
||||
if (Number.isNaN(hours) || Number.isNaN(minutes)) return 'invalid_time';
|
||||
|
||||
const scheduled = new Date(now);
|
||||
if (schedule.preferredDate === 'tomorrow') {
|
||||
scheduled.setDate(scheduled.getDate() + 1);
|
||||
}
|
||||
scheduled.setHours(hours, minutes, 0, 0);
|
||||
|
||||
const minTime = new Date(now.getTime() + PICKUP_LEAD_TIME_MINUTES * 60 * 1000);
|
||||
if (scheduled.getTime() < minTime.getTime()) return 'too_soon';
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export function hasInquirySchedule(schedule: InquirySchedule): boolean {
|
||||
return validateInquirySchedule(schedule) === 'ok' &&
|
||||
schedule.preferredDate !== '' &&
|
||||
schedule.preferredTime.trim() !== '';
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Göteborg municipality postcodes used for delivery validation. */
|
||||
const GOTHENBURG_POSTCODE_PREFIXES = ['41', '42', '43'] as const;
|
||||
|
||||
const GOTHENBURG_NAME_PATTERNS = [
|
||||
'göteborg',
|
||||
'gothenburg',
|
||||
'goteborg',
|
||||
'göteborgs stad',
|
||||
'goteborgs stad',
|
||||
] as const;
|
||||
|
||||
export interface DeliverableAddressFields {
|
||||
city?: string;
|
||||
municipality?: string;
|
||||
postcode?: string;
|
||||
countryCode?: string;
|
||||
formatted?: string;
|
||||
}
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function matchesGothenburgName(value: string): boolean {
|
||||
const normalized = normalize(value);
|
||||
return GOTHENBURG_NAME_PATTERNS.some((pattern) => normalized.includes(pattern));
|
||||
}
|
||||
|
||||
function hasGothenburgPostcode(postcode?: string): boolean {
|
||||
if (!postcode) return false;
|
||||
const digits = postcode.replace(/\s/g, '');
|
||||
return GOTHENBURG_POSTCODE_PREFIXES.some((prefix) => digits.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function isGothenburgDeliveryAddress(fields: DeliverableAddressFields): boolean {
|
||||
if (fields.countryCode && normalize(fields.countryCode) !== 'se') return false;
|
||||
|
||||
if (matchesGothenburgName(fields.city ?? '')) return true;
|
||||
if (matchesGothenburgName(fields.municipality ?? '')) return true;
|
||||
if (hasGothenburgPostcode(fields.postcode)) return true;
|
||||
|
||||
const formatted = normalize(fields.formatted ?? '');
|
||||
if (
|
||||
formatted.includes('göteborg') ||
|
||||
formatted.includes('gothenburg') ||
|
||||
formatted.includes('goteborg')
|
||||
) {
|
||||
return (
|
||||
hasGothenburgPostcode(fields.postcode) ||
|
||||
/\b41\d{3}\b|\b42\d{3}\b|\b43\d{3}\b/.test(formatted)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MenuCategory } from './entities';
|
||||
|
||||
export type MenuVersionTrigger = 'auto' | 'manual' | 'restore';
|
||||
|
||||
export interface MenuVersionMeta {
|
||||
id: string;
|
||||
label: string;
|
||||
createdAt: string;
|
||||
trigger: MenuVersionTrigger;
|
||||
categoryCount: number;
|
||||
dishCount: number;
|
||||
isBaseline: boolean;
|
||||
}
|
||||
|
||||
export interface MenuVersionSnapshot {
|
||||
id: string;
|
||||
label: string;
|
||||
createdAt: string;
|
||||
trigger: MenuVersionTrigger;
|
||||
categories: MenuCategory[];
|
||||
isBaseline: boolean;
|
||||
}
|
||||
|
||||
export interface MenuVersionListItem extends MenuVersionMeta {
|
||||
matchesLive: boolean;
|
||||
}
|
||||
|
||||
export const MENU_BASELINE_VERSION_ID = 'baseline' as const;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const ADMIN_SESSION_COOKIE = 'shahi_admin_session';
|
||||
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function getSessionSecret(): string {
|
||||
return process.env.ADMIN_SESSION_SECRET ?? 'shahi-kitchen-admin-dev-secret';
|
||||
}
|
||||
|
||||
export function createAdminSessionToken(): string {
|
||||
const issuedAt = Date.now().toString();
|
||||
const payload = `admin:${issuedAt}`;
|
||||
const signature = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
|
||||
return `${payload}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyAdminSessionToken(token: string | undefined | null): boolean {
|
||||
if (!token) return false;
|
||||
|
||||
const [payload, signature] = token.split('.');
|
||||
if (!payload || !signature) return false;
|
||||
|
||||
const expected = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
|
||||
const sigBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (sigBuffer.length !== expectedBuffer.length) return false;
|
||||
if (!timingSafeEqual(sigBuffer, expectedBuffer)) return false;
|
||||
|
||||
const [, issuedAtRaw] = payload.split(':');
|
||||
const issuedAt = Number(issuedAtRaw);
|
||||
if (!Number.isFinite(issuedAt)) return false;
|
||||
|
||||
return Date.now() - issuedAt <= SESSION_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
export async function isAdminAuthenticated(): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(ADMIN_SESSION_COOKIE)?.value;
|
||||
return verifyAdminSessionToken(token);
|
||||
}
|
||||
|
||||
export function getAdminSessionCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: SESSION_MAX_AGE_MS / 1000,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const CUSTOMER_SESSION_COOKIE = 'shahi_customer_session';
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface CustomerSession {
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
function getSessionSecret(): string {
|
||||
return process.env.CUSTOMER_SESSION_SECRET ?? 'shahi-kitchen-customer-dev-secret';
|
||||
}
|
||||
|
||||
function encodeEmail(email: string): string {
|
||||
return Buffer.from(email, 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function decodeEmail(encoded: string): string | null {
|
||||
try {
|
||||
return Buffer.from(encoded, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createCustomerSessionToken(session: CustomerSession): string {
|
||||
const issuedAt = Date.now().toString();
|
||||
const namePart = session.name ? `:${encodeEmail(session.name)}` : '';
|
||||
const payload = `customer:${encodeEmail(session.email)}${namePart}:${issuedAt}`;
|
||||
const signature = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
|
||||
return `${payload}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyCustomerSessionToken(token: string | undefined | null): CustomerSession | null {
|
||||
if (!token) return null;
|
||||
|
||||
const [payload, signature] = token.split('.');
|
||||
if (!payload || !signature) return null;
|
||||
|
||||
const expected = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
|
||||
const sigBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (sigBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(sigBuffer, expectedBuffer)) return null;
|
||||
|
||||
const parts = payload.split(':');
|
||||
if (parts[0] !== 'customer' || parts.length < 3) return null;
|
||||
|
||||
const issuedAt = Number(parts[parts.length - 1]);
|
||||
if (!Number.isFinite(issuedAt) || Date.now() - issuedAt > SESSION_MAX_AGE_MS) return null;
|
||||
|
||||
const email = decodeEmail(parts[1]);
|
||||
if (!email) return null;
|
||||
|
||||
let name: string | undefined;
|
||||
if (parts.length === 4) {
|
||||
name = decodeEmail(parts[2]) ?? undefined;
|
||||
}
|
||||
|
||||
return { email, name };
|
||||
}
|
||||
|
||||
export async function getCustomerSession(): Promise<CustomerSession | null> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(CUSTOMER_SESSION_COOKIE)?.value;
|
||||
return verifyCustomerSessionToken(token);
|
||||
}
|
||||
|
||||
export function getCustomerSessionCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: SESSION_MAX_AGE_MS / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function getClearedCustomerSessionCookieOptions() {
|
||||
return {
|
||||
...getCustomerSessionCookieOptions(),
|
||||
maxAge: 0,
|
||||
expires: new Date(0),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo';
|
||||
|
||||
export function isGoogleOAuthConfigured(): boolean {
|
||||
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
export function getSiteOrigin(): string {
|
||||
return process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
|
||||
}
|
||||
|
||||
export function getGoogleRedirectUri(): string {
|
||||
return `${getSiteOrigin()}/api/auth/callback/google`;
|
||||
}
|
||||
|
||||
function getOAuthStateSecret(): string {
|
||||
return process.env.CUSTOMER_SESSION_SECRET ?? 'shahi-kitchen-customer-dev-secret';
|
||||
}
|
||||
|
||||
export function createOAuthState(): string {
|
||||
const nonce = randomBytes(16).toString('hex');
|
||||
const signature = createHmac('sha256', getOAuthStateSecret()).update(nonce).digest('hex');
|
||||
return `${nonce}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyOAuthState(state: string | null | undefined): boolean {
|
||||
if (!state) return false;
|
||||
const [nonce, signature] = state.split('.');
|
||||
if (!nonce || !signature) return false;
|
||||
|
||||
const expected = createHmac('sha256', getOAuthStateSecret()).update(nonce).digest('hex');
|
||||
const sigBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (sigBuffer.length !== expectedBuffer.length) return false;
|
||||
return timingSafeEqual(sigBuffer, expectedBuffer);
|
||||
}
|
||||
|
||||
export function buildGoogleAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: process.env.GOOGLE_CLIENT_ID!,
|
||||
redirect_uri: getGoogleRedirectUri(),
|
||||
response_type: 'code',
|
||||
scope: 'openid email profile',
|
||||
access_type: 'online',
|
||||
prompt: 'select_account',
|
||||
state,
|
||||
});
|
||||
|
||||
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
interface GoogleTokenResponse {
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface GoogleUserInfo {
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function fetchGoogleUserFromCode(
|
||||
code: string,
|
||||
): Promise<{ email: string; name?: string } | null> {
|
||||
const tokenResponse = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: process.env.GOOGLE_CLIENT_ID!,
|
||||
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
redirect_uri: getGoogleRedirectUri(),
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as GoogleTokenResponse;
|
||||
if (!tokenResponse.ok || !tokenData.access_token) return null;
|
||||
|
||||
const userResponse = await fetch(GOOGLE_USERINFO_URL, {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||
});
|
||||
|
||||
const userData = (await userResponse.json()) as GoogleUserInfo;
|
||||
if (!userResponse.ok || !userData.email || userData.email_verified === false) return null;
|
||||
|
||||
return {
|
||||
email: userData.email,
|
||||
name: userData.name,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const OAUTH_RETURN_COOKIE = 'shahi_google_oauth_return_to';
|
||||
const DEFAULT_RETURN_PATH = '/account';
|
||||
const SAFE_PATH_PATTERN = /^\/[a-zA-Z0-9/_-]*$/;
|
||||
|
||||
export function getOAuthReturnCookieName(): string {
|
||||
return OAUTH_RETURN_COOKIE;
|
||||
}
|
||||
|
||||
/** Only same-origin relative paths (optionally with query) are allowed after OAuth. */
|
||||
export function sanitizeOAuthReturnPath(value: string | null | undefined): string {
|
||||
if (!value) return DEFAULT_RETURN_PATH;
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith('/') || trimmed.startsWith('//') || trimmed.includes('://')) {
|
||||
return DEFAULT_RETURN_PATH;
|
||||
}
|
||||
|
||||
const withoutHash = trimmed.split('#')[0] ?? trimmed;
|
||||
const queryIndex = withoutHash.indexOf('?');
|
||||
const pathname = queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex);
|
||||
const search = queryIndex === -1 ? '' : withoutHash.slice(queryIndex);
|
||||
|
||||
if (!pathname || !SAFE_PATH_PATTERN.test(pathname)) {
|
||||
return DEFAULT_RETURN_PATH;
|
||||
}
|
||||
|
||||
return `${pathname}${search}`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { requireMenuManager } from './require-menu-manager';
|
||||
|
||||
/** Menu Management routes — requires an allowlisted Google customer session. */
|
||||
export async function requireAdmin(): Promise<import('next/server').NextResponse | null> {
|
||||
return requireMenuManager();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isMenuManagerEmail } from '@/domain/auth/menu-managers';
|
||||
import { getCustomerSession } from './customer-session';
|
||||
|
||||
export async function getMenuManagerSession() {
|
||||
const session = await getCustomerSession();
|
||||
if (!session || !isMenuManagerEmail(session.email)) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function requireMenuManager(): Promise<NextResponse | null> {
|
||||
const session = await getMenuManagerSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { isGothenburgDeliveryAddress } from '@/domain/delivery/gothenburg-address';
|
||||
import {
|
||||
disableGeoapifyAutocomplete,
|
||||
isGeoapifyApiKeyConfigured,
|
||||
isGeoapifyAutocompleteDisabled,
|
||||
mapGeoapifyHttpStatus,
|
||||
type GeoapifyUnavailableReason,
|
||||
} from './geoapify-availability';
|
||||
import {
|
||||
getCachedGeoapifyFeatures,
|
||||
getInFlightGeoapifyRequest,
|
||||
setCachedGeoapifyFeatures,
|
||||
trackInFlightGeoapifyRequest,
|
||||
waitForGeoapifyRateLimit,
|
||||
} from './geoapify-request-cache';
|
||||
import type { GeoapifyAutocompleteResponse, GeoapifyFeature } from './geoapify-types';
|
||||
|
||||
/** Backaplan branch — bias autocomplete toward Gothenburg. */
|
||||
const GOTHENBURG_BIAS = 'proximity:11.944,57.699';
|
||||
|
||||
/** Approximate Göteborg municipality bounding box (minLon,minLat,maxLon,maxLat). */
|
||||
const GOTHENBURG_RECT_FILTER = 'rect:11.74,57.60,12.15,57.82';
|
||||
|
||||
export type GeoapifyAutocompleteResult =
|
||||
| { status: 'ok'; features: GeoapifyFeature[] }
|
||||
| { status: 'unavailable'; reason: GeoapifyUnavailableReason; features: [] };
|
||||
|
||||
export function isDeliverableGeoapifyFeature(feature: GeoapifyFeature): boolean {
|
||||
const { properties } = feature;
|
||||
return isGothenburgDeliveryAddress({
|
||||
city: properties.city,
|
||||
municipality: properties.municipality,
|
||||
postcode: properties.postcode,
|
||||
countryCode: properties.country_code,
|
||||
formatted: properties.formatted,
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldUseGeoapifyAutocomplete(): boolean {
|
||||
return isGeoapifyApiKeyConfigured() && !isGeoapifyAutocompleteDisabled();
|
||||
}
|
||||
|
||||
function unavailable(reason: GeoapifyUnavailableReason): GeoapifyAutocompleteResult {
|
||||
disableGeoapifyAutocomplete();
|
||||
return { status: 'unavailable', reason, features: [] };
|
||||
}
|
||||
|
||||
async function requestGeoapifyAddressSuggestions(
|
||||
query: string,
|
||||
lang: 'sv' | 'en',
|
||||
): Promise<GeoapifyFeature[]> {
|
||||
const cached = getCachedGeoapifyFeatures(query, lang);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlight = getInFlightGeoapifyRequest(query, lang);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const request = (async () => {
|
||||
await waitForGeoapifyRateLimit();
|
||||
|
||||
const apiKey = process.env.NEXT_PUBLIC_GEOAPIFY_API_KEY!.trim();
|
||||
const params = new URLSearchParams({
|
||||
text: query.trim(),
|
||||
format: 'geojson',
|
||||
lang,
|
||||
limit: '8',
|
||||
filter: `countrycode:se|${GOTHENBURG_RECT_FILTER}`,
|
||||
bias: GOTHENBURG_BIAS,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.geoapify.com/v1/geocode/autocomplete?${params.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new GeoapifyRequestError(mapGeoapifyHttpStatus(response.status));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GeoapifyAutocompleteResponse;
|
||||
const features = (data.features ?? []).filter(isDeliverableGeoapifyFeature);
|
||||
setCachedGeoapifyFeatures(query, lang, features);
|
||||
return features;
|
||||
})();
|
||||
|
||||
return trackInFlightGeoapifyRequest(query, lang, request);
|
||||
}
|
||||
|
||||
class GeoapifyRequestError extends Error {
|
||||
constructor(public readonly reason: GeoapifyUnavailableReason) {
|
||||
super(reason);
|
||||
this.name = 'GeoapifyRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGeoapifyAddressSuggestions(
|
||||
query: string,
|
||||
lang: 'sv' | 'en' = 'sv',
|
||||
): Promise<GeoapifyAutocompleteResult> {
|
||||
if (!isGeoapifyApiKeyConfigured()) {
|
||||
return unavailable('missing_key');
|
||||
}
|
||||
|
||||
if (isGeoapifyAutocompleteDisabled()) {
|
||||
return unavailable('quota_exceeded');
|
||||
}
|
||||
|
||||
if (query.trim().length < 3) {
|
||||
return { status: 'ok', features: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const features = await requestGeoapifyAddressSuggestions(query, lang);
|
||||
return { status: 'ok', features };
|
||||
} catch (error) {
|
||||
if (error instanceof GeoapifyRequestError) {
|
||||
return unavailable(error.reason);
|
||||
}
|
||||
return unavailable('network');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
const SESSION_STORAGE_KEY = 'shahi-geoapify-disabled';
|
||||
|
||||
export type GeoapifyUnavailableReason =
|
||||
| 'missing_key'
|
||||
| 'quota_exceeded'
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'rate_limited'
|
||||
| 'payment_required'
|
||||
| 'network'
|
||||
| 'server_error';
|
||||
|
||||
export function isGeoapifyApiKeyConfigured(): boolean {
|
||||
const key = process.env.NEXT_PUBLIC_GEOAPIFY_API_KEY;
|
||||
return typeof key === 'string' && key.trim().length > 0;
|
||||
}
|
||||
|
||||
export function isGeoapifyAutocompleteDisabled(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return sessionStorage.getItem(SESSION_STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function disableGeoapifyAutocomplete(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, '1');
|
||||
} catch {
|
||||
// sessionStorage may be unavailable in private mode
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGeoapifyHttpStatus(status: number): GeoapifyUnavailableReason {
|
||||
switch (status) {
|
||||
case 401:
|
||||
return 'unauthorized';
|
||||
case 402:
|
||||
return 'payment_required';
|
||||
case 403:
|
||||
return 'forbidden';
|
||||
case 429:
|
||||
return 'rate_limited';
|
||||
default:
|
||||
if (status >= 500) return 'server_error';
|
||||
return 'quota_exceeded';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { GeoapifyFeature } from './geoapify-types';
|
||||
|
||||
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
const MAX_MEMORY_ENTRIES = 80;
|
||||
const MAX_SESSION_ENTRIES = 40;
|
||||
const MIN_REQUEST_INTERVAL_MS = 750;
|
||||
const SESSION_CACHE_KEY = 'shahi-geoapify-cache';
|
||||
|
||||
interface CacheEntry {
|
||||
features: GeoapifyFeature[];
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
const memoryCache = new Map<string, CacheEntry>();
|
||||
const inFlight = new Map<string, Promise<GeoapifyFeature[]>>();
|
||||
let lastNetworkRequestAt = 0;
|
||||
let sessionHydrated = false;
|
||||
|
||||
export function buildGeoapifyCacheKey(query: string, lang: string): string {
|
||||
return `${lang}:${query.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function isFresh(entry: CacheEntry): boolean {
|
||||
return Date.now() - entry.cachedAt < CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
function trimMemoryCache(): void {
|
||||
while (memoryCache.size > MAX_MEMORY_ENTRIES) {
|
||||
const oldestKey = memoryCache.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
memoryCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateFromSessionStorage(): void {
|
||||
if (sessionHydrated || typeof window === 'undefined') return;
|
||||
sessionHydrated = true;
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_CACHE_KEY);
|
||||
if (!raw) return;
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, CacheEntry>;
|
||||
for (const [key, entry] of Object.entries(parsed)) {
|
||||
if (isFresh(entry)) {
|
||||
memoryCache.set(key, entry);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore corrupt cache payloads
|
||||
}
|
||||
}
|
||||
|
||||
function persistToSessionStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const payload: Record<string, CacheEntry> = {};
|
||||
const entries = [...memoryCache.entries()]
|
||||
.filter(([, entry]) => isFresh(entry))
|
||||
.slice(-MAX_SESSION_ENTRIES);
|
||||
|
||||
for (const [key, entry] of entries) {
|
||||
payload[key] = entry;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(SESSION_CACHE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
// sessionStorage may be full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedGeoapifyFeatures(
|
||||
query: string,
|
||||
lang: string,
|
||||
): GeoapifyFeature[] | null {
|
||||
hydrateFromSessionStorage();
|
||||
|
||||
const key = buildGeoapifyCacheKey(query, lang);
|
||||
const entry = memoryCache.get(key);
|
||||
if (!entry || !isFresh(entry)) {
|
||||
if (entry) memoryCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.features;
|
||||
}
|
||||
|
||||
export function setCachedGeoapifyFeatures(
|
||||
query: string,
|
||||
lang: string,
|
||||
features: GeoapifyFeature[],
|
||||
): void {
|
||||
hydrateFromSessionStorage();
|
||||
|
||||
const key = buildGeoapifyCacheKey(query, lang);
|
||||
memoryCache.set(key, { features, cachedAt: Date.now() });
|
||||
trimMemoryCache();
|
||||
persistToSessionStorage();
|
||||
}
|
||||
|
||||
export function getInFlightGeoapifyRequest(
|
||||
query: string,
|
||||
lang: string,
|
||||
): Promise<GeoapifyFeature[]> | null {
|
||||
return inFlight.get(buildGeoapifyCacheKey(query, lang)) ?? null;
|
||||
}
|
||||
|
||||
export function trackInFlightGeoapifyRequest(
|
||||
query: string,
|
||||
lang: string,
|
||||
request: Promise<GeoapifyFeature[]>,
|
||||
): Promise<GeoapifyFeature[]> {
|
||||
const key = buildGeoapifyCacheKey(query, lang);
|
||||
inFlight.set(key, request);
|
||||
return request.finally(() => {
|
||||
if (inFlight.get(key) === request) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForGeoapifyRateLimit(): Promise<void> {
|
||||
const elapsed = Date.now() - lastNetworkRequestAt;
|
||||
const waitMs = MIN_REQUEST_INTERVAL_MS - elapsed;
|
||||
if (waitMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
lastNetworkRequestAt = Date.now();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface GeoapifyAddressProperties {
|
||||
formatted?: string;
|
||||
address_line1?: string;
|
||||
address_line2?: string;
|
||||
city?: string;
|
||||
municipality?: string;
|
||||
postcode?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
export interface GeoapifyFeature {
|
||||
type: 'Feature';
|
||||
properties: GeoapifyAddressProperties;
|
||||
}
|
||||
|
||||
export interface GeoapifyAutocompleteResponse {
|
||||
type: string;
|
||||
features: GeoapifyFeature[];
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type { MenuCategory, MenuItem } from '@/domain/menu/entities';
|
||||
import type { MenuVersionTrigger } from '@/domain/menu/versioning';
|
||||
import { menuCategories as defaultMenuCategories } from './static-menu-data';
|
||||
|
||||
const MENU_DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const MENU_DATA_FILE = path.join(MENU_DATA_DIR, 'menu.json');
|
||||
|
||||
let memoryCache: MenuCategory[] | null = null;
|
||||
|
||||
async function ensureMenuFile(): Promise<void> {
|
||||
await mkdir(MENU_DATA_DIR, { recursive: true });
|
||||
|
||||
try {
|
||||
await readFile(MENU_DATA_FILE, 'utf8');
|
||||
} catch {
|
||||
await writeFile(MENU_DATA_FILE, JSON.stringify(defaultMenuCategories, null, 2), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function isMenuCategoryArray(value: unknown): value is MenuCategory[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(category) =>
|
||||
typeof category === 'object' &&
|
||||
category !== null &&
|
||||
typeof (category as MenuCategory).id === 'string' &&
|
||||
Array.isArray((category as MenuCategory).items),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function readMenuCategories(): Promise<MenuCategory[]> {
|
||||
if (memoryCache) return memoryCache;
|
||||
|
||||
await ensureMenuFile();
|
||||
const raw = await readFile(MENU_DATA_FILE, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
|
||||
if (!isMenuCategoryArray(parsed)) {
|
||||
memoryCache = defaultMenuCategories;
|
||||
return memoryCache;
|
||||
}
|
||||
|
||||
memoryCache = parsed;
|
||||
return memoryCache;
|
||||
}
|
||||
|
||||
export interface WriteMenuOptions {
|
||||
snapshotLabel?: string;
|
||||
snapshotTrigger?: MenuVersionTrigger;
|
||||
skipSnapshot?: boolean;
|
||||
}
|
||||
|
||||
export async function persistMenuCategories(categories: MenuCategory[]): Promise<void> {
|
||||
await ensureMenuFile();
|
||||
await writeFile(MENU_DATA_FILE, JSON.stringify(categories, null, 2), 'utf8');
|
||||
memoryCache = categories;
|
||||
}
|
||||
|
||||
export async function writeMenuCategories(
|
||||
categories: MenuCategory[],
|
||||
options?: WriteMenuOptions,
|
||||
): Promise<void> {
|
||||
await persistMenuCategories(categories);
|
||||
|
||||
if (!options?.skipSnapshot) {
|
||||
const { createMenuSnapshot, ensureMenuBaseline } = await import('./menu-versioning');
|
||||
await ensureMenuBaseline();
|
||||
await createMenuSnapshot(
|
||||
categories,
|
||||
options?.snapshotLabel ?? 'Menu updated',
|
||||
options?.snapshotTrigger ?? 'auto',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMenuItem(
|
||||
categoryId: string,
|
||||
itemId: string,
|
||||
updates: Partial<MenuItem>,
|
||||
): Promise<MenuItem | null> {
|
||||
const categories = await readMenuCategories();
|
||||
let updatedItem: MenuItem | null = null;
|
||||
|
||||
const nextCategories = categories.map((category) => {
|
||||
if (category.id !== categoryId) return category;
|
||||
|
||||
return {
|
||||
...category,
|
||||
items: category.items.map((item) => {
|
||||
if (item.id !== itemId) return item;
|
||||
updatedItem = { ...item, ...updates, id: item.id };
|
||||
return updatedItem;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
if (!updatedItem) return null;
|
||||
|
||||
const dishName =
|
||||
nextCategories
|
||||
.find((category) => category.id === categoryId)
|
||||
?.items.find((item) => item.id === itemId)?.name ?? itemId;
|
||||
|
||||
await writeMenuCategories(nextCategories, {
|
||||
snapshotLabel: `Updated dish: ${dishName}`,
|
||||
});
|
||||
return updatedItem;
|
||||
}
|
||||
|
||||
export function slugifyMenuId(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function allItems(categories: MenuCategory[]): MenuItem[] {
|
||||
return categories.flatMap((category) => category.items);
|
||||
}
|
||||
|
||||
function isItemIdTaken(categories: MenuCategory[], itemId: string): boolean {
|
||||
return allItems(categories).some((item) => item.id === itemId);
|
||||
}
|
||||
|
||||
function uniqueItemId(categories: MenuCategory[], baseId: string): string {
|
||||
if (!isItemIdTaken(categories, baseId)) return baseId;
|
||||
let suffix = 2;
|
||||
while (isItemIdTaken(categories, `${baseId}-${suffix}`)) suffix += 1;
|
||||
return `${baseId}-${suffix}`;
|
||||
}
|
||||
|
||||
export async function addMenuItem(categoryId: string, item: MenuItem): Promise<MenuItem | null> {
|
||||
const categories = await readMenuCategories();
|
||||
const categoryExists = categories.some((category) => category.id === categoryId);
|
||||
if (!categoryExists) return null;
|
||||
|
||||
const id = uniqueItemId(categories, slugifyMenuId(item.id || item.name));
|
||||
if (!id) return null;
|
||||
|
||||
const newItem: MenuItem = { ...item, id };
|
||||
|
||||
const nextCategories = categories.map((category) =>
|
||||
category.id === categoryId
|
||||
? { ...category, items: [...category.items, newItem] }
|
||||
: category,
|
||||
);
|
||||
|
||||
await writeMenuCategories(nextCategories, {
|
||||
snapshotLabel: `Added dish: ${newItem.name}`,
|
||||
});
|
||||
return newItem;
|
||||
}
|
||||
|
||||
export async function removeMenuItem(categoryId: string, itemId: string): Promise<boolean> {
|
||||
const categories = await readMenuCategories();
|
||||
let removed = false;
|
||||
|
||||
const nextCategories = categories.map((category) => {
|
||||
if (category.id !== categoryId) return category;
|
||||
const nextItems = category.items.filter((item) => {
|
||||
if (item.id === itemId) {
|
||||
removed = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { ...category, items: nextItems };
|
||||
});
|
||||
|
||||
if (!removed) return false;
|
||||
|
||||
const removedName =
|
||||
categories
|
||||
.find((category) => category.id === categoryId)
|
||||
?.items.find((item) => item.id === itemId)?.name ?? itemId;
|
||||
|
||||
await writeMenuCategories(nextCategories, {
|
||||
snapshotLabel: `Removed dish: ${removedName}`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function addMenuCategory(category: MenuCategory): Promise<MenuCategory | null> {
|
||||
const categories = await readMenuCategories();
|
||||
const id = slugifyMenuId(category.id || category.name);
|
||||
if (!id) return null;
|
||||
|
||||
if (categories.some((existing) => existing.id === id)) return null;
|
||||
|
||||
const newCategory: MenuCategory = {
|
||||
id,
|
||||
name: category.name.trim(),
|
||||
items: category.items ?? [],
|
||||
};
|
||||
|
||||
await writeMenuCategories([...categories, newCategory], {
|
||||
snapshotLabel: `Added category: ${newCategory.name}`,
|
||||
});
|
||||
return newCategory;
|
||||
}
|
||||
|
||||
export async function removeMenuCategory(categoryId: string): Promise<boolean> {
|
||||
const categories = await readMenuCategories();
|
||||
if (!categories.some((category) => category.id === categoryId)) return false;
|
||||
|
||||
const removedCategory = categories.find((category) => category.id === categoryId);
|
||||
const nextCategories = categories.filter((category) => category.id !== categoryId);
|
||||
await writeMenuCategories(nextCategories, {
|
||||
snapshotLabel: `Removed category: ${removedCategory?.name ?? categoryId}`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateMenuCategory(
|
||||
categoryId: string,
|
||||
updates: Partial<Pick<MenuCategory, 'name'>>,
|
||||
): Promise<MenuCategory | null> {
|
||||
const categories = await readMenuCategories();
|
||||
let updatedCategory: MenuCategory | null = null;
|
||||
|
||||
const nextCategories = categories.map((category) => {
|
||||
if (category.id !== categoryId) return category;
|
||||
updatedCategory = {
|
||||
...category,
|
||||
...(typeof updates.name === 'string' ? { name: updates.name.trim() } : {}),
|
||||
};
|
||||
return updatedCategory;
|
||||
});
|
||||
|
||||
if (!updatedCategory) return null;
|
||||
|
||||
const categoryName =
|
||||
nextCategories.find((category) => category.id === categoryId)?.name ?? categoryId;
|
||||
|
||||
await writeMenuCategories(nextCategories, {
|
||||
snapshotLabel: `Updated category: ${categoryName}`,
|
||||
});
|
||||
return updatedCategory;
|
||||
}
|
||||
|
||||
export function clearMenuCache(): void {
|
||||
memoryCache = null;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { mkdir, readFile, writeFile, unlink } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type { MenuCategory } from '@/domain/menu/entities';
|
||||
import {
|
||||
MENU_BASELINE_VERSION_ID,
|
||||
type MenuVersionListItem,
|
||||
type MenuVersionMeta,
|
||||
type MenuVersionSnapshot,
|
||||
type MenuVersionTrigger,
|
||||
} from '@/domain/menu/versioning';
|
||||
import { menuCategories as defaultMenuCategories } from './static-menu-data';
|
||||
|
||||
const VERSIONS_DIR = path.join(process.cwd(), 'data', 'menu-versions');
|
||||
const INDEX_FILE = path.join(VERSIONS_DIR, 'index.json');
|
||||
const BASELINE_FILE = path.join(VERSIONS_DIR, 'baseline.json');
|
||||
|
||||
interface VersionIndex {
|
||||
versions: MenuVersionMeta[];
|
||||
}
|
||||
|
||||
function versionFilePath(id: string): string {
|
||||
return id === MENU_BASELINE_VERSION_ID
|
||||
? BASELINE_FILE
|
||||
: path.join(VERSIONS_DIR, `${id}.json`);
|
||||
}
|
||||
|
||||
function isMenuCategoryArray(value: unknown): value is MenuCategory[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(category) =>
|
||||
typeof category === 'object' &&
|
||||
category !== null &&
|
||||
typeof (category as MenuCategory).id === 'string' &&
|
||||
Array.isArray((category as MenuCategory).items),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function countMenuStats(categories: MenuCategory[]): { categoryCount: number; dishCount: number } {
|
||||
return {
|
||||
categoryCount: categories.length,
|
||||
dishCount: categories.reduce((sum, category) => sum + category.items.length, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function menuFingerprint(categories: MenuCategory[]): string {
|
||||
return JSON.stringify(categories);
|
||||
}
|
||||
|
||||
function cloneCategories(categories: MenuCategory[]): MenuCategory[] {
|
||||
return JSON.parse(JSON.stringify(categories)) as MenuCategory[];
|
||||
}
|
||||
|
||||
async function ensureVersionsDir(): Promise<void> {
|
||||
await mkdir(VERSIONS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function readIndex(): Promise<VersionIndex> {
|
||||
await ensureVersionsDir();
|
||||
try {
|
||||
const raw = await readFile(INDEX_FILE, 'utf8');
|
||||
const parsed = JSON.parse(raw) as VersionIndex;
|
||||
if (!Array.isArray(parsed.versions)) return { versions: [] };
|
||||
return parsed;
|
||||
} catch {
|
||||
return { versions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex(index: VersionIndex): Promise<void> {
|
||||
await ensureVersionsDir();
|
||||
await writeFile(INDEX_FILE, JSON.stringify(index, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
async function writeSnapshotFile(snapshot: MenuVersionSnapshot): Promise<void> {
|
||||
await ensureVersionsDir();
|
||||
await writeFile(versionFilePath(snapshot.id), JSON.stringify(snapshot, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
async function readSnapshotFile(id: string): Promise<MenuVersionSnapshot | null> {
|
||||
try {
|
||||
const raw = await readFile(versionFilePath(id), 'utf8');
|
||||
const parsed = JSON.parse(raw) as MenuVersionSnapshot;
|
||||
if (!isMenuCategoryArray(parsed.categories)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createVersionId(): string {
|
||||
return `v-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export async function ensureMenuBaseline(): Promise<MenuVersionMeta> {
|
||||
await ensureVersionsDir();
|
||||
const index = await readIndex();
|
||||
const existing = index.versions.find((version) => version.id === MENU_BASELINE_VERSION_ID);
|
||||
if (existing) return existing;
|
||||
|
||||
const categories = cloneCategories(defaultMenuCategories);
|
||||
const stats = countMenuStats(categories);
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
const baseline: MenuVersionSnapshot = {
|
||||
id: MENU_BASELINE_VERSION_ID,
|
||||
label: 'Original menu (baseline)',
|
||||
createdAt,
|
||||
trigger: 'auto',
|
||||
categories,
|
||||
isBaseline: true,
|
||||
};
|
||||
|
||||
await writeSnapshotFile(baseline);
|
||||
|
||||
const meta: MenuVersionMeta = {
|
||||
id: MENU_BASELINE_VERSION_ID,
|
||||
label: baseline.label,
|
||||
createdAt,
|
||||
trigger: 'auto',
|
||||
isBaseline: true,
|
||||
...stats,
|
||||
};
|
||||
|
||||
index.versions.push(meta);
|
||||
await writeIndex(index);
|
||||
return meta;
|
||||
}
|
||||
|
||||
export async function createMenuSnapshot(
|
||||
categories: MenuCategory[],
|
||||
label: string,
|
||||
trigger: MenuVersionTrigger = 'auto',
|
||||
): Promise<MenuVersionMeta> {
|
||||
await ensureMenuBaseline();
|
||||
const index = await readIndex();
|
||||
const id = createVersionId();
|
||||
const createdAt = new Date().toISOString();
|
||||
const stats = countMenuStats(categories);
|
||||
|
||||
const snapshot: MenuVersionSnapshot = {
|
||||
id,
|
||||
label: label.trim() || 'Menu updated',
|
||||
createdAt,
|
||||
trigger,
|
||||
categories: cloneCategories(categories),
|
||||
isBaseline: false,
|
||||
};
|
||||
|
||||
await writeSnapshotFile(snapshot);
|
||||
|
||||
const meta: MenuVersionMeta = {
|
||||
id,
|
||||
label: snapshot.label,
|
||||
createdAt,
|
||||
trigger,
|
||||
isBaseline: false,
|
||||
...stats,
|
||||
};
|
||||
|
||||
index.versions.unshift(meta);
|
||||
await writeIndex(index);
|
||||
return meta;
|
||||
}
|
||||
|
||||
export async function listMenuVersions(liveCategories: MenuCategory[]): Promise<MenuVersionListItem[]> {
|
||||
await ensureMenuBaseline();
|
||||
const index = await readIndex();
|
||||
const liveHash = menuFingerprint(liveCategories);
|
||||
|
||||
const items: MenuVersionListItem[] = [];
|
||||
|
||||
for (const meta of index.versions) {
|
||||
const snapshot = await readSnapshotFile(meta.id);
|
||||
const matchesLive = snapshot ? menuFingerprint(snapshot.categories) === liveHash : false;
|
||||
items.push({ ...meta, matchesLive });
|
||||
}
|
||||
|
||||
const baseline = items.find((item) => item.isBaseline);
|
||||
const nonBaseline = items.filter((item) => !item.isBaseline);
|
||||
nonBaseline.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
return baseline ? [...nonBaseline, baseline] : nonBaseline;
|
||||
}
|
||||
|
||||
export async function getMenuVersionCategories(versionId: string): Promise<MenuCategory[] | null> {
|
||||
const snapshot = await readSnapshotFile(versionId);
|
||||
return snapshot ? cloneCategories(snapshot.categories) : null;
|
||||
}
|
||||
|
||||
export async function restoreMenuVersion(
|
||||
versionId: string,
|
||||
persist: (categories: MenuCategory[]) => Promise<void>,
|
||||
readLive: () => Promise<MenuCategory[]>,
|
||||
): Promise<MenuVersionMeta> {
|
||||
const snapshot = await readSnapshotFile(versionId);
|
||||
if (!snapshot) {
|
||||
throw new Error('Version not found.');
|
||||
}
|
||||
|
||||
const current = await readLive();
|
||||
await createMenuSnapshot(
|
||||
current,
|
||||
`Before rollback to "${snapshot.label}"`,
|
||||
'restore',
|
||||
);
|
||||
|
||||
await persist(snapshot.categories);
|
||||
|
||||
return {
|
||||
id: snapshot.id,
|
||||
label: snapshot.label,
|
||||
createdAt: snapshot.createdAt,
|
||||
trigger: snapshot.trigger,
|
||||
isBaseline: snapshot.isBaseline,
|
||||
...countMenuStats(snapshot.categories),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resetMenuVersionHistory(categories: MenuCategory[]): Promise<MenuVersionMeta> {
|
||||
await ensureVersionsDir();
|
||||
const index = await readIndex();
|
||||
|
||||
for (const version of index.versions) {
|
||||
if (version.id === MENU_BASELINE_VERSION_ID) continue;
|
||||
try {
|
||||
await unlink(versionFilePath(version.id));
|
||||
} catch {
|
||||
// File may already be missing.
|
||||
}
|
||||
}
|
||||
|
||||
const stats = countMenuStats(categories);
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
const baseline: MenuVersionSnapshot = {
|
||||
id: MENU_BASELINE_VERSION_ID,
|
||||
label: 'Current menu (baseline)',
|
||||
createdAt,
|
||||
trigger: 'manual',
|
||||
categories: cloneCategories(categories),
|
||||
isBaseline: true,
|
||||
};
|
||||
|
||||
await writeSnapshotFile(baseline);
|
||||
|
||||
const meta: MenuVersionMeta = {
|
||||
id: MENU_BASELINE_VERSION_ID,
|
||||
label: baseline.label,
|
||||
createdAt,
|
||||
trigger: 'manual',
|
||||
isBaseline: true,
|
||||
...stats,
|
||||
};
|
||||
|
||||
index.versions = [meta];
|
||||
await writeIndex(index);
|
||||
return meta;
|
||||
}
|
||||
|
||||
export async function deleteMenuVersion(versionId: string): Promise<boolean> {
|
||||
if (versionId === MENU_BASELINE_VERSION_ID) {
|
||||
throw new Error('The baseline version cannot be deleted.');
|
||||
}
|
||||
|
||||
const index = await readIndex();
|
||||
const nextVersions = index.versions.filter((version) => version.id !== versionId);
|
||||
if (nextVersions.length === index.versions.length) return false;
|
||||
|
||||
index.versions = nextVersions;
|
||||
await writeIndex(index);
|
||||
|
||||
try {
|
||||
await unlink(versionFilePath(versionId));
|
||||
} catch {
|
||||
// File may already be missing; index update is sufficient.
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ export const menuCategories: MenuCategory[] = [
|
||||
{ id: "chicken-karahi", name: "Chicken Karahi", description: "Wok-tossed chicken in a robust tomato, chili and ginger gravy.", price: 149, image: "chicken-karahi.jpg", video: "chicken-karahi.mp4" },
|
||||
{ id: "lahore-sizzler", name: "Lahore Sizzler", description: "Sizzling platter of marinated chicken with vegetables and spicy sauces.", price: 169, image: "lahore-sizzler.jpg", video: "lahore-sizzler.mp4" },
|
||||
{ id: "butter-chicken", name: "Butter Chicken", description: "Tender chicken in a creamy tomato and butter gravy with mild spices.", price: 149, image: "butter-chicken.jpg" },
|
||||
{ id: "chicken-haleem", name: "Chicken Haleem", description: "Slow-cooked shredded chicken with lentils, wheat and aromatic spices.", price: 149, image: "chicken-haleem.jpg", video: "chicken-haleem.mp4" },
|
||||
{ id: "chicken-haleem", name: "Chicken Haleem", description: "Slow-cooked shredded chicken with lentils, wheat and aromatic spices.", price: 139, image: "chicken-haleem.jpg", video: "chicken-haleem.mp4" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -171,7 +171,7 @@ export const menuCategories: MenuCategory[] = [
|
||||
{ id: "lambay-gulab-jaman", name: "Lambay Gulab Jamun", description: "Elongated gulab jamun with extra syrup — a Shahi Sweets favourite.", image: "lambay-gulab-jaman.jpg", ...WEIGHT_SWEET_PRICING },
|
||||
{ id: "cream-gulab-jaman", name: "Cream Gulab Jamun", description: "Gulab jamun filled with creamy centre, finished in fragrant syrup.", image: "cream-gulab-jaman.jpg", ...WEIGHT_SWEET_PRICING },
|
||||
{ id: "ras-gulay", name: "Ras Gulay", description: "Spongy cottage-cheese balls in light sugar syrup — chilled and refreshing.", image: "ras-gulay.jpg", video: "ras-gulay.mp4", ...WEIGHT_SWEET_PRICING },
|
||||
{ id: "rasmalai", name: "Rasmalai", description: "Soft cheese dumplings soaked in chilled sweetened milk with cardamom and saffron.", image: "rasmalai.jpg", video: "rasmalai.mp4", ...WEIGHT_SWEET_PRICING },
|
||||
{ id: "rasmalai", name: "Rasmalai", description: "Soft cheese dumplings soaked in chilled sweetened milk with cardamom and saffron.", price: 45, image: "rasmalai.jpg", video: "rasmalai.mp4" },
|
||||
{ id: "cham-cham", name: "Cham Cham", description: "Oval Bengali sweet coated in coconut or pistachio — soft and milky.", image: "cham-cham.jpg", video: "cham-cham.mp4", ...WEIGHT_SWEET_PRICING },
|
||||
{ id: "paira", name: "Paira", description: "Traditional milk fudge sweet with a smooth, grainy texture.", image: "paira.jpg", video: "paira.mp4", ...WEIGHT_SWEET_PRICING },
|
||||
{ id: "laddu", name: "Laddu", description: "Round gram-flour and ghee sweet balls — festive and aromatic.", image: "laddu.jpg", video: "laddu.mp4", ...WEIGHT_SWEET_PRICING },
|
||||
@@ -197,10 +197,10 @@ export const menuCategories: MenuCategory[] = [
|
||||
items: [
|
||||
{ id: "masala-chai", name: "Masala Chai", description: "Traditional spiced tea brewed with milk, cardamom, ginger and aromatic spices.", price: 39, image: "masala-chai.jpg" },
|
||||
{ id: "mango-lassi", name: "Mango Lassi", description: "Refreshing sweet yogurt drink blended with ripe mango and cardamom.", price: 45, image: "mango-lassi.jpg", video: "mango-lassi.mp4" },
|
||||
{ id: "coca-cola", name: "Coca-Cola", description: "Classic chilled cola soft drink.", price: 29, video: "coca-cola.mp4" },
|
||||
{ id: "pepsi-fanta", name: "Pepsi / Fanta", description: "Refreshing cola or orange flavored carbonated beverage.", price: 29, video: "pepsi-fanta.mp4" },
|
||||
{ id: "sprite-ramlosa", name: "Sprite / Ramlösa", description: "Crisp lemon-lime soda or sparkling mineral water.", price: 29, video: "sprite-ramlosa.mp4" },
|
||||
{ id: "energy-drink", name: "Energy Drink", description: "Caffeinated beverage for an instant energy boost.", price: 39, video: "energy-drink.mp4" },
|
||||
{ id: "coca-cola", name: "Coca-Cola", description: "Classic chilled cola soft drink.", price: 25, video: "coca-cola.mp4" },
|
||||
{ id: "pepsi-fanta", name: "Pepsi / Fanta", description: "Refreshing cola or orange flavored carbonated beverage.", price: 25, video: "pepsi-fanta.mp4" },
|
||||
{ id: "sprite-ramlosa", name: "Sprite / Ramlösa", description: "Crisp lemon-lime soda or sparkling mineral water.", price: 25, video: "sprite-ramlosa.mp4" },
|
||||
{ id: "energy-drink", name: "Energy Drink", description: "Caffeinated beverage for an instant energy boost.", price: 29, video: "energy-drink.mp4" },
|
||||
{ id: "juice", name: "Juice", description: "Fresh fruit juice, typically mango or other seasonal flavors.", price: 20, image: "mango-juice.jpg" },
|
||||
{ id: "coffee", name: "Coffee", description: "Freshly brewed hot coffee.", price: 39, image: "black-coffee.jpg" },
|
||||
{ id: "latte", name: "Latte", description: "Espresso coffee with steamed milk and a light layer of foam.", price: 49, image: "latte.jpg", video: "latte.mp4" },
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/** Shared header layout constants — safe for Server Components (no 'use client'). */
|
||||
|
||||
export const LANGUAGE_BANNER_HEIGHT = 48;
|
||||
export const LANGUAGE_BANNER_HEIGHT_LOGGED_IN_DESKTOP = 56;
|
||||
export const LANGUAGE_BANNER_HEIGHT_LOGGED_IN_MOBILE = 160;
|
||||
export const LANGUAGE_BANNER_HEIGHT_MENU_MANAGER_MOBILE = 160;
|
||||
export const NAVBAR_HEIGHT = 68;
|
||||
export const HEADER_HEIGHT = LANGUAGE_BANNER_HEIGHT + NAVBAR_HEIGHT; // 116px
|
||||
/** Guest: 116px. Logged-in desktop: 124px. Logged-in mobile: 172–200px. */
|
||||
export const HEADER_HEIGHT = LANGUAGE_BANNER_HEIGHT + NAVBAR_HEIGHT;
|
||||
export const HEADER_HEIGHT_MOBILE = HEADER_HEIGHT;
|
||||
export const HEADER_OFFSET_VAR = '--header-height';
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
Generated
+141
@@ -30,6 +30,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.35.2",
|
||||
"tailwindcss": "^4",
|
||||
@@ -299,6 +300,22 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@derhuerst/http-basic": {
|
||||
"version": "8.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
|
||||
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caseless": "^0.12.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"http-response-object": "^3.0.1",
|
||||
"parse-cache-control": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
@@ -3067,6 +3084,19 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -3617,6 +3647,13 @@
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
@@ -3720,6 +3757,13 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/caseless": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
|
||||
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -3812,6 +3856,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"dev": true,
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -4109,6 +4169,16 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.2",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||
@@ -4869,6 +4939,23 @@
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ffmpeg-static": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
|
||||
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@derhuerst/http-basic": "^8.2.0",
|
||||
"env-paths": "^2.2.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"progress": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -5339,6 +5426,37 @@
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/http-response-object": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
|
||||
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^10.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/http-response-object/node_modules/@types/node": {
|
||||
"version": "10.17.60",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
|
||||
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -7573,6 +7691,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-cache-control": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
|
||||
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -7744,6 +7868,16 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/promise-worker-transferable": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
|
||||
@@ -9173,6 +9307,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { Language } from '@/domain/language/entities';
|
||||
import type { EventTypeId } from '@/domain/booking/event-types';
|
||||
import type { BookingMessageCopy } from '@/application/messaging/whatsapp-message-builder';
|
||||
|
||||
const EVENT_LABELS_EN: Record<EventTypeId, string> = {
|
||||
birthday: 'Birthday',
|
||||
wedding: 'Wedding / Walima',
|
||||
engagement: 'Engagement / Nikah',
|
||||
anniversary: 'Anniversary',
|
||||
'baby-shower': 'Baby Shower',
|
||||
graduation: 'Graduation',
|
||||
'office-party': 'Office Party',
|
||||
'corporate-lunch': 'Corporate Lunch',
|
||||
'family-gathering': 'Family Gathering',
|
||||
'religious-celebration': 'Eid, Diwali & Festivals',
|
||||
farewell: 'Farewell Party',
|
||||
retirement: 'Retirement',
|
||||
'bridal-shower': 'Bridal Shower',
|
||||
'holiday-party': 'Holiday Party',
|
||||
other: 'Other Celebration',
|
||||
};
|
||||
|
||||
const EVENT_LABELS_SV: Record<EventTypeId, string> = {
|
||||
birthday: 'Födelsedag',
|
||||
wedding: 'Bröllop / Walima',
|
||||
engagement: 'Förlovning / Nikah',
|
||||
anniversary: 'Årsdag',
|
||||
'baby-shower': 'Babyshower',
|
||||
graduation: 'Examensfest',
|
||||
'office-party': 'Kontorsfest',
|
||||
'corporate-lunch': 'Företagslunch',
|
||||
'family-gathering': 'Familjesammankomst',
|
||||
'religious-celebration': 'Eid, Diwali & högtider',
|
||||
farewell: 'Avskedsfest',
|
||||
retirement: 'Pensionering',
|
||||
'bridal-shower': 'Möhippa',
|
||||
'holiday-party': 'Helgfest',
|
||||
other: 'Annat firande',
|
||||
};
|
||||
|
||||
const LOCALIZED: Partial<Record<Language, Record<EventTypeId, string>>> = {
|
||||
sv: EVENT_LABELS_SV,
|
||||
};
|
||||
|
||||
export interface BookingEventsCopy {
|
||||
heroBadge: string;
|
||||
heroTitle: string;
|
||||
heroSubtitle: string;
|
||||
experienceDiningTitle: string;
|
||||
experienceDiningDesc: string;
|
||||
experienceCelebrateTitle: string;
|
||||
experienceCelebrateDesc: string;
|
||||
experienceCorporateTitle: string;
|
||||
experienceCorporateDesc: string;
|
||||
modeTable: string;
|
||||
modeTableDesc: string;
|
||||
modeEvent: string;
|
||||
modeEventDesc: string;
|
||||
eventPickerTitle: string;
|
||||
eventPickerSubtitle: string;
|
||||
eventTypeOtherLabel: string;
|
||||
eventTypeOtherPlaceholder: string;
|
||||
eventTypeRequired: string;
|
||||
eventTypeOtherRequired: string;
|
||||
formSubtitleTable: string;
|
||||
formSubtitleEvent: string;
|
||||
whatsappEventLine: string;
|
||||
whatsappModeTable: string;
|
||||
whatsappModeEvent: string;
|
||||
whatsappGreeting: string;
|
||||
whatsappLabelName: string;
|
||||
whatsappLabelPhone: string;
|
||||
whatsappLabelEmail: string;
|
||||
whatsappLabelLocation: string;
|
||||
whatsappLabelDate: string;
|
||||
whatsappLabelTime: string;
|
||||
whatsappLabelGuests: string;
|
||||
whatsappLabelNotes: string;
|
||||
whatsappLabelPreOrder: string;
|
||||
whatsappPreOrderNone: string;
|
||||
whatsappPreOrderTotal: string;
|
||||
whatsappConfirmLine: string;
|
||||
whatsappThanksLine: string;
|
||||
}
|
||||
|
||||
const COPY_EN: BookingEventsCopy = {
|
||||
heroBadge: 'Dine • Celebrate • Gather',
|
||||
heroTitle: 'Reserve a Table or Book Your Event',
|
||||
heroSubtitle:
|
||||
'From an intimate dinner for two to birthdays, weddings, engagements, and office celebrations — Shahi Kitchen sets the stage for unforgettable moments in Gothenburg.',
|
||||
experienceDiningTitle: 'Intimate Dining',
|
||||
experienceDiningDesc: 'Quiet tables, warm hospitality, and your favourite Shahi dishes.',
|
||||
experienceCelebrateTitle: 'Life Celebrations',
|
||||
experienceCelebrateDesc: 'Birthdays, engagements, weddings, anniversaries & milestones.',
|
||||
experienceCorporateTitle: 'Teams & Offices',
|
||||
experienceCorporateDesc: 'Lunches, farewells, team outings & corporate gatherings.',
|
||||
modeTable: 'Table Reservation',
|
||||
modeTableDesc: 'Book a table for dining',
|
||||
modeEvent: 'Private Event',
|
||||
modeEventDesc: 'Birthdays, weddings & more',
|
||||
eventPickerTitle: 'What are you celebrating?',
|
||||
eventPickerSubtitle: 'Choose your occasion — we tailor seating, menu ideas, and setup to your event.',
|
||||
eventTypeOtherLabel: 'Describe your celebration',
|
||||
eventTypeOtherPlaceholder: 'e.g. Engagement dinner, company awards night…',
|
||||
eventTypeRequired: 'Please select an event type.',
|
||||
eventTypeOtherRequired: 'Please describe your celebration.',
|
||||
formSubtitleTable: 'Tell us when you would like to dine with us.',
|
||||
formSubtitleEvent: 'Share your event details — our team will craft a memorable Shahi experience.',
|
||||
whatsappEventLine: 'Occasion',
|
||||
whatsappModeTable: 'Table reservation',
|
||||
whatsappModeEvent: 'Private event booking',
|
||||
whatsappGreeting: 'Hello Shahi Kitchen 👋',
|
||||
whatsappLabelName: 'Name',
|
||||
whatsappLabelPhone: 'Phone',
|
||||
whatsappLabelEmail: 'Email',
|
||||
whatsappLabelLocation: 'Location',
|
||||
whatsappLabelDate: 'Date',
|
||||
whatsappLabelTime: 'Time',
|
||||
whatsappLabelGuests: 'Guests',
|
||||
whatsappLabelNotes: 'Special Requests',
|
||||
whatsappLabelPreOrder: 'Pre-ordered items',
|
||||
whatsappPreOrderNone: 'None',
|
||||
whatsappPreOrderTotal: 'Pre-order total',
|
||||
whatsappConfirmLine: 'Please confirm availability and pre-order.',
|
||||
whatsappThanksLine: 'Thank you!',
|
||||
};
|
||||
|
||||
const COPY_SV: BookingEventsCopy = {
|
||||
...COPY_EN,
|
||||
heroBadge: 'Ät • Fira • Samla',
|
||||
heroTitle: 'Boka bord eller boka ditt evenemang',
|
||||
heroSubtitle:
|
||||
'Från en intim middag till födelsedagar, bröllop, förlovningar och kontorsfester — Shahi Kitchen skapar oförglömliga stunder i Göteborg.',
|
||||
experienceDiningTitle: 'Intim middag',
|
||||
experienceDiningDesc: 'Lugna bord, varm gästfrihet och dina Shahi-favoriter.',
|
||||
experienceCelebrateTitle: 'Livets firanden',
|
||||
experienceCelebrateDesc: 'Födelsedagar, förlovningar, bröllop, jubileum & milstolpar.',
|
||||
experienceCorporateTitle: 'Team & kontor',
|
||||
experienceCorporateDesc: 'Luncher, avsked, teamutflykter & företagssammankomster.',
|
||||
modeTable: 'Bordsbokning',
|
||||
modeTableDesc: 'Boka bord för middag',
|
||||
modeEvent: 'Privat evenemang',
|
||||
modeEventDesc: 'Födelsedagar, bröllop & mer',
|
||||
eventPickerTitle: 'Vad firar ni?',
|
||||
eventPickerSubtitle: 'Välj tillfälle — vi anpassar plats, menyidéer och upplägg till ert event.',
|
||||
eventTypeOtherLabel: 'Beskriv ert firande',
|
||||
eventTypeOtherPlaceholder: 't.ex. förlovningsmiddag, företagsgalan…',
|
||||
eventTypeRequired: 'Välj en typ av evenemang.',
|
||||
eventTypeOtherRequired: 'Beskriv ert firande.',
|
||||
formSubtitleTable: 'Berätta när ni vill äta hos oss.',
|
||||
formSubtitleEvent: 'Dela eventdetaljer — vårt team skapar en minnesvärd Shahi-upplevelse.',
|
||||
whatsappEventLine: 'Tillfälle',
|
||||
whatsappModeTable: 'Bordsbokning',
|
||||
whatsappModeEvent: 'Bokning av privat evenemang',
|
||||
whatsappGreeting: 'Hej Shahi Kitchen 👋',
|
||||
whatsappLabelName: 'Namn',
|
||||
whatsappLabelPhone: 'Telefon',
|
||||
whatsappLabelEmail: 'E-post',
|
||||
whatsappLabelLocation: 'Plats',
|
||||
whatsappLabelDate: 'Datum',
|
||||
whatsappLabelTime: 'Tid',
|
||||
whatsappLabelGuests: 'Gäster',
|
||||
whatsappLabelNotes: 'Särskilda önskemål',
|
||||
whatsappLabelPreOrder: 'Förbeställda rätter',
|
||||
whatsappPreOrderNone: 'Inga',
|
||||
whatsappPreOrderTotal: 'Förbeställning totalt',
|
||||
whatsappConfirmLine: 'Vänligen bekräfta tillgänglighet och förbeställning.',
|
||||
whatsappThanksLine: 'Tack!',
|
||||
};
|
||||
|
||||
const COPY_LOCALIZED: Partial<Record<Language, BookingEventsCopy>> = {
|
||||
sv: COPY_SV,
|
||||
};
|
||||
|
||||
export function getBookingEventsCopy(lang: Language): BookingEventsCopy {
|
||||
if (lang === 'en') return COPY_EN;
|
||||
return COPY_LOCALIZED[lang] ?? COPY_EN;
|
||||
}
|
||||
|
||||
export function getEventTypeLabel(lang: Language, id: EventTypeId): string {
|
||||
if (lang === 'en') return EVENT_LABELS_EN[id];
|
||||
return LOCALIZED[lang]?.[id] ?? EVENT_LABELS_EN[id];
|
||||
}
|
||||
|
||||
export function toBookingMessageCopy(
|
||||
copy: BookingEventsCopy,
|
||||
location: { askim: string; backaplan: string },
|
||||
): BookingMessageCopy {
|
||||
return {
|
||||
askim: location.askim,
|
||||
backaplan: location.backaplan,
|
||||
modeTable: copy.whatsappModeTable,
|
||||
modeEvent: copy.whatsappModeEvent,
|
||||
eventLine: copy.whatsappEventLine,
|
||||
greeting: copy.whatsappGreeting,
|
||||
labelName: copy.whatsappLabelName,
|
||||
labelPhone: copy.whatsappLabelPhone,
|
||||
labelEmail: copy.whatsappLabelEmail,
|
||||
labelLocation: copy.whatsappLabelLocation,
|
||||
labelDate: copy.whatsappLabelDate,
|
||||
labelTime: copy.whatsappLabelTime,
|
||||
labelGuests: copy.whatsappLabelGuests,
|
||||
labelNotes: copy.whatsappLabelNotes,
|
||||
labelPreOrder: copy.whatsappLabelPreOrder,
|
||||
preOrderNone: copy.whatsappPreOrderNone,
|
||||
preOrderTotal: copy.whatsappPreOrderTotal,
|
||||
confirmLine: copy.whatsappConfirmLine,
|
||||
thanksLine: copy.whatsappThanksLine,
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,9 @@ export const arTranslations = {
|
||||
title: 'احجز طاولتك',
|
||||
subtitle: 'احجز طاولة في أسكيم أو باكابلان. اطلب مسبقاً أطباقك المفضلة من القائمة الكاملة لتجربة سلسة.',
|
||||
formTitle: 'تفاصيل الحجز',
|
||||
signInRequiredTitle: 'سجّل الدخول للمتابعة',
|
||||
signInRequiredSubtitle: 'سجّل الدخول عبر Google لمتابعة حجزك.',
|
||||
signedInAs: 'تم تسجيل الدخول كـ',
|
||||
locationLabel: 'الفرع',
|
||||
locationPlaceholder: 'اختر الفرع',
|
||||
dateLabel: 'التاريخ',
|
||||
@@ -236,14 +239,32 @@ export const arTranslations = {
|
||||
remove: 'إزالة',
|
||||
inquiryModalTitlePickup: 'استفسار الاستلام',
|
||||
inquiryModalTitleDelivery: 'استفسار التوصيل للمنزل',
|
||||
signInRequiredTitle: 'سجّل الدخول للمتابعة',
|
||||
signInRequiredSubtitle: 'سجّل الدخول عبر Google لإرسال استفسار الاستلام أو التوصيل.',
|
||||
signedInAs: 'تم تسجيل الدخول كـ',
|
||||
nameLabel: 'الاسم الكامل',
|
||||
namePlaceholder: 'اسمك',
|
||||
emailLabel: 'البريد الإلكتروني',
|
||||
emailPlaceholder: 'your@email.com',
|
||||
emailRequired: 'يرجى تسجيل الدخول لاستخدام بريد Google الخاص بك.',
|
||||
phoneLabel: 'رقم الهاتف',
|
||||
phonePlaceholder: '+46 70 123 4567',
|
||||
addressLabel: 'عنوان التوصيل',
|
||||
branchLabel: 'فرع المطعم',
|
||||
branchPlaceholder: 'اختر الفرع',
|
||||
branchAskim: 'Askim',
|
||||
branchBackaplan: 'Backaplan',
|
||||
branchRequired: 'يرجى اختيار فرع.',
|
||||
askimOnlineUnavailable: 'قريباً، هذا الفرع لا يوصل عبر الإنترنت بعد',
|
||||
addressLabel: 'عنوان التوصيل (اختياري)',
|
||||
addressPlaceholder: 'الشارع، الرمز البريدي، المدينة…',
|
||||
preferredTimeLabel: 'وقت التوصيل المفضل',
|
||||
preferredTimePlaceholder: 'مثلاً اليوم 18:00',
|
||||
preferredDateLabel: 'التاريخ المفضل (اختياري)',
|
||||
preferredDatePlaceholder: 'اختر التاريخ',
|
||||
dateToday: 'اليوم',
|
||||
dateTomorrow: 'غداً',
|
||||
preferredTimeLabel: 'الوقت المفضل (اختياري)',
|
||||
scheduleOptionalHint: 'اختياري: اختر اليوم أو غداً. إذا اخترت اليوم، يجب أن يكون الوقت بعد 30 دقيقة على الأقل من الآن.',
|
||||
scheduleIncomplete: 'يرجى اختيار التاريخ والوقت معاً، أو تركهما فارغين.',
|
||||
scheduleTooSoon: 'يرجى اختيار وقت بعد 30 دقيقة على الأقل من الآن.',
|
||||
submitPickup: 'إرسال عبر واتساب',
|
||||
submitDelivery: 'إرسال عبر واتساب',
|
||||
inquiryCancel: 'إلغاء',
|
||||
@@ -256,8 +277,11 @@ export const arTranslations = {
|
||||
inquiryDeliveryIntro: 'مرحباً! أود طلب توصيل للمنزل:',
|
||||
messageTotal: 'الإجمالي',
|
||||
messageName: 'الاسم',
|
||||
messageEmail: 'البريد الإلكتروني',
|
||||
messagePhone: 'الهاتف',
|
||||
messageBranch: 'الفرع',
|
||||
messageAddress: 'العنوان',
|
||||
messagePreferredDate: 'التاريخ المفضل',
|
||||
messagePreferredTime: 'الوقت المفضل',
|
||||
deliverySwishNote: 'سأرسل تأكيد الدفع عبر Swish هنا بعد تأكيد الطلب.',
|
||||
},
|
||||
@@ -275,8 +299,24 @@ export const arTranslations = {
|
||||
continueBrowsing: 'متابعة التصفح',
|
||||
},
|
||||
|
||||
// Authentication / Staff Login Page (fully translated, placeholder only)
|
||||
auth: {
|
||||
tabs: { customer: 'عميل', staff: 'موظف' },
|
||||
customer: {
|
||||
badge: 'بوابة العملاء',
|
||||
title: 'دخول العملاء',
|
||||
subtitle: 'سجّل الدخول بحساب Google للوصول إلى ملفك في مطبخ شاهي.',
|
||||
signInWithGoogle: 'المتابعة مع Google',
|
||||
footerNote: 'المزيد من ميزات العملاء قريباً.',
|
||||
notConfigured: 'تسجيل الدخول عبر Google غير مُعدّ بعد.',
|
||||
googleDenied: 'تم إلغاء تسجيل الدخول عبر Google. حاول مرة أخرى.',
|
||||
googleFailed: 'تعذّر تسجيل الدخول عبر Google. حاول مرة أخرى.',
|
||||
loading: 'جارٍ تحميل حسابك…',
|
||||
welcomeTitle: 'مرحباً بك في مطبخ شاهي',
|
||||
welcomeSubtitle: 'تم تسجيل دخولك بنجاح. المزيد من الميزات قريباً.',
|
||||
loggedInAs: 'أنت مسجّل الدخول كـ',
|
||||
logout: 'تسجيل الخروج',
|
||||
menuManagement: 'إدارة القائمة',
|
||||
},
|
||||
title: 'دخول الموظفين',
|
||||
subtitle: 'وصول آمن لأعضاء فريق مطبخ شاهي في فروعنا',
|
||||
usernameLabel: 'اسم المستخدم',
|
||||
@@ -289,6 +329,7 @@ export const arTranslations = {
|
||||
backaplan: 'باكابلان',
|
||||
submit: 'تسجيل الدخول',
|
||||
submitting: 'جارٍ تسجيل الدخول...',
|
||||
accessDenied: 'تم رفض الوصول. يرجى الاتصال بالمسؤول.',
|
||||
construction: {
|
||||
title: 'نظام المصادقة قيد الإنشاء',
|
||||
message: 'نظام دخول الموظفين قيد التطوير حالياً. خدمة المصادقة الخلفية غير متاحة بعد. يرجى المحاولة لاحقاً. للمساعدة الفورية في الطلبات أو الحجوزات، تواصل مع المطعم مباشرة عبر الهاتف أو واتساب.',
|
||||
|
||||
@@ -18,6 +18,9 @@ export const trTranslations = {
|
||||
title: 'Masanızı Rezerve Edin',
|
||||
subtitle: 'Askim veya Backaplan\'da masa ayırtın. Sorunsuz bir deneyim için tam menüden favorilerinizi önceden sipariş edin.',
|
||||
formTitle: 'Rezervasyon Bilgileriniz',
|
||||
signInRequiredTitle: 'Devam etmek için giriş yapın',
|
||||
signInRequiredSubtitle: 'Rezervasyonunuza devam etmek için Google ile giriş yapın.',
|
||||
signedInAs: 'Şu hesapla giriş yapıldı:',
|
||||
locationLabel: 'Şube',
|
||||
locationPlaceholder: 'Şube seçin',
|
||||
dateLabel: 'Tarih',
|
||||
@@ -236,14 +239,32 @@ export const trTranslations = {
|
||||
remove: 'Kaldır',
|
||||
inquiryModalTitlePickup: 'Gel-al sorgusu',
|
||||
inquiryModalTitleDelivery: 'Ev teslimatı sorgusu',
|
||||
signInRequiredTitle: 'Devam etmek için giriş yapın',
|
||||
signInRequiredSubtitle: 'Teslim alma veya teslimat talebinizi göndermek için Google ile giriş yapın.',
|
||||
signedInAs: 'Şu hesapla giriş yapıldı:',
|
||||
nameLabel: 'Ad soyad',
|
||||
namePlaceholder: 'Adınız',
|
||||
emailLabel: 'E-posta',
|
||||
emailPlaceholder: 'eposta@ornek.com',
|
||||
emailRequired: 'Google e-postanızı kullanabilmemiz için lütfen giriş yapın.',
|
||||
phoneLabel: 'Telefon numarası',
|
||||
phonePlaceholder: '+46 70 123 4567',
|
||||
addressLabel: 'Teslimat adresi',
|
||||
branchLabel: 'Restoran şubesi',
|
||||
branchPlaceholder: 'Şube seçin',
|
||||
branchAskim: 'Askim',
|
||||
branchBackaplan: 'Backaplan',
|
||||
branchRequired: 'Lütfen bir şube seçin.',
|
||||
askimOnlineUnavailable: 'Yakında, bu şube henüz online teslimat yapmıyor',
|
||||
addressLabel: 'Teslimat adresi (isteğe bağlı)',
|
||||
addressPlaceholder: 'Sokak, posta kodu, şehir…',
|
||||
preferredTimeLabel: 'Tercih edilen teslimat saati',
|
||||
preferredTimePlaceholder: 'ör. Bugün 18:00',
|
||||
preferredDateLabel: 'Tercih edilen tarih (isteğe bağlı)',
|
||||
preferredDatePlaceholder: 'Tarih seçin',
|
||||
dateToday: 'Bugün',
|
||||
dateTomorrow: 'Yarın',
|
||||
preferredTimeLabel: 'Tercih edilen saat (isteğe bağlı)',
|
||||
scheduleOptionalHint: 'İsteğe bağlı: bugün veya yarın seçin. Bugün seçerseniz saat şu andan en az 30 dakika sonra olmalıdır.',
|
||||
scheduleIncomplete: 'Lütfen hem tarih hem saat seçin veya ikisini de boş bırakın.',
|
||||
scheduleTooSoon: 'Lütfen şu andan en az 30 dakika sonrası için bir saat seçin.',
|
||||
submitPickup: 'WhatsApp ile gönder',
|
||||
submitDelivery: 'WhatsApp ile gönder',
|
||||
inquiryCancel: 'İptal',
|
||||
@@ -256,8 +277,11 @@ export const trTranslations = {
|
||||
inquiryDeliveryIntro: 'Merhaba! Ev teslimatı siparişi vermek istiyorum:',
|
||||
messageTotal: 'Toplam',
|
||||
messageName: 'İsim',
|
||||
messageEmail: 'E-posta',
|
||||
messagePhone: 'Telefon',
|
||||
messageBranch: 'Şube',
|
||||
messageAddress: 'Adres',
|
||||
messagePreferredDate: 'Tercih edilen tarih',
|
||||
messagePreferredTime: 'Tercih edilen saat',
|
||||
deliverySwishNote: 'Sipariş onaylandıktan sonra Swish ödeme onayını buradan göndereceğim.',
|
||||
},
|
||||
@@ -275,8 +299,24 @@ export const trTranslations = {
|
||||
continueBrowsing: 'Göz atmaya devam et',
|
||||
},
|
||||
|
||||
// Authentication / Staff Login Page (fully translated, placeholder only)
|
||||
auth: {
|
||||
tabs: { customer: 'Müşteri', staff: 'Personel' },
|
||||
customer: {
|
||||
badge: 'MÜŞTERİ PORTALI',
|
||||
title: 'Müşteri Girişi',
|
||||
subtitle: 'Shahi Kitchen profilinize erişmek için Google hesabınızla giriş yapın.',
|
||||
signInWithGoogle: 'Google ile devam et',
|
||||
footerNote: 'Daha fazla müşteri özelliği yakında.',
|
||||
notConfigured: 'Google girişi henüz yapılandırılmadı.',
|
||||
googleDenied: 'Google girişi iptal edildi. Lütfen tekrar deneyin.',
|
||||
googleFailed: 'Google ile giriş yapılamadı. Lütfen tekrar deneyin.',
|
||||
loading: 'Hesabınız yükleniyor…',
|
||||
welcomeTitle: 'Shahi Kitchen\'a Hoş Geldiniz',
|
||||
welcomeSubtitle: 'Başarıyla giriş yaptınız. Daha fazla özellik yakında.',
|
||||
loggedInAs: 'Şu hesapla giriş yaptınız:',
|
||||
logout: 'Çıkış yap',
|
||||
menuManagement: 'Menü Yönetimi',
|
||||
},
|
||||
title: 'Personel Girişi',
|
||||
subtitle: 'Shahi Kitchen ekip üyeleri için güvenli erişim',
|
||||
usernameLabel: 'Kullanıcı Adı',
|
||||
@@ -289,6 +329,7 @@ export const trTranslations = {
|
||||
backaplan: 'Backaplan',
|
||||
submit: 'Giriş Yap',
|
||||
submitting: 'Giriş yapılıyor...',
|
||||
accessDenied: 'Erişim reddedildi. Lütfen yöneticiyle iletişime geçin.',
|
||||
construction: {
|
||||
title: 'Kimlik Doğrulama Yapım Aşamasında',
|
||||
message: 'Personel giriş sistemimiz şu anda geliştirilme aşamasındadır. Arka uç kimlik doğrulama hizmeti henüz kullanılamıyor. Lütfen daha sonra tekrar deneyin. Sipariş veya rezervasyon için acil yardım için lütfen restoranla doğrudan telefon veya WhatsApp üzerinden iletişime geçin.',
|
||||
|
||||
@@ -70,7 +70,25 @@ const LOCALIZED: Partial<Record<Language, Record<string, string>>> = {
|
||||
}
|
||||
};
|
||||
|
||||
const INCLUSION_CATEGORIES = new Set(['meat', 'chicken', 'vegetarian']);
|
||||
|
||||
const DISH_INCLUSION_NOTE_EN = '1x Naan or Rice included';
|
||||
|
||||
const DISH_INCLUSION_NOTE_LOCALIZED: Partial<Record<Language, string>> = {
|
||||
sv: '1x Naan eller ris ingår',
|
||||
ar: '1x نان أو أرز مشمول',
|
||||
tr: '1x Naan veya pilav dahil',
|
||||
hi: '1x नान या चावल शामिल',
|
||||
ur: '1x نان یا چاول شامل',
|
||||
};
|
||||
|
||||
export function getCategoryName(lang: Language, categoryId: string, fallback: string): string {
|
||||
if (lang === 'en') return EN[categoryId] ?? fallback;
|
||||
return LOCALIZED[lang]?.[categoryId] ?? EN[categoryId] ?? fallback;
|
||||
}
|
||||
|
||||
export function getMenuItemInclusionNote(lang: Language, categoryId: string): string | undefined {
|
||||
if (!INCLUSION_CATEGORIES.has(categoryId)) return undefined;
|
||||
if (lang === 'en') return DISH_INCLUSION_NOTE_EN;
|
||||
return DISH_INCLUSION_NOTE_LOCALIZED[lang] ?? DISH_INCLUSION_NOTE_EN;
|
||||
}
|
||||
|
||||
@@ -37,8 +37,11 @@ export const translations = {
|
||||
// Table Booking / Reserve Page
|
||||
booking: {
|
||||
title: 'Reserve Your Table',
|
||||
subtitle: 'Book a table at Askim or Backaplan. Pre-order your favorites from the full menu for a seamless experience.',
|
||||
subtitle: 'Book a table or plan a private celebration at Askim or Backaplan. Pre-order your favorites for a seamless arrival.',
|
||||
formTitle: 'Your Booking Details',
|
||||
signInRequiredTitle: 'Sign in to continue',
|
||||
signInRequiredSubtitle: 'Log in with Google to continue with your booking.',
|
||||
signedInAs: 'Signed in as',
|
||||
locationLabel: 'Location',
|
||||
locationPlaceholder: 'Select location',
|
||||
dateLabel: 'Date',
|
||||
@@ -51,7 +54,7 @@ export const translations = {
|
||||
emailLabel: 'Email (optional)',
|
||||
emailPlaceholder: 'your@email.com',
|
||||
notesLabel: 'Special Requests (optional)',
|
||||
notesPlaceholder: 'e.g. window seat, birthday, allergies',
|
||||
notesPlaceholder: 'e.g. cake setup, dietary needs, décor preferences, seating layout',
|
||||
continueBtn: 'Continue to Pre-Order Menu',
|
||||
menuTitle: 'Pre-order Food (Optional)',
|
||||
menuSubtitle: 'Select dishes to prepare in advance for your arrival.',
|
||||
@@ -257,14 +260,41 @@ export const translations = {
|
||||
remove: 'Remove',
|
||||
inquiryModalTitlePickup: 'Pickup inquiry',
|
||||
inquiryModalTitleDelivery: 'Delivery inquiry',
|
||||
signInRequiredTitle: 'Sign in to continue',
|
||||
signInRequiredSubtitle: 'Log in with Google to send your pickup or delivery inquiry.',
|
||||
signedInAs: 'Signed in as',
|
||||
nameLabel: 'Full name',
|
||||
namePlaceholder: 'Your name',
|
||||
emailLabel: 'Email',
|
||||
emailPlaceholder: 'your@email.com',
|
||||
emailRequired: 'Please sign in so we can use your Google email.',
|
||||
phoneLabel: 'Phone number',
|
||||
phonePlaceholder: '+46 70 123 4567',
|
||||
addressLabel: 'Delivery address',
|
||||
addressPlaceholder: 'Street, post code, city…',
|
||||
preferredTimeLabel: 'Preferred delivery time',
|
||||
preferredTimePlaceholder: 'e.g. Today 18:00',
|
||||
branchLabel: 'Restaurant branch',
|
||||
branchPlaceholder: 'Select branch',
|
||||
branchAskim: 'Askim',
|
||||
branchBackaplan: 'Backaplan',
|
||||
branchRequired: 'Please select a branch.',
|
||||
askimOnlineUnavailable: 'Coming soon, the branch is not delivering online',
|
||||
addressLabel: 'Delivery address (optional)',
|
||||
addressPlaceholder: 'Start typing your street address…',
|
||||
addressHint: 'Only Gothenburg addresses can be selected. Pick one from the suggestions.',
|
||||
addressFallbackPlaceholder: 'Street, postcode, city…',
|
||||
addressFallbackHint:
|
||||
'Address search is temporarily unavailable. Type your full delivery address manually.',
|
||||
addressSearching: 'Searching addresses…',
|
||||
addressNoResults: 'No matching addresses. Try street name and number.',
|
||||
addressOutsideGothenburg: 'This address is outside Gothenburg. We only deliver within the city.',
|
||||
addressSelectSuggestion: 'Please pick an address from the suggestions.',
|
||||
addressInvalid: 'Select a valid Gothenburg address from the list.',
|
||||
preferredDateLabel: 'Preferred date (optional)',
|
||||
preferredDatePlaceholder: 'Select date',
|
||||
dateToday: 'Today',
|
||||
dateTomorrow: 'Tomorrow',
|
||||
preferredTimeLabel: 'Preferred time (optional)',
|
||||
scheduleOptionalHint: 'Optional: choose today or tomorrow. If you pick today, time must be at least 30 minutes from now.',
|
||||
scheduleIncomplete: 'Please select both date and time, or leave both empty.',
|
||||
scheduleTooSoon: 'Please choose a time at least 30 minutes from now.',
|
||||
submitPickup: 'Send via WhatsApp',
|
||||
submitDelivery: 'Send via WhatsApp',
|
||||
inquiryCancel: 'Cancel',
|
||||
@@ -277,8 +307,11 @@ export const translations = {
|
||||
inquiryDeliveryIntro: "Hi! I'd like to order home delivery:",
|
||||
messageTotal: 'Total',
|
||||
messageName: 'Name',
|
||||
messageEmail: 'Email',
|
||||
messagePhone: 'Phone',
|
||||
messageBranch: 'Branch',
|
||||
messageAddress: 'Address',
|
||||
messagePreferredDate: 'Preferred date',
|
||||
messagePreferredTime: 'Preferred time',
|
||||
deliverySwishNote: "I'll send Swish payment confirmation here once the order is confirmed.",
|
||||
},
|
||||
@@ -296,8 +329,28 @@ export const translations = {
|
||||
continueBrowsing: 'Continue browsing',
|
||||
},
|
||||
|
||||
// Authentication / Staff Login Page (fully translated, placeholder only)
|
||||
// Authentication — Customer & Staff Login
|
||||
auth: {
|
||||
tabs: {
|
||||
customer: 'Customer',
|
||||
staff: 'Staff',
|
||||
},
|
||||
customer: {
|
||||
badge: 'CUSTOMER PORTAL',
|
||||
title: 'Customer Login',
|
||||
subtitle: 'Sign in with your Google account to access your Shahi Kitchen profile.',
|
||||
signInWithGoogle: 'Continue with Google',
|
||||
footerNote: 'More customer features are coming soon.',
|
||||
notConfigured: 'Google sign-in is not configured yet. Please add GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to your environment.',
|
||||
googleDenied: 'Google sign-in was cancelled. Please try again.',
|
||||
googleFailed: 'Could not sign in with Google. Please try again.',
|
||||
loading: 'Loading your account…',
|
||||
welcomeTitle: 'Welcome to Shahi Kitchen',
|
||||
welcomeSubtitle: 'You are successfully signed in. More features for customers will arrive soon.',
|
||||
loggedInAs: 'You are logged in as',
|
||||
logout: 'Sign out',
|
||||
menuManagement: 'Menu Management',
|
||||
},
|
||||
title: 'Staff Login',
|
||||
subtitle: 'Secure access for Shahi Kitchen team members at our locations',
|
||||
usernameLabel: 'Username',
|
||||
@@ -310,6 +363,7 @@ export const translations = {
|
||||
backaplan: 'Backaplan',
|
||||
submit: 'Sign In',
|
||||
submitting: 'Signing in...',
|
||||
accessDenied: 'Access denied. Please contact the administrator.',
|
||||
construction: {
|
||||
title: 'Authentication Under Construction',
|
||||
message: 'Our staff login system is currently under development. The backend authentication service is not yet available. Please try again later. For immediate assistance with orders or reservations, please contact the restaurant directly via phone or WhatsApp.',
|
||||
@@ -414,6 +468,9 @@ export const translations = {
|
||||
title: 'Boka Bord',
|
||||
subtitle: 'Boka bord på Askim eller Backaplan. Förbeställ dina favoriter från hela menyn för en smidig upplevelse.',
|
||||
formTitle: 'Dina Bokningsuppgifter',
|
||||
signInRequiredTitle: 'Logga in för att fortsätta',
|
||||
signInRequiredSubtitle: 'Logga in med Google för att fortsätta med din bokning.',
|
||||
signedInAs: 'Inloggad som',
|
||||
locationLabel: 'Plats',
|
||||
locationPlaceholder: 'Välj plats',
|
||||
dateLabel: 'Datum',
|
||||
@@ -626,14 +683,41 @@ export const translations = {
|
||||
remove: 'Ta bort',
|
||||
inquiryModalTitlePickup: 'Förfrågan om upphämtning',
|
||||
inquiryModalTitleDelivery: 'Förfrågan om hemleverans',
|
||||
signInRequiredTitle: 'Logga in för att fortsätta',
|
||||
signInRequiredSubtitle: 'Logga in med Google för att skicka din hämtnings- eller leveransförfrågan.',
|
||||
signedInAs: 'Inloggad som',
|
||||
nameLabel: 'Fullständigt namn',
|
||||
namePlaceholder: 'Ditt namn',
|
||||
emailLabel: 'E-post',
|
||||
emailPlaceholder: 'din@epost.se',
|
||||
emailRequired: 'Logga in så att vi kan använda din Google-e-post.',
|
||||
phoneLabel: 'Telefonnummer',
|
||||
phonePlaceholder: '+46 70 123 4567',
|
||||
addressLabel: 'Leveransadress',
|
||||
addressPlaceholder: 'Gata, postnummer, stad…',
|
||||
preferredTimeLabel: 'Önskad leveranstid',
|
||||
preferredTimePlaceholder: 't.ex. Idag 18:00',
|
||||
branchLabel: 'Restaurangfilial',
|
||||
branchPlaceholder: 'Välj filial',
|
||||
branchAskim: 'Askim',
|
||||
branchBackaplan: 'Backaplan',
|
||||
branchRequired: 'Välj en filial.',
|
||||
askimOnlineUnavailable: 'Kommer snart, filialen levererar inte online ännu',
|
||||
addressLabel: 'Leveransadress (valfritt)',
|
||||
addressPlaceholder: 'Börja skriva din gatuadress…',
|
||||
addressHint: 'Endast Göteborg-adresser kan väljas. Välj ett förslag från listan.',
|
||||
addressFallbackPlaceholder: 'Gata, postnummer, stad…',
|
||||
addressFallbackHint:
|
||||
'Adresssökning är tillfälligt otillgänglig. Skriv din fullständiga leveransadress manuellt.',
|
||||
addressSearching: 'Söker adresser…',
|
||||
addressNoResults: 'Inga träffar. Prova gatunamn och nummer.',
|
||||
addressOutsideGothenburg: 'Adressen ligger utanför Göteborg. Vi levererar bara inom staden.',
|
||||
addressSelectSuggestion: 'Välj en adress från förslagen.',
|
||||
addressInvalid: 'Välj en giltig Göteborg-adress från listan.',
|
||||
preferredDateLabel: 'Önskat datum (valfritt)',
|
||||
preferredDatePlaceholder: 'Välj datum',
|
||||
dateToday: 'Idag',
|
||||
dateTomorrow: 'Imorgon',
|
||||
preferredTimeLabel: 'Önskad tid (valfritt)',
|
||||
scheduleOptionalHint: 'Valfritt: välj idag eller imorgon. Väljer du idag måste tiden vara minst 30 minuter från nu.',
|
||||
scheduleIncomplete: 'Välj både datum och tid, eller lämna båda tomma.',
|
||||
scheduleTooSoon: 'Välj en tid minst 30 minuter från nu.',
|
||||
submitPickup: 'Skicka via WhatsApp',
|
||||
submitDelivery: 'Skicka via WhatsApp',
|
||||
inquiryCancel: 'Avbryt',
|
||||
@@ -646,8 +730,11 @@ export const translations = {
|
||||
inquiryDeliveryIntro: 'Hej! Jag vill beställa hemleverans:',
|
||||
messageTotal: 'Totalt',
|
||||
messageName: 'Namn',
|
||||
messageEmail: 'E-post',
|
||||
messagePhone: 'Telefon',
|
||||
messageBranch: 'Filial',
|
||||
messageAddress: 'Adress',
|
||||
messagePreferredDate: 'Önskat datum',
|
||||
messagePreferredTime: 'Önskad tid',
|
||||
deliverySwishNote: 'Jag skickar Swish-betalningsbekräftelse här när beställningen är bekräftad.',
|
||||
},
|
||||
@@ -665,8 +752,28 @@ export const translations = {
|
||||
continueBrowsing: 'Fortsätt bläddra',
|
||||
},
|
||||
|
||||
// Authentication / Staff Login Page (fully translated, placeholder only)
|
||||
// Authentication — Customer & Staff Login
|
||||
auth: {
|
||||
tabs: {
|
||||
customer: 'Kund',
|
||||
staff: 'Personal',
|
||||
},
|
||||
customer: {
|
||||
badge: 'KUNDPORTAL',
|
||||
title: 'Kundinloggning',
|
||||
subtitle: 'Logga in med ditt Google-konto för att komma åt din Shahi Kitchen-profil.',
|
||||
signInWithGoogle: 'Fortsätt med Google',
|
||||
footerNote: 'Fler kundfunktioner kommer snart.',
|
||||
notConfigured: 'Google-inloggning är inte konfigurerad ännu.',
|
||||
googleDenied: 'Google-inloggning avbröts. Försök igen.',
|
||||
googleFailed: 'Kunde inte logga in med Google. Försök igen.',
|
||||
loading: 'Laddar ditt konto…',
|
||||
welcomeTitle: 'Välkommen till Shahi Kitchen',
|
||||
welcomeSubtitle: 'Du är nu inloggad. Fler kundfunktioner kommer snart.',
|
||||
loggedInAs: 'Du är inloggad som',
|
||||
logout: 'Logga ut',
|
||||
menuManagement: 'Menyhantering',
|
||||
},
|
||||
title: 'Personalloggning',
|
||||
subtitle: 'Säker åtkomst för Shahi Kitchen teammedlemmar vid våra platser',
|
||||
usernameLabel: 'Användarnamn',
|
||||
@@ -679,6 +786,7 @@ export const translations = {
|
||||
backaplan: 'Backaplan',
|
||||
submit: 'Logga in',
|
||||
submitting: 'Loggar in...',
|
||||
accessDenied: 'Åtkomst nekad. Kontakta administratören.',
|
||||
construction: {
|
||||
title: 'Autentisering under uppbyggnad',
|
||||
message: 'Vårt personallogginssystem är för närvarande under utveckling. Backend-autentiseringstjänsten är inte tillgänglig ännu. Vänligen försök igen senare. För omedelbar hjälp med beställningar eller bokningar, kontakta restaurangen direkt via telefon eller WhatsApp.',
|
||||
@@ -783,6 +891,9 @@ export const translations = {
|
||||
title: 'टेबल बुक करें',
|
||||
subtitle: 'अस्किम या बैकप्लान पर टेबल बुक करें। एक सहज अनुभव के लिए पूर्ण मेनू से अपने पसंदीदा व्यंजनों को पहले से ऑर्डर करें।',
|
||||
formTitle: 'आपकी बुकिंग विवरण',
|
||||
signInRequiredTitle: 'Sign in to continue',
|
||||
signInRequiredSubtitle: 'Log in with Google to continue with your booking.',
|
||||
signedInAs: 'Signed in as',
|
||||
locationLabel: 'स्थान',
|
||||
locationPlaceholder: 'स्थान चुनें',
|
||||
dateLabel: 'तारीख',
|
||||
@@ -1065,14 +1176,32 @@ export const translations = {
|
||||
remove: 'हटाएं',
|
||||
inquiryModalTitlePickup: 'पिकअप पूछताछ',
|
||||
inquiryModalTitleDelivery: 'होम डिलीवरी पूछताछ',
|
||||
signInRequiredTitle: 'Sign in to continue',
|
||||
signInRequiredSubtitle: 'Log in with Google to send your pickup or delivery inquiry.',
|
||||
signedInAs: 'Signed in as',
|
||||
nameLabel: 'पूरा नाम',
|
||||
namePlaceholder: 'आपका नाम',
|
||||
emailLabel: 'Email',
|
||||
emailPlaceholder: 'your@email.com',
|
||||
emailRequired: 'Please sign in so we can use your Google email.',
|
||||
phoneLabel: 'फ़ोन नंबर',
|
||||
phonePlaceholder: '+46 70 123 4567',
|
||||
addressLabel: 'डिलीवरी पता',
|
||||
branchLabel: 'रेस्तरां शाखा',
|
||||
branchPlaceholder: 'शाखा चुनें',
|
||||
branchAskim: 'Askim',
|
||||
branchBackaplan: 'Backaplan',
|
||||
branchRequired: 'कृपया एक शाखा चुनें।',
|
||||
askimOnlineUnavailable: 'जल्द आ रहा है, यह शाखा अभी ऑनलाइन डिलीवरी नहीं करती',
|
||||
addressLabel: 'डिलीवरी पता (वैकल्पिक)',
|
||||
addressPlaceholder: 'सड़क, पिन कोड, शहर…',
|
||||
preferredTimeLabel: 'पसंदीदा डिलीवरी समय',
|
||||
preferredTimePlaceholder: 'जैसे आज 18:00',
|
||||
preferredDateLabel: 'पसंदीदा तारीख (वैकल्पिक)',
|
||||
preferredDatePlaceholder: 'तारीख चुनें',
|
||||
dateToday: 'आज',
|
||||
dateTomorrow: 'कल',
|
||||
preferredTimeLabel: 'पसंदीदा समय (वैकल्पिक)',
|
||||
scheduleOptionalHint: 'वैकल्पिक: आज या कल चुनें। आज चुनने पर समय अभी से कम से कम 30 मिनट बाद होना चाहिए।',
|
||||
scheduleIncomplete: 'कृपया तारीख और समय दोनों चुनें, या दोनों खाली छोड़ दें।',
|
||||
scheduleTooSoon: 'कृपया अभी से कम से कम 30 मिनट बाद का समय चुनें।',
|
||||
submitPickup: 'व्हाट्सएप पर भेजें',
|
||||
submitDelivery: 'व्हाट्सएप पर भेजें',
|
||||
inquiryCancel: 'रद्द करें',
|
||||
@@ -1085,8 +1214,11 @@ export const translations = {
|
||||
inquiryDeliveryIntro: 'नमस्ते! मैं होम डिलीवरी ऑर्डर करना चाहता/चाहती हूँ:',
|
||||
messageTotal: 'कुल',
|
||||
messageName: 'नाम',
|
||||
messageEmail: 'Email',
|
||||
messagePhone: 'फ़ोन',
|
||||
messageBranch: 'शाखा',
|
||||
messageAddress: 'पता',
|
||||
messagePreferredDate: 'पसंदीदा तारीख',
|
||||
messagePreferredTime: 'पसंदीदा समय',
|
||||
deliverySwishNote: 'ऑर्डर की पुष्टि होने के बाद मैं Swish भुगतान की पुष्टि यहाँ भेजूँगा/भेजूँगी।',
|
||||
},
|
||||
@@ -1104,8 +1236,25 @@ export const translations = {
|
||||
continueBrowsing: 'ब्राउज़िंग जारी रखें',
|
||||
},
|
||||
|
||||
// Authentication / Staff Login Page (fully translated, placeholder only)
|
||||
// Authentication — Customer & Staff Login
|
||||
auth: {
|
||||
tabs: { customer: 'ग्राहक', staff: 'स्टाफ' },
|
||||
customer: {
|
||||
badge: 'CUSTOMER PORTAL',
|
||||
title: 'Customer Login',
|
||||
subtitle: 'Sign in with your Google account to access your Shahi Kitchen profile.',
|
||||
signInWithGoogle: 'Continue with Google',
|
||||
footerNote: 'More customer features are coming soon.',
|
||||
notConfigured: 'Google sign-in is not configured yet.',
|
||||
googleDenied: 'Google sign-in was cancelled. Please try again.',
|
||||
googleFailed: 'Could not sign in with Google. Please try again.',
|
||||
loading: 'Loading your account…',
|
||||
welcomeTitle: 'Welcome to Shahi Kitchen',
|
||||
welcomeSubtitle: 'You are successfully signed in. More features for customers will arrive soon.',
|
||||
loggedInAs: 'You are logged in as',
|
||||
logout: 'Sign out',
|
||||
menuManagement: 'Menu Management',
|
||||
},
|
||||
title: 'स्टाफ लॉगिन',
|
||||
subtitle: 'शाही किचन टीम सदस्यों के लिए सुरक्षित पहुंच',
|
||||
usernameLabel: 'उपयोगकर्ता नाम',
|
||||
@@ -1118,6 +1267,7 @@ export const translations = {
|
||||
backaplan: 'Backaplan',
|
||||
submit: 'साइन इन करें',
|
||||
submitting: 'साइन इन हो रहा है...',
|
||||
accessDenied: 'Access denied. Please contact the administrator.',
|
||||
construction: {
|
||||
title: 'प्रमाणीकरण निर्माणाधीन',
|
||||
message: 'हमारा स्टाफ लॉगिन सिस्टम वर्तमान में विकास के अधीन है। बैकएंड प्रमाणीकरण सेवा अभी उपलब्ध नहीं है। कृपया बाद में पुनः प्रयास करें। तत्काल सहायता के लिए, कृपया रेस्तरां से सीधे फोन या व्हाट्सएप के माध्यम से संपर्क करें।',
|
||||
@@ -1147,6 +1297,9 @@ export const translations = {
|
||||
title: 'ٹیبل بک کریں',
|
||||
subtitle: 'اسکیم یا بیکپلان پر ٹیبل بک کریں۔ ایک ہموار تجربے کے لیے مکمل مینو سے اپنے پسندیدہ پکوان پہلے سے آرڈر کریں۔',
|
||||
formTitle: 'آپ کی بکنگ کی تفصیلات',
|
||||
signInRequiredTitle: 'Sign in to continue',
|
||||
signInRequiredSubtitle: 'Log in with Google to continue with your booking.',
|
||||
signedInAs: 'Signed in as',
|
||||
locationLabel: 'مقام',
|
||||
locationPlaceholder: 'مقام منتخب کریں',
|
||||
dateLabel: 'تاریخ',
|
||||
@@ -1429,14 +1582,32 @@ export const translations = {
|
||||
remove: 'ہٹائیں',
|
||||
inquiryModalTitlePickup: 'پک اپ استفسار',
|
||||
inquiryModalTitleDelivery: 'گھر ڈیلیوری استفسار',
|
||||
signInRequiredTitle: 'Sign in to continue',
|
||||
signInRequiredSubtitle: 'Log in with Google to send your pickup or delivery inquiry.',
|
||||
signedInAs: 'Signed in as',
|
||||
nameLabel: 'پورا نام',
|
||||
namePlaceholder: 'آپ کا نام',
|
||||
emailLabel: 'Email',
|
||||
emailPlaceholder: 'your@email.com',
|
||||
emailRequired: 'Please sign in so we can use your Google email.',
|
||||
phoneLabel: 'فون نمبر',
|
||||
phonePlaceholder: '+46 70 123 4567',
|
||||
addressLabel: 'ڈیلیوری پتہ',
|
||||
branchLabel: 'ریسٹورانٹ برانچ',
|
||||
branchPlaceholder: 'برانچ منتخب کریں',
|
||||
branchAskim: 'Askim',
|
||||
branchBackaplan: 'Backaplan',
|
||||
branchRequired: 'براہ کرم ایک برانچ منتخب کریں۔',
|
||||
askimOnlineUnavailable: 'جلد آ رہا ہے، یہ برانچ ابھی آن لائن ڈیلیوری نہیں کرتی',
|
||||
addressLabel: 'ڈیلیوری پتہ (اختیاری)',
|
||||
addressPlaceholder: 'گلی، پوسٹ کوڈ، شہر…',
|
||||
preferredTimeLabel: 'پسندیدہ ڈیلیوری وقت',
|
||||
preferredTimePlaceholder: 'مثلاً آج 18:00',
|
||||
preferredDateLabel: 'پسندیدہ تاریخ (اختیاری)',
|
||||
preferredDatePlaceholder: 'تاریخ منتخب کریں',
|
||||
dateToday: 'آج',
|
||||
dateTomorrow: 'کل',
|
||||
preferredTimeLabel: 'پسندیدہ وقت (اختیاری)',
|
||||
scheduleOptionalHint: 'اختیاری: آج یا کل منتخب کریں۔ آج منتخب کرنے پر وقت اب سے کم از کم 30 منٹ بعد ہونا چاہیے۔',
|
||||
scheduleIncomplete: 'براہ کرم تاریخ اور وقت دونوں منتخب کریں، یا دونوں خالی چھوڑ دیں۔',
|
||||
scheduleTooSoon: 'براہ کرم اب سے کم از کم 30 منٹ بعد کا وقت منتخب کریں۔',
|
||||
submitPickup: 'واٹس ایپ پر بھیجیں',
|
||||
submitDelivery: 'واٹس ایپ پر بھیجیں',
|
||||
inquiryCancel: 'منسوخ',
|
||||
@@ -1449,8 +1620,11 @@ export const translations = {
|
||||
inquiryDeliveryIntro: 'ہیلو! میں گھر ڈیلیوری آرڈر کرنا چاہتا ہوں:',
|
||||
messageTotal: 'کل',
|
||||
messageName: 'نام',
|
||||
messageEmail: 'Email',
|
||||
messagePhone: 'فون',
|
||||
messageBranch: 'برانچ',
|
||||
messageAddress: 'پتہ',
|
||||
messagePreferredDate: 'پسندیدہ تاریخ',
|
||||
messagePreferredTime: 'پسندیدہ وقت',
|
||||
deliverySwishNote: 'آرڈر کی تصدیق کے بعد میں Swish ادائیگی کی تصدیق یہاں بھیجوں گا۔',
|
||||
},
|
||||
@@ -1468,8 +1642,25 @@ export const translations = {
|
||||
continueBrowsing: 'براؤزنگ جاری رکھیں',
|
||||
},
|
||||
|
||||
// Authentication / Staff Login Page (fully translated, placeholder only)
|
||||
// Authentication — Customer & Staff Login
|
||||
auth: {
|
||||
tabs: { customer: 'کسٹمر', staff: 'اسٹاف' },
|
||||
customer: {
|
||||
badge: 'CUSTOMER PORTAL',
|
||||
title: 'Customer Login',
|
||||
subtitle: 'Sign in with your Google account to access your Shahi Kitchen profile.',
|
||||
signInWithGoogle: 'Continue with Google',
|
||||
footerNote: 'More customer features are coming soon.',
|
||||
notConfigured: 'Google sign-in is not configured yet.',
|
||||
googleDenied: 'Google sign-in was cancelled. Please try again.',
|
||||
googleFailed: 'Could not sign in with Google. Please try again.',
|
||||
loading: 'Loading your account…',
|
||||
welcomeTitle: 'Welcome to Shahi Kitchen',
|
||||
welcomeSubtitle: 'You are successfully signed in. More features for customers will arrive soon.',
|
||||
loggedInAs: 'You are logged in as',
|
||||
logout: 'Sign out',
|
||||
menuManagement: 'Menu Management',
|
||||
},
|
||||
title: 'اسٹاف لاگ ان',
|
||||
subtitle: 'شاہی کچن ٹیم کے ممبران کے لیے محفوظ رسائی',
|
||||
usernameLabel: 'صارف نام',
|
||||
@@ -1482,6 +1673,7 @@ export const translations = {
|
||||
backaplan: 'Backaplan',
|
||||
submit: 'سائن ان کریں',
|
||||
submitting: 'سائن ان ہو رہا ہے...',
|
||||
accessDenied: 'Access denied. Please contact the administrator.',
|
||||
construction: {
|
||||
title: 'تصدیق زیر تعمیر',
|
||||
message: 'ہمارا اسٹاف لاگ ان سسٹم فی الحال ترقی کے مراحل میں ہے۔ بیک اینڈ تصدیقی سروس ابھی دستیاب نہیں ہے۔ براہ کرم بعد میں دوبارہ کوشش کریں۔ فوری مدد کے لیے، براہ کرم براہ راست فون یا واٹس ایپ کے ذریعے ریستوراں سے رابطہ کریں۔',
|
||||
@@ -1521,5 +1713,6 @@ function withMenuText(lang: Language, bundle: TranslationBundle): TranslationBun
|
||||
export function getTranslation(lang: Language): TranslationBundle {
|
||||
if (lang === 'ar') return withMenuText(lang, arBundle as TranslationBundle);
|
||||
if (lang === 'tr') return withMenuText(lang, trBundle as TranslationBundle);
|
||||
return withMenuText(lang, translations[lang] as TranslationBundle);
|
||||
const bundle = translations[lang] ?? translations.sv;
|
||||
return withMenuText(lang, bundle as TranslationBundle);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
interface CustomerAuthContextValue {
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isMenuManager: boolean;
|
||||
isLoading: boolean;
|
||||
refreshSession: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const CustomerAuthContext = createContext<CustomerAuthContextValue>({
|
||||
email: null,
|
||||
name: null,
|
||||
isAuthenticated: false,
|
||||
isMenuManager: false,
|
||||
isLoading: true,
|
||||
refreshSession: async () => {},
|
||||
logout: async () => {},
|
||||
});
|
||||
|
||||
export function CustomerAuthProvider({ children }: { children: ReactNode }) {
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [isMenuManager, setIsMenuManager] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refreshSession = useCallback(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;
|
||||
isMenuManager?: boolean;
|
||||
};
|
||||
|
||||
if (data.authenticated && data.email) {
|
||||
setEmail(data.email);
|
||||
setName(data.name ?? null);
|
||||
setIsMenuManager(Boolean(data.isMenuManager));
|
||||
} else {
|
||||
setEmail(null);
|
||||
setName(null);
|
||||
setIsMenuManager(false);
|
||||
}
|
||||
} catch {
|
||||
setEmail(null);
|
||||
setName(null);
|
||||
setIsMenuManager(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await fetch('/api/auth/customer/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
} finally {
|
||||
setEmail(null);
|
||||
setName(null);
|
||||
setIsMenuManager(false);
|
||||
window.dispatchEvent(new Event('shahi-customer-auth-changed'));
|
||||
window.location.assign('/');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSession();
|
||||
}, [refreshSession]);
|
||||
|
||||
useEffect(() => {
|
||||
const onAuthChanged = () => {
|
||||
void refreshSession();
|
||||
};
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void refreshSession();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('shahi-customer-auth-changed', onAuthChanged);
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => {
|
||||
window.removeEventListener('shahi-customer-auth-changed', onAuthChanged);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}, [refreshSession]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
email,
|
||||
name,
|
||||
isAuthenticated: Boolean(email),
|
||||
isMenuManager,
|
||||
isLoading,
|
||||
refreshSession,
|
||||
logout,
|
||||
}),
|
||||
[email, name, isMenuManager, isLoading, refreshSession, logout],
|
||||
);
|
||||
|
||||
return <CustomerAuthContext.Provider value={value}>{children}</CustomerAuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCustomerAuth() {
|
||||
return useContext(CustomerAuthContext);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type { MenuCategory, MenuItem } from '@/domain/menu/entities';
|
||||
import {
|
||||
allMenuItems as staticAllMenuItems,
|
||||
menuCategories as staticMenuCategories,
|
||||
} from '@/infrastructure/menu/static-menu-data';
|
||||
|
||||
interface MenuContextValue {
|
||||
categories: MenuCategory[];
|
||||
allItems: MenuItem[];
|
||||
getItemById: (id: string) => MenuItem | undefined;
|
||||
isLoading: boolean;
|
||||
refreshMenu: () => Promise<void>;
|
||||
}
|
||||
|
||||
const MenuContext = createContext<MenuContextValue>({
|
||||
categories: staticMenuCategories,
|
||||
allItems: staticAllMenuItems,
|
||||
getItemById: (id) => staticAllMenuItems.find((item) => item.id === id),
|
||||
isLoading: false,
|
||||
refreshMenu: async () => {},
|
||||
});
|
||||
|
||||
export function MenuProvider({ children }: { children: ReactNode }) {
|
||||
const [categories, setCategories] = useState<MenuCategory[]>(staticMenuCategories);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refreshMenu = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/menu', { cache: 'no-store' });
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = (await response.json()) as { categories?: MenuCategory[] };
|
||||
if (Array.isArray(data.categories) && data.categories.length > 0) {
|
||||
setCategories(data.categories);
|
||||
}
|
||||
} catch {
|
||||
// Keep the last known menu (static seed on first load).
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshMenu();
|
||||
}, [refreshMenu]);
|
||||
|
||||
// Refetch when the tab becomes visible again (e.g. after editing in /admin).
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void refreshMenu();
|
||||
}
|
||||
};
|
||||
const onMenuUpdated = () => {
|
||||
void refreshMenu();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
window.addEventListener('shahi-menu-updated', onMenuUpdated);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
window.removeEventListener('shahi-menu-updated', onMenuUpdated);
|
||||
};
|
||||
}, [refreshMenu]);
|
||||
|
||||
const allItems = useMemo(() => categories.flatMap((category) => category.items), [categories]);
|
||||
|
||||
const getItemById = useCallback(
|
||||
(id: string) => allItems.find((item) => item.id === id),
|
||||
[allItems],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
categories,
|
||||
allItems,
|
||||
getItemById,
|
||||
isLoading,
|
||||
refreshMenu,
|
||||
}),
|
||||
[categories, allItems, getItemById, isLoading, refreshMenu],
|
||||
);
|
||||
|
||||
return <MenuContext.Provider value={value}>{children}</MenuContext.Provider>;
|
||||
}
|
||||
|
||||
export function useMenu() {
|
||||
return useContext(MenuContext);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user