[리팩토링] Next.js 쇼핑몰 프로젝트 - 장바구니 컴포넌트

YouGyoung·2024년 4월 4일
0

기존 코드

지금은 주석 파티가 한창이라 버튼을 눌러도 이벤트가 발생되지 않는 상태입니다.
지난 상품 리스트 컴포넌트 리팩토링 시 작성했던 장바구니 관련 함수를 사용해서 리팩토링을 진행해보겠습니다.

UI 수정 예정 (오늘의 집 장바구니)

현재는 위와 같은 UI로 되어있으나 리팩토링 시 오늘의 집 장바구니 UI를 참고하여 수정할 예정입니다.
개인적으로 오늘의 집 장바구니가모바일과 PC 화면에 맞춰 반응형 구성이 잘 되어있다는 생각이듭니다.

src/app/cart/page.tsx

import React from 'react';
import CartList from './components/CartList';

const Cart: React.FC = () => {
  return (
    <>
      <div className="mt-14 flex flex-col justify-center w-full items-center mb-8">
        <h2>장바구니</h2>
      </div>
      <CartList />
    </>
  );
};

export default Cart;

src/app/cart/components/CartList.tsx

CartList 컴포넌트에서는 장바구니 아이템들에 대한 체크박스 체크 여부 상태 checkBoxes와 전체 선택 체크 박스의 체크 여부 상태 checkAllBoxes를 가지고 있습니다.
Summary 컴포넌트에서 checkBoxes 상태를 기준으로 결제 금액이 구해지기 때문에 이렇게 구성을 했었습니다.

'use client';
import { useAppSelector } from '@/hooks/useAppSelector';
import { CartItems, CheckBoxes } from '@/types/globalTypes';
import { useEffect, useMemo, useState } from 'react';
import CartItem from './CartItem';
import SelectedDeleteButton from './SelectedDeleteButton';
import Summary from './Summary';
import PurchaseButton from './PurchaseButton';

const CartList: React.FC = () => {
  const [checkBoxes, setCheckBoxes] = useState<{ [key: string]: boolean }>({});
  const [checkAllBoxes, setCheckAllBoxes] = useState<boolean>(true);
  const cartItems: CartItems = useAppSelector((state) => state.cart.cartItems);
  const cartItemKeys = useMemo(() => Object.keys(cartItems), [cartItems]);

  useEffect(() => {
    const initialCheckBoxes: { [key: string]: boolean } = {};
    Object.keys(cartItems).forEach((key) => {
      initialCheckBoxes[key] = true;
    });
    setCheckBoxes(initialCheckBoxes);
    setCheckAllBoxes(true);
  }, [cartItems]);

  const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, checked } = e.target;
    let newCheckBoxes: CheckBoxes = {};
    if (name === 'all') {
      cartItemKeys.forEach((key) => {
        newCheckBoxes[key] = checked;
      });
      setCheckAllBoxes(checked);
    } else {
      newCheckBoxes = {
        ...checkBoxes,
        [name]: checked,
      };
      const allChecked = cartItemKeys.every((key) => newCheckBoxes[key]);
      setCheckAllBoxes(allChecked);
    }
    setCheckBoxes(newCheckBoxes);
  };

  return (
    <>
      <ul className="flex flex-col">
        <li className="my-4   sm-max-textsize-12 hidden md:flex">
          <input
            type="checkbox"
            className="mr-4"
            onChange={handleCheckboxChange}
            name="all"
            checked={checkAllBoxes}
            disabled={cartItemKeys.length === 0}
          />
          <div className="w-2/12 py-8 mx-4 flex items-center">
            <p className="whitespace-pre-line=true">제품</p>
          </div>
          <div className="w-full flex items-center">
            <p className="w-1/5 whitespace-pre-line=true flex items-center md:w-1/4"></p>
            <p className=" w-1/5 whitespace-pre-line=true  flex justify-end  items-center md:w-1/4">
              가격
            </p>
            <p className=" w-1/5 whitespace-pre-line=true  flex justify-end  items-center md:w-1/4">
              수량
            </p>
            <p className=" w-1/5 whitespace-pre-line=true  flex justify-end  items-center md:w-1/4 ">
              합계
            </p>
            <p className=" w-1/5 whitespace-pre-line=true  flex justify-end  items-center md:w-1/4"></p>
          </div>
        </li>

        {cartItemKeys.length !== 0 ? (
          cartItemKeys.map((key) => {
            return (
              <CartItem
                handleCheckboxChange={handleCheckboxChange}
                itemKey={key}
                key={key}
                checkBoxes={checkBoxes}
                setCheckAllBoxes={setCheckAllBoxes}
                setCheckBoxes={setCheckBoxes}
              />
            );
          })
        ) : (
          <p className="text-center p-14 ">장바구니가 비어 있습니다.</p>
        )}
      </ul>
      <SelectedDeleteButton
        setCheckAllBoxes={setCheckAllBoxes}
        setCheckBoxes={setCheckBoxes}
        checkBoxes={checkBoxes}
        cartItemKeys={cartItemKeys}
      />
      <div className="flex flex-col  items-end w-full text-sm">
        <Summary checkBoxes={checkBoxes} />
        <PurchaseButton
          setCheckAllBoxes={setCheckAllBoxes}
          setCheckBoxes={setCheckBoxes}
          checkBoxes={checkBoxes}
        />
      </div>
    </>
  );
};

export default CartList;

src/app/cart/components/CartItem.tsx

import { CartItems, CheckBoxes } from '@/types/globalTypes';
import Image from 'next/image';
import Link from 'next/link';
import QtyAdjustButton from './QtyAdjustButton';
import DeleteCartItem from './DeleteCartItem';
import { useAppSelector } from '@/hooks/useAppSelector';

