[React JS] 6. ์ƒํ’ˆ ์ƒ์„ฑ, ๋ฉ”์ธ ํŽ˜์ด์ง€ ์ƒ์„ฑํ•˜๊ธฐ

 

1. Shop App์„ ์œ„ํ•œ ์†Œ์Šค ์ฝ”๋“œ ์ถ”๊ฐ€ํ•˜๊ธฐ

RegisterPage.js

UploadProductPage.js

DetailProductPagejs

CartPage.js

HistoryPage.js

์ถ”๊ฐ€

 

App.jsx์— route ์ถ”๊ฐ€

 

Model ์ƒ์„ฑ

- Product

- Payment

 

NavItem์— ์ถ”๊ฐ€

- cart

- upload

import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
import { logoutUser } from "../../../store/thunkFunctions";
import { AiOutlineShoppingCart } from "react-icons/ai";

const routes = [
  { to: "/login", name: "๋กœ๊ทธ์ธ", auth: false },
  { to: "/register", name: "ํšŒ์›๊ฐ€์ž…", auth: false },
  { to: "/product/upload", name: "์—…๋กœ๋“œ", auth: true },
  {
    to: "/user/cart",
    name: "์นดํŠธ",
    auth: true,
    icon: <AiOutlineShoppingCart style={{ fontSize: "1.4rem" }} />,
  },
  { to: "", name: "๋กœ๊ทธ์•„์›ƒ", auth: true },
  { to: "/history", name: "์ฃผ๋ฌธ๋ชฉ๋ก", auth: true },
];

const NavItem = ({ mobile }) => {
  const isAuth = useSelector((state) => state.user?.isAuth);
  const dispatch = useDispatch();
  const navigate = useNavigate();

  const handleLogout = () => {
    dispatch(logoutUser()).then(() => {
      navigate("/login");
    });
  };
  return (
    <ul
      className={`text-md justify-center w-full flex gap-4 ${
        mobile && "flex-col bg-gray-900 h-full"
      } items-center`}
    >
      {routes.map(({ to, name, auth, icon }) => {
        if (isAuth !== auth) return null;

        if (name === "๋กœ๊ทธ์•„์›ƒ") {
          return (
            <li
              key={name}
              className="py-2 text-center border-b-4 cursor-pointer"
            >
              <Link onClick={handleLogout}>{name}</Link>
            </li>
          );
        } else if (icon) {
          return (
            <li
              className="relative py-2 text-center border-b-4 cursor-pointer"
              key={name}
            >
              <Link to={to}>
                {icon}
                <span className="absolute top-0 inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -right-3">
                  {1}
                </span>
              </Link>
            </li>
          );
        } else {
          return (
            <li
              key={name}
              className="py-2 text-center border-b-4 cursor-pointer"
            >
              <Link to={to}>{name}</Link>
            </li>
          );
        }
      })}
    </ul>
  );
};

export default NavItem;

ํŒจํ‚ค์ง€ ์„ค์น˜

frontend: npm i react-dropzone react-image-gallery react-responsive-carousel   
backend: npm i async multer

2. ์ƒํ’ˆ ์—…๋กœ๋“œ ํŽ˜์ด์ง€ UI ์ƒ์„ฑํ•˜๊ธฐ

UploadProductPage.js

import React from "react";

const continents = [
  { key: 1, value: "Africa" },
  { key: 2, value: "Europe" },
  { key: 3, value: "Asia" },
  { key: 4, value: "North America" },
  { key: 5, value: "South America" },
  { key: 6, value: "Australia" },
  { key: 7, value: "Antarctica" },
];

