REACT/쇼핑몰 프로젝트

[REACT] React + Typescript + Vite + SCSS로 만드는 쇼핑몰 개인프로젝트 (4/10)

예글 2024. 1. 22. 20:44

4일차 시작!

 

오늘 할 것

1.  장바구니 상태관리 (recoil)

2. 결제페이지

 

1️⃣ Recoil로 상태관리 하기

- 체크된 상품이 결제예정 상품에도 출력되어야하고, 결제페이지에도 출력되어야 함!

- 두 번 이상 다른 곳에 쓰이므로 결제예정상품을 컴포넌트로 분리하기!

 

- components/willPay/index.tsx

import { useRecoilValue } from "recoil";
import checkedCartState from "../../../recoils/cart";
import ItemData from "../cart/itemData";
import { SyntheticEvent } from "react";

// 결제예정
const WillPay = ({
  submitTitle,
  handleSubmit,
}: {
  submitTitle: string;
  handleSubmit: (e: SyntheticEvent) => void;
}) => {
  // recoil 상태가 변경되는 경우 재렌더링되어 업데이트 값 가져옴
  const checkedItems = useRecoilValue(checkedCartState);
  
  // reduce로 체크된 상품데이터를 누적 합산한 값
  const totalPrice = checkedItems.reduce((res, { price, amount }) => {
    res += price * amount;
    return res;
  }, 0);

  return (
    <div className="cart-willpay">
      <ul>
        {checkedItems.map(({ imageUrl, price, title, amount, id }) => (
          <li key={id}>
            <ItemData
              imageUrl={imageUrl}
              price={price}
              title={title}
              key={id}
            />
            <p>수량 : {amount}</p>
            <p>금액 : {price * amount}</p>
          </li>
        ))}
      </ul>

      <p>총예상금액 : {totalPrice}</p>

      <button onClick={handleSubmit}>{submitTitle}</button>
    </div>
  );
};

export default WillPay;

 

- components/cart/list.tsx

import { SyntheticEvent, createRef, useEffect, useRef, useState } from "react";
import { Cart } from "../../../graphql/cart";
import CartItem from "./item";
import { useRecoilState } from "recoil";
import checkedCartState from "../../../recoils/cart";
import WillPay from "../willPay";
import { useNavigate } from "react-router-dom";

const CartList = ({ items }: { items: Cart[] }) => {
  const navigate = useNavigate();

  // 체크된 요소의 상태
  const [checkedCartData, setCheckedCartData] =
    useRecoilState(checkedCartState);

  // 전체 폼 요소에 대한 참조
  const formRef = useRef<HTMLFormElement>(null);

  // 각 아이템 체크박스 요소에 대한 참조 배열
  const checkboxRefs = items.map(() => createRef<HTMLInputElement>());

  const [formData, setFormData] = useState<FormData>();

  // 개별 아이템 선택 시
  const setAllCheckedFromItems = () => {
    if (!formRef.current) return;

    // 현재 폼의 데이터를 FormData로 가져오기
    const data = new FormData(formRef.current);

    // 선택된 아이템의 개수 계산
    const selectedCount = data.getAll("select-item").length;

    // 개별 아이템의 체크박스가 변경되었을 때
    const allChecked = selectedCount === items.length;

    formRef.current.querySelector<HTMLInputElement>(
      ".cart_select-all"
    )!.checked = allChecked;
  };
  
  // 전체선택을 클릭하여 모든 체크박스가 체크되는 경우
  const setItemsCheckedFromAll = (targetInput: HTMLInputElement) => {
    const allChecked = targetInput.checked;
    checkboxRefs.forEach((inputElem) => {
      inputElem.current!.checked = allChecked;
    });
  };

  // 전체 선택 핸들러 함수
  const handleCheckboxChanged = (e?: SyntheticEvent) => {
    if (!formRef.current) return;

    // 변경된 체크박스 요소 확인
    const targetInput = e?.target as HTMLInputElement;

    // 전체 선택 체크박스가 변경되었을 때
    if (targetInput && targetInput.classList.contains("cart_select-all")) {
      setItemsCheckedFromAll(targetInput);
    } else {
      setAllCheckedFromItems();
    }

    // 현재 폼의 데이터를 FormData로 가져오기
    const data = new FormData(formRef.current);
    setFormData(data);
  };

  const handleSubmit = () => {
    if (checkedCartData.length) {
      navigate("/payment");
    } else {
      alert("결제할 대상이 없어요");
    }
  };

  useEffect(() => {
    checkedCartData.forEach((item) => {
      const itemRef = checkboxRefs.find(
        (ref) => ref.current!.dataset.id === item.id
      );
      if (itemRef) itemRef.current!.checked = true;
    });
    handleCheckboxChanged();
  }, []);

  // items, formData의 변경에 따라 렌더링
  useEffect(() => {
    // 체크된 아이템을 식별하기 위해
    const checkedItems = checkboxRefs.reduce<Cart[]>((res, ref, i) => {
      if (ref.current!.checked) res.push(items[i]);
      return res;
    }, []);
    setCheckedCartData(checkedItems);
  }, [items, formData]);

  return (
    <div>
      {/* 폼 엘리먼트에 참조를 설정하고, 체크박스 변경 시 이벤트 핸들러를 호출 */}
      <form ref={formRef} onChange={handleCheckboxChanged}>
        <label>
          <input
            className="cart_select-all"
            type="checkbox"
            name="select-all"
          />
          전체선택
        </label>

        <ul className="cart">
          {items?.map((item: any, i) => (
            <CartItem {...item} key={item.id} ref={checkboxRefs[i]} />
          ))}
        </ul>
      </form>

      <WillPay submitTitle="결제창으로" handleSubmit={handleSubmit} />
    </div>
  );
};

