React로 만드는 쇼핑몰 프로젝트

LOOPY·2022년 8월 31일
0
post-thumbnail

👩🏻‍💻 이번 프로젝트의 궁극적 목표는 Context API와 Hooks를 이용한 전역 상태 관리를 익히는 것입니다!

(단순히 비주얼적인 과정들은 세세히 기술하지 않았습니다. 깃헙에서 코드 확인 가능합니다!)

1. 기본 디자인 설정하기

  • Roboto 글꼴 불러오기 (web font 설정하기)
// index.html (title 밑에)

<link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet">
  • 'src/index.css' 작성
  • 각 컴포넌트 작성
  • 'src/App.js' 작성
// 'src/App.js'

import Header from "./components/Header";
import Prototypes from "./components/Prototypes";
import Orders from "./components/Orders";
import Footer from "./components/Footer";

function App() {
  return (
    <>
    <Header />
    <div className="container">
      <Prototypes />
      <Orders />
      <Footer />
    </div>
    </>
  );
}

export default App;

2. 상품 리스트 보여주기

// 'src/components/Prototypes.jsx

const prototypes = [
  {
    id: "pp-01",
    title: "Kids-story",
    artist: "Thomas Buisson",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Kids-story_1.mp4",
    price: 10,
    pieUrl: "https://cloud.protopie.io/p/8a6461ad85",
  },
  {
    id: "pp-02",
    title: "mockyapp",
    artist: "Ahmed Amr",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/mockyapp.mp4",
    price: 20,
    pieUrl: "https://cloud.protopie.io/p/27631ac9d5",
  },
  {
    id: "pp-03",
    title: "macOS Folder Concept",
    artist: "Dominik Kandravý",
    desc: "Folder concept prototype by Dominik Kandravý.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/macOS_Folder_Concept_-_Folder_concept.mp4",
    price: 30,
    pieUrl: "https://cloud.protopie.io/p/acde5ccdf9",
  },
  {
    id: "pp-04",
    title: "Translator",
    artist: "Tony Kim",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Translator.mp4",
    price: 40,
    pieUrl: "https://cloud.protopie.io/p/b91edba11d",
  },
  {
    id: "pp-05",
    title: "In-car voice control",
    artist: "Tony Kim",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/In-car_voice_control.mp4",
    price: 50,
    pieUrl: "https://cloud.protopie.io/p/6ec7e70d1a",
  },
  {
    id: "pp-06",
    title: "The Adventures of Proto",
    artist: "Richard Oldfield",
    desc: `Made exclusively for Protopie Playoff 2021
            Shout up if you get stuck!
            For the full experience. View in the Protopie App.
            #PieDay #PlayOff #ProtoPie`,
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/The_Adventures_of_Proto.mp4",
    price: 60,
    pieUrl: "https://cloud.protopie.io/p/95ee13709f",
  },
  {
    id: "pp-07",
    title: "Sunglasses shop app",
    artist: "Mustafa Alabdullah",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/sunglasses_shop_app.mp4",
    price: 70,
    pieUrl: "https://cloud.protopie.io/p/6f336cac8c",
  },
  {
    id: "pp-08",
    title: "Alwritey—Minimalist Text Editor",
    artist: "Fredo Tan",
    desc: `This minimalist text editor prototype was made with ProtoPie by Fredo Tan.
            ---
            Inspired by Writty, a simple writing app by Carlos Yllobre. Try out Writty at https://writtyapp.com.
            ---
            ProtoPie is an interactive prototyping tool for all digital products.
            ---
            Learn more about ProtoPie at https://protopie.io.`,
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/minimalist-text-editor.mp4",
    price: 80,
    pieUrl: "https://cloud.protopie.io/p/946f88f8d3",
  },
  {
    id: "pp-09",
    title: "Voice search for TV",
    artist: "Tony Kim",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/TV.mp4",
    price: 90,
    pieUrl: "https://cloud.protopie.io/p/60ee64cda0",
  },
  {
    id: "pp-10",
    title: "Finance App Visual Interaction 2.0",
    artist: "Arpit Agrawal",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Credit_Card_App.mp4",
    price: 90,
    pieUrl:
      "https://cloud.protopie.io/p/09ce2fdf84/21?ui=true&mockup=true&touchHint=true&scaleToFit=true&cursorType=touch",
  },
  {
    id: "pp-11",
    title: "Whack-a-mole",
    artist: "Changmo Kang",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Whack_a_mole.mp4",
    price: 90,
    pieUrl: "https://cloud.protopie.io/p/ab796f897e",
  },
  {
    id: "pp-12",
    title: "Voice Note",
    artist: "Haerin Song",
    desc: `Made by Haerin Song
            (Soda Design)`,
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Voice_note_with_sound_wave.mp4",
    price: 90,
    pieUrl: "https://cloud.protopie.io/p/7a0d6567d2",
  },
];

