From 82287e4d0b78ce6dfb1a82dc416c67cf0feaef8b Mon Sep 17 00:00:00 2001 From: ibdevlawal Date: Sat, 27 Jun 2026 23:55:50 +0100 Subject: [PATCH 1/2] menu implement menu management with item addition, editing, and availability toggling --- .../app/dashboard/menu/AvailabilityToggle.tsx | 75 ++++++ apps/web/app/dashboard/menu/MenuItemModal.tsx | 219 ++++++++++++++++++ apps/web/app/dashboard/menu/MenuItemTable.tsx | 98 ++++++++ apps/web/app/dashboard/menu/page.tsx | 148 ++++++++++++ apps/web/components/ImageUploader.tsx | 90 +++++++ 5 files changed, 630 insertions(+) create mode 100644 apps/web/app/dashboard/menu/AvailabilityToggle.tsx create mode 100644 apps/web/app/dashboard/menu/MenuItemModal.tsx create mode 100644 apps/web/app/dashboard/menu/MenuItemTable.tsx create mode 100644 apps/web/app/dashboard/menu/page.tsx create mode 100644 apps/web/components/ImageUploader.tsx diff --git a/apps/web/app/dashboard/menu/AvailabilityToggle.tsx b/apps/web/app/dashboard/menu/AvailabilityToggle.tsx new file mode 100644 index 0000000..f5e1be0 --- /dev/null +++ b/apps/web/app/dashboard/menu/AvailabilityToggle.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useState } from 'react'; + +interface Props { + itemId: string; + restaurantId: string; + isAvailable: boolean; + token: string; + onToggled: (itemId: string, isAvailable: boolean) => void; +} + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000'; + +export function AvailabilityToggle({ itemId, restaurantId, isAvailable, token, onToggled }: Props) { + const [optimistic, setOptimistic] = useState(isAvailable); + const [loading, setLoading] = useState(false); + + async function toggle() { + const next = !optimistic; + setOptimistic(next); + setLoading(true); + + try { + const res = await fetch(`${API_URL}/api/restaurants/${restaurantId}/food-items/${itemId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ isAvailable: next }), + }); + + if (!res.ok) { + setOptimistic(!next); + return; + } + + onToggled(itemId, next); + } catch { + setOptimistic(!next); + } finally { + setLoading(false); + } + } + + return ( + + ); +} diff --git a/apps/web/app/dashboard/menu/MenuItemModal.tsx b/apps/web/app/dashboard/menu/MenuItemModal.tsx new file mode 100644 index 0000000..fc5603e --- /dev/null +++ b/apps/web/app/dashboard/menu/MenuItemModal.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { DIETARY_TAGS } from '@discoverly/shared'; +import { useEffect, useState } from 'react'; +import { z } from 'zod'; +import { ImageUploader } from '../../../components/ImageUploader'; +import { TagMultiSelect } from '../../../components/TagMultiSelect'; + +const foodItemSchema = z.object({ + name: z.string().trim().min(2, 'Name must be at least 2 characters').max(100), + description: z.string().trim().max(300).optional(), + price: z.number().positive('Price must be positive'), + category: z.string().trim().min(1, 'Category is required').max(50), + dietaryTags: z.array(z.string()).max(10).optional(), + imageUrls: z.array(z.string().url()).max(5).optional(), +}); + +export interface FoodItemFormData { + name: string; + description: string; + priceDollars: string; + category: string; + dietaryTags: string[]; + imageUrls: string[]; +} + +interface FoodItem { + _id: string; + name: string; + description: string; + price: number; + category: string; + dietaryTags: string[]; + imageUrls: string[]; + isAvailable: boolean; +} + +interface Props { + open: boolean; + onClose: () => void; + onSave: (data: Record) => Promise; + editItem: FoodItem | null; + token: string; + categories: string[]; +} + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000'; + +const EMPTY_FORM: FoodItemFormData = { + name: '', + description: '', + priceDollars: '', + category: '', + dietaryTags: [], + imageUrls: [], +}; + +export function MenuItemModal({ open, onClose, onSave, editItem, token, categories }: Props) { + const [form, setForm] = useState(EMPTY_FORM); + const [errors, setErrors] = useState>({}); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + useEffect(() => { + if (editItem) { + setForm({ + name: editItem.name, + description: editItem.description ?? '', + priceDollars: (editItem.price / 100).toFixed(2), + category: editItem.category, + dietaryTags: editItem.dietaryTags ?? [], + imageUrls: editItem.imageUrls ?? [], + }); + } else { + setForm(EMPTY_FORM); + } + setErrors({}); + setSubmitError(null); + }, [editItem, open]); + + if (!open) return null; + + function update(field: K, value: FoodItemFormData[K]) { + setForm((prev) => ({ ...prev, [field]: value })); + } + + async function handleSubmit() { + const priceCents = Math.round(parseFloat(form.priceDollars) * 100); + + const result = foodItemSchema.safeParse({ + name: form.name, + description: form.description || undefined, + price: isNaN(priceCents) ? -1 : priceCents, + category: form.category, + dietaryTags: form.dietaryTags.length ? form.dietaryTags : undefined, + imageUrls: form.imageUrls.length ? form.imageUrls : undefined, + }); + + if (!result.success) { + const fieldErrors: Record = {}; + for (const issue of result.error.issues) { + const key = issue.path[0] as string; + if (!fieldErrors[key]) fieldErrors[key] = issue.message; + } + setErrors(fieldErrors); + return; + } + + setErrors({}); + setSubmitting(true); + setSubmitError(null); + + try { + await onSave(result.data); + onClose(); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Save failed'); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

{editItem ? 'Edit Item' : 'Add Item'}

+ + {submitError && ( +

{submitError}

+ )} + +
+ + update('name', e.target.value)} /> + {errors['name'] &&

{errors['name']}

} +
+ +
+ +