Skip to content

Commit 4131f5b

Browse files
committed
feat: 개인 후원 상품에서 추가 금액을 받을 수 있도록 수정
1 parent 3ba4918 commit 4131f5b

File tree

1 file changed

+158
-60
lines changed

1 file changed

+158
-60
lines changed

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

Lines changed: 158 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as Common from "@frontend/common";
2-
import { AccordionProps, Button, ButtonProps, CircularProgress, Divider, Stack, Typography } from "@mui/material";
2+
import { AccordionProps, Button, ButtonProps, CircularProgress, Divider, Stack, TextField, Typography } from "@mui/material";
33
import { ErrorBoundary, Suspense } from "@suspensive/react";
44
import { useQueryClient } from "@tanstack/react-query";
55
import { enqueueSnackbar, OptionsObject } from "notistack";
@@ -15,22 +15,25 @@ import CommonComponents from "../common";
1515
const getCartAppendRequestPayload = (
1616
product: ShopSchemas.Product,
1717
formRef: React.RefObject<HTMLFormElement | null>
18-
): ShopSchemas.CartItemAppendRequest => {
19-
if (!Common.Utils.isFormValid(formRef.current)) throw new Error("Form is not valid");
20-
21-
const options = Object.entries(
22-
Common.Utils.getFormValue<{ [key: string]: string }>({
23-
form: formRef.current,
24-
})
25-
).map(([product_option_group, value]) => {
26-
const optionGroup = product.option_groups.find((group) => group.id === product_option_group);
27-
if (!optionGroup) throw new Error(`Option group ${product_option_group} not found`);
28-
29-
const product_option = optionGroup.is_custom_response ? null : value;
30-
const custom_response = optionGroup.is_custom_response ? value : null;
31-
return { product_option_group, product_option, custom_response };
32-
});
33-
return { product: product.id, options };
18+
): ShopSchemas.CartItemAppendRequest | null => {
19+
if (!Common.Utils.isFormValid(formRef.current)) return null;
20+
21+
const formValue = Common.Utils.getFormValue<{ [key: string]: string }>({ form: formRef.current });
22+
let donation_price = formValue.donation_price ? parseInt(formValue.donation_price) : 0;
23+
if (isNaN(donation_price)) donation_price = 0;
24+
25+
const options = Object.entries(formValue)
26+
.filter(([product_option_group]) => product_option_group !== "donation_price")
27+
.map(([product_option_group, value]) => {
28+
const optionGroup = product.option_groups.find((group) => group.id === product_option_group);
29+
if (!optionGroup) throw new Error(`Option group ${product_option_group} not found`);
30+
31+
const product_option = optionGroup.is_custom_response ? null : value;
32+
const custom_response = optionGroup.is_custom_response ? value : null;
33+
return { product_option_group, product_option, custom_response };
34+
});
35+
36+
return { product: product.id, options, ...(product.donation_allowed ? { donation_price } : {}) };
3437
};
3538

3639
const getProductNotPurchasableReason = (language: "ko" | "en", product: ShopSchemas.Product): string | null => {
@@ -45,16 +48,11 @@ const getProductNotPurchasableReason = (language: "ko" | "en", product: ShopSche
4548
return `You cannot purchase this product yet!\n(Starts at ${orderableStartsAt.toLocaleString()})`;
4649
}
4750
}
48-
if (orderableEndsAt < now)
49-
return language === "ko" ? "판매가 종료됐어요!" : "This product is no longer available for purchase!";
51+
if (orderableEndsAt < now) return language === "ko" ? "판매가 종료됐어요!" : "This product is no longer available for purchase!";
5052

5153
if (R.isNumber(product.leftover_stock) && product.leftover_stock <= 0)
5254
return language === "ko" ? "상품이 매진되었어요!" : "This product is out of stock!";
53-
if (
54-
product.option_groups.some(
55-
(og) => !R.isEmpty(og.options) && og.options.every((o) => R.isNumber(o.leftover_stock) && o.leftover_stock <= 0)
56-
)
57-
)
55+
if (product.option_groups.some((og) => !R.isEmpty(og.options) && og.options.every((o) => R.isNumber(o.leftover_stock) && o.leftover_stock <= 0)))
5856
return language === "ko"
5957
? "선택 가능한 상품 옵션이 모두 품절되어 구매할 수 없어요!"
6058
: "All selectable options for this product are out of stock!";
@@ -77,46 +75,99 @@ type ProductItemPropType = Omit<AccordionProps, "children"> & {
7775
startPurchaseProcess: (oneItemOrderData: ShopSchemas.CartItemAppendRequest) => void;
7876
};
7977