export default function Prototypes() {
  return (
    <main>
      <div className="prototypes">
        {prototypes.map(prototype => {
          const {id, thumbnail, title, price, desc, pieUrl} = prototype;
          return (
            <div className="prototype" key={id}>
              <a href={pieUrl} target="_BLANK">
                <div style={{
                  padding: '25px 0 33px 0',
                  }}
                >
                  <video 
                    autoplay 
                    loop 
                    playsInline 
                    className="prototype__artwork prototype__edit" 
                    src={thumbnail}
                    style={{
                      objectFit: "contain",
                    }}
                  />
                </div>
              </a>
              <div className="prototype__body">
                <div className="prototype__title">
                  <div className="btn btn--primary float--right">
                    <i className="icon icon--plus" /> 
                  </div>  
                  {title}
                </div>
                <p className="prototype__price">$ {price}</p>
                <desc className="prototype__desc">$ {desc}</desc>
              </div>    
            </div>
          );
        })}
      </div>
    </main>
  );
}

먼저 prototypes라는 배열 변수에 아이템을 모두 저장해둔 뒤, map() 함수를 사용해 요소를 하나씩 꺼내와 출력하는 방식으로 작성된 코드이다. styled-components만 주구장창 사용하다가 기본 className 정리하려니 왠지 어지러운 느낌이지만.. 시각적 사항들은 나중에 따로 정리해보도록 하겠다!🤦🏻‍♀️

3. 상품 선택과 삭제를 주문에서 보여주기

1) Context 사용하기

먼저 전역 상태 설정 위해 context를, context에 값 주입 위해 provider를 생성한다.

// 'src/contexts/AppStateContext.jsx'

import React from 'react';

const AppStateContext = React.createContext();

export default AppStateContext;

context는 React.createContext() 함수를 이용해 생성한다.

// 'src/providers/AppStateProvider.jsx'

import AppStateContext from "../contexts/AppStateContext";
import { useState } from "react";

