diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index df1be6c5f..0eae8eb32 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/package.json b/package.json index f9cfbf059..3b25151bb 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@tanstack/react-router": "^1.22.5", "axios": "^1.6.8", "clsx": "^2.1.0", - "myfirstpackage-payments": "^0.2.0", + "myfirstpackage-payments": "^0.2.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.13", diff --git a/src/assets/arrow-left.png b/src/assets/arrow-left.png new file mode 100644 index 000000000..cadf30611 Binary files /dev/null and b/src/assets/arrow-left.png differ diff --git a/src/components/Carousel/Carousel.tsx b/src/components/Carousel/Carousel.tsx new file mode 100644 index 000000000..d08d13b70 --- /dev/null +++ b/src/components/Carousel/Carousel.tsx @@ -0,0 +1,120 @@ +import { ReactNode } from 'react'; +import ArrowLeft from 'src/assets/arrow-left.png'; +import { css } from '@styled-system/css'; +import { Button } from '../Button'; + +type CarouselProps = { + items: ReactNode[]; + selectedIndex: number; + onSelect: (index: number) => void; +}; + +export const Carousel = ({ items, selectedIndex, onSelect }: CarouselProps) => { + const totalItems = items.length; + const isFirstItem = selectedIndex === 0; + const isLastItem = selectedIndex === totalItems - 1; + + const prev = () => { + const prevIndex = (selectedIndex - 1 + totalItems) % totalItems; + onSelect(prevIndex); + onSelect(prevIndex); + }; + + const next = () => { + const nextIndex = (selectedIndex + 1) % totalItems; + onSelect((selectedIndex + 1) % totalItems); + onSelect(nextIndex); + }; + + const getTranslateX = () => { + const itemWidth = 208; + const itemMargin = 10; + const lastItemIndex = totalItems - 1; + const isLastItem = items.length > 2 && selectedIndex === lastItemIndex; + const translateX = selectedIndex * (itemWidth + itemMargin) - (isLastItem ? 20 : 0); + + return `translateX(calc(-${translateX}px))`; + }; + + return ( +
+ {items.length !== 0 && ( + <> + + + + )} +
+
+ {items.map((item, index) => { + const itemMarginLeft = index === 0 ? 0 : '10px'; + return ( +
+ {item} +
+ ); + })} +
+
+
+ ); +}; + +Carousel.displayName = 'Carousel'; + +const carouselStyle = css({ + position: 'relative', + width: '100%', + height: '130px', +}); + +const carouselItemsStyle = css({ + position: 'absolute', + width: '228px', + top: '0', + left: '50%', + transform: 'translateX(-50%)', + overflow: 'hidden', +}); + +const carouselItemsContainerStyle = css({ + display: 'flex', + transition: 'transform 0.3s', +}); + +const carouselItemStyle = css({ + flexShrink: 0, + width: '208px', +}); diff --git a/src/components/Carousel/index.ts b/src/components/Carousel/index.ts new file mode 100644 index 000000000..586b90053 --- /dev/null +++ b/src/components/Carousel/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './Carousel'; diff --git a/src/components/Cart/CartOrderProduct.spec.tsx b/src/components/Cart/CartOrderProduct.spec.tsx index cc9f2b839..2fc450bc5 100644 --- a/src/components/Cart/CartOrderProduct.spec.tsx +++ b/src/components/Cart/CartOrderProduct.spec.tsx @@ -18,7 +18,12 @@ describe('CartOrderProduct 컴포넌트', () => { const renderCartOrderProduct = () => render( - + , ); diff --git a/src/components/Cart/CartOrderProduct.tsx b/src/components/Cart/CartOrderProduct.tsx index 45c6e9b25..429628b28 100644 --- a/src/components/Cart/CartOrderProduct.tsx +++ b/src/components/Cart/CartOrderProduct.tsx @@ -6,17 +6,20 @@ import { CART_MAX_QUANTITY_VALUE } from '@/constants'; import { useAlert } from '@/hooks'; import { useRemoveProductFromCartMutation } from '@/queries'; import { useCartStore } from '@/store'; -import { Cart } from '@/types'; +import { Product } from '@/types'; import { formatNumberWithCommas } from '@/utils'; type CartProductProps = { - value: Cart; + type?: 'cart' | 'order'; + product: Product; + quantity: number; + checked?: boolean; }; type ProductImageProps = { - processedOrder: boolean; checked?: boolean; imageUrl: string; + visibleCheckbox: boolean; onCheckboxChange: () => void; }; @@ -24,21 +27,21 @@ type ProductInfoProps = { name: string; price: string; quantity: number; - orderProcess: boolean; liked: boolean; productId: number; + visibleActions: boolean; + visibleQuantityCounter: boolean; onDeleteCartProduct: () => void; onQuantityChange: (quantity: number) => void; }; -export const CartOrderProduct = ({ value }: CartProductProps) => { - const { product, quantity, checked } = value; +export const CartOrderProduct = ({ type = 'cart', product, quantity, checked }: CartProductProps) => { const alert = useAlert(); const cartStore = useCartStore(); const removeProductToCartMutation = useRemoveProductFromCartMutation(); const productPrice = `${formatNumberWithCommas(product.price * quantity)}원`; - const processedOrder = checked === undefined; + const visibleExtras = type === 'cart'; const handleCheckboxChange = () => { cartStore.toggleProductCheck(product.id); @@ -63,18 +66,19 @@ export const CartOrderProduct = ({ value }: CartProductProps) => { return (
  • @@ -82,9 +86,9 @@ export const CartOrderProduct = ({ value }: CartProductProps) => { ); }; -const ProductImage = ({ processedOrder, checked, imageUrl, onCheckboxChange }: ProductImageProps) => ( +const ProductImage = ({ visibleCheckbox, checked, imageUrl, onCheckboxChange }: ProductImageProps) => (
    - {!processedOrder ? ( + {visibleCheckbox ? ( ) : null} 주문 상품 이미지 @@ -95,16 +99,17 @@ const ProductInfo = ({ name, price, quantity, - orderProcess, liked, productId, + visibleActions, + visibleQuantityCounter, onDeleteCartProduct, onQuantityChange, }: ProductInfoProps) => (
    {name}
    - {!orderProcess ? ( + {visibleActions ? (
    {price}
    - {orderProcess ? ( - {quantity}개 - ) : ( + {visibleQuantityCounter ? ( onQuantityChange(quantity + 1)} decrement={() => onQuantityChange(quantity - 1)} /> + ) : ( + {quantity}개 )}
    diff --git a/src/components/Cart/CartSummary.tsx b/src/components/Cart/CartSummary.tsx index e998b371f..626c19450 100644 --- a/src/components/Cart/CartSummary.tsx +++ b/src/components/Cart/CartSummary.tsx @@ -1,4 +1,4 @@ -import { memo, PropsWithChildren } from 'react'; +import { memo, MouseEventHandler, PropsWithChildren } from 'react'; import { FaCirclePlus } from 'react-icons/fa6'; import { css } from '@styled-system/css'; import { flex } from '@styled-system/patterns'; @@ -13,6 +13,11 @@ type CartSummaryProps = { addCartProduct?: (product: Product) => void; }; +type CurationProductProps = { + imageUrl: string; + onClick?: MouseEventHandler; +}; + export const CartSummary = memo( ({ children, @@ -42,9 +47,13 @@ export const CartSummary = memo( 함께 담으면 좋을 상품 😎
    - {curationProducts.map((product) => ( - - ))} + {curationProducts.map((product) => { + const addProductToCart = () => { + addCartProduct?.(product); + }; + + return ; + })}
    )} @@ -68,17 +77,9 @@ export const CartSummary = memo( CartSummary.displayName = 'CartSummary'; -const CurationProduct = ({ - product, - addCartProduct, -}: { product: Product } & Pick) => ( -
    - + {paymentDetailString} diff --git a/src/components/Overlay/Alert.tsx b/src/components/Overlay/Alert.tsx index 35dda60bd..86e90fa3f 100644 --- a/src/components/Overlay/Alert.tsx +++ b/src/components/Overlay/Alert.tsx @@ -17,26 +17,26 @@ export const Alert = ({ type, message, confirmText = '확인', cancelText = '취 {message}
    - {type === 'confirm' && ( + {type === 'confirm' ? ( - )} + ) : null} +); + +CardAddDisplay.displayName = 'CardAddDisplay'; + +const cardAddDisplayStyle = css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '208px', + height: '130px', + padding: 0, + backgroundColor: 'gray300', + '&:hover': { + backgroundColor: 'gray300', + }, +}); diff --git a/src/components/Payments/CardDisplay.tsx b/src/components/Payments/CardDisplay.tsx new file mode 100644 index 000000000..028a47c6e --- /dev/null +++ b/src/components/Payments/CardDisplay.tsx @@ -0,0 +1,128 @@ +import { css } from '@styled-system/css'; +import type { CardState } from '../../types'; +import { CardAddDisplay } from './CardAddDisplay'; +import { Button } from '@/components'; +import { splitString } from '@/utils'; + +type CardDisplayProps = { + onClick?: () => void; +} & Omit; + +export const CardDisplay = ({ + cardBrandName, + cardColor = 'blue', + cardNumber, + month, + year, + name, + onClick, +}: CardDisplayProps) => ( + +); + +const CardNumberDisplay = ({ cardNumber }: { cardNumber: CardDisplayProps['cardNumber'] }) => { + const splitCardNumber = splitString(cardNumber, 4); + return ( +
    + {splitCardNumber.map((number, index) => ( + + {number} + + ))} +
    + ); +}; + +const CardOwnerDisplay = ({ + ownerName, + expirationMonth, + expirationYear, +}: { + ownerName: string; + expirationMonth: string; + expirationYear: string; +}) => ( +
    + {ownerName} +
    + {expirationMonth} + + / + + {expirationYear} +
    +
    +); + +const cardDisplayStyle = css({ + color: 'gray600', + backgroundColor: 'color', + borderRadius: '5px', + padding: '10px 14px', + width: '208px', + height: '130px', + fontSize: '14px', +}); + +const cardChipStyle = css({ + backgroundColor: 'mustard', + borderRadius: '4px', + width: '40px', + height: '26px', + marginTop: '9px', +}); + +const cardNumberStyle = css({ + width: '100%', + paddingLeft: '20px', + display: 'grid', + gridTemplateColumns: 'repeat(4, 1fr)', +}); + +const cardOwnerStyle = css({ + width: '100%', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: '10px', + marginTop: '4px', +}); + +const labelStyle = css({ + height: '24px', +}); + +const typographyStyle = css({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + lineHeight: 1, + color: 'rgba(255,255,255,0.59)', +}); + +CardDisplay.displayName = 'CardDisplay'; + +CardDisplay.Root = CardDisplay; +CardDisplay.Add = CardAddDisplay; diff --git a/src/components/Payments/PaymentCardMaker.tsx b/src/components/Payments/PaymentCardMaker.tsx new file mode 100644 index 000000000..441a0f1c9 --- /dev/null +++ b/src/components/Payments/PaymentCardMaker.tsx @@ -0,0 +1,70 @@ +import { Payments } from 'myfirstpackage-payments'; +import { IoClose } from 'react-icons/io5'; +import { css } from '@styled-system/css'; +import { IconButton } from '@/components'; + +type PaymentCardMakerFormProps = { + opened: boolean; + close: () => void; +}; + +export const PaymentCardMaker = ({ opened, close }: PaymentCardMakerFormProps) => + opened ? ( +
    + } + onClick={close} + /> + +
    + ) : null; + +PaymentCardMaker.displayName = 'PaymentCardMaker'; + +const cardMakerFormStyle = css({ + position: 'absolute', + top: 0, + left: 0, + width: '375px', + height: '700px', + backgroundColor: 'white', + zIndex: 'popover', + padding: '16px 24px', + '& > main': { + width: '100%', + height: '100%', + '& > div': { + width: '100%', + height: '100%', + }, + '& [class*="modal-dimmed"]': { + borderRadius: '0 !important', + '& > ul, & > div': { + borderRadius: '0 !important', + }, + }, + }, + '& h2': { + marginBottom: '32px', + fontSize: '18px !important', + fontWeight: '500', + '& button': { + padding: '0 10px 5px 10px !important', + marginRight: '-8px !important', + }, + }, +}); + +const closeButtonStyle = css({ + position: 'absolute', + top: '10px', + right: '10px', + zIndex: 'popover', +}); + +const closeIconStyle = css({ + width: '24px', + height: '24px', +}); diff --git a/src/components/Payments/PaymentForm.tsx b/src/components/Payments/PaymentForm.tsx new file mode 100644 index 000000000..8599b1dca --- /dev/null +++ b/src/components/Payments/PaymentForm.tsx @@ -0,0 +1,208 @@ +import { useState } from 'react'; +import { css } from '@styled-system/css'; +import { flex } from '@styled-system/patterns'; +import { PaymentsDisplay } from './PaymentsDisplay'; +import { + Button, + CardAddDisplay, + CardDisplay, + Checkbox, + Overlay, + PaymentCardMaker, + Typography, + useCheckbox, +} from '@/components'; +import { Carousel } from '@/components/Carousel/Carousel'; +import { useCardState } from '@/hooks'; +import { CardState, CardStateSchema, PaymentFormState, PaymentResult } from '@/types'; +import { formatNumberWithCommas, maskCardNumber } from '@/utils'; + +type PaymentFormProps = PaymentFormState & { + onClose?: () => void; +}; + +const isValidateCardState = (card: CardState | null): card is CardState => { + if (!card) { + return false; + } + try { + CardStateSchema.parse(card); + return true; + } catch (e) { + console.error('isValidateCardState: ', e); + return false; + } +}; + +export const PaymentForm = ({ orderId, totalPrice, onPaymentCancel, onPaymentComplete, onClose }: PaymentFormProps) => { + const ownerCards = useCardState(); + const agreeCheckbox = useCheckbox(); + const [selectedCardIndex, setSelectedCardIndex] = useState(0); + const [openedCardMaker, setOpenedCardMaker] = useState(false); + + const selectedCard = ownerCards.length === 0 ? null : ownerCards[selectedCardIndex]; + const formattedTotalAmount = `${formatNumberWithCommas(totalPrice)}원`; + + const validCardState = isValidateCardState(selectedCard); + const validPayment = validCardState && agreeCheckbox.checked; + + const openCardMakerForm = () => { + setOpenedCardMaker(true); + }; + + const closeCardMakerForm = () => { + setSelectedCardIndex(0); + setOpenedCardMaker(false); + }; + + const handlePaymentCancel = () => { + const paymentResult: Pick = { + success: false, + orderId, + }; + onPaymentCancel(paymentResult); + onClose?.(); + }; + + const handlePaymentComplete = async () => { + if (!selectedCard) { + return; + } + const cardNumber = maskCardNumber(selectedCard.cardNumber); + const paymentResult: PaymentResult = { + success: true, + orderId, + totalPrice, + cardNumber, + cardBrandName: selectedCard.cardBrandName, + paymentTimestamp: Date.now(), + }; + onPaymentComplete(paymentResult); + onClose?.(); + }; + + const handleCardSelect = (index: number) => { + setSelectedCardIndex(index); + }; + + return ( + + + + + + 결제하기 + + + +
    + ), + , + ]} + selectedIndex={selectedCardIndex} + onSelect={handleCardSelect} + /> +
    +
    + + 결제금액 + +
    +
    + + 총 결제금액 + + + {formattedTotalAmount} + +
    +
    + + 약관 이용 및 동의 + +
    +
    + + 주문내역을 확인하였으며, 결제를 진행합니다. + + +
    +
    + +
    + + +
    +
    +
    +
    + ); +}; + +PaymentForm.displayName = 'PaymentForm'; + +const paymentAmountStyle = css({ + borderBottom: '1px solid #ddd', + paddingBottom: '8px', + marginTop: '32px', +}); + +const paymentAmountTitleStyle = css({ + textAlign: 'left', + margin: '30px 0 20px', + paddingBottom: '10px', +}); + +const totalAmountStyle = flex({ + justifyContent: 'space-between', + alignItems: 'center', +}); + +const totalAmountLabelStyle = css({ + textAlign: 'left', + fontWeight: 400, + margin: 0, +}); + +const totalAmountValueStyle = css({ + textAlign: 'left', + fontWeight: 400, + margin: 0, +}); + +const agreementStyle = css({ + borderBottom: '1px solid #ddd', + paddingBottom: '8px', + marginTop: '32px', +}); + +const agreementTitleStyle = css({ + textAlign: 'left', + margin: '30px 0 20px', + paddingBottom: '10px', +}); + +const agreementCheckboxStyle = flex({ + justifyContent: 'space-between', + alignItems: 'center', +}); + +const agreementTextStyle = css({ + textAlign: 'left', + margin: 0, +}); + +const buttonContainerStyle = flex({ + gap: '10px', + width: '100%', +}); + +const buttonStyle = css({ + width: '100%', +}); diff --git a/src/components/Payments/PaymentReceipt.tsx b/src/components/Payments/PaymentReceipt.tsx new file mode 100644 index 000000000..2afcff79c --- /dev/null +++ b/src/components/Payments/PaymentReceipt.tsx @@ -0,0 +1,183 @@ +import { css } from '@styled-system/css'; +import { flex } from '@styled-system/patterns'; +import { PaymentsDisplay } from './PaymentsDisplay'; +import { Button, Divider, Overlay, Typography } from '@/components'; +import { Order, OrderSchema } from '@/types'; +import { formatNumberWithCommas } from '@/utils'; +import { formatTimestamp } from '@/utils/formatTimestamp.ts'; + +type PaymentReceiptProps = { + order: Order; + onClose?: () => void; +}; + +type PaymentInfoProps = { + paymentTime: string; + orderId: number; + cardNumber: string; +}; + +type ProductItemProps = { + name: string; + quantity: number; + totalPrice: string; +}; + +const PaymentInfo = ({ paymentTime, orderId, cardNumber }: PaymentInfoProps) => ( +
    + + {`주문 일시: ${paymentTime}`} + + + {`주문 번호: ${orderId}`} + + + {`결제 카드: ${cardNumber}`} + +
    +); + +const ProductItem = ({ name, quantity, totalPrice }: ProductItemProps) => ( +
    +
    + + {name} + + + {`${quantity}개`} + +
    +
    + + {`${totalPrice}원`} + +
    +
    +); + +const ProductList = ({ products }: Pick) => ( +
    +
    + {products.map((product) => { + const { id, name, quantity, price } = product; + const totalPrice = formatNumberWithCommas(quantity * price); + + return ; + })} +
    +
    +); + +export const PaymentReceipt = ({ order, onClose }: PaymentReceiptProps) => { + const validOrder = OrderSchema.required().parse(order); + + const paymentTime = formatTimestamp(validOrder.payment.timestamp); + const cardNumber = `[${validOrder.payment.cardBrand}] ${validOrder.payment.cardNumber}`; + + return ( + + + + + 주문 상세 + + + +
    + + + +
    +
    + + +
    + + 총 결제금액 + + + {formatNumberWithCommas(validOrder.totalPrice)}원 + +
    +
    + +
    +
    +
    +
    + ); +}; + +PaymentReceipt.displayName = 'PaymentReceipt'; + +const textAlignLeft = css({ + textAlign: 'left', +}); + +const textAlignRight = css({ + textAlign: 'right', +}); + +const orderInfoStyle = flex({ + flexDirection: 'column', + gap: '6px', + marginTop: '30px', +}); + +const dividerStyle = css({ + width: 'calc(100% + 48px)', + borderWidth: '3px', + borderColor: 'gray200', + marginLeft: '-24px', +}); + +const productListWrapperStyle = css({ + width: 'calc(100% + 48px)', + marginLeft: '-24px', + maxHeight: '380px', + overflowY: 'auto', + padding: '0 24px', +}); + +const productListStyle = flex({ + flexDirection: 'column', + gap: '10px', +}); + +const productItemStyle = css({ + padding: '6px 12px', + borderBottom: '1px solid #ddd', +}); + +const productNameStyle = css({ + maxWidth: '240px', + textAlign: 'left', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + wordBreak: 'break-all', +}); + +const totalAmountStyle = flex({ + justifyContent: 'space-between', + alignItems: 'center', +}); + +const totalAmountLabelStyle = css({ + textAlign: 'left', + fontWeight: 400, + margin: 0, +}); + +const totalAmountValueStyle = css({ + textAlign: 'left', + fontWeight: 400, + margin: 0, +}); + +const buttonWrapperStyle = css({ + marginTop: '20px', + width: '100%', +}); diff --git a/src/components/Payments/PaymentsDisplay.tsx b/src/components/Payments/PaymentsDisplay.tsx new file mode 100644 index 000000000..6a450c626 --- /dev/null +++ b/src/components/Payments/PaymentsDisplay.tsx @@ -0,0 +1,85 @@ +import { PropsWithChildren } from 'react'; +import { css } from '@styled-system/css'; +import { flex } from '@styled-system/patterns'; + +export const PaymentsDisplay = ({ children }: PropsWithChildren) => ( +
    + {children} +
    +); + +const PaymentsDisplayLayout = ({ children }: PropsWithChildren) => ( +
    + {children} +
    +); + +const PaymentsDisplayHeader = ({ children }: PropsWithChildren) => ( +
    + {children} +
    +); + +const PaymentsDisplayBody = ({ children }: PropsWithChildren) => ( +
    +
    + {children} +
    +
    +); + +const PaymentsDisplayFooter = ({ children }: PropsWithChildren) => ( +
    + {children} +
    +); + +PaymentsDisplay.Root = PaymentsDisplay; +PaymentsDisplay.Header = PaymentsDisplayHeader; +PaymentsDisplay.Body = PaymentsDisplayBody; +PaymentsDisplay.Footer = PaymentsDisplayFooter; + +PaymentsDisplay.displayName = 'PaymentsDisplay'; diff --git a/src/components/Payments/index.ts b/src/components/Payments/index.ts new file mode 100644 index 000000000..ed4b7bf1b --- /dev/null +++ b/src/components/Payments/index.ts @@ -0,0 +1,10 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './CardAddDisplay'; +export * from './CardDisplay'; +export * from './PaymentCardMaker'; +export * from './PaymentForm'; +export * from './PaymentReceipt'; +export * from './PaymentsDisplay'; diff --git a/src/components/index.ts b/src/components/index.ts index 93d2e05b1..358843b3b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,6 +3,7 @@ */ export * from './Button/index'; +export * from './Carousel/index'; export * from './Cart/index'; export * from './Checkbox/index'; export * from './Divider/index'; @@ -12,6 +13,7 @@ export * from './Image/index'; export * from './Layout/index'; export * from './OrderHistory/index'; export * from './Overlay/index'; +export * from './Payments/index'; export * from './Product/index'; export * from './ProductDetail/index'; export * from './QuantityCounter/index'; diff --git a/src/constants/localStorageKey.ts b/src/constants/localStorageKey.ts index 843bccf15..27d573103 100644 --- a/src/constants/localStorageKey.ts +++ b/src/constants/localStorageKey.ts @@ -2,3 +2,4 @@ export const LOCAL_STORAGE_CART_KEY = 'carts'; export const LOCAL_STORAGE_ORDER_KEY = 'orders'; export const LOCAL_STORAGE_LIKE_KEY = 'likes'; export const LOCAL_STORAGE_SCROLL_KEY = 'page-scroll-position'; +export const LOCAL_STORAGE_CARD_STATE_KEY = 'card-state'; diff --git a/src/hooks/order/index.ts b/src/hooks/order/index.ts index a8760b383..d810404de 100644 --- a/src/hooks/order/index.ts +++ b/src/hooks/order/index.ts @@ -2,4 +2,5 @@ * @file Automatically generated by barrelsby. */ +export * from './useCardState'; export * from './useSyncOrder'; diff --git a/src/hooks/order/useCardState.ts b/src/hooks/order/useCardState.ts new file mode 100644 index 000000000..a3b006be2 --- /dev/null +++ b/src/hooks/order/useCardState.ts @@ -0,0 +1,42 @@ +import { useCardInfo } from 'myfirstpackage-payments'; +import { useEffect, useState } from 'react'; +import { LOCAL_STORAGE_CARD_STATE_KEY } from '@/constants'; +import { CardState } from '@/types'; +import { localStorageUtil } from '@/utils'; + +type CardLocalStorage = { + [cardNumber: string]: CardState; +}; + +export const useCardState = () => { + const { cardInfo } = useCardInfo(); + const [cardStates, setCardStates] = useState({}); + const ownerCards = Object.values(cardStates).sort((a, b) => b.timestamp - a.timestamp); + + useEffect(() => { + const storedCardStates = localStorageUtil.getItem(LOCAL_STORAGE_CARD_STATE_KEY) || {}; + setCardStates(storedCardStates); + }, []); + + useEffect(() => { + if (cardInfo.cardNo) { + const { cardNumber, cardType, month, name, year } = cardInfo; + const newCardState: CardState = { + cardNumber: `${cardNumber.first}${cardNumber.second}${cardNumber.third}${cardNumber.fourth}`, + cardBrandName: cardType.name ?? '', + cardColor: cardType.theme ?? '', + month, + year, + name, + timestamp: Date.now(), + }; + setCardStates((prevCardStates) => { + const updatedCardStates = { ...prevCardStates, [newCardState.cardNumber]: newCardState }; + localStorageUtil.setItem(LOCAL_STORAGE_CARD_STATE_KEY, updatedCardStates); + return updatedCardStates; + }); + } + }, [cardInfo]); + + return ownerCards; +}; diff --git a/src/pages/CartPage.tsx b/src/pages/CartPage.tsx index a9df0ab27..8f1ad5d6d 100644 --- a/src/pages/CartPage.tsx +++ b/src/pages/CartPage.tsx @@ -120,7 +120,13 @@ const CartList = () => { })} > {cartStore.cartProducts.map((cart) => ( - + ))} ; @@ -28,6 +30,7 @@ const PaymentList = () => { const orderId = Number(id); const alert = useAlert(); + const overlay = useOverlay(); const paymentMutation = usePaymentMutation(); const orderStore = useOrderStore(); @@ -42,19 +45,61 @@ const PaymentList = () => { const totalPrice = orderData?.totalPrice ?? 0; const checkedCartProductImages = orderProducts.map((value) => value.product.imageUrl); - const onPayment = () => { - const payment: Payment = { - cardNumber: '1234-5678-1234-5678', - cardBrand: 'NEXTSTEP', - timestamp: Date.now(), - }; - const order: Order = { + const onPayment = async () => { + const order = { ...OrderSchema.parse(orderData), - payment, }; - paymentMutation.mutate(order, { + + const responsePayment = await new Promise((resolve) => { + overlay.open(({ opened, close }) => + opened ? ( + + + { + resolve(paymentResult); + close(); + }} + onPaymentCancel={(paymentCancel) => { + resolve(paymentCancel); + close(); + }} + /> + + + ) : null, + ); + }); + + const validPayment = (responsePayment: PaymentResult | PaymentCancel): responsePayment is PaymentResult => + responsePayment.success; + + if (!validPayment(responsePayment)) { + const confirmed = await alert.open({ + message: '결제를 취소했습니다.\n다시 시도하시겠습니까?', + confirmText: '예', + cancelText: '아니오', + }); + if (confirmed) { + onPayment(); + } + return; + } + + const requiredOrder = OrderSchema.required().parse({ + ...orderData, + payment: { + cardNumber: responsePayment.cardNumber, + cardBrand: responsePayment.cardBrandName, + timestamp: responsePayment.paymentTimestamp, + }, + }); + + paymentMutation.mutate(requiredOrder, { onSuccess: async () => { - orderStore.setOrder(order); + orderStore.setOrder(requiredOrder); await alert.open({ type: 'alert', message: '결제가 완료되었습니다.', @@ -103,7 +148,13 @@ const PaymentList = () => { })} > {orderProducts.map((cart) => ( - + ))} diff --git a/src/types/card.type.ts b/src/types/card.type.ts new file mode 100644 index 000000000..0e8444618 --- /dev/null +++ b/src/types/card.type.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +export const CardStateSchema = z.object({ + cardNumber: z.string(), + cardBrandName: z.string(), + cardColor: z.string(), + month: z.string(), + year: z.string(), + name: z.string(), + timestamp: z.number(), +}); + +const paymentResultSchema = z.object({ + success: z.boolean(), + orderId: z.number(), + totalPrice: z.number(), + cardNumber: z.string(), + cardBrandName: z.string(), + paymentTimestamp: z.number(), +}); + +const paymentCancelSchema = paymentResultSchema.pick({ + success: true, + orderId: true, +}); + +const paymentFormStateSchema = z.object({ + orderId: z.number(), + totalPrice: z.number(), + onPaymentCancel: z.function().args(paymentCancelSchema).returns(z.void()), + onPaymentComplete: z.function().args(paymentResultSchema).returns(z.void()), +}); + +export type PaymentResult = z.infer; +export type PaymentCancel = z.infer; +export type PaymentFormState = z.infer; + +export type CardState = z.infer; diff --git a/src/types/index.ts b/src/types/index.ts index a3b91ea37..43711a857 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,7 @@ * @file Automatically generated by barrelsby. */ +export * from './card.type'; export * from './cart.type'; export * from './order.type'; export * from './product.type'; diff --git a/src/utils/formatTimestamp.ts b/src/utils/formatTimestamp.ts new file mode 100644 index 000000000..58104d474 --- /dev/null +++ b/src/utils/formatTimestamp.ts @@ -0,0 +1,18 @@ +export const formatTimestamp = (timestamp: number) => { + if (!timestamp) { + return ''; + } + const date = new Date(timestamp); + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + const hour = date.getHours(); + const minute = String(date.getMinutes()).padStart(2, '0'); + + const ampm = hour >= 12 ? '오후' : '오전'; + const hour12 = hour % 12 || 12; + + return `${year}년 ${month}월 ${day}일 ${ampm} ${hour12}:${minute}`; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 227a95ce9..f465ffa59 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,7 +3,10 @@ */ export * from './formatNumberWithCommas'; +export * from './formatTimestamp'; export * from './generateQueryKey'; export * from './generateQueryParams'; export * from './http'; export * from './localStorageUtil'; +export * from './maskCardNumber'; +export * from './splitString'; diff --git a/src/utils/maskCardNumber.ts b/src/utils/maskCardNumber.ts new file mode 100644 index 000000000..742e6bacd --- /dev/null +++ b/src/utils/maskCardNumber.ts @@ -0,0 +1,7 @@ +import { splitString } from '@/utils/splitString.ts'; + +export const maskCardNumber = (cardNumber: string) => { + const splitCardNumber = splitString(cardNumber, 4); + + return splitCardNumber.map((numberModule, index) => (index === 2 || index === 3 ? '****' : numberModule)).join('-'); +}; diff --git a/src/utils/splitString.ts b/src/utils/splitString.ts new file mode 100644 index 000000000..562718500 --- /dev/null +++ b/src/utils/splitString.ts @@ -0,0 +1,7 @@ +export const splitString = (str: string, length: number) => + [...str].reduce((acc, _, index, arr) => { + if (index % length === 0) { + acc.push(arr.slice(index, index + length).join('')); + } + return acc; + }, []); diff --git a/yarn.lock b/yarn.lock index edba6e640..20822f267 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9204,15 +9204,15 @@ __metadata: languageName: node linkType: hard -"myfirstpackage-payments@npm:^0.2.0": - version: 0.2.0 - resolution: "myfirstpackage-payments@npm:0.2.0" +"myfirstpackage-payments@npm:^0.2.2": + version: 0.2.2 + resolution: "myfirstpackage-payments@npm:0.2.2" dependencies: "@xstate/react": "npm:^4.1.0" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" xstate: "npm:^5.9.1" - checksum: 10c0/5379942c517a3ba60dbfd407b81b9b0d709fe379d839c9cb9039bd512a9e631e059adb9e66489bc7910a47103e6d8382a98181025824058954e0aae12d941ebd + checksum: 10c0/bc57d7c802357194afc8e86592c4af202cd2fefe573c98a0afbdee6b05d2c4d6a97e701d5d17c2ac31417a44b0a6a73356534a77d3b90f703a94b8aa2b424bd6 languageName: node linkType: hard @@ -10511,7 +10511,7 @@ __metadata: husky: "npm:^8.0.3" jsdom: "npm:^24.0.0" msw: "npm:^2.2.13" - myfirstpackage-payments: "npm:^0.2.0" + myfirstpackage-payments: "npm:^0.2.2" prettier: "npm:^3.2.5" react: "npm:^18.2.0" react-dom: "npm:^18.2.0"