Files
frontend/components/organisms/manager/MenuItemsTab.tsx
T
Thanh Quy - wolf df1f32c23d feat: enhance login, registration, and payment functionalities
- Added `credentials: "include"` to fetch requests in LoginOtpPage and RegisterPage for cookie handling.
- Introduced `eateryId` in PaymentPage to manage cart context.
- Updated ProductCardProps and ShopCardProps to include `eateryId`.
- Enhanced PaymentSummaryCard to accept `eateryId` prop.
- Modified ShopCard to set `eateryId` in cart context on click.
- Implemented menu item management in MenuItemsTab with add, edit, and delete functionalities.
- Created new API functions for handling reviews and cart operations.
- Updated GraphQL queries and mutations for menu items and cart management.
- Added loading states and error handling in ProductGrid and ReviewModal.
- Enhanced local storage management for cart and eatery ID.
2026-05-08 21:59:18 +07:00

456 lines
17 KiB
TypeScript

"use client";
import { useAuth } from "@/lib/auth-context";
import {
addMenuItem,
deleteMenuItem,
gqlFetch,
getMenuItemsByEatery,
updateMenuItem,
} from "@/lib/graphql";
import type { MenuItem } from "@/lib/types";
import { useCallback, useEffect, useState } from "react";
interface Eatery {
id: string;
ownerId: string;
}
function formatPrice(price: number) {
return price.toLocaleString("en-US") + "₫";
}
export default function MenuItemsTab() {
const { user } = useAuth();
const [eateryId, setEateryId] = useState<string | null>(null);
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Add state
const [showForm, setShowForm] = useState(false);
const [newName, setNewName] = useState("");
const [newPrice, setNewPrice] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Edit state
const [editItem, setEditItem] = useState<MenuItem | null>(null);
const [editName, setEditName] = useState("");
const [editPrice, setEditPrice] = useState("");
const [editSubmitting, setEditSubmitting] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
// Delete state
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const loadEateryId = useCallback(async (): Promise<string | null> => {
if (!user) return null;
const data = await gqlFetch<{ allEateries: Eatery[] }>(
"{ allEateries { id ownerId } }",
);
return (
data.allEateries.find((e) => e.ownerId === String(user.id))?.id ?? null
);
}, [user]);
const loadMenuItems = useCallback(
(id: string) => getMenuItemsByEatery(id),
[],
);
useEffect(() => {
async function init() {
setLoading(true);
setError(null);
try {
const id = await loadEateryId();
setEateryId(id);
if (id) setMenuItems(await loadMenuItems(id));
else setMenuItems([]);
} catch {
setError("Cannot load menu data. Please check the backend connection.");
} finally {
setLoading(false);
}
}
init();
}, [loadEateryId, loadMenuItems]);
// --- Add ---
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim() || !newPrice) return;
setSubmitting(true);
setSubmitError(null);
try {
const added = await addMenuItem({
name: newName.trim(),
price: parseFloat(newPrice),
});
if (added) setMenuItems((prev) => [...prev, added]);
setNewName("");
setNewPrice("");
setShowForm(false);
} catch {
setSubmitError("Cannot add dish. Please try again.");
} finally {
setSubmitting(false);
}
};
const closeForm = () => {
setShowForm(false);
setSubmitError(null);
setNewName("");
setNewPrice("");
};
// --- Edit ---
const openEdit = (item: MenuItem) => {
setEditItem(item);
setEditName(item.name);
setEditPrice(String(item.price));
setEditError(null);
};
const closeEdit = () => {
setEditItem(null);
setEditName("");
setEditPrice("");
setEditError(null);
};
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editItem || !editName.trim() || !editPrice) return;
setEditSubmitting(true);
setEditError(null);
try {
const updated = await updateMenuItem({
id: editItem.id,
name: editName.trim(),
price: parseFloat(editPrice),
});
if (updated) {
setMenuItems((prev) =>
prev.map((item) => (item.id === updated.id ? updated : item)),
);
}
closeEdit();
} catch {
setEditError("Cannot update dish. Please try again.");
} finally {
setEditSubmitting(false);
}
};
// --- Delete ---
const handleDelete = async (id: string) => {
setDeletingId(id);
setConfirmDeleteId(null);
try {
const ok = await deleteMenuItem(id);
if (ok) setMenuItems((prev) => prev.filter((item) => item.id !== id));
} catch {
// silently fail — row stays; user can retry
} finally {
setDeletingId(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-(--color-text-muted)">
<i className="fa-solid fa-spinner mr-2 animate-spin"></i>
Loading menu...
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-16 text-red-500">
<i className="fa-solid fa-circle-exclamation mb-2 text-2xl"></i>
<p className="text-sm">{error}</p>
</div>
);
}
if (!eateryId) {
return (
<div className="flex flex-col items-center justify-center py-16 text-(--color-text-muted)">
<i className="fa-solid fa-store mb-2 block text-3xl opacity-30"></i>
<p className="text-sm">Your eatery has not been initialized in the system.</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{menuItems.length}</strong> items on the menu
</p>
<button
onClick={() => setShowForm(true)}
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-plus"></i>
<span className="hidden sm:inline">Add new dish</span>
</button>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
<table className="min-w-full divide-y divide-(--color-border-light) text-sm">
<thead className="bg-background">
<tr>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Dish name
</th>
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
Price
</th>
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-(--color-border-light)">
{menuItems.length === 0 ? (
<tr>
<td
colSpan={3}
className="py-12 text-center text-(--color-text-muted)"
>
<i className="fa-solid fa-bowl-food mb-2 block text-3xl opacity-30"></i>
There are no items on the menu yet.
</td>
</tr>
) : (
menuItems.map((item) => (
<tr
key={item.id}
className="transition-colors hover:bg-background"
>
<td className="px-4 py-3">
<p className="text-foreground font-medium">{item.name}</p>
</td>
<td className="px-4 py-3 text-right font-semibold text-(--color-primary)">
{formatPrice(item.price)}
</td>
<td className="px-4 py-3 text-right">
{deletingId === item.id ? (
<i className="fa-solid fa-spinner animate-spin text-(--color-text-muted)"></i>
) : confirmDeleteId === item.id ? (
<span className="inline-flex items-center gap-2">
<span className="text-xs text-(--color-text-muted)">
Are you sure?
</span>
<button
onClick={() => handleDelete(item.id)}
className="cursor-pointer rounded-lg border-none bg-red-500 px-2 py-1 text-xs font-semibold text-white transition hover:bg-red-600"
>
Yes
</button>
<button
onClick={() => setConfirmDeleteId(null)}
className="cursor-pointer rounded-lg border border-(--color-border-light) bg-transparent px-2 py-1 text-xs font-medium text-(--color-text-secondary) transition hover:bg-background"
>
No
</button>
</span>
) : (
<span className="inline-flex items-center gap-1">
<button
onClick={() => openEdit(item)}
className="flex cursor-pointer items-center gap-1.5 rounded-lg border-none bg-transparent px-2 py-1.5 text-xs font-medium text-(--color-text-muted) transition hover:bg-background hover:text-(--color-primary)"
title="Edit"
>
<i className="fa-solid fa-pen-to-square"></i>
<span className="hidden lg:inline">Edit</span>
</button>
<button
onClick={() => setConfirmDeleteId(item.id)}
className="flex cursor-pointer items-center gap-1.5 rounded-lg border-none bg-transparent px-2 py-1.5 text-xs font-medium text-(--color-text-muted) transition hover:bg-red-50 hover:text-red-500"
title="Delete"
>
<i className="fa-solid fa-trash"></i>
<span className="hidden lg:inline">Delete</span>
</button>
</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Add Item Modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-foreground font-bold">Add new dish</h2>
<button
onClick={closeForm}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark"></i>
</button>
</div>
<form onSubmit={handleAdd} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
Dish name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Enter dish name..."
required
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
Price (VND) <span className="text-red-500">*</span>
</label>
<input
type="number"
value={newPrice}
onChange={(e) => setNewPrice(e.target.value)}
placeholder="Enter price..."
required
min={0}
step={500}
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
</div>
{submitError && (
<p className="text-sm text-red-500">
<i className="fa-solid fa-circle-exclamation mr-1"></i>
{submitError}
</p>
)}
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={closeForm}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent py-2 text-sm font-medium text-(--color-text-secondary) transition hover:bg-background"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) disabled:opacity-60"
>
{submitting ? (
<>
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
Saving...
</>
) : (
"Add more dishes"
)}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Item Modal */}
{editItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-foreground font-bold">Edit dish</h2>
<button
onClick={closeEdit}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark"></i>
</button>
</div>
<form onSubmit={handleEdit} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
Dish name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Enter dish name..."
required
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
Price (VND) <span className="text-red-500">*</span>
</label>
<input
type="number"
value={editPrice}
onChange={(e) => setEditPrice(e.target.value)}
placeholder="Enter price..."
required
min={0}
step={500}
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
</div>
{editError && (
<p className="text-sm text-red-500">
<i className="fa-solid fa-circle-exclamation mr-1"></i>
{editError}
</p>
)}
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={closeEdit}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent py-2 text-sm font-medium text-(--color-text-secondary) transition hover:bg-background"
>
Cancel
</button>
<button
type="submit"
disabled={editSubmitting}
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) disabled:opacity-60"
>
{editSubmitting ? (
<>
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
Saving...
</>
) : (
"Save changes"
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}