const UploadProductPage = () => {
  return (
    <section>
      <div className="text-center m-7">
        <h1>์˜ˆ์ƒ ์ƒํ’ˆ ์—…๋กœ๋“œ</h1>
      </div>

      <form className="mt-6">
        <div className="mt-4">
          <label htmlFor="title">์ด๋ฆ„</label>
          <input
            className="w-full px-4 py-2 bg-white border rounded-md border-spacing-4"
            name="title"
            id="title"
          />
        </div>

        <div className="mt-4">
          <label htmlFor="description">์„ค๋ช…</label>
          <input
            className="w-full px-4 py-2 bg-white border rounded-md"
            name="description"
            id="description"
          />
        </div>

        <div className="mt-4">
          <label htmlFor="price">๊ฐ€๊ฒฉ</label>
          <input
            className="w-full px-4 py-2 bg-white border rounded-md"
            type="number"
            name="price"
            id="price"
          />
        </div>

        <div className="mt-4">
          <label htmlFor="continents">์ง€์—ญ</label>
          <select
            className="w-full px-4 mt-2 bg-white border rounded-md"
            name="continents"
            id="continents"
          >
            {continents.map((item) => (
              <option key={item.key} value={item.key}>
                {item.value}
              </option>
            ))}
          </select>
        </div>

        <div className="mt-4">
          <button className="w-full px-4 text-white bg-black rounded-md hover:bg-gray-700 py-2">
            ์ƒ์„ฑํ•˜๊ธฐ
          </button>
        </div>
      </form>
    </section>
  );
};

export default UploadProductPage;

3. ์ƒํ’ˆ ์—…๋กœ๋“œ ํŽ˜์ด์ง€ ๊ธฐ๋Šฅ ์ƒ์„ฑํ•˜๊ธฐ

State ์ƒ์„ฑ 

state : ์˜ˆ๋ฅผ ๋“ค์–ด ํผ์— ์ž‘์„ฑํ•ด์ฃผ๋Š” ๊ฒƒ๋“ค์„ ๊ด€๋ฆฌ

const [product, setProduct] = useState({
    title: "",
    description: "",
    price: 0,
    continents: 1,
    images: [],
  });

ํ•ธ๋“ค๋Ÿฌ ํ•จ์ˆ˜ ์ƒ์„ฑ

const handleChange = (event) => {
    const { name, value } = event.target;
    setProduct((prevState) => ({
      ...prevState,
      [name]: value,
    }));
  };

state์—์„œ userdata ๊ฐ€์ ธ์˜ค๊ธฐ (id) -> auth ๋กœ ๋งŒ๋“ค์–ด์ ธ์žˆ์Œ

const userData = useSelector(state => state.user?.userData);
const navigate = useNavigate();

submit

const handleSubmit = async (event) => {
    event.preventDefault();
    //์ด๋ ‡๊ฒŒ ํ•ด๋„ ๋˜๊ณ  
    const { title, description, price, images, continents } = product;
    const body = {
      writer: userData.id,
      // ์ด๋ ‡๊ฒŒ ํ•ด๋„ ๋จ
      ...product,
    };

    try {
      await axiosInstance.post("/products", body);
      navigate("/");
    } catch (error) {
      console.error(error);
    }
  };

๋ฐฑ๋‹จ ๋ผ์šฐํ„ฐ

router.post("/", auth, async (req, res, next) => {
  try {
    const product = new Product(req.body);
    await product.save();
    return res.sendStatus(200);
  } catch (error) {
    next(error);
  }
});

4. ํŒŒ์ผ ์—…๋กœ๋“œ ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑํ•˜๊ธฐ

ํŒŒ์ผ์—…๋กœ๋“œ ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ

components > FileUpload.jsx

 

์ด๋ฏธ์ง€ ๋“œ๋กญ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ

npm i install react-dropzone

 

UploadProductPage.jsx

<FileUpload images={product.images} onImageChange={handleImages}/>
const handleImages = (newImages) => {
    setProduct((prevState) => ({
      ...prevState,
      images: newImages,
    }));
  };

FileUpload.jsx

import React from "react";
import Dropzone from "react-dropzone";
import axiosInstance from "../utils/axios";

const FileUpload = ({ onImageChange, images }) => {
  const handleDrop = async (files) => {
    let formData = new FormData();

    const config = {
      header: { "content-type": "multipart/form-data" },
    };
    formData.append("file", files[0]);

    try {
      const response = await axiosInstance.post(
        "/products/image",
        formData,
        config
      );
      onImageChange([...images, response.data.fileName]);
    } catch (error) {
      console.error(error);
    }
  };

  const handleDelete = (image) => {
    const currentIndex = images.indexOf(image);
    let newImages = [...images];
    newImages.splice(currentIndex, 1);
    onImageChange(newImages);
  };

  return (
    <div className="flex gap-4">
      <Dropzone onDrop={handleDrop}>
        {({ getRootProps, getInputProps }) => (
          <section className="min-w-[300px] border flex itmes-center justify-center">
            <div {...getRootProps()}>
              <input {...getInputProps()} />
              <p className="text-3xl">+</p>
            </div>
          </section>
        )}
      </Dropzone>
      <div className="flex-grow h-[300px] borader flex items-center justify-center overflow-x-scroll overflow-y-hidden">
        {images.map((image) => (
          <div key={image} onClick={() => handleDelete(image)}>
            <img
              className="min-w-[300px] h-[300px]"
              src={`${import.meta.env.VITE_SERVER_URL}/${image}`}
              alt={image}
            />
          </div>
        ))}
      </div>
    </div>
  );
};

