프로그래머스 쇼핑몰 SPA - 커피주문페이지 만들기 공부 겸 해설 2. 상세 페이지

zaman·2022년 8월 26일
0
post-thumbnail

👉 github에서 코드 보기

이전 포스팅 - 상품 목록 만들기

문제풀이

1. 페이지 이동 처리하기

이전 포스팅에서 목록을 만들었다면 이젠 디테일 페이지로 이동해야한다
참조 👉 문제풀이1. 제품 목록 만들기

내가 풀 때 생각했던 방법은 바로 a 태그를 사용하는 것
그러나 생각처럼 잘 실행되지 않았다. 바로 아래와 같은 이유 때문!

이렇게 하면 페이지 이동 시마다 페이지를 매번 새로 불러오게 됩니다. SPA라는 말을 잘 생각해보면, 이런 식으로 매번 페이지를 새로 불러오는 방식이 아니라 클라이언트에서 페이지가 변경되는 부분만 새로 그리도록 처리를 해야 하는 것을 떠올릴 수 있을 것입니다.

ㅎㅎ역시나 이걸 생각 못했다

그래서 풀이에서 제안한 방법은 HTML History API를 사용하는 것이다.
history.pushState를 이용하면 URL만 업데이트하면서 웹 브라우저의 기본적인 페이지 이동은 처리되지 않는다고 한다

DOM의 Window 객체는 history 객체를 통해 브라우저의 세션 기록에 접근할 수 있는 방법을 제공합니다. history는 사용자를 자신의 방문 기록 앞과 뒤로 보내고 기록 스택의 콘텐츠도 조작할 수 있는, 유용한 메서드와 속성을 가집니다.

1. 이동할 페이지 url을 history.pushState를 통해 변경하기
2. App.js의 this.route 함수 실행하기

History.pushState()

=> 화면 전환! 페이지 이동 없이 주소만 바꿔줌
: HTML 문서에서, history.pushState() 메서드는 브라우저의 세션 기록 스택에 상태를 추가합니다.

구문
: history.pushState(state, title[, url]);

  • State : 브라우저 이동 시 넘겨줄 데이터
  • Title : 변경할 브라우저 제목
  • Url : 변경할 주소

먼저 URL이 변경되는 것을 감지하기 위해 커스텀 이벤트를 사용해보자

// 커스텀 이벤트를 통해 처리하기
const ROUTER_CHANGE_EVENT = "ROUTER_CHANGE";

export const init = (onRouteChange) => {
// 커스텀 이벤트를 통해 ROUTER_CHANGE 이벤트 발생 시
  window.addEventListener(ROUTER_CHANGE_EVENT, () => {
    // onRouteChange 콜백 함수를 호출하도록 이벤트 바인딩
    onRouteChange();
  });
};

// URL을 업데이트하고 커스텀 이벤트를 발생시키는 함수
export const routeChange = (url, params) => {
  // history.pushState(state: 브라우저 이동 시 넘겨줄 데이터, title: 변경할 브라우저 제목, url: 변경할 주소)
  history.pushState(null, null, url); 
  window.dispatchEvent(new CustomEvent(ROUTER_CHANGE_EVENT, params));
};

dispatcgEvent 참조 자료

다음으로 App.js 코드를 수정한다

// App.js
import CartPage from "./pages/CartPage.js";
import DetailPage from "./pages/DetailPage.js";
import ListPage from "./pages/ListPage.js";
import { init } from "./router.js";

function App({ $app }) {
  this.route = () => {
    const { pathname } = location;

    $app.innerHTML = "";

    if (pathname === "/coffee/index.html") {
      new ListPage({ $app }).render();
    } else if (pathname.includes("/products/")) {
      const [, , productId] = pathname.split("/");
      new DetailPage({ $app, productId }).render();
    } else if (pathname === "/coffee/cart") {
      new CartPage({ $app }).render();
    }
  };

  // 코드가 추가된 부분:
  // ROUTE_CHANGE 이벤트 발생 시 App의 this.route함수가 호출되게 하는 효과
  init(this.route);
  // const init = (onRouteChange) => {
  //    window.addEventListener(ROUTER_CHANGE_EVENT, () => {
  //      onRouteChange();
  //    });
  //  };
  
  this.route();
}

