230525 ~ 230602 - React (meal site)

백승연·2023년 6월 2일
0

일마다 나눠쓰기 힘들어서 한 번에 작성

🚩 React

음식 주문 사이트 코딩

📝 설명

  • 장바구니에 음식을 한 개씩 추가하고 제거할 수 있게 코딩
  • 장바구니에 음식을 추가/제거 할 때마다 금액 계산
  • css module을 사용하여 한 컴포넌트에 모듈 한 개씩 생성하여 작업


✒️ 코드 작성

  • 컴포넌트마다 css module 따로 있기 때문에 css는 제외하고 컴포넌트만 작성

입력

App.js

import { useState } from 'react';
import Cart from './components/Cart/Cart';
import Header from './components/Layout/Header';
import Meals from './components/Meals/Meals';
import CartProvider from './store/CartProvider';
import './App.css';

function App() {
  const [cartInShow, setCartInShow] = useState(false);
  //cart모달창이 보이고 안보이고, 상태관리

  //cart모달 보이게 하는 함수
  const showCartHandler=()=>{
    setCartInShow(true)
  }
  
  //cart모달 안보이게 하는 함수
  const hideCartHandler=()=>{
    setCartInShow(false)
  }

  return (
    // CartProvider 태그 안에서 cart-context의 내용을 전부 갱신/삭제/추가 할 수 있음
    <CartProvider>
      { cartInShow && <Cart onClose ={hideCartHandler} />}
      {/* cartInShow가 true일때만 모달이 보임 */}
      <Header onShowCart={showCartHandler}/>
      <main>
        <Meals />
      </main>
    </CartProvider>
  );
}

export default App;



index.html

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />

    <title>종합예제</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <!-- portal 지정 -->
    <div id="overlay"></div>
    <div id="root"></div>
  </body>
</html>



  • components 폴더 내 Layout 폴더
    Header.jsx
import React from 'react'
import classes from "./Header.module.css";
import mealsImg from "../../asset/meals.jpg";
import HeaderCartButton from './HeaderCartButton';
//public에 놓고 일반 웹페이지에서 이미지 쓰듯이 사용할 수도 있음

const Header = (props) => {
  return (
    <>
      <header className={classes.header}>
        <h1>First React Meals</h1>
        {/* onclick : props 이름 */}
        <HeaderCartButton onclick={props.onShowCart} />
      </header> 
      <div className={classes["main-image"]}>
        {/* main-image 클라스는 안에 -가 있어서 .을 못 씀 */}
        <img src={mealsImg} alt="meals" />
      </div>


    </>
  )
}

export default Header;



HeaderCartButton.jsx

import React, { useContext, useEffect, useState } from "react";
import CartIcon from "../Cart/CartIcon";
import classes from "./HeaderCartButton.module.css";
import CartContext from "../../store/cart-context";

// useEffect() : 어떤 일이 발생할 때마다 실행되는 React hook

const HeaderCartButton = (props) => {
  const cartCtx = useContext(CartContext);
  // const numberOfCartItems = cartCtx.items.length; // = 아이템 숫자를 의미 -> 아이템 안의 amount를 합해줘야 함
  const [btnIsHigh, setBtnIsHigh] = useState(false); // 버튼 상태(애니메이션 적용 여부)
  const { items } = cartCtx; // 구조분해

  // 컨텍스트에 배열이 바뀔 때 적용
  useEffect(() => {
    // 항목이 없을 때
    if (items.length === 0) {
      return;
    }
    setBtnIsHigh(true);

    const timer = setTimeout(() => {
      setBtnIsHigh(false);
    }, 300);

    // 사이드이펙트 정리, 클린업 함수 (눈에 보이지 않지만 코드적으로 데이터를 깔끔하게 정리함.)
    return () => {
      clearTimeout(timer);
    };
  }, [items]);

  // 아이템에 변화가 있을 때만 bump 클래스 들어감
  const btnClass = `${classes.button} ${btnIsHigh ? classes.bump : ""}`;

  // 위에서 구조분해 했기 때문에 cartCtx.items라고 안 써도 됨
  const numberOfCartItems = items.reduce((sum, item) => {
    return (sum += item.amount);
  }, 0);
  // 배열.reduce((합해진 값, value) => { 합해진값 + 밸류 }, 합해진 값의 초기값);

  return (
    <button className={btnClass} onClick={props.onclick}>
      <span className={classes.icon}>
        <CartIcon />
      </span>
      <span>Your Cart</span>
      <span className={classes.badge}>{numberOfCartItems}</span>
    </button>
  );
};

