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' ? (
{
- resolve(false);
close();
+ resolve(false);
}}
>
{cancelText}
- )}
+ ) : null}
{
- resolve(true);
close();
+ resolve(true);
}}
>
{confirmText}
diff --git a/src/components/Payments/CardAddDisplay.tsx b/src/components/Payments/CardAddDisplay.tsx
new file mode 100644
index 000000000..c686aec63
--- /dev/null
+++ b/src/components/Payments/CardAddDisplay.tsx
@@ -0,0 +1,32 @@
+import { css } from '@styled-system/css';
+import { Button } from '@/components';
+
+type CardAddDisplayProps = {
+ onClick?: () => void;
+};
+
+const plusIconStyle = css({
+ fontSize: '36px',
+ color: 'gray600',
+});
+
+export const CardAddDisplay = ({ onClick }: CardAddDisplayProps) => (
+
+ +
+
+);
+
+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;
+}) => (
+
+);
+
+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) => (
+
+);
+
+const PaymentsDisplayHeader = ({ children }: PropsWithChildren) => (
+
+);
+
+const PaymentsDisplayBody = ({ children }: PropsWithChildren) => (
+
+);
+
+const PaymentsDisplayFooter = ({ children }: PropsWithChildren) => (
+
+);
+
+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"