본문 바로가기

Language & Framework/실습

바닐라 자바스크립트로 드래그앤 드롭 장바구니 만들기(웹개발 기능 대회 문제)

 

 

 

코딩애플에서 심심하면 도전해보라고 올려놓은 문제인데, 사실 3주 전에 시도했다가 "강해져서 돌아오마.."를 시전하고 이제서야 만들었다. 사실 답안을 참고해서 만들어도 됐겠지만 그건 자존심이 허락하지 않았고 그렇다고 막히는 걸 계속 붙잡고 있기엔 내가 시간이 별로 없으니 잠시 뒤로 미뤄놓게 되었다.

사실 만드는 것보다도 내가 꾸준히 집중해서 코딩을 할 수 있는 환경이 아니다보니 계속 흐름이 끊겨서 뭘 하고 있었는지 다시 파악하는 게 더 어려웠다 ^^..

 

그래도 처음 시도했을 때는 날 꽤나 괴롭게 만들었던 녀석이기 때문에 기억 보존을 위해 기록을 남긴다.

 

문제 조건은 다음과 같다.

1. JSON 파일을 이용하여 동적으로 HTML 생성 (상품 목록)

2. 검색창에 제품 이름을 입력하면 해당 제품만 필터링.

3. 제품을 드래그 앤 드롭해서 장바구니에 넣으면 동적으로 HTML 생성.

4. 같은 제품을 추가로 넣으면 수량 증가 혹은 Alert으로 이미 장바구니에 있다 등등.. 해주면 되지만 아마 자율인 듯?

5. 장바구니에서 제품 수량 변경 시 제품의 가격, total 가격 변경.

6. 주문하기 버튼을 누르면 email과 연락처를 입력하는 모달창을 띄우고 정보 전송 시 출력 가능한 영수증 이미지 띄우기.

 

 

 

HTML

<body>
    <nav id="navbar">
        <div class="navbar__container">
            <div class="navbar__logoAndList">
                <i class="fa-solid fa-house navbar__logo">내집꾸미기</i>
                <ul class="navbar__list">
                    <li class="navbar__list__item">홈</li>
                    <li class="navbar__list__item">온라인 집들이</li>
                    <li class="navbar__list__item active">스토어</li>
                    <li class="navbar__list__item">전문가</li>
                    <li class="navbar__list__item">시공 견적</li>
                </ul>
            </div>
            <div class="navbar__btnBox">
                <button class="navbar__btnBox__loginBtn">로그인</button>
                <button class="navbar__btnBox__signInBtn">회원가입</button>
            </div>
        </div>
    </nav>

    <div class="searchBox">
        <form>
            <input type="text" id="searchBox" placeholder="검색어를 입력해주세요.">
            <input type="text" style="display: none;">
        </form>
    </div>
    <div class="main">
        <section class="shop">
            <h1 class="shop__header">모든 상품 리스트</h1>
            <div class="shop__itemList">
            </div>
        </section>
        <section class="cart">
            <h1 class="cart__header">장바구니</h1>
            <div class="cart__cartBox">
                <div class="cart__cartBox__dropArea">
                    이 곳에 상품을 놓아주세요.
                </div>
                <div class="cart__cartBox__itemList"></div>
                <span>total <span class="cart__cartBox__totalPrice">0</span>₩</span>
                <button class="cart__cartBox__purchaseBtn">구매하기</button>
            </div>
            <div class="blackBackground">
                <div class="purchase__modal">
                    <button class="purchase__modal__cancelBtn">X</button>
                    <h1>Thank you for your purchase!</h1>
                    <h3>배송 정보를 전달 받을 연락처와 이메일을 입력해주세요.</h3>
                    <form>
                        <span>Email</span>
                        <input type="email" id="emailInput" placeholder="이메일 주소를 입력해주세요.">
                        <span>Phone Number</span>
                        <input type="tel" id="telInput" placeholder="연락처를 입력해주세요.">
                    </form>
                    <button class="purchase__modal__submitBtn">전송하기</button>
                </div>
                <div class="receiptContainer">
                    <canvas id="receipt" width="600" height="600">
                    </canvas>
                    <button class="receiptBtn">닫기</button>
                </div>
            </div>
        </section>
    </div>
</body>

별건 없다.

<canvas></canvas> 사이에 button 태그가 들어가지 않아서 위치 맞춰주기 골치 아프다.