export default App;

마지막으로 ProductList를 변경해보자

import { routeChange } from "../router.js";

function ProductList({ $target, initialState }) {
  const $productList = document.createElement("ul");
  $target.appendChild($productList);

  this.state = initialState;
  
  this.render = () => {
    if (!this.state) {
      return;
    }

    // data-product-id: product-id -> custom attribute
    const list = this.state
      .map(
        (item) => `<li class="Product" data-product-id="${item.id}">
      <img src="${item.imageUrl}">
      <div class="Product__info">
        <div>${item.name}</div>
        <div>${item.price}~</div>
      </div>
    </li>`
      )
      .join(``);

    $productList.innerHTML = list;
  };

  this.render();

  $productList.addEventListener("click", (event) => {
    const $li = event.target.closest("li");
    const { productId } = $li.dataset;

    if (productId) {
      routeChange(`/products/${productId}`);
     // const routeChange = (url, params) => {
     //   history.pushState(null, null, url);
     //   window.dispatchEvent(new CustomEvent(ROUTER_CHANGE_EVENT, params));
     // }; 
     // => ROUTE_CHANGE 이벤트 발생 시 App의 this.route함수가 호출
     }
  });
}

export default ProductList;

dataset
: data-속성명="속성값", HTML5 표준 속성처럼 접근할 수 있음

HTML에 추가의 속성이나 데이터를 표기하는 표준이 없어 비표준적인 방법으로 데이터를 표기하던 라이브러리들이 표준적인 방법을 사용할 수 있도록 개선되었으며, 자바스크립트 또한 표준화된 DOM 메서드로 데이터셋 속성에 접근할 수 있습니다.

이제 디테일 페이지로 이동도 완성이다!
그러나 이렇게 구현하면 상세페이지에서 뒤로 가기를 했을 때 렌더링이 안되는 것을 확인할 수 있을 것이다.

(뒤로가기로 url은 바뀌었지만 화면 렌더링에 문제 발생)

2. 페이지 뒤로가기 처리하기

이 경우엔 popstate 이벤트를 통해 처리할 수 있다고 한다.

popstate-mdn
: The popstate event of the Window interface is fired when the active history entry changes while the user navigates the session history. -> 현재 활성화된 기록 항목이 바뀔 때 발생

It changes the current history entry to that of the last page the user visited or, if history.pushState() has been used to add a history entry to the history stack, that history entry is used instead. -> 만약 활성화된 엔트리가 history.pushState() 메서드나 history.replaceState() 메서드에 의해 생성되면, popstate 이벤트의 state 속성은 히스토리 엔트리 state 객체의 복사본을 갖게 됨

이 이벤트를 통해 뒤로 가기나 앞으로 가기 등으로 통해 브라우저 url이 변경된 경우를 감지할 수 있다

import CartPage from "./pages/CartPage.js";
import DetailPage from "./pages/DetailPage.js";
import ListPage from "./pages/ListPage.js";
import { init } from "./router.js";

function App({ $app }) {
  this.route = () => {
    const { pathname } = location;

    $app.innerHTML = "";

    if (pathname === "/coffee/index.html") {
      new ListPage({ $app }).render();
    } else if (pathname.includes("/products/")) {
      const [, , productId] = pathname.split("/");

      new DetailPage({ $app, productId }).render();
    } else if (pathname === "/coffee/cart") {
      console.log(pathname);
      new CartPage({ $app }).render();
    }
  };


  init(this.route);
  this.route();

  // 뒤로가기, 앞으로 가기 발생 시 popstate 이벤트 발생
  window.addEventListener("popstate", this.route);
}

export default App;

3. 상세 페이지 구현

