[React JS] 7. ์ƒํ’ˆ ๋””ํ…Œ์ผ ํŽ˜์ด์ง€

 

1. ์ƒํ’ˆ ๋””ํ…Œ์ผ ํŽ˜์ด์ง€ ์ƒ์„ฑํ•˜๊ธฐ

App.jsx

          <Route path="/product/:productId" element={<DetailProductPage />} />

CardItem์—์„œ ๋ˆ„๋ฅด๋ฉด ๋ฐฑ์—”๋“œ ํ˜ธ์ถœ

Detailedpage

import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import axiosInstance from "../../utils/axios";
import ProductImage from "./Sections/ProductImage";
import ProductInfo from "./Sections/ProductInfo";

const DetailedProductPage = () => {
  const { productId } = useParams();
  const [product, setproduct] = useState(null);

  useEffect(() => {
    async function fetchProduct() {
      try {
        const response = await axiosInstance.get(
          `/products/${productId}?type=single`
        );
        console.log(response);
        setproduct(response.data[0]);
      } catch (error) {
        console.error(error);
      }
    }
    fetchProduct();
  }, [productId]);

  if (!product) return null;
  return (
    <section>
      <div className="text-center">
        <h1 className="p-4 text-2xl">{product.title}</h1>
      </div>

      <div className="flex gap-4">
        {/* ProductImage */}
        <div className="w-1/2">
          <ProductImage product={product} />
        </div>

        {/* ProductInfo */}
        <div className="w-1/2">
          <ProductInfo procuct={product} />
        </div>
      </div>
    </section>
  );
};

export default DetailedProductPage;

ProductImage.jsx

import React, { useState, useEffect } from "react";
import ImageGallery from "react-image-gallery";

const ProductImage = ({ product }) => {
  const [images, setimages] = useState([]);
  useEffect(() => {
    if (product?.images?.length > 0) {
      let images = [];
      product.images.map((imageName) => {
        return images.push({
          original: `${import.meta.env.VITE_SERVER_URL}/${imageName}`,
          thumbnail: `${import.meta.env.VITE_SERVER_URL}/${imageName}`,
        });
      });
      setimages(images);
    }

    
  }, [product]);

  return <ImageGallery items={images} />;
};

export default ProductImage;

index.css

@import "react-image-gallery/styles/css/image-gallery.css";

๋ผ์šฐํ„ฐ

router.get("/:productId", async (req, res, next) => {
  const type = req.query.type;
  let productIds = req.params.productId;

  // productId๋ฅผ ์ด์šฉํ•ด์„œ DB์—์„œ productId์™€ ๊ฐ™์€ ์ƒํ’ˆ์˜ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ด
  try {
    const product = await Product.find({ _id: { $in: productIds } }).populate(
      "writer"
    );
    return res.status(200).send(product);
  } catch (error) {
    next(error);
  }
});

2. ProductInfo ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑํ•˜๊ธฐ

ProductInfo.jsx

import React from "react";
import { useDispatch } from "react-redux";
import { addToCart } from "../../../store/thunkFunctions";

const ProductInfo = ({ product }) => {
  const dispatch = useDispatch();
  const handleClick = () => {
    dispatch(addToCart({ productId: product._id }));
  };
  return (
    <div>
      <p className="text-xl text-bold">Product Info</p>
      <ul>
        <li>
          <span className="font-semibold text-gray-900">Price: </span>
          {product.price} ์›
        </li>
        <li>
          <span className="font-semibold text-gray-900">Sold: </span>
          {product.sold} ๊ฐœ
        </li>
        <li>
          <span className="font-semibold text-gray-900">Description: </span>
          {product.description}
        </li>
      </ul>

      <div className="mt-3">
        <button
          onClick={handleClick}
          className="w-full px-4 py-2 text-white bg-black hover:bg-gray rounded-md"
        >
          Cart
        </button>
      </div>
    </div>
  );
};


export default ProductInfo;

thunkfunction.js