purchase__modal에서 전송하기 버튼은 button 태그가 아니라 input 태그로 만들어줘야 하지만 실습 과정에서는 페이지 리로드 때문에 번거롭기만 하기 때문에 button으로 대체했다.

 

 

CSS

$titleSize: 20px;
$marginSpace: 10px;
$navbarColor: #343a40;
$navMenuItemColor: #878b8e;
$shopItemBottom: #f7f7f7;
$cartButton: #027bff;
$shopPriceBackgroud: #f7f7f7;
$white: #ffffff;
$borderGrey: 1px solid rgba(0, 0, 0, 0.2);
%blueBtn {
  border: none;
  outline: none;
  background-color: #027bff;
  color: $white;
  position: absolute;
  bottom: 0px;
  width: 100%;
  left: 0px;
  height: 30px;
  cursor: pointer;
}

* {
  box-sizing: border-box;
  -webkit-font-smoothing: antialiased;
  -ms-overflow-style: none; /* IE and Edge */
  scrollbar-width: none; /* Firefox */

  &::-webkit-scrollbar {
    display: none; /* Chrome, Safari, Opera*/
  }
}

li {
  list-style: none;
}

body {
  margin: 0px;
}

h3 {
  font-weight: 500;
}

#navbar {
  display: flex;
  justify-content: space-between;
  width: 100%;
  height: 40px;
  background-color: $navbarColor;
  padding: $marginSpace;

  .navbar__container {
    display: flex;
    text-align: center;
    width: 100%;
    max-width: 1200px;
    justify-content: space-between;
  }

  .navbar__logoAndList {
    display: flex;
    text-align: center;
    height: 100%;
  }

  .navbar__logo {
    color: $white;
    font-size: $titleSize;
  }

  .navbar__list {
    display: flex;
    color: $navMenuItemColor;
    align-self: center;
  }

  .navbar__list__item {
    margin: 0px $marginSpace;
  }

  .navbar__list__item.active {
    color: $white;
  }
}

#searchBox {
  width: 95%;
  max-width: 1200px;
  height: 30px;
  display: block;
  margin: $marginSpace * 2;
  border: $borderGrey;
}

.main {
  display: flex;
  justify-content: space-between;
  padding: 0px $marginSpace * 2;
  max-width: 1200px;

  .shop {
    display: flex;
    flex-direction: column;
  }

  .cart {
    display: flex;
    flex-direction: column;
  }

  .shop__itemList {
    display: flex;
  }

  .shop__itemList__item {
    width: 200px;
    height: 300px;
    display: flex;
    flex-direction: column;
    margin-right: $marginSpace * 2;
    border: $borderGrey;
    position: relative;
    cursor: move;
  }

  .shop__itemList__img {
    height: 170px;
  }

  .shop__itemList__item--productName,
  .shop__itemList__item--branName {
    margin: $marginSpace;
  }

  .shop__itemList__item--branName {
    margin-top: 0px;
  }

  .shop__itemList__item--price {
    position: absolute;
    bottom: 0px;
    display: block;
    background-color: $shopItemBottom;
    width: 100%;
    height: 40px;
    line-height: 40px;
    padding-left: $marginSpace;
  }

  .cart__cartBox {
    position: relative;
    border: $borderGrey;
    padding: $marginSpace;
    display: flex;
    flex-direction: column;
  }

  .cart__cartBox__dropArea {
    width: 250px;
    height: 250px;
    background-color: $navbarColor;
    text-align: center;
    line-height: 250px;
    margin-bottom: $marginSpace;
    color: $white;
  }

  .cart__cartBox__itemList--item img {
    display: flex;
    flex-direction: column;
    width: 200px;
  }

  .cartItem__productName {
    margin-bottom: 0px;
  }

  .cartItemQuantity__label {
    font-size: 14px;
    width: 30px;
    height: 20px;
    text-align: center;
    display: inline-block;
    background-color: $shopItemBottom;
    border: $borderGrey;
  }

  .cartItemInput {
    height: 20px;
    border: $borderGrey;
  }

  .cart__cartBox__totalPrice {
    display: inline-block;
    margin-bottom: $marginSpace * 3;
  }

  .cart__cartBox__purchaseBtn {
    @extend %blueBtn;
  }

  .purchase__modal {
    display: none;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background-color: $white;
    width: 50%;
    height: 50%;
    border: $borderGrey;
    padding: 30px;

    .purchase__modal__cancelBtn {
      position: absolute;
      top: 10px;
      right: 10px;
      border: none;
      background-color: transparent;
      font-size: 30px;
      color: rgba(0, 0, 0, 0.4);
      cursor: pointer;
    }

    input {
      width: 50%;
      height: 30px;
    }

    span {
      display: block;
      margin-top: $marginSpace * 2;
    }

    .purchase__modal__submitBtn {
      @extend %blueBtn;
    }
  }

  .blackBackground {
    position: absolute;
    top: 0px;
    left: 0px;
    width: 100vw;
    height: 100vh;
    background-color: rgba(0, 0, 0, 0.2);
    display: none;
  }

  .receiptContainer {
    display: none;
  }

  #receipt {
    background-color: beige;
    position: absolute;
    top: 500px;
    left: 500px;
    transform: translate(-50%, -50%);
    background-color: $white;
    border: $borderGrey;
    z-index: 10;
  }

  .receiptBtn {
    position: absolute;
    top: 730px;
    left: 700px;
    z-index: 20;
  }
}

 

 

