fix: cart connect to backend (#39)
Release package / release (push) Failing after 8m34s

Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
2026-05-13 08:56:58 +00:00
parent ae8134fd64
commit 877c7be84b
7 changed files with 183 additions and 288 deletions
+4 -4
View File
@@ -9,8 +9,8 @@ import { useManager } from "@/lib/manager-context";
import { MenuItemEntity } from "@/lib/types"; import { MenuItemEntity } from "@/lib/types";
import { useState } from "react"; import { useState } from "react";
const formatPrice = (value: number) => export const formatPrice = (value?: number) =>
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" }); (value ?? 0).toLocaleString("vi-VN", { style: "currency", currency: "VND" });
export default function PaymentPage() { export default function PaymentPage() {
const { const {
@@ -42,7 +42,7 @@ export default function PaymentPage() {
</h1> </h1>
</div> </div>
{items.length === 0 ? ( {items?.length === 0 ? (
<div className="px-4 py-10 text-center text-(--color-text-muted)"> <div className="px-4 py-10 text-center text-(--color-text-muted)">
Chưa sản phẩm nào trong giỏ hàng. Chưa sản phẩm nào trong giỏ hàng.
</div> </div>
@@ -72,7 +72,7 @@ export default function PaymentPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{items.map( {items?.map(
({ ({
productId: id, productId: id,
priceAtTimeOfAdding: price, priceAtTimeOfAdding: price,
@@ -1,3 +1,4 @@
import { formatPrice } from "@/app/(main)/payment/page";
import Button from "@/components/atoms/buttons/Button"; import Button from "@/components/atoms/buttons/Button";
import { ReviewModal } from "@/components/organisms/modals"; import { ReviewModal } from "@/components/organisms/modals";
import Link from "next/link"; import Link from "next/link";
@@ -5,9 +6,6 @@ import { useState } from "react";
import type { PaymentSummaryCardProps } from "./Card.types"; import type { PaymentSummaryCardProps } from "./Card.types";
const formatPrice = (value: number) =>
value.toLocaleString("vi-VN", { style: "currency", currency: "VND" });
export default function PaymentSummaryCard({ export default function PaymentSummaryCard({
totalPrice, totalPrice,
isCustomer = false, isCustomer = false,
+1 -1
View File
@@ -38,7 +38,7 @@ export default function ProductCard({
</div> </div>
{/* Product image */} {/* Product image */}
<Image <Image
src={image} src={image || "/"}
alt={imageAlt} alt={imageAlt}
fill fill
className="z-1 object-cover" className="z-1 object-cover"
@@ -2,7 +2,7 @@
import { ProductCard } from "@/components/molecules/cards"; import { ProductCard } from "@/components/molecules/cards";
import { useCart } from "@/lib/cart-context"; import { useCart } from "@/lib/cart-context";
import { MOCK_PRODUCTS } from "@/lib/constants"; import { useManager } from "@/lib/manager-context";
import type { ProductGridProps } from "./ProductGrid.types"; import type { ProductGridProps } from "./ProductGrid.types";
@@ -11,8 +11,9 @@ export default function ProductGrid({
isSidebarOpen = false, isSidebarOpen = false,
}: ProductGridProps) { }: ProductGridProps) {
const { addToCart } = useCart(); const { addToCart } = useCart();
const { products } = useManager();
const filteredProducts = MOCK_PRODUCTS.filter((p) => { const filteredProducts = products.filter((p) => {
const isAvailable = p.available !== false; const isAvailable = p.available !== false;
const matchesSearch = const matchesSearch =
searchQuery.trim() === "" || searchQuery.trim() === "" ||
@@ -30,17 +31,19 @@ export default function ProductGrid({
{/* ── Product grid ── */} {/* ── Product grid ── */}
{filteredProducts.length > 0 ? ( {filteredProducts.length > 0 ? (
<div className={`grid gap-4 ${gridCols}`}> <div className={`grid gap-4 ${gridCols}`}>
{filteredProducts.map((product) => ( {filteredProducts.map(
({ id, imageUrl, name, price, description }) => (
<ProductCard <ProductCard
key={product.id} key={id}
image={product.imageUrl} image={imageUrl}
imageAlt={product.name} imageAlt={name}
productName={product.name} productName={name}
price={product.price} price={price}
description={product.description} description={description}
onBuy={() => addToCart(product)} onBuy={() => addToCart({ productId: id!, quantity: 1 })}
/> />
))} ),
)}
</div> </div>
) : ( ) : (
/* Empty state */ /* Empty state */
+151 -89
View File
@@ -1,131 +1,193 @@
"use client"; "use client";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import { createContext, useContext, useEffect, useMemo, useState } from "react"; import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { CartItemEntity, MenuItemEntity } from "./types"; import { cartClient, eateryClient } from "./apollo-clients";
import {
CartEntity,
CartItemEntity,
addMenuItemMutation,
allEateriesQuery,
createCartMutation,
getCartQuery,
} from "./types";
interface CartContextValue { interface CartContextValue {
items: CartItemEntity[]; items: CartItemEntity[];
totalItems: number; totalItems: number;
totalPrice: number; totalPrice: number;
addToCart: (product: MenuItemEntity) => void; addToCart: (product: CartItemEntity) => void;
increaseQty: (id: string) => void; increaseQty: (id: string) => void;
decreaseQty: (id: string) => void; decreaseQty: (id: string) => void;
removeFromCart: (id: string) => void; removeFromCart: (id: string) => void;
setQuantity: (id: string, quantity: number) => void; setQuantity: (id: string, quantity: number) => void;
} }
const STORAGE_KEY = "coffee-shop-cart"; const CART_ID = "cartId";
const CartContext = createContext<CartContextValue | null>(null); const CartContext = createContext<CartContextValue | null>(null);
const GET_CART_ITEMS = gql`
query getCart($cartId: String!) {
getCart(cartId: $cartId) {
Id
userId
eateryId
items {
productId
quantity
priceAtTimeOfAdding
subTotal
}
totalAmount
paymentQrUrl
}
}
`;
const GET_EATERY = gql`
query GetEateryMenu {
allEateries {
id
}
}
`;
const CREATE_CART = gql`
mutation createCart($eateryId: String!) {
createCart(eateryId: $eateryId)
}
`;
const ADD_ITEM = gql`
mutation addItem(
$cartId: String!
$menuItemId: String!
$quantity: BigInteger!
) {
addItem(cartId: $cartId, menuItemId: $menuItemId, quantity: $quantity) {
Id
userId
eateryId
items {
productId
quantity
priceAtTimeOfAdding
subTotal
}
totalAmount
paymentQrUrl
}
}
`;
export function CartProvider({ children }: { children: React.ReactNode }) { export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItemEntity[]>([]); const [cart, setCart] = useState<CartEntity>(null!);
const [cartId, setCartId] = useState<string | null>(null);
useEffect(() => { const { data: eateryData } = useQuery<allEateriesQuery>(GET_EATERY, {
try { client: eateryClient,
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as CartItemEntity[];
if (Array.isArray(parsed)) {
setItems(parsed.filter((i) => i && i.productId && i.quantity > 0));
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}, []);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}, [items]);
const addToCart = (product: MenuItemEntity) => {
setItems((prev) => {
const index = prev.findIndex((i) => i.productId === product.id);
if (index === -1) {
return [
...prev,
{
productId: product.id!,
name: product.name,
description: product.description,
priceAtTimeOfAdding: product.price,
quantity: 1,
},
];
}
const next = [...prev];
next[index] = { ...next[index], quantity: next[index].quantity + 1 };
return next;
}); });
};
const increaseQty = (id: string) => { const [createCart] = useMutation<createCartMutation>(CREATE_CART, {
setItems((prev) => client: cartClient,
prev.map((item) => });
item.productId === id ? { ...item, quantity: item.quantity + 1 } : item,
),
);
};
const decreaseQty = (id: string) => { const { data, loading, error } = useQuery<getCartQuery>(GET_CART_ITEMS, {
setItems((prev) => client: cartClient,
prev variables: { cartId },
.map((item) => skip: !cartId,
item.productId === id });
? { ...item, quantity: Math.max(0, item.quantity - 1) }
: item,
)
.filter((item) => item.quantity > 0),
);
};
const removeFromCart = (id: string) => { const [addMenuItem] = useMutation<addMenuItemMutation>(ADD_ITEM, {
setItems((prev) => prev.filter((item) => item.productId !== id)); client: cartClient,
}; });
const setQuantity = (id: string, quantity: number) => { useEffect(() => {
const safeQty = Number.isFinite(quantity) const createCartFunc = async () => {
? Math.max(0, Math.floor(quantity)) if (eateryData && eateryData.allEateries?.length > 0) {
: 0; try {
if (safeQty === 0) { const firstEateryId = eateryData.allEateries[0].id;
removeFromCart(id); const { data: mutationResult } = await createCart({
return; variables: { eateryId: firstEateryId },
});
const newCartId = mutationResult!.createCart;
if (newCartId) {
localStorage.setItem(CART_ID, newCartId);
setCartId(newCartId);
}
} catch (err) {
console.error("Lỗi khi tạo giỏ hàng:", err);
}
} }
setItems((prev) =>
prev.map((item) =>
item.productId === id ? { ...item, quantity: safeQty } : item,
),
);
}; };
if (error) createCartFunc();
else {
const localCartId = localStorage.getItem(CART_ID);
if (localCartId) setCartId(localCartId);
}
}, [eateryData, createCart, data, loading]);
useEffect(() => {
if (data?.getCart) setCart(data.getCart);
}, [data]);
const addToCart = async (product: CartItemEntity) => {
if (!cartId) return;
const { data: result } = await addMenuItem({
variables: {
cartId,
menuItemId: product.productId!,
quantity: product.quantity,
},
});
if (result) setCart(result.addItem);
};
const setQuantity = async (id: string, quantity: number) => {
if (!cartId) return;
const { data: result } = await addMenuItem({
variables: {
cartId,
menuItemId: id,
quantity,
},
});
if (result) setCart(result.addItem);
};
const removeFromCart = (id: string) => setQuantity(id, 0);
const increaseQty = (id: string) =>
setQuantity(id, cart.items.find((i) => i.productId == id)!.quantity + 1);
const decreaseQty = (id: string) =>
setQuantity(id, cart.items.find((i) => i.productId == id)!.quantity - 1);
const totalItems = useMemo( const totalItems = useMemo(
() => items.reduce((sum, item) => sum + item.quantity, 0), () => cart?.items.reduce((sum, item) => sum + item.quantity, 0),
[items], [cart],
);
const totalPrice = useMemo(
() =>
items.reduce(
(sum, item) => sum + item.priceAtTimeOfAdding * item.quantity,
0,
),
[items],
); );
const value = useMemo( const value = useMemo(
() => ({ () => ({
items, items: cart?.items,
totalItems, totalItems,
totalPrice, totalPrice: cart?.totalAmount,
addToCart, addToCart,
increaseQty, increaseQty,
decreaseQty, decreaseQty,
removeFromCart, removeFromCart,
setQuantity, setQuantity,
}), }),
[items, totalItems, totalPrice], [cart?.items, cart?.totalAmount],
); );
return <CartContext.Provider value={value}>{children}</CartContext.Provider>; return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
-183
View File
@@ -33,189 +33,6 @@ export const SOCIAL_LINKS: SocialLinks = {
website: "/", website: "/",
}; };
// ===== MOCK PRODUCTS =====
// Placeholder data replace with real API calls when backend is ready
export const MOCK_PRODUCTS: MenuItemEntity[] = [
{
id: "1",
name: "Black Coffee",
price: 25000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Traditional Vietnamese black coffee, rich and bold, brewed by hand with a phin filter.",
available: true,
},
{
id: "2",
name: "Milk Coffee",
price: 30000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Aromatic condensed milk coffee, rich and creamy — a perfect blend of strong coffee and sweet condensed milk.",
available: true,
},
{
id: "3",
name: "White Coffee",
price: 32000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Light and mild, with less coffee and more milk — perfect for those just starting to enjoy coffee.",
available: true,
},
{
id: "4",
name: "Egg Coffee",
price: 45000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"A Hanoi specialty — smooth, velvety egg cream layered over a bold coffee base.",
available: true,
},
{
id: "5",
name: "Peach Orange Lemongrass Tea",
price: 35000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Fragrant peach tea with fresh orange and lemongrass — refreshing and wonderfully cooling.",
available: true,
},
{
id: "6",
name: "Matcha Green Tea",
price: 40000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Pure Japanese matcha with a signature light bitterness — aromatic, refreshing, and nutritious.",
available: true,
},
{
id: "7",
name: "Lychee Jasmine Tea",
price: 38000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Sweet lychee tea delicately infused with jasmine blossom — a calming and soulful sip.",
available: true,
},
{
id: "8",
name: "Yogurt with Tapioca Pearls",
price: 38000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Smooth, creamy yogurt paired with chewy black tapioca pearls — a perfectly balanced sweet and tangy treat.",
available: true,
},
{
id: "9",
name: "Strawberry Yogurt",
price: 40000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Chilled yogurt with fresh sweet-tart strawberries, packed with vitamins and minerals.",
available: true,
},
{
id: "10",
name: "Fresh Orange Juice",
price: 35000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"100% freshly squeezed orange juice, rich in vitamin C and great for your health.",
available: true,
},
{
id: "11",
name: "Watermelon Juice",
price: 30000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Ice-cold watermelon juice — instantly refreshing on hot summer days.",
available: true,
},
{
id: "12",
name: "Caramel Latte",
price: 45000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Sweet and indulgent caramel latte with a velvety milk foam topping and a drizzle of caramel sauce.",
available: true,
},
{
id: "13",
name: "Vanilla Latte",
price: 45000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Smooth and gentle vanilla latte with a delicate natural vanilla fragrance.",
available: true,
},
{
id: "14",
name: "Toasted Butter Bread",
price: 20000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Crispy toasted bread spread with fragrant butter and strawberry jam — the perfect coffee companion.",
available: true,
},
{
id: "15",
name: "Caramel Flan",
price: 25000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Silky smooth flan with a golden caramel topping that melts in your mouth.",
available: true,
},
{
id: "16",
name: "Black Tapioca Pearls",
price: 10000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Chewy black tapioca pearls — add them to any drink for extra flavor and texture.",
available: true,
},
{
id: "17",
name: "Coffee Jelly",
price: 10000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Cool coffee jelly that adds a distinctive flavor boost to your drink.",
available: true,
},
{
id: "18",
name: "White Tapioca Pearls",
price: 10000,
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Chewy white tapioca pearls — add them to any drink for extra flavor and texture.",
available: true,
},
];
// ===== MOCK SHOPS (for Feed page) ===== // ===== MOCK SHOPS (for Feed page) =====
export const MOCK_SHOPS: Shop[] = [ export const MOCK_SHOPS: Shop[] = [
{ {
+16 -1
View File
@@ -125,6 +125,7 @@ export interface MenuItemEntity {
export interface ShiftEntity {} export interface ShiftEntity {}
export interface EateryEntity { export interface EateryEntity {
id: string;
ownerId: string; ownerId: string;
name: string; name: string;
menuItems: MenuItemEntity[]; menuItems: MenuItemEntity[];
@@ -150,10 +151,24 @@ export interface deleteMenuItemMutation {
export interface CartItemEntity { export interface CartItemEntity {
productId: string; productId: string;
quantity: number; quantity: number;
priceAtTimeOfAdding: number; priceAtTimeOfAdding?: number;
} }
export interface CartEntity { export interface CartEntity {
Id: string; Id: string;
items: CartItemEntity[]; items: CartItemEntity[];
totalAmount: number;
paymentQrUrl: string;
}
export interface getCartQuery {
getCart: CartEntity;
}
export interface createCartMutation {
createCart: string;
}
export interface addMenuItemMutation {
addItem: CartEntity;
} }