diff --git a/src/api/useCarts.ts b/src/api/useCarts.ts index fc30dc66b..2efcfbeff 100644 --- a/src/api/useCarts.ts +++ b/src/api/useCarts.ts @@ -4,20 +4,37 @@ import { useQueryClient, } from "@tanstack/react-query"; import { apiClient } from "./axios"; -import type { ICart, IProduct } from "@/types"; +import type { ICart, IProduct, ResponseDto } from "@/types"; +import type { InfiniteData } from "@tanstack/react-query"; const fetchCarts = async (page: number) => { const query = new URLSearchParams({ page: page.toString(), limit: "5" }); - const { data } = await apiClient.get(`/carts?${query.toString()}`); + const { data } = await apiClient.get>( + `/carts?${query.toString()}` + ); return data; }; const postCart = async (product: IProduct) => { - const { data } = await apiClient.post("/carts", product); + const { data } = await apiClient.post<{ + ok: boolean; + code: string; + message?: string; + data?: IProduct; + }>("/carts", product); + return data; +}; +const changeQuantity = async (productId: number, quantity: number) => { + const { data } = await apiClient.patch<{ + ok: boolean; + code: string; + message?: string; + product?: IProduct; + }>(`/carts/${productId}`, { quantity }); return data; }; const deleteCartItem = async (id: number) => { - const { data } = await apiClient.delete(`/carts/${id}`); + const { data } = await apiClient.delete(`/carts/${id}`); return data; }; @@ -31,20 +48,127 @@ export const useCarts = () => { }); }; -export const usePostCarts = () => { +export const usePostCarts = (successCallback?: () => void) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (product: IProduct) => postCart(product), + + onMutate: async (newTodo) => { + await queryClient.cancelQueries({ queryKey: ["carts"] }); + + // Snapshot the previous value + const previousTodos = queryClient.getQueryData(["carts"]); + + // Optimistically update to the new value --> if exists quantity should be updated + queryClient.setQueryData( + ["carts"], + (old: InfiniteData> | undefined) => { + if (!old) return; + const existingItem = old.pages.flatMap((page) => + page.data.find((item) => item.product.id === newTodo.id) + ); + if (!existingItem) { + return { + pageParams: old.pageParams, + pages: [ + { + data: [ + ...old.pages.flatMap((page) => page.data), + { product: newTodo, quantity: 1 }, + ], + nextCursor: null, + }, + ], + }; + } else { + const updatedData = old.pages.flatMap((page) => + page.data.map((item) => { + if (item.product.id === newTodo.id) { + return { ...item, quantity: item.quantity + 1 }; + } + return item; + }) + ); + return { + pageParams: old.pageParams, + pages: [ + { + data: updatedData, + nextCursor: null, + }, + ], + }; + } + } + ); + return { previousTodos }; + }, + onError: (_err, _newTodo, context) => { + queryClient.setQueryData(["carts"], context?.previousTodos); + }, onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["carts"], - exact: true, - }); + successCallback?.(); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["carts"] }); }, }); }; -export const useDeleteCartItem = () => { +export const useDeleteCartItem = (successCallback?: () => void) => { + const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: number) => deleteCartItem(id), + onSuccess: () => { + successCallback?.(); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["carts"] }); + }, + }); +}; +export const useChangeQuantity = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + productId, + quantity, + }: { + productId: number; + quantity: number; + }) => changeQuantity(productId, quantity), + onMutate: async ({ productId, quantity }) => { + await queryClient.cancelQueries({ queryKey: ["carts"] }); + const previousData = queryClient.getQueryData(["carts"]); + queryClient.setQueryData( + ["carts"], + (old: InfiniteData> | undefined) => { + if (!old) return; + const updatedData = old.pages.flatMap((page) => + page.data.map((item) => { + if (item.product.id === productId) { + return { ...item, quantity }; + } + return item; + }) + ); + return { + pageParams: old.pageParams, + pages: [ + { + data: updatedData, + nextCursor: null, + }, + ], + }; + } + ); + return { previousData }; + }, + onError: (_err, _newTodo, context) => { + queryClient.setQueryData(["carts"], context?.previousData); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["carts"] }); + }, }); }; diff --git a/src/app/cart/page.tsx b/src/app/cart/page.tsx index 888c551d6..85cf7b927 100644 --- a/src/app/cart/page.tsx +++ b/src/app/cart/page.tsx @@ -1,34 +1,30 @@ import Spinner from "@/assets/spinner.svg?react"; -import { useEffect } from "react"; -import { useCarts } from "@/api"; +import { useEffect, useRef } from "react"; +import { useCarts, useDeleteCartItem, useChangeQuantity } from "@/api"; import { useIntersectionObserver } from "@/hooks"; -import { Loading, Checkbox, Footer, Button } from "@/components"; +import { + Loading, + Checkbox, + Footer, + TotalPrice, + Modal, + Button, +} from "@/components"; +import selectedCartAtom from "@/atoms/cartAtom"; +import { useAtom } from "jotai"; +import type { ModalRef } from "@/components"; +import { useNavigate } from "react-router-dom"; -function TotalPrice() { - return ( -
-

결제예상금액

-
-
- 총 상품금액 -
-
- 0원 -
-
-
- -
-
- ); -} const Cart = () => { const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetching } = useCarts(); + const modalRef = useRef(null); + const confirmModalRef = useRef(null); const { ref, isIntersecting } = useIntersectionObserver(); - // const { mutate: deleteCartItem } = useDeleteCartItem(); + const { mutate: deleteCartItem } = useDeleteCartItem(modalRef.current?.open); + const { mutate: changeQuantity } = useChangeQuantity(); + const [selectedCart, setSelectedCart] = useAtom(selectedCartAtom); + const navigate = useNavigate(); useEffect(() => { if (isIntersecting && hasNextPage) { @@ -43,53 +39,157 @@ const Cart = () => { return
Error
; } return ( -
-

- 장바구니 -

-
-
-
- - + <> +
+

+ 장바구니 +

+
+
+
+ item.data) + .every((item) => + selectedCart.some((cart) => cart.id === item.id) + ) ?? false + } + onChange={(e) => { + setSelectedCart( + e.target.checked + ? data?.pages.flatMap((item) => item.data) ?? [] + : [] + ); + }} + /> + +
+
    + {data?.pages + .flatMap((item) => item.data) + .map(({ product, quantity, id: cartId }) => { + const { id, name, imageUrl, price } = product; + return ( +
  1. +
    + item.id === cartId + )} + onChange={(e) => { + setSelectedCart((prev) => { + if (e.target.checked) { + return [ + ...prev, + { product, quantity, id: cartId }, + ]; + } + return prev.filter((item) => item.id !== cartId); + }); + }} + /> + {name} +
    {name}
    +
    + + { + changeQuantity({ + productId: id, + quantity: +e.target.value, + }); + setSelectedCart((prev) => + prev.map((item) => + item.id === cartId + ? { ...item, quantity: +e.target.value } + : item + ) + ); + }} + /> +
    + {(price * quantity).toLocaleString("ko-KR")}원 +
    +
    +
    +
  2. + ); + })} +
    +