Java Script

"use strict";

const shopItemList = document.querySelector(".shop__itemList");
const searchBox = document.querySelector("#searchBox");

function loadItems() {
  return fetch("./store.json")
    .then((res) => res.json())
    .then((json) => json.products);
}

function displayShopItems(products) {
  shopItemList.innerHTML = products
    .map((product) => creatShopItem(product))
    .join("");
}

function creatShopItem(product) {
  return `<div data-id=${product.id} class="shop__itemList__item" draggable="true">
  <img src=${product.photo} alt="shopItemImg" class="shop__itemList__img" draggable="false">
  <h3 class="shop__itemList__item--productName">
      ${product.product_name}
  </h3>
  <span class="shop__itemList__item--branName">
      ${product.brand_name}
  </span>
  <span class="shop__itemList__item--price">
      ${product.price}
  </span>
</div>`;
}

function searchFilter() {
  const value = searchBox.value;
  const shopItemName = document.querySelectorAll(
    ".shop__itemList__item--productName"
  );
  shopItemName.forEach((e) => {
    if (e.innerText.search(value) > -1) {
      showOrHideItem(e.parentElement, "flex");
    } else {
      showOrHideItem(e.parentElement, "none");
    }
  });
}

function showOrHideItem(target, showOrHide) {
  target.style.display = `${showOrHide}`;
}

function dragEvent() {
  const shopItem = document.querySelectorAll(".shop__itemList__item");
  const dropArea = document.querySelector(".cart__cartBox__dropArea");

  for (let i = 0; i < shopItem.length; i++) {
    shopItem[i].addEventListener("dragstart", (e) => {
      e.dataTransfer.setData("text", e.target.dataset.id);
    });
  }

  dropArea.addEventListener("dragover", dragOverHandler);
  dropArea.addEventListener("drop", dropHandler);
}

function creatCartITem(i) {
  const shopItem = document.querySelectorAll(".shop__itemList__item");
  const shopItemImg = document.querySelectorAll(".shop__itemList__img");
  const shopItemName = document.querySelectorAll(
    ".shop__itemList__item--productName"
  );
  const shopItemBrand = document.querySelectorAll(
    ".shop__itemList__item--branName"
  );
  const shopItemPrice = document.querySelectorAll(
    ".shop__itemList__item--price"
  );
  return `<div class="cart__cartBox__itemList--item" data-id='${shopItem[i].dataset.id}'>
  <img src="${shopItemImg[i].src}" alt="cartItemImg">
  <h3 class="cartItem__productName">
      ${shopItemName[i].innerText}
  </h3>
  <span class="cartItem__brandName">
  ${shopItemBrand[i].innerText}
  </span>
  <form>
      <label class="cartItemQuantity">수량</label>
      <input type="number" class="cartItemInput" data-id-input='${shopItem[i].dataset.id}' value=0>
      <input type="text" style="display:none">
  </form>
  <span class="cartItem__price" data-price='${shopItemPrice[i].innerText}'>${shopItemPrice[i].innerText}</span>
</div>
</div>`;
}

function dropHandler(e) {
  const cartItemList = document.querySelector(".cart__cartBox__itemList");
  const dataId = e.dataTransfer.getData("text");
  const exists = document.querySelectorAll(`[data-id='${dataId}']`);

  if (exists.length < 2) {
    cartItemList.insertAdjacentHTML("beforeend", creatCartITem(dataId));
    increaseQuantity(dataId);
    updateCart();
  } else {
    increaseQuantity(dataId);
    updateCart();
  }

  writeInputBox();
}

