장바구니 구현 (Vanilla JS & React)

Vincent·2023년 5월 19일
1

요구사항

1. 비동기 API 요청 모킹하기

src/api/productData.json 파일에 상품 목록 데이터(더미 데이터)가 저장되어 있습니다.
이 데이터를 바로 import 해 사용하는 것이 아니라, fetch API를 통해 비동기로 데이터를 받아와 사용하도록 구현해주세요.

2. 상품 목록 렌더링하기

main 브랜치의 보일러플레이트 코드에는 상품 목록 마크업이 하드코딩 되어 있습니다. (src.index.html)
하드코딩 되어 있는 마크업 코드를 참고해, 1에서 가져오도록 구현한 더미 데이터를 바탕으로 상품 목록을 렌더링하도록 html 코드를 수정해주세요.
데이터가 없을 때는 "상품이 없습니다" 라는 텍스트를 보여주어야 합니다.
모든 가격은 천 단위의 화폐 표기법에 맞게, 천 단위마다 쉼표를 사용해 렌더링해야 합니다.

3. 장바구니 토글 기능

상단 우측의 장바구니 아이콘을 누르면 장바구니가 열려야 합니다.
상품 카드를 클릭하면 장바구니가 열려야 합니다.
장바구니가 열린 상태에서, backdrop(검은 배경)을 누르면 장바구니가 닫혀야 합니다.
장바구니가 열린 상태에서, 장바구니 상단 우측의 X 아이콘을 누르면 장바구니가 닫혀야 합니다.

4. 장바구니 렌더링하기

main 브랜치의 보일러플레이트 코드에는 장바구니 마크업이 하드코딩 되어 있습니다. (src.index.html)
처음 접속할 경우엔 장바구니가 비어 있어야 하고, 사용자 상호작용에 따라 장바구니 상태를 관리해야 합니다.
하드코딩 되어 있는 마크업 코드를 참고해, 장바구니 데이터를 바탕으로 장바구니 목록을 렌더링하도록 html 코드를 수정해주세요.

5. 장바구니 추가 기능

상품 카드를 클릭하면 해당 상품이 장바구니에 추가되어야 합니다.
장바구니에 보여지는 상품 정보는 아래와 같습니다. (이름, 가격, 수량)

6. 장바구니 총 가격 합산 기능

장바구니 하단에 현재 장바구니에 담겨있는 상품 가격들의 총 합을 표시해주어야 합니다.

7. 장바구니 상품 삭제 기능

장바구니 목록에서 삭제하기 글씨를 클릭하면 해당 상품이 삭제됩니다.
변경된 목록에 맞게 장바구니 목록의 총 합계 금액도 변경되어야 합니다.

8. 장바구니 상품 중복 추가 방지 기능

상품 목록에서 이미 장바구니에 담겨있는 상품을 클릭했을 때는, 장바구니 내 해당 상품의 수량이 증가해야 합니다.

9. 장바구니 상품 수량 수정 기능

장바구니 목록에서 - 버튼을 클릭하면 해당 상품의 수량이 1 감소하고, + 버튼을 누르면 해당 상품의 수량이 1 증가합니다.
변경된 수량에 맞게 장바구니 목록의 합계 금액도 변경되어야 합니다.
최소 수량은 1개, 최대 수량은 10개입니다.
최소 수량에 도달한 경우 - 버튼을 눌러도 수량이 감소하지 않고, "장바구니에 담을 수 있는 최소 수량은 1개입니다." 라는 alert 창을 보여줍니다.
최대 수량에 도달한 경우 + 버튼을 눌러도 수량이 증가하지 않고, "장바구니에 담을 수 있는 최대 수량은 10개입니다." 라는 alert 창을 보여줍니다.

10. Web Storage API를 사용한 장바구니 데이터 저장 기능

장바구니의 결제하기 버튼을 누르면 장바구니 데이터를 브라우저에 저장합니다.
브라우저에 장바구니 데이터가 저장되어 있는 경우, 장바구니 렌더링 시 해당 데이터를 보여줍니다. (새로고침 해도 장바구니 유지)

Vanilla JS

이번 프로젝트에서는 선언적 프로그래밍 방식을 활용한다.
setState : 상태 갱신하여 DOM 표시
render : DOM 조작 함수

productList.js

export default class ProductList {
  constructor($target, initialData) {
    this.$target = $target;
    this.state = initialData;
    this.render();
  }

  setState(newState) {
    this.state = newState;
    this.render();
  }

