From 8ccf033329da3af50ac3b7511fe73bf7e7d9466e Mon Sep 17 00:00:00 2001 From: root Date: Fri, 3 Jul 2026 02:51:15 +0000 Subject: [PATCH] Replace entire repo content with code from /root/shahikitchen-google/ --- app/account/page.tsx | 103 +++ app/admin/layout.tsx | 12 + app/admin/page.tsx | 872 ++++++++++++++++++ app/api/admin/login/route.ts | 23 + app/api/admin/logout/route.ts | 9 + app/api/admin/menu/route.ts | 208 +++++ app/api/admin/menu/versions/[id]/route.ts | 29 + app/api/admin/menu/versions/route.ts | 75 ++ app/api/admin/session/route.ts | 15 + app/api/admin/upload/route.ts | 46 + app/api/auth/callback/google/route.ts | 79 ++ app/api/auth/customer/logout/route.ts | 28 + app/api/auth/customer/session/route.ts | 16 + app/api/auth/google/route.ts | 47 + app/api/menu/route.ts | 9 + app/catering/page.tsx | 6 +- app/globals.css | 10 +- app/layout.tsx | 26 +- app/locations/page.tsx | 8 +- app/login/page.tsx | 270 ++++-- app/menu/page.tsx | 182 +++- app/orderfromtable/OrderFromTableClient.tsx | 4 +- app/page.tsx | 11 +- app/reserve/page.tsx | 358 +++++-- application/admin/validate-credentials.ts | 6 + application/i18n/menu-localization.ts | 9 +- application/language/language-use-cases.ts | 16 +- application/messaging/inquiry-language.ts | 33 + .../messaging/whatsapp-message-builder.ts | 127 ++- components/CartDrawer.tsx | 231 ++++- components/CartInquiryModal.tsx | 385 +++++--- components/CateringPackageCard.tsx | 10 +- components/DeliveryAddressAutocomplete.tsx | 269 ++++++ components/DynamicToaster.tsx | 31 + components/Footer.tsx | 2 +- components/HeaderHeightSync.tsx | 29 + components/HeaderSpacer.tsx | 26 +- components/LanguageSwitcher.tsx | 182 +++- components/Navbar.tsx | 12 +- components/WishlistDrawer.tsx | 9 +- components/admin/MenuVersionTimeline.tsx | 346 +++++++ components/reserve/EventTypePicker.tsx | 99 ++ data/menu-versions/baseline.json | 722 +++++++++++++++ data/menu-versions/index.json | 13 + data/menu.json | 715 ++++++++++++++ domain/auth/menu-managers.ts | 15 + domain/booking/entities.ts | 4 + domain/booking/event-types.ts | 76 ++ domain/booking/validation.ts | 8 +- domain/cart/inquiry.ts | 65 +- domain/delivery/gothenburg-address.ts | 55 ++ domain/menu/versioning.ts | 28 + infrastructure/auth/admin-session.ts | 51 + infrastructure/auth/customer-session.ts | 87 ++ infrastructure/auth/google-oauth.ts | 95 ++ infrastructure/auth/oauth-return.ts | 28 + infrastructure/auth/require-admin.ts | 6 + infrastructure/auth/require-menu-manager.ts | 17 + .../geocoding/geoapify-autocomplete.ts | 121 +++ .../geocoding/geoapify-availability.ts | 50 + .../geocoding/geoapify-request-cache.ts | 130 +++ infrastructure/geocoding/geoapify-types.ts | 22 + infrastructure/menu/menu-persistence.ts | 248 +++++ infrastructure/menu/menu-versioning.ts | 281 ++++++ infrastructure/menu/static-menu-data.ts | 12 +- lib/header-constants.ts | 6 +- next-env.d.ts | 2 +- package-lock.json | 141 +++ presentation/i18n/booking-events.ts | 210 +++++ presentation/i18n/languages/ar.ts | 49 +- presentation/i18n/languages/tr.ts | 49 +- presentation/i18n/menu/category-names.ts | 18 + presentation/i18n/translations.ts | 235 ++++- .../providers/customer-auth-provider.tsx | 123 +++ presentation/providers/menu-provider.tsx | 99 ++ public/images/dishes/aloo.jpeg | Bin 0 -> 48764 bytes public/images/dishes/burger.jpg | Bin 0 -> 294437 bytes public/images/dishes/sandwich.jpg | Bin 0 -> 265680 bytes tsconfig.tsbuildinfo | 1 - 79 files changed, 7611 insertions(+), 439 deletions(-) create mode 100644 app/account/page.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/api/admin/login/route.ts create mode 100644 app/api/admin/logout/route.ts create mode 100644 app/api/admin/menu/route.ts create mode 100644 app/api/admin/menu/versions/[id]/route.ts create mode 100644 app/api/admin/menu/versions/route.ts create mode 100644 app/api/admin/session/route.ts create mode 100644 app/api/admin/upload/route.ts create mode 100644 app/api/auth/callback/google/route.ts create mode 100644 app/api/auth/customer/logout/route.ts create mode 100644 app/api/auth/customer/session/route.ts create mode 100644 app/api/auth/google/route.ts create mode 100644 app/api/menu/route.ts create mode 100644 application/admin/validate-credentials.ts create mode 100644 application/messaging/inquiry-language.ts create mode 100644 components/DeliveryAddressAutocomplete.tsx create mode 100644 components/DynamicToaster.tsx create mode 100644 components/HeaderHeightSync.tsx create mode 100644 components/admin/MenuVersionTimeline.tsx create mode 100644 components/reserve/EventTypePicker.tsx create mode 100644 data/menu-versions/baseline.json create mode 100644 data/menu-versions/index.json create mode 100644 data/menu.json create mode 100644 domain/auth/menu-managers.ts create mode 100644 domain/booking/event-types.ts create mode 100644 domain/delivery/gothenburg-address.ts create mode 100644 domain/menu/versioning.ts create mode 100644 infrastructure/auth/admin-session.ts create mode 100644 infrastructure/auth/customer-session.ts create mode 100644 infrastructure/auth/google-oauth.ts create mode 100644 infrastructure/auth/oauth-return.ts create mode 100644 infrastructure/auth/require-admin.ts create mode 100644 infrastructure/auth/require-menu-manager.ts create mode 100644 infrastructure/geocoding/geoapify-autocomplete.ts create mode 100644 infrastructure/geocoding/geoapify-availability.ts create mode 100644 infrastructure/geocoding/geoapify-request-cache.ts create mode 100644 infrastructure/geocoding/geoapify-types.ts create mode 100644 infrastructure/menu/menu-persistence.ts create mode 100644 infrastructure/menu/menu-versioning.ts create mode 100644 presentation/i18n/booking-events.ts create mode 100644 presentation/providers/customer-auth-provider.tsx create mode 100644 presentation/providers/menu-provider.tsx create mode 100644 public/images/dishes/aloo.jpeg create mode 100644 public/images/dishes/burger.jpg create mode 100644 public/images/dishes/sandwich.jpg delete mode 100644 tsconfig.tsbuildinfo diff --git a/app/account/page.tsx b/app/account/page.tsx new file mode 100644 index 0000000..cf027e5 --- /dev/null +++ b/app/account/page.tsx @@ -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(null); + const [name, setName] = useState(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 ( +
+ {t.auth.customer.loading} +
+ ); + } + + return ( +
+ + +
+
+ Shahi Kitchen +
+ +

{t.auth.customer.badge}

+

+ {t.auth.customer.welcomeTitle} +

+

+ {t.auth.customer.welcomeSubtitle} +

+ +
+

{t.auth.customer.loggedInAs}

+ {name && ( +

{name}

+ )} +
+ + {email} +
+
+ + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..dd938d0 --- /dev/null +++ b/app/admin/layout.tsx @@ -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; +} \ No newline at end of file diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..07702ad --- /dev/null +++ b/app/admin/page.tsx @@ -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 { + const item: Partial = { + 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([]); + const [activeCategoryId, setActiveCategoryId] = useState(''); + const [selectedItemId, setSelectedItemId] = useState(''); + const [form, setForm] = useState(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(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( + `[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 ( +
+ {isAuthLoading ? 'Checking access…' : 'Redirecting…'} +
+ ); + } + + 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 ( +
+ + +
+
+
+

ADMIN

+

Menu Management

+

+ Add, edit, or remove dishes and categories. Changes apply to the public menu right away. +

+
+ +
+ +
+ { + setCategories(restoredCategories); + setActiveCategoryId((current) => { + if (current && restoredCategories.some((c) => c.id === current)) return current; + return restoredCategories[0]?.id || ''; + }); + clearSelection(); + bumpVersionHistory(); + }} + onNotifyMenuUpdated={notifyMenuUpdated} + /> +
+ +
+ {([ + { id: 'categories' as const, label: 'Categories' }, + { id: 'dishes' as const, label: 'Dishes' }, + { id: 'editor' as const, label: 'Edit' }, + ]).map((tab) => ( + + ))} +
+ +
+ + +
+
+

+ Dishes +

+ +
+ +
+ + setSearch(e.target.value)} + placeholder="Search dishes…" + className={`${inputClass} ps-10`} + /> +
+ +
+ {filteredItems.length === 0 ? ( +

+ {search ? 'No dishes match your search.' : 'No dishes in this category yet.'} +

+ ) : ( + filteredItems.map((item) => ( + + )) + )} +
+
+ +
+ {!form ? ( +

+ Select a dish to edit, or click “Add dish” to create a new one. +

+ ) : ( +
+ +
+

+ {isCreatingItem ? 'New dish' : selectedItem?.name} +

+ {!isCreatingItem && selectedItem && ( +

ID: {selectedItem.id}

+ )} +
+ + {previewItem?.image && ( +
+ {previewItem.name} +
+ )} + + {previewItem && !previewItem.image && !isCreatingItem && selectedItem && ( +
+ {previewItem.name} +
+ )} + +
+ + setForm({ ...form, name: e.target.value })} + /> +
+ +
+ +