export const addToCart = createAsyncThunk(
  "user/addToCart",
  async (body, thunkAPI) => {
    try {
      const response = await axiosInstance.post("/users/cart", body);
      return response.data;
    } catch (error) {
      console.log(error);
      return thunkAPI.rejectWithValue(error.response.data || error.message);
    }
  }
);

๋ผ์šฐํ„ฐ

router.post("/cart", auth, async (req, res, next) => {
  try {
    // ๋จผ์ € User Collection์— ํ•ด๋‹น ์œ ์ € ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
    const userInfo = await User.findOne({ _id: req.user._id });

    // ๊ฐ€์ ธ์˜จ ์ •๋ณด์—์„œ ์นดํŠธ์—๋‹ค ๋„ฃ์œผ๋ ค๊ณ  ํ•˜๋Š” ์ƒํ’ˆ์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ํ™•์ธ
    let duplicate = false;
    userInfo.cart.forEach((item) => {
      if (item.id === req.body.productId) {
        duplicate = true;
      }
    });

    // ์ƒํ’ˆ์ด ์ด๋ฏธ ์žˆ์„ ๋•Œ
    if (duplicate) {
      const user = await User.findOneAndUpdate(
        { _id: req.user._id, "cart.id": req.body.productId },
        { $inc: { "cart.$.quantity": 1 } },
        { new: true }
      );
      return res.status(201).send(user.cart);
    }

    // ์ƒํ’ˆ์ด ์žˆ์ง€ ์•Š์„ ๋•Œ
    else {
      const user = await User.findOneAndUpdate(
        { _id: req.user._id },
        {
          $push: {
            cart: {
              id: req.body.productId,
              quantity: 1,
              date: Date.now(),
            },
          },
        },
        { new: true }
      );
      return res.status(201).send(user.cart);
    }
  } catch (error) {
    next(error);
  }
});

userSlice.js

.addCase(addToCart.pending, (state) => {
        state.isLoading = true;
      })
      .addCase(addToCart.fulfilled, (state, action) => {
        state.isLoading = false;
        state.userData.cart = action.payload;
        toast.info('์žฅ๋ฐ”๊ตฌ๋‹ˆ์— ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.')
      })
      .addCase(addToCart.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.payload;
        toast.error(action.payload);
      })

3. ์‡ผํ•‘์นดํŠธ ํŽ˜์ด์ง€์— ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ

- ์นดํŠธ ์•ˆ์— ๋“ค์–ด๊ฐ€์žˆ๋Š” ์ƒํ’ˆ๋“ค์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๊ฐ€์ ธ์˜ค๊ธฐ

 

CartPage.js

import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getCartItems } from "../../store/thunkFunctions";

const CartPage = () => {
  const userData = useSelector((state) => state.user?.userData);
  const dispatch = useDispatch();

  useEffect(() => {
    let cartItemIds = [];

    if (userData?.cart && userData.cart.length > 0) {
      userData.cart.forEach((item) => {
        cartItemIds.push(item.id);
      });

      const body = {
        cartItemIds,
        userCart: userData.cart
      };

      dispatch(getCartItems(body));
    }

    return () => {
      
    };
  }, [dispatch, userData]);

  return <div>CartPage</div>;
};

export default CartPage;

thunkfunction.js

export const getCartItems = createAsyncThunk(
  "user/getCartItems",
  async ({ cartItemIds, userCart }, thunkAPI) => {
    try {
      const response = await axiosInstance.get(
        `/products/${cartItemIds}?type=array`
      );

      // product ๋ฐ์ดํ„ฐ\ ์š”์ฒญ๋ณด๋‚ด์„œ ๊ฐ€์ ธ์˜ค๋ฉด (response) cart์™€ ํ•ฉ์ณ์ฃผ๋Š” ๊ฒƒ
      userCart.forEach((cartItem) => {
        response.data.forEach((productDetail, index) => {
          if (cartItem.id === productDetail._id) {
            response.data[index].quantity = cartItem.quantity;
          }
        });
      });
      return response.data;
    } catch (error) {
      console.log(error);
      return thunkAPI.rejectWithValue(error.response.data || error.message);
    }
  }
);

