// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { MutableRefObject, ReactNode } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import classNames from 'classnames'; import type { ReadonlyDeep } from 'type-fest'; import type { LocalizerType } from '../types/Util.std.js'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard.dom.js'; import { donationErrorTypeSchema, DonationProcessor, donationStateSchema, ONE_TIME_DONATION_CONFIG_ID, PaymentMethod, } from '../types/Donations.std.js'; import type { CardDetail, DonationErrorType, DonationStateType, HumanDonationAmount, DonationWorkflow, OneTimeDonationHumanAmounts, } from '../types/Donations.std.js'; import type { CardCvcError, CardExpirationError, CardNumberError, } from '../types/DonationsCardForm.std.js'; import { cardFormToCardDetail, getCardFormSettings, getPossibleCardFormats, parseCardCvc, parseCardExpiration, parseCardForm, parseCardNumber, } from '../types/DonationsCardForm.std.js'; import { brandHumanDonationAmount, brandStripeDonationAmount, type CurrencyFormatResult, getCurrencyFormat, getMaximumStripeAmount, parseCurrencyString, toHumanCurrencyString, toHumanDonationAmount, toStripeDonationAmount, } from '../util/currency.dom.js'; import { PreferencesContent } from './Preferences.dom.js'; import type { SubmitDonationType } from '../state/ducks/donations.preload.js'; import { Select } from './Select.dom.js'; import { DonateInputCardNumber, getCardNumberErrorMessage, } from './preferences/donations/DonateInputCardNumber.dom.js'; import { DonateInputCardExp, getCardExpirationErrorMessage, } from './preferences/donations/DonateInputCardExp.dom.js'; import { DonateInputCardCvc, getCardCvcErrorMessage, } from './preferences/donations/DonateInputCardCvc.dom.js'; import { I18n } from './I18n.dom.js'; import { strictAssert } from '../util/assert.std.js'; import { DonationsOfflineTooltip } from './conversation/DonationsOfflineTooltip.dom.js'; import { DonateInputAmount } from './preferences/donations/DonateInputAmount.dom.js'; import { Tooltip, TooltipPlacement } from './Tooltip.dom.js'; import { offsetDistanceModifier } from '../util/popperUtil.std.js'; import { AxoButton } from '../axo/AxoButton.dom.js'; import { missingCaseError } from '../util/missingCaseError.std.js'; import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser.dom.js'; import { usePrevious } from '../hooks/usePrevious.std.js'; import { tw } from '../axo/tw.dom.js'; const SUPPORT_URL = 'https://support.signal.org/hc/requests/new?desktop'; export type PropsDataType = { i18n: LocalizerType; initialCurrency: string; isOnline: boolean; donationAmountsConfig: ReadonlyDeep | undefined; lastError: DonationErrorType | undefined; validCurrencies: ReadonlyArray; workflow: DonationWorkflow | undefined; renderDonationHero: () => React.JSX.Element; }; type PropsHousekeepingType = { contentsRef: MutableRefObject; }; type PropsActionType = { clearWorkflow: () => void; showPrivacyModal: () => void; submitDonation: (payload: SubmitDonationType) => void; onBack: () => void; }; export type PropsType = PropsDataType & PropsActionType & PropsHousekeepingType; const isPaymentDetailFinalizedInWorkflow = (workflow: DonationWorkflow) => { const finalizedStates: Array = [ donationStateSchema.Enum.INTENT_CONFIRMED, donationStateSchema.Enum.INTENT_REDIRECT, donationStateSchema.Enum.PAYPAL_APPROVED, donationStateSchema.Enum.PAYMENT_CONFIRMED, donationStateSchema.Enum.RECEIPT, donationStateSchema.Enum.DONE, ]; return finalizedStates.includes(workflow.type); }; export function PreferencesDonateFlow({ contentsRef, i18n, initialCurrency, isOnline, donationAmountsConfig, lastError, validCurrencies, workflow, clearWorkflow, renderDonationHero, showPrivacyModal, submitDonation, onBack, }: PropsType): React.JSX.Element { const tryClose = useRef<(() => void) | null>(null); // When returning to the donate flow with a pending PayPal payment, load the pending // amount in case the user wants to go back and choose a different payment processor. const { initialStep, initialAmount } = useMemo((): { initialStep: 'amount' | 'paypal'; initialAmount: HumanDonationAmount | undefined; } => { if ( workflow?.type === donationStateSchema.Enum.PAYPAL_INTENT || workflow?.type === donationStateSchema.Enum.PAYPAL_APPROVED ) { const savedAmount = brandStripeDonationAmount(workflow.paymentAmount); const humanAmount = toHumanDonationAmount({ amount: savedAmount, currency: workflow.currencyType, }); return { initialStep: 'paypal', initialAmount: humanAmount }; } return { initialStep: 'amount', initialAmount: undefined, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const [step, setStep] = useState< 'amount' | 'paymentProcessor' | 'stripePaymentDetails' | 'paypal' >(initialStep); const [amount, setAmount] = useState( initialAmount ); const [currency, setCurrency] = useState(initialCurrency); const [isCardFormDisabled, setIsCardFormDisabled] = useState(false); const [cardFormValues, setCardFormValues] = useState< CardFormValues | undefined >(); const prevStep = usePrevious(step, step); const hasCardFormData = useMemo(() => { if (!cardFormValues) { return false; } return ( cardFormValues.cardNumber !== '' || cardFormValues.cardExpiration !== '' || cardFormValues.cardCvc !== '' ); }, [cardFormValues]); // When changing currency, clear out the last selected amount const handleAmountPickerCurrencyChanged = useCallback((value: string) => { setAmount(undefined); setCurrency(value); }, []); const isPaymentProcessorStepEnabled = useMemo(() => { if ( donationAmountsConfig == null || donationAmountsConfig[currency] == null ) { return false; } return donationAmountsConfig[currency].supportedPaymentMethods.includes( PaymentMethod.Paypal ); }, [donationAmountsConfig, currency]); const handleAmountPickerResult = useCallback( (result: AmountPickerResult) => { const { currency: pickedCurrency, amount: pickedAmount } = result; setAmount(pickedAmount); setCurrency(pickedCurrency); if (isPaymentProcessorStepEnabled) { setStep('paymentProcessor'); } else { setStep('stripePaymentDetails'); } }, [isPaymentProcessorStepEnabled] ); const handleCardFormChanged = useCallback((values: CardFormValues) => { setCardFormValues(values); }, []); const handleSubmitDonation = useCallback( ({ processor, cardDetail, }: { processor: DonationProcessor; cardDetail?: CardDetail; }): boolean => { if (amount == null || currency == null) { return false; } const paymentAmount = toStripeDonationAmount({ amount, currency }); if (processor === DonationProcessor.Stripe) { strictAssert(cardDetail, 'cardDetail is required for Stripe'); submitDonation({ currencyType: currency, paymentAmount, processor: DonationProcessor.Stripe, paymentDetail: cardDetail, }); } else if (processor === DonationProcessor.Paypal) { submitDonation({ currencyType: currency, paymentAmount, processor: DonationProcessor.Paypal, }); } else { throw missingCaseError(processor); } return true; }, [amount, currency, submitDonation] ); const handleSubmitStripeDonation = useCallback( (cardDetail: CardDetail) => { if ( handleSubmitDonation({ processor: DonationProcessor.Stripe, cardDetail, }) ) { setIsCardFormDisabled(true); } }, [handleSubmitDonation] ); const handleSubmitPaypalDonation = useCallback(() => { handleSubmitDonation({ processor: DonationProcessor.Paypal }); // An effect will transition step to paypal after chat server confirmation }, [handleSubmitDonation]); const handleBackFromCardForm = useCallback(() => { if (isPaymentProcessorStepEnabled) { setStep('paymentProcessor'); } else { setStep('amount'); } }, [isPaymentProcessorStepEnabled]); useEffect(() => { if (!workflow || lastError) { setIsCardFormDisabled(false); } }, [lastError, setIsCardFormDisabled, workflow]); useEffect(() => { // When starting a Paypal payment, we create a workflow in the PAYPAL_INTENT state // which contains the approvalUrl. if ( prevStep === 'paymentProcessor' && workflow?.type === donationStateSchema.Enum.PAYPAL_INTENT ) { setStep('paypal'); openLinkInWebBrowser(workflow.approvalUrl); } }, [prevStep, workflow]); const discardModalBodyText = useMemo(() => { if (step === 'stripePaymentDetails') { return i18n('icu:DonateFlow__discard-dialog-body'); } if (step === 'paypal') { return i18n('icu:DonateFlow__discard-paypal-dialog-body'); } return undefined; }, [i18n, step]); const discardModalCancelText = useMemo(() => { if (step === 'paypal') { return i18n('icu:DonateFlow__discard-paypal-dialog-cancel'); } // Use default text "Cancel" return undefined; }, [i18n, step]); const discardModalDiscardText = useMemo(() => { if (step === 'stripePaymentDetails') { return i18n('icu:DonateFlow__discard-dialog-remove-info'); } if (step === 'paypal') { return i18n('icu:DonateFlow__discard-paypal-dialog-discard'); } return undefined; }, [i18n, step]); const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ i18n, bodyText: discardModalBodyText, cancelText: discardModalCancelText, discardText: discardModalDiscardText, name: 'PreferencesDonateFlow', tryClose, }); const onTryClose = useCallback(() => { const onDiscard = () => { // Don't clear the workflow if we're processing the payment and // payment information is finalized. if (!workflow || !isPaymentDetailFinalizedInWorkflow(workflow)) { clearWorkflow(); } }; const isConfirmationNeeded = ((hasCardFormData && !isCardFormDisabled) || (step === 'paypal' && lastError !== donationErrorTypeSchema.Enum.PaypalCanceled)) && (!workflow || !isPaymentDetailFinalizedInWorkflow(workflow)); confirmDiscardIf(isConfirmationNeeded, onDiscard); }, [ clearWorkflow, confirmDiscardIf, hasCardFormData, isCardFormDisabled, lastError, step, workflow, ]); tryClose.current = onTryClose; let innerContent: React.JSX.Element; let handleBack: () => void; if (step === 'amount') { innerContent = ( <> {renderDonationHero()} ); // Dismiss DonateFlow and return to Donations home handleBack = () => onBack(); } else if (step === 'paymentProcessor') { strictAssert(amount, 'Amount is required for payment processor form'); innerContent = ( <> ); handleBack = () => { setStep('amount'); }; } else if (step === 'stripePaymentDetails') { strictAssert(amount, 'Amount is required for payment card form'); innerContent = ( <>
); handleBack = handleBackFromCardForm; } else if (step === 'paypal') { strictAssert(amount, 'Amount is required for Paypal page'); const isDisabled = workflow?.type !== donationStateSchema.Enum.PAYPAL_INTENT; innerContent = ( <>
{i18n('icu:Donations__PaymentMethod')}
{i18n('icu:DonateFlow__Paypal__Cancel')} { if (workflow?.type === donationStateSchema.Enum.PAYPAL_INTENT) { openLinkInWebBrowser(workflow.approvalUrl); } }} > {i18n('icu:DonateFlow__Paypal__CompleteDonation')}
); handleBack = () => { clearWorkflow(); setStep('amount'); setIsCardFormDisabled(false); }; } else { throw missingCaseError(step); } const backButton = (