export default HeaderCartButton;



  • components 폴더 내 UI 폴더
    Card.jsx
import React from 'react'
import classes from "./Card.module.css";

const Card = (props) => {
  return (
    <div className={classes.card}>{props.children}</div>
  )
}

export default Card



input.jsx

import React from "react";
import classes from "./Input.module.css";

const Input = props => {
  return (
    <div className={classes.input}>
      <label htmlFor={props.input.id}>{props.label}</label>
      <input ref={props.propsRef} {...props.input} />
    </div>
  );
};

export default Input;

/* 
  forwardRef()
    - 전달받은 ref 어트리뷰트를 하부 트리 내의 다른 컴포넌트로 전달할 때 함수 부분을 감싸서 사용
*/



Modal.jsx

//모달 팝업창
import React from 'react'
import ReactDOM from 'react-dom';
import classes from './Modal.module.css'

//모달뒤 까만 반투명
const Backdrop=(props)=>{
  return <div className={classes.backdrop} onClick={props.onClose} > </div>
}

//실제 모달(가운데 하얀네모)
const ModalOverlay=(props)=>{
  return (
    <div className={classes.modal}>
      <div>{props.children}</div>
    </div>
  )
};


// portal을 출력할 위치를 가져옴
const portalElement = document.getElementById('overlay');

//메인 컴포넌트
const Modal = (props) => {
  return (
    <div>
      {/* {ReactDOM.createPortal(<Backdrop onClose={props.onClose} />,portalElement)}
      {ReactDOM.createPortal(<ModalOverlay>{props.children}</ModalOverlay>, portalElement )}       */}
      {ReactDOM.createPortal(<Backdrop onClose={props.onClose} />, portalElement)}
      {ReactDOM.createPortal(<ModalOverlay>{props.children}</ModalOverlay>, portalElement)}
    </div>
  )
}

export default Modal;
//  createPortal(child(자식요소), container(포탈이름))



  • components 폴더 내 Meals 폴더
    Meals.jsx
import React from 'react'
import AvailableMeals from './AvailableMeals'
import MealsSummary from './MealsSummary'

const Meals = () => {
  return (
    <>
      <MealsSummary />
      <AvailableMeals />
    </>
  )
}

export default Meals



MealsSummary.jsx

import React from "react";
import classes from "./MealsSummary.module.css";

const MealsSummary = () => {
  return (
    <section className={classes.summary}>
      {/* <h2>MealsSummary.js --</h2> */}
      <p>
        Choose your favorite meal from our broad selection of available meals
        and enjoy a delicious lunch or dinner at home.
        </p>
      
      <p>
        All our meals are cooked with high-quality ingredients, just-in-time and of course by experienced
        chefs!
      </p>
    </section>
  );
};

export default MealsSummary;



AvailableMeals.jsx

import React from "react";
import Card from "../UI/Card";
import classes from "./AvailableMeals.module.css";
import MealItem from "./MealsItem/MealItem";

const DUMMY_MEALS = [
  {
    id: "m1",
    name: "Sushi",
    description: "Finest fish and veggies",
    price: 22.9998,
  },
  {
    id: "m2",
    name: "Schnitzel",
    description: "A german specialty!",
    price: 16.5,
  },
  {
    id: "m3",
    name: "Barbecue Burger",
    description: "American, raw, meaty",
    price: 12.991,
  },
  {
    id: "m4",
    name: "Green Bowl",
    description: "Healthy...and green...",
    price: 18.993,
  },
];

