장바구니 상태관리(recoil)
선택된 상품에 대한 상태관리를 하기 위해 checkedCartState를 recoil로 관리한다.
▼ src/recoils/cart.ts
export const checkedCartState = atom<Cart[]>({
key: "cartState",
default: [],
});
});
결제창 연결하기
Pick
- 기존 타입에서 원하는 속성만 선택하여 새로운 타입을 생성하는 역할을 한다.
- Pick을 사용하면 기존 타입에서 필요한 일부 속성만 선택하여 새로운 타입을 만들 수 있다.
Pick<Cart, "imageUrl" | "title" | "price">
itemData 컴포넌트 분리하기
▼ src/components/cart/itemData.tsx
const ItemData = ({
imageUrl,
title,
price,
}: Pick<Cart, "imageUrl" | "title" | "price">) => {
return (
<div>
<img className="cart-item_img" src={imageUrl} />
<p className="cart-item_title">{title}</p>
<p className="cart-item_price">{price}원</p>
</div>
);
};
결제예정 컴포넌트(WillPay) 추가하기
- 장바구니 창에서 상품을 선택하면(체크박스 체크) 결제예정 컴포넌트에 추가된다. (map)
- 선택된 상품들은 checkedCartState(recoil)에서 받아온다.
- 결제할 상품이 있으면 payment창으로 이동한다.
- 장바구니 페이지에 WillPay 컴포넌트를 추가한다.
▼ src/components/cart/willPay.tsx
const WillPay = () => {
const navigate = useNavigate();
const checkedItems = useRecoilValue(checkedCartState);
const totalPrice = checkedItems.reduce((res, { price, amount }) => {
res += price * amount;
return res;
}, 0);
const handleSubmit = () => {
if (checkedItems.length) {
navigate("/payment");
} else {
alert("결제 할 상품이 없어요.");
}
};
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>
<span>총 예상결제액 : {totalPrice}원 </span>
<button onClick={handleSubmit}>결제하기</button>
</div>
);
};
상품 선택 핸들링하기
- 상품의 수량을 변경하거나, 상품을 체크하거나, 삭제할 때 아래 WillPay 컴포넌트에 즉각 반영되지 않는 이슈가 발생한다.
- items가 변경되거나 현재 선택된 상품(formData)가 변경되면 다시 로드하기 위해 빈 배열에 체크된 상품들을 push하여 checkedCartData를 업데이트(setCheckedCartData)한다.
▼ src/components/cart/index.tsx
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
const checkedItems = checkboxRefs.reduce<Cart[]>((res, ref, i) => {
if (ref.current!.checked) res.push(items[i]);
return res;
}, []);
setCheckedCartData(checkedItems);
}, [items, formData]);
- 장바구니 페이지에서 상품을 선택한 후 다른 페이지에 이동했다가 다시 장바구니로 가면 선택된 체크박스들이 체크해제되는 이슈가 발생한다. (체크박스의 속성)
- setAllcheckedFromItems 함수는 개별 항목이 선택되어있을 때 모두 선택이 되어있다면 상단의 전체선택 체크박스가 체크되도록하는 함수이다. (기존 코드에서 리팩토링)
const [formData, setFormData] = useState<FormData>();
const setAllcheckedFromItems = () => {
if (!formRef.current) return;
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;
};
- 장바구니 페이지가 새로 렌더링될 때 기존에 체크되었던 항목들의 체크가 유지도록되기 위해 useEffect를 사용한다.
- checkedCartData로 부터 어떤 상품들이 선택되어있는지 확인하여 실제 체크박스에 체크가 반영되도록 한다.
- 체크박스가 모두 선택되었을 때 전체선택 체크박스도 체크되게 하기 위해 setAllcheckedFromItems() 함수를 호출한다.
useEffect(() => { checkedCartData.forEach((item) => { const itemRef = checkboxRefs.find( (ref) => ref.current!.dataset.id === item.id ); if (itemRef) itemRef.current!.checked = true; }); setAllcheckedFromItems(); }, []);
- 기존의 코드에서 setItemsCheckedFromAll 함수를 분리하였다.
- 전체선택 체크박스가 체크되었을 경우 모든 체크박스를 선택(체크)한다.
const setItemsCheckedFromAll = (targetInput: HTMLInputElement) => { const allchecked = targetInput.checked; checkboxRefs.forEach((inputElem) => { inputElem.current!.checked = allchecked; }); };
- 함수들을 분리하여 리팩토링한 후 handleCheckboxChanged 함수도 수정해주었다.
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();
}
const data = new FormData(formRef.current);
setFormData(data);
};
결제페이지 모달 추가
Portals - createProtal
https://ko.legacy.reactjs.org/docs/portals.html
ReactDOM.createPortal(child, container)
- React의 createPortal은 React 애플리케이션에서 DOM의 다른 부분에 컴포넌트를 렌더링하는 기능을 제공하는 메소드이다.
- createPortal은 이러한 문제를 해결하기 위해 도입된 메소드이다. 이 메소드를 사용하면 컴포넌트를 부모 컴포넌트의 DOM 트리의 외부로 렌더링할 수 있다.
ReactNode
- ReactNode는 React에서 사용되는 타입 중 하나로, 컴포넌트가 렌더링할 수 있는 모든 종류의 데이터를 나타냅니다. 이는 JSX 구문에서 컴포넌트의 자식 요소로 전달되는 모든 값을 포함한다.
Modal 컴포넌트 정의하기
최상단 index.html의 body에 id가 modal인 div를 추가한다. 이 곳에 Portal을 통해 모달 컴포넌트가 렌더링된다.
▼ index.html
<body>
<div id="root"></div>
<div id="modal"></div>
</body>
modal 컴포넌트에 childrun이 ReactNode이고, id가 modal인(위에서 정의) 요소를 createPortal하여 리턴하는 컴포넌트 ModalPortal을 정의한다.
▼ src/components/payment/modal.tsx
const ModalPortal = ({ children }: { children: ReactNode }) => {
return createPortal(children, document.getElementById("modal")!);
};
- 모달의 show 여부를 props로 전달받아 true일 때만 모달 화면이 보이게 한다.
- 위에서 정의한 ModalPortal 컴포넌트를 호출하고, 해당 컴포넌트로 감싼 부분이 modal 화면에 들어가게 된다.
- 예/ 아니오 버튼을 각각 클릭했을 때 수행될 함수도 props로 전달받는다.결제 쿼리 작성
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> ) ); };
- 결제할 상품들의 아이디를 전달한다.
▼ src/graphql/payment.ts
export const EXECUTE_PAY = gql`
mutation EXCUTE_PAY($info: [string]) {
payInfo(info: $info)
}
- 결제할 상품들은 현재 장바구니에서 삭제된다.
결제 쿼리 핸들러함수 작성
▼ src/mocks/handlers.ts
graphql.mutation(EXECUTE_PAY, ({ variables: ids }, res, ctx) => {
ids.forEach((id: string) => {
delete cartData[id];
});
return res(ctx.data(ids));
}),
결제 컴포넌트 작성(모달 호출)
- 결제창에서는 현재 결제할 상품들 목록을 보여주고 결제하기 버튼을 클릭하면 결제 모달창을 띄운다.
- 모달창에서 예를 클릭하면 수행될 함수 proceed는 현재 체크된 상품들(결제할 상품들)의 id값들을 payInfos에 저장하고, 위에서 정의한 쿼리 EXECUTE_PAY에 전달한다.
- 결제가 완료되었다는 알림창을 띄우고, 상품 목록 페이지로 이동한다.
type PaymentInfos = string[];
const Payment = () => {
const { mutate: executePay } = useMutation((payInfos: PaymentInfos) =>
graphqlFetcher(EXECUTE_PAY, payInfos)
);
const proceed = () => {
const payInfos = checkedCartData.map(({ id }) => id);
executePay(payInfos);
setCheckedCartData([]);
alert("결제가 완료되었습니다.");
navigate("/products", { replace: true });
};