product.js

router.get("/:productId", async (req, res, next) => {
  const type = req.query.type;
  let productIds = req.params.productId;

  if (type === "array") {
    // id=3123123, 213123,123123
    // -> {'123213','123213',123123}
    let ids = productIds.split(",");
    productIds = ids.map((item) => {
      return item;
    });
  }

  // productId๋ฅผ ์ด์šฉํ•ด์„œ DB์—์„œ productId์™€ ๊ฐ™์€ ์ƒํ’ˆ์˜ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ด
  try {
    const product = await Product.find({ _id: { $in: productIds } }).populate(
      "writer"
    );
    return res.status(200).send(product);
  } catch (error) {
    next(error);
  }
});

 

payload / userSlice

.addCase(getCartItems.pending, (state) => {
        state.isLoading = true;
      })
      .addCase(getCartItems.fulfilled, (state, action) => {
        state.isLoading = false;
        state.cartDetail = action.payload;
      })
      .addCase(getCartItems.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.payload;
        toast.error(action.payload);
      });

4. ์‡ผํ•‘์นดํŠธ ํŽ˜์ด์ง€ ์ƒ์„ฑํ•˜๊ธฐ

CartPage.js

import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getCartItems } from "../../store/thunkFunctions";

const CartPage = () => {
  const userData = useSelector((state) => state.user?.userData);
  const cartDetail = useSelector((state) => state.user?.cartDetail);
  const dispatch = useDispatch();
  const [total, settotal] = useState(0);
  useEffect(() => {
    let cartItemIds = [];

    if (userData?.cart && userData.cart.length > 0) {
      userData.cart.forEach((item) => {
        cartItemIds.push(item.id);
      });

      const body = {
        cartItemIds,
        userCart: userData.cart,
      };

      dispatch(getCartItems(body));
    }

    return () => {};
  }, [dispatch, userData]);

  useEffect(() => {
    calculateTotal(cartDetail);
  }, [cartDetail]);

  const calculateTotal = (cartItems) => {
    let total = 0;
    cartItems.map((item) => (total += item.price * item.quantity));
    settotal(total);
  };

  return (
    <section>
      <div className="text-center m-7">
        <h2 className="text-2xl">My cart</h2>
      </div>

      {cartDetail?.length > 0 ? (
        <>
          <div className="mt-10">
            <p>
              <span className="font-bold">Total: </span>
              {total}$
            </p>
            <button className="px-4 py-2 mt-5 text-white bg-black rounded-md hover:bg-gray-500">
              Pay
            </button>
          </div>
        </>
      ) : (
        <p>์žฅ๋ฐ”๊ตฌ๋‹ˆ๊ฐ€ ๋น„์—ˆ์Šต๋‹ˆ๋‹ค..</p>
      )}
    </section>
  );
};

export default CartPage;

 

CartTable

          <CartTable products={cartDetail} onRemoveItem={handleRemoveCartItem}/>
import React from "react";

const CartTable = ({ products, onRemoveItem }) => {

  const renderCartImage = (images) => {
    if (images.length > 0) {
      let image = images[0];
      return `${import.meta.env.VITE_SERVER_URL}/${image}`;
    }
  };

  const renderItems =
    products.length > 0 &&
    products.map((product) => (
      <tr>
        <td>
          <img
            className="w-[70px]"
            alt="product"
            src={renderCartImage(product.images)}
          />
        </td>
        <td>amount : {product.quantity}</td>
        <td>{product.price} $</td>
        <td>
          <button onClick={() => onRemoveItem(product.id)}>delete</button>
        </td>
      </tr>
    ));

  return (
    <table className="w-full text-sm text-left text-gray-500">
      <thead className="border-[1px]">
        <tr>
          <th>Photo</th>
          <th>Amount</th>
          <th>Price</th>
          <th>Delete</th>
        </tr>
      </thead>

      <tbody>{renderItems}</tbody>
    </table>
  );
};