const AvailableMeals = () => {
  const mealsList = DUMMY_MEALS.map((meal)=>
    <MealItem key={meal.id} id={meal.id} name={meal.name} description={meal.description} price={meal.price}/>
  )

  return (
    <section className={classes.meals}>
      <Card>
        <ul>
          {mealsList}
        </ul>
      </Card>
    </section>
  )
}

export default AvailableMeals;



  • Meals 폴더 내 MealsItem 폴더
    MealItem.jsx
import React, { useContext } from "react";
import classes from "./MealItem.module.css";
import MealItemForm from "./MealItemForm";
import CartContext from "../../../store/cart-context";

const MealItem = (props) => {
  const cartCtx = useContext(CartContext);
  const price = `$${props.price.toFixed(2)}`; 
  // toFixed(n) : 소수점 n번째 자리까지만 출력(반올림). 첫 번째 자리까지만 있으면 두 번째 자리는 0으로 출력

  // 함수 정의
  // context에 전달하는 함수 (amount : 수량)
  // 인자값은 MealItemForm에서 value를 받아옴
  const addToCartHandler = (amount) => {
    console.log("수량은? : ", amount);
    
    // 여기서 addItem을 가져와서 작동시킴
    cartCtx.addItem({
      id: props.id,
      name: props.name,
      amount: amount, // amount로 받아와서
      price: props.price,
    });
  };

  return (
    <li className={classes.meal}>
      <div>
        <h3>{props.name}</h3>
        <div className={classes.description}>{props.description}</div>
        <div className={classes.price}>{price}</div>
      </div>
      <div>
        {/* mealitem에서 mealitemform으로 보냄 (props) */}
        <MealItemForm id={props.id} onAddToCart={addToCartHandler} />
      </div>
    </li>
  );
};

export default MealItem;



MealItemForm.jsx

import React, { useRef } from "react";
import Input from "../../UI/Input";
import classes from "./MealItemForm.module.css";

const MealItemForm = (props) => {
  // ref를 통해서 입력된 값을 받아옴(특정 DOM을 선택할 때 사용)
  const amountInputRef = useRef();

  const submitHandler = (event) => {
    event.preventDefault();
    const enteredAmount = amountInputRef.current.value; // string
    // const enteredAmountNumber = Number(enteredAmount); // number
    const enteredAmountNumber = +enteredAmount; // number (숫자열로 변경)

    // console.log(typeof enteredAmountNumber);
    // 수량을 onAddToCart의 인자값으로 받아 Mealitem에 넘겨줌
    props.onAddToCart(enteredAmountNumber);
    
    /*
    // 유효성 검사
    // 공백이 있는가
    if (enteredAmount.trim().length === 0 || enteredAmount < 1 || enteredAmount > 5) {
      return;
    }
    */
  };

  return (
    <form className={classes.form} onSubmit={submitHandler}>
      <Input
        label="Amount"
        propsRef={amountInputRef} // props
        input={{
          id: "amount_" + props.id,
          type: "number",
          min: "1",
          max: "5",
          defaultValue: "1",
          step: "1",
        }}
      />

      {/* <input
        type="number"
        min="1"
        max="5"
        defaultValue="1"
        step="1"
        id={props.id}
      /> */}
      <button>+ Add</button>
    </form>
  );
};

export default MealItemForm;



  • components 폴더 내 Cart 폴더
    Cart.jsx
//모달팝업 안에 들어갈 내용
import React, { useContext } from "react";
import Modal from "../UI/Modal";
import classes from "./Cart.module.css";
import CartContext from "../../store/cart-context";