1. 상품 id에 맞는 상품 불러오기

불러오기는 앞에서 이미 구현한 목록 불러오기와 유사하다

api에 id에 맞는 product 정보를 불러오기 추가

...

export const getIdProduct = async (id) => await request(`/products/${id}`);

DetailPage에서 불러오기 기능 추가

import { getIdProduct } from "../apis/api.js";
import ProductDetail from "../components/ProductDetail.js";

function DetailPage({ $app, productId }) {
  const $page = document.createElement("div");
  $page.className = "ProductDetailPage";
  $page.innerHTML = `<h1>상품 정보</h1>`;

  this.state = { productId, product: null };

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  this.render = () => {
    if (!this.state.product) {
      $app.innerHTML = "Loading";
    } else {
      $app.innerHTML = "";
      $app.appendChild($page);
      new ProductDetail({ $target: $page, initialState: this.state });
    }
  };

  this.fetchProduct = async () => {
    const { productId } = this.state;
    const product = await getIdProduct(productId);

    this.setState({
      ...this.state,
      product,
    });
  };

  this.fetchProduct();
}
export default DetailPage;

ProductDetail 컴포넌트 구현

function ProductDetail({ $target, initialState }) {
  const $product = document.createElement("div");
  $product.className = "ProductDetail";

  $target.appendChild($product);

  this.state = initialState;

  this.render = () => {
    const { product } = this.state;
    const detail = `<img
          src="${product.imageUrl}"
        />
        <div class="ProductDetail__info">
          <h2>${product.name}</h2>
          <div class="ProductDetail__price">${product.price}원~</div>
          <select>
            <option>선택하세요.</option>
            ${product.productOptions
              .map(
                (opt) =>
                  `<option value="${opt.id}" ${opt.stock === 0 ? "disabled" : ""}>${opt.stock === 0 ? "(품절)" : ""} ${
                    opt.name
                  } ${opt.price > 0 ? `(+ ${opt.price}원 )` : ""}</option>`
              )
              .join()}
          </select>
          <div class="ProductDetail__selectedOptions">
          </div>
        </div>`;
    $product.innerHTML = detail;
  };

  this.render();
}

export default ProductDetail;

여기선 option 불러오는 부분 빼곤 무난하게 넘어갈 수 있었다

2. 상품 옵션 선택 처리


먼저 선택 된 상품들을 담아 둘 selectedOptions를 DetailPage에 추가한다

// import 생략
function DetailPage({ $app, productId }) {
// ... 코드 생략
  this.render = () => {
    if (!this.state.product) {
      $app.innerHTML = "Loading";
    } else {
      $app.innerHTML = "";
      $app.appendChild($page);

      new ProductDetail({
        $target: $page,
        initialState: {
          product: this.state.product,

          // 이 부분 수정! ProductDetail의 initialState에 선택된 상품들을 담아둘 곳
          selectedOptions: [],
        },
      });
    }
  };

  this.fetchProduct = async () => {
    const { productId } = this.state;
    const product = await getIdProduct(productId);

    this.setState({
      ...this.state,
      product,
    });
  };

  this.fetchProduct();
}
export default DetailPage;

이제 ProductDetail에서 상품이 선택되었을 때 옵션 중복을 막는 처리를 해준다

import SelectedOptions from "./SelectedOptions.js";

