src/api/productData.json 파일에 상품 목록 데이터(더미 데이터)가 저장되어 있습니다.
이 데이터를 바로 import 해 사용하는 것이 아니라, fetch API를 통해 비동기로 데이터를 받아와 사용하도록 구현해주세요.
main 브랜치의 보일러플레이트 코드에는 상품 목록 마크업이 하드코딩 되어 있습니다. (src.index.html)
하드코딩 되어 있는 마크업 코드를 참고해, 1에서 가져오도록 구현한 더미 데이터를 바탕으로 상품 목록을 렌더링하도록 html 코드를 수정해주세요.
데이터가 없을 때는 "상품이 없습니다" 라는 텍스트를 보여주어야 합니다.
모든 가격은 천 단위의 화폐 표기법에 맞게, 천 단위마다 쉼표를 사용해 렌더링해야 합니다.
상단 우측의 장바구니 아이콘을 누르면 장바구니가 열려야 합니다.
상품 카드를 클릭하면 장바구니가 열려야 합니다.
장바구니가 열린 상태에서, backdrop(검은 배경)을 누르면 장바구니가 닫혀야 합니다.
장바구니가 열린 상태에서, 장바구니 상단 우측의 X 아이콘을 누르면 장바구니가 닫혀야 합니다.
main 브랜치의 보일러플레이트 코드에는 장바구니 마크업이 하드코딩 되어 있습니다. (src.index.html)
처음 접속할 경우엔 장바구니가 비어 있어야 하고, 사용자 상호작용에 따라 장바구니 상태를 관리해야 합니다.
하드코딩 되어 있는 마크업 코드를 참고해, 장바구니 데이터를 바탕으로 장바구니 목록을 렌더링하도록 html 코드를 수정해주세요.
상품 카드를 클릭하면 해당 상품이 장바구니에 추가되어야 합니다.
장바구니에 보여지는 상품 정보는 아래와 같습니다. (이름, 가격, 수량)
장바구니 하단에 현재 장바구니에 담겨있는 상품 가격들의 총 합을 표시해주어야 합니다.
장바구니 목록에서 삭제하기 글씨를 클릭하면 해당 상품이 삭제됩니다.
변경된 목록에 맞게 장바구니 목록의 총 합계 금액도 변경되어야 합니다.
상품 목록에서 이미 장바구니에 담겨있는 상품을 클릭했을 때는, 장바구니 내 해당 상품의 수량이 증가해야 합니다.
장바구니 목록에서 - 버튼을 클릭하면 해당 상품의 수량이 1 감소하고, + 버튼을 누르면 해당 상품의 수량이 1 증가합니다.
변경된 수량에 맞게 장바구니 목록의 합계 금액도 변경되어야 합니다.
최소 수량은 1개, 최대 수량은 10개입니다.
최소 수량에 도달한 경우 - 버튼을 눌러도 수량이 감소하지 않고, "장바구니에 담을 수 있는 최소 수량은 1개입니다." 라는 alert 창을 보여줍니다.
최대 수량에 도달한 경우 + 버튼을 눌러도 수량이 증가하지 않고, "장바구니에 담을 수 있는 최대 수량은 10개입니다." 라는 alert 창을 보여줍니다.
장바구니의 결제하기 버튼을 누르면 장바구니 데이터를 브라우저에 저장합니다.
브라우저에 장바구니 데이터가 저장되어 있는 경우, 장바구니 렌더링 시 해당 데이터를 보여줍니다. (새로고침 해도 장바구니 유지)
이번 프로젝트에서는 선언적 프로그래밍 방식을 활용한다.
setState : 상태 갱신하여 DOM 표시
render : DOM 조작 함수
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('');
}
}
}
//비동기 호출 일반화
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;
import ProductList from './component/productList.js';
const productList = new ProductList($productListGrid, []); //객체 생성
const fetchProductData = async () => {
const result = await getProductData();
productList.setState(result);
};
fetchProductData();
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);
const onKeyDown = (e) => {
switch (e.code) {
case 'KeyL':
onClickLapResetBtn();
break;
case 'KeyS':
onClickStartStopBtn();
break;
}
};
document.addEventListener('keydown', onKeyDown);
카드가 많을 수록 카드마다 이벤트를 달아줄 수 없으므로 부모(productListGrid)에게 이벤트를 달아준다. (이벤트 위임)
앞서 만든 ProductList.js 처럼 CartList.js를 만들어서 선언적 프로그래밍한다.
ul안에 li를 넣어서 cartlist div에 넣는 방식으로 만든다.
기존 productData.json 객체에는 count(상품 수량)이 없으므로 장바구니에 담을 때 객체에 count를 추가 해준다.
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('');
}
}
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);
reduce 활용
첫번쨰 인자는 콜백함수, 두번쨰 인자는 초기값
arr.reduce((acc, cur) => acc + cur, initialvalue)
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() + '원';
...
...
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);
}
...
...
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);
saveToLocalStorage() {
localStorage.setItem('cartState', JSON.stringify(this.state));
}
...
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);
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;
//비동기 호출 일반화
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;
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;
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;
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;