interface CartItemProps {
  handleCheckboxChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  itemKey: string;
  checkBoxes: CheckBoxes;

  setCheckAllBoxes: (newCheckAllBox: boolean) => void;
  setCheckBoxes: React.Dispatch<React.SetStateAction<CheckBoxes>>;
}

const CartItem: React.FC<CartItemProps> = ({
  handleCheckboxChange,
  itemKey,
  checkBoxes,

  setCheckAllBoxes,
  setCheckBoxes,
}) => {
  const cartItems: CartItems = useAppSelector((state) => state.cart.cartItems);
  return (
    <li className="my-4 items-center flex flex-row mb-8">
      <input
        name={itemKey}
        type="checkbox"
        className="mr-4 flex"
        onChange={handleCheckboxChange}
        checked={checkBoxes[itemKey] || false}
      />
      <div className="relative flex items-start h-44 md:h-24 w-6/12 md:w-2/12 mx-4 bg-white">
        <Link
          href={`/product/detail/${cartItems[itemKey].product.id}`}
          className=" h-full w-full relative"
        >
          <Image
            src={cartItems[itemKey].product.image}
            alt={cartItems[itemKey].product.title}
            sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
            fill
            style={{ objectFit: 'contain', minWidth: '56px' }}
          />
        </Link>
      </div>
      <div className="w-full flex sm-max-textsize-12 flex-col md:flex-row">
        <div className="w-full md:w-1/5 whitespace-pre-line=true flex items-center md:w-1/4 overflow-hidden">
          <Link
            href={`/product/detail/${cartItems[itemKey].product.id}`}
            className="min-w-16 max-h-24 "
          >
            {cartItems[itemKey].product.title}
          </Link>
        </div>
        <div className="mt-4 md:mt-0 w-full md:w-1/5 whitespace-pre-line=true flex justify-end items-center md:w-1/4">
          <p aria-label="제품 가격">${cartItems[itemKey].product.price}</p>
        </div>
        <div className="w-full md:w-1/5 whitespace-pre-line=true flex justify-end items-center md:w-1/4">
          <QtyAdjustButton
            isIncrease={false}
            itemKey={itemKey}
            setCheckAllBoxes={setCheckAllBoxes}
          />
          <input
            className="p-0 w-6 bg-transparent border-0 text-gray-800 text-center focus:ring-0 dark:text-white"
            type="text"
            value={cartItems[itemKey].count}
            data-hs-input-number-input
            readOnly
            aria-live="assertive"
          />
          <QtyAdjustButton
            isIncrease={true}
            itemKey={itemKey}
            setCheckAllBoxes={setCheckAllBoxes}
          />
        </div>
        <div className="w-full md:w-1/5 whitespace-pre-line=true flex justify-end items-center md:w-1/4">
          <p aria-label="제품 수량 합계 금액">
            $
            {(
              cartItems[itemKey].product.price * cartItems[itemKey].count
            ).toFixed(2)}
          </p>
        </div>
        <div className="w-full md:w-1/5 whitespace-pre-line=true flex justify-end items-center md:w-1/4">
          <DeleteCartItem
            itemKey={itemKey}
            setCheckAllBoxes={setCheckAllBoxes}
            setCheckBoxes={setCheckBoxes}
          />
        </div>
      </div>
    </li>
  );
};

export default CartItem;

src/app/cart/components/QtyAdjustButton.tsx

지금은 QtyAdjustButton에서 props로 받은 isIncrease 상태 기준으로 수량 감소 및 수량 추가 버튼을 렌더링하고 있습니다.

중복 코드를 없애려고 이렇게 구현을 한 건데, 유지보수성 향상을 위해 분리할 생각입니다.

import { db } from '@/app/firebaseConfig';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';

import { CartItems } from '@/types/globalTypes';
import { setCartItemsLocalStorage } from '../../../utils/localstorage';
import { doc, updateDoc } from 'firebase/firestore';
import { useContext } from 'react';

interface QtyAdjustButtonProps {
  isIncrease: boolean;
  itemKey: string;

  setCheckAllBoxes: (newCheckAllBoxes: boolean) => void;
}

const QtyAdjustButton: React.FC<QtyAdjustButtonProps> = ({
  isIncrease,
  itemKey,

  setCheckAllBoxes,
}) => {
  const dispatch = useAppDispatch();
  const currentUser = null;
  const cartItems: CartItems = useAppSelector((state) => state.cart.cartItems);
  const changeQty = async () => {
    setCheckAllBoxes(true);
    // const currentCount = cartItems[itemKey]?.count;
    // const newCount = isIncrease
    //   ? currentCount + 1
    //   : Math.max(currentCount - 1, 0);

    // if (currentCount === newCount) return;

    // const newItem = {
    //   [itemKey]: {
    //     product: cartItems[itemKey].product,
    //     count: newCount,
    //   },
    // };
    // const newCartItems: CartItems = { ...cartItems, ...newItem };

    // if (currentUser?.email) {
    //   const userRef = doc(db, 'users', currentUser.email);
    //   await updateDoc(userRef, { cartItems: newCartItems });
    // } else {
    //   setCartItemsLocalStorage(newCartItems);
    // }
    // dispatch(setCartItems(newCartItems));
  };

  return (
    <button
      type="button"
      className="p-2"
      onClick={changeQty}
      aria-controls="number"
      aria-label={isIncrease ? '수량 1개 증가' : '수량 1개 감소'}
    >
      {isIncrease ? '+' : '-'}
    </button>
  );
};
export default QtyAdjustButton;

src/app/cart/components/SelectedDeleteButton.tsx

import { db } from '@/app/firebaseConfig';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { CartItems, CheckBoxes } from '@/types/globalTypes';
import { AppDispatch } from '@/types/reduxTypes';
import {
  deleteCartItemsLocalStorage,
  getCartItemsLocalStorage,
} from '../../../utils/localstorage';
import { doc, updateDoc } from 'firebase/firestore';
import { useContext } from 'react';

