Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / components / containers / payments / CreditsModal.tsx
blobc5c9e704841bd2b4fe7680689c8467f505276c0c
1 import { useState } from 'react';
3 import { c } from 'ttag';
5 import { useSubscription } from '@proton/account/subscription/hooks';
6 import { useUser } from '@proton/account/user/hooks';
7 import { Button, ButtonLike, Href } from '@proton/atoms';
8 import PrimaryButton from '@proton/components/components/button/PrimaryButton';
9 import Form from '@proton/components/components/form/Form';
10 import useDebounceInput from '@proton/components/components/input/useDebounceInput';
11 import Loader from '@proton/components/components/loader/Loader';
12 import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
13 import ModalTwo from '@proton/components/components/modalTwo/Modal';
14 import ModalTwoContent from '@proton/components/components/modalTwo/ModalContent';
15 import ModalTwoFooter from '@proton/components/components/modalTwo/ModalFooter';
16 import ModalTwoHeader from '@proton/components/components/modalTwo/ModalHeader';
17 import Price from '@proton/components/components/price/Price';
18 import useConfig from '@proton/components/hooks/useConfig';
19 import useEventManager from '@proton/components/hooks/useEventManager';
20 import useNotifications from '@proton/components/hooks/useNotifications';
21 import { useAutomaticCurrency, usePaymentFacade } from '@proton/components/payments/client-extensions';
22 import { useChargebeeContext } from '@proton/components/payments/client-extensions/useChargebeeContext';
23 import { usePollEvents } from '@proton/components/payments/client-extensions/usePollEvents';
24 import type { PaymentProcessorHook } from '@proton/components/payments/react-extensions/interface';
25 import { useLoading } from '@proton/hooks';
26 import {
27     type Currency,
28     MAX_BITCOIN_AMOUNT,
29     MIN_BITCOIN_AMOUNT,
30     MIN_CREDIT_AMOUNT,
31     PAYMENT_METHOD_TYPES,
32     type PaymentMethodStatusExtended,
33     type PlainPaymentMethodType,
34     isFreeSubscription,
35 } from '@proton/payments';
36 import { getPaymentsVersion } from '@proton/shared/lib/api/payments';
37 import { APPS } from '@proton/shared/lib/constants';
38 import { captureMessage } from '@proton/shared/lib/helpers/sentry';
39 import { getHasSomeVpnPlan } from '@proton/shared/lib/helpers/subscription';
40 import { getKnowledgeBaseUrl } from '@proton/shared/lib/helpers/url';
41 import { ChargebeeEnabled } from '@proton/shared/lib/interfaces';
42 import { getSentryError } from '@proton/shared/lib/keys';
43 import noop from '@proton/utils/noop';
45 import { ChargebeePaypalWrapper } from '../../payments/chargebee/ChargebeeWrapper';
46 import AmountRow from './AmountRow';
47 import PaymentInfo from './PaymentInfo';
48 import PaymentWrapper from './PaymentWrapper';
49 import StyledPayPalButton from './StyledPayPalButton';
51 const getCurrenciesI18N = () => ({
52     EUR: c('Monetary unit').t`Euro`,
53     CHF: c('Monetary unit').t`Swiss franc`,
54     USD: c('Monetary unit').t`US Dollar`,
55     BRL: c('Monetary unit').t`Brazilian real`,
56     GBP: c('Monetary unit').t`British pound`,
57     AUD: c('Monetary unit').t`Australian dollar`,
58     CAD: c('Monetary unit').t`Canadian dollar`,
59 });
61 type Props = {
62     status: PaymentMethodStatusExtended;
63 } & ModalProps;
65 export const DEFAULT_CREDITS_AMOUNT = 5000;
67 const nonChargeableMethods = new Set<PlainPaymentMethodType | undefined>([
68     PAYMENT_METHOD_TYPES.BITCOIN,
69     PAYMENT_METHOD_TYPES.CHARGEBEE_BITCOIN,
70     PAYMENT_METHOD_TYPES.CASH,
71 ]);
73 const CreditsModal = ({ status, ...props }: Props) => {
74     const { APP_NAME } = useConfig();
75     const { call } = useEventManager();
76     const { createNotification } = useNotifications();
77     const [loading, withLoading] = useLoading();
79     const [preferredCurrency] = useAutomaticCurrency();
80     const [currency, setCurrency] = useState<Currency>(preferredCurrency);
81     const [amount, setAmount] = useState(DEFAULT_CREDITS_AMOUNT);
82     const debouncedAmount = useDebounceInput(amount);
83     const amountLoading = debouncedAmount !== amount;
84     const i18n = getCurrenciesI18N();
85     const i18nCurrency = i18n[currency];
86     const pollEventsMultipleTimes = usePollEvents();
87     const chargebeeContext = useChargebeeContext();
88     const [subscription, loadingSubscription] = useSubscription();
89     const [user, loadingUser] = useUser();
91     const paymentFacade = usePaymentFacade({
92         amount: debouncedAmount,
93         currency,
94         billingPlatform: subscription?.BillingPlatform,
95         chargebeeUserExists: user.ChargebeeUserExists,
96         paymentMethodStatusExtended: status,
97         onChargeable: (operations, data) => {
98             const run = async () => {
99                 await operations.buyCredit();
100                 await call();
101                 props.onClose?.();
103                 if (data.sourceType === 'chargebee-bitcoin') {
104                     createNotification({
105                         text: c('Payments')
106                             .t`The transaction is successfully detected. The credits will be added to your account once the transaction is fully confirmed.`,
107                         expiration: 20000,
108                     });
109                 } else {
110                     createNotification({ text: c('Success').t`Credits added` });
111                 }
112             };
114             const promise = run();
115             void withLoading(promise);
117             promise.then(() => pollEventsMultipleTimes()).catch(noop);
119             return promise;
120         },
121         flow: 'credit',
122         user,
123     });
125     if (loadingSubscription || loadingUser) {
126         return <Loader />;
127     }
129     const methodValue = paymentFacade.selectedMethodValue;
131     const submit = (() => {
132         const bitcoinAmountInRange = debouncedAmount >= MIN_BITCOIN_AMOUNT && debouncedAmount <= MAX_BITCOIN_AMOUNT;
133         if (
134             debouncedAmount < MIN_CREDIT_AMOUNT ||
135             ((methodValue === PAYMENT_METHOD_TYPES.BITCOIN || methodValue === PAYMENT_METHOD_TYPES.CHARGEBEE_BITCOIN) &&
136                 !bitcoinAmountInRange)
137         ) {
138             return null;
139         }
141         if (paymentFacade.methods.isNewPaypal) {
142             return (
143                 <StyledPayPalButton
144                     type="submit"
145                     paypal={paymentFacade.paypal}
146                     amount={debouncedAmount}
147                     currency={paymentFacade.currency}
148                     loading={loading}
149                     disabled={amountLoading}
150                     data-testid="paypal-button"
151                 />
152             );
153         }
155         if (methodValue === PAYMENT_METHOD_TYPES.CHARGEBEE_PAYPAL) {
156             if (loading) {
157                 return (
158                     <ButtonLike disabled loading={true}>
159                         {c('Payments').t`Processing payment`}
160                     </ButtonLike>
161                 );
162             }
164             return (
165                 <div className="flex justify-end">
166                     <div className="w-1/2 mr-1">
167                         <ChargebeePaypalWrapper
168                             chargebeePaypal={paymentFacade.chargebeePaypal}
169                             iframeHandles={paymentFacade.iframeHandles}
170                         />
171                     </div>
172                 </div>
173             );
174         }
176         const topUpText = c('Action').t`Top up`;
177         if (methodValue === PAYMENT_METHOD_TYPES.BITCOIN || methodValue === PAYMENT_METHOD_TYPES.CHARGEBEE_BITCOIN) {
178             return (
179                 <PrimaryButton
180                     loading={
181                         paymentFacade.bitcoinInhouse.bitcoinLoading || paymentFacade.bitcoinChargebee.bitcoinLoading
182                     }
183                     disabled={true}
184                     data-testid="top-up-button"
185                 >
186                     {paymentFacade.bitcoinInhouse.awaitingBitcoinPayment ||
187                     paymentFacade.bitcoinChargebee.awaitingBitcoinPayment
188                         ? c('Info').t`Awaiting transaction`
189                         : topUpText}
190                 </PrimaryButton>
191             );
192         }
194         return (
195             <PrimaryButton
196                 loading={loading}
197                 disabled={paymentFacade.methods.loading || !paymentFacade.userCanTriggerSelected || amountLoading}
198                 type="submit"
199                 data-testid="top-up-button"
200             >
201                 {topUpText}
202             </PrimaryButton>
203         );
204     })();
206     const process = async (processor?: PaymentProcessorHook) =>
207         withLoading(async () => {
208             if (!processor) {
209                 return;
210             }
212             try {
213                 await processor.processPaymentToken();
214             } catch (e) {
215                 const error = getSentryError(e);
216                 if (error) {
217                     const context = {
218                         app: APP_NAME,
219                         currency,
220                         amount,
221                         debouncedAmount,
222                         processorType: paymentFacade.selectedProcessor?.meta.type,
223                         paymentMethod: paymentFacade.selectedMethodType,
224                         paymentMethodValue: paymentFacade.selectedMethodValue,
225                         paymentsVersion: getPaymentsVersion(),
226                         chargebeeEnabled: chargebeeContext.enableChargebeeRef.current,
227                     };
229                     captureMessage('Payments: failed to handle credits', {
230                         level: 'error',
231                         extra: { error, context },
232                     });
233                 }
234             }
235         });
237     const disableCurrencySelector =
238         chargebeeContext.enableChargebeeRef.current !== ChargebeeEnabled.INHOUSE_FORCED &&
239         !isFreeSubscription(subscription);
241     const amountToCharge =
242         amountLoading || nonChargeableMethods.has(paymentFacade.selectedMethodType) ? null : (
243             <Price currency={currency}>{debouncedAmount}</Price>
244         );
246     return (
247         <ModalTwo
248             className="credits-modal"
249             size="large"
250             as={Form}
251             onSubmit={() => process(paymentFacade.selectedProcessor)}
252             {...props}
253         >
254             <ModalTwoHeader title={c('Title').t`Add credits`} />
255             <ModalTwoContent>
256                 <PaymentInfo paymentMethodType={paymentFacade.selectedMethodType} />
257                 <div className="mb-4">
258                     <div>
259                         {c('Info')
260                             .jt`Top up your account with credits that you can use to subscribe to a new plan or renew your current plan. You get one credit for every ${i18nCurrency} spent.`}
261                     </div>
262                     <Href
263                         href={
264                             APP_NAME === APPS.PROTONVPN_SETTINGS
265                                 ? 'https://protonvpn.com/support/vpn-credit-proration/'
266                                 : getKnowledgeBaseUrl('/credit-proration-coupons')
267                         }
268                     >
269                         {c('Link').t`Learn more`}
270                     </Href>
271                 </div>
272                 <AmountRow
273                     status={status}
274                     paymentMethodType={paymentFacade.selectedMethodType}
275                     amount={amount}
276                     onChangeAmount={setAmount}
277                     currency={currency}
278                     onChangeCurrency={setCurrency}
279                     disableCurrencySelector={disableCurrencySelector}
280                 />
281                 <PaymentWrapper
282                     {...paymentFacade}
283                     onPaypalCreditClick={() => process(paymentFacade.paypalCredit)}
284                     noMaxWidth
285                     triggersDisabled={amountLoading}
286                     hasSomeVpnPlan={getHasSomeVpnPlan(subscription)}
287                 />
288                 {
289                     <p
290                         className="text-sm text-center color-weak min-h-custom"
291                         style={{
292                             '--min-h-custom': '1.5rem',
293                         }}
294                     >
295                         {amountToCharge
296                             ? c('Payments').jt`You will be charged ${amountToCharge} from your selected payment method.`
297                             : null}
298                     </p>
299                 }
300             </ModalTwoContent>
302             <ModalTwoFooter>
303                 <Button onClick={props.onClose}>{c('Action').t`Close`}</Button>
304                 {submit}
305             </ModalTwoFooter>
306         </ModalTwo>
307     );
310 export default CreditsModal;