Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / components / containers / payments / EditCardModal.tsx
blob669cd294ca773f3a8f4ac1738e5237bc3271abdb
1 import type { FormEvent } from 'react';
2 import { useEffect, useState } from 'react';
4 import { c } from 'ttag';
6 import { useSubscription } from '@proton/account/subscription/hooks';
7 import { useUser } from '@proton/account/user/hooks';
8 import { Button } from '@proton/atoms';
9 import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
10 import ModalTwo from '@proton/components/components/modalTwo/Modal';
11 import ModalTwoContent from '@proton/components/components/modalTwo/ModalContent';
12 import ModalTwoFooter from '@proton/components/components/modalTwo/ModalFooter';
13 import ModalTwoHeader from '@proton/components/components/modalTwo/ModalHeader';
14 import useApi from '@proton/components/hooks/useApi';
15 import useEventManager from '@proton/components/hooks/useEventManager';
16 import useNotifications from '@proton/components/hooks/useNotifications';
17 import { usePaymentFacade } from '@proton/components/payments/client-extensions';
18 import { useChargebeeContext } from '@proton/components/payments/client-extensions/useChargebeeContext';
19 import { useLoading } from '@proton/hooks';
20 import type { CardModel, PaymentMethodCardDetails } from '@proton/payments';
21 import {
22     Autopay,
23     PAYMENT_METHOD_TYPES,
24     isV5PaymentToken,
25     paymentMethodPaymentsVersion,
26     v5PaymentTokenToLegacyPaymentToken,
27 } from '@proton/payments';
28 import {
29     getPaymentsVersion,
30     setPaymentMethodV4,
31     setPaymentMethodV5,
32     updatePaymentMethod,
33 } from '@proton/shared/lib/api/payments';
34 import { captureMessage } from '@proton/shared/lib/helpers/sentry';
35 import { getSentryError } from '@proton/shared/lib/keys';
36 import noop from '@proton/utils/noop';
38 import { ChargebeeCreditCardWrapper } from '../../payments/chargebee/ChargebeeWrapper';
39 import CreditCard from './CreditCard';
40 import RenewToggle, { useRenewToggle } from './RenewToggle';
42 interface Props extends Omit<ModalProps<'form'>, 'as' | 'children' | 'size'> {
43     card?: CardModel;
44     renewState?: Autopay;
45     paymentMethod?: PaymentMethodCardDetails;
46     onMethodAdded?: () => void;
47     enableRenewToggle?: boolean;
50 const EditCardModal = ({
51     card: existingCard,
52     renewState,
53     paymentMethod,
54     onMethodAdded,
55     enableRenewToggle = true,
56     ...rest
57 }: Props) => {
58     const api = useApi();
59     const [user] = useUser();
60     const [subscription] = useSubscription();
62     const { call } = useEventManager();
63     const [processing, withProcessing] = useLoading();
64     const { createNotification } = useNotifications();
65     const title = existingCard ? c('Title').t`Edit credit/debit card` : c('Title').t`Add credit/debit card`;
67     const [chargebeeFormInitialized, setChargebeeFormInitialized] = useState(false);
69     const {
70         onChange: renewOnChange,
71         setRenewState,
72         ...renewToggleProps
73     } = useRenewToggle({ initialRenewState: renewState });
75     const chargebeeContext = useChargebeeContext();
77     const paymentFacade = usePaymentFacade({
78         amount: 0,
79         currency: user.Currency,
80         flow: 'add-card',
81         billingPlatform: subscription?.BillingPlatform,
82         chargebeeUserExists: user.ChargebeeUserExists,
83         onChargeable: async (_, { chargeablePaymentParameters, sourceType }) => {
84             withProcessing(async () => {
85                 if (!isV5PaymentToken(chargeablePaymentParameters)) {
86                     return;
87                 }
89                 if (sourceType === PAYMENT_METHOD_TYPES.CARD) {
90                     const legacyPaymentToken = v5PaymentTokenToLegacyPaymentToken(chargeablePaymentParameters);
91                     await api(
92                         setPaymentMethodV4({
93                             ...legacyPaymentToken.Payment,
94                             Autopay: renewToggleProps.renewState,
95                         })
96                     );
97                 } else if (sourceType === PAYMENT_METHOD_TYPES.CHARGEBEE_CARD) {
98                     await api(
99                         setPaymentMethodV5({
100                             PaymentToken: chargeablePaymentParameters.PaymentToken,
101                             v: 5,
102                             Autopay: renewToggleProps.renewState,
103                         })
104                     );
105                 }
107                 await call();
108                 rest.onClose?.();
109                 if (existingCard) {
110                     createNotification({ text: c('Success').t`Payment method updated` });
111                 } else {
112                     createNotification({ text: c('Success').t`Payment method added` });
113                     onMethodAdded?.();
114                 }
115             }).catch(noop);
116         },
117         user,
118     });
120     const paymentMethodId = paymentMethod?.ID;
121     const process = async () => {
122         try {
123             await paymentFacade.selectedProcessor?.processPaymentToken();
124         } catch (e) {
125             const error = getSentryError(e);
126             if (error) {
127                 const context = {
128                     hasExistingCard: !!existingCard,
129                     renewState,
130                     paymentMethodId,
131                     processorType: paymentFacade.selectedProcessor?.meta.type,
132                     paymentsVersion: getPaymentsVersion(),
133                     chargebeeEnabled: chargebeeContext.enableChargebeeRef.current,
134                 };
136                 captureMessage('Payments: failed to add card', {
137                     level: 'error',
138                     extra: { error, context },
139                 });
140             }
141         }
142     };
144     const loading = paymentFacade.methods.loading;
145     useEffect(() => {
146         if (loading) {
147             return;
148         }
150         if (paymentFacade.methods.isMethodTypeEnabled(PAYMENT_METHOD_TYPES.CHARGEBEE_CARD)) {
151             paymentFacade.methods.selectMethod(PAYMENT_METHOD_TYPES.CHARGEBEE_CARD);
152         } else {
153             paymentFacade.methods.selectMethod(PAYMENT_METHOD_TYPES.CARD);
154         }
155     }, [loading]);
157     const isInhouseCard = paymentFacade.selectedMethodType === PAYMENT_METHOD_TYPES.CARD;
158     const isChargebeeCard = paymentFacade.selectedMethodType === PAYMENT_METHOD_TYPES.CHARGEBEE_CARD;
159     const formFullyLoaded = isInhouseCard || (isChargebeeCard && chargebeeFormInitialized);
161     const content = (
162         <>
163             {isInhouseCard && <CreditCard {...paymentFacade.card} loading={processing} />}
164             {isChargebeeCard && (
165                 <ChargebeeCreditCardWrapper
166                     onInitialized={() => setChargebeeFormInitialized(true)}
167                     iframeHandles={paymentFacade.iframeHandles}
168                     chargebeeCard={paymentFacade.chargebeeCard}
169                     themeCode={paymentFacade.themeCode}
170                     initialCountryCode={paymentFacade.methods.status?.CountryCode}
171                 />
172             )}
173             {enableRenewToggle && formFullyLoaded && (
174                 <RenewToggle
175                     loading={processing}
176                     onChange={async () => {
177                         const result = await renewOnChange();
179                         // Case when the change wasn't done. For example because user canceled the change and decided to keep the setting as-is.
180                         if (result === null) {
181                             return;
182                         }
184                         // Case when <EditCardModal /> is rendered in Add mode. In this case there is no existing paymentMethodId.
185                         if (!paymentMethodId) {
186                             return;
187                         }
189                         void withProcessing(async () => {
190                             try {
191                                 await api(
192                                     updatePaymentMethod(
193                                         paymentMethodId,
194                                         {
195                                             Autopay: result,
196                                         },
197                                         paymentMethodPaymentsVersion(paymentMethod)
198                                     )
199                                 );
201                                 await call().catch(noop);
203                                 const text =
204                                     result === Autopay.ENABLE
205                                         ? c('Subscription renewal state').t`Auto-pay is enabled`
206                                         : c('Subscription renewal state').t`Auto-pay is disabled`;
207                                 createNotification({ text });
209                                 rest.onClose?.();
210                             } catch {
211                                 setRenewState(result === Autopay.ENABLE ? Autopay.DISABLE : Autopay.ENABLE);
212                             }
213                         });
214                     }}
215                     {...renewToggleProps}
216                 />
217             )}
218         </>
219     );
221     return (
222         <ModalTwo
223             size="small"
224             as="form"
225             onSubmit={(event: FormEvent) => {
226                 event.preventDefault();
227                 // it handles the case when the EditCardModal is rendered as part of SubscriptionContainer.
228                 // We need to prevent premature closing of the SubscriptionContainer by stopping the event propagation
229                 // and subsequent handling
230                 event.stopPropagation();
231                 withProcessing(process()).catch(noop);
232             }}
233             {...rest}
234         >
235             <ModalTwoHeader title={title} />
236             <ModalTwoContent>
237                 {/* In the future, this spinner can be passed inside of chargebee card component to 
238                 replace its internal spinner and make to loading animation continious 
239                 currently there are two stages: first wait till the facade is fully loaded, 
240                 then wait till the chargebee form is initialized. We need to find a way to use one loading spinner 
241                 for both stages
242                 */}
243                 {/* {loading ? (
244                     <div
245                         className="flex justify-center items-center h-custom"
246                         style={{
247                             '--h-custom': '27rem',
248                         }}
249                     >
250                         <CircleLoader size="large" />
251                     </div>
252                 ) : (
253                     content
254                 )} */}
255                 {content}
256             </ModalTwoContent>
257             {formFullyLoaded && (
258                 <ModalTwoFooter>
259                     <Button disabled={processing} onClick={rest.onClose}>{c('Action').t`Cancel`}</Button>
260                     <Button loading={processing} color="norm" type="submit" data-testid="edit-card-action-save">{c(
261                         'Action'
262                     ).t`Save`}</Button>
263                 </ModalTwoFooter>
264             )}
265         </ModalTwo>
266     );
269 export default EditCardModal;