const AppStateProvider = ({children}) => {
  const [prototypes, setPrototypes] = useState([
    {
      id: "pp-01",
      title: "Kids-story",
      artist: "Thomas Buisson",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Kids-story_1.mp4",
      price: 10,
      pieUrl: "https://cloud.protopie.io/p/8a6461ad85",
    },
    {
      id: "pp-02",
      title: "mockyapp",
      artist: "Ahmed Amr",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/mockyapp.mp4",
      price: 20,
      pieUrl: "https://cloud.protopie.io/p/27631ac9d5",
    },
    {
      id: "pp-03",
      title: "macOS Folder Concept",
      artist: "Dominik Kandravý",
      desc: "Folder concept prototype by Dominik Kandravý.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/macOS_Folder_Concept_-_Folder_concept.mp4",
      price: 30,
      pieUrl: "https://cloud.protopie.io/p/acde5ccdf9",
    },
    {
      id: "pp-04",
      title: "Translator",
      artist: "Tony Kim",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Translator.mp4",
      price: 40,
      pieUrl: "https://cloud.protopie.io/p/b91edba11d",
    },
    {
      id: "pp-05",
      title: "In-car voice control",
      artist: "Tony Kim",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/In-car_voice_control.mp4",
      price: 50,
      pieUrl: "https://cloud.protopie.io/p/6ec7e70d1a",
    },
    {
      id: "pp-06",
      title: "The Adventures of Proto",
      artist: "Richard Oldfield",
      desc: `Made exclusively for Protopie Playoff 2021
              Shout up if you get stuck!
              For the full experience. View in the Protopie App.
              #PieDay #PlayOff #ProtoPie`,
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/The_Adventures_of_Proto.mp4",
      price: 60,
      pieUrl: "https://cloud.protopie.io/p/95ee13709f",
    },
    {
      id: "pp-07",
      title: "Sunglasses shop app",
      artist: "Mustafa Alabdullah",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/sunglasses_shop_app.mp4",
      price: 70,
      pieUrl: "https://cloud.protopie.io/p/6f336cac8c",
    },
    {
      id: "pp-08",
      title: "Alwritey—Minimalist Text Editor",
      artist: "Fredo Tan",
      desc: `This minimalist text editor prototype was made with ProtoPie by Fredo Tan.
              ---
              Inspired by Writty, a simple writing app by Carlos Yllobre. Try out Writty at https://writtyapp.com.
              ---
              ProtoPie is an interactive prototyping tool for all digital products.
              ---
              Learn more about ProtoPie at https://protopie.io.`,
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/minimalist-text-editor.mp4",
      price: 80,
      pieUrl: "https://cloud.protopie.io/p/946f88f8d3",
    },
    {
      id: "pp-09",
      title: "Voice search for TV",
      artist: "Tony Kim",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/TV.mp4",
      price: 90,
      pieUrl: "https://cloud.protopie.io/p/60ee64cda0",
    },
    {
      id: "pp-10",
      title: "Finance App Visual Interaction 2.0",
      artist: "Arpit Agrawal",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Credit_Card_App.mp4",
      price: 90,
      pieUrl:
        "https://cloud.protopie.io/p/09ce2fdf84/21?ui=true&mockup=true&touchHint=true&scaleToFit=true&cursorType=touch",
    },
    {
      id: "pp-11",
      title: "Whack-a-mole",
      artist: "Changmo Kang",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Whack_a_mole.mp4",
      price: 90,
      pieUrl: "https://cloud.protopie.io/p/ab796f897e",
    },
    {
      id: "pp-12",
      title: "Voice Note",
      artist: "Haerin Song",
      desc: `Made by Haerin Song
              (Soda Design)`,
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Voice_note_with_sound_wave.mp4",
      price: 90,
      pieUrl: "https://cloud.protopie.io/p/7a0d6567d2",
    },
  ]);
  const [orders, setOrders] = useState([]);

  const addToOrder = useCallback((id) => { },[]);
  const remove = useCallback((id) => { },[]);
  const removeAll = useCallback(() => { },[]);

  // .Provider 주의
  return <AppStateContext.Provider value={{
    prototypes,
    orders,
    addToOrder, // 상품 추가 함수
    remove, // 상품 삭제 함수
    removeAll,
  }}>
    {children}
  </AppStateContext.Provider>
}

export default AppStateProvider;

'src/components/Prototypes.jsx'에 있던 prototypes 배열 전체를 삭제하고 provider로 가져왔다.

Provider가 되는 컴포넌트는 기본 props로 children을 받아와야 한다. 이외 형식은 기본 화살표 함수형 컴포넌트와 다르지 않고, return하는 요소({children})를 <AppStateContext.Provider value={...}>로 감쌌음에 주의하자.

다음으로 위의 Provider에서 제공한 value를 전역으로 공유하기 위해 App 최상위에 Provider 컴포넌트를 감싸준다.

// 'src/App.js'

function App() {
  return (
    <AppStateProvider>
      <Header />
      <div className="container">
        <Prototypes />
        <Orders />
        <Footer />
      </div>
    </AppStateProvider>
  );
}

이제 'src/components/Prototypes.jsx'에서 context를 통해 prototypes 데이터를 사용할 준비가 완료 되었다.

// 'src/components/Prototypes.jsx'

const {prototypes} = useContext(AppStateContext);