export default FileUpload;

multer ์‚ฌ์šฉ

products router

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, "uploads/");
  },
  filename: function (req, file, cb) {
    cb(null, `${Date.now()}_${file.originalname}`);
  },
});

const upload = multer({ storage: storage }).single("file");

router.post("/image", auth, async (req, res, next) => {
  upload(req, res, (err) => {
    if (err) {
      return req.status(500).send(err);
    }
    return res.json({ fileName: res.req.file.filename });
  });
});

5. Landing Page ์ƒ์„ฑํ•˜๊ธฐ

์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ (CheckBox, RadioBox, SearchInput, CardItem)

 

State ์ƒ์„ฑ

const limit = 4;
  const [products, setproducts] = useState([]);
  const [skip, setskip] = useState(0);
  const [hasMore, sethasMore] = useState(false);
  const [filters, setfilters] = useState({
    continents: [],
    price: [],
  });

 

fetch ํ•จ์ˆ˜ ์‚ฌ์šฉํ•ด์„œ ๋ฐฑ์—”๋“œ ์š”์ฒญํ•ด db์—์„œ product ๊ฐ€์ ธ์˜ด

const fetchProducts = async (
    skip,
    limit,
    loadMore = false,
    filters = {},
    searchTerm = ""
  ) => {
    const params = {
      skip,
      limit,
      filters,
      searchTerm,
    };

    try {
      const response = await axiosInstance.get("/products", { params });
      setproducts(response.data.products);
    } catch (error) {
      console.error(error);
    }
  };

์ด๋•Œ useEffect dependency array์— ๋นˆ ๊ฐ’์„ ๋„ฃ์–ด์„œ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋งˆ์šดํŠธ๋œ ํ›„  ํ•œ๋ฒˆ๋งŒ ๋ Œ๋”๋ง๋  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ค„ ๊ฒƒ

useEffect(() => {
    fetchProducts({ skip, limit });
  }, []);

๋ฐฑ์—”๋“œ ๋ผ์šฐํ„ฐ ์ถ”๊ฐ€

router.get("/", async (req, res, next) => {
  try {
    const product = await Product.find().populate('writer');
    return res.sendStatus(200).json({
      products
    });
  } catch (error) {
    next(error);
  }
});

CardItem component

import React from "react";
import { Link } from "react-router-dom";
import ImageSlider from "../../../components/ImageSlider";

const CardItem = ({ product }) => {
  return (
    <div className="border-[1px] border-gray-300">
      <ImageSlider images={product.images} />
      <Link to={`/product/${product._id}`}>
        <p className="p-1">{product.title}</p>
        <p className="p-1">{product.continents}</p>
        <p className="p-1 text-xs text-gray-500">{product.price}์›</p>
      </Link>
    </div>
  );
};

export default CardItem;

ImageSlider

import React from "react";
import "react-responsive-carousel/lib/styles/carousel.min.css";
import { Carousel } from "react-responsive-carousel";

const ImageSlider = ({ images }) => {
  return (
    <Carousel autoPlay showThumbs={false} infiniteLoop>
      {images.map((image) => (
        <div key={image}>
          <img
            src={`${import.meta.env.VITE_SERVER_URL}/${image}`}
            alt={image}
            className="w-full max-h-[150px]"
          />
          <p className="legend">view product</p>
        </div>
      ))}
    </Carousel>
  );
};

export default ImageSlider;

 

<๋”๋ณด๊ธฐ ๊ธฐ๋Šฅ>

 

skip : skip + limit (4๊ฐœ ์Šคํ‚ต, ์ฒ˜์Œ 4๊ฐœ๋Š” ๋ณด์—ฌ์ง€๊ฒŒ) 

limit : 4

loadmore : true

