From 8d1dcf058f02d9225c8706ef3d861135f9a4f724 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Tue, 10 Jun 2025 09:43:34 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=83=B5=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=B2=88=EC=97=AD=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pyconkr/src/main.tsx | 3 +- .../components/common/option_group_input.tsx | 23 +- .../src/components/common/price_display.tsx | 15 +- .../src/components/common/signin_guard.tsx | 47 ++-- .../shop/src/components/features/cart.tsx | 222 ++++++++++-------- .../shop/src/components/features/product.tsx | 108 +++++---- packages/shop/src/contexts/index.ts | 2 + 7 files changed, 254 insertions(+), 166 deletions(-) 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/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..f761821 100644 --- a/packages/shop/src/components/features/cart.tsx +++ b/packages/shop/src/components/features/cart.tsx @@ -21,109 +21,139 @@ 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} - - - - - {cartProdRel.options.map((optionRel) => ( - - ))} - -
- -
- - 상품 가격: - -
- - - -
-); - -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); - - 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잠시 후 다시 시도해주세요."), - }); +}> = ({ 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"; - const disabled = removeItemFromCartMutation.isPending || cartOrderStartMutation.isPending; - - const WrappedShopCartList: React.FC = () => { - const { data } = ShopHooks.useCart(shopAPIClient); - - return !R.isArray(data.products) || data.products.length === 0 ? ( - - 장바구니가 비어있어요! - - ) : ( - <> - {data.products.map((prodRel) => ( - - ))} + return ( + + }> + + {cartProdRel.product.name} + + + + + {cartProdRel.options.map((optionRel) => ( + + ))} +
+
- 결제 금액: + {productPriceStr}: - - - ); - }; - - return ( - - 장바구니 정보를 불러오는 중 문제가 발생했습니다.}> - }> - - - - +
+ + + + ); + }; + + return ( + + + }> + + + + + ); + } +); diff --git a/packages/shop/src/components/features/product.tsx b/packages/shop/src/components/features/product.tsx index beceb70..3b2d632 100644 --- a/packages/shop/src/components/features/product.tsx +++ b/packages/shop/src/components/features/product.tsx @@ -44,22 +44,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; }; @@ -73,9 +82,10 @@ const NotPurchasable: React.FC = ({ children }) => { }; 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(); @@ -83,6 +93,22 @@ const ProductItem: React.FC<{ const oneItemOrderStartMutation = ShopHooks.usePrepareOneItemOrderMutation(shopAPIClient); const addItemToCartMutation = ShopHooks.useAddItemToCartMutation(shopAPIClient); + 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), { @@ -94,11 +120,11 @@ const ProductItem: React.FC<{ queryClient.resetQueries(); onPaymentCompleted?.(); }, - (response) => alert("결제를 실패했습니다!\n" + response.error_msg), + (response) => alert(failedToOrderStr + response.error_msg), () => {} ); }, - onError: (error) => alert(error.message || "결제 준비 중 문제가 발생했습니다,\n잠시 후 다시 시도해주세요."), + onError: (error) => alert(error.message || orderErrorStr), }); const formOnSubmit: React.FormEventHandler = (e) => { @@ -107,7 +133,7 @@ const ProductItem: React.FC<{ }; const disabled = oneItemOrderStartMutation.isPending || addItemToCartMutation.isPending; - const notPurchasableReason = getProductNotPurchasableReason(product); + const notPurchasableReason = getProductNotPurchasableReason(language, product); const actionButtonProps: ButtonProps = { variant: "contained", color: "secondary", @@ -123,9 +149,7 @@ const ProductItem: React.FC<{
- 로그인 후 장바구니에 담거나 구매할 수 있어요.} - > + {requiresSignInStr}}> {R.isNullish(notPurchasableReason) ? ( <>
@@ -146,7 +170,7 @@ const ProductItem: React.FC<{
- 결제 금액: + {orderPriceStr}: ) : ( @@ -156,36 +180,40 @@ const ProductItem: React.FC<{ {R.isNullish(notPurchasableReason) && ( - - + diff --git a/packages/shop/src/components/features/product.tsx b/packages/shop/src/components/features/product.tsx index 3b2d632..1debb8c 100644 --- a/packages/shop/src/components/features/product.tsx +++ b/packages/shop/src/components/features/product.tsx @@ -81,6 +81,11 @@ const NotPurchasable: React.FC = ({ children }) => { ); }; +type ProductItemStateType = { + openDialog: boolean; + openBackdrop: boolean; +}; + const ProductItem: React.FC<{ language: "ko" | "en"; product: ShopSchemas.Product; @@ -89,9 +94,14 @@ const ProductItem: React.FC<{ 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" @@ -110,22 +120,35 @@ const ProductItem: React.FC<{ 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(failedToOrderStr + response.error_msg), - () => {} - ); - }, - onError: (error) => alert(error.message || orderErrorStr), - }); + + 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(); @@ -141,50 +164,57 @@ const ProductItem: React.FC<{ }; return ( - - } sx={{ m: "0" }}> - {product.name} - - - -
- - {requiresSignInStr}}> - {R.isNullish(notPurchasableReason) ? ( - <> -
-
- - {product.option_groups.map((group) => ( - - ))} - -
-
- -
- - {orderPriceStr}: - - - ) : ( - {notPurchasableReason} - )} -
-
- {R.isNullish(notPurchasableReason) && ( - - + + + ); + 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 1debb8c..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"; @@ -157,11 +144,13 @@ const ProductItem: React.FC<{ const disabled = oneItemOrderStartMutation.isPending || addItemToCartMutation.isPending; const notPurchasableReason = getProductNotPurchasableReason(language, product); - const actionButtonProps: ButtonProps = { - variant: "contained", - color: "secondary", - disabled, - }; + const actionButtonProps: ButtonProps = { variant: "contained", color: "secondary", disabled }; + const actionButton = R.isNullish(notPurchasableReason) && ( + <> +