function ProductDetail({ $target, initialState }) {
  const $product = document.createElement("div");
  $product.className = "ProductDetail";

  $target.appendChild($product);
  // ... 생략
  this.render();

  // change 이벤트가 발생했을 때 
  $product.addEventListener("change", (event) => {
    if (event.target.tagName === "SELECT") {
      // option value에 optionId를 담음, 이를 가져와 Int로 변경
      const selectedOptionId = parseInt(event.target.value);
      const { product, selectedOptions } = this.state;

      // 상품 옵션 데이터에서 현재 선택한 optionId가 존재하는지 찾기
      const option = product.productOptions.find((option) => option.id === selectedOptionId);

      // 이미 선택한 상품인지 선택된 상품 데이터에서 선택한 optionId 찾기
      const seletedOption = selectedOptions.find((selectedOption) => selectedOption.id === selectedOptionId);

      // option이고 이미 선택한 옵션이 아닌 경우 옵션을 추가함
      if (option && !seletedOption) {
        const nextSeletedOptions = [
          ...selectedOptions,
          {
            productId: product.id,
            optionId: option.id,
            optionName: option.name,
            optionPrice: option.price,
            quantity: 1,
          },
        ];
        this.setState({
          ...this.state,
          selectedOptions: nextSeletedOptions,
        });
      }
    }
  });
}

export default ProductDetail;

다음에는 ProductDetail에서 selectedOptions를 그려주기 위한 컴포넌트 SelectedOptions를 만들어보자

원본 html

              <h3>선택된 상품</h3>
              <ul>
                <li>
                  커피잔 100개 번들 10,000원
                  <div><input type="number" value="10" /></div>
                </li>
                <li>
                  커피잔 1000개 번들 15,000원
                  <div><input type="number" value="5" /></div>
                </li>
              </ul>
              <div class="ProductDetail__totalPrice">175,000원</div>
              <button class="OrderButton">주문하기</button>
            </div>
function SelectedOptions({ $target, initialState }) {
  const $component = document.createElement("div");
  $target.appendChild($component);

  // initialState: { product: this.state.product, selectedOptions: this.state.selectedOptions }
  this.state = initialState;

  // 선택 상품 가격의 총합 
  this.getTotalPrice = () => {
    const { product, selectedOptions } = this.state;
    const { price: productPrice } = product;

    return selectedOptions.reduce((acc, option) => acc + (productPrice + option.optionPrice) * option.quantity, 0); 
  };

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  this.render = () => {
    const { product, selectedOptions = [] } = this.state;

    if (product && selectedOptions) {
      const selected = `
      <h3>선택된 상품</h3>
      <ul>
        ${selectedOptions
          .map(
            (option) => `
      <li>
          ${option.optionName} ${product.price + option.optionPrice}원
        <div><input type="text" data-optionId="${option.optionId}" value="${option.quantity}"/>개</div>
      </li>`
          )
          .join("")}
      </ul>
      <div class="ProductDetail__totalPrice">${this.getTotalPrice()}원</div>
      <button class="OrderButton">주문하기</button>`;
      $component.innerHTML = selected;
    }
  };

  this.render();
}
export default SelectedOptions;

마지막으로 ProductDetail에서 SelectedOption 컴포넌트를 생성해 사용한다

import SelectedOptions from "./SelectedOptions.js";

function ProductDetail({ $target, initialState }) {
// ... 이전 코드 생략
  this.state = initialState;

  // fetchProduct 이후 화면이 렌더링 되었을 때 동작할 수 있도록 let으로 생성
  let selectedOptions = null;

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();

    // selectedOptions에 값이 있으면 값 업데이트, 재렌더링
   if (selectedOptions) {
      selectedOptions.setState({
        product: this.state.product,
        selectedOptions: this.state.selectedOptions,
      });
    }
  };

  this.render = () => {
    const { product } = this.state;

    const detail = `<img
          src="${product.imageUrl}"
        />
        <div class="ProductDetail__info">
          <h2>${product.name}</h2>
          <div class="ProductDetail__price">${product.price}원~</div>
          <select>
            <option>선택하세요.</option>
            ${product.productOptions
              .map(
                (opt) =>
                  `<option value="${opt.id}" ${opt.stock === 0 ? "disabled" : ""}>${opt.stock === 0 ? "(품절)" : ""} ${
                    opt.name
                  } ${opt.price > 0 ? `(+ ${opt.price}원 )` : ""}</option>`
              )
              .join()}
          </select>
          <div class="ProductDetail__selectedOptions">
          </div>
        </div>`;
    $product.innerHTML = detail;

    selectedOptions = new SelectedOptions({
      $target: $product.querySelector(".ProductDetail__selectedOptions"),
      initialState: { product: this.state.product, selectedOptions: this.state.selectedOptions },
    });
  };

  this.render();

  // ... 이후 코드 생략
}