interface SelectedDeleteButtonProps {
  setCheckAllBoxes: (value: boolean) => void;
  setCheckBoxes: React.Dispatch<React.SetStateAction<CheckBoxes>>;
  checkBoxes: CheckBoxes;
  cartItemKeys: string[];
}
const SelectedDeleteButton: React.FC<SelectedDeleteButtonProps> = ({
  setCheckAllBoxes,
  setCheckBoxes,
  checkBoxes,
  cartItemKeys,
}) => {
  const dispatch: AppDispatch = useAppDispatch();
  const currentUser = null;
  const cartItems: CartItems = useAppSelector((state) => state.cart.cartItems);
  const deleteSelectedItems = (e: React.MouseEvent<HTMLButtonElement>) => {
    let newItems = { ...cartItems };
    const keys: string[] = Object.keys(checkBoxes).filter(
      (key) => checkBoxes[key]
    );
    if (currentUser) {
      let newCartItems: CartItems = {
        ...cartItems,
      };
      keys.forEach((key) => {
        delete newCartItems[key];
      });
      newItems = newCartItems;
      let userRef = null;
      // if (currentUser?.email) userRef = doc(db, 'users', currentUser?.email);
      // if (userRef)
      //   updateDoc(userRef, {
      //     cartItems: newCartItems,
      //   });

      setCheckAllBoxes(true);

      //  dispatch(setCartItems(newItems));
    } else {
      deleteCartItemsLocalStorage(keys);
      newItems = getCartItemsLocalStorage();
      //  dispatch(setCartItems(newItems));

      setCheckAllBoxes(true);
    }
  };

  return (
    <button
      name="deleteMany"
      onClick={deleteSelectedItems}
      className="mt-4 bg-zinc-900 dark:hover:bg-zinc-200 dark:bg-white dark:disabled:bg-zinc-400 py-2 px-4 text-white dark:text-black text-xs rounded hover:bg-zinc-700 transition disabled:bg-zinc-400"
      disabled={cartItemKeys.length === 0}
      aria-label="선택한 장바구니 아이템 삭제하기"
    >
      선택 삭제
    </button>
  );
};

export default SelectedDeleteButton;

src/app/cart/components/Summary.tsx

Object.keys 메서드를 사용해 Props로 전달받은 checkBoxeskey를 배열로 변환하는 코드가 반복되고 있네요.

import { useAppSelector } from '@/hooks/useAppSelector';
import { CartItems, CheckBoxes } from '@/types/globalTypes';

interface SummaryProps {
  checkBoxes: CheckBoxes;
}
const Summary: React.FC<SummaryProps> = ({ checkBoxes }) => {
  const cartItems: CartItems = useAppSelector((state) => state.cart.cartItems);
  return (
    <>
      <div className="flex">
        <p className="text-right ">합계: </p>
        <p className="text-right  pl-2">
          $
          {Object.keys(checkBoxes)
            .filter((key: any) => checkBoxes[key] === true)
            .reduce((prev, key) => {
              return (
                prev + cartItems[key]?.product?.price * cartItems[key]?.count
              );
            }, 0)
            .toFixed(2)}
        </p>
      </div>
      <div className=" flex mt-2">
        <p className="text-right ">VAT: </p>
        <p className="text-right pl-2">
          $
          {(
            Object.keys(checkBoxes)
              .filter((key: any) => checkBoxes[key] === true)
              .reduce((prev, key) => {
                return (
                  prev + cartItems[key]?.product?.price * cartItems[key]?.count
                );
              }, 0) * 0.1
          ).toFixed(2)}
        </p>
      </div>
      <div className=" mt-2 flex">
        <p className="text-right">총 합계: </p>
        <p className="text-right pl-2">
          $
          {(
            Object.keys(checkBoxes)
              .filter((key: any) => checkBoxes[key] === true)
              .reduce((prev, key) => {
                return (
                  prev + cartItems[key]?.product.price * cartItems[key]?.count
                );
              }, 0) * 1.1
          ).toFixed(2)}
        </p>
      </div>
    </>
  );
};

export default Summary;

src/app/cart/components/PurchaseButton.tsx

PurchaseButton 컴포넌트에는 아직 완성하지 못한 구매하기 로직이 주석처리되어 포함되어 있습니다.
가독성 향상을 위해 구매하기 관련 로직은 일단 모두 제거 후 추후 기능 추가 시점에 별도 모듈화 할 예정입니다.

import { db } from '@/app/firebaseConfig';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';

