Fixes for donation amount picker

This commit is contained in:
ayumi-signal
2025-09-03 10:47:19 -07:00
committed by GitHub
parent c09dc17867
commit 14e0086943
9 changed files with 286 additions and 39 deletions
+14
View File
@@ -118,6 +118,13 @@ const exportLocalBackupResult = {
};
const donationAmountsConfig = {
cad: {
minimum: 4,
oneTime: {
1: [7, 15, 30, 40, 70, 140],
100: [7],
},
},
jpy: {
minimum: 400,
oneTime: {
@@ -132,6 +139,13 @@ const donationAmountsConfig = {
100: [5],
},
},
ugx: {
minimum: 8000,
oneTime: {
1: [15000, 35000, 70000, 100000, 150000, 300000],
100: [15000],
},
},
} as unknown as OneTimeDonationHumanAmounts;
function renderUpdateDialog(
+90 -7
View File
@@ -42,6 +42,9 @@ import {
} from '../types/DonationsCardForm';
import {
brandHumanDonationAmount,
type CurrencyFormatResult,
getCurrencyFormat,
getMaximumStripeAmount,
parseCurrencyString,
toHumanCurrencyString,
toStripeDonationAmount,
@@ -315,10 +318,20 @@ function AmountPicker({
const [presetAmount, setPresetAmount] = useState<
HumanDonationAmount | undefined
>();
// Use localized group and decimal separators, but no symbol
// Symbol will be added by DonateInputAmount
const [customAmount, setCustomAmount] = useState<string>(
initialAmount?.toString() ?? ''
toHumanCurrencyString({
amount: initialAmount,
currency,
symbol: 'none',
})
);
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(() => {
@@ -356,17 +369,38 @@ function AmountPicker({
return toHumanCurrencyString({ amount: minimumAmount, currency });
}, [minimumAmount, currency]);
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]);
const currencyFormat = useMemo<CurrencyFormatResult | undefined>(
() => getCurrencyFormat(currency),
[currency]
);
const { error, parsedCustomAmount } = useMemo<{
error: 'invalid' | 'amount-below-minimum' | undefined;
error:
| 'invalid'
| 'amount-below-minimum'
| 'amount-above-maximum'
| undefined;
parsedCustomAmount: HumanDonationAmount | undefined;
}>(() => {
if (customAmount === '' || customAmount == null) {
if (
customAmount === '' ||
customAmount == null ||
(currencyFormat?.symbol && customAmount === currencyFormat?.symbol)
) {
return {
error: undefined,
parsedCustomAmount: undefined,
@@ -379,6 +413,13 @@ function AmountPicker({
});
if (parseResult != null) {
if (parseResult > maximumAmount) {
return {
error: 'amount-above-maximum',
parsedCustomAmount: undefined,
};
}
if (parseResult >= minimumAmount) {
// Valid input
return {
@@ -397,7 +438,7 @@ function AmountPicker({
error: 'invalid',
parsedCustomAmount: undefined,
};
}, [currency, customAmount, minimumAmount]);
}, [currency, currencyFormat, customAmount, minimumAmount, maximumAmount]);
const handleCurrencyChanged = useCallback(
(value: string) => {
@@ -412,6 +453,14 @@ function AmountPicker({
setPresetAmount(undefined);
}, []);
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);
@@ -429,8 +478,15 @@ function AmountPicker({
onSubmit({ amount, currency });
}, [amount, currency, isContinueEnabled, onSubmit]);
useEffect(() => {
// While entering custom amount, clear error as soon as we see a valid value.
if (error == null) {
setIsCustomAmountErrorVisible(false);
}
}, [error]);
let customInputClassName;
if (error) {
if (error && isCustomAmountErrorVisible) {
customInputClassName = 'DonationAmountPicker__CustomInput--with-error';
} else if (parsedCustomAmount) {
customInputClassName = 'DonationAmountPicker__CustomInput--selected';
@@ -438,6 +494,27 @@ function AmountPicker({
customInputClassName = 'DonationAmountPicker__CustomInput';
}
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>
);
}
}
const continueButton = (
<Button
className="PreferencesDonations__PrimaryButton"
@@ -445,7 +522,7 @@ function AmountPicker({
onClick={handleContinueClicked}
variant={isOnline ? ButtonVariant.Primary : ButtonVariant.Secondary}
>
Continue
{i18n('icu:DonateFlow__continue')}
</Button>
);
@@ -498,7 +575,11 @@ function AmountPicker({
}}
type="button"
>
{toHumanCurrencyString({ amount: value, currency })}
{toHumanCurrencyString({
amount: value,
currency,
symbol: 'narrowSymbol',
})}
</button>
))}
<DonateInputAmount
@@ -507,11 +588,13 @@ function AmountPicker({
id="customAmount"
onValueChange={handleCustomAmountChanged}
onFocus={handleCustomAmountFocus}
onBlur={handleCustomAmountBlur}
placeholder={i18n(
'icu:DonateFlow__amount-picker-custom-amount-placeholder'
)}
value={customAmount}
/>
{customInputError}
</div>
<div className="DonationAmountPicker__PrimaryButtonContainer">
{continueButtonWithTooltip ?? continueButton}
@@ -5,7 +5,10 @@ import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import type { Formatter, FormatterToken } from '@signalapp/minimask';
import { useInputMask } from '../../../hooks/useInputMask';
import type { CurrencyFormatResult } from '../../../util/currency';
import { getCurrencyFormat } from '../../../util/currency';
import {
getCurrencyFormat,
ZERO_DECIMAL_CURRENCIES,
} from '../../../util/currency';
export type DonateInputAmountProps = Readonly<{
className: string;
@@ -18,14 +21,21 @@ export type DonateInputAmountProps = Readonly<{
onFocus?: () => void;
}>;
const AMOUNT_MAX_DIGITS_STRIPE = 8;
const getAmountFormatter = (
currencyFormat: CurrencyFormatResult | undefined
): Formatter => {
return (input: string) => {
const { symbolPrefix, symbolSuffix, decimal, group } = currencyFormat ?? {};
const { currency, decimal, group, symbolPrefix, symbolSuffix } =
currencyFormat ?? {};
const isZeroDecimal = Boolean(
currency && ZERO_DECIMAL_CURRENCIES.has(currency)
);
const tokens: Array<FormatterToken> = [];
let isDecimalPresent = false;
let isDigitPresent = false;
let firstDigitWasZero = false;
let digitCount = 0;
let decimalLength = 0;
if (symbolPrefix) {
@@ -35,22 +45,34 @@ const getAmountFormatter = (
}
for (const [index, char] of input.split('').entries()) {
if (/[\d., ']/.test(char) || (group && char === group)) {
if (decimal && char === decimal) {
// Prevent multiple decimal separators
if (isDecimalPresent) {
const isCharDigit = /\d/.test(char);
const isCharGroup = group && char === group;
const isCharDecimal = decimal && char === decimal;
if (isCharDigit || isCharGroup || isCharDecimal) {
if (isCharDecimal) {
// Prevent multiple decimal separators and decimals for zero decimal currencies
if (isDecimalPresent || isZeroDecimal) {
continue;
} else {
isDecimalPresent = true;
// Force leading 0 for decimal-only values (for parseCurrencyString)
if (!isDigitPresent) {
if (digitCount === 0) {
tokens.push({ char: '0', index, mask: false });
}
}
}
if (!isDigitPresent && /\d/.test(char)) {
isDigitPresent = true;
if (/\d/.test(char)) {
// Prevent starting a number with multiple 0's
if (char === '0') {
if (digitCount === 0) {
firstDigitWasZero = true;
} else if (firstDigitWasZero) {
continue;
}
}
digitCount += 1;
}
// Prevent over 2 decimal digits due to issues with parsing
@@ -95,14 +117,31 @@ export const DonateInputAmount = memo(function DonateInputAmount(
);
useInputMask(inputRef, amountFormatter);
const handleInput = useCallback(
(event: FormEvent<HTMLInputElement>) => {
onValueChange(event.currentTarget.value);
},
[onValueChange]
);
const inputMaxLength = useMemo<number | undefined>(() => {
if (!currencyFormat) {
return;
}
useEffect(() => {
const {
currency: normalizedCurrency,
symbolPrefix,
symbolSuffix,
} = currencyFormat;
const isZeroDecimal = ZERO_DECIMAL_CURRENCIES.has(normalizedCurrency);
const maxNonDecimalDigits = isZeroDecimal
? AMOUNT_MAX_DIGITS_STRIPE
: AMOUNT_MAX_DIGITS_STRIPE - 2;
const lengthForDecimal = isZeroDecimal ? 0 : 3;
return (
symbolPrefix.length +
maxNonDecimalDigits +
lengthForDecimal +
symbolSuffix.length
);
}, [currencyFormat]);
const ensureInputCaretPosition = useCallback(() => {
const input = inputRef.current;
if (!input) {
return;
@@ -110,12 +149,13 @@ export const DonateInputAmount = memo(function DonateInputAmount(
// If the only value is the prefilled currency symbol, then set the input caret
// position to the correct position it should be in based on locale-currency config.
const inputValue = input.value;
const lastIndex = inputValue.length;
const { symbolPrefix, symbolSuffix } = currencyFormat ?? {};
const lastIndex = value.length;
if (symbolPrefix && value === symbolPrefix) {
if (symbolPrefix && inputValue === symbolPrefix) {
// Prefix, set selection to the end
input.setSelectionRange(lastIndex, lastIndex);
} else if (symbolSuffix && value.includes(symbolSuffix)) {
} else if (symbolSuffix && inputValue.includes(symbolSuffix)) {
// Suffix, set selection to before symbol
if (
input.selectionStart === input.selectionEnd &&
@@ -125,11 +165,27 @@ export const DonateInputAmount = memo(function DonateInputAmount(
input.setSelectionRange(indexBeforeSymbol, indexBeforeSymbol);
}
}
}, [currencyFormat]);
const handleInput = useCallback(
(event: FormEvent<HTMLInputElement>) => {
onValueChange(event.currentTarget.value);
ensureInputCaretPosition();
},
[ensureInputCaretPosition, onValueChange]
);
useEffect(() => {
const input = inputRef.current;
if (!input) {
return;
}
// If we're missing the currency symbol then add it. This can happen if the user
// tries to delete it, or goes forward to the payment card form then goes back,
// prefilling the last custom amount
if (value || document.activeElement === input) {
const { symbolPrefix, symbolSuffix } = currencyFormat ?? {};
if (symbolPrefix && !value.includes(symbolPrefix)) {
onValueChange(`${symbolPrefix}${value}`);
}
@@ -137,7 +193,9 @@ export const DonateInputAmount = memo(function DonateInputAmount(
onValueChange(`${value}${symbolSuffix}`);
}
}
}, [currencyFormat, onValueChange, value]);
ensureInputCaretPosition();
}, [currencyFormat, ensureInputCaretPosition, onValueChange, value]);
useEffect(() => {
const input = inputRef.current;
@@ -198,6 +256,8 @@ export const DonateInputAmount = memo(function DonateInputAmount(
type="text"
inputMode="decimal"
autoComplete="transaction-amount"
spellCheck={false}
maxLength={inputMaxLength}
value={value}
onInput={handleInput}
onFocus={onFocusWithCurrencyHandler}