export default CartTable;

 

---------

์‚ญ์ œ๊ธฐ๋Šฅ

  const handleRemoveCartItem = (productId) => {
    dispatch(removeCartItem(productId));
  }

<CartTable products={cartDetail} onRemoveItem={handleRemoveCartItem}/>
<button onClick={() => onRemoveItem(product._id)}>delete</button>

thunkfunction.js

export const removeCartItem = createAsyncThunk(
  "user/removeCartItem",
  async (productId, thunkAPI) => {
    try {
      const response = await axiosInstance.delete(
        `/users/cart?productId=${productId}`
      );

      // product ๋ฐ์ดํ„ฐ ์š”์ฒญ๋ณด๋‚ด์„œ ๊ฐ€์ ธ์˜ค๋ฉด (response) cart์—์„œ ๋นผ์ฃผ๋Š” ๊ฒƒ
      response.data.cart.forEach((cartItem) => {
        response.data.productInfo.forEach((productDetail, index) => {
          if (cartItem.id === productDetail._id) {
            response.data.productInfo[index].quantity = cartItem.quantity;
          }
        });
      });

      return response.data;
    } catch (error) {
      console.log(error);
      return thunkAPI.rejectWithValue(error.response.data || error.message);
    }
  }
);

users.js

router.delete("/cart", auth, async (req, res, next) => {
  try {
    //๋จผ์ € cart์— ์ง€์šฐ๋ ค๋Š” ์ƒํ’ˆ ์ง€์šฐ๊ธฐ
    const userInfo = await User.findOneAndUpdate(
      { _id: req.user._id },
      {
        $pull: { cart: { id: req.query.productId } },
      },
      { new: true }
    );

    const cart = userInfo.cart;
    const array = cart.map((item) => {
      return item.id;
    });

    const productInfo = await Product.find({ _id: { $in: array } }).populate(
      "writer"
    );

    return res.json({
      productInfo,
      cart,
    });
  } catch (error) {
    next(error);
  }
});

5. ๊ฒฐ์ œ & ํžˆ์Šคํ† ๋ฆฌ

CartPage.js

  const handlePaymentClick = () => {
    dispatch(payProducts({ cartDetail }));
  };
  
  <button
              className="px-4 py-2 mt-5 text-white bg-black rounded-md hover:bg-gray-500"
              onClick={handlePaymentClick}
            >

Thunkfunciton

export const payProducts = createAsyncThunk(
  "user/payProducts",
  async (body, thunkAPI) => {
    try {
      const response = await axiosInstance.post(`/users/payment`, body);

      return response.data;
    } catch (error) {
      console.log(error);
      return thunkAPI.rejectWithValue(error.response.data || error.message);
    }
  }
);

users.js 

router.post("/payment", auth, async (req, res) => {
  // User Collection ์•ˆ์— History ํ•„๋“œ ์•ˆ์— ๊ฐ„๋‹จํ•œ ๊ฒฐ์ œ ์ •๋ณด ๋„ฃ์–ด์ฃผ๊ธฐ
  let history = [];
  let transactionData = {};

  req.body.cartDetail.forEach((item) => {
    history.push({
      dateOfPurchase: new Date().toISOString(),
      name: item.title,
      id: item._id,
      price: item.price,
      quantity: item.quantity,
      paymentId: crypto.randomUUID(),
    });
  });

  // Payment Collection์•ˆ์— ์ž์„ธํ•œ ๊ฒฐ์ œ์ •๋ณด ๋„ฃ์–ด์ฃผ๊ธฐ
  transactionData.user = {
    id: req.user._id,
    name: req.user.name,
    email: req.user.email,
  };

  transactionData.product = history;

  // history์— ์žฅ๋ฐ”๊ตฌ๋‹ˆ ์ •๋ณด ๋„ฃ์–ด์ฃผ๊ณ  ์นดํŠธ ๋น„์›Œ์ฃผ๊ธฐ
  await User.findOneAndUpdate(
    { _id: req.user._id },
    { $push: { history: { $each: history } }, $set: { cart: [] } }
  );

  // payment collection
  const payment = new Payment(transactionData);
  const paymentDocs = await payment.save();

  let products = [];
  paymentDocs.product.forEach((item) => {
    products.push({ id: item.id, quantity: item.quantity });
  });

  async.eachSeries(
    products,
    async (item) => {
      await Product.updateOne(
        { _id: item.id },
        {
          $inc: {
            sold: item.quantity,
          },
        }
      );
    },
    (err) => {
      if (err) return res.status(500).send(err);
      return res.sendStatus(200);
    }
  );
});