import { CartItems, CheckBoxes } from '@/types/globalTypes';
import {
  deleteCartItemsLocalStorage,
  getCartItemsLocalStorage,
} from '../../../utils/localstorage';
import { doc, updateDoc } from 'firebase/firestore';
import { useContext } from 'react';
interface PurchaseButtonProps {
  setCheckAllBoxes: (value: boolean) => void;
  setCheckBoxes: React.Dispatch<React.SetStateAction<CheckBoxes>>;
  checkBoxes: CheckBoxes;
}
const PurchaseButton: React.FC<PurchaseButtonProps> = ({
  setCheckAllBoxes,

  checkBoxes,
}) => {
  const currentUser = null;
  // const cartItems = useAppSelector((state) => state.product.cartItems);
  // const purchaseList = useAppSelector((state) => state.product.purchaseList);
  const dispatch = useAppDispatch();
  const purchase = () => {
    if (!currentUser) {
      // deleteCartItemsLocalStorage(keys);
      // const newItems = getCartItemsLocalStorage();
      // dispatch(setCartItems(newItems));
      // let checkBoxesData: { [key: string]: boolean } = {};
      // Object.keys(newItems).forEach((key) => {
      //   checkBoxesData[key] = true;
      // });
      // setCheckAllBoxes(true);
      alert('로그인이 필요한 서비스입니다.');
      return;
    }
    // const today = new Date();
    // const year = today.getFullYear();
    // const month = ('0' + (today.getMonth() + 1)).slice(-2);
    // const day = ('0' + today.getDate()).slice(-2);
    // const dateString = year + '-' + month + '-' + day;

    const keys: string[] = Object.keys(checkBoxes).filter(
      (key) => checkBoxes[key]
    );

    //let purchases: any = {};
    // keys.forEach((key) => (purchases[key] = { ...cartItems[key] }));

    // let newCartItems: CartItems = {
    //   ...cartItems,
    // };

    // keys.forEach((key) => {
    //   delete newCartItems[key];
    // });

    // let userRef = null;
    // if (currentUser?.email) userRef = doc(db, 'users', currentUser?.email);
    // if (userRef)
    //   updateDoc(userRef, {
    //     cartItems: { ...newCartItems },
    //     purchaseList: {
    //       ...purchaseList,

    //       [today.getMilliseconds() + Math.random() * 1000]: {
    //         date: dateString,
    //         products: purchases,
    //       },
    //     },
    //   });
    //dispatch(setCartItems({ ...newCartItems }));
    setCheckAllBoxes(true);
    alert('구매가 완료되었습니다. (구매 내역은 개발 중입니다...)');
  };

  return (
    <div className="flex mt-8 ">
      <button
        onClick={purchase}
        className="w-full bg-zinc-900 dark:hover:bg-zinc-200 dark:bg-white dark:disabled:bg-zinc-400 py-2 px-8 text-white dark:text-black  rounded hover:bg-zinc-700 transition disabled:bg-zinc-400"
        aria-label="선택한 장바구니 아이템 구매하기"
      >
        구매하기
      </button>
    </div>
  );
};

export default PurchaseButton;

리팩토링 완료된 UI

변경 전

PC 화면

모바일 화면

비어있는 장바구니

변경 후

환골탈태란 이런 걸 뜻하는 게 아닐까요?

PC 화면

CartSummary 를 오른쪽에 고정시켜 사용자가 총 결제금액을 항상 확인할 수 있도록 하여 편의성을 향상시켰습니다.

장바구니 페이지에서 뿐만 아니라 다른 페이지에서도 항목과 배경색 모두 흰색이라 콘텐츠 부분과 배경 부분 구분이 안 되는 문제가 있어 배경색을 추가했습니다.

금액 표시의 경우 달러에서 원화로 변경을 진행했습니다.

배송비는 결제 금액이 10만원 미만 시 3,000원이 추가되도록 했습니다.


+리스트의 헤더가 고정되도록 수정됐습니다.

모바일 화면

2개 열에서 1개 열로 변화를 주어 모바일 UX를 향상 시켰습니다.

+리스트의 헤더가 고정되도록 수정됐습니다.

비어있는 장바구니

버튼의 경우 Daisy UI의 버튼 Class를 사용했습니다.

수량 변경

수량 변경의 경우 10,000 이상의 숫자가 입력 값으로 들어오면 9,999로 설정되도록 했으며, 0이하의 숫자가 입력 값으로 들어오면 1로 설정되도록 했습니다.

리팩토링 완료된 코드

파일 트리 구조

컴포넌트 분리를 통해 많은 파일이 생겨났습니다.
복잡해 보이지만, 장기적인 관점에서 볼 때 버그를 찾고 수정하기가 쉬워지는 장점이 있습니다.

src/
├── app/
│   └── cart/
│       ├── components/
│       │   ├── CartList.tsx
│       │   ├── CartSummary.tsx
│       │   ├── ProductListNavigator.tsx
│       │   ├── PurchaseButton.tsx
│       │   └── cartList/
│       │       ├── CartItems.tsx
│       │       ├── CartListHeader.tsx
│       │       ├── cartListHeader/
│       │       │   ├── DeleteSelectedButton.tsx
│       │       │   └── ToggleAllSelectionButton.tsx
│       │       └── cartItems/
│       │           ├── CartItemDetails.tsx
│       │           ├── cartItemDetails/
│       │           │   └── quantityAdjuster/
│       │           │       └── QuantityAdjusterModalContents.tsx
│       │           │   ├── QuantityAdjuster.tsx
│       │           ├── RemoveButton.tsx
│       │           └── ToggleSelectButton.tsx
│       └── page.tsx
├── hooks/
│   └── useStore.ts
└── utils/
    └── calculateTotal.ts

src/app/cart/page.tsx

page 파일은 스타일링 관련 코드가 추가되어 꽤나 산만해지긴 했습니다.

장바구니 아이템의 개수가 0개일 때는 ProductListNavigator 컴포넌트를 표시하게 했습니다.
ProductListNavigator은 컴포넌트 이름 그대로 상품 리스트(/product/all/1)로 유도하는 버튼이 포함된 컴포넌트입니다.

CartList는 장바구니 리스트를 렌더링해 주는 컴포넌트입니다.
CartSummary는 장바구니에서 선택된 아이템들의 결제금액을 요약해서 보여주는 컴포넌트입니다.
PurchaseButton은 결제하기 버튼이 포함된 컴포넌트로 선택된 아이템들의 개수를 같이 나타내 줍니다.

cartItems 관련

cartItems를 각 자식 컴포넌트에서 가져올지 page.tsx에서 가져온 후 props로 내려줄지 많은 고민을 했습니다. 처음에는 page.tsx에서 가져온 후 props로 내려주는 코드로 작성을 했습니다만 이러면 Redux Store를 왜 사용하는 것인가 라는 생각이 들었습니다. 그래서 각 자식 컴포넌트에서 가져오는 것으로 힘들게 코드를 변경했는데요... 렌더링이 많이 발생하는 문제가 발생하더군요...🥲

