signal-desktop/ts/components/PreferencesDonateFlow.tsx

944 lines
27 KiB
TypeScript
Raw Normal View History

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
2025-07-31 14:48:12 -07:00
import type { MutableRefObject, ReactNode } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
2025-07-21 10:55:21 -07:00
import classNames from 'classnames';
import type { LocalizerType } from '../types/Util';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
import { Button, ButtonVariant } from './Button';
import type {
2025-07-31 14:48:12 -07:00
CardDetail,
DonationErrorType,
2025-08-12 19:35:52 -05:00
DonationStateType,
HumanDonationAmount,
} from '../types/Donations';
import {
2025-08-12 19:35:52 -05:00
donationStateSchema,
ONE_TIME_DONATION_CONFIG_ID,
type DonationWorkflow,
type OneTimeDonationHumanAmounts,
} from '../types/Donations';
2025-07-17 14:38:19 -07:00
import type {
CardCvcError,
CardExpirationError,
CardNumberError,
} from '../types/DonationsCardForm';
import {
cardFormToCardDetail,
getCardFormSettings,
getPossibleCardFormats,
parseCardCvc,
parseCardExpiration,
parseCardForm,
parseCardNumber,
} from '../types/DonationsCardForm';
import {
brandHumanDonationAmount,
2025-09-03 10:47:19 -07:00
type CurrencyFormatResult,
getCurrencyFormat,
getMaximumStripeAmount,
parseCurrencyString,
toHumanCurrencyString,
toStripeDonationAmount,
} from '../util/currency';
import { PreferencesContent } from './Preferences';
import type { SubmitDonationType } from '../state/ducks/donations';
import { Select } from './Select';
2025-07-17 14:38:19 -07:00
import {
DonateInputCardNumber,
getCardNumberErrorMessage,
} from './preferences/donations/DonateInputCardNumber';
import {
DonateInputCardExp,
getCardExpirationErrorMessage,
} from './preferences/donations/DonateInputCardExp';
import {
DonateInputCardCvc,
getCardCvcErrorMessage,
} from './preferences/donations/DonateInputCardCvc';
2025-07-21 10:55:21 -07:00
import { I18n } from './I18n';
2025-07-31 14:48:12 -07:00
import { strictAssert } from '../util/assert';
2025-08-06 09:40:30 -07:00
import { DonationsOfflineTooltip } from './conversation/DonationsOfflineTooltip';
import { DonateInputAmount } from './preferences/donations/DonateInputAmount';
2025-08-18 16:51:16 -07:00
import { Tooltip, TooltipPlacement } from './Tooltip';
import { offsetDistanceModifier } from '../util/popperUtil';
2025-07-21 10:55:21 -07:00
const SUPPORT_URL = 'https://support.signal.org/hc/requests/new?desktop';
export type PropsDataType = {
i18n: LocalizerType;
initialCurrency: string;
2025-08-06 09:40:30 -07:00
isOnline: boolean;
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
lastError: DonationErrorType | undefined;
validCurrencies: ReadonlyArray<string>;
workflow: DonationWorkflow | undefined;
2025-07-21 10:55:21 -07:00
renderDonationHero: () => JSX.Element;
};
type PropsHousekeepingType = {
contentsRef: MutableRefObject<HTMLDivElement | null>;
};
type PropsActionType = {
clearWorkflow: () => void;
2025-07-31 14:48:12 -07:00
showPrivacyModal: () => void;
submitDonation: (payload: SubmitDonationType) => void;
onBack: () => void;
};
export type PropsType = PropsDataType & PropsActionType & PropsHousekeepingType;
2025-08-12 19:35:52 -05:00
const isPaymentDetailFinalizedInWorkflow = (workflow: DonationWorkflow) => {
const finalizedStates: Array<DonationStateType> = [
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,
2025-08-06 09:40:30 -07:00
isOnline,
donationAmountsConfig,
lastError,
validCurrencies,
workflow,
clearWorkflow,
2025-07-21 10:55:21 -07:00
renderDonationHero,
2025-07-31 14:48:12 -07:00
showPrivacyModal,
submitDonation,
onBack,
}: PropsType): JSX.Element {
const tryClose = useRef<() => void | undefined>();
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
i18n,
bodyText: i18n('icu:DonateFlow__discard-dialog-body'),
discardText: i18n('icu:DonateFlow__discard-dialog-remove-info'),
name: 'PreferencesDonateFlow',
tryClose,
});
const [step, setStep] = useState<'amount' | 'paymentDetails'>('amount');
const [amount, setAmount] = useState<HumanDonationAmount>();
const [currency, setCurrency] = useState<string>(initialCurrency);
2025-07-31 14:48:12 -07:00
const [isCardFormDisabled, setIsCardFormDisabled] = useState(false);
const [cardFormValues, setCardFormValues] = useState<
CardFormValues | undefined
>();
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 handleAmountPickerResult = useCallback((result: AmountPickerResult) => {
const { currency: pickedCurrency, amount: pickedAmount } = result;
setAmount(pickedAmount);
setCurrency(pickedCurrency);
setStep('paymentDetails');
}, []);
2025-07-31 14:48:12 -07:00
const handleCardFormChanged = useCallback((values: CardFormValues) => {
setCardFormValues(values);
}, []);
2025-07-17 14:38:19 -07:00
2025-07-31 14:48:12 -07:00
const handleSubmitDonation = useCallback(
(cardDetail: CardDetail) => {
if (amount == null || currency == null) {
return;
}
2025-07-17 14:38:19 -07:00
2025-07-31 14:48:12 -07:00
const paymentAmount = toStripeDonationAmount({ amount, currency });
2025-07-31 14:48:12 -07:00
setIsCardFormDisabled(true);
submitDonation({
currencyType: currency,
paymentAmount,
paymentDetail: cardDetail,
});
},
[amount, currency, setIsCardFormDisabled, submitDonation]
);
useEffect(() => {
if (!workflow || lastError) {
2025-07-31 14:48:12 -07:00
setIsCardFormDisabled(false);
}
2025-07-31 14:48:12 -07:00
}, [lastError, setIsCardFormDisabled, workflow]);
const onTryClose = useCallback(() => {
const onDiscard = () => {
2025-08-12 19:35:52 -05:00
// 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 &&
(!workflow || !isPaymentDetailFinalizedInWorkflow(workflow));
2025-07-31 14:48:12 -07:00
confirmDiscardIf(isConfirmationNeeded, onDiscard);
}, [
clearWorkflow,
confirmDiscardIf,
hasCardFormData,
isCardFormDisabled,
workflow,
]);
tryClose.current = onTryClose;
let innerContent: JSX.Element;
let handleBack: () => void;
if (step === 'amount') {
innerContent = (
2025-07-21 10:55:21 -07:00
<>
{renderDonationHero()}
<AmountPicker
i18n={i18n}
initialAmount={amount}
initialCurrency={currency}
2025-08-06 09:40:30 -07:00
isOnline={isOnline}
2025-07-21 10:55:21 -07:00
donationAmountsConfig={donationAmountsConfig}
validCurrencies={validCurrencies}
onChangeCurrency={handleAmountPickerCurrencyChanged}
2025-07-21 10:55:21 -07:00
onSubmit={handleAmountPickerResult}
/>
<HelpFooter i18n={i18n} showOneTimeOnlyNotice />
</>
);
// Dismiss DonateFlow and return to Donations home
handleBack = () => onBack();
} else {
2025-07-31 14:48:12 -07:00
strictAssert(amount, 'Amount is required for payment card form');
innerContent = (
2025-07-31 14:48:12 -07:00
<>
<CardFormHero i18n={i18n} amount={amount} currency={currency} />
<hr className="PreferencesDonations__separator PreferencesDonations__separator--card-form" />
<CardForm
amount={amount}
currency={currency}
disabled={isCardFormDisabled}
i18n={i18n}
initialValues={cardFormValues}
2025-08-06 09:40:30 -07:00
isOnline={isOnline}
2025-07-31 14:48:12 -07:00
onChange={handleCardFormChanged}
onSubmit={handleSubmitDonation}
showPrivacyModal={showPrivacyModal}
/>
2025-07-31 14:48:12 -07:00
<HelpFooter i18n={i18n} />
</>
);
handleBack = () => {
setStep('amount');
};
}
const backButton = (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={handleBack}
type="button"
/>
);
const content = (
2025-07-21 10:55:21 -07:00
<div className="PreferencesDonations DonationForm">
{confirmDiscardModal}
{innerContent}
2025-07-21 10:55:21 -07:00
</div>
);
return (
<PreferencesContent
backButton={backButton}
contents={content}
contentsRef={contentsRef}
title={undefined}
/>
);
}
type AmountPickerResult = {
amount: HumanDonationAmount;
currency: string;
};
type AmountPickerProps = {
i18n: LocalizerType;
initialAmount: HumanDonationAmount | undefined;
initialCurrency: string | undefined;
2025-08-06 09:40:30 -07:00
isOnline: boolean;
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
validCurrencies: ReadonlyArray<string>;
onChangeCurrency: (value: string) => void;
onSubmit: (result: AmountPickerResult) => void;
};
function AmountPicker({
donationAmountsConfig,
i18n,
initialAmount,
2025-07-21 10:55:21 -07:00
initialCurrency = 'usd',
2025-08-06 09:40:30 -07:00
isOnline,
validCurrencies,
onChangeCurrency,
onSubmit,
}: AmountPickerProps): JSX.Element {
2025-07-21 10:55:21 -07:00
const [currency, setCurrency] = useState(initialCurrency);
const [presetAmount, setPresetAmount] = useState<
HumanDonationAmount | undefined
2025-07-21 10:55:21 -07:00
>();
2025-09-03 10:47:19 -07:00
// Use localized group and decimal separators, but no symbol
// Symbol will be added by DonateInputAmount
const [customAmount, setCustomAmount] = useState<string>(
2025-09-03 10:47:19 -07:00
toHumanCurrencyString({
amount: initialAmount,
currency,
symbol: 'none',
})
);
2025-09-03 10:47:19 -07:00
const [isCustomAmountErrorVisible, setIsCustomAmountErrorVisible] =
useState<boolean>(false);
// Reset amount selections when API donation config or selected currency changes
// Memo here so preset options instantly load when component mounts.
const presetAmountOptions = useMemo(() => {
if (!donationAmountsConfig || !donationAmountsConfig[currency]) {
return [];
}
const currencyAmounts = donationAmountsConfig[currency];
const presets = currencyAmounts.oneTime[ONE_TIME_DONATION_CONFIG_ID] ?? [];
return presets;
}, [donationAmountsConfig, currency]);
useEffect(() => {
2025-07-21 10:55:21 -07:00
if (
initialAmount &&
presetAmountOptions.find(option => option === initialAmount)
) {
setPresetAmount(initialAmount);
setCustomAmount('');
2025-07-21 10:55:21 -07:00
} else {
setPresetAmount(undefined);
}
}, [initialAmount, presetAmountOptions]);
const minimumAmount = useMemo<HumanDonationAmount>(() => {
if (!donationAmountsConfig || !donationAmountsConfig[currency]) {
return brandHumanDonationAmount(0);
}
const currencyAmounts = donationAmountsConfig[currency];
return currencyAmounts.minimum;
}, [donationAmountsConfig, currency]);
2025-08-18 16:51:16 -07:00
const formattedMinimumAmount = useMemo<string>(() => {
return toHumanCurrencyString({ amount: minimumAmount, currency });
}, [minimumAmount, currency]);
2025-09-03 10:47:19 -07:00
const maximumAmount = useMemo<HumanDonationAmount>(() => {
return getMaximumStripeAmount(currency);
}, [currency]);
const formattedMaximumAmount = useMemo<string>(() => {
return toHumanCurrencyString({ amount: maximumAmount, currency });
}, [maximumAmount, currency]);
const currencyOptionsForSelect = useMemo(() => {
return validCurrencies.toSorted().map((currencyString: string) => {
return { text: currencyString.toUpperCase(), value: currencyString };
});
}, [validCurrencies]);
2025-09-03 10:47:19 -07:00
const currencyFormat = useMemo<CurrencyFormatResult | undefined>(
() => getCurrencyFormat(currency),
[currency]
);
const { error, parsedCustomAmount } = useMemo<{
2025-09-03 10:47:19 -07:00
error:
| 'invalid'
| 'amount-below-minimum'
| 'amount-above-maximum'
| undefined;
parsedCustomAmount: HumanDonationAmount | undefined;
}>(() => {
2025-09-03 10:47:19 -07:00
if (
customAmount === '' ||
customAmount == null ||
(currencyFormat?.symbol && customAmount === currencyFormat?.symbol)
) {
return {
error: undefined,
parsedCustomAmount: undefined,
};
}
const parseResult = parseCurrencyString({
currency,
value: customAmount,
});
if (parseResult != null) {
2025-09-03 10:47:19 -07:00
if (parseResult > maximumAmount) {
return {
error: 'amount-above-maximum',
parsedCustomAmount: undefined,
};
}
if (parseResult >= minimumAmount) {
// Valid input
return {
error: undefined,
parsedCustomAmount: parseResult,
};
}
return {
error: 'amount-below-minimum',
parsedCustomAmount: undefined,
};
}
return {
error: 'invalid',
parsedCustomAmount: undefined,
};
2025-09-03 10:47:19 -07:00
}, [currency, currencyFormat, customAmount, minimumAmount, maximumAmount]);
const handleCurrencyChanged = useCallback(
(value: string) => {
setCurrency(value);
setCustomAmount('');
onChangeCurrency(value);
},
[onChangeCurrency]
);
const handleCustomAmountFocus = useCallback(() => {
setPresetAmount(undefined);
}, []);
2025-09-03 10:47:19 -07:00
const handleCustomAmountBlur = useCallback(() => {
// Only show parse errors on blur to avoid interrupting entry.
// For example if you enter $1000 then it shouldn't show an error after '$1'.
if (error) {
setIsCustomAmountErrorVisible(true);
}
}, [error]);
const handleCustomAmountChanged = useCallback((value: string) => {
// Custom amount overrides any selected preset amount
setPresetAmount(undefined);
setCustomAmount(value);
}, []);
const amount = parsedCustomAmount ?? presetAmount;
2025-08-06 09:40:30 -07:00
const isContinueEnabled = isOnline && currency != null && amount != null;
const handleContinueClicked = useCallback(() => {
if (!isContinueEnabled) {
return;
}
onSubmit({ amount, currency });
}, [amount, currency, isContinueEnabled, onSubmit]);
2025-09-03 10:47:19 -07:00
useEffect(() => {
// While entering custom amount, clear error as soon as we see a valid value.
if (error == null) {
setIsCustomAmountErrorVisible(false);
}
}, [error]);
2025-07-21 10:55:21 -07:00
let customInputClassName;
2025-09-03 10:47:19 -07:00
if (error && isCustomAmountErrorVisible) {
2025-07-21 10:55:21 -07:00
customInputClassName = 'DonationAmountPicker__CustomInput--with-error';
} else if (parsedCustomAmount) {
customInputClassName = 'DonationAmountPicker__CustomInput--selected';
} else {
customInputClassName = 'DonationAmountPicker__CustomInput';
}
2025-09-03 10:47:19 -07:00
let customInputError: JSX.Element | undefined;
if (isCustomAmountErrorVisible) {
if (error === 'amount-below-minimum') {
customInputError = (
<div className="DonationAmountPicker__CustomAmountError">
{i18n('icu:DonateFlow__custom-amount-below-minimum-error', {
formattedCurrencyAmount: formattedMinimumAmount,
})}
</div>
);
} else if (error === 'amount-above-maximum') {
customInputError = (
<div className="DonationAmountPicker__CustomAmountError">
{i18n('icu:DonateFlow__custom-amount-above-maximum-error', {
formattedCurrencyAmount: formattedMaximumAmount,
})}
</div>
);
}
}
2025-08-06 09:40:30 -07:00
const continueButton = (
<Button
className="PreferencesDonations__PrimaryButton"
disabled={!isContinueEnabled}
onClick={handleContinueClicked}
variant={isOnline ? ButtonVariant.Primary : ButtonVariant.Secondary}
>
2025-09-03 10:47:19 -07:00
{i18n('icu:DonateFlow__continue')}
2025-08-06 09:40:30 -07:00
</Button>
);
2025-08-18 16:51:16 -07:00
let continueButtonWithTooltip: JSX.Element | undefined;
if (!isOnline) {
continueButtonWithTooltip = (
<DonationsOfflineTooltip i18n={i18n}>
{continueButton}
</DonationsOfflineTooltip>
);
} else if (error === 'amount-below-minimum') {
continueButtonWithTooltip = (
<Tooltip
className="InAnotherCallTooltip"
content={i18n('icu:DonateFlow__custom-amount-below-minimum-tooltip', {
formattedCurrencyAmount: formattedMinimumAmount,
})}
direction={TooltipPlacement.Top}
popperModifiers={[offsetDistanceModifier(20)]}
>
{continueButton}
</Tooltip>
);
}
return (
2025-07-21 10:55:21 -07:00
<div className="DonationAmountPicker">
<Select
2025-07-21 10:55:21 -07:00
moduleClassName="DonationForm__CurrencySelect"
id="currency"
options={currencyOptionsForSelect}
onChange={handleCurrencyChanged}
value={currency}
/>
2025-07-21 10:55:21 -07:00
<div className="PreferencesDonations__section-header PreferencesDonations__section-header--donate-flow">
{i18n('icu:DonateFlow__make-a-one-time-donation')}
</div>
<div className="DonationAmountPicker__AmountOptions">
{presetAmountOptions.map(value => (
2025-07-21 10:55:21 -07:00
<button
className={classNames({
DonationAmountPicker__PresetButton: true,
'DonationAmountPicker__PresetButton--selected':
presetAmount === value,
})}
key={value}
onClick={() => {
setCustomAmount('');
setPresetAmount(value);
}}
2025-07-21 10:55:21 -07:00
type="button"
>
2025-09-03 10:47:19 -07:00
{toHumanCurrencyString({
amount: value,
currency,
symbol: 'narrowSymbol',
})}
2025-07-21 10:55:21 -07:00
</button>
))}
<DonateInputAmount
className={customInputClassName}
currency={currency}
id="customAmount"
onValueChange={handleCustomAmountChanged}
onFocus={handleCustomAmountFocus}
2025-09-03 10:47:19 -07:00
onBlur={handleCustomAmountBlur}
placeholder={i18n(
'icu:DonateFlow__amount-picker-custom-amount-placeholder'
)}
value={customAmount}
/>
2025-09-03 10:47:19 -07:00
{customInputError}
</div>
2025-07-21 10:55:21 -07:00
<div className="DonationAmountPicker__PrimaryButtonContainer">
2025-08-18 16:51:16 -07:00
{continueButtonWithTooltip ?? continueButton}
2025-07-21 10:55:21 -07:00
</div>
</div>
);
}
2025-07-31 14:48:12 -07:00
type CardFormValues = {
cardExpiration: string | undefined;
cardNumber: string | undefined;
cardCvc: string | undefined;
};
type CardFormProps = {
amount: HumanDonationAmount;
currency: string;
disabled: boolean;
i18n: LocalizerType;
initialValues: CardFormValues | undefined;
2025-08-06 09:40:30 -07:00
isOnline: boolean;
2025-07-31 14:48:12 -07:00
onChange: (values: CardFormValues) => void;
onSubmit: (cardDetail: CardDetail) => void;
showPrivacyModal: () => void;
};
function CardForm({
amount,
currency,
disabled,
i18n,
initialValues,
2025-08-06 09:40:30 -07:00
isOnline,
2025-07-31 14:48:12 -07:00
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<CardNumberError | null>(null);
const [cardExpirationError, setCardExpirationError] =
useState<CardExpirationError | null>(null);
const [cardCvcError, setCardCvcError] = useState<CardCvcError | null>(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 (
<button
type="button"
className="PreferencesDonations__description__read-more"
onClick={showPrivacyModal}
>
{parts}
</button>
);
},
[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<string>(() => {
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 ||
formResult.cardNumber.error ||
formResult.cardExpiration.error ||
formResult.cardCvc.error
) {
2025-07-31 14:48:12 -07:00
return;
}
onSubmit(cardDetail);
}, [cardCvc, cardExpiration, cardNumber, onSubmit]);
const isDonateDisabled = useMemo(
() =>
disabled ||
!isOnline ||
cardNumber === '' ||
cardExpiration === '' ||
cardCvc === '' ||
cardNumberError != null ||
cardExpirationError != null ||
cardCvcError != null,
[
cardCvc,
cardCvcError,
cardExpiration,
cardExpirationError,
cardNumber,
cardNumberError,
disabled,
isOnline,
]
);
const handleInputEnterKey = useCallback(() => {
if (!isDonateDisabled) {
handleDonateClicked();
}
}, [handleDonateClicked, isDonateDisabled]);
2025-07-31 14:48:12 -07:00
2025-08-06 09:40:30 -07:00
const donateButton = (
<Button
className="PreferencesDonations__PrimaryButton"
disabled={isDonateDisabled}
onClick={handleDonateClicked}
variant={isOnline ? ButtonVariant.Primary : ButtonVariant.Secondary}
>
{i18n('icu:PreferencesDonations__donate-button-with-amount', {
formattedCurrencyAmount,
})}
</Button>
);
2025-07-31 14:48:12 -07:00
return (
<div className="DonationCardForm">
<div className="DonationCardForm__Header--Info PreferencesDonations__section-header">
{i18n('icu:DonateFlow__credit-or-debit-card')}
</div>
<div className="DonationCardForm__Info">
<I18n
components={{
learnMoreLink: privacyLearnMoreLink,
}}
i18n={i18n}
id="icu:DonateFlow__card-form-instructions"
/>
</div>
<div className="DonationCardForm_Field DonationCardForm_CardNumberField">
<label className="DonationCardForm_Label" htmlFor="cardNumber">
{i18n('icu:DonateFlow__card-form-card-number')}
</label>
<div
className={classNames({
'DonationCardForm_InputContainer--with-error':
cardNumberError != null,
})}
>
<DonateInputCardNumber
id="cardNumber"
value={cardNumber}
onValueChange={handleCardNumberChange}
maxInputLength={cardFormSettings.cardNumber.maxInputLength}
onBlur={handleCardNumberBlur}
onEnter={handleInputEnterKey}
2025-07-31 14:48:12 -07:00
/>
{cardNumberError != null && (
<div className="DonationCardForm_FieldError">
{getCardNumberErrorMessage(i18n, cardNumberError)}
</div>
)}
</div>
</div>
<div className="DonationCardForm_Field DonationCardForm_CardExpirationField">
<label className="DonationCardForm_Label" htmlFor="cardExpiration">
{i18n('icu:DonateFlow__card-form-expiration-date')}
</label>
<div
className={classNames({
'DonationCardForm_InputContainer--with-error':
cardExpirationError != null,
})}
>
<DonateInputCardExp
id="cardExpiration"
value={cardExpiration}
onValueChange={handleCardExpirationChange}
onBlur={handleCardExpirationBlur}
onEnter={handleInputEnterKey}
2025-07-31 14:48:12 -07:00
/>
{cardExpirationError && (
<div className="DonationCardForm_FieldError">
{getCardExpirationErrorMessage(i18n, cardExpirationError)}
</div>
)}
</div>
</div>
<div className="DonationCardForm_Field DonationCardForm_CardCvcField">
<label className="DonationCardForm_Label" htmlFor="cardCvc">
{cardFormSettings.cardCvc.label}
</label>
<div
className={classNames({
'DonationCardForm_InputContainer--with-error': cardCvcError != null,
})}
>
<DonateInputCardCvc
id="cardCvc"
value={cardCvc}
onValueChange={handleCardCvcChange}
maxInputLength={cardFormSettings.cardCvc.maxInputLength}
onBlur={handleCardCvcBlur}
onEnter={handleInputEnterKey}
2025-07-31 14:48:12 -07:00
/>
{cardCvcError && (
<div className="DonationCardForm_FieldError">
{getCardCvcErrorMessage(i18n, cardCvcError)}
</div>
)}
</div>
</div>
<div className="DonationCardForm__PrimaryButtonContainer">
2025-08-06 09:40:30 -07:00
{isOnline ? (
donateButton
) : (
<DonationsOfflineTooltip i18n={i18n}>
{donateButton}
</DonationsOfflineTooltip>
)}
2025-07-31 14:48:12 -07:00
</div>
</div>
);
}
type CardFormHeroProps = {
amount: HumanDonationAmount;
currency: string;
i18n: LocalizerType;
};
// Similar to <DonationHero> or renderDonationHero
function CardFormHero({
amount,
currency,
i18n,
}: CardFormHeroProps): JSX.Element {
const formattedCurrencyAmount = useMemo<string>(() => {
return toHumanCurrencyString({ amount, currency });
}, [amount, currency]);
return (
<>
<div className="PreferencesDonations__avatar">
<div className="DonationCardFormHero__Badge" />
</div>
<div className="PreferencesDonations__title">
{i18n('icu:DonateFlow__card-form-title-donate-with-amount', {
formattedCurrencyAmount,
})}
</div>
<div className="PreferencesDonations__description">
{i18n('icu:DonateFlow__one-time-donation-boost-badge-info')}
</div>
</>
);
}
2025-07-21 10:55:21 -07:00
type HelpFooterProps = {
i18n: LocalizerType;
showOneTimeOnlyNotice?: boolean;
};
function HelpFooter({
i18n,
showOneTimeOnlyNotice,
}: HelpFooterProps): JSX.Element {
const contactSupportLink = (parts: Array<string | JSX.Element>) => (
<a
className="DonationFormHelpFooter__ContactSupportLink"
href={SUPPORT_URL}
rel="noreferrer"
target="_blank"
>
{parts}
</a>
);
return (
<div className="DonationForm__HelpFooter">
{showOneTimeOnlyNotice && (
<div className="DonationForm__HelpFooterDesktopOneTimeOnlyNotice">
{i18n('icu:DonateFlow__desktop-one-time-only-notice')}
</div>
)}
<I18n
id="icu:DonateFlow__having-issues-contact-support"
i18n={i18n}
components={{
contactSupportLink,
}}
/>
</div>
);
}