const Cart = (props) => {
  const cartCtx = useContext(CartContext);
  const hasItems = cartCtx.items.length > 0; // 장바구니에 항목이 있을 경우
  const totalAmount = `$ ${cartCtx.totalAmount.toFixed(2)}`;

  const cartItemRemoveHandler = (id) => {
    cartCtx.removeItem(id);
  };
  const cartItemAddHandler = (item) => {
    cartCtx.addItem({...item, amount: 1});
  };
  
  // 계속 변화하는 값
  const cartItem = (
    <ul className={classes["cart-items"]}>
      {/* li를 map으로 돌림 */}
      {cartCtx.items.map((item) => (
        <li key={item.id}>
          <div>
            <h2>{item.name}</h2>
            <div>
              <span className={classes.price}>{`$${item.price.toFixed(2)}`}</span>
              <span className={classes.amount}>x {item.amount}</span>
            </div>
          </div>
          <div className={classes.btns}>
            <button onClick={() => cartItemRemoveHandler(item.id)}>-</button>
            <button onClick={() => cartItemAddHandler(item)}>+</button>
          </div>
        </li>
      ))}
    </ul>
  );

  return (
    // 모달 안에 모든 내용을 집어넣음
    <Modal onClose={props.onClose}>
      <div>
        {cartItem}
        <div className={classes.total}>
          <span>Total Amount</span>
          <span>{totalAmount}</span>
        </div>
        <div className={classes.action}>
          <button className={classes["button-outline"]} onClick={props.onClose}>
            Close
          </button>
          {/* hasItems가 true일 때만 Order버튼이 보이게 */}
          {hasItems && <button className={classes.button}>Order</button>}
        </div>
      </div>
    </Modal>
  );
};

export default Cart;



CartIcon.jsx

import React from 'react'

const CartIcon = () => {
  return (
    <svg fill="#fff" viewBox="0 0 576 512" width="20px" height="20px" xmlns="http://www.w3.org/2000/svg"><path d="M463.1 416c-26.51 0-47.1 21.49-47.1 48s21.49 48 47.1 48s47.1-21.49 47.1-48S490.5 416 463.1 416zM175.1 416c-26.51 0-47.1 21.49-47.1 48S149.5 512 175.1 512s47.1-21.49 47.1-48S202.5 416 175.1 416zM569.5 44.73c-6.109-8.094-15.42-12.73-25.56-12.73H121.1L119.6 19.51C117.4 8.19 107.5 0 96 0H23.1C10.75 0 0 10.75 0 23.1S10.75 48 23.1 48h52.14l60.28 316.5C138.6 375.8 148.5 384 160 384H488c13.25 0 24-10.75 24-23.1C512 346.7 501.3 336 488 336H179.9L170.7 288h318.4c14.29 0 26.84-9.47 30.77-23.21l54.86-191.1C577.5 63.05 575.6 52.83 569.5 44.73z"/></svg>
  )
}

export default CartIcon




  • store 폴더
    cart-context.js
import React from "react";

// 컨텍스트를 만듦, 컨텍스트 안에 데이타가 있음 (초기화)
const CartContext = React.createContext({
  items: [], // 아이템이 들어있는 배열
  totalAmount: 0, // 총 금액
  addItem: (item) => {},
  removeItem: (id) => {},
});
// 장바구니 항목 초기값

export default CartContext;



cartProvider.jsx

import React, { useReducer } from "react";
import CartContext from "./cart-context";
// CartContext = cart-context.js 안의 4가지 데이터 (초기화)