그래서 각 자식 컴포넌트에서 해당 코드들을 모두 지우고 page.tsx에서 가져온 후 props로 내려주는 코드를 다시 작성해야 했습니다.

시간이 너무 오래걸렸다는 슬픈 이야기

checkBoxes, checkAllBoxes 관련

리팩토링 전 코드에 있었던 checkBoxes 상태는 제거되었습니다.
리팩토링 시작부터 제거가 되었던 건 아니었습니다.
리팩토링이 거의 마무리될 무렵 테스트를 했더니 Cart 컴포넌트가 재렌더링될 때 체크박스가 모두 true로 초기화되는 문제가 확인되었습니다.

문제 해결을 위해 Firestore에 저장되는 carItems 데이터 구조를 변경해야 했습니다.
원래는 제품 정보와 수량만 저장이 됐었는데, 여기에 isChecked를 추가하여 체크 유무를 유지하도록 했습니다.

checkAllBoxes 상태는 ToggleAllSelectionButton 컴포넌트에서 관리되도록 위치를 변경했습니다.

'use client';
import React from 'react';
import CartSummary from './components/CartSummary';
import PurchaseButton from './components/PurchaseButton';
import CartList from './components/CartList';
import useStore from '@/hooks/useStore';
import ProductListNavigator from './components/ProductListNavigator';

const Cart: React.FC = () => {
  const { cartItems } = useStore();

  return (
    <>
      <h2 className="visually-hidden">장바구니</h2>
      <div className="flex flex-col md:flex-row md:justify-between">
        {Object.keys(cartItems).length === 0 ? (
          <div className="w-full flex justify-center mt-16">
            <ProductListNavigator />
          </div>
        ) : (
          <>
            <div className="md:w-7/12">
              <CartList cartItems={cartItems} />
            </div>
            <div className="md:w-4/12 ">
              <div className="md:sticky md:top-24">
                <CartSummary cartItems={cartItems} />
                <PurchaseButton cartItemCount={Object.keys(cartItems).length} />
              </div>
            </div>
          </>
        )}
      </div>
    </>
  );
};

export default Cart;

src/app/cart/components/ProductListNavigator.tsx

import useRouterPush from '@/hooks/useRouterPush';

const ProductListNavigator: React.FC = () => {
  const { navigateToSelectedProductListPage } = useRouterPush();
  return (
    <button
      className="btn btn-md bg-zinc-200"
      type="button"
      onClick={() => navigateToSelectedProductListPage('all', 1)}
    >
      상품 보러가기
    </button>
  );
};

export default ProductListNavigator;

src/app/cart/components/CartList.tsx

CartList에는 CartListHeaderCartItems가 포함되어 있습니다.

CartListHeader는 전체 선택 체크박스와 선택 삭제 버튼을 렌더링하는 컴포넌트입니다.
CartItems는 장바구니 아이템을 렌더링 하는 컴포넌트입니다.

import { CartProps } from '@/types/globalTypes';
import CartItems from './cartList/CartItems';
import CartListHeader from './cartList/CartListHeader';

const CartList: React.FC<CartProps> = ({ cartItems }) => {
  return (
    <>
      <div className="flex items-center justify-between mb-4">
        <CartListHeader cartItems={cartItems} />
      </div>
      <ul className="flex flex-col">
        <CartItems cartItems={cartItems} />
      </ul>
    </>
  );
};

export default CartList;

src/app/cart/components/cartList/CartListHeader.tsx

import { CartProps } from '@/types/globalTypes';
import DeleteSelectedButton from './cartListHeader/DeleteSelectedButton';
import ToggleAllSelectionButton from './cartListHeader/ToggleAllSelectionButton';

const CartListHeader: React.FC<CartProps> = ({ cartItems }) => {
  return (
    <>
      <ToggleAllSelectionButton cartItems={cartItems} />
      <DeleteSelectedButton cartItems={cartItems} />
    </>
  );
};

export default CartListHeader;

src/app/cart/components/cartList/tableHeader/ToggleAllSelectionButton.tsx

import useStore from '@/hooks/useStore';
import { CartProps } from '@/types/globalTypes';
import { useEffect, useState } from 'react';

const ToggleAllSelectionButton: React.FC<CartProps> = ({ cartItems }) => {
  const [isAllSelected, setIsAllSelected] = useState<boolean>(true);
  const { toggleAllChecked } = useStore();

  useEffect(() => {
    if (
      Object.keys(cartItems).filter((itemId) => cartItems[itemId].isChecked)
        .length === Object.keys(cartItems).length
    )
      setIsAllSelected(true);
    else setIsAllSelected(false);
  }, [cartItems]);

  return (
    <label className="flex items-center">
      <input
        type="checkbox"
        checked={isAllSelected}
        onChange={() => {
          toggleAllChecked(cartItems, !isAllSelected);
          setIsAllSelected(!isAllSelected);
        }}
      />
      <span className="ml-2 text-sm">전체 선택</span>
    </label>
  );
};

export default ToggleAllSelectionButton;

src/app/cart/components/cartList/tableHeader/DeleteSelectedButton.tsx

import useStore from '@/hooks/useStore';
import { CartProps } from '@/types/globalTypes';

const DeleteSelectedButton: React.FC<CartProps> = ({ cartItems }) => {
  const { removeCartItems } = useStore();
  return (
    <button
      type="button"
      className="text-zinc-600 dark:text-zinc-400  hover:text-zinc-400 dark:hover:text-zinc-200 text-xs"
      onClick={() => {
        removeCartItems(
          Object.keys(cartItems).filter((itemId) => cartItems[itemId].isChecked)
        );
      }}
    >
      선택 삭제
    </button>
  );
};

