// 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, HumanDonationAmount, } from '../types/Donations'; import { 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 { Input } from './Input'; 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'; const SUPPORT_URL = 'https://support.signal.org/hc/requests/new?desktop'; export type PropsDataType = { i18n: LocalizerType; initialCurrency: string; 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; export function PreferencesDonateFlow({ contentsRef, i18n, initialCurrency, 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 >(); 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 = () => { clearWorkflow(); }; const isConfirmationNeeded = Boolean( step === 'paymentDetails' && !isCardFormDisabled && workflow?.type !== 'DONE' ); 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 = ( ); } type CardFormValues = { cardExpiration: string | undefined; cardNumber: string | undefined; cardCvc: string | undefined; }; type CardFormProps = { amount: HumanDonationAmount; currency: string; disabled: boolean; i18n: LocalizerType; initialValues: CardFormValues | undefined; onChange: (values: CardFormValues) => void; onSubmit: (cardDetail: CardDetail) => void; showPrivacyModal: () => void; }; function CardForm({ amount, currency, disabled, i18n, initialValues, onChange, onSubmit, showPrivacyModal, }: CardFormProps): JSX.Element { const [cardExpiration, setCardExpiration] = useState( initialValues?.cardExpiration ?? '' ); const [cardNumber, setCardNumber] = useState(initialValues?.cardNumber ?? ''); const [cardCvc, setCardCvc] = useState(initialValues?.cardCvc ?? ''); const [cardNumberError, setCardNumberError] = useState(null); const [cardExpirationError, setCardExpirationError] = useState(null); const [cardCvcError, setCardCvcError] = useState(null); const possibleCardFormats = useMemo(() => { return getPossibleCardFormats(cardNumber); }, [cardNumber]); const cardFormSettings = useMemo(() => { return getCardFormSettings(possibleCardFormats); }, [possibleCardFormats]); useEffect(() => { onChange({ cardExpiration, cardNumber, cardCvc }); }, [cardExpiration, cardNumber, cardCvc, onChange]); const privacyLearnMoreLink = useCallback( (parts: ReactNode): JSX.Element => { return ( ); }, [showPrivacyModal] ); const handleCardNumberChange = useCallback((value: string) => { setCardNumber(value); setCardNumberError(null); }, []); const handleCardNumberBlur = useCallback(() => { if (cardNumber !== '') { const result = parseCardNumber(cardNumber); setCardNumberError(result.error ?? null); } }, [cardNumber]); const handleCardExpirationChange = useCallback((value: string) => { setCardExpiration(value); setCardExpirationError(null); }, []); const handleCardExpirationBlur = useCallback(() => { if (cardExpiration !== '') { const result = parseCardExpiration(cardExpiration); setCardExpirationError(result.error ?? null); } }, [cardExpiration]); const handleCardCvcChange = useCallback((value: string) => { setCardCvc(value); setCardCvcError(null); }, []); const handleCardCvcBlur = useCallback(() => { if (cardCvc !== '') { const result = parseCardCvc(cardCvc, possibleCardFormats); setCardCvcError(result.error ?? null); } }, [cardCvc, possibleCardFormats]); const formattedCurrencyAmount = useMemo(() => { return toHumanCurrencyString({ amount, currency }); }, [amount, currency]); const handleDonateClicked = useCallback(() => { const formResult = parseCardForm({ cardNumber, cardExpiration, cardCvc }); setCardNumberError(formResult.cardNumber.error ?? null); setCardExpirationError(formResult.cardExpiration.error ?? null); setCardCvcError(formResult.cardCvc.error ?? null); const cardDetail = cardFormToCardDetail(formResult); if (cardDetail == null) { return; } onSubmit(cardDetail); }, [cardCvc, cardExpiration, cardNumber, onSubmit]); const isDonateDisabled = disabled || cardNumber === '' || cardExpiration === '' || cardCvc === '' || cardNumberError != null || cardExpirationError != null || cardCvcError != null; return (
{i18n('icu:DonateFlow__credit-or-debit-card')}
{cardNumberError != null && (
{getCardNumberErrorMessage(i18n, cardNumberError)}
)}
{cardExpirationError && (
{getCardExpirationErrorMessage(i18n, cardExpirationError)}
)}
{cardCvcError && (
{getCardCvcErrorMessage(i18n, cardCvcError)}
)}
); } type CardFormHeroProps = { amount: HumanDonationAmount; currency: string; i18n: LocalizerType; }; // Similar to or renderDonationHero function CardFormHero({ amount, currency, i18n, }: CardFormHeroProps): JSX.Element { const formattedCurrencyAmount = useMemo(() => { return toHumanCurrencyString({ amount, currency }); }, [amount, currency]); return ( <>
{i18n('icu:DonateFlow__card-form-title-donate-with-amount', { formattedCurrencyAmount, })}
{i18n('icu:DonateFlow__one-time-donation-boost-badge-info')}
); } type HelpFooterProps = { i18n: LocalizerType; showOneTimeOnlyNotice?: boolean; }; function HelpFooter({ i18n, showOneTimeOnlyNotice, }: HelpFooterProps): JSX.Element { const contactSupportLink = (parts: Array) => ( {parts} ); return (
{showOneTimeOnlyNotice && (
{i18n('icu:DonateFlow__desktop-one-time-only-notice')}
)}
); }