function dragOverHandler(e) {
  e.preventDefault();
}

function increaseQuantity(id) {
  const cartItemInput = document.querySelector(`[data-id-input='${id}']`);
  cartItemInput.value = Number(cartItemInput.value) + 1;
}

function updateCart() {
  const cartItemQuantity = document.querySelectorAll(".cartItemInput");
  const cartItemPrice = document.querySelectorAll(".cartItem__price");
  const totalPrice = document.querySelector(".cart__cartBox__totalPrice");

  for (let i = 0; i < cartItemPrice.length; i++) {
    cartItemPrice[i].innerText = `${
      cartItemPrice[i].dataset.price * Number(cartItemQuantity[i].value)
    }`;
  }

  let total = 0;
  cartItemPrice.forEach(function (e) {
    total = total + Number(e.innerText);
  });

  totalPrice.innerText = total;
}

function writeInputBox() {
  const cartItemQuantity = document.querySelectorAll(".cartItemInput");
  cartItemQuantity.forEach((e) => {
    e.addEventListener("keyup", (event) => {
      updateCart();
    });
  });
}

function clickPurchaseBtn() {
  const container = document.querySelector(".cart");
  container.addEventListener("click", clickPurchaseEvent);
}

function clickPurchaseEvent(e) {
  const cartItemList = document.querySelector(".cart__cartBox__itemList");
  const purchaseBtn = document.querySelector(".cart__cartBox__purchaseBtn");
  const blackBackground = document.querySelector(".blackBackground");
  const modal = document.querySelector(".purchase__modal");
  const modalSubmitBtn = document.querySelector(".purchase__modal__submitBtn");
  const cancelPurchaseBtn = document.querySelector(
    ".purchase__modal__cancelBtn"
  );
  const receipt = document.querySelector(".receiptContainer");
  const receiptBtn = document.querySelector(".receiptBtn");

  if (e.target.tagName !== "BUTTON" && e.target.tagName !== "INPUT") {
    return;
  }
  if (e.target == purchaseBtn) {
    if (!cartItemList.hasChildNodes()) {
      alert("상품을 담아주세요.");
    } else {
      scrollToTop();
      showOrHideItem(blackBackground, "block");
      showOrHideItem(modal, "block");
    }
  }
  if (e.target == cancelPurchaseBtn) {
    showOrHideItem(blackBackground, "none");
    showOrHideItem(modal, "none");
  }
  if (e.target == modalSubmitBtn) {
    updateReceipt();
    showOrHideItem(modal, "none");
    showOrHideItem(receipt, "block");
  }
  if (e.target == receiptBtn) {
    showOrHideItem(receipt, "none");
    showOrHideItem(blackBackground, "none");
  }
}

function scrollToTop() {
  scrollTo(0, 0);
}

function updateReceipt() {
  const today = new Date();
  const cartItemName = document.querySelectorAll(".cartItem__productName");
  const cartItemBrand = document.querySelectorAll(".cartItem__brandName");
  const cartItemQuantity = document.querySelectorAll(".cartItemInput");
  const cartItemPrice = document.querySelectorAll(".cartItem__price");
  const totalPrice = document.querySelector(".cart__cartBox__totalPrice");
  const receipt = document.querySelector("#receipt");
  const ctx = receipt.getContext("2d");
  ctx.font = "20px serif";
  ctx.fillText("영수증", 30, 50);
  ctx.font = "12px serif";
  ctx.fillText(today.toLocaleDateString(), 30, 70);
  ctx.fillText(today.toLocaleTimeString(), 120, 70);
  for (let i = 0; i < cartItemName.length; i++) {
    ctx.font = "16px serif";
    ctx.fillText(` 제품명 : ${cartItemName[i].innerText}`, 30, 100 * (i + 1));
    ctx.fillText(
      ` 브랜드 : ${cartItemBrand[i].innerText}`,
      30,
      100 * (i + 1) + 20
    );
    ctx.fillText(
      ` 수량 : ${cartItemQuantity[i].value}개`,
      30,
      100 * (i + 1) + 40
    );
    ctx.fillText(
      ` 금액 : ${cartItemPrice[i].innerText}원`,
      30,
      100 * (i + 1) + 60
    );
  }
  ctx.fillText(` 총액 : ${totalPrice.innerText}원`, 400, 500);
}

loadItems()
  .then((products) => {
    displayShopItems(products);
  })
  .then(() => {
    dragEvent();
    clickPurchaseBtn();
    searchBox.addEventListener("keyup", searchFilter);
  });

 

