| 
 | 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