  render() {
    if (this.state.length === 0) {
      this.$target.innerHTML = '<h1>상품이 없습니다.</h1>';
    } else {
      this.$target.innerHTML = this.state
        .map((item) => {
          return `<article id="product-card">
      <div class="rounded-lg overflow-hidden border-2 relative">
        <img
          src=${item.imgSrc}
          class="object-center object-cover"
        />
        <div
          class="hover:bg-sky-500 w-full h-full absolute top-0 left-0 opacity-90 transition-colors ease-linear duration-75"
        >
          <div
            data-productid="${item.id}"
            class="hover:opacity-100 opacity-0 w-full h-full flex justify-center items-center text-xl text-white font-bold cursor-pointer"
          >
            장바구니에 담기
          </div>
        </div>
      </div>
      <h3 class="mt-4 text-gray-700">${item.name}</h3>
      <p class="mt-1 text-lg font-semibold text-gray-900">${item.price.toLocaleString()}</p>
            </article>`;
        })
        .join('');
    }
  }
}

1. 비동기 API 요청 모킹하기 & 2. 상품 목록 렌더링 하기

getProductData.js

//비동기 호출 일반화
const request = async (url) => {
  try {
    const response = await fetch(url);
    if (response.ok) {
      const data = await response.json();
      return data;
    }
    const errData = await response.json();
    throw errData;
  } catch (e) {
    //에러 핸들링
    console.log(e);
  }
};
const getProductData = async () => {
  const result = await request('./api/productData.json');
  return result;
};

export default getProductData;

index.js

import ProductList from './component/productList.js';

const productList = new ProductList($productListGrid, []); //객체 생성

const fetchProductData = async () => {
  const result = await getProductData();
  productList.setState(result);
};

fetchProductData();

3. 장바구니 토글 기능

index.js

const $productListGrid = document.getElementById('product-card-grid');
const $openCartBtn = document.getElementById('open-cart-btn');
const $closeCartBtn = document.getElementById('close-cart-btn');
const $shoppingCart = document.getElementById('shopping-cart');
const $backDrop = document.getElementById('backdrop');

const toggleCart = () => {
  $shoppingCart.classList.toggle('translate-x-full');
  $shoppingCart.classList.toggle('translate-x-0');
  $backDrop.hidden = !$backDrop.hidden;
};

$openCartBtn.addEventListener('click', toggleCart);
$closeCartBtn.addEventListener('click', toggleCart);
$backDrop.addEventListener('click', toggleCart);
$productListGrid.addEventListener('click', toggleCart);

4. 장바구니 렌더링하기

const onKeyDown = (e) => {
    switch (e.code) {
        case 'KeyL':
            onClickLapResetBtn();
            break;
        case 'KeyS':
            onClickStartStopBtn();
            break;
    }
};
document.addEventListener('keydown', onKeyDown);

5. 장바구니 추가 기능

카드가 많을 수록 카드마다 이벤트를 달아줄 수 없으므로 부모(productListGrid)에게 이벤트를 달아준다. (이벤트 위임)

앞서 만든 ProductList.js 처럼 CartList.js를 만들어서 선언적 프로그래밍한다.
ul안에 li를 넣어서 cartlist div에 넣는 방식으로 만든다.
기존 productData.json 객체에는 count(상품 수량)이 없으므로 장바구니에 담을 때 객체에 count를 추가 해준다.

CartList.js

export default class CartList {
  constructor($target, initialData) {
    this.$target = $target;
    this.$container = document.createElement('ul');
    this.$container.className = 'divide-y divide-gray-200';
    this.state = initialData;
    this.$target.append(this.$container);
    this.render();
  }

  setState(newState) {
    this.state = newState;
    this.render();
  }

  addCartItem(productData) {
    const newState = [...this.state, { ...productData, count: 1 }];
    this.setState(newState);
  }

  render() {
    this.$container.innerHTML = this.state
      .map((item) => {
        return `<li class="flex py-6" id="4">
          <div
            class="h-24 w-24 overflow-hidden rounded-md border border-gray-200"
          >
            <img
              src=${item.imgSrc}
              class="h-full w-full object-cover object-center"
            />
          </div>
          <div class="ml-4 flex flex-1 flex-col">
            <div>
              <div
                class="flex justify-between text-base font-medium text-gray-900"
              >
                <h3>${item.name}</h3>
                <p class="ml-4">${item.price.toLocaleString()}</p>
              </div>
            </div>
            <div class="flex flex-1 items-end justify-between">
              <div class="flex text-gray-500">
                <button class="decrease-btn">-</button>
                <div class="mx-2 font-bold">${item.count}</div>
                <button class="increase-btn">+</button>
              </div>
              <button
                type="button"
                class="font-medium text-sky-400 hover:text-sky-500"
              >
                <p class="remove-btn">삭제하기</p>
              </button>
            </div>
          </div>
        </li>`;
      })
      .join('');
  }
}

