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 }  ) ; 
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 }  ) ; 
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 } ${ 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 }   : `Failed to complete the payment.${ pleaseRetryStr }  ; 
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