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일차 끝!!
바로바로 안 쓰니까 너무 힘들다 ㅠㅠ
아직 이해 안 가는 것들도 많지만 어떻게 처음부터 다 완전히 이해하고 할 수 있겠어,, 그럼 내가 천재지 뭐야 😮 이런 마음으로 진행하는 중,,
이 프로젝트가 끝나고 좀 더 성장한 사람이 되어있으면 좋겠다!