export default DeleteSelectedButton;

src/app/cart/components/cartList/CartItems.tsx

CartItems 대신 CartItem으로 만들어 props로 받은 cartItem 정보를 렌더링할지 고민했었습니다만 일단 지금 구성을 유지하기로 했습니다.

각 아이템은 체크 박스, 상품 정보, 삭제 버튼을 포함하고 있습니다.

import ToggleSelectButton from './cartItems/ToggleSelectButton';
import CartItemDetails from './cartItems/CartItemDetails';
import RemoveButton from './cartItems/RemoveButton';
import { CartProps } from '@/types/globalTypes';

const CartItems: React.FC<CartProps> = ({ cartItems }) => {
  return (
    <>
      {[...Object.keys(cartItems)].map((itemId) => (
        <li
          key={itemId}
          className="shadow bg-zinc-50 dark:bg-zinc-950 mb-6 rounded flex flex-row py-6 px-4"
        >
          <div className="mr-4">
            <ToggleSelectButton
              itemId={itemId}
              cartItems={cartItems}
              isChecked={cartItems[itemId].isChecked}
            />
          </div>
          <div className="w-full">
            <CartItemDetails
              product={cartItems[itemId].product}
              count={cartItems[itemId].count}
              isChecked={cartItems[itemId].isChecked}
            />
          </div>
          <div>
            <RemoveButton itemId={itemId} />
          </div>
        </li>
      ))}
    </>
  );
};

export default CartItems;

src/app/cart/components/cartList/cartItems/ToggleSelectButton.tsx

import useStore from '@/hooks/useStore';
import { CartItemProps, CartItems } from '@/types/globalTypes';

const ToggleSelectButton: React.FC<CartItemProps> = ({
  cartItems,
  itemId,
  isChecked = false,
}) => {
  const { toggleChecked } = useStore();
  return (
    <input
      type="checkbox"
      checked={isChecked}
      onChange={() => {
        toggleChecked(cartItems, itemId);
      }}
    ></input>
  );
};

export default ToggleSelectButton;

src/app/cart/components/cartList/cartItems/CartItemDetails.tsx

import { Product } from '@/types/globalTypes';
import QuantityAdjuster from './cartItemDetails/QuantityAdjuster';
import Image from 'next/image';

interface CartItemDetailsProps {
  product: Product;
  count: number;
  isChecked: boolean;
}
const CartItemDetails: React.FC<CartItemDetailsProps> = ({
  product,
  count,
  isChecked,
}) => {
  return (
    <div>
      <div className="mb-4 flex items-center ">
        <div className="relative w-20 h-20 bg-white rounded mr-4">
          <Image
            src={product.image}
            alt={product.title}
            style={{ padding: '6px', objectFit: 'contain' }}
            sizes="150px"
            fill
          />
        </div>
        <div>
          <p className="text-sm">{product.title}</p>
        </div>
      </div>

      <div className="flex items-center justify-between	w-full">
        <div className="flex dark:text-black">
          <QuantityAdjuster
            product={product}
            count={count}
            isChecked={isChecked}
          />
        </div>
        <div>
          <p aria-label="price" className="font-semibold mr-6">
            {(product.price * 1000 * count).toLocaleString()}원
          </p>
        </div>
      </div>
    </div>
  );
};

export default CartItemDetails;

src/app/cart/components/cartList/cartItems/cartItemDetails/QuantityAdjuster.tsx

QuantityAdjuster는 버튼으로 수량을 변경할 수 있는 기능과 입력으로 수량을 변경할 수 있는 기능을 포함하고 있습니다.

import Modal from '@/app/components/Modal';
import useStore from '@/hooks/useStore';
import { Product } from '@/types/globalTypes';
import { toggleModal } from '@/utils/modal';
import { useState } from 'react';
import QuantityAdjusterModalContents from './quantityAdjuster/QuantityAdjusterModalContents';

interface QuantityAdjusterProps {
  product: Product;
  count: number;
  isChecked: boolean;
}
const QuantityAdjuster: React.FC<QuantityAdjusterProps> = ({
  product,
  count,
  isChecked,
}) => {
  const { incrementQuantity, decrementQuantity } = useStore();
  const [isModalOpen, setIsModalOpen] = useState<boolean>(false);

  return (
    <>
      <span className="h-8 bg-white flex rounded border">
        <button
          className="w-6 hover:bg-zinc-100 disabled:"
          type="button"
          onClick={() => decrementQuantity(product, count, isChecked)}
          disabled={count === 1}
        >
          -
        </button>
        <button
          type="button"
          className="w-12 hover:bg-zinc-100	"
          onClick={() => setIsModalOpen(!isModalOpen)}
        >
          {count}
        </button>
        <button
          className="w-6 hover:bg-zinc-100"
          type="button"
          onClick={() => incrementQuantity(product, count, isChecked)}
          disabled={count === 9999}
        >
          +
        </button>
      </span>
      <Modal
        isModalOpen={isModalOpen}
        toggleModal={() => toggleModal(isModalOpen, setIsModalOpen)}
      >
        <QuantityAdjusterModalContents
          product={product}
          count={count}
          isChecked={isChecked}
          toggleModal={() => toggleModal(isModalOpen, setIsModalOpen)}
        />
      </Modal>
    </>
  );
};

export default QuantityAdjuster;

src/app/cart/components/cartList/cartItems/cartItemDetails/quantityAdjuster/QuantityAdjusterModalContents.tsx

import useStore from '@/hooks/useStore';
import { ModalContentsProps, Product } from '@/types/globalTypes';
import { useEffect, useState } from 'react';

interface QuantityAdjusterModalContentsProps extends ModalContentsProps {
  product: Product;
  count: number;
  isChecked: boolean;
}
const QuantityAdjusterModalContents: React.FC<
  QuantityAdjusterModalContentsProps
