Fixes for donation amount picker

This commit is contained in:
ayumi-signal 2025-09-03 10:47:19 -07:00 committed by GitHub
commit 14e0086943
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 286 additions and 39 deletions

View file

@ -8982,10 +8982,18 @@
"messageformat": "Get the Signal Boost badge for 30 days",
"description": "Info text above the payment card entry form when making a one time donation. Explains the in-app badge which you can receive after donating."
},
"icu:DonateFlow__custom-amount-below-minimum-error": {
"messageformat": "The minimum donation amount is {formattedCurrencyAmount}",
"description": "Error text next to input box when selecting a donation amount and the user has entered a custom amount below the minimum allowed amount. Minimum amount text includes the currency symbol and is formatted in the locale's standard format. Examples: $3; ¥300; €3"
},
"icu:DonateFlow__custom-amount-below-minimum-tooltip": {
"messageformat": "The minimum amount you can donate is {formattedCurrencyAmount}",
"description": "Tooltip for a disabled continue button when selecting a donation amount, when the user has entered a custom amount below the minimum allowed donation amount. Minimum amount text includes the currency symbol and is formatted in the locale's standard format. Examples: $3; ¥300; €3"
},
"icu:DonateFlow__custom-amount-above-maximum-error": {
"messageformat": "The maximum donation amount is {formattedCurrencyAmount}",
"description": "Error text next to input box when selecting a donation amount and the user has entered a custom amount greater than the maximum allowed amount. Maximum amount text includes the currency symbol and is formatted in the locale's standard format. Examples: $999999.99; ¥99999999; €999999.99"
},
"icu:DonateFlow__discard-dialog-body": {
"messageformat": "Leaving this page will remove your credit card information. Do you want to proceed?",
"description": "While making a donation and after entering payment details, if you try to navigate away then a dialog shows with this body text."
@ -8994,6 +9002,10 @@
"messageformat": "Remove info",
"description": "While making a donation and after entering payment details, if you try to navigate away then a dialog shows and its primary button has this text to confirm cancellation of the donation."
},
"icu:DonateFlow__continue": {
"messageformat": "Continue",
"description": "While making a donation and selecting the donation amount, this is the primary button to move to the next step."
},
"icu:DonationReceipt__title": {
"messageformat": "Donation receipt",
"description": "Title shown at the top of donation receipt documents"

View file

@ -4,6 +4,8 @@
@use '../mixins';
@use '../variables';
$custom-amount-width: 320px;
.DonationForm {
max-width: 439px;
align-self: center;
@ -51,7 +53,7 @@ a.DonationFormHelpFooter__ContactSupportLink {
.DonationAmountPicker__AmountOptions {
display: flex;
flex-wrap: wrap;
max-width: 340px;
max-width: $custom-amount-width + 20px;
justify-content: center;
}
@ -95,7 +97,6 @@ a.DonationFormHelpFooter__ContactSupportLink {
.DonationAmountPicker__PresetButton--selected,
.DonationForm .DonationAmountPicker__CustomInput--selected,
.DonationForm .DonationAmountPicker__CustomInput--with-error:focus,
.DonationForm .DonationAmountPicker__CustomInput:focus {
border-color: variables.$color-ultramarine;
outline: 2.5px solid variables.$color-ultramarine;
@ -105,12 +106,12 @@ a.DonationFormHelpFooter__ContactSupportLink {
.DonationForm .DonationAmountPicker__CustomInput,
.DonationForm .DonationAmountPicker__CustomInput--selected,
.DonationForm .DonationAmountPicker__CustomInput--with-error {
width: 320px;
width: $custom-amount-width;
padding-block: 0;
border-width: 0.5px;
}
.DonationForm .DonationAmountPicker__CustomInput--with-error:not(:focus) {
.DonationForm .DonationAmountPicker__CustomInput--with-error {
border-color: variables.$color-deep-red;
outline: 2.5px solid variables.$color-deep-red;
outline-offset: -2.5px;
@ -139,6 +140,15 @@ a.DonationFormHelpFooter__ContactSupportLink {
color: transparent;
}
.DonationAmountPicker__CustomAmountError {
@include mixins.font-caption;
width: $custom-amount-width;
margin-block: 0 -7px;
margin-inline-start: 12px;
text-align: start;
color: variables.$color-deep-red;
}
.DonationAmountPicker__PrimaryButtonContainer {
margin-block-start: 11px;
margin-inline-end: 10px;

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(

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}

View file

@ -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}

View file

@ -244,6 +244,9 @@ export async function _internalDoDonation({
workflow = await _createPaymentMethodForIntent(workflow, paymentDetail);
await _saveAndRunWorkflow(workflow);
} catch (error) {
const errorType: string | undefined = error.response?.error?.type;
await failDonation(donationErrorTypeSchema.Enum.GeneralError, errorType);
} finally {
isInternalDonationInProgress = false;
}

View file

@ -157,9 +157,9 @@ function submitDonation({
void,
RootStateType,
unknown,
UpdateWorkflowAction
UpdateWorkflowAction | UpdateLastErrorAction
> {
return async (_dispatch, getState) => {
return async (dispatch, getState) => {
try {
const { currentWorkflow } = getState().donations;
if (
@ -178,7 +178,11 @@ function submitDonation({
await donations.finishDonationWithCard(paymentDetail);
} catch (error) {
log.warn('submitDonation failed', Errors.toLogFormat(error));
log.error('submitDonation failed', Errors.toLogFormat(error));
dispatch({
type: UPDATE_LAST_ERROR,
payload: { lastError: 'GeneralError' },
});
}
};
}

View file

@ -129,10 +129,12 @@ describe('toHumanCurrencyString', () => {
{
amount,
currency,
symbol = 'symbol',
showInsignificantFractionDigits = false,
}: {
amount: number;
currency: string;
symbol?: 'symbol' | 'narrowSymbol' | 'none';
showInsignificantFractionDigits?: boolean;
},
expectedOutput: string | undefined
@ -142,6 +144,7 @@ describe('toHumanCurrencyString', () => {
toHumanCurrencyString({
amount: humanAmount,
currency,
symbol,
showInsignificantFractionDigits,
}),
expectedOutput
@ -157,6 +160,12 @@ describe('toHumanCurrencyString', () => {
testFn({ amount: 10.5, currency: 'USD' }, '$10.50');
testFn({ amount: 10.5, currency: 'USD' }, '$10.50');
testFn({ amount: 10.69, currency: 'USD' }, '$10.69');
testFn({ amount: 10.5, currency: 'USD', symbol: 'none' }, '10.50');
});
it('handles CAD', () => {
testFn({ amount: 10, currency: 'CAD' }, 'CA$10');
testFn({ amount: 10, currency: 'CAD', symbol: 'narrowSymbol' }, '$10');
});
it('handles EUR', () => {

View file

@ -12,6 +12,7 @@ import {
stripeDonationAmountSchema,
} from '../types/Donations';
import { parseStrict, safeParseStrict } from './schemas';
import { missingCaseError } from './missingCaseError';
// See: https://docs.stripe.com/currencies?presentment-currency=US
export const ZERO_DECIMAL_CURRENCIES = new Set([
@ -72,10 +73,12 @@ function getLocales(): Intl.LocalesArgument {
export function toHumanCurrencyString({
amount,
currency,
symbol = 'symbol',
showInsignificantFractionDigits = false,
}: {
amount: HumanDonationAmount | undefined;
currency: string | undefined;
symbol?: 'symbol' | 'narrowSymbol' | 'none';
showInsignificantFractionDigits?: boolean;
}): string {
if (amount == null || currency == null) {
@ -87,18 +90,38 @@ export function toHumanCurrencyString({
showInsignificantFractionDigits || amount % 1 !== 0
? {}
: { minimumFractionDigits: 0 };
let currencyDisplay: 'code' | 'symbol' | 'narrowSymbol';
if (symbol === 'symbol' || symbol === 'narrowSymbol') {
currencyDisplay = symbol;
} else if (symbol === 'none') {
// we will filter it out later
currencyDisplay = 'code';
} else {
throw missingCaseError(symbol);
}
const formatter = new Intl.NumberFormat(getLocales(), {
style: 'currency',
currency,
currencyDisplay,
...fractionOptions,
});
return formatter.format(amount);
// replace &nbsp; with space
const rawResult = formatter.format(amount).replace(/\u00a0/g, ' ');
if (symbol === 'none') {
return rawResult.replace(currency.toUpperCase(), '').trim();
}
return rawResult;
} catch {
return '';
}
}
export type CurrencyFormatResult = {
currency: string;
decimal: string | undefined;
group: string | undefined;
symbol: string;
@ -114,12 +137,14 @@ export function getCurrencyFormat(
}
try {
const currencyLowercase = currency.toLowerCase();
const formatter = new Intl.NumberFormat(getLocales(), {
style: 'currency',
currency,
currencyDisplay: 'narrowSymbol',
});
let isDigitPresent = false;
let symbol = '';
let symbolPrefix = '';
let symbolSuffix = '';
@ -132,18 +157,36 @@ export function getCurrencyFormat(
if (type === 'currency') {
symbol += value;
if (index === 0) {
symbolPrefix = part.value;
symbolPrefix += part.value;
} else {
symbolSuffix = part.value;
symbolSuffix += part.value;
}
} else if (type === 'literal') {
symbol += value;
if (!isDigitPresent) {
symbolPrefix += part.value;
} else {
symbolSuffix += part.value;
}
} else if (type === 'group') {
group = value;
} else if (type === 'decimal') {
decimal = value;
} else if (type === 'integer' || type === 'fraction') {
if (!isDigitPresent) {
isDigitPresent = true;
}
}
}
return { decimal, group, symbol, symbolPrefix, symbolSuffix };
return {
currency: currencyLowercase,
decimal,
group,
symbol,
symbolPrefix,
symbolSuffix,
};
} catch {
return undefined;
}
@ -209,3 +252,12 @@ export function getHumanDonationAmount(
const amount = brandStripeDonationAmount(paymentAmount);
return toHumanDonationAmount({ amount, currency });
}
export function getMaximumStripeAmount(currency: string): HumanDonationAmount {
// 8 digits in the minimum currency unit
const amount = brandStripeDonationAmount(99999999);
return toHumanDonationAmount({
amount,
currency,
});
}