// 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 { LocalizerType } from '../types/Util'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; import { Button, ButtonVariant } from './Button'; import type { CardDetail, DonationErrorType, DonationStateType, HumanDonationAmount, } from '../types/Donations'; import { donationStateSchema, ONE_TIME_DONATION_CONFIG_ID, type DonationWorkflow, type OneTimeDonationHumanAmounts, } from '../types/Donations'; import type { CardCvcError, CardExpirationError, CardNumberError, } from '../types/DonationsCardForm'; import { cardFormToCardDetail, getCardFormSettings, getPossibleCardFormats, parseCardCvc, parseCardExpiration, parseCardForm, parseCardNumber, } from '../types/DonationsCardForm'; import { brandHumanDonationAmount, parseCurrencyString, toHumanCurrencyString, toStripeDonationAmount, } from '../util/currency'; import { PreferencesContent } from './Preferences'; import type { SubmitDonationType } from '../state/ducks/donations'; import { Select } from './Select'; import { DonateInputCardNumber, getCardNumberErrorMessage, } from './preferences/donations/DonateInputCardNumber'; import { DonateInputCardExp, getCardExpirationErrorMessage, } from './preferences/donations/DonateInputCardExp'; import { DonateInputCardCvc, getCardCvcErrorMessage, } from './preferences/donations/DonateInputCardCvc'; import { I18n } from './I18n'; import { strictAssert } from '../util/assert'; import { DonationsOfflineTooltip } from './conversation/DonationsOfflineTooltip'; import { DonateInputAmount } from './preferences/donations/DonateInputAmount'; import { Tooltip, TooltipPlacement } from './Tooltip'; import { offsetDistanceModifier } from '../util/popperUtil'; const SUPPORT_URL = 'https://support.signal.org/hc/requests/new?desktop'; export type PropsDataType = { i18n: LocalizerType; initialCurrency: string; isOnline: boolean; donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; lastError: DonationErrorType | undefined; validCurrencies: ReadonlyArray; workflow: DonationWorkflow | undefined; renderDonationHero: () => 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.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): JSX.Element { const tryClose = useRef<() => void | undefined>(); const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ i18n, name: 'PreferencesDonateFlow', tryClose, }); const [step, setStep] = useState<'amount' | 'paymentDetails'>('amount'); const [amount, setAmount] = useState(); const [currency, setCurrency] = useState(initialCurrency); const [isCardFormDisabled, setIsCardFormDisabled] = useState(false); const [cardFormValues, setCardFormValues] = useState< CardFormValues | undefined >(); // When changing currency, clear out the last selected amount const handleAmountPickerCurrencyChanged = useCallback((value: string) => { setAmount(undefined); setCurrency(value); }, []); const handleAmountPickerResult = useCallback((result: AmountPickerResult) => { const { currency: pickedCurrency, amount: pickedAmount } = result; setAmount(pickedAmount); setCurrency(pickedCurrency); setStep('paymentDetails'); }, []); const handleCardFormChanged = useCallback((values: CardFormValues) => { setCardFormValues(values); }, []); const handleSubmitDonation = useCallback( (cardDetail: CardDetail) => { if (amount == null || currency == null) { return; } const paymentAmount = toStripeDonationAmount({ amount, currency }); setIsCardFormDisabled(true); submitDonation({ currencyType: currency, paymentAmount, paymentDetail: cardDetail, }); }, [amount, currency, setIsCardFormDisabled, submitDonation] ); useEffect(() => { if (!workflow || lastError) { setIsCardFormDisabled(false); } }, [lastError, setIsCardFormDisabled, workflow]); 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 = Boolean( step === 'paymentDetails' && !isCardFormDisabled && (!workflow || !isPaymentDetailFinalizedInWorkflow(workflow)) ); confirmDiscardIf(isConfirmationNeeded, onDiscard); }, [clearWorkflow, confirmDiscardIf, isCardFormDisabled, step, workflow]); tryClose.current = onTryClose; let innerContent: JSX.Element; let handleBack: () => void; if (step === 'amount') { innerContent = ( <> {renderDonationHero()} ); // Dismiss DonateFlow and return to Donations home handleBack = () => onBack(); } else { strictAssert(amount, 'Amount is required for payment card form'); innerContent = ( <>
); handleBack = () => { setStep('amount'); }; } const backButton = ( ); let continueButtonWithTooltip: JSX.Element | undefined; if (!isOnline) { continueButtonWithTooltip = ( {continueButton} ); } else if (error === 'amount-below-minimum') { continueButtonWithTooltip = ( {continueButton} ); } return (