> = ({ toggleModal, product, count, isChecked }) => {
  const [countInput, setCountInput] = useState<number>(0);
  useEffect(() => {
    setCountInput(count);
  }, [count]);
  const { changeQuantity } = useStore();
  return (
    <>
      <div
        className="absolute dark:bg-black flex flex-col modal-center shadow-md items-center pt-8 z-30  w-96 "
        aria-modal="true"
        role="alertdialog"
      >
        <p className=" mb-4">상품 수량을 입력해 주세요.</p>
        <div className="w-full">
          <input
            type="number"
            value={countInput}
            onChange={(e) => {
              if (Number(e.target.value) < 1 || !e.target.value) {
                setCountInput(Number(1));
                return;
              }
              if (Number(e.target.value) > 9999) {
                setCountInput(Number(9999));
                return;
              }
              setCountInput(Number(e.target.value));
            }}
            className="px-4   h-14 bg-gray-50  dark:text-black border-gray-200 border mb-6 outline-none w-full "
          />
        </div>
        <div className="flex flex-row w-full">
          <button
            type="button"
            aria-label="취소"
            name="continueShopping"
            onClick={() => {
              toggleModal();
              setCountInput(count);
            }}
            className="w-1/2 bg-zinc-50  hover:bg-zinc-100 border text-black dark:hover:bg-zinc-200 dark:bg-white dark:disabled:bg-zinc-400 py-2 px-4 t dark:text-black  rounded  transition disabled:bg-zinc-400 mr-2"
          >
            취소
          </button>
          <button
            type="button"
            aria-label="변경"
            name="continueShopping"
            onClick={() => {
              changeQuantity(product, countInput, isChecked);
              toggleModal();
            }}
            className="w-1/2 bg-zinc-900 dark:hover:bg-zinc-200 dark:bg-white dark:disabled:bg-zinc-400 py-2 px-4 text-white dark:text-black  rounded hover:bg-zinc-700 transition disabled:bg-zinc-400"
          >
            변경
          </button>
        </div>
      </div>
    </>
  );
};
export default QuantityAdjusterModalContents;

src/app/cart/components/cartList/cartItems/RemoveButton.tsx

import useStore from '@/hooks/useStore';
import { IoCloseSharp } from 'react-icons/io5';
interface RemoveButtonProps {
  itemId: string | number;
}
const RemoveButton: React.FC<RemoveButtonProps> = ({ itemId }) => {
  const { removeCartItem } = useStore();
  return (
    <>
      <button type="button" onClick={() => removeCartItem(itemId)}>
        <IoCloseSharp />
      </button>
    </>
  );
};

export default RemoveButton;

src/app/cart/components/CartSummary.tsx

CartSummary는 장바구니 결제 금액을 요약해서 보여줍니다.

import { CartProps } from '@/types/globalTypes';
import calculateTotal from '@/utils/calculateTotal';
import { useEffect, useState } from 'react';

const CartSummary: React.FC<CartProps> = ({ cartItems }) => {
  const [total, setTotal] = useState<number>(0);

  useEffect(() => {
    const newTotal = calculateTotal(cartItems);
    setTotal(newTotal);
  }, [cartItems]);

  return (
    <>
      <div className="shadow bg-zinc-50 dark:bg-zinc-950 mb-6 rounded flex flex-row py-6 px-4 ">
        <div className="w-full text-sm">
          <div className="flex justify-between ">
            <p>총 상품금액</p>
            <p className="font-semibold	">{total.toLocaleString()}원</p>
          </div>
          <div className="flex justify-between mt-2 mt-4">
            <p>배송비</p>
            <p className="font-semibold	">
              + {total < 100000 && total !== 0 ? '3,000' : 0}원
            </p>
          </div>
          <div className="mb-4 mt-2">
            <p className="text-xs text-right">
              (10만원 이상 구매 시 배송비 무료)
            </p>
          </div>
          <div className="flex justify-between border-t pt-4">
            <p className="font-semibold	">결제금액</p>
            <p className="font-semibold	text-xl">
              {(total + (total < 100000 ? 3000 : 0)).toLocaleString()}원
            </p>
          </div>
        </div>
      </div>
    </>
  );
};

export default CartSummary;

src/app/cart/components/PurchaseButton.tsx

기능 구현은 나중에 진행 예정입니다.

interface PurchaseButtonProps {
  cartItemCount: number;
}

const PurchaseButton1: React.FC<PurchaseButtonProps> = ({ cartItemCount }) => {
  return (
    <>
      <div>
        <button
          className="text-center w-full rounded bg-zinc-900 hover:bg-zinc-700 text-white py-4 disabled:bg-zinc-500 	   "
          disabled={cartItemCount === 0}
        >
          {cartItemCount}개 상품 구매하기
        </button>
      </div>
    </>
  );
};

export default PurchaseButton1;

src/hooks/useStore.ts

코드가 길어지다보니 장바구니와 위시리스트 코드를 분리할까 생각 중에 있습니다.

import { resetCartItems, setCartItems } from '@/slices/cartSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { CartItems, Product, WishlistItems } from '@/types/globalTypes';
import { resetWishlistItems, setWishlistItems } from '@/slices/wishListSlice';
import getUserCartItems from '@/_utils/getUserCartItems';
import getUserWishlistItems from '@/_utils/getUserWishlistItems';
import getCartItemsLocalStorage from '@/_utils/getCartItemsLocalStorage';
import { useAppSelector } from './useAppSelector';
import checkItemExistsById from '@/utils/checkItemExistsById';
import { useSession } from 'next-auth/react';
import setCartItemsFireStore from '@/_utils/setCartItemsFireStore';
import setWishlistItemsFireStore from '@/_utils/setWishlistItemsFireStore';
import setCartItemsLocalStorage from '@/_utils/setCartItemsLocalStorage copy';

