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 })} + /> +
+ +
+ +