export default CartList;

 

- components/cart/item.tsx

import { useMutation } from "react-query";
import { Cart, DELETE_CART, UPDATE_CART } from "../../../graphql/cart";
import { QueryKeys, getClient, graphqlFetcher } from "../../../queryClient";
import { forwardRef, ForwardedRef, SyntheticEvent } from "react";
import ItemData from "./itemData";

const CartItem = (
  { id, imageUrl, price, title, amount }: Cart,
  ref: ForwardedRef<HTMLInputElement>
) => {
  const queryClient = getClient();

  // 장바구니 수량 업데이트
  const { mutate: updateCart } = useMutation(
    ({ id, amount }: { id: string; amount: number }) =>
      graphqlFetcher(UPDATE_CART, { id, amount }),
    {
      onMutate: async ({ id, amount }) => {
        await queryClient.cancelQueries(QueryKeys.CART);

        // Snapshot the previous value
        const prevCart = queryClient.getQueryData<{ [key: string]: Cart }>(
          QueryKeys.CART
        );

        if (!prevCart?.[id]) return prevCart;

        const newCart = {
          ...(prevCart || {}),
          [id]: { ...prevCart[id], amount },
        };

        queryClient.setQueryData(QueryKeys.CART, newCart);
        return prevCart;
      },
      onSuccess: (newValue) => {
        // 아이템 하나에 대한 데이터
        const prevCart = queryClient.getQueryData<{ [key: string]: Cart }>(
          QueryKeys.CART
        );
        const newCart = {
          ...(prevCart || {}),
          [id]: newValue,
        };
        queryClient.setQueryData(QueryKeys.CART, newCart); // Cart 전체에 대한 데이터
      },
    }
  );

  // 장바구니 삭제
  const { mutate: deleteCart } = useMutation(
    ({ id }: { id: string }) => graphqlFetcher(DELETE_CART, { id }),
    {
      onSuccess: () => {
        queryClient.invalidateQueries(QueryKeys.CART);
      },
    }
  );

  const handleUpdateAmount = (e: SyntheticEvent) => {
    const amount = Number((e.target as HTMLInputElement).value);
    if (amount < 1) return;
    updateCart({ id, amount });
  };

  const handleDeleteItem = () => {
    deleteCart({ id });
  };

  return (
    <li className="cart-item">
      <input
        className="cart-item__checkbox"
        type="checkbox"
        name={`select-item`}
        ref={ref}
        data-id={id}
      />
      <ItemData imageUrl={imageUrl} price={price} title={title} />

      <input
        type="number"
        className="cart-item__amount"
        value={amount}
        onChange={handleUpdateAmount}
        min={1}
      />

      <button
        className="cart-item__button"
        type="button"
        onClick={handleDeleteItem}
      >
        삭제
      </button>
    </li>
  );
};

