20241126(화) ▶️ 굿즈샵 상세보기 페이지 - 장바구니 버튼 클릭시 로컬스토리지에 저장하기
장바구니 버튼을 클릭할 때 로컬스토리지에 저장해서 비회원도 장바구니에 담을 수 있도록 구현해 보았다.
장바구니 담을 시 고려할 사항
- 같은 상품 담을 시 최대 수량 5개로 제한, 더 담으려고 할때 toast 창으로 더이상 담을 수 없다고 알림창 띄워줌
- 장바구니 아이콘에 총 수량 표시(header)
먼저 로컬스토리지에 담을 유틸리티 함수를 만든다.
//utils/cartStorage.ts
import type { CartItem } from "@/types";
export const cartStorage = {
getCart: (): CartItem[] => {
if (typeof window === "undefined") return [];
const cart = localStorage.getItem("shopping_cart");
return cart ? JSON.parse(cart) : [];
},
//현재 장바구니에 담긴 특정 상품의 수량을 확인하는 함수
getItemQuantity: (itemId: string): number => {
const currentCart = cartStorage.getCart();
const existingItem = currentCart.find((item) => item.id === itemId);
return existingItem?.quantity || 0;
},
addItem: (item: CartItem) => {
const currentCart = cartStorage.getCart();
const existingItem = currentCart.find(
(cartItem) => cartItem.id === item.id
);
//현재 장바구니에 담긴 수량 + 새로 담을 수량이 5개를 초과하는지 체크
const currentQuantity = existingItem?.quantity || 0;
const newTotalQuantity = currentQuantity + item.quantity;
if (newTotalQuantity > 5) {
throw new Error("최대 구매 가능 수량은 5개입니다.");
}
let newCart;
if (existingItem) {
newCart = currentCart.map((cartItem) =>
cartItem.id === item.id
? { ...cartItem, quantity: newTotalQuantity }
: cartItem
);
} else {
newCart = [...currentCart, item];
}
localStorage.setItem("shopping_cart", JSON.stringify(newCart));
return newCart;
},
};
코드 설명
`cartStorage`를 객체로 만들어서, 장바구니 관련 기능들을 묶어서 관리한다.
장바구니 조회
getCart: (): CartItem[] => {
// 서버사이드에서 실행될 경우 빈 배열 반환
if (typeof window === "undefined") return [];
// localStorage에서 장바구니 데이터 가져오기
const cart = localStorage.getItem("shopping_cart");
return cart ? JSON.parse(cart) : [];
}
로컬스토리지는 CSR일 때 실행되기 때문에 if문으로 SSR에서 실행되면 빈 배열을 반환하도록 체크한다. (하이드레이션 에러 방지)
로컬스토리지에서 `getItem`으로 장바구니에 있는 데이터를 가져온다.
데이터가 있으면 `cart`변수에 담은 내용을 가져오고, 없으면 빈 배열을 반환한다.
로컬스토리지에서 가져온 문자열을 `JSON.parse`로 CartItem[] 배열로 반환한다.
(로컬스토리지는 문자열만 저장할 수 있기 때문에!)
특정 상품의 수량 확인 체크
getItemQuantity: (itemId: string): number => {
const currentCart = cartStorage.getCart(); //CartItem[] 배열을 반환한다!
//특정 상품을 찾는다.
const existingItem = currentCart.find((item) => item.id === itemId);
//반환
return existingItem?.quantity || 0;
}
매개변수로 특정 상품의 id를 받아와서 장바구니 안에 있는 수량을 반환한다.
`currentCart` : cartStorage에 있는 getCart 메서드를 호출해서 현재 장바구니의 데이터를 가져온다.
`existingItem` : find 메서드로 조건에 맞는 아이템을 찾아서 반환하고, 매개변수로 받은 itemId와 같은 상품을 찾는다. 없으면 undefined.
반환하는 return에는 `existingItem`안에 있는 quantity가 있으면 보여주고, 상품이 없으면 0을 반환한다.
상품 추가
addItem: (item: CartItem) => {
const currentCart = cartStorage.getCart();
const existingItem = currentCart.find(
(cartItem) => cartItem.id === item.id
);
//현재 장바구니에 담긴 수량 + 새로 담을 수량이 5개를 초과하는지 체크
const currentQuantity = existingItem?.quantity || 0;
const newTotalQuantity = currentQuantity + item.quantity;
if (newTotalQuantity > 5) {
throw new Error("최대 구매 가능 수량은 5개입니다.");
}
let newCart;
if (existingItem) {
newCart = currentCart.map((cartItem) =>
cartItem.id === item.id
? { ...cartItem, quantity: newTotalQuantity }
: cartItem
);
} else {
newCart = [...currentCart, item];
}
localStorage.setItem("shopping_cart", JSON.stringify(newCart));
return newCart;
},
const currentCart = cartStorage.getCart();
현재 장바구니를 가져온다.
const existingItem = currentCart.find(
(cartItem) => cartItem.id === item.id
);
find 메서드로 현재 장바구니에 있는 item의 id를 찾는다.
const currentQuantity = existingItem?.quantity || 0;
const newTotalQuantity = currentQuantity + item.quantity;
수량을 체크하는 함수이다.
`currentQuantity` : existingItem에 quantity를 가져오고, 없으면 0을 담는다.
`newTotalQuantity` : 현재 로컬스토리지에 있는 수량과 매개변수로 받아온 item의 quantity를 더한 내용을 담는다.
if (newTotalQuantity > 5) {
throw new Error("최대 구매 가능 수량은 5개입니다.");
}
if문으로 수량이 5개를 초과하면 에러를 던지고 함수실행을 중단한다.
// 장바구니 업데이트
let newCart;
if (existingItem) {
// 기존 상품이 있으면 수량만 업데이트
newCart = currentCart.map((cartItem) =>
cartItem.id === item.id
? { ...cartItem, quantity: newTotalQuantity }
: cartItem
);
} else {
// 새 상품이면 배열에 추가!
newCart = [...currentCart, item];
}
새로운 장바구니를 업데이트 해야하기 때문에 `newCart`변수를 생성한다.
map으로 순회하면서 기존 상품이 있으면 `quantity`를 업데이트 하고 나머지는 그대로 유지한다.
만약에 새로운 상품이면 기존 배열에 새 상품을 추가한다.
localStorage.setItem("shopping_cart", JSON.stringify(newCart));
return newCart;
`newCart` 객체는 문자열로 변환해서 로컬스토리지에 저장하고, 업데이트 된 장바구니를 반환한다.
적용할 컴포넌트
//ProductActions.tsx
const ProductActions = ({ product, quantity }: ProductActionsProps) => {
const setCartItems = useSetRecoilState(cartState);
const onClickCart = () => {
try {
//현재 장바구니에 담긴 수량 확인
const currentQuantity = cartStorage.getItemQuantity(product.id);
//추가할 수량과 합쳐서 5개를 초과하는지 체크
if (currentQuantity + quantity > 5) {
toast({
title: `현재 장바구니에 ${currentQuantity}개가 있어 ${quantity}개를 추가할 수 없습니다.`,
description: "최대 구매 가능 수량은 5개입니다.",
});
return;
}
const cartItem: CartItem = {
...product,
quantity,
};
const updatedCart = cartStorage.addItem(cartItem);
setCartItems(updatedCart);
setIsDrawerOpen(true);
} catch (error) {
if (error instanceof Error) {
toast({
title: error.message,
});
}
}
};
return (
<>
{/* 중략 */}
<ActionButton
onClick={onClickCart}
variant="outline"
className="w-full py-3 text-center"
>
장바구니
</ActionButton>
</>
);
};
export default ProductActions;
부모 컴포넌트에서 상품과 현재 수량을 props로 받아와서 장바구니 버튼을 클릭을 할때 액션을 취한다.
const onClickCart = () => {
try {
//현재 장바구니에 담긴 수량 확인
const currentQuantity = cartStorage.getItemQuantity(product.id);
//추가할 수량과 합쳐서 5개를 초과하는지 체크
if (currentQuantity + quantity > 5) {
toast({
title: `현재 장바구니에 ${currentQuantity}개가 있어 ${quantity}개를 추가할 수 없습니다.`,
description: "최대 구매 가능 수량은 5개입니다.",
});
return;
}
const cartItem: CartItem = {
...product,
quantity,
};
const updatedCart = cartStorage.addItem(cartItem);
setCartItems(updatedCart);
setIsDrawerOpen(true);
} catch (error) {
if (error instanceof Error) {
toast({
title: error.message,
});
}
}
};
const currentQuantity = cartStorage.getItemQuantity(product.id);
현재 장바구니에 담긴 수량을 확인하기 위해 `cartStorage`함수에 있는 특정 상품 수량을 확인하는 함수에 id를 보내줘서 체크하고, `currentQuantity` 변수에 담는다.
//추가할 수량과 합쳐서 5개를 초과하는지 체크
if (currentQuantity + quantity > 5) {
toast({
title: `현재 장바구니에 ${currentQuantity}개가 있어 ${quantity}개를 추가할 수 없습니다.`,
description: "최대 구매 가능 수량은 5개입니다.",
});
return;
}
로컬스토리지에 있는 수량과 추가할 수량을 체크해서 5개 이상이면 toast로 알림창을 띄운다.
const cartItem: CartItem = {
...product,
quantity,
};
이제 장바구니에 담을 상품 객체를 만든다.
기존 상품 `내용` + `수량`을 합쳐야 하기 때문에
기존 상품 정보를 스프레드 연산자로 복사하고 선택된 수량을 추가 해준다.
`quantity`는 숏핸드 프로퍼티로 줄여주었다
const updatedCart = cartStorage.addItem(cartItem);
setCartItems(updatedCart); //Recoil state 업데이트(header)에 장바구니 업데이트
setIsDrawerOpen(true); //모달창 열기(서랍)
장바구니를 업데이트하기 위해서 로컬스토리지에 상품을 추가하고 업데이트 된 상품을 반환한다.
장바구니 수량 카운트(header)
장바구니에 담고 현재 담은 내용을 장바구니 아이콘에 카운팅 하기위해서 전역스테이트인 `Recoil`에 담았다.
export const cartState = atom({
key: "cartState",
default: cartStorage.getCart(),
});
`default`는 초기값으로 로컬스토리지의 장바구니 데이터를 가져온다.
적용할 컴포넌트
//CartIcon.tsx
const CartIcon = ({
iconSize = 24,
iconClassName = "",
linkClassName = "",
className = "",
}: CartIconProps): JSX.Element => {
const [mounted, setMounted] = useState(false);
const [cartItems] = useRecoilState(cartState);
const totalQuantity = cartItems.reduce(
(sum, item) => sum + (item.quantity || 0),
0
);
useEffect(() => {
setMounted(true);
}, []);
return (
<Link href="/cart" className={linkClassName}>
<div className="relative">
<IoCartOutline
className={`${iconClassName} cursor-pointer`}
size={iconSize}
/>
{mounted && totalQuantity > 0 && (
<span
className={`${className} absolute bottom-3 rounded-full bg-rose-500 text-xs text-white w-5 h-5 flex items-center justify-center`}
>
{totalQuantity}
</span>
)}
</div>
</Link>
);
};
export default CartIcon;
const CartIcon = ({
iconSize = 24, //기본 아이콘 크기
iconClassName = "", //기본 아이콘 클래스
linkClassName = "", //기본 링크 클래스
className = "", //기본 클래스
}: CartIconProps): JSX.Element => {
PC와 모바일 버전에서 재사용되는 컴포넌트여서, props로 커스터마이징이 가능하도록 설계하였다.
JSX.Element 타입 지정이 필요한 이유
SSR(서버 사이드 렌더링)과정
- 초기 프리렌더링때 로컬스토리지에 접근이 불가하기 때문이다.
- 이 시점에서 컴포넌트가 무엇을 반환하지? 를 모르고 있었다.
CSR(클라이언트 사이드 렌더링)과정
- 하이드레이션이 끝나고, 컴포넌트를 마운트 한다.
- 마운트가 끝나야 로컬스토리지에 있는 데이터에 접근이 가능했다.
- 그 뒤로 JSX를 렌더링한다.
→ 결국 프리렌더링 과정으로 타입에러가 발생하기 때문에 JSX를 반환할거야! 라고 명시해 주어야 한다.
타입스크립트에서 반환타입을 정확하게 알려주고, JSX 관련 타입 체크를 명시적으로 해주었다.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
로컬스토리지는 브라우저 기능이기 때문에 useEffect로 컴포넌트가 마운트 되면 내용을 불러올 수 있도록 상태관리를 추가하였다.
const totalQuantity = cartItems.reduce(
(sum, item) => sum + (item.quantity || 0),
0
);
`reduce` 메서드를 사용해서 배열을 순회하면서 누적값과 현재값을 더한다.
만약에 item에 수량이 없으면 `||` 연산자로 0을 반환한다.
{mounted && totalQuantity > 0 && (
<span
className={`${className} absolute bottom-3 rounded-full bg-rose-500 text-xs text-white w-5 h-5 flex items-center justify-center`}
>
{totalQuantity}
</span>
)}
`mounted`가 true 고 `totalQuanity`가 1부터 시작하면(즉, 장바구니에 내용이 있으면) 카운팅한 갯수를 보여준다.
적용 결과