2025-06-27 13:48:50 -07:00
|
|
|
// 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';
|
2025-07-15 09:52:56 -07:00
|
|
|
import React, {
|
|
|
|
useCallback,
|
|
|
|
useEffect,
|
|
|
|
useMemo,
|
|
|
|
useRef,
|
|
|
|
useState,
|
|
|
|
} from 'react';
|
2025-06-27 13:48:50 -07:00
|
|
|
|
2025-07-21 10:55:21 -07:00
|
|
|
import classNames from 'classnames';
|
2025-06-27 13:48:50 -07:00
|
|
|
import type { LocalizerType } from '../types/Util';
|
|
|
|
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
|
|
|
|
import { Button, ButtonVariant } from './Button';
|
2025-07-22 07:21:41 +10:00
|
|
|
import type {
|
2025-07-31 14:48:12 -07:00
|
|
|
CardDetail,
|
2025-07-22 07:21:41 +10:00
|
|
|
DonationErrorType,
|
2025-08-12 19:35:52 -05:00
|
|
|
DonationStateType,
|
2025-07-22 07:21:41 +10:00
|
|
|
HumanDonationAmount,
|
|
|
|
} from '../types/Donations';
|
2025-07-15 09:52:56 -07:00
|
|
|
import {
|
2025-08-12 19:35:52 -05:00
|
|
|
donationStateSchema,
|
2025-07-15 09:52:56 -07:00
|
|
|
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';
|
2025-07-15 09:52:56 -07:00
|
|
|
import {
|
|
|
|
brandHumanDonationAmount,
|
|
|
|
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';
|
2025-08-04 16:22:22 -07:00
|
|
|
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';
|
2025-06-27 13:48:50 -07:00
|
|
|
|
|
|
|
export type PropsDataType = {
|
|
|
|
i18n: LocalizerType;
|
2025-07-29 02:08:05 +10:00
|
|
|
initialCurrency: string;
|
2025-08-06 09:40:30 -07:00
|
|
|
isOnline: boolean;
|
2025-07-15 09:52:56 -07:00
|
|
|
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
|
2025-07-22 07:21:41 +10:00
|
|
|
lastError: DonationErrorType | undefined;
|
2025-07-15 09:52:56 -07:00
|
|
|
validCurrencies: ReadonlyArray<string>;
|
2025-06-27 13:48:50 -07:00
|
|
|
workflow: DonationWorkflow | undefined;
|
2025-07-21 10:55:21 -07:00
|
|
|
renderDonationHero: () => JSX.Element;
|
2025-06-27 13:48:50 -07:00
|
|
|
};
|
|
|
|
|
2025-07-15 09:52:56 -07:00
|
|
|
type PropsHousekeepingType = {
|
|
|
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
|
|
|
};
|
|
|
|
|
2025-06-27 13:48:50 -07:00
|
|
|
type PropsActionType = {
|
|
|
|
clearWorkflow: () => void;
|
2025-07-31 14:48:12 -07:00
|
|
|
showPrivacyModal: () => void;
|
2025-07-15 09:52:56 -07:00
|
|
|
submitDonation: (payload: SubmitDonationType) => void;
|
|
|
|
onBack: () => void;
|
2025-06-27 13:48:50 -07:00
|
|
|
};
|
|
|
|
|
2025-07-15 09:52:56 -07:00
|
|
|
export type PropsType = PropsDataType & PropsActionType & PropsHousekeepingType;
|
2025-06-27 13:48:50 -07:00
|
|
|
|
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);
|
|
|
|
};
|
|
|
|
|
2025-06-27 13:48:50 -07:00
|
|
|
export function PreferencesDonateFlow({
|
2025-07-15 09:52:56 -07:00
|
|
|
contentsRef,
|
2025-06-27 13:48:50 -07:00
|
|
|
i18n,
|
2025-07-29 02:08:05 +10:00
|
|
|
initialCurrency,
|
2025-08-06 09:40:30 -07:00
|
|
|
isOnline,
|
2025-07-15 09:52:56 -07:00
|
|
|
donationAmountsConfig,
|
2025-07-22 07:21:41 +10:00
|
|
|
lastError,
|
2025-07-15 09:52:56 -07:00
|
|
|
validCurrencies,
|
2025-06-27 13:48:50 -07:00
|
|
|
workflow,
|
|
|
|
clearWorkflow,
|
2025-07-21 10:55:21 -07:00
|
|
|
renderDonationHero,
|
2025-07-31 14:48:12 -07:00
|
|
|
showPrivacyModal,
|
2025-06-27 13:48:50 -07:00
|
|
|
submitDonation,
|
2025-07-15 09:52:56 -07:00
|
|
|
onBack,
|
2025-06-27 13:48:50 -07:00
|
|
|
}: PropsType): JSX.Element {
|
|
|
|
const tryClose = useRef<() => void | undefined>();
|
|
|
|
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
|
|
|
|
i18n,
|
|
|
|
name: 'PreferencesDonateFlow',
|
|
|
|
tryClose,
|
|
|
|
});
|
|
|
|
|
2025-07-15 09:52:56 -07:00
|
|
|
const [step, setStep] = useState<'amount' | 'paymentDetails'>('amount');
|
|
|
|
|
|
|
|
const [amount, setAmount] = useState<HumanDonationAmount>();
|
2025-07-29 02:08:05 +10:00
|
|
|
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
|
|
|
|
>();
|
2025-07-15 09:52:56 -07:00
|
|
|
|
2025-08-22 16:10:38 -05:00
|
|
|
const hasCardFormData = useMemo(() => {
|
|
|
|
if (!cardFormValues) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return (
|
|
|
|
cardFormValues.cardNumber !== '' ||
|
|
|
|
cardFormValues.cardExpiration !== '' ||
|
|
|
|
cardFormValues.cardCvc !== ''
|
|
|
|
);
|
|
|
|
}, [cardFormValues]);
|
|
|
|
|
2025-08-04 16:22:22 -07:00
|
|
|
// When changing currency, clear out the last selected amount
|
|
|
|
const handleAmountPickerCurrencyChanged = useCallback((value: string) => {
|
|
|
|
setAmount(undefined);
|
|
|
|
setCurrency(value);
|
|
|
|
}, []);
|
|
|
|
|
2025-07-15 09:52:56 -07:00
|
|
|
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-06-27 13:48:50 -07:00
|
|
|
|
2025-07-31 14:48:12 -07:00
|
|
|
setIsCardFormDisabled(true);
|
|
|
|
submitDonation({
|
|
|
|
currencyType: currency,
|
|
|
|
paymentAmount,
|
|
|
|
paymentDetail: cardDetail,
|
|
|
|
});
|
|
|
|
},
|
|
|
|
[amount, currency, setIsCardFormDisabled, submitDonation]
|
|
|
|
);
|
2025-07-22 07:21:41 +10:00
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (!workflow || lastError) {
|
2025-07-31 14:48:12 -07:00
|
|
|
setIsCardFormDisabled(false);
|
2025-07-22 07:21:41 +10:00
|
|
|
}
|
2025-07-31 14:48:12 -07:00
|
|
|
}, [lastError, setIsCardFormDisabled, workflow]);
|
2025-06-27 13:48:50 -07:00
|
|
|
|
|
|
|
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();
|
|
|
|
}
|
2025-06-27 13:48:50 -07:00
|
|
|
};
|
2025-08-22 16:10:38 -05:00
|
|
|
const isConfirmationNeeded =
|
|
|
|
hasCardFormData &&
|
|
|
|
!isCardFormDisabled &&
|
|
|
|
(!workflow || !isPaymentDetailFinalizedInWorkflow(workflow));
|
2025-06-27 13:48:50 -07:00
|
|
|
|
2025-07-31 14:48:12 -07:00
|
|
|
confirmDiscardIf(isConfirmationNeeded, onDiscard);
|
2025-08-22 16:10:38 -05:00
|
|
|
}, [
|
|
|
|
clearWorkflow,
|
|
|
|
confirmDiscardIf,
|
|
|
|
hasCardFormData,
|
|
|
|
isCardFormDisabled,
|
|
|
|
workflow,
|
|
|
|
]);
|
2025-06-27 13:48:50 -07:00
|
|
|
tryClose.current = onTryClose;
|
|
|
|
|
2025-07-15 09:52:56 -07:00
|
|
|
let innerContent: JSX.Element;
|
|
|
|
let handleBack: () => void;
|
2025-06-27 13:48:50 -07:00
|
|
|
|
2025-07-15 09:52:56 -07:00
|
|
|
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}
|
2025-08-04 16:22:22 -07:00
|
|
|
onChangeCurrency={handleAmountPickerCurrencyChanged}
|
2025-07-21 10:55:21 -07:00
|
|
|
onSubmit={handleAmountPickerResult}
|
|
|
|
/>
|
|
|
|
<HelpFooter i18n={i18n} showOneTimeOnlyNotice />
|
|
|
|
</>
|
2025-07-15 09:52:56 -07:00
|
|
|
);
|
|
|
|
// 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');
|
2025-07-15 09:52:56 -07:00
|
|
|
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-15 09:52:56 -07:00
|
|
|
/>
|
2025-07-31 14:48:12 -07:00
|
|
|
<HelpFooter i18n={i18n} />
|
|
|
|
</>
|
2025-07-15 09:52:56 -07:00
|
|
|
);
|
|
|
|
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">
|
2025-07-15 09:52:56 -07:00
|
|
|
{confirmDiscardModal}
|
|
|
|
{innerContent}
|
2025-07-21 10:55:21 -07:00
|
|
|
</div>
|
2025-07-15 09:52:56 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
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;
|
2025-07-15 09:52:56 -07:00
|
|
|
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
|
|
|
|
validCurrencies: ReadonlyArray<string>;
|
2025-08-04 16:22:22 -07:00
|
|
|
onChangeCurrency: (value: string) => void;
|
2025-07-15 09:52:56 -07:00
|
|
|
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,
|
2025-07-15 09:52:56 -07:00
|
|
|
validCurrencies,
|
2025-08-04 16:22:22 -07:00
|
|
|
onChangeCurrency,
|
2025-07-15 09:52:56 -07:00
|
|
|
onSubmit,
|
|
|
|
}: AmountPickerProps): JSX.Element {
|
2025-07-21 10:55:21 -07:00
|
|
|
const [currency, setCurrency] = useState(initialCurrency);
|
2025-07-15 09:52:56 -07:00
|
|
|
|
|
|
|
const [presetAmount, setPresetAmount] = useState<
|
|
|
|
HumanDonationAmount | undefined
|
2025-07-21 10:55:21 -07:00
|
|
|
>();
|
2025-08-04 16:22:22 -07:00
|
|
|
const [customAmount, setCustomAmount] = useState<string>(
|
|
|
|
initialAmount?.toString() ?? ''
|
|
|
|
);
|
2025-07-15 09:52:56 -07:00
|
|
|
|
|
|
|
// 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);
|
2025-08-04 16:22:22 -07:00
|
|
|
setCustomAmount('');
|
2025-07-21 10:55:21 -07:00
|
|
|
} else {
|
|
|
|
setPresetAmount(undefined);
|
|
|
|
}
|
|
|
|
}, [initialAmount, presetAmountOptions]);
|
2025-07-15 09:52:56 -07:00
|
|
|
|
|
|
|
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-07-15 09:52:56 -07:00
|
|
|
const currencyOptionsForSelect = useMemo(() => {
|
2025-08-04 16:22:22 -07:00
|
|
|
return validCurrencies.toSorted().map((currencyString: string) => {
|
2025-07-15 09:52:56 -07:00
|
|
|
return { text: currencyString.toUpperCase(), value: currencyString };
|
|
|
|
});
|
|
|
|
}, [validCurrencies]);
|
|
|
|
|
|
|
|
const { error, parsedCustomAmount } = useMemo<{
|
|
|
|
error: 'invalid' | 'amount-below-minimum' | undefined;
|
|
|
|
parsedCustomAmount: HumanDonationAmount | undefined;
|
|
|
|
}>(() => {
|
|
|
|
if (customAmount === '' || customAmount == null) {
|
|
|
|
return {
|
|
|
|
error: undefined,
|
|
|
|
parsedCustomAmount: undefined,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
const parseResult = parseCurrencyString({
|
|
|
|
currency,
|
|
|
|
value: customAmount,
|
|
|
|
});
|
|
|
|
|
|
|
|
if (parseResult != null) {
|
|
|
|
if (parseResult >= minimumAmount) {
|
|
|
|
// Valid input
|
|
|
|
return {
|
|
|
|
error: undefined,
|
|
|
|
parsedCustomAmount: parseResult,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
error: 'amount-below-minimum',
|
|
|
|
parsedCustomAmount: undefined,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
error: 'invalid',
|
|
|
|
parsedCustomAmount: undefined,
|
|
|
|
};
|
|
|
|
}, [currency, customAmount, minimumAmount]);
|
|
|
|
|
2025-08-04 16:22:22 -07:00
|
|
|
const handleCurrencyChanged = useCallback(
|
|
|
|
(value: string) => {
|
|
|
|
setCurrency(value);
|
|
|
|
setCustomAmount('');
|
|
|
|
onChangeCurrency(value);
|
|
|
|
},
|
|
|
|
[onChangeCurrency]
|
|
|
|
);
|
2025-07-15 09:52:56 -07:00
|
|
|
|
2025-08-26 12:36:44 -07:00
|
|
|
const handleCustomAmountFocus = useCallback(() => {
|
|
|
|
setPresetAmount(undefined);
|
|
|
|
}, []);
|
|
|
|
|
2025-07-15 09:52:56 -07:00
|
|
|
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;
|
2025-07-15 09:52:56 -07:00
|
|
|
|
|
|
|
const handleContinueClicked = useCallback(() => {
|
|
|
|
if (!isContinueEnabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
onSubmit({ amount, currency });
|
|
|
|
}, [amount, currency, isContinueEnabled, onSubmit]);
|
|
|
|
|
2025-07-21 10:55:21 -07:00
|
|
|
let customInputClassName;
|
|
|
|
if (error) {
|
|
|
|
customInputClassName = 'DonationAmountPicker__CustomInput--with-error';
|
|
|
|
} else if (parsedCustomAmount) {
|
|
|
|
customInputClassName = 'DonationAmountPicker__CustomInput--selected';
|
|
|
|
} else {
|
|
|
|
customInputClassName = 'DonationAmountPicker__CustomInput';
|
|
|
|
}
|
|
|
|
|
2025-08-06 09:40:30 -07:00
|
|
|
const continueButton = (
|
|
|
|
<Button
|
|
|
|
className="PreferencesDonations__PrimaryButton"
|
|
|
|
disabled={!isContinueEnabled}
|
|
|
|
onClick={handleContinueClicked}
|
|
|
|
variant={isOnline ? ButtonVariant.Primary : ButtonVariant.Secondary}
|
|
|
|
>
|
|
|
|
Continue
|
|
|
|
</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>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2025-07-15 09:52:56 -07:00
|
|
|
return (
|
2025-07-21 10:55:21 -07:00
|
|
|
<div className="DonationAmountPicker">
|
2025-07-15 09:52:56 -07:00
|
|
|
<Select
|
2025-07-21 10:55:21 -07:00
|
|
|
moduleClassName="DonationForm__CurrencySelect"
|
2025-07-15 09:52:56 -07:00
|
|
|
id="currency"
|
|
|
|
options={currencyOptionsForSelect}
|
|
|
|
onChange={handleCurrencyChanged}
|
|
|
|
value={currency}
|
2025-06-27 13:48:50 -07:00
|
|
|
/>
|
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">
|
2025-07-15 09:52:56 -07:00
|
|
|
{presetAmountOptions.map(value => (
|
2025-07-21 10:55:21 -07:00
|
|
|
<button
|
|
|
|
className={classNames({
|
|
|
|
DonationAmountPicker__PresetButton: true,
|
|
|
|
'DonationAmountPicker__PresetButton--selected':
|
|
|
|
presetAmount === value,
|
|
|
|
})}
|
2025-07-15 09:52:56 -07:00
|
|
|
key={value}
|
|
|
|
onClick={() => {
|
2025-08-04 16:22:22 -07:00
|
|
|
setCustomAmount('');
|
2025-07-15 09:52:56 -07:00
|
|
|
setPresetAmount(value);
|
|
|
|
}}
|
2025-07-21 10:55:21 -07:00
|
|
|
type="button"
|
2025-07-15 09:52:56 -07:00
|
|
|
>
|
|
|
|
{toHumanCurrencyString({ amount: value, currency })}
|
2025-07-21 10:55:21 -07:00
|
|
|
</button>
|
2025-07-15 09:52:56 -07:00
|
|
|
))}
|
2025-08-04 16:22:22 -07:00
|
|
|
<DonateInputAmount
|
|
|
|
className={customInputClassName}
|
|
|
|
currency={currency}
|
2025-07-15 09:52:56 -07:00
|
|
|
id="customAmount"
|
2025-08-04 16:22:22 -07:00
|
|
|
onValueChange={handleCustomAmountChanged}
|
2025-08-26 12:36:44 -07:00
|
|
|
onFocus={handleCustomAmountFocus}
|
2025-08-04 16:22:22 -07:00
|
|
|
placeholder={i18n(
|
|
|
|
'icu:DonateFlow__amount-picker-custom-amount-placeholder'
|
|
|
|
)}
|
2025-07-15 09:52:56 -07:00
|
|
|
value={customAmount}
|
|
|
|
/>
|
|
|
|
</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);
|
2025-08-21 14:41:30 -07:00
|
|
|
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]);
|
|
|
|
|
2025-08-21 14:41:30 -07:00
|
|
|
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}
|
2025-08-21 14:41:30 -07:00
|
|
|
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}
|
2025-08-21 14:41:30 -07:00
|
|
|
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}
|
2025-08-21 14:41:30 -07:00
|
|
|
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,
|
|
|
|
}}
|
|
|
|
/>
|
2025-06-27 13:48:50 -07:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|