filters (state) -> ํ•„ํ„ฐ ์กฐ๊ฑด์œผ๋กœ ๋”๋ณด๊ธฐ ๊ฐ€์ ธ์˜ค๋Š”  ๊ฒƒ

 

(skip๊ณผ limit์€ mongodb์—์„œ ์ œ๊ณต)

 

๋”๋ณด๊ธฐ ํด๋ฆญํ•˜๋ฉด ์‹คํ–‰๋  handleLoadMore

const handleLoadMore = () => {
    const body = {
      skip: skip + limit,
      limit,
      loadMore: true,
      filters,
    };
    fetchProducts(body);
    setskip(skip + limit);
  };

๋ผ์šฐํ„ฐ ์ˆ˜์ •

router.get("/", async (req, res, next) => {
  const order = req.query.order ? req.query.order : "desc";
  const sortBy = req.query.sortBy ? req.query.sortBy : "_id";
  const limit = req.query.limit ? Number(req.query.limit) : 20;
  const skip = req.query.skip ? Number(req.query.skip) : 0;
  try {
    const products = await Product.find()
      .populate("writer")
      .sort([[sortBy, order]])
      .skip(skip)
      .limit(limit);
    const productsTotal = await Product.countDocuments();
    const hasMore = skip + limit < productsTotal ? true : false;
    return res.status(200).json({
      products,
      hasMore,
    });
  } catch (error) {
    next(error);
  }
});

fetchProducts ์ˆ˜์ •

const fetchProducts = async ({
    skip,
    limit,
    loadMore = false,
    filters = {},
    searchTerm = "",
  }) => {
    const params = {
      skip,
      limit,
      filters,
      searchTerm,
    };

    try {
      const response = await axiosInstance.get("/products", { params });

      if (loadMore) {
        setproducts([...products, ...response.data.products]);
      } else {
        setproducts(response.data.products);
      }
      sethasMore(response.data.hasMore)
    } catch (error) {
      console.error(error);
    }
  };

<CheckBox Filter ๊ธฐ๋Šฅ>

 

LandingPage

<CheckBox
            continents={continents}
            checkedContinents={filters.continents}
            onFilters={(filters) => handleFilters(filters, "continents")}
          />

 

filterData

const continents = [
  {
    "_id": 1,
    "name": "Africa",
  },
  {
    "_id": 2,
    "name": "Europe",
  },
  {
    "_id": 3,
    "name": "Asia",
  },
  {
    "_id": 4,
    "name": "North America",
  },
  {
    "_id": 5,
    "name": "South America",
  },
  {
    "_id": 6,
    "name": "Australia",
  },
  {
    "_id": 7,
    "name": "Antarctica",
  },
];

const prices = [
  {
    "_id": 1,
    "name": "All",
    "array": [],
  },
  {
    "_id": 2,
    "name": "0~9$",
    "array": [0, 9],
  },
  {
    "_id": 3,
    "name": "10~99$",
    "array": [10, 99],
  },
  {
    "_id": 4,
    "name": "100~999$",
    "array": [100, 999],
  },
  {
    "_id": 5,
    "name": "1000~9999$",
    "array": [1000, 9999],
  },
  {
    "_id": 6,
    "name": "10000~99999$",
    "array": [10000, 99999],
  },
];

export {
  continents,
  prices
}

CheckBox

import React from "react";

const CheckBox = ({ continents, checkedContinents, onFilters }) => {
  const handleToggle = (continentId) => {
    // ํ˜„์žฌ ๋ˆ„๋ฅธ CheckBox๊ฐ€ ์ด๋ฏธ ๋ˆ„๋ฅธ checkbox์ธ์ง€ ํ™•์ธ
    const currentIndex = checkedContinents.indexOf(continentId);
    const newChecked = [...checkedContinents];
    if (currentIndex === -1) {
      newChecked.push(continentId);
    } else {
      // ์ „์ฒด Checked๋œ State์—์„œ ํ˜„์žฌ ๋ˆ„๋ฅธ CheckBox๊ฐ€ ์žˆ๋‹ค๋ฉด ์ œ๊ฑฐ
      newChecked.splice(currentIndex, 1);
    }
    onFilters(newChecked);
  };

  return (
    <div className="p-2 mb-3 bg-gray-100 rounded-md">
      {continents?.map(continent => (
        <div key={continent._id}>
          <input
            type="checkbox"
            onChange={() => handleToggle(continent._id)}
            checked={
              checkedContinents.indexOf(continent._id) === -1 ? false : true
            }
          />{" "}
          <label>{continent.name}</label>
        </div>
      ))}
    </div>
  );
};