// reducer 함수 정의
const cartReducer = (state, action) => {
  if (action.type === "ADD") {
    const updatedTotalAmount =
      state.totalAmount + action.item.price * action.item.amount;
    // 기존의 totalAmount + 내가 가져온 item의 가격 * 내가 보낸 아이템의

    // * 검사
    // state.item - 기존 배열 / Array.findIndex() - 제일 먼저 나오는 조건에 맞는 아이템의 인덱스(순서) 반환
    // item의 id와 액션으로 찾아온 item의 id
    const existingCartItemIndex = state.items.findIndex(
      (item) => item.id === action.item.id
    ); // 숫자만 리턴
    const existingCartItem = state.items[existingCartItemIndex]; // item 자체를 리턴
    // 기존에
    // console.log("기존에 동일한 아이템이 있는가? : ", existingCartItem);

    let updatedItems;
    // 추가한 아이템이 기존에 있는 아이템일 경우
    if (existingCartItem) {
      const updatedItem = {
        ...existingCartItem,
        amount: existingCartItem.amount + action.item.amount,
      };
      updatedItems = [...state.items]; // 기존의 객체를 새 배열로
      updatedItems[existingCartItemIndex] = updatedItem; // 값을 더해준 기존 아이템 업데이트
    } else {
      // 추가한 아이템이 기존에 없는 아이템일 경우
      updatedItems = state.items.concat(action.item);
    }

    return {
      items: updatedItems,
      totalAmount: updatedTotalAmount,
    };
  }
  if (action.type === "REMOVE") {
    const existingCartItemIndex = state.items.findIndex(
      (item) => item.id === action.id
    );
    const existingCartItem = state.items[existingCartItemIndex]; //기존아이템이 없을 경우는 undefined

    // console.log("기존에 동일한 아이템이 있는가? : ", existingCartItem);

    const updatedTotalAmount = state.totalAmount - existingCartItem.price;

    let updatedItems;
    // 추가한 아이템이 기존에 있는 아이템일 경우
    if (existingCartItem.amount === 1) {
      // 1인 상태에서 빼주면 완전히 사라져야 함
      updatedItems = state.items.filter((item) => item.id !== action.id);
    } else {
      const updatedItem = {
        ...existingCartItem,
        amount: existingCartItem.amount - 1,
      };
      updatedItems = [...state.items]; // 기존의 객체를 새 배열로
      updatedItems[existingCartItemIndex] = updatedItem; // 값을 더해준 기존 아이템 업데이트
    }
    return {
      items: updatedItems,
      totalAmount: updatedTotalAmount,
    };
  }

  // switch (action.type) {
  //   case "ADD":
  //     return {
  //       items: state.items.concat(action.item), // ?
  //       totalAmount: state.items.totalAmount + action.item.price * action.item.amount, // ?
  //     }
  // }
  // return defaultCartState;
};

// reducer 초기화 정의
const defaultCartState = {
  // 아래 두 개의 상태 관리(addItemToCartHandler로 상태관리)
  items: [], // 아이템이 들어있는 배열
  totalAmount: 0, // 총 금액
};

// 2
const CartProvider = (props) => {
  // useReducer 호출(선언)
  // dispatchCartAction은 cartReducer 함수에 action으로 보내는 역할을 함
  const [cartState, dispatchCartAction] = useReducer(
    cartReducer,
    defaultCartState
  );

  // 2-1
  const addItemToCartHandler = (item) => {
    // dispatchCartAction으로 요소들을 객체 형식으로 보냄 (타입과 현재 아이템)
    // dispatchCartAction은 cartReducer 함수에 action으로 보내는 역할을 함
    dispatchCartAction({ type: "ADD", item: item });
  };
  const removeItemFromCartHandler = (id) => {
    dispatchCartAction({ type: "REMOVE", id: id });
  };

  // 1
  // 업데이트 될 객체 - 다이나믹하게 변하는 부분(게속 변하는 부분)
  // items, totalAmount 두 개를 저장하는 것이 목적임. 나머지는 함수 정의
  const cartContext = {
    items: cartState.items, // 아이템이 들어있는 배열
    totalAmount: cartState.totalAmount, // 총 갯수
    addItem: addItemToCartHandler,
    removeItem: removeItemFromCartHandler,
  };

  return (
    // 1-1
    <CartContext.Provider value={cartContext}>
      {props.children}
    </CartContext.Provider>
  );
};

export default CartProvider;



출력

  • 이미지로 대체









🚩 netlify

netlify

📝 설명

  • 프론트엔드 정적 페이지를 쉽게 호스팅 할 수 있도록 해주는 사이트.
  • npm이나 yarn으로 터미널에서 시작하는 프로젝트들을 쉽게 호스팅하여 웹에서 볼 수 있음

✒️ 사용법

먼저 회원가입을 한 후 github와 연결한다.
(작업중)


🔗 참고 링크 & 도움이 되는 링크






profile
공부하는 벨로그

0개의 댓글