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.
This commit is contained in:
Thanh Quy - wolf
2026-05-08 21:59:18 +07:00
parent 3caa37426e
commit df1f32c23d
18 changed files with 619 additions and 56 deletions
+1
View File
@@ -49,6 +49,7 @@ export default function LoginOtpPage() {
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ phone, otp }),
});
+2
View File
@@ -14,6 +14,7 @@ export default function PaymentPage() {
const {
items,
totalPrice,
eateryId,
increaseQty,
decreaseQty,
removeFromCart,
@@ -132,6 +133,7 @@ export default function PaymentPage() {
totalPrice={totalPrice}
isCustomer={isCustomer}
backHref="/"
eateryId={eateryId ?? undefined}
/>
</div>
</div>
+1
View File
@@ -116,6 +116,7 @@ export default function RegisterPage() {
const res = await fetch("/api/customer/quick_login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ phone, otp }),
});
+2
View File
@@ -11,6 +11,7 @@ export interface ProductCardProps {
export interface ShopCardProps {
id: number;
eateryId: string;
name: string;
address: string;
image: string;
@@ -21,5 +22,6 @@ export interface PaymentSummaryCardProps {
totalPrice: number;
isCustomer: boolean;
backHref: string;
eateryId?: string;
onPay?: () => void;
}
@@ -12,6 +12,7 @@ export default function PaymentSummaryCard({
totalPrice,
isCustomer = false,
backHref,
eateryId,
}: PaymentSummaryCardProps) {
const [isReviewOpen, setIsReviewOpen] = useState(false);
const handlePayment = () => {
@@ -86,6 +87,7 @@ export default function PaymentSummaryCard({
<ReviewModal
isOpen={isReviewOpen}
onClose={() => setIsReviewOpen(false)}
eateryId={eateryId}
/>
</aside>
);
+6 -1
View File
@@ -1,9 +1,13 @@
"use client";
import { useCart } from "@/lib/cart-context";
import Image from "next/image";
import Link from "next/link";
import type { ShopCardProps } from "./Card.types";
export default function ShopCard({ name, address, image }: ShopCardProps) {
export default function ShopCard({ name, address, image, eateryId }: ShopCardProps) {
const { setEateryId } = useCart();
return (
<div className="overflow-hidden rounded-2xl border border-(--color-border) bg-(--color-bg-card) shadow-[0_2px_12px_var(--color-shadow-sm)] transition-all duration-250 hover:-translate-y-1 hover:shadow-[0_4px_20px_var(--color-shadow-md)]">
{/* Shop image */}
@@ -25,6 +29,7 @@ export default function ShopCard({ name, address, image }: ShopCardProps) {
</h3>
<Link
href="/"
onClick={() => setEateryId(eateryId)}
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl bg-(--color-primary) px-3.5 py-2 text-xs font-semibold text-white no-underline transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-book-open text-[10px]"></i>
+209 -19
View File
@@ -1,15 +1,16 @@
"use client";
import { useAuth } from "@/lib/auth-context";
import { addManagerMenuItem, gqlFetch } from "@/lib/graphql";
import {
addMenuItem,
deleteMenuItem,
gqlFetch,
getMenuItemsByEatery,
updateMenuItem,
} from "@/lib/graphql";
import type { MenuItem } from "@/lib/types";
import { useCallback, useEffect, useState } from "react";
interface MenuItem {
id: string;
name: string;
price: number;
}
interface Eatery {
id: string;
ownerId: string;
@@ -26,12 +27,24 @@ export default function MenuItemsTab() {
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[] }>(
@@ -42,15 +55,10 @@ export default function MenuItemsTab() {
);
}, [user]);
const loadMenuItems = useCallback(async (id: string): Promise<MenuItem[]> => {
const data = await gqlFetch<{ menuItemsByEatery: MenuItem[] }>(
`query($eateryId: String!) {
menuItemsByEatery(eateryId: $eateryId) { id name price }
}`,
{ eateryId: id },
);
return data.menuItemsByEatery ?? [];
}, []);
const loadMenuItems = useCallback(
(id: string) => getMenuItemsByEatery(id),
[],
);
useEffect(() => {
async function init() {
@@ -70,13 +78,17 @@ export default function MenuItemsTab() {
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 addManagerMenuItem(newName.trim(), parseFloat(newPrice));
const added = await addMenuItem({
name: newName.trim(),
price: parseFloat(newPrice),
});
if (added) setMenuItems((prev) => [...prev, added]);
setNewName("");
setNewPrice("");
@@ -95,6 +107,59 @@ export default function MenuItemsTab() {
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)">
@@ -149,13 +214,16 @@ export default function MenuItemsTab() {
<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={2}
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>
@@ -166,7 +234,7 @@ export default function MenuItemsTab() {
menuItems.map((item) => (
<tr
key={item.id}
className="hover:bg-background transition-colors"
className="transition-colors hover:bg-background"
>
<td className="px-4 py-3">
<p className="text-foreground font-medium">{item.name}</p>
@@ -174,6 +242,48 @@ export default function MenuItemsTab() {
<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>
))
)}
@@ -260,6 +370,86 @@ export default function MenuItemsTab() {
</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>
);
}
@@ -1,6 +1,7 @@
export interface ReviewModalProps {
isOpen: boolean;
onClose: () => void;
eateryId?: string;
}
export interface ConfirmModalProps {
+38 -7
View File
@@ -1,28 +1,52 @@
"use client";
import { Button, Heading, Text, Textarea } from "@/components/atoms";
import { createReview, hasAuthCookie } from "@/lib/api/review";
import { useAuth } from "@/lib/auth-context";
import { useState } from "react";
import type { ReviewModalProps } from "./Modal.types";
export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
export default function ReviewModal({ isOpen, onClose, eateryId }: ReviewModalProps) {
const { user } = useAuth();
const [rating, setRating] = useState(0);
const [hovered, setHovered] = useState(0);
const [review, setReview] = useState("");
const [submitted, setSubmitted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null;
const handleSubmit = () => {
setSubmitted(true);
const isAuthenticated = hasAuthCookie() || user?.role === "customer";
const handleSubmit = async () => {
if (!isAuthenticated) {
setError("Please log in as a customer to submit a review");
return;
}
if (!eateryId) {
setError("No eatery selected — please go back to the feed and select a shop first");
return;
}
setIsLoading(true);
setError(null);
try {
await createReview({ eateryId, rating, comment: review });
setSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
// Reset state when closing
setRating(0);
setHovered(0);
setReview("");
setSubmitted(false);
setError(null);
onClose();
};
@@ -128,6 +152,13 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
/>
</div>
{/* Error message */}
{error && (
<p className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">
{error}
</p>
)}
{/* Footer buttons */}
<div className="flex gap-3">
<Button
@@ -142,11 +173,11 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
<Button
type="button"
onClick={handleSubmit}
disabled={rating === 0}
disabled={rating === 0 || isLoading}
className="flex-1"
icon="fa-check"
icon={isLoading ? "fa-spinner fa-spin" : "fa-check"}
>
Confirm
{isLoading ? "Submitting..." : "Confirm"}
</Button>
</div>
</>
@@ -2,19 +2,66 @@
import { ProductCard } from "@/components/molecules/cards";
import { useCart } from "@/lib/cart-context";
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
import { MENU_CATEGORIES } from "@/lib/constants";
import { getMenuItemsByEatery } from "@/lib/graphql";
import { useMenu } from "@/lib/menu-context";
import type { Product } from "@/lib/types";
import { useEffect, useState } from "react";
import type { ProductGridProps } from "./ProductGrid.types";
function generateDescription(name: string): string {
return `${name} thơm ngon, hấp dẫn — hoàn hảo cho một buổi thư giãn.`;
}
export default function ProductGrid({
searchQuery = "",
isSidebarOpen = false,
}: ProductGridProps) {
const { activeCategory, setActiveCategory } = useMenu();
const { addToCart } = useCart();
const { addToCart, eateryId } = useCart();
const filteredProducts = MOCK_PRODUCTS.filter((p) => {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!eateryId) {
setProducts([]);
return;
}
let cancelled = false;
setLoading(true);
getMenuItemsByEatery(eateryId)
.then((items) => {
if (cancelled) return;
setProducts(
items.map((item, index) => ({
id: index,
menuItemId: item.id,
name: item.name,
price: item.price,
image: "/imgs/products/placeholder.jpg",
category: "all",
description: generateDescription(item.name),
available: true,
})),
);
})
.catch(() => {
if (!cancelled) setProducts([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [eateryId]);
const filteredProducts = products.filter((p) => {
const isAvailable = p.available !== false;
const matchesCategory =
activeCategory === "all" || p.category === activeCategory;
@@ -25,9 +72,6 @@ export default function ProductGrid({
return isAvailable && matchesCategory && matchesSearch;
});
const activeCategoryLabel =
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "All";
const gridCols = isSidebarOpen
? "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
: "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
@@ -59,8 +103,20 @@ export default function ProductGrid({
</div>
</div>
{/* ── Loading skeleton ── */}
{loading && (
<div className={`grid gap-4 ${gridCols}`}>
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="animate-pulse rounded-2xl bg-(--color-border-light) h-64"
/>
))}
</div>
)}
{/* ── Product grid ── */}
{filteredProducts.length > 0 ? (
{!loading && filteredProducts.length > 0 && (
<div className={`grid gap-4 ${gridCols}`}>
{filteredProducts.map((product) => (
<ProductCard
@@ -74,8 +130,10 @@ export default function ProductGrid({
/>
))}
</div>
) : (
/* Empty state */
)}
{/* ── Empty state ── */}
{!loading && filteredProducts.length === 0 && (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
<i className="fa-solid fa-mug-hot text-5xl opacity-30"></i>
<p className="text-base font-medium">
@@ -34,6 +34,7 @@ export default function ShopGrid({
<ShopCard
key={shop.id}
id={shop.id}
eateryId={shop.eateryId}
name={shop.name}
address={shop.address}
image={shop.image}
+24
View File
@@ -0,0 +1,24 @@
export interface SendReview {
eateryId: string;
rating: number;
comment: string;
}
export function hasAuthCookie(): boolean {
if (typeof document === "undefined") return false;
return document.cookie.split(";").some((c) => c.trim().startsWith("auth="));
}
export async function createReview(input: SendReview): Promise<void> {
const res = await fetch("/api/eatery/review", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Failed to submit review (${res.status})`);
}
}
+110 -8
View File
@@ -1,7 +1,19 @@
"use client";
import {
addItem as addItemGql,
createCart as createCartGql,
} from "@/lib/graphql/cart";
import type { Product } from "@/lib/types";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
export interface CartItem {
id: number;
@@ -9,12 +21,16 @@ export interface CartItem {
description: string;
price: number;
quantity: number;
menuItemId?: string; // backend UUID for GraphQL sync
}
interface CartContextValue {
items: CartItem[];
totalItems: number;
totalPrice: number;
eateryId: string | null;
backendCartId: string | null;
setEateryId: (id: string) => void;
addToCart: (product: Product) => void;
increaseQty: (id: number) => void;
decreaseQty: (id: number) => void;
@@ -23,29 +39,88 @@ interface CartContextValue {
}
const STORAGE_KEY = "coffee-shop-cart";
const EATERY_KEY = "coffee-shop-eatery-id";
const CART_ID_KEY = "coffee-shop-cart-id";
const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const [eateryId, setEateryIdState] = useState<string | null>(null);
const [backendCartId, setBackendCartId] = useState<string | null>(null);
// Refs give async callbacks always-current values without stale closures
const backendCartIdRef = useRef<string | null>(null);
const eateryIdRef = useRef<string | null>(null);
const pendingCreateRef = useRef<Promise<string | null> | null>(null);
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as CartItem[];
if (Array.isArray(parsed)) {
setItems(parsed.filter((i) => i && i.id && i.quantity > 0));
if (raw) {
const parsed = JSON.parse(raw) as CartItem[];
if (Array.isArray(parsed)) {
setItems(parsed.filter((i) => i && i.id && i.quantity > 0));
}
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
const savedEateryId = localStorage.getItem(EATERY_KEY);
if (savedEateryId) {
setEateryIdState(savedEateryId);
eateryIdRef.current = savedEateryId;
}
const savedCartId = localStorage.getItem(CART_ID_KEY);
if (savedCartId) {
setBackendCartId(savedCartId);
backendCartIdRef.current = savedCartId;
}
}, []);
useEffect(() => {
backendCartIdRef.current = backendCartId;
}, [backendCartId]);
useEffect(() => {
eateryIdRef.current = eateryId;
}, [eateryId]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}, [items]);
const persistCartId = useCallback((cartId: string) => {
setBackendCartId(cartId);
backendCartIdRef.current = cartId;
localStorage.setItem(CART_ID_KEY, cartId);
}, []);
// Creates backend cart on demand; deduplicates concurrent calls
const ensureBackendCartId = useCallback(async (): Promise<string | null> => {
if (backendCartIdRef.current) return backendCartIdRef.current;
const eid = eateryIdRef.current;
if (!eid) return null;
if (pendingCreateRef.current) return pendingCreateRef.current;
pendingCreateRef.current = createCartGql(eid)
.then((cartId) => {
persistCartId(cartId);
pendingCreateRef.current = null;
return cartId;
})
.catch(() => {
pendingCreateRef.current = null;
return null;
});
return pendingCreateRef.current;
}, [persistCartId]);
const addToCart = (product: Product) => {
const snapshot = items;
setItems((prev) => {
const index = prev.findIndex((i) => i.id === product.id);
if (index === -1) {
@@ -57,14 +132,21 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
description: product.description,
price: product.price,
quantity: 1,
menuItemId: product.menuItemId,
},
];
}
const next = [...prev];
next[index] = { ...next[index], quantity: next[index].quantity + 1 };
return next;
});
if (product.menuItemId) {
const mid = product.menuItemId;
ensureBackendCartId()
.then((cartId) => (cartId ? addItemGql(cartId, mid, 1) : undefined))
.catch(() => setItems(snapshot));
}
};
const increaseQty = (id: number) => {
@@ -87,8 +169,24 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
);
};
const setEateryId = (id: string) => {
setEateryIdState(id);
eateryIdRef.current = id;
localStorage.setItem(EATERY_KEY, id);
};
const removeFromCart = (id: number) => {
setItems((prev) => prev.filter((item) => item.id !== id));
const item = items.find((i) => i.id === id);
const snapshot = items;
setItems((prev) => prev.filter((i) => i.id !== id));
if (item?.menuItemId && backendCartIdRef.current) {
const cartId = backendCartIdRef.current;
addItemGql(cartId, item.menuItemId, -item.quantity).catch(() =>
setItems(snapshot),
);
}
};
const setQuantity = (id: number, quantity: number) => {
@@ -122,13 +220,17 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
items,
totalItems,
totalPrice,
eateryId,
backendCartId,
setEateryId,
addToCart,
increaseQty,
decreaseQty,
removeFromCart,
setQuantity,
}),
[items, totalItems, totalPrice],
// eslint-disable-next-line react-hooks/exhaustive-deps
[items, totalItems, totalPrice, eateryId, backendCartId],
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
+5
View File
@@ -281,6 +281,7 @@ export const MOCK_COMBOS: Combo[] = [
export const MOCK_SHOPS: Shop[] = [
{
id: 1,
eateryId: "f39b2da0-aa73-4939-8601-d87b53fe7e27",
name: "The Coffee House",
address: "86 Cao Thắng, Quận 3, TP. Hồ Chí Minh",
image:
@@ -288,6 +289,7 @@ export const MOCK_SHOPS: Shop[] = [
},
{
id: 2,
eateryId: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
name: "Highlands Coffee",
address: "123 Nguyễn Huệ, Quận 1, TP. Hồ Chí Minh",
image:
@@ -295,6 +297,7 @@ export const MOCK_SHOPS: Shop[] = [
},
{
id: 3,
eateryId: "c3d4e5f6-a7b8-9012-cdef-123456789012",
name: "Phúc Long Heritage",
address: "42 Lê Lợi, Quận 1, TP. Hồ Chí Minh",
image:
@@ -302,6 +305,7 @@ export const MOCK_SHOPS: Shop[] = [
},
{
id: 4,
eateryId: "d4e5f6a7-b8c9-0123-def0-234567890123",
name: "Katinat Saigon Kafe",
address: "26 Lý Tự Trọng, Quận 1, TP. Hồ Chí Minh",
image:
@@ -309,6 +313,7 @@ export const MOCK_SHOPS: Shop[] = [
},
{
id: 5,
eateryId: "e5f6a7b8-c9d0-1234-ef01-345678901234",
name: "Trung Nguyên E-Coffee",
address: "15 Hai Bà Trưng, Quận 1, TP. Hồ Chí Minh",
image:
+53 -12
View File
@@ -1,4 +1,4 @@
import type { Eatery } from "./types";
import type { Eatery, MenuItem } from "./types";
export async function gqlFetch<T = Record<string, unknown>>(
query: string,
@@ -11,25 +11,66 @@ export async function gqlFetch<T = Record<string, unknown>>(
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`GraphQL request failed: ${res.status}`);
if (!res.ok) {
const body = await res.json().catch(() => null);
const message =
Array.isArray(body) && body[0]?.message
? body[0].message
: `GraphQL request failed: ${res.status}`;
throw new Error(message);
}
return res.json() as Promise<T>;
}
export async function addManagerMenuItem(
name: string,
price: number,
): Promise<{ id: string; name: string; price: number } | null> {
const data = await gqlFetch<{
addMenuItem: { id: string; name: string; price: number } | null;
}>(
`mutation($name: String!, $price: Float!) {
addMenuItem(name: $name, price: $price) { id name price }
export async function getMenuItemsByEatery(
eateryId: string,
): Promise<MenuItem[]> {
const data = await gqlFetch<{ menuItemsByEatery: MenuItem[] }>(
`query menuItemsByEatery($eateryId: String!) {
menuItemsByEatery(eateryId: $eateryId) { id name price }
}`,
{ name, price },
{ eateryId },
);
return data.menuItemsByEatery ?? [];
}
export async function addMenuItem(input: {
name: string;
price: number;
}): Promise<MenuItem | null> {
const data = await gqlFetch<{ addMenuItem: MenuItem | null }>(
`mutation addMenuItem($menuItem: AddMenuItemInput!) {
addMenuItem(menuItem: $menuItem) { id name price }
}`,
{ menuItem: input },
);
return data.addMenuItem;
}
export async function updateMenuItem(input: {
id: string;
name?: string;
price?: number;
}): Promise<MenuItem | null> {
const data = await gqlFetch<{ updateMenuItem: MenuItem | null }>(
`mutation updateMenuItem($menuItem: UpdateMenuItemInput!) {
updateMenuItem(menuItem: $menuItem) { id name price }
}`,
{ menuItem: input },
);
return data.updateMenuItem;
}
export async function deleteMenuItem(menuItemId: string): Promise<boolean> {
const data = await gqlFetch<{ deleteMenuItem: boolean }>(
`mutation deleteMenuItem($menuItemId: String!) {
deleteMenuItem(menuItemId: $menuItemId)
}`,
{ menuItemId },
);
return data.deleteMenuItem ?? false;
}
export async function fetchManagerEatery(): Promise<Eatery | null> {
const data = await gqlFetch<{ eateriesByOwner: Eatery | null }>(`
query {
+78
View File
@@ -0,0 +1,78 @@
import type { GqlCart } from "./cart.types";
async function cartFetch<T>(
query: string,
variables?: Record<string, unknown>,
): Promise<T> {
const res = await fetch("/api/cart/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ query, variables }),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
const message =
Array.isArray(body) && body[0]?.message
? (body[0].message as string)
: `Cart API error: ${res.status}`;
throw new Error(message);
}
return res.json() as Promise<T>;
}
export async function createCart(eateryId: string): Promise<string> {
const data = await cartFetch<{ createCart: string }>(
`mutation createCart($eateryId: String) {
createCart(eateryId: $eateryId)
}`,
{ eateryId },
);
return data.createCart;
}
export async function getCart(cartId: string): Promise<GqlCart> {
const data = await cartFetch<{ getCart: GqlCart }>(
`query getCart($cartId: String) {
getCart(cartId: $cartId) {
id
userId
eateryId
items { productId quantity }
}
}`,
{ cartId },
);
return data.getCart;
}
// quantity > 0: add; quantity < 0: decrease/remove (backend uses Redis hincrby)
export async function addItem(
cartId: string,
menuItemId: string,
quantity: number,
): Promise<GqlCart> {
const data = await cartFetch<{ addItem: GqlCart }>(
`mutation addItem($cartId: String, $menuItemId: String, $quantity: BigInteger) {
addItem(cartId: $cartId, menuItemId: $menuItemId, quantity: $quantity) {
id
userId
eateryId
items { productId quantity }
}
}`,
{ cartId, menuItemId, quantity },
);
return data.addItem;
}
// Backend has no removeItem — zero out via negative addItem
export async function removeItem(
cartId: string,
menuItemId: string,
currentQuantity: number,
): Promise<GqlCart> {
return addItem(cartId, menuItemId, -currentQuantity);
}
+11
View File
@@ -0,0 +1,11 @@
export interface GqlCartItem {
productId: string;
quantity: number;
}
export interface GqlCart {
id: string;
userId: string;
eateryId: string;
items: GqlCartItem[];
}
+8
View File
@@ -25,6 +25,7 @@ export interface Product {
image: string;
description: string;
available?: boolean;
menuItemId?: string; // backend UUID — present when product comes from eatery-service
}
// ===== SHOP INFO TYPES =====
@@ -60,9 +61,16 @@ export interface Eatery {
isVerified: boolean;
}
export interface MenuItem {
id: string;
name: string;
price: number;
}
// ===== SHOP (QUÁN NƯỚC) TYPES =====
export interface Shop {
id: number;
eateryId: string;
name: string;
address: string;
image: string;