Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/pyconkr/src/debug/page/shop_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React from "react";

export const ShopTestPage: React.FC = () => (
<Stack>
<Stack spacing={2} sx={{ px: 4, backgroundColor: "#ddd", py: 2 }}>
<Stack spacing={2} sx={{ px: 4, py: 2 }}>
<Typography variant="h4" gutterBottom>
Shop Test Page
</Typography>
Expand All @@ -16,7 +16,7 @@ export const ShopTestPage: React.FC = () => (
<Typography variant="h5" gutterBottom>
상품 목록
</Typography>
<Shop.Components.Features.ProductList />
<Shop.Components.Features.ProductList category_group="2025" category="티켓" />
<Divider />
<Typography variant="h5" gutterBottom>
장바구니
Expand Down
3 changes: 2 additions & 1 deletion apps/pyconkr/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -76,7 +77,7 @@ const MainApp: React.FC = () => {
<BrowserRouter>
<AppContext.Provider value={{ ...appState, setAppContext }}>
<Common.Components.CommonContextProvider options={{ ...CommonOptions, language: appState.language }}>
<Shop.Components.Common.ShopContextProvider options={ShopOptions}>
<Shop.Components.Common.ShopContextProvider options={{ ...ShopOptions, language: appState.language }}>
<ErrorBoundary fallback={Common.Components.ErrorFallback}>
<Suspense fallback={SuspenseFallback}>
<ThemeProvider theme={muiTheme}>
Expand Down
4 changes: 2 additions & 2 deletions packages/shop/src/apis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ namespace ShopAPIs {
* 고객의 장바구니에 담긴 전체 상품 결제를 PortOne에 등록합니다.
* @returns PortOne에 등록된 주문 정보
*/
export const prepareCartOrder = (client: ShopAPIClient) => () =>
client.post<ShopSchemas.Order, undefined>("v1/orders/", undefined);
export const prepareCartOrder = (client: ShopAPIClient) => (data: ShopSchemas.CustomerInfo) =>
client.post<ShopSchemas.Order, ShopSchemas.CustomerInfo>("v1/orders/", data);

/**
* 고객의 모든 결제 내역을 가져옵니다.
Expand Down
102 changes: 102 additions & 0 deletions packages/shop/src/components/common/customer_info_form_dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<CustomerInfoFormDialogPropsType> = Suspense.with(
{ fallback: <CircularProgress /> },
({ open, closeFunc, onSubmit, defaultValue }) => {
const formRef = React.useRef<HTMLFormElement | null>(null);

const { language } = ShopHooks.useShopContext();
const shopAPIClient = ShopHooks.useShopClient();
const { data: userInfo } = ShopHooks.useUserStatus(shopAPIClient);

if (!userInfo) {
closeFunc();
return;
}

const onSubmitFunc: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
e.stopPropagation();
if (Common.Utils.isFormValid(formRef?.current))
onSubmit?.(Common.Utils.getFormValue<ShopSchemas.CustomerInfo>({ 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 (
<Dialog open={open} onClose={closeFunc} fullWidth maxWidth="sm">
<DialogTitle>{titleStr}</DialogTitle>
<DialogContent>
<form ref={formRef}>
<Stack spacing={2}>
<TextField name="name" label={nameLabelStr} defaultValue={defaultValue?.name} required fullWidth />
<TextField
name="organization"
label={organizationLabelStr}
defaultValue={defaultValue?.organization}
fullWidth
/>
<TextField
name="email"
label={emailLabelStr}
defaultValue={defaultValue?.email || userInfo.data.user.email}
type="email"
required
fullWidth
/>
<TextField
name="phone"
label={phoneLabelStr}
defaultValue={defaultValue?.phone}
slotProps={{ htmlInput: { pattern: PHONE_REGEX, title: phoneValidationFailedStr } }}
fullWidth
required
/>
</Stack>
</form>
</DialogContent>
<DialogActions>
<Button onClick={closeFunc} color="error" children={cancelButtonText} />
<Button onClick={onSubmitFunc} children={submitButtonText} />
</DialogActions>
</Dialog>
);
}
);
2 changes: 2 additions & 0 deletions packages/shop/src/components/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CustomerInfoFormDialog as CustomerInfoFormDialog_ } from "./customer_info_form_dialog";
import {
OptionGroupInput as OptionGroupInput_,
OrderProductRelationOptionInput as OrderProductRelationOptionInput_,
Expand All @@ -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;
23 changes: 16 additions & 7 deletions packages/shop/src/components/common/option_group_input.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -25,12 +27,13 @@ type SimplifiedOption = Pick<ShopSchemas.Option, "id" | "name" | "additional_pri
const isFilledString = (str: unknown): str is string => 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;

Expand All @@ -43,7 +46,7 @@ const SelectableOptionGroupInput: React.FC<{
[ +<PriceDisplay price={option.additional_price} /> ]
</>
)}
{isOptionOutOfStock && <> (품절)</>}
{isOptionOutOfStock && <> ({language === "ko" ? "품절" : "Out of stock"})</>}
</MenuItem>
);
});
Expand Down Expand Up @@ -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 ? (
<CustomResponseOptionGroupInput
optionGroup={optionGroup}
Expand All @@ -109,6 +113,7 @@ export const OptionGroupInput: React.FC<{
/>
) : (
<SelectableOptionGroupInput
language={language || "ko"}
optionGroup={optionGroup}
options={options}
defaultValue={defaultValue}
Expand All @@ -121,7 +126,8 @@ export const OrderProductRelationOptionInput: React.FC<{
optionRel: ShopSchemas.OrderProductItem["options"][number];
disabled?: boolean;
disabledReason?: string;
}> = ({ optionRel, disabled, disabledReason }) => {
}> = Suspense.with({ fallback: <CircularProgress /> }, ({ optionRel, disabled, disabledReason }) => {
const { language } = ShopHooks.useShopContext();
let defaultValue: string | null = null;
let guessedDisabledReason: string | undefined = undefined;
let dummyOptions: {
Expand All @@ -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,
Expand All @@ -157,4 +166,4 @@ export const OrderProductRelationOptionInput: React.FC<{
disabledReason={disabledReason || guessedDisabledReason}
/>
);
};
});
15 changes: 12 additions & 3 deletions packages/shop/src/components/common/price_display.tsx
Original file line number Diff line number Diff line change
@@ -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: <CircularProgress /> },
({ price, label }) => {
const { language } = ShopHooks.useShopContext();
const priceStr = language === "ko" ? "원" : "KRW";
return <>{(label ? `${label} : ` : "") + price.toLocaleString() + priceStr}</>;
}
);
47 changes: 28 additions & 19 deletions packages/shop/src/components/common/signin_guard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,32 @@ type SignInGuardProps = {
fallback?: React.ReactNode;
};

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

export const SignInGuard: React.FC<SignInGuardProps> = ({ children, fallback }) => {
return (
<ErrorBoundary fallback={<>로그인 정보를 불러오는 중 문제가 발생했습니다.</>}>
<Suspense fallback={<CircularProgress />}>
<InnerSignInGuard fallback={fallback}>{children}</InnerSignInGuard>
</Suspense>
</ErrorBoundary>
);
};
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 || (
<Typography variant="h6" gutterBottom>
{signInRequiredStr}
</Typography>
);

return (
<ErrorBoundary fallback={errorFallbackStr}>
{data?.meta?.is_authenticated === true ? children : signInRequiredFallback}
</ErrorBoundary>
);
}
);
Loading