userSlice.js

router.post("/payment", auth, async (req, res) => {
  // User Collection ์•ˆ์— History ํ•„๋“œ ์•ˆ์— ๊ฐ„๋‹จํ•œ ๊ฒฐ์ œ ์ •๋ณด ๋„ฃ์–ด์ฃผ๊ธฐ
  let history = [];
  let transactionData = {};

  req.body.cartDetail.forEach((item) => {
    history.push({
      dateOfPurchase: new Date().toISOString(),
      name: item.title,
      id: item._id,
      price: item.price,
      quantity: item.quantity,
      paymentId: crypto.randomUUID(),
    });
  });

  // Payment Collection์•ˆ์— ์ž์„ธํ•œ ๊ฒฐ์ œ์ •๋ณด ๋„ฃ์–ด์ฃผ๊ธฐ
  transactionData.user = {
    id: req.user._id,
    name: req.user.name,
    email: req.user.email,
  };

  transactionData.product = history;

  // history์— ์žฅ๋ฐ”๊ตฌ๋‹ˆ ์ •๋ณด ๋„ฃ์–ด์ฃผ๊ณ  ์นดํŠธ ๋น„์›Œ์ฃผ๊ธฐ
  await User.findOneAndUpdate(
    { _id: req.user._id },
    { $push: { history: { $each: history } }, $set: { cart: [] } }
  );

  // payment collection
  const payment = new Payment(transactionData);
  const paymentDocs = await payment.save();

  let products = [];
  paymentDocs.product.forEach((item) => {
    products.push({ id: item.id, quantity: item.quantity });
  });

  async.eachSeries(
    products,
    async (item) => {
      await Product.updateOne(
        { _id: item.id },
        {
          $inc: {
            sold: item.quantity,
          },
        }
      );
    },
    (err) => {
      if (err) return res.status(500).send(err);
      return res.sendStatus(200);
    }
  );
});

history

import React from "react";
import { useSelector } from "react-redux";
import dayjs from 'dayjs'

const HistoryPage = () => {
  const userData = useSelector((state) => state.user?.userData);

  return (
    <section>
      <div className="text-center m-7">
        <h2 className="text-2xl">History</h2>
      </div>

      <table className="w-full text-sm text-left text-gray-500">
        <thead className="border-[1px]">
          <tr>
            <th>Payment Id</th>
            <th>Price</th>
            <th>Date of Purchase</th>
          </tr>
        </thead>
        <tbody>
          {userData?.history.map((item) => (
            <tr className="border-b" key={item.id}>
              <td>{item.id}</td>
              <td>{item.price}</td>
              <td>{item.quantity}</td>
              <td>{dayjs(item.dateOfPurchase).format('YYYY-MM-DD HH:mm:ss')}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </section>
  );
};

export default HistoryPage;
  • ๋„ค์ด๋ฒ„ ๋ธ”๋Ÿฌ๊ทธ ๊ณต์œ ํ•˜๊ธฐ
  • ๋„ค์ด๋ฒ„ ๋ฐด๋“œ์— ๊ณต์œ ํ•˜๊ธฐ
  • ํŽ˜์ด์Šค๋ถ ๊ณต์œ ํ•˜๊ธฐ
  • ์นด์นด์˜ค์Šคํ† ๋ฆฌ ๊ณต์œ ํ•˜๊ธฐ