+ {isFetching && ( +
+ +
+ )} +
+ +
+
-
    - {data?.pages.flat().map(({ id, product }) => ( -
  1. -
    - - {product.name} -
    {product.name}
    -
    - - -
    {product.price.toLocaleString("ko-KR")}원
    -
    -
    -
  2. - ))} -
    - {isFetching && } -
-
- +
+ +
+
+ +
+

상품이 삭제되었습니다.

+ +
+
+ +
+

상품을 주문하시겠습니까?

+
+ + +
-
-
- -
-
+ + ); }; diff --git a/src/app/order/page.tsx b/src/app/order/page.tsx index 283f7df78..275aeb3fa 100644 --- a/src/app/order/page.tsx +++ b/src/app/order/page.tsx @@ -1,4 +1,9 @@ +import { useLocation } from "react-router-dom"; const Order = () => { + const location = useLocation(); + // const setSelectedCart = useSetAtom(selectedCartAtom); + console.log(location.state); + return
Order
; }; diff --git a/src/atoms/cartAtom.ts b/src/atoms/cartAtom.ts index 2f4ce7e28..4dcc8b638 100644 --- a/src/atoms/cartAtom.ts +++ b/src/atoms/cartAtom.ts @@ -1,6 +1,6 @@ import { ICart } from "@/types/cart"; -import { atomWithStorage } from "jotai/utils"; +import { atom } from "jotai"; -const cartAtom = atomWithStorage("cart", []); +const selectedCartAtom = atom([]); -export default cartAtom; +export default selectedCartAtom; diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 848e58258..949ef5378 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -14,11 +14,13 @@ const Button = ({ htmlType = "button", type = "default", block = false, + disabled, ...props }: ButtonProps) => { return ( + + + ); +}; + +export default TotalPrice; diff --git a/src/components/index.ts b/src/components/index.ts index 9118290a2..9433f0fb4 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -10,6 +10,7 @@ import Checkbox from "./Checkbox"; import Footer from "./Footer"; import Button from "./Button"; import LinkButton from "./LinkButton"; +import TotalPrice from "./TotalPrice"; export { Provider, @@ -23,5 +24,6 @@ export { Footer, Button, LinkButton, + TotalPrice, }; export type { ModalRef }; diff --git a/src/mocks/api/cart.ts b/src/mocks/api/cart.ts index 82c943f8c..3d125c7d1 100644 --- a/src/mocks/api/cart.ts +++ b/src/mocks/api/cart.ts @@ -1,40 +1,72 @@ import { HttpResponse, delay } from "msw"; import { pagination } from "@/utils"; -// import { groupByProductId } from "@/utils/cart"; +import { addToCart, getCartData, deleteProductFromCart } from "@/utils/cart"; import type { ICart, IProduct } from "@/types"; const getCarts = async (page: number, limit: number) => { - const cartData = localStorage.getItem("cart") || "[]"; - const parsedCartData = JSON.parse(cartData); try { - const paginatedData = pagination(parsedCartData, page, limit); - // const groupByProduct = groupByProductId(paginatedData); - await delay(1000); - return HttpResponse.json(paginatedData); - } catch (error) { - return new HttpResponse("Failed to fetch cart items", { - status: 500, + const cartData = getCartData(); + const paginatedData = pagination(cartData, page, limit); + + await delay(500); + return HttpResponse.json({ + ok: true, + data: paginatedData, + page: { + total: cartData.length, + page, + limit, + }, }); + } catch (error) { + return HttpResponse.error(); } }; -const addToCart = async (product: IProduct) => { - const newItem = { - id: window.crypto.randomUUID(), - product, - }; - const cartData = localStorage.getItem("cart") || "[]"; - const parsedCartData = JSON.parse(cartData); +const postCart = async (product: IProduct) => { try { - const newCart = [...parsedCartData, newItem]; - localStorage.setItem("cart", JSON.stringify(newCart)); - await delay(1000); - return HttpResponse.json(newItem); + addToCart(product); + await delay(500); + return HttpResponse.json( + { + data: product, + code: "ITEM_ADDED_TO_CART", + ok: true, + }, + { status: 200 } + ); } catch (error) { - return new HttpResponse("Failed to add item to cart", { - status: 500, + return HttpResponse.error(); + } +}; +const changeQuantity = async (productId: IProduct["id"], quantity: number) => { + try { + const cartData = getCartData(); + const updatedData = cartData.map((item) => { + if (item.product.id === productId) { + return { ...item, quantity }; + } + return item; }); + + localStorage.setItem("cart", JSON.stringify(updatedData)); + await delay(500); + return HttpResponse.json({ + ok: true, + data: updatedData.find((item) => item.product.id === productId), + }); + } catch (error) { + return HttpResponse.error(); + } +}; +const deleteCartItem = async (productId: IProduct["id"]) => { + try { + deleteProductFromCart(productId); + await delay(500); + return HttpResponse.json(productId); + } catch (error) { + return HttpResponse.error(); } }; -export { getCarts, addToCart }; +export { getCarts, postCart, deleteCartItem, changeQuantity }; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index cbb1a16cf..73301230a 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,7 +1,12 @@ import { http, HttpResponse } from "msw"; import { IProduct } from "@/types/product"; import { getProduct, getProducts } from "@/mocks/api/product"; -import { getCarts, addToCart } from "@/mocks/api/cart"; +import { + getCarts, + postCart, + deleteCartItem, + changeQuantity, +} from "@/mocks/api/cart"; export const handlers = [ http.get("/products", ({ request }) => { @@ -30,10 +35,15 @@ export const handlers = [ status: 400, }); } - return addToCart(body as IProduct); + return postCart(body as IProduct); + }), + http.patch("/carts/:productId", async ({ params, request }) => { + const { productId } = params; + const { quantity } = (await request.json()) as { quantity: number }; + return changeQuantity(Number(productId), quantity); + }), + http.delete("/carts/:productId", ({ params }) => { + const { productId } = params; + return deleteCartItem(Number(productId)); }), - // http.delete("/cart/:productId", ({ params }) => { - // const { productId } = params; - // return deleteCartItem(Number(productId)); - // }), ]; diff --git a/src/types/cart.ts b/src/types/cart.ts index 83c4a1054..15040948f 100644 --- a/src/types/cart.ts +++ b/src/types/cart.ts @@ -1,6 +1,7 @@ -import { IProduct } from "./product"; +import type { IProduct } from "./product"; -export interface ICart { +export type ICart = { id: string; product: IProduct; -} + quantity: number; +}; diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 000000000..1d61e797c --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,26 @@ +interface PageDto { + total: number; + page: number; + limit: number; +} + +export interface ResponseDto { + data: T; + page?: PageDto; +} + +export type MutateResponseDto = SuccessResponse | ErrorResponse; + +interface SuccessResponse { + ok: true; + data: T; + page?: PageDto; +} + +interface ErrorResponse { + ok: false; + data: { + code: string; + message: string; + }; +} diff --git a/src/types/index.d.ts b/src/types/index.d.ts deleted file mode 100644 index 4dcf2307e..000000000 --- a/src/types/index.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare interface PageDto { - total: number; - page: number; - limit: number; -} diff --git a/src/types/index.ts b/src/types/index.ts index 5e879023c..876a5e06d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ import type { IProduct } from "./product"; import type { ICart } from "./cart"; import type { IOrder } from "./order"; - -export type { IProduct, ICart, IOrder }; +import type { ResponseDto } from "./common"; +export type { IProduct, ICart, IOrder, ResponseDto }; diff --git a/src/utils/cart.ts b/src/utils/cart.ts index 2ad260401..2f1da1cad 100644 --- a/src/utils/cart.ts +++ b/src/utils/cart.ts @@ -1,22 +1,35 @@ +import { IProduct } from "@/types"; import { ICart } from "@/types/cart"; -import { IOrder } from "@/types/order"; -const groupByProductId = (cart: ICart[]): IOrder[] => { - return cart.reduce((acc: IOrder[], item: ICart) => { - const existingItem = acc.find((order) => order.id === item.product.id); - if (existingItem) { - existingItem.quantity += 1; - } else { - acc.push({ - id: item.product.id, - name: item.product.name, - price: item.product.price, - imageUrl: item.product.imageUrl, - quantity: 1, - }); - } - return acc; - }, []); +const getCartData = () => { + const cartData = localStorage.getItem("cart") || "[]"; + return JSON.parse(cartData) as ICart[]; }; +const addToCart = (product: IProduct) => { + const data: ICart[] = getCartData(); + const exist = data.find((item) => item.product.id === product.id); + if (exist) { + const newData = data.map((item) => { + if (item.product.id === product.id) { + return { ...item, quantity: item.quantity + 1 }; + } + return item; + }); + localStorage.setItem("cart", JSON.stringify(newData)); + return; + } -export { groupByProductId }; + const newData = [ + ...data, + { id: window.crypto.randomUUID(), product, quantity: 1 }, + ]; + localStorage.setItem("cart", JSON.stringify(newData)); +}; + +const deleteProductFromCart = (productId: ICart["product"]["id"]) => { + const data: ICart[] = getCartData(); + const newData = data.filter((item) => item.product.id !== productId); + localStorage.setItem("cart", JSON.stringify(newData)); +}; + +export { addToCart, getCartData, deleteProductFromCart };