80-
const ProductItem: React.FC<ProductItemPropType> = ({
81-
disabled,
82-
language,
83-
product,
84-
startPurchaseProcess,
85-
...props
86-
}) => {
78+
const ProductItem: React.FC<ProductItemPropType> = ({ disabled: rootDisabled, language, product, startPurchaseProcess, ...props }) => {
8779
const navigate = useNavigate();
80+
const [, forceRender] = React.useReducer((x) => x + 1, 0);
81+
const [donationPrice, setDonationPrice] = React.useState<string>(product.donation_min_price?.toString() || "0");
82+
const [helperText, setHelperText] = React.useState<string | undefined>(undefined);
83+
const donationInputRef = React.useRef<HTMLInputElement>(null);
8884
const optionFormRef = React.useRef<HTMLFormElement>(null);
8985
const shopAPIClient = ShopHooks.useShopClient();
9086
const addItemToCartMutation = ShopHooks.useAddItemToCartMutation(shopAPIClient);
9187
const addSnackbar = (c: string | React.ReactNode, variant: OptionsObject["variant"]) =>
9288
enqueueSnackbar(c, { variant, anchorOrigin: { vertical: "bottom", horizontal: "center" } });
9389

9490
const requiresSignInStr =
95-
language === "ko"
96-
? "로그인 후 장바구니에 담거나 구매할 수 있어요."
97-
: "You need to sign in to add items to the cart or make a purchase.";
91+
language === "ko" ? "로그인 후 장바구니에 담거나 구매할 수 있어요." : "You need to sign in to add items to the cart or make a purchase.";
9892
const addToCartStr = language === "ko" ? "장바구니에 담기" : "Add to Cart";
9993
const orderOneItemStr = language === "ko" ? "즉시 구매" : "Buy Now";
10094
const orderPriceStr = language === "ko" ? "결제 금액" : "Price";
101-
const succeededToAddOneItemToCartStr =
102-
language === "ko" ? "장바구니에 상품을 담았어요!" : "The product has been added to the cart!";
95+
const succeededToAddOneItemToCartStr = language === "ko" ? "장바구니에 상품을 담았어요!" : "The product has been added to the cart!";
10396
const failedToAddOneItemToCartStr =
10497
language === "ko"
10598
? "장바구니에 상품을 담는 중 문제가 발생했어요,\n잠시 후 다시 시도해주세요."
10699
: "An error occurred while adding the product to the cart,\nplease try again later.";
107100
const gotoCartPageStr = language === "ko" ? "장바구니로 이동" : "Go to Cart";
101+
const donationLabelStr = language === "ko" ? "추가 기부 금액" : "Additional Donation Amount";
102+
const thankYouForDonationStr =
103+
language === "ko"
104+
? "후원을 통해 PyCon 한국 준비 위원회와 함께해주셔서 정말 감사합니다!"
105+
: "Thank you for supporting PyCon Korea Organizing Team!";
106+
const pleaseEnterDonationAmountStr =
107+
language === "ko"
108+
? "만약 추가로 후원하고 싶은 금액이 있으시면, 아래에 입력해주시면 추가로 후원해주실 수 있습니다!"
109+
: "If you would like to donate more, you can donate more by entering the amount below!";
110+
const errDonationPriceShouldBetweenMinAndMaxStr =
111+
language === "ko"
112+
? `기부 금액은 ${product.donation_min_price}원 이상, ${product.donation_max_price}원 이하로 입력해주세요.`
113+
: `Please enter a donation amount between ${product.donation_min_price} and ${product.donation_max_price}.`;
114+
const errDonationPriceIsNotNumberStr =
115+
language === "ko" ? "기부 금액은 숫자로 입력해주세요." : "Please enter a valid number for the donation amount.";
116+
const possibleDonationAmountStr =
117+
language === "ko" ? (
118+
<>
119+
최소 <CommonComponents.PriceDisplay price={product.donation_min_price || 0} />
120+
, 최대 <CommonComponents.PriceDisplay price={product.donation_max_price || 0} />
121+
까지 입력할 수 있습니다.
122+
</>
123+
) : (
124+
<>
125+
You can enter a minimum of <CommonComponents.PriceDisplay price={product.donation_min_price || 0} />
126+
&nbsp;and a maximum of <CommonComponents.PriceDisplay price={product.donation_max_price || 0} />.
127+
</>
128+
);
108129

109130
const formOnSubmit: React.FormEventHandler = (e) => {
110131
e.preventDefault();
111132
e.stopPropagation();
112133
};
113-
const shouldBeDisabled = disabled || addItemToCartMutation.isPending;
134+
const disabled = rootDisabled || addItemToCartMutation.isPending;
114135

115136
const notPurchasableReason = getProductNotPurchasableReason(language, product);
116-
const actionButtonProps: ButtonProps = { variant: "contained", color: "secondary", disabled: shouldBeDisabled };
137+
const actionButtonProps: ButtonProps = { variant: "contained", color: "secondary", disabled: disabled || R.isString(helperText) };
138+
139+
const validateDonationPrice: React.FocusEventHandler<HTMLInputElement | HTMLTextAreaElement> = (e) => {
140+
const value = e.target.value.trim().replace(/e/i, "") || "0";
141+
const originalValue = donationPrice;
142+
143+
if (!/^[0-9]+$/.test(value)) {
144+
setHelperText(errDonationPriceIsNotNumberStr);
145+
setDonationPrice(originalValue);
146+
return;
147+
}
117148

118-
const addItemToCart = () =>
119-
addItemToCartMutation.mutate(getCartAppendRequestPayload(product, optionFormRef), {
149+
const parsedValue = parseInt(value);
150+
if (parsedValue < (product.donation_min_price || 0) || parsedValue > (product.donation_max_price || 0)) {
151+
setHelperText(errDonationPriceShouldBetweenMinAndMaxStr);
152+
setDonationPrice(parsedValue.toString());
153+
return;
154+
}
155+
setHelperText(undefined);
156+
setDonationPrice(parsedValue.toString());
157+
forceRender();
158+
};
159+
const onEnterPressedOnDonationInput: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
160+
if (e.key === "Enter") {
161+
e.preventDefault();
162+
e.stopPropagation();
163+
validateDonationPrice(e as unknown as React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>);
164+
}
165+
};
166+
const addItemToCart = () => {
167+
const formData = getCartAppendRequestPayload(product, optionFormRef);
168+
if (!formData) return;
169+
170+
addItemToCartMutation.mutate(formData, {
120171
onSuccess: () =>
121172
addSnackbar(
122173
<Stack spacing={2} justifyContent="center" alignItems="center" sx={{ width: "100%", flexGrow: 1 }}>
@@ -134,7 +185,23 @@ const ProductItem: React.FC<ProductItemPropType> = ({
134185
),
135186
onError: () => alert(failedToAddOneItemToCartStr),
136187
});
137-
const onOrderOneItemButtonClick = () => startPurchaseProcess(getCartAppendRequestPayload(product, optionFormRef));
188+
};
189+
const onOrderOneItemButtonClick = () => {
190+
const formData = getCartAppendRequestPayload(product, optionFormRef);
191+
if (!formData) return;
192+
193+
startPurchaseProcess(formData);
194+
};
195+
196+
const getTotalProductPrice = (): number => {
197+
let totalPrice = product.price;
198+
if (product.donation_allowed) {
199+
const donation_price = parseInt(donationPrice);
200+
if (!isNaN(donation_price)) totalPrice += donation_price;
201+
}
202+
return totalPrice;
203+
};
204+
138205
const actionButton = R.isNullish(notPurchasableReason) && (
139206
<CommonComponents.SignInGuard fallback={<NotPurchasable>{requiresSignInStr}</NotPurchasable>}>
140207
<Button {...actionButtonProps} onClick={addItemToCart} children={addToCartStr} />
@@ -161,17 +228,56 @@ const ProductItem: React.FC<ProductItemPropType> = ({
161228
disabled={disabled}
162229
/>
163230
))}
231+
{product.donation_allowed && (
232+
<>
233+
{product.option_groups.length > 0 && (
234+
<>
235+
<Divider />
236+
<br />
237+
</>
238+
)}
239+
<Typography variant="body1" sx={{ mb: 1 }}>
240+
{thankYouForDonationStr}
241+
<br />
242+
{pleaseEnterDonationAmountStr}
243+
</Typography>
244+
<Typography variant="body2" sx={{ mb: 1 }} children={possibleDonationAmountStr} />
245+
<TextField
246+
label={donationLabelStr}
247+
disabled={disabled}
248+
/*
249+
TODO: FIXME: Fis this to use controlled input instead of this shitty uncontrolled input.
250+
This was the worst way to handle the donation price input validation...
251+
Whatever reason, this stupid input unfocus when user types any character,
252+
so I had to use a uncontrolled input to prevent this issue, and handle the validation manually on onBlur and onKeyDown events.
253+
I really hate this.
254+
*/
255+
defaultValue={donationPrice}
256+
onBlur={validateDonationPrice}
257+
onKeyDown={onEnterPressedOnDonationInput}
258+
type="number"
259+
name="donation_price"
260+
fullWidth
261+
helperText={helperText}
262+
error={R.isString(helperText)}
263+
inputRef={donationInputRef}
264+
slotProps={{
265+
htmlInput: {
266+
min: product.donation_min_price,
267+
max: product.donation_max_price,
268+
pattern: new RegExp(/^[0-9]+$/, "i").source,
269+
},
270+
}}
271+
/>
272+
</>
273+
)}
274+
<Divider />
275+
<br />
164276
</Stack>
165277
</form>
166278
<br />
167-
{product.option_groups.length > 0 && (
168-
<>
169-
<Divider />
170-
<br />
171-
</>
172-
)}
173279
<Typography variant="h6" sx={{ textAlign: "right" }}>
174-
{orderPriceStr}: <CommonComponents.PriceDisplay price={product.price} />
280+
{orderPriceStr}: <CommonComponents.PriceDisplay price={getTotalProductPrice()} />
175281
</Typography>
176282
</>
177283
) : (
@@ -211,14 +317,10 @@ export const ProductList: React.FC<ShopSchemas.ProductListQueryParams> = (qs) =>
211317
openDialog();
212318
};
213319

320+
const pleaseRetryStr = language === "ko" ? "\n잠시 후 다시 시도해주세요." : "\nPlease try again later.";
321+
const failedToOrderStr = language === "ko" ? `결제에 실패했습니다.${pleaseRetryStr}\n` : `Failed to complete the payment.${pleaseRetryStr}\n`;
214322
const orderErrorStr =
215-
language === "ko"
216-
? "결제 준비 중 문제가 발생했습니다,\n잠시 후 다시 시도해주세요."
217-
: "An error occurred while preparing the payment, please try again later.";
218-
const failedToOrderStr =
219-
language === "ko"
220-
? "결제에 실패했습니다.\n잠시 후 다시 시도해주세요.\n"
221-
: "Failed to complete the payment. Please try again later.\n";
323+
language === "ko" ? `결제 준비 중 문제가 발생했습니다,${pleaseRetryStr}` : `An error occurred while preparing the payment,${pleaseRetryStr}`;
222324

223325
const onFormSubmit = (customer_info: ShopSchemas.CustomerInfo) => {
224326
if (!state.oneItemOrderData) return;
@@ -248,11 +350,7 @@ export const ProductList: React.FC<ShopSchemas.ProductListQueryParams> = (qs) =>
248350

249351
return (
250352
<>
251-
<CommonComponents.CustomerInfoFormDialog
252-
open={state.openDialog}
253-
closeFunc={closeDialog}
254-
onSubmit={onFormSubmit}
255-
/>
353+
<CommonComponents.CustomerInfoFormDialog open={state.openDialog} closeFunc={closeDialog} onSubmit={onFormSubmit} />
256354
<Common.Components.MDX.OneDetailsOpener>
257355
{data.map((p) => (
258356
<ProductItem

0 commit comments

Comments
 (0)