export default CheckBox;

LandingPage functions

  const handleFilters = (newFilteredData, category) => {
    const newFilters = { ...filters };
    newFilters[category] = newFilteredData;

    showFilteredResults(newFilters);
    setfilters(newFilters);
  };

  const showFilteredResults = (filters) => {
    const body = {
      skip: 0,
      limit,
      filters,
    };
    fetchProducts(body);
    setskip(0);
  };

add option in router

router.get("/", async (req, res, next) => {
  const order = req.query.order ? req.query.order : "desc";
  const sortBy = req.query.sortBy ? req.query.sortBy : "_id";
  const limit = req.query.limit ? Number(req.query.limit) : 20;
  const skip = req.query.skip ? Number(req.query.skip) : 0;

  let findArgs = {};
  for(let key in req.query.filters){
    if(req.query.filters[key].length > 0){
      findArgs[key] = req.query.filters[key];
    }
  }

  try {
    const products = await Product.find(findArgs)
      .populate("writer")
      .sort([[sortBy, order]])
      .skip(skip)
      .limit(limit);
    const productsTotal = await Product.countDocuments(findArgs);
    const hasMore = skip + limit < productsTotal ? true : false;
    return res.status(200).json({
      products,
      hasMore,
    });
  } catch (error) {
    next(error);
  }
});

 

 

<RadioBox ๊ฐ€๊ฒฉ ๊ธฐ๋Šฅ>

 

<RadioBox
            prices={prices}
            checkedPrice={filters.price}
            onFilters={(filters) => handleFilters(filters, "price")}
          />
import React from "react";

const RadioBox = ({ prices, checkedPrice, onFilters }) => {
  return (
    <div className="p-2 mb-3 bg-gray-100 rounded-md">
      {prices?.map((price) => (
        <div key={price._id}>
          <input
            checked={checkedPrice === price.array}
            onChange={(e) => onFilters(e.target.value)}
            type="radio"
            id={price._id}
            value={price._id}
          />{" "}
          <label htmlFor={price._id}>{price.name}</label>
        </div>
      ))}
    </div>
  );
};

export default RadioBox;
for (let key in req.query.filters) {
    if (req.query.filters[key].length > 0) {
      if (key === "price") {
        findArgs[key] = {
          //Greater than equal
          $gte: req.query.filters[key][0],
          //Less than equal
          $lte: req.query.filters[key][1],
        };
      } else {
        findArgs[key] = req.query.filters[key];
      }
    }
  }

 

<< ๊ฒ€์ƒ‰๊ธฐ๋Šฅ ๋งŒ๋“ค๊ธฐ >>

searchInput

import React from "react";

const SearchInput = ({ searchTerm, onSearch }) => {
  return (
    <input
      className="p-2 border border-gray-300 rounded-md"
      type="text"
      placeholder="search title"
      onChange={onSearch}
      value={searchTerm}
    />
  );
};

export default SearchInput;

LandingPage

<SearchInput searchTerm={searchTerm} onSearch={handleSearchTerm} />
const handleSearchTerm = (event) => {
    const body = {
      skip: 0,
      limit,
      filters,
      searchTerm: event.target.value,
    };
    setskip(0);
    setsearchTerm(event.target.value);
    fetchProducts(body);
  };

๋ผ์šฐํ„ฐ

if (term) {
    findArgs["$text"] = { $search: term };
  }

๋ชจ๋ธ ์ธ๋ฑ์Šค ์ถ”๊ฐ€

productSchema.index(
  {
    title: "text",
    description: "text",
  },
  {
    weights: {
      title: 5,
      description: 1,
    },
  }
);
  • ๋„ค์ด๋ฒ„ ๋ธ”๋Ÿฌ๊ทธ ๊ณต์œ ํ•˜๊ธฐ
  • ๋„ค์ด๋ฒ„ ๋ฐด๋“œ์— ๊ณต์œ ํ•˜๊ธฐ
  • ํŽ˜์ด์Šค๋ถ ๊ณต์œ ํ•˜๊ธฐ
  • ์นด์นด์˜ค์Šคํ† ๋ฆฌ ๊ณต์œ ํ•˜๊ธฐ