Skip to content

Commit 884e497

Browse files
committed
feat: ProductImageCardList 추가
1 parent 03f18f6 commit 884e497

File tree

3 files changed

+239
-17
lines changed

3 files changed

+239
-17
lines changed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export const ShopTestPage: React.FC = () => (
1717
상품 목록
1818
</Typography>
1919
<Shop.Components.Features.ProductList category_group="2025" category="티켓" />
20+
<Typography variant="h5" gutterBottom>
21+
상품 목록 (이미지 카드)
22+
</Typography>
23+
<Shop.Components.Features.ProductImageCardList category_group="2025" category="티셔츠" />
2024
<Divider />
2125
<Typography variant="h5" gutterBottom>
2226
장바구니

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { CartStatus as CartStatus_ } from "./cart";
22
import { OrderList as OrderList_ } from "./order";
3-
import { ProductList as ProductList_ } from "./product";
3+
import { ProductImageCardList as ProductImageCardList_, ProductList as ProductList_ } from "./product";
44
import { UserInfo as UserInfo_ } from "./user_status";
55

66
namespace FeatureComponents {
77
export const CartStatus = CartStatus_;
88
export const OrderList = OrderList_;
99
export const ProductList = ProductList_;
10+
export const ProductImageCardList = ProductImageCardList_;
1011
export const UserInfo = UserInfo_;
1112
}
1213

packages/shop/src/components/features/product.tsx

Lines changed: 233 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
import * as Common from "@frontend/common";
2-
import { AccordionProps, Button, ButtonProps, CircularProgress, Divider, Stack, TextField, Typography } from "@mui/material";
2+
import { Close } from "@mui/icons-material";
3+
import {
4+
AccordionProps,
5+
Box,
6+
Button,
7+
ButtonProps,
8+
Card,
9+
CardActions,
10+
CardContent,
11+
CardMedia,
12+
CircularProgress,
13+
Dialog,
14+
DialogContent,
15+
DialogProps,
16+
DialogTitle,
17+
Divider,
18+
Grid,
19+
IconButton,
20+
Stack,
21+
styled,
22+
TextField,
23+
Typography,
24+
} from "@mui/material";
325
import { ErrorBoundary, Suspense } from "@suspensive/react";
426
import { useQueryClient } from "@tanstack/react-query";
527
import { enqueueSnackbar, OptionsObject } from "notistack";
@@ -68,14 +90,15 @@ const NotPurchasable: React.FC<React.PropsWithChildren> = ({ children }) => {
6890
);
6991
};
7092