Prototypes 함수 가장 첫 줄에 위의 코드를 추가해줬다. useContext() 함수로 Provider에서 제공했던 AppStateContext를 받아오며, 동시에 키 값인 prototypes을 지정해 원하는 배열만 저장해왔다.

이제는 useContext()를 직접 활용하지 않고 따로 Hooks으로 빼보도록 하자.

// 'src/hooks/usePrototypes.js'

export default function usePrototypes() {
  const {prototypes} = useContext(AppStateContext);
  return prototypes;
}

이렇게 따로 hooks 폴더에 컴포넌트를 작성해두면, 아래 코드처럼 context를 사용하는 각 컴포넌트에서 매 번 useContext나 AppStateContext를 import할 필요가 없어진다.

// 'src/components/Prototypes.jsx'

// const {prototypes} = useContext(AppStateContext);
const prototypes = usePrototypes();

👩🏻 따라서 context를 사용할 때,
1) 'src/contexts/AppStateContext.jsx'에서 AppStateContext = React.createContext(); export 해주기
2) 'src/providers/AppStateProvider.jsx'에서 AppStateContext를 import 받은 후, AppStateProvider는 기본 props로 children을 받아 return문에 해당 children을 <AppStateContext.Provider value={{..}}>로 감싸며, value에 전달하고싶은 값을 넘겨주기
3) 'src/App.js'에서 최상위<AppStateProvider>을 감싸기
4) 각 컴포넌트에서 const {prototypes} = useContext(AppStateContext)와 같이 context 가져와 사용 가능
4-1) 매번 import 귀찮으니, 'src/hooks/usePrototypes.js'에 위처럼 각 context를 받아와 return해주는 훅 작성해두고 사용하기!

2) addToOrder 구현하기

먼저 Prototypes 컴포넌트 내에 클릭 시 addToOrder가 실행되는 코드를 작성한다.

// `src/components/Prototyes.jsx'
...
// map 함수 내
const click = () => {
	addToOrder(id);
}
...
// return문 내
<div 
	className="btn btn--primary float--right" 
	onClick={click}
>

그럼 addToOrder 역시 context를 사용해 가져와야 하고, 위와 같이 hooks를 따로 작성해야 한다.

// 'src/hooks/useActions.js'

export default function useActions() {
  const {addToOrder, remove, removeAll} = useContext(AppStateContext);
  return {addToOrder, remove, removeAll};
}

위에 작성했던 usePrototypes와 같은 로직을 가지고 있으면서 action과 관련된 세 가지 요소를 받아와 return해주는 훅이다.

// 'src/components/Prototypes.jsx'

const { addToOrder } = useActions();

Prototypes 컴포넌트 함수 최상단에 위의 코드를 작성해 addToOrder을 사용할 수 있게 하였다.

이제 본격적인 addToOrder 내부 코드를 작성해볼 것이다.

// 'src/providers/AppStateProvider.jsx'
const addToOrder = useCallback((id) => {
    // [{id, quantity: 1}]
    setOrders(orders => {
      const finded = orders.find(order => order.id === id);
      
      if(finded === undefined){
        return [...orders, {id, quantity: 1}];
      }else{
        return orders.map(order => {
          if(order.id === id){
            return {
              id,
              quantity: order.quantity + 1
            }
          } else {
            return order;
          }
        })
      }
    });
  },[]);

addToOrder 함수가 실행되면, setOrders 내에 화살표 함수를 실행하는데, 먼저 find()함수를 사용해 현재 orders 중 인자로 들어온 id와 같은 id를 가진 order가 있는지 검색한다. 아직 주문이 없다면, 현재 orders와 함께 새로운 id와 수량이 1인 order를 함께 return한다. 이미 주문이 있다면, 다시 orders에 map() 함수를 사용해 각 order에 대해 인자로 들어온 id와 같은 id를 가진 order을 찾아 quantity에 1을 더해 return한다. (이 때 map은 새로운 배열을 반환하는 함수이므로 해당 조건에 부합하는 요소만 +1을 수행하고 나머지는 그대로 알아서 return된다는 점 주의!)

