diff --git a/apps/pyconkr/src/debug/page/shop_test.tsx b/apps/pyconkr/src/debug/page/shop_test.tsx index 563a6db..bc47291 100644 --- a/apps/pyconkr/src/debug/page/shop_test.tsx +++ b/apps/pyconkr/src/debug/page/shop_test.tsx @@ -4,7 +4,7 @@ import React from "react"; export const ShopTestPage: React.FC = () => ( - + Shop Test Page @@ -16,7 +16,7 @@ export const ShopTestPage: React.FC = () => ( 상품 목록 - + 장바구니 diff --git a/apps/pyconkr/src/main.tsx b/apps/pyconkr/src/main.tsx index 0dba916..739cb3c 100644 --- a/apps/pyconkr/src/main.tsx +++ b/apps/pyconkr/src/main.tsx @@ -44,6 +44,7 @@ const CommonOptions: Common.Contexts.ContextOptions = { }; const ShopOptions: Shop.Contexts.ContextOptions = { + language: "ko", shopApiDomain: import.meta.env.VITE_PYCONKR_SHOP_API_DOMAIN, shopApiCSRFCookieName: import.meta.env.VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME, shopApiTimeout: 10000, @@ -76,7 +77,7 @@ const MainApp: React.FC = () => { - + diff --git a/packages/shop/src/apis/index.ts b/packages/shop/src/apis/index.ts index 90046e0..0e87c3d 100644 --- a/packages/shop/src/apis/index.ts +++ b/packages/shop/src/apis/index.ts @@ -109,8 +109,8 @@ namespace ShopAPIs { * 고객의 장바구니에 담긴 전체 상품 결제를 PortOne에 등록합니다. * @returns PortOne에 등록된 주문 정보 */ - export const prepareCartOrder = (client: ShopAPIClient) => () => - client.post("v1/orders/", undefined); + export const prepareCartOrder = (client: ShopAPIClient) => (data: ShopSchemas.CustomerInfo) => + client.post("v1/orders/", data); /** * 고객의 모든 결제 내역을 가져옵니다. diff --git a/packages/shop/src/components/common/customer_info_form_dialog.tsx b/packages/shop/src/components/common/customer_info_form_dialog.tsx new file mode 100644 index 0000000..c69c371 --- /dev/null +++ b/packages/shop/src/components/common/customer_info_form_dialog.tsx @@ -0,0 +1,102 @@ +import * as Common from "@frontend/common"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + TextField, +} from "@mui/material"; +import { Suspense } from "@suspensive/react"; +import * as React from "react"; + +import ShopHooks from "../../hooks"; +import ShopSchemas from "../../schemas"; + +const PHONE_REGEX = new RegExp(/^(010-\d{4}-\d{4}|(\+82|0)10\d{3,4}\d{4})$/, "g").source; + +type CustomerInfoFormDialogPropsType = { + open: boolean; + closeFunc: () => void; + onSubmit?: (formData: ShopSchemas.CustomerInfo) => void; + defaultValue?: ShopSchemas.CustomerInfo | null; +}; + +export const CustomerInfoFormDialog: React.FC = Suspense.with( + { fallback: }, + ({ open, closeFunc, onSubmit, defaultValue }) => { + const formRef = React.useRef(null); + + const { language } = ShopHooks.useShopContext(); + const shopAPIClient = ShopHooks.useShopClient(); + const { data: userInfo } = ShopHooks.useUserStatus(shopAPIClient); + + if (!userInfo) { + closeFunc(); + return; + } + + const onSubmitFunc: React.MouseEventHandler = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (Common.Utils.isFormValid(formRef?.current)) + onSubmit?.(Common.Utils.getFormValue({ form: formRef.current })); + }; + + const titleStr = language === "ko" ? "고객 정보 입력" : "Customer Information"; + const cancelButtonText = language === "ko" ? "취소" : "Cancel"; + const submitButtonText = language === "ko" ? "결제" : "Proceed to Payment"; + const nameLabelStr = language === "ko" ? "성명" : "Name"; + const organizationLabelStr = language === "ko" ? "소속" : "Organization"; + const emailLabelStr = language === "ko" ? "이메일 주소" : "Email Address"; + const phoneLabelStr = + language === "ko" + ? "전화번호 (예: 010-1234-5678 또는 +821012345678)" + : "Phone Number (e.g., 010-1234-5678 or +821012345678)"; + const phoneValidationFailedStr = + language === "ko" + ? "전화번호 형식이 올바르지 않습니다. 예: 010-1234-5678 또는 +821012345678" + : "Invalid phone number format. e.g., 010-1234-5678 or +821012345678"; + + return ( + + {titleStr} + +
+ + + + + + +
+
+ +
+ ); + } +); diff --git a/packages/shop/src/components/common/index.ts b/packages/shop/src/components/common/index.ts index 340e25c..934df11 100644 --- a/packages/shop/src/components/common/index.ts +++ b/packages/shop/src/components/common/index.ts @@ -1,3 +1,4 @@ +import { CustomerInfoFormDialog as CustomerInfoFormDialog_ } from "./customer_info_form_dialog"; import { OptionGroupInput as OptionGroupInput_, OrderProductRelationOptionInput as OrderProductRelationOptionInput_, @@ -12,6 +13,7 @@ namespace CommonComponents { export const OrderProductRelationOptionInput = OrderProductRelationOptionInput_; export const PriceDisplay = PriceDisplay_; export const SignInGuard = SignInGuard_; + export const CustomerInfoFormDialog = CustomerInfoFormDialog_; } export default CommonComponents; diff --git a/packages/shop/src/components/common/option_group_input.tsx b/packages/shop/src/components/common/option_group_input.tsx index 26a20e1..1c1cad8 100644 --- a/packages/shop/src/components/common/option_group_input.tsx +++ b/packages/shop/src/components/common/option_group_input.tsx @@ -1,8 +1,10 @@ -import { FormControl, InputLabel, MenuItem, Select, TextField, Tooltip } from "@mui/material"; +import { CircularProgress, FormControl, InputLabel, MenuItem, Select, TextField, Tooltip } from "@mui/material"; +import { Suspense } from "@suspensive/react"; import * as React from "react"; import * as R from "remeda"; import { PriceDisplay } from "./price_display"; +import ShopHooks from "../../hooks"; import ShopSchemas from "../../schemas"; import ShopAPIUtil from "../../utils"; @@ -25,12 +27,13 @@ type SimplifiedOption = Pick R.isString(str) && !R.isEmpty(str); const SelectableOptionGroupInput: React.FC<{ + language: "ko" | "en"; optionGroup: SelectableOptionGroupType; options: SimplifiedOption[]; defaultValue?: string; disabled?: boolean; disabledReason?: string; -}> = ({ optionGroup, options, defaultValue, disabled, disabledReason }) => { +}> = ({ language, optionGroup, options, defaultValue, disabled, disabledReason }) => { const optionElements = options.map((option) => { const isOptionOutOfStock = R.isNumber(option.leftover_stock) && option.leftover_stock <= 0; @@ -43,7 +46,7 @@ const SelectableOptionGroupInput: React.FC<{ [ + ] )} - {isOptionOutOfStock && <> (품절)} + {isOptionOutOfStock && <> ({language === "ko" ? "품절" : "Out of stock"})} ); }); @@ -93,13 +96,14 @@ const CustomResponseOptionGroupInput: React.FC<{ }; export const OptionGroupInput: React.FC<{ + language?: "ko" | "en"; optionGroup: OptionGroupType; options: SimplifiedOption[]; defaultValue?: string; disabled?: boolean; disabledReason?: string; -}> = ({ optionGroup, options, defaultValue, disabled, disabledReason }) => +}> = ({ language, optionGroup, options, defaultValue, disabled, disabledReason }) => optionGroup.is_custom_response ? ( ) : ( = ({ optionRel, disabled, disabledReason }) => { +}> = Suspense.with({ fallback: }, ({ optionRel, disabled, disabledReason }) => { + const { language } = ShopHooks.useShopContext(); let defaultValue: string | null = null; let guessedDisabledReason: string | undefined = undefined; let dummyOptions: { @@ -134,7 +140,10 @@ export const OrderProductRelationOptionInput: React.FC<{ // type hinting을 위해 if문을 사용함 if (optionRel.product_option_group.is_custom_response === false && R.isNonNull(optionRel.product_option)) { defaultValue = optionRel.product_option.id; - guessedDisabledReason = "추가 비용이 발생하는 옵션은 수정할 수 없어요."; + guessedDisabledReason = + language === "ko" + ? "추가 비용이 발생하는 옵션은 수정할 수 없어요." + : "You cannot modify options that incur additional costs."; dummyOptions = [ { id: optionRel.product_option.id, @@ -157,4 +166,4 @@ export const OrderProductRelationOptionInput: React.FC<{ disabledReason={disabledReason || guessedDisabledReason} /> ); -}; +}); diff --git a/packages/shop/src/components/common/price_display.tsx b/packages/shop/src/components/common/price_display.tsx index 01701e1..79a4830 100644 --- a/packages/shop/src/components/common/price_display.tsx +++ b/packages/shop/src/components/common/price_display.tsx @@ -1,5 +1,14 @@ +import { CircularProgress } from "@mui/material"; +import { Suspense } from "@suspensive/react"; import * as React from "react"; -export const PriceDisplay: React.FC<{ price: number; label?: string }> = ({ price, label }) => { - return <>{(label ? `${label} : ` : "") + price.toLocaleString()}원; -}; +import ShopHooks from "../../hooks"; + +export const PriceDisplay: React.FC<{ price: number; label?: string }> = Suspense.with( + { fallback: }, + ({ price, label }) => { + const { language } = ShopHooks.useShopContext(); + const priceStr = language === "ko" ? "원" : "KRW"; + return <>{(label ? `${label} : ` : "") + price.toLocaleString() + priceStr}; + } +); diff --git a/packages/shop/src/components/common/signin_guard.tsx b/packages/shop/src/components/common/signin_guard.tsx index e1497fb..bd95519 100644 --- a/packages/shop/src/components/common/signin_guard.tsx +++ b/packages/shop/src/components/common/signin_guard.tsx @@ -9,23 +9,32 @@ type SignInGuardProps = { fallback?: React.ReactNode; }; -const InnerSignInGuard: React.FC = ({ children, fallback }) => { - const shopAPIClient = ShopHooks.useShopClient(); - const { data } = ShopHooks.useUserStatus(shopAPIClient); - const renderedFallback = fallback || ( - - 로그인 후 이용해주세요. - - ); - return data?.meta?.is_authenticated === true ? children : renderedFallback; -}; +export const SignInGuard: React.FC = Suspense.with( + { fallback: }, + ({ children, fallback }) => { + const { language } = ShopHooks.useShopContext(); + const shopAPIClient = ShopHooks.useShopClient(); + const { data } = ShopHooks.useUserStatus(shopAPIClient); -export const SignInGuard: React.FC = ({ children, fallback }) => { - return ( - 로그인 정보를 불러오는 중 문제가 발생했습니다.}> - }> - {children} - - - ); -}; + const errorFallbackStr = + language === "ko" + ? "로그인 정보를 불러오는 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요." + : "An error occurred while loading sign-in information. Please try again later."; + const signInRequiredStr = + language === "ko" + ? "로그인이 필요합니다. 로그인 후 다시 시도해주세요." + : "You need to sign in. Please sign in and try again."; + + const signInRequiredFallback = fallback || ( + + {signInRequiredStr} + + ); + + return ( + + {data?.meta?.is_authenticated === true ? children : signInRequiredFallback} + + ); + } +); diff --git a/packages/shop/src/components/features/cart.tsx b/packages/shop/src/components/features/cart.tsx index 4f588f0..1c6ee0a 100644 --- a/packages/shop/src/components/features/cart.tsx +++ b/packages/shop/src/components/features/cart.tsx @@ -1,15 +1,5 @@ -import { ExpandMore } from "@mui/icons-material"; -import { - Accordion, - AccordionActions, - AccordionDetails, - AccordionSummary, - Button, - CircularProgress, - Divider, - Stack, - Typography, -} from "@mui/material"; +import * as Common from "@frontend/common"; +import { Backdrop, Button, CircularProgress, Divider, Stack, Typography } from "@mui/material"; import { ErrorBoundary, Suspense } from "@suspensive/react"; import { useQueryClient } from "@tanstack/react-query"; import * as React from "react"; @@ -21,24 +11,38 @@ import ShopUtils from "../../utils"; import CommonComponents from "../common"; const CartItem: React.FC<{ + language: "ko" | "en"; cartProdRel: ShopSchemas.OrderProductItem; removeItemFromCartFunc: (cartProductId: string) => void; disabled?: boolean; -}> = ({ cartProdRel, disabled, removeItemFromCartFunc }) => ( - - }> - - {cartProdRel.product.name} - - - +}> = ({ language, cartProdRel, disabled, removeItemFromCartFunc }) => { + const cannotModifyOptionsStr = + language === "ko" + ? "상품 옵션을 수정하려면 장바구니에서 상품을 삭제한 후 다시 담아주세요." + : "To modify product options, please remove the item from the cart and add it again."; + const productPriceStr = language === "ko" ? "상품 가격" : "Product Price"; + const removeFromCartStr = language === "ko" ? "장바구니에서 상품 삭제" : "Remove from Cart"; + + return ( + removeItemFromCartFunc(cartProdRel.id)} + disabled={disabled} + children={removeFromCartStr} + />, + ]} + > {cartProdRel.options.map((optionRel) => ( ))} @@ -46,84 +50,125 @@ const CartItem: React.FC<{
- 상품 가격: + {productPriceStr}: -
- - - -
-); + + ); +}; -export const CartStatus: React.FC<{ onPaymentCompleted?: () => void }> = ({ onPaymentCompleted }) => { - const queryClient = useQueryClient(); - const shopAPIClient = ShopHooks.useShopClient(); - const cartOrderStartMutation = ShopHooks.usePrepareCartOrderMutation(shopAPIClient); - const removeItemFromCartMutation = ShopHooks.useRemoveItemFromCartMutation(shopAPIClient); +type CartStatusStateType = { + openDialog: boolean; + openBackdrop: boolean; +}; - const removeItemFromCart = (cartProductId: string) => removeItemFromCartMutation.mutate({ cartProductId }); - const startCartOrder = () => - cartOrderStartMutation.mutate(undefined, { - onSuccess: (order: ShopSchemas.Order) => { - ShopUtils.startPortOnePurchase( - order, - () => { - queryClient.invalidateQueries(); - queryClient.resetQueries(); - onPaymentCompleted?.(); - }, - (response) => alert("결제를 실패했습니다!\n" + response.error_msg), - () => {} - ); - }, - onError: (error) => alert(error.message || "결제 준비 중 문제가 발생했습니다,\n잠시 후 다시 시도해주세요."), +export const CartStatus: React.FC<{ onPaymentCompleted?: () => void }> = Suspense.with( + { fallback: }, + ({ onPaymentCompleted }) => { + const queryClient = useQueryClient(); + const { language, shopImpAccountId } = ShopHooks.useShopContext(); + const shopAPIClient = ShopHooks.useShopClient(); + const cartOrderStartMutation = ShopHooks.usePrepareCartOrderMutation(shopAPIClient); + const removeItemFromCartMutation = ShopHooks.useRemoveItemFromCartMutation(shopAPIClient); + const [state, setState] = React.useState({ + openDialog: false, + openBackdrop: false, }); - const disabled = removeItemFromCartMutation.isPending || cartOrderStartMutation.isPending; + const cartIsEmptyStr = language === "ko" ? "장바구니가 비어있어요!" : "Your cart is empty!"; + const totalPriceStr = language === "ko" ? "총 결제 금액" : "Total Payment Amount"; + const orderCartStr = language === "ko" ? "장바구니에 담긴 상품 결제" : "Pay for Items in Cart"; + const errorWhileLoadingCartStr = + language === "ko" + ? "장바구니 정보를 불러오는 중 문제가 발생했습니다." + : "An error occurred while loading the cart information."; + const errorWhilePreparingOrderStr = + language === "ko" + ? "장바구니 결제 준비 중 문제가 발생했습니다.\n잠시 후 다시 시도해주세요." + : "An error occurred while preparing the cart order.\nPlease try again later."; + const failedToOrderStr = + language === "ko" + ? "장바구니 결제에 실패했습니다.\n잠시 후 다시 시도해주세요.\n" + : "Failed to complete the cart order.\nPlease try again later.\n"; - const WrappedShopCartList: React.FC = () => { - const { data } = ShopHooks.useCart(shopAPIClient); + const removeItemFromCart = (cartProductId: string) => removeItemFromCartMutation.mutate({ cartProductId }); - return !R.isArray(data.products) || data.products.length === 0 ? ( - - 장바구니가 비어있어요! - - ) : ( - <> - {data.products.map((prodRel) => ( - - ))} -
- - - 결제 금액: + const openDialog = () => setState((ps) => ({ ...ps, openDialog: true })); + const closeDialog = () => setState((ps) => ({ ...ps, openDialog: false })); + const openBackdrop = () => setState((ps) => ({ ...ps, openBackdrop: true })); + const closeBackdrop = () => setState((ps) => ({ ...ps, openBackdrop: false })); + + const onFormSubmit = (formData: ShopSchemas.CustomerInfo) => { + closeDialog(); + openBackdrop(); + cartOrderStartMutation.mutate(formData, { + onSuccess: (order: ShopSchemas.Order) => { + ShopUtils.startPortOnePurchase( + shopImpAccountId, + order, + () => { + queryClient.invalidateQueries(); + queryClient.resetQueries(); + onPaymentCompleted?.(); + }, + (response) => alert(failedToOrderStr + response.error_msg), + closeBackdrop + ); + }, + onError: (error) => alert(error.message || errorWhilePreparingOrderStr), + }); + }; + + const disabled = removeItemFromCartMutation.isPending || cartOrderStartMutation.isPending; + + const WrappedShopCartList: React.FC = () => { + const { data } = ShopHooks.useCart(shopAPIClient); + + return !R.isArray(data.products) || data.products.length === 0 ? ( + + {cartIsEmptyStr} - - - ); - }; + ) : ( + <> + ({ zIndex: theme.zIndex.drawer + 1 })} + open={state.openBackdrop} + onClick={() => {}} + /> + + {data.products.map((prodRel) => ( + + ))} +
+ + + {totalPriceStr}: + + + + ); + }; - return ( - - 장바구니 정보를 불러오는 중 문제가 발생했습니다.}> - }> - - - - - ); -}; + return ( + + + }> + + + + + ); + } +); diff --git a/packages/shop/src/components/features/order.tsx b/packages/shop/src/components/features/order.tsx index a0bfaec..ef174ae 100644 --- a/packages/shop/src/components/features/order.tsx +++ b/packages/shop/src/components/features/order.tsx @@ -1,17 +1,5 @@ import * as Common from "@frontend/common"; -import { ExpandMore } from "@mui/icons-material"; -import { - Accordion, - AccordionActions, - AccordionDetails, - AccordionSummary, - Button, - CircularProgress, - Divider, - List, - Stack, - Typography, -} from "@mui/material"; +import { Button, CircularProgress, Divider, Stack, Typography } from "@mui/material"; import { ErrorBoundary, Suspense } from "@suspensive/react"; import * as React from "react"; import * as R from "remeda"; @@ -92,42 +80,37 @@ const OrderProductRelationItem: React.FC = ({ }); }; + const actionButtons = ( + <> + + + + ); + return ( - - }>{prodRel.product.name} - -
{ - e.preventDefault(); - patchOneItemOptions(); - }} - > - - {prodRel.options.map((optionRel) => ( - - ))} - -
-
- - - - -
+ +
{ + e.preventDefault(); + patchOneItemOptions(); + }} + > + + {prodRel.options.map((optionRel) => ( + + ))} + +
+
); }; @@ -154,45 +137,43 @@ const OrderItem: React.FC<{ order: ShopSchemas.Order; disabled?: boolean }> = ({ ? "주문 전체 환불됨" : order.not_fully_refundable_reason; + const actionButtons = ( + <> + + + + ); + return ( - - }> - {order.name} - - - -
- - 주문 결제 금액 : - - 상태: {PaymentHistoryStatusTranslated[order.current_status]} -
- -
- 주문 상품 목록 -
- {order.products.map((prodRel) => ( - - ))} -
- -
- - - - -
+ + +
+ + 주문 결제 금액 : + + 상태: {PaymentHistoryStatusTranslated[order.current_status]} +
+ +
+ 주문 상품 목록 +
+ {order.products.map((prodRel) => ( + + ))} +
+ +
); }; @@ -201,13 +182,7 @@ export const OrderList: React.FC = () => { const shopAPIClient = ShopHooks.useShopClient(); const { data } = ShopHooks.useOrders(shopAPIClient); - return ( - - {data.map((item) => ( - - ))} - - ); + return data.map((item) => ); }; return ( diff --git a/packages/shop/src/components/features/product.tsx b/packages/shop/src/components/features/product.tsx index beceb70..4ec834f 100644 --- a/packages/shop/src/components/features/product.tsx +++ b/packages/shop/src/components/features/product.tsx @@ -1,18 +1,5 @@ import * as Common from "@frontend/common"; -import { ExpandMore } from "@mui/icons-material"; -import { - Accordion, - AccordionActions, - AccordionDetails, - AccordionSummary, - Button, - ButtonProps, - CircularProgress, - Divider, - List, - Stack, - Typography, -} from "@mui/material"; +import { Button, ButtonProps, CircularProgress, Divider, Stack, Typography } from "@mui/material"; import { ErrorBoundary, Suspense } from "@suspensive/react"; import { useQueryClient } from "@tanstack/react-query"; import * as React from "react"; @@ -44,22 +31,31 @@ const getCartAppendRequestPayload = ( return { product: product.id, options }; }; -const getProductNotPurchasableReason = (product: ShopSchemas.Product): string | null => { +const getProductNotPurchasableReason = (language: "ko" | "en", product: ShopSchemas.Product): string | null => { // 상품이 구매 가능 기간 내에 있고, 상품이 매진되지 않았으며, 매진된 상품 옵션 재고가 없으면 true const now = new Date(); const orderableStartsAt = new Date(product.orderable_starts_at); const orderableEndsAt = new Date(product.orderable_ends_at); - if (orderableStartsAt > now) - return `아직 구매할 수 없어요!\n(${orderableStartsAt.toLocaleString()}부터 구매하실 수 있어요.)`; - if (orderableEndsAt < now) return "판매가 종료됐어요!"; - - if (R.isNumber(product.leftover_stock) && product.leftover_stock <= 0) return "상품이 품절되었어요!"; + if (orderableStartsAt > now) { + if (language === "ko") { + return `아직 구매할 수 없어요!\n(${orderableStartsAt.toLocaleString()}부터 구매하실 수 있어요.)`; + } else { + return `You cannot purchase this product yet!\n(Starts at ${orderableStartsAt.toLocaleString()})`; + } + } + if (orderableEndsAt < now) + return language === "ko" ? "판매가 종료됐어요!" : "This product is no longer available for purchase!"; + + if (R.isNumber(product.leftover_stock) && product.leftover_stock <= 0) + return language === "ko" ? "상품이 매진되었어요!" : "This product is out of stock!"; if ( product.option_groups.some( (og) => !R.isEmpty(og.options) && og.options.every((o) => R.isNumber(o.leftover_stock) && o.leftover_stock <= 0) ) ) - return "선택 가능한 상품 옵션이 모두 품절되어 구매할 수 없어요!"; + return language === "ko" + ? "선택 가능한 상품 옵션이 모두 품절되어 구매할 수 없어요!" + : "All selectable options for this product are out of stock!"; return null; }; @@ -72,34 +68,74 @@ const NotPurchasable: React.FC = ({ children }) => { ); }; +type ProductItemStateType = { + openDialog: boolean; + openBackdrop: boolean; +}; + const ProductItem: React.FC<{ + language: "ko" | "en"; product: ShopSchemas.Product; onPaymentCompleted?: () => void; -}> = ({ product, onPaymentCompleted }) => { +}> = ({ language, product, onPaymentCompleted }) => { const optionFormRef = React.useRef(null); const queryClient = useQueryClient(); + const { shopImpAccountId } = ShopHooks.useShopContext(); const shopAPIClient = ShopHooks.useShopClient(); const oneItemOrderStartMutation = ShopHooks.usePrepareOneItemOrderMutation(shopAPIClient); const addItemToCartMutation = ShopHooks.useAddItemToCartMutation(shopAPIClient); + const [state, setState] = React.useState({ + openDialog: false, + openBackdrop: false, + }); + + const orderErrorStr = + language === "ko" + ? "결제 준비 중 문제가 발생했습니다,\n잠시 후 다시 시도해주세요." + : "An error occurred while preparing the payment, please try again later."; + const failedToOrderStr = + language === "ko" + ? "결제에 실패했습니다.\n잠시 후 다시 시도해주세요.\n" + : "Failed to complete the payment. Please try again later.\n"; + const requiresSignInStr = + language === "ko" + ? "로그인 후 장바구니에 담거나 구매할 수 있어요." + : "You need to sign in to add items to the cart or make a purchase."; + const addToCartStr = language === "ko" ? "장바구니에 담기" : "Add to Cart"; + const orderOneItemStr = language === "ko" ? "즉시 구매" : "Buy Now"; + const orderPriceStr = language === "ko" ? "결제 금액" : "Price"; const addItemToCart = () => addItemToCartMutation.mutate(getCartAppendRequestPayload(product, optionFormRef)); - const oneItemOrderStart = () => - oneItemOrderStartMutation.mutate(getCartAppendRequestPayload(product, optionFormRef), { - onSuccess: (order: ShopSchemas.Order) => { - ShopUtils.startPortOnePurchase( - order, - () => { - queryClient.invalidateQueries(); - queryClient.resetQueries(); - onPaymentCompleted?.(); - }, - (response) => alert("결제를 실패했습니다!\n" + response.error_msg), - () => {} - ); - }, - onError: (error) => alert(error.message || "결제 준비 중 문제가 발생했습니다,\n잠시 후 다시 시도해주세요."), - }); + + const openDialog = () => setState((ps) => ({ ...ps, openDialog: true })); + const closeDialog = () => setState((ps) => ({ ...ps, openDialog: false })); + const openBackdrop = () => setState((ps) => ({ ...ps, openBackdrop: true })); + const closeBackdrop = () => setState((ps) => ({ ...ps, openBackdrop: false })); + + const onFormSubmit = (formData: ShopSchemas.CustomerInfo) => { + closeDialog(); + openBackdrop(); + oneItemOrderStartMutation.mutate( + { ...getCartAppendRequestPayload(product, optionFormRef), customer_info: formData }, + { + onSuccess: (order: ShopSchemas.Order) => { + ShopUtils.startPortOnePurchase( + shopImpAccountId, + order, + () => { + queryClient.invalidateQueries(); + queryClient.resetQueries(); + onPaymentCompleted?.(); + }, + (response) => alert(failedToOrderStr + response.error_msg), + closeBackdrop + ); + }, + onError: (error) => alert(error.message || orderErrorStr), + } + ); + }; const formOnSubmit: React.FormEventHandler = (e) => { e.preventDefault(); @@ -107,25 +143,27 @@ const ProductItem: React.FC<{ }; const disabled = oneItemOrderStartMutation.isPending || addItemToCartMutation.isPending; - const notPurchasableReason = getProductNotPurchasableReason(product); - const actionButtonProps: ButtonProps = { - variant: "contained", - color: "secondary", - disabled, - }; + const notPurchasableReason = getProductNotPurchasableReason(language, product); + const actionButtonProps: ButtonProps = { variant: "contained", color: "secondary", disabled }; + const actionButton = R.isNullish(notPurchasableReason) && ( + <> + - - - )} - + + ); }; export const ProductList: React.FC = (qs) => { const WrappedProductList: React.FC = () => { + const { language } = ShopHooks.useShopContext(); const shopAPIClient = ShopHooks.useShopClient(); const { data } = ShopHooks.useProducts(shopAPIClient, qs); - return ( - - {data.map((product) => ( - - ))} - - ); + + return data.map((p) => ); }; return ( diff --git a/packages/shop/src/contexts/index.ts b/packages/shop/src/contexts/index.ts index cc0ac2e..9bfd521 100644 --- a/packages/shop/src/contexts/index.ts +++ b/packages/shop/src/contexts/index.ts @@ -2,6 +2,7 @@ import * as React from "react"; namespace ShopContext { export type ContextOptions = { + language: "ko" | "en"; shopApiDomain: string; shopApiCSRFCookieName: string; shopApiTimeout: number; @@ -9,6 +10,7 @@ namespace ShopContext { }; export const context = React.createContext({ + language: "ko", shopApiDomain: "", shopApiCSRFCookieName: "", shopApiTimeout: 10000, diff --git a/packages/shop/src/schemas/index.ts b/packages/shop/src/schemas/index.ts index e2dc166..0c05c45 100644 --- a/packages/shop/src/schemas/index.ts +++ b/packages/shop/src/schemas/index.ts @@ -173,6 +173,13 @@ namespace ShopSchemas { )[]; }; + export type CustomerInfo = { + name: string; // ^(.*)$ + phone: string; // ^([\d]{3}-[\d]{3,4}-[\d]{4}|\+[\d]{9,14})$ + email: string; // $email + organization: string | null; // ^(.*)$ + }; + export type Order = { id: string; name: string; @@ -184,6 +191,7 @@ namespace ShopSchemas { payment_histories: PaymentHistory[]; products: OrderProductItem[]; + customer_info: CustomerInfo | null; }; export type Cart = Order; @@ -195,7 +203,7 @@ namespace ShopSchemas { custom_response: string | null; }[]; }; - export type OneItemOrderRequest = CartItemAppendRequest; + export type OneItemOrderRequest = CartItemAppendRequest & { customer_info: CustomerInfo }; export type OneItemRefundRequest = { order_id: string; diff --git a/packages/shop/src/utils/portone.ts b/packages/shop/src/utils/portone.ts index 889844b..2d6083e 100644 --- a/packages/shop/src/utils/portone.ts +++ b/packages/shop/src/utils/portone.ts @@ -4,6 +4,7 @@ import * as R from "remeda"; import ShopSchemas from "../schemas"; export const startPortOnePurchase = ( + portOneAccountId: string, order: ShopSchemas.Order, onSuccess?: (response: RequestPayResponse) => void, onFailure?: (response: RequestPayResponse) => void, @@ -15,8 +16,6 @@ export const startPortOnePurchase = ( return; } - const portOneAccountId = - import.meta?.env?.VITE_PYCONKR_SHOP_PORTONE_ACCOUNT_ID || process?.env?.PYCONKR_SHOP_PORTONE_ACCOUNT_ID || ""; if (!R.isString(portOneAccountId) || R.isEmpty(portOneAccountId)) { alert("PortOne 계정 ID가 설정되지 않았습니다."); return;