index.js

const $cartList = document.getElementById('cart-list');

const cartList = new CartList($cartList, []);

let productData = [];

const fetchProductData = async () => {
  const result = await getProductData();
  productList.setState(result);
  productData = result;
};

fetchProductData();

const addCartItem = (e) => {
  const clickedProduct = productData.find(
    (product) => product.id == e.target.dataset.productid
  );
  if (!clickedProduct) return;
  cartList.addCartItem(clickedProduct);
  toggleCart();
};

$productListGrid.addEventListener('click', addCartItem);

6. 장바구니 총 가격 합산 기능

reduce 활용
첫번쨰 인자는 콜백함수, 두번쨰 인자는 초기값
arr.reduce((acc, cur) => acc + cur, initialvalue)

cartList.js

constructor($target, initialdata){
...
this.$totalcount = document.getElementById('total-count');
}
...
render() {
  this.$totalcount.innerHTML =
      this.state
        .reduce((acc, cur) => acc + cur.price * cur.count, 0)
        .toLocaleString() + '원';
...

7. 장바구니 상품 삭제 기능 & 8. 장바구니 상품 중복 추가 방지 기능 & 9. 장바구니 상품 수량 수정 기능

CartList.js

...
increaseCartItem(id) {
    const newState = [...this.state];
    const checkedIndex = this.state.findIndex((item) => item.id === id);
    if (newState[checkedIndex].count < MAX_COUNT) {
      newState[checkedIndex].count += 1;
    } else {
      alert('장바구니에 담을 수 있는 최대 수량은 10개 입니다.');
    }
    this.setState(newState);
  }
  decreaseCartItem(id) {
    const newState = [...this.state];
    const checkedIndex = this.state.findIndex((item) => item.id === id);
    if (newState[checkedIndex].count > MIN_COUNT) {
      newState[checkedIndex].count -= 1;
    } else {
      alert('장바구니에 담을 수 있는 최소 수량은 1개 입니다.');
    }
    this.setState(newState);
  }

  removeCartItem(id) {
    const newState = this.state.filter((item) => item.id != id);
    this.setState(newState);
  }

...

index.js

...
const modifyCartItem = (e) => {
  const currentProductId = parseInt(e.target.closest('li').id);
  switch (e.target.className) {
    case 'increase-btn':
      cartList.increaseCartItem(currentProductId);
      break;
    case 'decrease-btn':
      cartList.decreaseCartItem(currentProductId);
      break;
    case 'remove-btn':
   	  cartList.removeCartItem(currentProductId);
      break;
    default:
      return;
  }
};
...
$cartList.addEventListener('click', modifyCartItem);

10. Web Storage API를 사용한 장바구니 데이터 저장 기능

cartList.js

 saveToLocalStorage() {
    localStorage.setItem('cartState', JSON.stringify(this.state));
  }

index.js

...
const initialCartState = localStorage.getItem('cartState')
  ? JSON.parse(localStorage.getItem('cartState'))
  : [];
const cartList = new CartList($cartList, initialCartState);
...
const saveToLocalStorage = () => {
  //장바구니 데이터를 localStorage에 저장
  cartList.saveToLocalStorage();
};

...
$paymentBtn.addEventListener('click', saveToLocalStorage);

React

0. 컴포넌트화

전체 구조

App.js

import { useState, useEffect } from 'react';
import './App.css';
import CartList from './components/CartList';
import ProductList from './components/ProductList';
import BackDrop from './components/BackDrop';
import getProductData from './api/getProductData';

function App() {
    const localCartState = localStorage.getItem('cartState');
    const initialCartItems = localCartState ? JSON.parse(localCartState) : [];
    const [productItems, setProductItems] = useState([]);
    const [cartItems, setCartItems] = useState(initialCartItems);
    const [isCartOpen, setIsCartOpen] = useState(false);

    const saveToLocalStorage = () => {
        localStorage.setItem('cartState', JSON.stringify(cartItems));
    };

    const totalCount = cartItems
        .reduce((acc, cur) => acc + cur.price * cur.count, 0)
        .toLocaleString();
    const toggleCart = () => {
        setIsCartOpen((prev) => !prev);
    };

    useEffect(() => {
        const fetchProductData = async () => {
            const result = await getProductData();
            setProductItems(result);
        };
        fetchProductData();
    }, []);

    return (
        <div className="relative min-h-screen">
            <div className="max-w-7xl mx-auto px-6 py-12">
                <header className="flex justify-between mb-4">
                    <h2 className="text-3xl font-bold">오늘의 상품</h2>
                    <button
                        id="open-cart-btn"
                        className="fill-gray-400 hover:fill-gray-500"
                        onClick={toggleCart}
                    >
                        <svg
                            xmlns="http://www.w3.org/2000/svg"
                            width="36"
                            height="36"
                            viewBox="0 0 24 24"
                        >
                            <path d="M10 19.5c0 .829-.672 1.5-1.5 1.5s-1.5-.671-1.5-1.5c0-.828.672-1.5 1.5-1.5s1.5.672 1.5 1.5zm3.5-1.5c-.828 0-1.5.671-1.5 1.5s.672 1.5 1.5 1.5 1.5-.671 1.5-1.5c0-.828-.672-1.5-1.5-1.5zm6.304-15l-3.431 12h-2.102l2.542-9h-16.813l4.615 11h13.239l3.474-12h1.929l.743-2h-4.196z" />
                        </svg>
                    </button>
                </header>
                <section id="product-list">
                    <div
                        id="product-card-grid"
                        className="grid gap-4 auto-cols-fr grid-cols-2 md:grid-cols-4"
                    >
                        {productItems.length === 0 ? (
                            <h1>상품이 없습니다.</h1>
                        ) : (
                            <ProductList
                                productItems={productItems}
                                toggleCart={toggleCart}
                                cartItems={cartItems}
                                setCartItems={setCartItems}
                            />
                        )}
                    </div>
                </section>
            </div>
            {isCartOpen && <BackDrop onClickHandler={toggleCart} />}

            <aside className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
                {/* 장바구니의 가시성은 아래 div의 (id="shopping-cart") class명으로 제어합니다. 
          translate-x-full: 장바구니 닫힘 translate-x-0: 장바구니 열림 */}
                <section
                    className={`pointer-events-auto w-screen max-w-md transition ease-in-out duration-500 translate-x-${
                        isCartOpen ? 0 : 'full'
                    }`}
                    id="shopping-cart"
                >
                    <div className="flex h-full flex-col overflow-y-scroll bg-white shadow-xl">
                        <div className="flex-1 overflow-y-auto p-6">
                            <div className="flex items-start justify-between">
                                <h2 className="text-xl font-bold">장바구니</h2>
                                <div className="ml-3 flex h-7 items-center">
                                    <button
                                        type="button"
                                        className="-m-2 p-2 text-gray-400 hover:text-gray-500"
                                        onClick={toggleCart}
                                    >
                                        <svg
                                            id="close-cart-btn"
                                            className="h-6 w-6"
                                            xmlns="http://www.w3.org/2000/svg"
                                            viewBox="0 0 24 24"
                                            stroke="currentColor"
                                        >
                                            <path
                                                strokeWidth="2"
                                                d="M6 18L18 6M6 6l12 12"
                                            ></path>
                                        </svg>
                                    </button>
                                </div>
                            </div>
                            {/* 아래 하드코딩 되어있는 장바구니 목록들을 유저 상호작용에 맞게 렌더링 되도록 변경해주세요.  */}
                            <div id="cart-list">
                                <CartList
                                    cartItems={cartItems}
                                    setCartItems={setCartItems}
                                />
                            </div>
                        </div>
                        <div className="border-t border-gray-200 p-6">
                            <div className="flex justify-between font-medium">
                                <p>결제금액</p>
                                <p className="font-bold" id="total-count">
                                    {totalCount + '원'}
                                </p>
                            </div>
                            <a
                                id="payment-btn"
                                href="./"
                                className="flex items-center justify-center rounded-md border border-transparent bg-sky-400 px-6 py-3 mt-6 font-medium text-white shadow-sm hover:bg-sky-500"
                                onClick={saveToLocalStorage}
                            >
                                결제하기
                            </a>
                            <div className="mt-6 flex justify-center text-center text-sm text-gray-500">
                                <p>
                                    또는
                                    <button
                                        type="button"
                                        className="font-medium text-sky-400 hover:text-sky-500"
                                    >
                                        쇼핑 계속하기
                                    </button>
                                </p>
                            </div>
                        </div>
                    </div>
                </section>
            </aside>
        </div>
    );
}

export default App;

getProductData.js

//비동기 호출 일반화
const request = async (url) => {
    try {
        const response = await fetch(url);
        if (response.ok) {
            const data = await response.json();
            return data;
        }
        const errData = await response.json();
        throw errData;
    } catch (e) {
        //에러 핸들링
        console.log(e);
    }
};
const getProductData = async () => {
    const result = await request('/productData.json');
    return result;
};

export default getProductData;

BackDrop.js

const BackDrop = ({ onClickHandler }) => {
    return (
        <div
            id="backdrop"
            onClick={onClickHandler}
            className="absolute inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
        ></div>
    );
};

export default BackDrop;

CartList.js

const MAX_COUNT = 10;
const MIN_COUNT = 1;

const CartList = ({ cartItems, setCartItems }) => {
    const increaseCartItem = (idx) => {
        const newCartItems = [...cartItems];
        if (newCartItems[idx].count < MAX_COUNT) {
            newCartItems[idx].count += 1;
            setCartItems(newCartItems);
        } else {
            alert('장바구니에 담을 수 있는 최대 수량은 10개입니다.');
        }
    };
    const decreaseCartItem = (idx) => {
        const newCartItems = [...cartItems];
        if (newCartItems[idx].count > MIN_COUNT) {
            newCartItems[idx].count -= 1;
            setCartItems(newCartItems);
        } else {
            alert('장바구니에 담을 수 있는 최소 수량은 1개입니다.');
        }
    };

    const removeCartItem = (idx) => {
        //삭제할 아이템을 찾아서 state 업데이트
        const newCartItems = [...cartItems];
        newCartItems.splice(idx, 1);
        setCartItems(newCartItems);
    };
    return (
        <ul className="divide-y divide-gray-200">
            {cartItems.map(({ id, name, imgSrc, price, count }, idx) => (
                <li className="flex py-6" id={id} key={id}>
                    <div className="h-24 w-24 overflow-hidden rounded-md border border-gray-200">
                        <img
                            src={imgSrc}
                            className="h-full w-full object-cover object-center"
                            alt={name}
                        />
                    </div>
                    <div className="ml-4 flex flex-1 flex-col">
                        <div>
                            <div className="flex justify-between text-base font-medium text-gray-900">
                                <h3>{name}</h3>
                                <p className="ml-4">
                                    {(price * count).toLocaleString()}</p>
                            </div>
                        </div>
                        <div className="flex flex-1 items-end justify-between">
                            <div className="flex text-gray-500">
                                <button
                                    className="decrease-btn"
                                    onClick={() => decreaseCartItem(idx)}
                                >
                                    -
                                </button>
                                <div className="mx-2 font-bold">{count}</div>
                                <button
                                    className="increase-btn"
                                    onClick={() => increaseCartItem(idx)}
                                >
                                    +
                                </button>
                            </div>
                            <button
                                type="button"
                                className="font-medium text-sky-400 hover:text-sky-500"
                            >
                                <p
                                    onClick={() => removeCartItem(idx)}
                                    className="remove-btn"
                                >
                                    삭제하기
                                </p>
                            </button>
                        </div>
                    </div>
                </li>
            ))}
        </ul>
    );
};

export default CartList;

ProductList.js

const ProductList = ({ productItems, toggleCart, cartItems, setCartItems }) => {
    const handleAddProduct = (idx) => {
        const currentProduct = productItems[idx];
        const checkedIdx = cartItems.findIndex(
            (item) => item.id === currentProduct.id
        );
        //checkedIdx === -1 : 처음 담는 아이템
        //checkedIdx !== -1 : 중복 아이템
        if (checkedIdx === -1) {
            const newCartItems = [
                ...cartItems,
                { ...currentProduct, count: 1 },
            ];
            setCartItems(newCartItems);
        } else {
            const newCartItems = [...cartItems];
            newCartItems[checkedIdx].count += 1;
            setCartItems(newCartItems);
        }
        //장바구니에 아이템 추가
        toggleCart();
    };
    return productItems.map(({ id, name, imgSrc, price }, idx) => (
        <article onClick={() => handleAddProduct(idx)} key={id}>
            <div className="rounded-lg overflow-hidden border-2 relative">
                <img
                    src={imgSrc}
                    className="object-center object-cover"
                    alt={name}
                />
                <div className="hover:bg-sky-500 w-full h-full absolute top-0 left-0 opacity-90 transition-colors ease-linear duration-75">
                    <div
                        data-productid={id}
                        className="hover:opacity-100 opacity-0 w-full h-full flex justify-center items-center text-xl text-white font-bold cursor-pointer"
                    >
                        장바구니에 담기
                    </div>
                </div>
            </div>
            <h3 className="mt-4 text-gray-700">{name}</h3>
            <p className="mt-1 text-lg font-semibold text-gray-900">
                {price.toLocaleString()}</p>
        </article>
    ));
};

export default ProductList;
profile
Frontend & Artificial Intelligence

0개의 댓글