Skip to content

Commit eeceeb4

Browse files
authored
Merge pull request #37 from pythonkr/feature/add-shop-customer-info-dialog
feat: 결제 시 고객 정보를 받는 dialog 추가
2 parents f50c559 + 4cc2fe4 commit eeceeb4

File tree

14 files changed

+480
-295
lines changed

14 files changed

+480
-295
lines changed

apps/pyconkr/src/debug/page/shop_test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React from "react";
44

55
export const ShopTestPage: React.FC = () => (
66
<Stack>
7-
<Stack spacing={2} sx={{ px: 4, backgroundColor: "#ddd", py: 2 }}>
7+
<Stack spacing={2} sx={{ px: 4, py: 2 }}>
88
<Typography variant="h4" gutterBottom>
99
Shop Test Page
1010
</Typography>
@@ -16,7 +16,7 @@ export const ShopTestPage: React.FC = () => (
1616
<Typography variant="h5" gutterBottom>
1717
상품 목록
1818
</Typography>
19-
<Shop.Components.Features.ProductList />
19+
<Shop.Components.Features.ProductList category_group="2025" category="티켓" />
2020
<Divider />
2121
<Typography variant="h5" gutterBottom>
2222
장바구니

apps/pyconkr/src/main.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const CommonOptions: Common.Contexts.ContextOptions = {
4444
};
4545

4646
const ShopOptions: Shop.Contexts.ContextOptions = {
47+
language: "ko",
4748
shopApiDomain: import.meta.env.VITE_PYCONKR_SHOP_API_DOMAIN,
4849
shopApiCSRFCookieName: import.meta.env.VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME,
4950
shopApiTimeout: 10000,
@@ -76,7 +77,7 @@ const MainApp: React.FC = () => {
7677
<BrowserRouter>
7778
<AppContext.Provider value={{ ...appState, setAppContext }}>
7879
<Common.Components.CommonContextProvider options={{ ...CommonOptions, language: appState.language }}>
79-
<Shop.Components.Common.ShopContextProvider options={ShopOptions}>
80+
<Shop.Components.Common.ShopContextProvider options={{ ...ShopOptions, language: appState.language }}>
8081
<ErrorBoundary fallback={Common.Components.ErrorFallback}>
8182
<Suspense fallback={SuspenseFallback}>
8283
<ThemeProvider theme={muiTheme}>

packages/shop/src/apis/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ namespace ShopAPIs {
109109
* 고객의 장바구니에 담긴 전체 상품 결제를 PortOne에 등록합니다.
110110
* @returns PortOne에 등록된 주문 정보
111111
*/
112-
export const prepareCartOrder = (client: ShopAPIClient) => () =>
113-
client.post<ShopSchemas.Order, undefined>("v1/orders/", undefined);
112+
export const prepareCartOrder = (client: ShopAPIClient) => (data: ShopSchemas.CustomerInfo) =>
113+
client.post<ShopSchemas.Order, ShopSchemas.CustomerInfo>("v1/orders/", data);
114114

115115
/**
116116
* 고객의 모든 결제 내역을 가져옵니다.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import * as Common from "@frontend/common";
2+
import {
3+
Button,
4+
CircularProgress,
5+
Dialog,
6+
DialogActions,
7+
DialogContent,
8+
DialogTitle,
9+
Stack,
10+
TextField,
11+
} from "@mui/material";
12+
import { Suspense } from "@suspensive/react";
13+
import * as React from "react";
14+
15+
import ShopHooks from "../../hooks";
16+
import ShopSchemas from "../../schemas";
17+
18+
const PHONE_REGEX = new RegExp(/^(010-\d{4}-\d{4}|(\+82|0)10\d{3,4}\d{4})$/, "g").source;
19+
20+
type CustomerInfoFormDialogPropsType = {
21+
open: boolean;
22+
closeFunc: () => void;
23+
onSubmit?: (formData: ShopSchemas.CustomerInfo) => void;
24+
defaultValue?: ShopSchemas.CustomerInfo | null;
25+
};
26+
27+
export const CustomerInfoFormDialog: React.FC<CustomerInfoFormDialogPropsType> = Suspense.with(
28+
{ fallback: <CircularProgress /> },
29+
({ open, closeFunc, onSubmit, defaultValue }) => {
30+
const formRef = React.useRef<HTMLFormElement | null>(null);
31+
32+
const { language } = ShopHooks.useShopContext();
33+
const shopAPIClient = ShopHooks.useShopClient();
34+
const { data: userInfo } = ShopHooks.useUserStatus(shopAPIClient);
35+
36+
if (!userInfo) {
37+
closeFunc();
38+
return;
39+
}
40+
41+
const onSubmitFunc: React.MouseEventHandler<HTMLButtonElement> = (e) => {
42+
e.preventDefault();
43+
e.stopPropagation();
44+
if (Common.Utils.isFormValid(formRef?.current))
45+
onSubmit?.(Common.Utils.getFormValue<ShopSchemas.CustomerInfo>({ form: formRef.current }));
46+
};
47+
48+
const titleStr = language === "ko" ? "고객 정보 입력" : "Customer Information";
49+
const cancelButtonText = language === "ko" ? "취소" : "Cancel";
50+
const submitButtonText = language === "ko" ? "결제" : "Proceed to Payment";
51+
const nameLabelStr = language === "ko" ? "성명" : "Name";
52+
const organizationLabelStr = language === "ko" ? "소속" : "Organization";
53+
const emailLabelStr = language === "ko" ? "이메일 주소" : "Email Address";
54+
const phoneLabelStr =
55+
language === "ko"
56+
? "전화번호 (예: 010-1234-5678 또는 +821012345678)"
57+
: "Phone Number (e.g., 010-1234-5678 or +821012345678)";
58+
const phoneValidationFailedStr =
59+
language === "ko"
60+
? "전화번호 형식이 올바르지 않습니다. 예: 010-1234-5678 또는 +821012345678"
61+
: "Invalid phone number format. e.g., 010-1234-5678 or +821012345678";
62+
63+
return (
64+
<Dialog open={open} onClose={closeFunc} fullWidth maxWidth="sm">
65+
<DialogTitle>{titleStr}</DialogTitle>
66+
<DialogContent>
67+
<form ref={formRef}>
68+
<Stack spacing={2}>
69+
<TextField name="name" label={nameLabelStr} defaultValue={defaultValue?.name} required fullWidth />
70+
<TextField
71+
name="organization"
72+
label={organizationLabelStr}
73+
defaultValue={defaultValue?.organization}
74+
fullWidth
75+
/>
76+
<TextField
77+
name="email"
78+
label={emailLabelStr}
79+
defaultValue={defaultValue?.email || userInfo.data.user.email}
80+
type="email"
81+
required
82+
fullWidth
83+
/>
84+
<TextField
85+
name="phone"
86+
label={phoneLabelStr}
87+
defaultValue={defaultValue?.phone}
88+
slotProps={{ htmlInput: { pattern: PHONE_REGEX, title: phoneValidationFailedStr } }}
89+
fullWidth
90+
required
91+
/>
92+
</Stack>
93+
</form>
94+
</DialogContent>
95+
<DialogActions>
96+
<Button onClick={closeFunc} color="error" children={cancelButtonText} />
97+
<Button onClick={onSubmitFunc} children={submitButtonText} />
98+
</DialogActions>
99+
</Dialog>
100+
);
101+
}
102+
);

packages/shop/src/components/common/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { CustomerInfoFormDialog as CustomerInfoFormDialog_ } from "./customer_info_form_dialog";
12
import {
23
OptionGroupInput as OptionGroupInput_,
34
OrderProductRelationOptionInput as OrderProductRelationOptionInput_,
@@ -12,6 +13,7 @@ namespace CommonComponents {
1213
export const OrderProductRelationOptionInput = OrderProductRelationOptionInput_;
1314
export const PriceDisplay = PriceDisplay_;
1415
export const SignInGuard = SignInGuard_;
16+
export const CustomerInfoFormDialog = CustomerInfoFormDialog_;
1517
}
1618

1719
export default CommonComponents;

packages/shop/src/components/common/option_group_input.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { FormControl, InputLabel, MenuItem, Select, TextField, Tooltip } from "@mui/material";
1+
import { CircularProgress, FormControl, InputLabel, MenuItem, Select, TextField, Tooltip } from "@mui/material";
2+
import { Suspense } from "@suspensive/react";
23
import * as React from "react";
34
import * as R from "remeda";
45

56
import { PriceDisplay } from "./price_display";
7+
import ShopHooks from "../../hooks";
68
import ShopSchemas from "../../schemas";
79
import ShopAPIUtil from "../../utils";
810

@@ -25,12 +27,13 @@ type SimplifiedOption = Pick<ShopSchemas.Option, "id" | "name" | "additional_pri
2527
const isFilledString = (str: unknown): str is string => R.isString(str) && !R.isEmpty(str);
2628

2729
const SelectableOptionGroupInput: React.FC<{
30+
language: "ko" | "en";
2831
optionGroup: SelectableOptionGroupType;
2932
options: SimplifiedOption[];
3033
defaultValue?: string;
3134
disabled?: boolean;
3235
disabledReason?: string;
33-
}> = ({ optionGroup, options, defaultValue, disabled, disabledReason }) => {
36+
}> = ({ language, optionGroup, options, defaultValue, disabled, disabledReason }) => {
3437
const optionElements = options.map((option) => {
3538
const isOptionOutOfStock = R.isNumber(option.leftover_stock) && option.leftover_stock <= 0;
3639

@@ -43,7 +46,7 @@ const SelectableOptionGroupInput: React.FC<{
4346
[ +<PriceDisplay price={option.additional_price} /> ]
4447
</>
4548
)}
46-
{isOptionOutOfStock && <> (품절)</>}
49+
{isOptionOutOfStock && <> ({language === "ko" ? "품절" : "Out of stock"})</>}
4750
</MenuItem>
4851
);
4952
});
@@ -93,13 +96,14 @@ const CustomResponseOptionGroupInput: React.FC<{
9396
};
9497

9598
export const OptionGroupInput: React.FC<{
99+
language?: "ko" | "en";
96100
optionGroup: OptionGroupType;
97101
options: SimplifiedOption[];
98102

99103
defaultValue?: string;
100104
disabled?: boolean;
101105
disabledReason?: string;
102-
}> = ({ optionGroup, options, defaultValue, disabled, disabledReason }) =>
106+
}> = ({ language, optionGroup, options, defaultValue, disabled, disabledReason }) =>
103107
optionGroup.is_custom_response ? (
104108
<CustomResponseOptionGroupInput
105109
optionGroup={optionGroup}
@@ -109,6 +113,7 @@ export const OptionGroupInput: React.FC<{
109113
/>
110114
) : (
111115
<SelectableOptionGroupInput
116+
language={language || "ko"}
112117
optionGroup={optionGroup}
113118
options={options}
114119
defaultValue={defaultValue}
@@ -121,7 +126,8 @@ export const OrderProductRelationOptionInput: React.FC<{
121126
optionRel: ShopSchemas.OrderProductItem["options"][number];
122127
disabled?: boolean;
123128
disabledReason?: string;
124-
}> = ({ optionRel, disabled, disabledReason }) => {
129+
}> = Suspense.with({ fallback: <CircularProgress /> }, ({ optionRel, disabled, disabledReason }) => {
130+
const { language } = ShopHooks.useShopContext();
125131
let defaultValue: string | null = null;
126132
let guessedDisabledReason: string | undefined = undefined;
127133
let dummyOptions: {
@@ -134,7 +140,10 @@ export const OrderProductRelationOptionInput: React.FC<{
134140
// type hinting을 위해 if문을 사용함
135141
if (optionRel.product_option_group.is_custom_response === false && R.isNonNull(optionRel.product_option)) {
136142
defaultValue = optionRel.product_option.id;
137-
guessedDisabledReason = "추가 비용이 발생하는 옵션은 수정할 수 없어요.";
143+
guessedDisabledReason =
144+
language === "ko"
145+
? "추가 비용이 발생하는 옵션은 수정할 수 없어요."
146+
: "You cannot modify options that incur additional costs.";
138147
dummyOptions = [
139148
{
140149
id: optionRel.product_option.id,
@@ -157,4 +166,4 @@ export const OrderProductRelationOptionInput: React.FC<{
157166
disabledReason={disabledReason || guessedDisabledReason}
158167
/>
159168
);
160-
};
169+
});
Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1+
import { CircularProgress } from "@mui/material";
2+
import { Suspense } from "@suspensive/react";
13
import * as React from "react";
24

3-
export const PriceDisplay: React.FC<{ price: number; label?: string }> = ({ price, label }) => {
4-
return <>{(label ? `${label} : ` : "") + price.toLocaleString()}</>;
5-
};
5+
import ShopHooks from "../../hooks";
6+
7+
export const PriceDisplay: React.FC<{ price: number; label?: string }> = Suspense.with(
8+
{ fallback: <CircularProgress /> },
9+
({ price, label }) => {
10+
const { language } = ShopHooks.useShopContext();
11+
const priceStr = language === "ko" ? "원" : "KRW";
12+
return <>{(label ? `${label} : ` : "") + price.toLocaleString() + priceStr}</>;
13+
}
14+
);

packages/shop/src/components/common/signin_guard.tsx

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,32 @@ type SignInGuardProps = {
99
fallback?: React.ReactNode;
1010
};
1111

12-
const InnerSignInGuard: React.FC<SignInGuardProps> = ({ children, fallback }) => {
13-
const shopAPIClient = ShopHooks.useShopClient();
14-
const { data } = ShopHooks.useUserStatus(shopAPIClient);
15-
const renderedFallback = fallback || (
16-
<Typography variant="h6" gutterBottom>
17-
로그인 후 이용해주세요.
18-
</Typography>
19-
);
20-
return data?.meta?.is_authenticated === true ? children : renderedFallback;
21-
};
12+
export const SignInGuard: React.FC<SignInGuardProps> = Suspense.with(
13+
{ fallback: <CircularProgress /> },
14+
({ children, fallback }) => {
15+
const { language } = ShopHooks.useShopContext();
16+
const shopAPIClient = ShopHooks.useShopClient();
17+
const { data } = ShopHooks.useUserStatus(shopAPIClient);
2218

23-
export const SignInGuard: React.FC<SignInGuardProps> = ({ children, fallback }) => {
24-
return (
25-
<ErrorBoundary fallback={<>로그인 정보를 불러오는 중 문제가 발생했습니다.</>}>
26-
<Suspense fallback={<CircularProgress />}>
27-
<InnerSignInGuard fallback={fallback}>{children}</InnerSignInGuard>
28-
</Suspense>
29-
</ErrorBoundary>
30-
);
31-
};
19+
const errorFallbackStr =
20+
language === "ko"
21+
? "로그인 정보를 불러오는 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요."
22+
: "An error occurred while loading sign-in information. Please try again later.";
23+
const signInRequiredStr =
24+
language === "ko"
25+
? "로그인이 필요합니다. 로그인 후 다시 시도해주세요."
26+
: "You need to sign in. Please sign in and try again.";
27+
28+
const signInRequiredFallback = fallback || (
29+
<Typography variant="h6" gutterBottom>
30+
{signInRequiredStr}
31+
</Typography>
32+
);
33+
34+
return (
35+
<ErrorBoundary fallback={errorFallbackStr}>
36+
{data?.meta?.is_authenticated === true ? children : signInRequiredFallback}
37+
</ErrorBoundary>
38+
);
39+
}
40+
);

0 commit comments

Comments
 (0)