1. 상품 목록 받아오기

처음 시도했을 당시에는 먼저 HTML을 받아온 뒤 각 클래스를 선언하고 거기에 받아온 json의 데이터를 넣어주는 아주 비효율적인 방식으로 코드를 작성했었는데, 드림코딩에서 배운대로 map과 join을 활용하여 깔끔하게 해결할 수 있었다.

 

2. 검색어 필터링하기

search() 함수는 indexOf() 함수와 비슷하게 배열의 시작부터 검사하며 일치하는 값의 위치를 찾아낸다.

search는 정규식을 포함 가능하고 대신 시작 위치를 바꿀 수 없다는 것 외에는 indexOf와 다른 게 없기 때문에 둘 중 어떤 것을 사용해도 상관 없다.

showOrHideItem 함수는 만들 때까지만 해도 겨우 이런 걸 함수로 만들어서 어디에 쓸까 싶어서 고민이 됐는데 끝까지 요긴하게 잘 사용했다.

 

3. 드래그 이벤트

 

바닐라 자바스크립트 드래그앤 드롭 API는 여러 복잡한 과정을 거쳐서 실행되지만 나는 dragstart(드래그 시작), dragover(드롭 대상 위를 지날 떄), drop(드래그한 요소를 드롭할 때) 이 세가지만 사용했다. 자세하게 알고 싶으면 여기서 확인하도록 하자.

 

dragover에 e.preventDefault()로 기본적으로 브라우저가 하는 행동을 취소시켜주지 않으면 똑바로 동작하지 않을 수도 있다고 하는데.. 궁금해서 지우고 해봤으나 별반 차이가 없었다.

 

드래그해서 전달해줄 데이터는 오직 dataset.id만 있으면 된다. 내가 이걸 몰라서 그렇게 어려워하면서 해맸다니..

난 모든 데이터를 다 전달해줘야 한다고 생각했는데 어차피 장바구니에 동적으로 만들어줄 요소는 쇼핑몰에서 모두 가지고 있다. 식별할 정보만 있으면 될 뿐.. 이것만 해결하니 나머지는 일사천리로 진행됐다.

 

4. 동적으로 카트에 HTML 생성

5. 제품이 없으면 추가하고 있으면 수량 증가

 

가져온 데이터를 querySelectorAll로 선언해주고 length로 해당 데이터가 카트에 이미 존재하는지 체크한다.

이미 쇼핑몰에 같은 데이터를 가진 요소가 존재하므로 length가 1이면 카트에 없는 것이고 2면 카트에 있는 것이다.

 

만약 이미 카트에 있을 경우 increaseQuantity함수로 수량을 증가시키고 updateCart함수로 제품 금액 및 토탈 금액을 변경한다.

카트에 없을 경우 insertAdjacentHTML함수로 요소를 장바구니에 넣어주고 updateCart함수로 제품 금액 및 토탈 금액을 변경한다.

 

6. 제품 수량 변경하면 제품 금액 및 토탈 금액 변경

 

장바구니의 인풋 박스에 수량을 변경해주면 total 금액과 상품 금액을 변경시켜주기 위해 만든 함수인데

내가 봐도 함수명이 너무 구리다. 근데 뭐라고 지어야할 지 모르겠다..

 

 

구매 버튼과 관련된 함수인데, addEventListener를 여러개 만드는게 지저분하게 느껴져서 이벤트 리스너를 아예 컨테이너 전체에 줘버리고 타겟을 일일히 지정해줬으나.. 이게 더 지저분한 것 같다.

 

 

7. 이미지 파일로 저장 가능한 영수증 이미지 출력하기

 

canvas를 써보는 건 처음이라 MDN 보면서 시키는대로 했다..

단순히 원하는 위치에 글씨 쓰는 건 노가다의 영역이라 어려운 부분은 없었으나 귀찮았고, MDN을 무시하고 canvas의 크기를 css로 건드렸다가 글씨가 죄다 꺠져버려서 덩달아 내 멘탈도 같이 깨질 뻔 했으나 구글신께서 빠르게 나에게 정답을 알려주셨다.

 

 

 

 

별 거 아니지만 이렇게 한 가지를 또 해냈고 미약하게나마 계속해서 발전하고 있다는 사실이 기쁘다.

매장을 빨리 정리하고 공부에 전념하고 싶지만 현실이 녹록지 않다  : (