그리고 orders가 제대로 전달되는지 확인하기 위해 아래와 같이 작성했다.

// 'src/hooks/useOrders.js'

export default function useOrders() {
  const {orders} = useContext(AppStateContext);
  return orders;
}
// 'src/components/Orders.jsx'
import useOrders from "../hooks/useOrders";

export default function Orders() {
	const orders = useOrders();
	console.log(orders);
	return (
	...

콘솔 창에 원하는 대로 orders 배열이 잘 출력된다!

3) Orders(주문) 출력하기

현재 작성해둔 코드는 주문 목록이 비었을 때(empty)만을 나타내고 있다. 따라서 주문이 있을 때 내역을 출력하기 위해 Orders 컴포넌트를 다시 작성했다.

// 'src/components/Orders.jsx'

export default function Orders() {
	const orders = useOrders();
	const prototypes = usePrototypes();
	const {remove} = useActions();

	if(orders.length === 0){
		return (
			<aside>
				<div className="empty">
					<div className="title">You don't have any orders</div>
					<div className="subtitle">Click on a + to add an order</div>
				</div>
			</aside>
		);
	}

	return (
		<aside>
			<div className="order">
			<div className="body">
					{orders.map(order => {
						const {id} = order;
						const prototype = prototypes.find(p => p.id === id);
						const click = () => {
							remove(id);
						}
						return (
						<div className="item" key={id}>
							<div className="img">
								<video src={prototype.thumbnail} />
							</div>
							<div className="content">
								<p className="title">
									{prototype.title} x {order.quantity}
								</p>
							</div>
							<div className="action">
								<p className="price">$ {prototype.price * order.quantity}</p>
								<button className="btn btn--link" onClick={click}>
									<i className="icon icon--cross" />
								</button>
							</div>
						</div>
						);
					})}
				</div>
			</div>
		</aside>
	);
}

다시 useOrders hooks를 이용해 받아온 orders의 length가 0이면 주문이 없음을 나타내는 목록을 출력하고, 그렇지 않으면 각 orders의 각 order에 대해 해당하는 id의 prototype을 찾은 후 썸네일과 이름 및 주문 수량, 그리고 총 가격을 나타내는 목록을 출력하도록 작성했다. 그리고 우측에 x 버튼을 두어 remove 함수를 실행할 수 있도록 코드를 추가했다.

다음은 total 가격을 출력하는 부분을 작성할 것이다.

// 'src/components/Orders.jsx'

...

const totalPrice = useMemo(() => {
		return orders.map(order => {
			const {id, quantity} = order;
			const prototype = prototypes.find(p => p.id === id);
			return prototype.price * quantity;
		}).reduce((l, r) => l + r, 0);
	}, [orders, prototypes]);

...

<div className="total">
	<hr />
    <div className="item">
    	<div className="content">Total</div>
  		<div className="action">
    		<div className="price">$ {totalPrice}</div>
  		</div>
  	</div>
</div>

먼저 컴포넌트 상단에 totalPrice를 계산해두었다. useMemo()를 사용해 orders나 prototypes가 변경될 때마다, orders에 map을 돌며 각 order에 대해 price와 quantity를 곱한 후 reduce()로 값을 축적되게 했다. (이후 total 가격 우측에 전체 삭제 버튼, 즉 removeAll을 추가했으나 위의 remove와 그 과정이 매우 유사해 기술하지 않을 것이다.)

그리고 remove와 removeAll 함수는 아래와 같이 간단히 작성했다.

// 'src/providers/AppStateProvider.jsx'
const remove = useCallback((id) => {
  setOrders(orders => {
    return orders.filter(order => order.id !== id);
  });
},[]);

const removeAll = useCallback(() => {
  setOrders([]);
},[]);

끝!!!😁

앞에서 포스팅 했듯이, 해당 프로젝트는 패스트캠퍼스 강의를 참고했음을 밝히며, 상단에 링크해둔 깃허브 주소에서 전체 코드 및 커밋 내역을 확인할 수 있습니다!

profile
1.5년차 프론트엔드 개발자의 소소한 기록을 담습니다 :-)

0개의 댓글