Skip to content

Commit 856a3ee

Browse files
committed
add: Shop 테스트 페이지 추가
1 parent d292ddf commit 856a3ee

File tree

5 files changed

+474
-0
lines changed

5 files changed

+474
-0
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import React from "react";
2+
3+
import { ExpandMore } from '@mui/icons-material';
4+
import { Accordion, AccordionActions, AccordionDetails, AccordionSummary, Button, CircularProgress, Divider, Stack, Typography } from "@mui/material";
5+
import { wrap } from "@suspensive/react";
6+
import { useQueryClient } from '@tanstack/react-query';
7+
8+
import ShopComponent from "@pyconkr-shop/components";
9+
import ShopAPIHook from "@pyconkr-shop/hooks";
10+
import ShopAPISchema from '@pyconkr-shop/schemas';
11+
import ShopAPIUtil from "@pyconkr-shop/utils";
12+
13+
const ShopCartItem: React.FC<{
14+
cartProdRel: ShopAPISchema.OrderProductItem,
15+
removeItemFromCartFunc: (cartProductId: string) => void,
16+
disabled?: boolean
17+
}> = ({ cartProdRel, disabled, removeItemFromCartFunc }) => <Accordion>
18+
<AccordionSummary expandIcon={<ExpandMore />}>
19+
<Typography variant="h6" sx={{ width: '100%', flexShrink: 0 }}>
20+
{cartProdRel.product.name}
21+
</Typography>
22+
</AccordionSummary>
23+
<AccordionDetails>
24+
<Stack spacing={2} sx={{ width: '100%' }}>
25+
{
26+
cartProdRel.options.map(
27+
(optionRel) => <ShopComponent.OrderProductRelationOptionInput
28+
key={optionRel.product_option_group.id + (optionRel.product_option?.id || '')}
29+
optionRel={optionRel}
30+
disabled
31+
disabledReason='상품 옵션을 수정하려면 장바구니에서 상품을 삭제한 후 다시 담아주세요.'
32+
/>
33+
)
34+
}
35+
</Stack>
36+
<br />
37+
<Divider />
38+
<br />
39+
<Typography variant="h6" sx={{ textAlign: 'end' }}>
40+
상품 가격: <ShopComponent.PriceDisplay price={cartProdRel.price} />
41+
</Typography>
42+
</AccordionDetails>
43+
<AccordionActions>
44+
<Button variant="contained" color="secondary" onClick={() => removeItemFromCartFunc(cartProdRel.id)} disabled={disabled}>장바구니에서 상품 삭제</Button>
45+
</AccordionActions>
46+
</Accordion>
47+
48+
49+
export const ShopCartList: React.FC<{ onPaymentCompleted?: () => void; }> = ({ onPaymentCompleted }) => {
50+
const queryClient = useQueryClient();
51+
const cartOrderStartMutation = ShopAPIHook.usePrepareCartOrderMutation();
52+
const removeItemFromCartMutation = ShopAPIHook.useRemoveItemFromCartMutation();
53+
54+
const removeItemFromCart = (cartProductId: string) => removeItemFromCartMutation.mutate({ cartProductId });
55+
const startCartOrder = () => cartOrderStartMutation.mutate(
56+
undefined,
57+
{
58+
onSuccess: (order: ShopAPISchema.Order) => {
59+
ShopAPIUtil.startPortOnePurchase(
60+
order,
61+
() => {
62+
queryClient.invalidateQueries();
63+
queryClient.resetQueries();
64+
onPaymentCompleted?.();
65+
},
66+
(response) => alert("결제를 실패했습니다!\n" + response.error_msg),
67+
() => { }
68+
);
69+
},
70+
onError: (error) => alert(error.message || '결제 준비 중 문제가 발생했습니다,\n잠시 후 다시 시도해주세요.'),
71+
}
72+
);
73+
74+
const disabled = removeItemFromCartMutation.isPending || cartOrderStartMutation.isPending;
75+
76+
const WrappedShopCartList = wrap
77+
.ErrorBoundary({ fallback: <div>장바구니 정보를 불러오는 중 문제가 발생했습니다.</div> })
78+
.Suspense({ fallback: <CircularProgress /> })
79+
.on(() => {
80+
// eslint-disable-next-line react-hooks/rules-of-hooks
81+
const { data } = ShopAPIHook.useCart();
82+
83+
return data.products.length === 0
84+
? <Typography variant="body1" color="error">장바구니가 비어있어요!</Typography>
85+
: <>
86+
{data.products.map((prodRel) => <ShopCartItem key={prodRel.id} cartProdRel={prodRel} disabled={disabled} removeItemFromCartFunc={removeItemFromCart} />)}
87+
<br />
88+
<Divider />
89+
<Typography variant="h6" sx={{ textAlign: 'end' }}>
90+
결제 금액: <ShopComponent.PriceDisplay price={data.first_paid_price} />
91+
</Typography>
92+
<Button variant="contained" color="secondary" onClick={startCartOrder} disabled={disabled}>장바구니에 담긴 상품 결제</Button>
93+
</>
94+
});
95+
96+
return <>
97+
<Typography variant="h5" gutterBottom>Cart List</Typography>
98+
<ShopComponent.ShopSignInGuard>
99+
<WrappedShopCartList />
100+
</ShopComponent.ShopSignInGuard>
101+
</>
102+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import React from "react";
2+
import * as R from "remeda";
3+
4+
import { ExpandMore } from '@mui/icons-material';
5+
import {
6+
Accordion,
7+
AccordionActions,
8+
AccordionDetails,
9+
AccordionSummary,
10+
Button,
11+
CircularProgress,
12+
Divider,
13+
List,
14+
Stack,
15+
Typography
16+
} from "@mui/material";
17+
import { wrap } from "@suspensive/react";
18+
19+
import { getFormValue, isFormValid } from '@pyconkr-common/utils/form';
20+
import ShopComponent from "@pyconkr-shop/components";
21+
import ShopAPIHook from "@pyconkr-shop/hooks";
22+
import ShopAPISchema from "@pyconkr-shop/schemas";
23+
import ShopAPIUtil from '@pyconkr-shop/utils';
24+
25+
const PaymentHistoryStatusTranslated: { [k in ShopAPISchema.PaymentHistoryStatus]: string } = {
26+
"pending": "결제 대기중",
27+
"completed": "결제 완료",
28+
"partial_refunded": "부분 환불됨",
29+
"refunded": "환불됨",
30+
}
31+
32+
const ShopOrderItem: React.FC<{ order: ShopAPISchema.Order, disabled?: boolean }> = ({ order, disabled }) => {
33+
const orderRefundMutation = ShopAPIHook.useOrderRefundMutation();
34+
const oneItemRefundMutation = ShopAPIHook.useOneItemRefundMutation();
35+
const optionsOfOneItemInOrderPatchMutation = ShopAPIHook.useOptionsOfOneItemInOrderPatchMutation();
36+
37+
const receiptUrl = `${import.meta.env.VITE_PYCONKR_SHOP_API_DOMAIN}/v1/orders/${order.id}/receipt/`
38+
39+
const refundOrder = () => orderRefundMutation.mutate({ order_id: order.id });
40+
const openReceipt = () => window.open(receiptUrl, '_blank');
41+
42+
const isPending = disabled || orderRefundMutation.isPending || oneItemRefundMutation.isPending || optionsOfOneItemInOrderPatchMutation.isPending;
43+
const btnDisabled = isPending || !R.isNullish(order.not_fully_refundable_reason);
44+
const btnText = R.isNullish(order.not_fully_refundable_reason) ? '주문 전체 환불' : order.current_status === 'refunded' ? '주문 전체 환불됨' : order.not_fully_refundable_reason;
45+
46+
return <Accordion>
47+
<AccordionSummary expandIcon={<ExpandMore />}>
48+
<Typography variant="h6">{order.name}</Typography>
49+
</AccordionSummary>
50+
<AccordionDetails>
51+
<Divider />
52+
<br />
53+
<Typography variant="body1">주문 결제 금액 : <ShopComponent.PriceDisplay price={order.current_paid_price} /></Typography>
54+
<Typography variant="body1">상태: {PaymentHistoryStatusTranslated[order.current_status]}</Typography>
55+
<br />
56+
<Divider />
57+
<br />
58+
<Typography variant="body1">주문 상품 목록</Typography>
59+
<br />
60+
{
61+
order.products.map(
62+
(prodRels) => {
63+
const formRef = React.useRef<HTMLFormElement>(null);
64+
const currentCustomOptionValues: { [k: string]: string } = prodRels.options
65+
.filter((optionRel) => ShopAPIUtil.isOrderProductOptionModifiable(optionRel))
66+
.reduce((acc, optionRel) => ({ ...acc, [optionRel.product_option_group.id]: optionRel.custom_response }), {});
67+
68+
const hasPatchableOption = Object.entries(currentCustomOptionValues).length > 0;
69+
const patchOptionBtnDisabled = isPending || !hasPatchableOption;
70+
71+
const refundBtnDisabled = isPending || !R.isNullish(prodRels.not_refundable_reason);
72+
const refundBtnText = R.isNullish(prodRels.not_refundable_reason) ? '단일 상품 환불' : prodRels.status === 'refunded' ? '환불됨' : prodRels.not_refundable_reason;
73+
74+
const refundOneItem = () => oneItemRefundMutation.mutate({ order_id: order.id, order_product_relation_id: prodRels.id });
75+
const patchOneItemOptions = () => {
76+
if (!isFormValid(formRef.current))
77+
throw new Error('Form is not valid');
78+
79+
const modifiedCustomOptionValues: ShopAPISchema.OrderOptionsPatchRequest['options'] = Object.entries(getFormValue<{ [key: string]: string }>({ form: formRef.current }))
80+
.filter(([key, value]) => currentCustomOptionValues[key] !== value)
81+
.map(([key, value]) => ({ order_product_option_relation: key, custom_response: value }));
82+
83+
optionsOfOneItemInOrderPatchMutation.mutate({ order_id: order.id, order_product_relation_id: prodRels.id, options: modifiedCustomOptionValues });
84+
}
85+
86+
return <Accordion key={prodRels.id}>
87+
<AccordionSummary expandIcon={<ExpandMore />}>{prodRels.product.name}</AccordionSummary>
88+
<AccordionDetails>
89+
<form ref={formRef} onSubmit={(e) => { e.preventDefault(); patchOneItemOptions(); }}>
90+
<Stack spacing={2} sx={{ width: '100%' }}>
91+
{
92+
prodRels.options.map(
93+
(optionRel) => <ShopComponent.OrderProductRelationOptionInput
94+
key={optionRel.product_option_group.id + (optionRel.product_option?.id || '')}
95+
optionRel={optionRel}
96+
disabled={isPending}
97+
/>
98+
)
99+
}
100+
</Stack>
101+
</form>
102+
</AccordionDetails>
103+
<AccordionActions>
104+
<Button variant="contained" sx={{ width: '100%' }} onClick={patchOneItemOptions} disabled={patchOptionBtnDisabled}>옵션 수정</Button>
105+
<Button variant="contained" sx={{ width: '100%' }} onClick={refundOneItem} disabled={refundBtnDisabled}>{refundBtnText}</Button>
106+
</AccordionActions>
107+
</Accordion>
108+
}
109+
)
110+
}
111+
<br />
112+
<Divider />
113+
</AccordionDetails>
114+
<AccordionActions>
115+
<Button variant="contained" sx={{ width: '100%' }} onClick={openReceipt} disabled={btnDisabled}>영수증</Button>
116+
<Button variant="contained" sx={{ width: '100%' }} onClick={refundOrder} disabled={btnDisabled}>{btnText}</Button>
117+
</AccordionActions>
118+
</Accordion>
119+
}
120+
121+
export const ShopOrderList: React.FC = () => {
122+
const WrappedShopOrderList = wrap
123+
.ErrorBoundary({ fallback: <div>주문 내역을 불러오는 중 문제가 발생했습니다.</div> })
124+
.Suspense({ fallback: <CircularProgress /> })
125+
.on(() => {
126+
// eslint-disable-next-line react-hooks/rules-of-hooks
127+
const { data } = ShopAPIHook.useOrders();
128+
return <List>{data.map((item) => <ShopOrderItem key={item.id} order={item} />)}</List>
129+
})
130+
131+
return <>
132+
<Typography variant="h5" gutterBottom>Order List</Typography>
133+
<ShopComponent.ShopSignInGuard>
134+
<WrappedShopOrderList />
135+
</ShopComponent.ShopSignInGuard>
136+
</>
137+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React from "react";
2+
import * as R from "remeda";
3+
4+
import { ExpandMore } from "@mui/icons-material";
5+
import {
6+
Accordion,
7+
AccordionActions,
8+
AccordionDetails,
9+
AccordionSummary,
10+
Button,
11+
ButtonProps,
12+
CircularProgress,
13+
Divider,
14+
List,
15+
Stack,
16+
Typography
17+
} from "@mui/material";
18+
import { wrap } from "@suspensive/react";
19+
import { useQueryClient } from '@tanstack/react-query';
20+
21+
import { MDXRenderer } from "@pyconkr-common/components/mdx";
22+
import { getFormValue, isFormValid } from '@pyconkr-common/utils/form';
23+
import ShopComponent from '@pyconkr-shop/components';
24+
import ShopAPIHook from "@pyconkr-shop/hooks";
25+
import ShopAPISchema from "@pyconkr-shop/schemas";
26+
import ShopAPIUtil from "@pyconkr-shop/utils";
27+
28+
const getCartAppendRequestPayload = (product: ShopAPISchema.Product, formRef: React.RefObject<HTMLFormElement | null>): ShopAPISchema.CartItemAppendRequest => {
29+
if (!isFormValid(formRef.current))
30+
throw new Error('Form is not valid');
31+
32+
const options = Object.entries(getFormValue<{ [key: string]: string }>({ form: formRef.current })).map(([product_option_group, value]) => {
33+
const optionGroup = product.option_groups.find((group) => group.id === product_option_group);
34+
if (!optionGroup) throw new Error(`Option group ${product_option_group} not found`);
35+
36+
const product_option = optionGroup.is_custom_response ? null : value;
37+
const custom_response = optionGroup.is_custom_response ? value : null;
38+
return { product_option_group, product_option, custom_response }
39+
});
40+
return { product: product.id, options };
41+
}
42+
43+
const getProductNotPurchasableReason = (product: ShopAPISchema.Product): string | null => {
44+
// 상품이 구매 가능 기간 내에 있고, 상품이 매진되지 않았으며, 매진된 상품 옵션 재고가 없으면 true
45+
const now = new Date();
46+
const orderableStartsAt = new Date(product.orderable_starts_at);
47+
const orderableEndsAt = new Date(product.orderable_ends_at);
48+
if (orderableStartsAt > now) return `아직 구매할 수 없어요!\n(${orderableStartsAt.toLocaleString()}부터 구매하실 수 있어요.)`;
49+
if (orderableEndsAt < now) return '판매가 종료됐어요!';
50+
51+
if (R.isNumber(product.leftover_stock) && product.leftover_stock <= 0) return '상품이 품절되었어요!';
52+
if (product.option_groups.some((og) => !R.isEmpty(og.options) && og.options.every((o) => R.isNumber(o.leftover_stock) && o.leftover_stock <= 0)))
53+
return '선택 가능한 상품 옵션이 모두 품절되어 구매할 수 없어요!';
54+
55+
return null;
56+
}
57+
58+
const NotPurchasable: React.FC<React.PropsWithChildren> = ({ children }) => {
59+
return <Typography variant="body1" color="error" sx={{ width: '100%', textAlign: 'center', mt: '2rem', mb: '1rem' }}>
60+
{children}
61+
</Typography>
62+
}
63+
64+
const ShopProductItem: React.FC<{ product: ShopAPISchema.Product; onPaymentCompleted?: () => void; }> = ({ product, onPaymentCompleted }) => {
65+
const optionFormRef = React.useRef<HTMLFormElement>(null);
66+
67+
const queryClient = useQueryClient();
68+
const oneItemOrderStartMutation = ShopAPIHook.usePrepareOneItemOrderMutation();
69+
const addItemToCartMutation = ShopAPIHook.useAddItemToCartMutation();
70+
71+
const addItemToCart = () => addItemToCartMutation.mutate(getCartAppendRequestPayload(product, optionFormRef));
72+
const oneItemOrderStart = () => oneItemOrderStartMutation.mutate(
73+
getCartAppendRequestPayload(product, optionFormRef),
74+
{
75+
onSuccess: (order: ShopAPISchema.Order) => {
76+
ShopAPIUtil.startPortOnePurchase(
77+
order,
78+
() => {
79+
queryClient.invalidateQueries();
80+
queryClient.resetQueries();
81+
onPaymentCompleted?.();
82+
},
83+
(response) => alert("결제를 실패했습니다!\n" + response.error_msg),
84+
() => { }
85+
);
86+
},
87+
onError: (error) => alert(error.message || '결제 준비 중 문제가 발생했습니다,\n잠시 후 다시 시도해주세요.'),
88+
}
89+
);
90+
91+
const formOnSubmit: React.FormEventHandler = (e) => {
92+
e.preventDefault();
93+
e.stopPropagation();
94+
}
95+
const disabled = oneItemOrderStartMutation.isPending || addItemToCartMutation.isPending;
96+
97+
const notPurchasableReason = getProductNotPurchasableReason(product);
98+
const actionButtonProps: ButtonProps = {
99+
variant: "contained",
100+
color: "secondary",
101+
disabled,
102+
}
103+
104+
return <Accordion sx={{ width: '100%' }}>
105+
<AccordionSummary expandIcon={<ExpandMore />} sx={{ m: '0' }}>
106+
<Typography variant="h6">
107+
{product.name}
108+
</Typography>
109+
</AccordionSummary>
110+
<AccordionDetails sx={{ pt: '0', pb: '1rem', px: '2rem' }}>
111+
<MDXRenderer text={product.description || ''} />
112+
<br />
113+
<Divider />
114+
<ShopComponent.ShopSignInGuard fallback={<NotPurchasable>로그인 후 장바구니에 담거나 구매할 수 있어요.</NotPurchasable>}>
115+
{
116+
R.isNullish(notPurchasableReason)
117+
? <>
118+
<br />
119+
<form ref={optionFormRef} onSubmit={formOnSubmit}>
120+
<Stack spacing={2}>
121+
{product.option_groups.map((group) => <ShopComponent.OptionGroupInput optionGroup={group} options={group.options} defaultValue={group?.options[0]?.id || ''} disabled={disabled} />)}
122+
</Stack>
123+
</form>
124+
<br />
125+
<Divider />
126+
<br />
127+
<Typography variant="h6" sx={{ textAlign: 'right' }}>결제 금액: <ShopComponent.PriceDisplay price={product.price} /></Typography>
128+
</>
129+
: <NotPurchasable>{notPurchasableReason}</NotPurchasable>
130+
}
131+
</ShopComponent.ShopSignInGuard>
132+
</AccordionDetails>
133+
{
134+
R.isNullish(notPurchasableReason) && <AccordionActions sx={{ pt: '0', pb: '1rem', px: '2rem' }}>
135+
<Button {...actionButtonProps} onClick={addItemToCart}>장바구니 담기</Button>
136+
<Button {...actionButtonProps} onClick={oneItemOrderStart}>즉시 구매</Button>
137+
</AccordionActions>
138+
}
139+
</Accordion>
140+
}
141+
142+
export const ShopProductList: React.FC = () => {
143+
const WrappedShopProductList = wrap
144+
.ErrorBoundary({ fallback: <div>상품 목록을 불러오는 중 문제가 발생했습니다.</div> })
145+
.Suspense({ fallback: <CircularProgress /> })
146+
.on(() => {
147+
// eslint-disable-next-line react-hooks/rules-of-hooks
148+
const { data } = ShopAPIHook.useProducts();
149+
return <List>{data.map((product) => <ShopProductItem key={product.id} product={product} />)}</List>
150+
})
151+
152+
return <Stack>
153+
<Typography variant="h5" gutterBottom>Product List</Typography>
154+
<WrappedShopProductList />
155+
</Stack>
156+
}

0 commit comments

Comments
 (0)