export default forwardRef(CartItem);
  • data-*: data-* 전역 속성은 사용자 지정 데이터 속성이라는 속성 클래스를 형성하며, 이를 통해 스크립트를 통해 HTML과 해당 DOM 표현 간에 독점 정보를 교환 가능.
  • dataset: HTMLElement 인터페이스의 읽기 전용 속성인 dataset은 요소의 사용자 정의 데이터 속성(data-*)에 대한 읽기/쓰기 액세스를 제공한다. 각 data-* 속성에 대한 항목이 있는 문자열 맵(DOMStringMap)을 노출.

- src/recoils/cart.ts

import { atom } from "recoil";
import { Cart } from "../graphql/cart";

const checkedCartState = atom<Cart[]>({
  key: "cartState",
  default: [],
});

 

- atom 객체 배열에 체크된 데이터 전달

- atom이 업데이트 되면 해당 atom을 구독하고 있던 모든 컴포넌트들이 새로운 값으로 리렌더링

- 여러 컴포넌트에서 가틈 atom을 구독하고 있으면 그 컴포넌트들이 동일한 상태 공유

 

2️⃣ 결제페이지 만들기

- components/payment/index.tsx

import { useRecoilState } from "recoil";
import checkedCartState from "../../../recoils/cart";
import WillPay from "../willPay";
import PaymentModal from "./modal";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation } from "react-query";
import { graphqlFetcher } from "../../../queryClient";
import { EXECUTE_PAY } from "../../../graphql/payment";

type PaymentInfos = string[];

const PaymentIndex = () => {
  const navigate = useNavigate();
  const [checkedCartData, setCheckedCartData] =
    useRecoilState(checkedCartState);

  const [modalShown, toggleModal] = useState(false);

  const { mutate: executePay } = useMutation((payInfos: PaymentInfos) =>
    graphqlFetcher(EXECUTE_PAY, payInfos)
  );

  const showModal = () => {
    toggleModal(true);
  };

  const proceed = () => {
    // 결제진행!

    const payInfos = checkedCartData.map(({ id }) => id);
    executePay(payInfos);
    setCheckedCartData([]);
    alert("결제 완료되었습니다.");
    navigate("/products", { replace: true });
  };

  const cancel = () => {
    toggleModal(false);
  };

  return (
    <div>
      <WillPay submitTitle="결제하기" handleSubmit={showModal} />
      <PaymentModal show={modalShown} proceed={proceed} cancel={cancel} />
    </div>
  );
};

export default PaymentIndex;

 

- Recat Portal을 이용하여 원하는 값을 모달창에 출력할 수 있음

 

// index.html
...
<div id='modal'><div/> 
...

 

 

- components/payment/modal.tsx

import { ReactNode } from "react";
import { createPortal } from "react-dom";

const ModalPortal = ({ children }: { children: ReactNode }) => {
  return createPortal(children, document.getElementById("modal")!);
};

const PaymentModal = ({
  show,
  proceed,
  cancel,
}: {
  show: boolean;
  proceed: () => void;
  cancel: () => void;
}) => {
  return show ? (
    <ModalPortal>
      <div className={`modal ${show ? "show" : ""}`}>
        <div className="modal__inner">
          <p>정말 결제할까요?</p>

          <div>
            <button onClick={proceed}>예</button>
            <button onClick={cancel}>아니오</button>
          </div>
        </div>
      </div>
    </ModalPortal>
  ) : null;
};

export default PaymentModal;

 

- mocks/handlers.ts

// 결제 실행
  graphql.mutation(EXECUTE_PAY, ({ variables: ids }, res, ctx) => {
    ids.forEach((id: string) => {
      delete cartData[id];
    });
    return res(ctx.data(ids));
  }),

 

- 체크된 데이터들은 cartData 에서 삭제

 

- graphql / payment.ts

import { gql } from "graphql-tag";

export const EXECUTE_PAY = gql`
  mutation EXECUTE_PAY($ids: [ID!]) {
    executePay(ids: $ids)
  }
`;

 

 

3일차 끝!!

바로바로 안 쓰니까 너무 힘들다 ㅠㅠ

아직 이해 안 가는 것들도 많지만 어떻게 처음부터 다 완전히 이해하고 할 수 있겠어,, 그럼 내가 천재지 뭐야 😮 이런 마음으로 진행하는 중,,

 

이 프로젝트가 끝나고 좀 더 성장한 사람이 되어있으면 좋겠다!