const useStore = () => {
  const { data: session, status } = useSession();
  const dispatch = useAppDispatch();
  const cartItems = useAppSelector((state) => state.cart.cartItems);
  const wishlistItems = useAppSelector((state) => state.wishlist.wishlistItems);

  const resetStore = () => {
    dispatch(resetCartItems());
    dispatch(resetWishlistItems());
  };

  const initializeUserStore = async (email: string) => {
    const cartItems: CartItems = await getUserCartItems(email);
    const wishlistItems: WishlistItems = await getUserWishlistItems(email);
    dispatch(setCartItems(cartItems));
    dispatch(setWishlistItems(wishlistItems));
  };

  const initializeStore = async () => {
    const cartItems: CartItems = getCartItemsLocalStorage();
    dispatch(setCartItems(cartItems));
  };

  const toggleCartItem = (product: Product) => {
    if (checkItemExistsById(product.id, cartItems)) {
      removeCartItem(product.id);
    } else {
      addCartItem(product);
    }
  };

  const updateCartItems = (cartItems: CartItems) => {
    dispatch(setCartItems(cartItems));
    if (status === 'unauthenticated') {
      setCartItemsLocalStorage(cartItems);
    } else {
      if (session?.user.email)
        setCartItemsFireStore(cartItems, session?.user.email);
    }
  };

  const addCartItem = (product: Product) => {
    const newCartItems: CartItems = {
      ...cartItems,
      [product.id]: { product: product, count: 1, isChecked: true },
    };
    updateCartItems(newCartItems);
  };

  const removeCartItems = (itemIds: string[] | number[]) => {
    let newCartItems = { ...cartItems };
    itemIds.forEach((itemId) => {
      delete newCartItems[Number(itemId)];
    });
    updateCartItems(newCartItems);
  };

  const removeCartItem = (itemId: string | number) => {
    const { [itemId]: removeItem, ...newCartItems } = cartItems;
    updateCartItems(newCartItems);
  };

  const incrementQuantity = (
    product: Product,
    count: number,
    isChecked: boolean
  ) => {
    if (count + 1 >= 10000) return;
    const newCartItem = {
      product: product,
      count: count + 1,
      isChecked: isChecked,
    };
    const newCartItems = { ...cartItems, [product.id]: newCartItem };
    updateCartItems(newCartItems);
  };

  const decrementQuantity = (
    product: Product,
    count: number,
    isChecked: boolean
  ) => {
    if (count - 1 < 1) return;
    const newCartItem = {
      product: product,
      count: count - 1,
      isChecked: isChecked,
    };
    const newCartItems = { ...cartItems, [product.id]: newCartItem };
    updateCartItems(newCartItems);
  };

  const changeQuantity = (
    product: Product,
    newCount: number,
    isChecked: boolean
  ) => {
    const newCartItem = {
      product: product,
      count: newCount,
      isChecked: isChecked,
    };
    const newCartItems = { ...cartItems, [product.id]: newCartItem };
    updateCartItems(newCartItems);
  };

  const toggleAllChecked = (cartItems: CartItems, isChecked: boolean) => {
    const newCartItems = JSON.parse(JSON.stringify(cartItems));
    Object.keys(newCartItems).forEach((itemId) => {
      newCartItems[itemId].isChecked = isChecked;
    });
    updateCartItems(newCartItems);
  };

  const toggleChecked = (cartItems: CartItems, itemId: number | string) => {
    const newCartItem = {
      product: cartItems[itemId].product,
      count: cartItems[itemId].count,
      isChecked: !cartItems[itemId].isChecked,
    };
    const newCartItems = { ...cartItems, [itemId]: newCartItem };

    updateCartItems(newCartItems);
  };

  const toggleWishlistItems = (product: Product) => {
    if (checkItemExistsById(product.id, wishlistItems)) {
      removeWishlistItem(product);
    } else {
      addWishlistItem(product);
    }
  };

  const addWishlistItem = (product: Product) => {
    const newWishlistItems: WishlistItems = {
      ...wishlistItems,
      [product.id]: product,
    };
    dispatch(setWishlistItems(newWishlistItems));
    if (session?.user.email)
      setWishlistItemsFireStore(newWishlistItems, session?.user.email);
  };

  const removeWishlistItem = (product: Product) => {
    const { [product.id]: removeItem, ...newWishlistItems } = wishlistItems;
    dispatch(setWishlistItems(newWishlistItems));
    if (session?.user.email)
      setWishlistItemsFireStore(newWishlistItems, session?.user.email);
  };

  return {
    resetStore,
    initializeUserStore,
    initializeStore,
    cartItems,
    wishlistItems,
    toggleCartItem,
    toggleWishlistItems,
    incrementQuantity,
    decrementQuantity,
    changeQuantity,
    removeCartItem,
    removeCartItems,
    toggleAllChecked,
    toggleChecked,
  };
};

export default useStore;

느낀 점

처음에 (데이터, 컴포넌트)구조를 잘 잡고 가야 된다는 걸 절실히 느낀 리팩토링 작업이었습니다.

이번 리팩토링은 구조 수정에 시간을 더 많이 쓴 거 같습니다.

작은 프로젝트여도 이렇게 번거롭고 시간이 많이 드는데, 큰 프로젝트에서 이런 수정이 생긴다면 참 곤란할 거 같네요.

마치며

새로 기획하는 것도 좋지만 리팩토링이 조금 더 즐거운 거 같습니다.
다른 개인 프로젝트를 기획해야 하는데, 리팩토링하느라 시간 가는 줄 몰랐습니다..

ㅠㅠ.. 이제 진짜 다른 개인 프로젝트에 집중하러 가야겠습니다

profile
프론트엔드 개발자

0개의 댓글

관련 채용 정보