df1f32c23d
- 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.
456 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|