지금은 주석 파티가 한창이라 버튼을 눌러도 이벤트가 발생되지 않는 상태입니다.
지난 상품 리스트 컴포넌트 리팩토링 시 작성했던 장바구니 관련 함수를 사용해서 리팩토링을 진행해보겠습니다.
현재는 위와 같은 UI로 되어있으나 리팩토링 시 오늘의 집 장바구니 UI를 참고하여 수정할 예정입니다.
개인적으로 오늘의 집 장바구니가모바일과 PC 화면에 맞춰 반응형 구성이 잘 되어있다는 생각이듭니다.
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;
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;
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;
지금은 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;
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;
Object.keys
메서드를 사용해 Props
로 전달받은 checkBoxes
의 key
를 배열로 변환하는 코드가 반복되고 있네요.
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;
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;
환골탈태란 이런 걸 뜻하는 게 아닐까요?
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
page
파일은 스타일링 관련 코드가 추가되어 꽤나 산만해지긴 했습니다.
장바구니 아이템의 개수가 0개일 때는 ProductListNavigator
컴포넌트를 표시하게 했습니다.
ProductListNavigator
은 컴포넌트 이름 그대로 상품 리스트(/product/all/1
)로 유도하는 버튼이 포함된 컴포넌트입니다.
CartList
는 장바구니 리스트를 렌더링해 주는 컴포넌트입니다.
CartSummary
는 장바구니에서 선택된 아이템들의 결제금액을 요약해서 보여주는 컴포넌트입니다.
PurchaseButton
은 결제하기 버튼이 포함된 컴포넌트로 선택된 아이템들의 개수를 같이 나타내 줍니다.
cartItems를 각 자식 컴포넌트에서 가져올지 page.tsx
에서 가져온 후 props로 내려줄지 많은 고민을 했습니다. 처음에는 page.tsx
에서 가져온 후 props로 내려주는 코드로 작성을 했습니다만 이러면 Redux Store를 왜 사용하는 것인가 라는 생각이 들었습니다. 그래서 각 자식 컴포넌트에서 가져오는 것으로 힘들게 코드를 변경했는데요... 렌더링이 많이 발생하는 문제가 발생하더군요...🥲
그래서 각 자식 컴포넌트에서 해당 코드들을 모두 지우고 page.tsx
에서 가져온 후 props로 내려주는 코드를 다시 작성해야 했습니다.
시간이 너무 오래걸렸다는 슬픈 이야기
리팩토링 전 코드에 있었던 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;
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;
CartList
에는 CartListHeader
와 CartItems
가 포함되어 있습니다.
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
기능 구현은 나중에 진행 예정입니다.
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;
코드가 길어지다보니 장바구니와 위시리스트 코드를 분리할까 생각 중에 있습니다.
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;
처음에 (데이터, 컴포넌트)구조를 잘 잡고 가야 된다는 걸 절실히 느낀 리팩토링 작업이었습니다.
이번 리팩토링은 구조 수정에 시간을 더 많이 쓴 거 같습니다.
작은 프로젝트여도 이렇게 번거롭고 시간이 많이 드는데, 큰 프로젝트에서 이런 수정이 생긴다면 참 곤란할 거 같네요.
새로 기획하는 것도 좋지만 리팩토링이 조금 더 즐거운 거 같습니다.
다른 개인 프로젝트를 기획해야 하는데, 리팩토링하느라 시간 가는 줄 몰랐습니다..
ㅠㅠ.. 이제 진짜 다른 개인 프로젝트에 집중하러 가야겠습니다