11import * 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" ;
33import { ErrorBoundary , Suspense } from "@suspensive/react" ;
44import { useQueryClient } from "@tanstack/react-query" ;
55import { enqueueSnackbar , OptionsObject } from "notistack" ;
@@ -15,22 +15,25 @@ import CommonComponents from "../common";
1515const 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
3639const 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+ 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