71-
type ProductItemPropType = Omit<AccordionProps, "children"> & {
93+
type ProductItemPropType = {
7294
disabled?: boolean;
7395
language: "ko" | "en";
7496
product: ShopSchemas.Product;
97+
onAddToCartSuccess?: () => void;
7598
startPurchaseProcess: (oneItemOrderData: ShopSchemas.CartItemAppendRequest) => void;
7699
};
77100

78-
const ProductItem: React.FC<ProductItemPropType> = ({ disabled: rootDisabled, language, product, startPurchaseProcess, ...props }) => {
101+
const ProductItem: React.FC<ProductItemPropType> = ({ disabled: rootDisabled, language, product, startPurchaseProcess, onAddToCartSuccess }) => {
79102
const navigate = useNavigate();
80103
const [, forceRender] = React.useReducer((x) => x + 1, 0);
81104
const [donationPrice, setDonationPrice] = React.useState<string>(product.donation_min_price?.toString() || "0");
@@ -168,7 +191,7 @@ const ProductItem: React.FC<ProductItemPropType> = ({ disabled: rootDisabled, la
168191
if (!formData) return;
169192

170193
addItemToCartMutation.mutate(formData, {
171-
onSuccess: () =>
194+
onSuccess: () => {
172195
addSnackbar(
173196
<Stack spacing={2} justifyContent="center" alignItems="center" sx={{ width: "100%", flexGrow: 1 }}>
174197
<div>{succeededToAddOneItemToCartStr}</div>
@@ -182,7 +205,9 @@ const ProductItem: React.FC<ProductItemPropType> = ({ disabled: rootDisabled, la
182205
/>
183206
</Stack>,
184207
"success"
185-
),
208+
);
209+
onAddToCartSuccess?.();
210+
},
186211
onError: () => alert(failedToAddOneItemToCartStr),
187212
});
188213
};
@@ -202,15 +227,8 @@ const ProductItem: React.FC<ProductItemPropType> = ({ disabled: rootDisabled, la
202227
return totalPrice;
203228
};
204229

205-
const actionButton = R.isNullish(notPurchasableReason) && (
206-
<CommonComponents.SignInGuard fallback={<NotPurchasable>{requiresSignInStr}</NotPurchasable>}>
207-
<Button {...actionButtonProps} onClick={addItemToCart} children={addToCartStr} />
208-
<Button {...actionButtonProps} onClick={onOrderOneItemButtonClick} children={orderOneItemStr} />
209-
</CommonComponents.SignInGuard>
210-
);
211-
212230
return (
213-
<Common.Components.MDX.PrimaryStyledDetails {...props} summary={product.name} actions={actionButton}>
231+
<>
214232
<Common.Components.MDXRenderer text={product.description || ""} />
215233
<br />
216234
<Divider />
@@ -283,11 +301,108 @@ const ProductItem: React.FC<ProductItemPropType> = ({ disabled: rootDisabled, la
283301
) : (
284302
<NotPurchasable>{notPurchasableReason}</NotPurchasable>
285303
)}
304+
{R.isNullish(notPurchasableReason) && (
305+
<CommonComponents.SignInGuard fallback={<NotPurchasable>{requiresSignInStr}</NotPurchasable>}>
306+
<Stack direction="row" spacing={1} sx={{ justifyContent: "flex-end", mt: 2 }}>
307+
<Button {...actionButtonProps} onClick={addItemToCart} children={addToCartStr} />
308+
<Button {...actionButtonProps} onClick={onOrderOneItemButtonClick} children={orderOneItemStr} />
309+
</Stack>
310+
</CommonComponents.SignInGuard>
311+
)}
312+
</>
313+
);
314+
};
315+
316+
type FoldableProductItemPropType = Omit<AccordionProps, "children"> & ProductItemPropType;
317+
318+
const FoldableProductItem: React.FC<FoldableProductItemPropType> = ({ disabled, language, product, startPurchaseProcess, ...props }) => {
319+
return (
320+
<Common.Components.MDX.PrimaryStyledDetails {...props} summary={product.name}>
321+
<ProductItem disabled={disabled} language={language} product={product} startPurchaseProcess={startPurchaseProcess} />
286322
</Common.Components.MDX.PrimaryStyledDetails>
287323
);
288324
};
289325

290-
type ProductStateType = {
326+
const CloseButton = styled(IconButton)(({ theme }) => ({
327+
position: "absolute",
328+
right: theme.spacing(1),
329+
top: theme.spacing(1),
330+
color: theme.palette.grey[500],
331+
}));
332+
333+
type DialogedProductItemPropType = Omit<DialogProps, "children"> &
334+
Omit<ProductItemPropType, "product"> & {
335+
product?: ShopSchemas.Product;
336+
};
337+
338+
const DialogedProductItem: React.FC<DialogedProductItemPropType> = ({ disabled, language, product, startPurchaseProcess, ...props }) => {
339+
const dialogTitle = language === "ko" ? "상품 상세 정보" : "Product Details";
340+
const onCloseClick = (props.onClose as () => void) || (() => {});
341+
return (
342+
<Dialog maxWidth="md" fullWidth {...props}>
343+
<DialogTitle>{dialogTitle}</DialogTitle>
344+
<CloseButton onClick={onCloseClick} children={<Close />} />
345+
<DialogContent>
346+
{product && (
347+
<ProductItem
348+
disabled={disabled}
349+
language={language}
350+
product={product}
351+
startPurchaseProcess={startPurchaseProcess}
352+
onAddToCartSuccess={onCloseClick}
353+
/>
354+
)}
355+
</DialogContent>
356+
</Dialog>
357+
);
358+
};
359+
360+
type ProductImageCardPropType = {
361+
language: "ko" | "en";
362+
product: ShopSchemas.Product;
363+
disabled?: boolean;
364+
showDetail: (product: ShopSchemas.Product) => void;
365+
};
366+
367+
const StyledProductImageCard = styled(Card)(({ theme }) => ({
368+
cursor: "pointer",
369+
maxWidth: "300px",
370+
borderRadius: "0.5rem",
371+
border: `1px solid ${theme.palette.primary.light}`,
372+
transition: "all 0.2s ease",
373+
374+
"&:hover": {
375+
boxShadow: theme.shadows[3],
376+
borderColor: theme.palette.primary.main,
377+
},
378+
}));
379+
380+
const ProductImageCard: React.FC<ProductImageCardPropType> = ({ language, product, disabled, showDetail }) => {
381+
const showDetailStr = language === "ko" ? "상품 상세 정보 보기" : "View Product Details";
382+
return (
383+
<StyledProductImageCard onClick={() => showDetail(product)} elevation={0}>
384+
<CardMedia sx={{ height: "200px", objectFit: "contain", borderRadius: "0 0 0.5rem 0.5rem" }}>
385+
<Common.Components.FallbackImage
386+
src={product.image || ""}
387+
alt="Product Image"
388+
loading="lazy"
389+
errorFallback={<Box sx={{ width: "100%", height: "100%", flexGrow: 1, backgroundColor: "#bbb", borderRadius: "0 0 0.5rem 0.5rem" }} />}
390+
/>
391+
</CardMedia>
392+
<CardContent sx={{ py: 1 }}>
393+
<Stack spacing={1}>
394+
<Typography variant="h6" sx={{ textAlign: "center" }} children={product.name} />
395+
<Typography variant="body1" sx={{ textAlign: "right" }} children={<CommonComponents.PriceDisplay price={product.price} />} />
396+
</Stack>
397+
</CardContent>
398+
<CardActions>
399+
<Button variant="outlined" color="primary" disabled={disabled} children={showDetailStr} fullWidth />
400+
</CardActions>
401+
</StyledProductImageCard>
402+
);
403+
};
404+
405+
type ProductListStateType = {
291406
openDialog: boolean;
292407
openBackdrop: boolean;
293408
product?: ShopSchemas.Product;
@@ -303,7 +418,7 @@ export const ProductList: React.FC<ShopSchemas.ProductListQueryParams> = (qs) =>
303418
const oneItemOrderStartMutation = ShopHooks.usePrepareOneItemOrderMutation(shopAPIClient);
304419
const { data } = ShopHooks.useProducts(shopAPIClient, qs);
305420

306-
const [state, setState] = React.useState<ProductStateType>({
421+
const [state, setState] = React.useState<ProductListStateType>({
307422
openDialog: false,
308423
openBackdrop: false,
309424
});
@@ -353,7 +468,7 @@ export const ProductList: React.FC<ShopSchemas.ProductListQueryParams> = (qs) =>
353468
<CommonComponents.CustomerInfoFormDialog open={state.openDialog} closeFunc={closeDialog} onSubmit={onFormSubmit} />
354469
<Common.Components.MDX.OneDetailsOpener>
355470
{data.map((p) => (
356-
<ProductItem
471+
<FoldableProductItem
357472
disabled={oneItemOrderStartMutation.isPending}
358473
language={language}
359474
key={p.id}
@@ -376,3 +491,105 @@ export const ProductList: React.FC<ShopSchemas.ProductListQueryParams> = (qs) =>
376491
</ErrorBoundary>
377492
);
378493
};
494+
495+
type ProductImageCardListStateType = {
496+
openProductDialog: boolean;
497+
openCustomerInfoDialog: boolean;
498+
openBackdrop: boolean;
499+
product?: ShopSchemas.Product;
500+
oneItemOrderData?: ShopSchemas.CartItemAppendRequest;
501+
};
502+
503+
export const ProductImageCardList: React.FC<ShopSchemas.ProductListQueryParams> = (qs) => {
504+
const WrappedProductImageCardList: React.FC = () => {
505+
const queryClient = useQueryClient();
506+
const navigate = useNavigate();
507+
const { language, shopImpAccountId } = ShopHooks.useShopContext();
508+
const shopAPIClient = ShopHooks.useShopClient();
509+
const oneItemOrderStartMutation = ShopHooks.usePrepareOneItemOrderMutation(shopAPIClient);
510+
const { data } = ShopHooks.useProducts(shopAPIClient, qs);
511+
512+
const [state, setState] = React.useState<ProductImageCardListStateType>({
513+
openProductDialog: false,
514+
openCustomerInfoDialog: false,
515+
openBackdrop: false,
516+
});
517+
518+
const openProductDialog = (product: ShopSchemas.Product) => setState((ps) => ({ ...ps, product, openProductDialog: true }));
519+
const closeProductDialog = () => setState((ps) => ({ ...ps, openProductDialog: false }));
520+
const openCustomerInfoDialog = () => setState((ps) => ({ ...ps, openCustomerInfoDialog: true }));
521+
const closeCustomerInfoDialog = () => setState((ps) => ({ ...ps, openCustomerInfoDialog: false }));
522+
const openBackdrop = () => setState((ps) => ({ ...ps, openBackdrop: true }));
523+
const closeBackdrop = () => setState((ps) => ({ ...ps, openBackdrop: false }));
524+
const setProductDataAndOpenDialog = (oneItemOrderData: ShopSchemas.CartItemAppendRequest) => {
525+
closeProductDialog();
526+
setState((ps) => ({ ...ps, oneItemOrderData }));
527+
openCustomerInfoDialog();
528+
};
529+
530+
const pleaseRetryStr = language === "ko" ? "\n잠시 후 다시 시도해주세요." : "\nPlease try again later.";
531+
const failedToOrderStr = language === "ko" ? `결제에 실패했습니다.${pleaseRetryStr}\n` : `Failed to complete the payment.${pleaseRetryStr}\n`;
532+
const orderErrorStr =
533+
language === "ko" ? `결제 준비 중 문제가 발생했습니다,${pleaseRetryStr}` : `An error occurred while preparing the payment,${pleaseRetryStr}`;
534+
535+
const onFormSubmit = (customer_info: ShopSchemas.CustomerInfo) => {
536+
if (!state.oneItemOrderData) return;
537+
538+
closeCustomerInfoDialog();
539+
openBackdrop();
540+
oneItemOrderStartMutation.mutate(
541+
{ ...state.oneItemOrderData, customer_info: customer_info },
542+
{
543+
onSuccess: (order: ShopSchemas.Order) => {
544+
ShopUtils.startPortOnePurchase(
545+
shopImpAccountId,
546+
order,
547+
() => {
548+
queryClient.invalidateQueries();
549+
queryClient.resetQueries();
550+
navigate("/store/thank-you-for-your-purchase");
551+
},
552+
(response) => alert(failedToOrderStr + response.error_msg),
553+
closeBackdrop
554+
);
555+
},
556+
onError: (error) => alert(error.message || orderErrorStr),
557+
}
558+
);
559+
};
560+
561+
return (
562+
<>
563+
<CommonComponents.CustomerInfoFormDialog open={state.openCustomerInfoDialog} closeFunc={closeCustomerInfoDialog} onSubmit={onFormSubmit} />
564+
<DialogedProductItem
565+
open={state.openProductDialog}
566+
onClose={closeProductDialog}
567+
language={language}
568+
product={state.product}
569+
startPurchaseProcess={setProductDataAndOpenDialog}
570+
/>
571+
<Grid>
572+
{data.map((p) => (
573+
<ProductImageCard
574+
disabled={oneItemOrderStartMutation.isPending}
575+
language={language}
576+
key={p.id}
577+
product={p}
578+
showDetail={openProductDialog}
579+
/>
580+
))}
581+
</Grid>
582+
</>
583+
);
584+
};
585+
586+
return (
587+
<ErrorBoundary fallback={<div>상품 목록을 불러오는 중 문제가 발생했습니다.</div>}>
588+
<Suspense fallback={<CircularProgress />}>
589+
<Stack spacing={2}>
590+
<WrappedProductImageCardList />
591+
</Stack>
592+
</Suspense>
593+
</ErrorBoundary>
594+
);
595+
};

0 commit comments

Comments
 (0)