2026-03-15 14:54:36 -04:00

480 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { api } from '@/lib/api'
interface MenuItem {
id: string
category_id: string
name: string
description: string | null
price: number
is_available: boolean
is_featured: boolean
}
interface Category {
id: string
name: string
sort_order: number
is_active: boolean
items: MenuItem[]
}
// ────────────────────────────────────────────────────────────────
// RESTAURANT MENU MANAGEMENT
// ────────────────────────────────────────────────────────────────
export default function RestaurantMenuPage() {
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [showAddCategory, setShowAddCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [addingCategory, setAddingCategory] = useState(false)
const [addingToCategory, setAddingToCategory] = useState<string | null>(null)
const [editingItem, setEditingItem] = useState<MenuItem | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const load = async () => {
try {
const { data } = await api.get('/restaurants/menu/items')
setCategories(data)
} catch {}
setLoading(false)
}
useEffect(() => { load() }, [])
const addCategory = async () => {
if (!newCategoryName.trim()) return
setAddingCategory(true)
try {
const { data } = await api.post('/restaurants/menu/categories', { name: newCategoryName })
setCategories((prev) => [...prev, { ...data, items: [] }])
setNewCategoryName('')
setShowAddCategory(false)
} catch {}
setAddingCategory(false)
}
const toggleAvailability = async (item: MenuItem) => {
try {
const { data } = await api.patch(`/restaurants/menu/items/${item.id}`, {
isAvailable: !item.is_available,
})
setCategories((prev) => prev.map((cat) => ({
...cat,
items: cat.items.map((i) => i.id === item.id ? { ...i, is_available: data.is_available } : i),
})))
} catch {}
}
const saveItem = async (catId: string, form: NewItemForm) => {
setSaving(true)
setError('')
try {
const { data } = await api.post('/restaurants/menu/items', {
categoryId: catId,
name: form.name,
description: form.description || undefined,
price: parseFloat(form.price),
dietaryTags: form.dietaryTags.length > 0 ? form.dietaryTags : undefined,
})
setCategories((prev) => prev.map((cat) =>
cat.id === catId ? { ...cat, items: [...cat.items, data] } : cat
))
setAddingToCategory(null)
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to add item')
}
setSaving(false)
}
const saveEdit = async (form: EditItemForm) => {
if (!editingItem) return
setSaving(true)
setError('')
try {
const { data } = await api.patch(`/restaurants/menu/items/${editingItem.id}`, {
name: form.name,
description: form.description || null,
price: parseFloat(form.price),
})
setCategories((prev) => prev.map((cat) => ({
...cat,
items: cat.items.map((i) => i.id === editingItem.id ? { ...i, ...data } : i),
})))
setEditingItem(null)
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to update item')
}
setSaving(false)
}
const deleteItem = async (item: MenuItem) => {
if (!confirm(`Delete "${item.name}"?`)) return
try {
await api.delete(`/restaurants/menu/items/${item.id}`)
setCategories((prev) => prev.map((cat) => ({
...cat,
items: cat.items.filter((i) => i.id !== item.id),
})))
} catch {}
}
const totalItems = categories.reduce((sum, c) => sum + c.items.length, 0)
const liveItems = categories.reduce((sum, c) => sum + c.items.filter((i) => i.is_available).length, 0)
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-white border-b border-slate-100 px-6 py-4 sticky top-0 z-10">
<div className="max-w-3xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/restaurant/dashboard" className="text-slate-400 hover:text-vibe-teal text-sm"> Dashboard</Link>
<span className="text-slate-300">|</span>
<h1 className="font-bold text-vibe-dark">Menu</h1>
<span className="text-slate-400 text-sm">{liveItems}/{totalItems} items live</span>
</div>
<button
onClick={() => setShowAddCategory(true)}
className="bg-vibe-teal text-white px-4 py-2 rounded-xl text-sm font-semibold hover:bg-teal-700 transition"
>
+ Add Category
</button>
</div>
</div>
<div className="max-w-3xl mx-auto px-4 py-6 space-y-6">
{/* Add category inline */}
{showAddCategory && (
<div className="bg-white rounded-2xl border-2 border-vibe-teal/40 p-4">
<h3 className="font-semibold text-vibe-dark mb-3">New Category</h3>
<div className="flex gap-3">
<input
autoFocus
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') setShowAddCategory(false) }}
placeholder="e.g. Burgers, Starters, Drinks..."
className="flex-1 border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
/>
<button
onClick={addCategory}
disabled={addingCategory || !newCategoryName.trim()}
className="bg-vibe-teal text-white px-5 py-2.5 rounded-xl font-semibold text-sm hover:bg-teal-700 transition disabled:opacity-50"
>
{addingCategory ? 'Adding...' : 'Add'}
</button>
<button onClick={() => setShowAddCategory(false)} className="text-slate-400 px-3 hover:text-slate-600"></button>
</div>
</div>
)}
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => <div key={i} className="bg-white rounded-2xl h-40 animate-pulse" />)}
</div>
) : categories.length === 0 ? (
<div className="text-center py-20">
<div className="text-5xl mb-4">🍽</div>
<h3 className="font-bold text-vibe-dark text-lg mb-2">No menu yet</h3>
<p className="text-slate-500 text-sm mb-6">Add a category to get started e.g. "Burgers", "Drinks"</p>
<button
onClick={() => setShowAddCategory(true)}
className="bg-vibe-teal text-white px-6 py-3 rounded-xl font-semibold hover:bg-teal-700 transition"
>
+ Add First Category
</button>
</div>
) : (
categories.map((cat) => (
<CategorySection
key={cat.id}
category={cat}
addingItem={addingToCategory === cat.id}
saving={saving}
error={error}
editingItem={editingItem}
onStartAdd={() => { setAddingToCategory(cat.id); setError('') }}
onCancelAdd={() => setAddingToCategory(null)}
onSaveItem={(form) => saveItem(cat.id, form)}
onToggle={toggleAvailability}
onEdit={(item) => { setEditingItem(item); setError('') }}
onCancelEdit={() => setEditingItem(null)}
onSaveEdit={saveEdit}
onDelete={deleteItem}
/>
))
)}
</div>
{/* Edit modal */}
{editingItem && (
<EditItemModal
item={editingItem}
saving={saving}
error={error}
onSave={saveEdit}
onClose={() => setEditingItem(null)}
/>
)}
</div>
)
}
// ────────────────────────────────────────────────────────────────
interface NewItemForm { name: string; description: string; price: string; dietaryTags: string[] }
interface EditItemForm { name: string; description: string; price: string }
const DIETARY_OPTIONS = ['Vegan', 'Vegetarian', 'Gluten-Free', 'Dairy-Free', 'Nut-Free', 'Halal', 'Spicy']
function CategorySection({ category, addingItem, saving, error, editingItem, onStartAdd, onCancelAdd, onSaveItem, onToggle, onEdit, onCancelEdit, onSaveEdit, onDelete }: {
category: Category
addingItem: boolean
saving: boolean
error: string
editingItem: MenuItem | null
onStartAdd: () => void
onCancelAdd: () => void
onSaveItem: (form: NewItemForm) => void
onToggle: (item: MenuItem) => void
onEdit: (item: MenuItem) => void
onCancelEdit: () => void
onSaveEdit: (form: EditItemForm) => void
onDelete: (item: MenuItem) => void
}) {
const [form, setForm] = useState<NewItemForm>({ name: '', description: '', price: '', dietaryTags: [] })
const liveCount = category.items.filter((i) => i.is_available).length
return (
<div className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
{/* Category header */}
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 className="font-bold text-vibe-dark">{category.name}</h2>
<p className="text-xs text-slate-400 mt-0.5">
{category.items.length} items · {liveCount} live
</p>
</div>
<button
onClick={onStartAdd}
className="text-vibe-teal text-sm font-semibold hover:underline"
>
+ Add Item
</button>
</div>
{/* Add item form */}
{addingItem && (
<div className="border-b border-slate-100 bg-slate-50 px-5 py-4">
<h3 className="text-sm font-semibold text-slate-600 mb-3">New Item in {category.name}</h3>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<input
autoFocus
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Item name *"
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
/>
</div>
<div>
<div className="relative">
<span className="absolute left-3 top-2.5 text-slate-400 text-sm">$</span>
<input
type="number"
min="0"
step="0.01"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
placeholder="0.00"
className="w-full border border-slate-200 rounded-xl pl-7 pr-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
/>
</div>
</div>
</div>
<textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Description (optional) — ingredients, allergens, portion size..."
rows={2}
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal resize-none"
/>
<div>
<p className="text-xs text-slate-500 mb-2">Dietary tags (optional)</p>
<div className="flex flex-wrap gap-2">
{DIETARY_OPTIONS.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setForm((f) => ({
...f,
dietaryTags: f.dietaryTags.includes(tag)
? f.dietaryTags.filter((t) => t !== tag)
: [...f.dietaryTags, tag],
}))}
className={`text-xs px-3 py-1 rounded-full border transition ${
form.dietaryTags.includes(tag)
? 'bg-vibe-teal text-white border-vibe-teal'
: 'bg-white text-slate-500 border-slate-200 hover:border-vibe-teal'
}`}
>
{tag}
</button>
))}
</div>
</div>
{error && <p className="text-red-600 text-sm bg-red-50 rounded-xl px-3 py-2">{error}</p>}
<div className="flex gap-3">
<button
onClick={() => { onSaveItem(form); setForm({ name: '', description: '', price: '', dietaryTags: [] }) }}
disabled={saving || !form.name.trim() || !form.price}
className="bg-vibe-teal text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-teal-700 transition disabled:opacity-50"
>
{saving ? 'Adding...' : 'Add to Menu'}
</button>
<button onClick={onCancelAdd} className="text-slate-500 text-sm hover:text-slate-700 px-3">Cancel</button>
</div>
</div>
</div>
)}
{/* Items list */}
{category.items.length === 0 && !addingItem ? (
<div className="px-5 py-6 text-center">
<p className="text-slate-400 text-sm">No items yet.</p>
<button onClick={onStartAdd} className="text-vibe-teal text-sm hover:underline mt-1">+ Add the first item</button>
</div>
) : (
<div className="divide-y divide-slate-50">
{category.items.map((item) => (
<div key={item.id} className={`px-5 py-3.5 flex items-center justify-between gap-4 ${!item.is_available ? 'opacity-60' : ''}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-vibe-dark text-sm">{item.name}</span>
{item.is_featured && <span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded-full">Featured</span>}
{!item.is_available && <span className="text-xs bg-slate-100 text-slate-400 px-1.5 py-0.5 rounded-full">Hidden</span>}
</div>
{item.description && (
<p className="text-xs text-slate-400 mt-0.5 truncate">{item.description}</p>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<span className="font-bold text-vibe-dark text-sm">${Number(item.price).toFixed(2)}</span>
{/* Live/hidden toggle */}
<button
onClick={() => onToggle(item)}
title={item.is_available ? 'Click to hide from customers' : 'Click to make live'}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
item.is_available ? 'bg-vibe-teal' : 'bg-slate-200'
}`}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${
item.is_available ? 'translate-x-4.5' : 'translate-x-0.5'
}`} />
</button>
<button
onClick={() => onEdit(item)}
className="text-slate-400 hover:text-vibe-teal text-xs px-2 py-1 rounded-lg hover:bg-slate-50 transition"
>
Edit
</button>
<button
onClick={() => onDelete(item)}
className="text-slate-300 hover:text-red-500 text-xs px-2 py-1 rounded-lg hover:bg-red-50 transition"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}
function EditItemModal({ item, saving, error, onSave, onClose }: {
item: MenuItem
saving: boolean
error: string
onSave: (form: EditItemForm) => void
onClose: () => void
}) {
const [form, setForm] = useState<EditItemForm>({
name: item.name,
description: item.description || '',
price: String(Number(item.price).toFixed(2)),
})
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4">
<div className="bg-white rounded-2xl w-full max-w-md p-6 shadow-xl">
<h2 className="font-bold text-vibe-dark text-lg mb-4">Edit Item</h2>
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Name *</label>
<input
autoFocus
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Description</label>
<textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={3}
className="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal resize-none"
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Price *</label>
<div className="relative">
<span className="absolute left-4 top-2.5 text-slate-400 text-sm">$</span>
<input
type="number"
min="0"
step="0.01"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
className="w-full border border-slate-200 rounded-xl pl-8 pr-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
/>
</div>
</div>
{error && <p className="text-red-600 text-sm bg-red-50 rounded-xl px-3 py-2">{error}</p>}
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => onSave(form)}
disabled={saving || !form.name.trim() || !form.price}
className="flex-1 bg-vibe-teal text-white py-3 rounded-xl font-semibold text-sm hover:bg-teal-700 transition disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button onClick={onClose} className="px-5 py-3 text-slate-500 text-sm hover:text-slate-700">
Cancel
</button>
</div>
</div>
</div>
)
}