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';
25 paymentMethodPaymentsVersion,
26 v5PaymentTokenToLegacyPaymentToken,
27 } from '@proton/payments';
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'> {
45 paymentMethod?: PaymentMethodCardDetails;
46 onMethodAdded?: () => void;
47 enableRenewToggle?: boolean;
50 const EditCardModal = ({
55 enableRenewToggle = true,
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);
70 onChange: renewOnChange,
73 } = useRenewToggle({ initialRenewState: renewState });
75 const chargebeeContext = useChargebeeContext();
77 const paymentFacade = usePaymentFacade({
79 currency: user.Currency,
81 billingPlatform: subscription?.BillingPlatform,
82 chargebeeUserExists: user.ChargebeeUserExists,
83 onChargeable: async (_, { chargeablePaymentParameters, sourceType }) => {
84 withProcessing(async () => {
85 if (!isV5PaymentToken(chargeablePaymentParameters)) {
89 if (sourceType === PAYMENT_METHOD_TYPES.CARD) {
90 const legacyPaymentToken = v5PaymentTokenToLegacyPaymentToken(chargeablePaymentParameters);
93 ...legacyPaymentToken.Payment,
94 Autopay: renewToggleProps.renewState,
97 } else if (sourceType === PAYMENT_METHOD_TYPES.CHARGEBEE_CARD) {
100 PaymentToken: chargeablePaymentParameters.PaymentToken,
102 Autopay: renewToggleProps.renewState,
110 createNotification({ text: c('Success').t`Payment method updated` });
112 createNotification({ text: c('Success').t`Payment method added` });
120 const paymentMethodId = paymentMethod?.ID;
121 const process = async () => {
123 await paymentFacade.selectedProcessor?.processPaymentToken();
125 const error = getSentryError(e);
128 hasExistingCard: !!existingCard,
131 processorType: paymentFacade.selectedProcessor?.meta.type,
132 paymentsVersion: getPaymentsVersion(),
133 chargebeeEnabled: chargebeeContext.enableChargebeeRef.current,
136 captureMessage('Payments: failed to add card', {
138 extra: { error, context },
144 const loading = paymentFacade.methods.loading;
150 if (paymentFacade.methods.isMethodTypeEnabled(PAYMENT_METHOD_TYPES.CHARGEBEE_CARD)) {
151 paymentFacade.methods.selectMethod(PAYMENT_METHOD_TYPES.CHARGEBEE_CARD);
153 paymentFacade.methods.selectMethod(PAYMENT_METHOD_TYPES.CARD);
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);
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}
173 {enableRenewToggle && formFullyLoaded && (
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) {
184 // Case when <EditCardModal /> is rendered in Add mode. In this case there is no existing paymentMethodId.
185 if (!paymentMethodId) {
189 void withProcessing(async () => {
197 paymentMethodPaymentsVersion(paymentMethod)
201 await call().catch(noop);
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 });
211 setRenewState(result === Autopay.ENABLE ? Autopay.DISABLE : Autopay.ENABLE);
215 {...renewToggleProps}
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);
235 <ModalTwoHeader title={title} />
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
245 className="flex justify-center items-center h-custom"
247 '--h-custom': '27rem',
250 <CircleLoader size="large" />
257 {formFullyLoaded && (
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(
269 export default EditCardModal;