export default ProductDetail;

3. 상품 옵션 수량 변경

옵션 수량은 SelectedOptions의 input값을 통해 변경한다

function SelectedOptions({ $target, initialState }) {
  // ... 이전 코드 생략
  
  this.render();
  
  // change이벤트가 발생했을 때 tagname이 input이면
  $component.addEventListener("change", (event) => {
    if (event.target.tagName === "INPUT") {
      try {
      // 새로운 수량
        const nextQuantity = parseInt(event.target.value);
        const nextSelectedOptions = [...this.state.selectedOptions];
        // input 값이 숫자인 경우만 처리
        if (typeof nextQuantity === "number") {
          const { product } = this.state;

          const optionId = parseInt(event.target.dataset.optionid);
          const option = product.productOptions.find((opt) => opt.id === optionId);
          const selectedOptionIndex = nextSelectedOptions.findIndex(
            (selectedOption) => selectedOption.optionId === optionId
          );
          // input에 입력한 값이 재고수량을 넘을 경우 재고 수량으로 입력한 것 바꾸기
          nextSelectedOptions[selectedOptionIndex].quantity = option.stock > nextQuantity ? nextQuantity : option.stock;

          this.setState({
            ...this.state,
            selectedOptions: nextSelectedOptions,
          });
        }
      } catch (err) {
        console.log(err);
      }
    }
  });
}
export default SelectedOptions;


이렇게 수량이 바뀌면서 총 가격도 바뀌는걸 확인할 수 있다

4. 상품 선택 후 주문하기 click -> localStorage에 주문한 상품 저장, 장바구니로 이동

이제 정말 다왔다!
주문할 상품을 localStorage에 저장해야하기 떄문에 localStorage를 다루기 위한 유틸리티 함수를 만들어야 한다

export const storage = localStorage;

export const getItem = (key, defaultValue) => {
  try {
    const value = storage.getItem(key);
    // key에 해당하는 값이 있다면 파싱, 없으면 디폴트값을 리턴한다
    return value ? JSON.parse(value) : defaultValue;
  } catch {
    // 에러가 생길시에도 디폴트값 리턴!
    return defaultValue;
  }
};

export const setItem = (key, value) => {
  try {
    storage.setItem(key, JSON.stringify(value));
  } catch {
    // ignore
  }
};

export const removeItem = (key) => {
  try {
    storage.removeItem(key);
  } catch {
    // ignore
  }
};

이제 주문하기 버튼이 있는 SelectedOptions에서 사용해주면 된다

import { routeChange } from "../router.js";
import { getItem, setItem } from "../storage.js";

function SelectedOptions({ $target, initialState }) {
  // ... 이전 코드 생략
  $component.addEventListener("click", (event) => {
    const { selectedOptions } = this.state;
    if (event.target.className === "OrderButton") {
      // 주문하기 버튼을 누르면
      // 먼저 기존에 담겨진 장바구니 데이터가 있을 수 있으므로 가져와본다 -> 없으면 빈배열
      const cartData = getItem("products_cart", []);

      // 장바구니 데이터 만들기
      setItem(
        "products_cart",
        cartData.concat(
          selectedOptions.map((opt) => ({
            productId: opt.productId,
            optionId: opt.optionId,
            quantity: opt.quantity,
          }))
        )
      );
  routeChange("/coffee/cart");
    }
  });
}
export default SelectedOptions;

그럼 로컬스토리지에 저장되고 카트로 이동한 것을 확인할 수 있다!


참조

https://github.com/ChangHyun2/programmers-vanillaJS-SPA
https://prgms.tistory.com/113

profile
개발자로 성장하기 위한 아카이브 😎

0개의 댓글