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", "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." "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": { "icu:DonateFlow__custom-amount-below-minimum-tooltip": {
"messageformat": "The minimum amount you can donate is {formattedCurrencyAmount}", "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" "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": { "icu:DonateFlow__discard-dialog-body": {
"messageformat": "Leaving this page will remove your credit card information. Do you want to proceed?", "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." "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", "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." "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": { "icu:DonationReceipt__title": {
"messageformat": "Donation receipt", "messageformat": "Donation receipt",
"description": "Title shown at the top of donation receipt documents" "description": "Title shown at the top of donation receipt documents"

View file

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

View file

@ -118,6 +118,13 @@ const exportLocalBackupResult = {
}; };
const donationAmountsConfig = { const donationAmountsConfig = {
cad: {
minimum: 4,
oneTime: {
1: [7, 15, 30, 40, 70, 140],
100: [7],
},
},
jpy: { jpy: {
minimum: 400, minimum: 400,
oneTime: { oneTime: {
@ -132,6 +139,13 @@ const donationAmountsConfig = {
100: [5], 100: [5],
}, },
}, },
ugx: {
minimum: 8000,
oneTime: {
1: [15000, 35000, 70000, 100000, 150000, 300000],
100: [15000],
},
},
} as unknown as OneTimeDonationHumanAmounts; } as unknown as OneTimeDonationHumanAmounts;
function renderUpdateDialog( function renderUpdateDialog(

View file

@ -42,6 +42,9 @@ import {
} from '../types/DonationsCardForm'; } from '../types/DonationsCardForm';
import { import {
brandHumanDonationAmount, brandHumanDonationAmount,
type CurrencyFormatResult,
getCurrencyFormat,
getMaximumStripeAmount,
parseCurrencyString, parseCurrencyString,
toHumanCurrencyString, toHumanCurrencyString,
toStripeDonationAmount, toStripeDonationAmount,
@ -315,10 +318,20 @@ function AmountPicker({
const [presetAmount, setPresetAmount] = useState< const [presetAmount, setPresetAmount] = useState<
HumanDonationAmount | undefined HumanDonationAmount | undefined
>(); >();
// Use localized group and decimal separators, but no symbol
// Symbol will be added by DonateInputAmount
const [customAmount, setCustomAmount] = useState<string>( 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 // Reset amount selections when API donation config or selected currency changes
// Memo here so preset options instantly load when component mounts. // Memo here so preset options instantly load when component mounts.
const presetAmountOptions = useMemo(() => { const presetAmountOptions = useMemo(() => {
@ -356,17 +369,38 @@ function AmountPicker({
return toHumanCurrencyString({ amount: minimumAmount, currency }); return toHumanCurrencyString({ amount: minimumAmount, currency });
}, [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(() => { const currencyOptionsForSelect = useMemo(() => {
return validCurrencies.toSorted().map((currencyString: string) => { return validCurrencies.toSorted().map((currencyString: string) => {
return { text: currencyString.toUpperCase(), value: currencyString }; return { text: currencyString.toUpperCase(), value: currencyString };
}); });
}, [validCurrencies]); }, [validCurrencies]);
const currencyFormat = useMemo<CurrencyFormatResult | undefined>(
() => getCurrencyFormat(currency),
[currency]
);
const { error, parsedCustomAmount } = useMemo<{ const { error, parsedCustomAmount } = useMemo<{
error: 'invalid' | 'amount-below-minimum' | undefined; error:
| 'invalid'
| 'amount-below-minimum'
| 'amount-above-maximum'
| undefined;
parsedCustomAmount: HumanDonationAmount | undefined; parsedCustomAmount: HumanDonationAmount | undefined;
}>(() => { }>(() => {
if (customAmount === '' || customAmount == null) { if (
customAmount === '' ||
customAmount == null ||
(currencyFormat?.symbol && customAmount === currencyFormat?.symbol)
) {
return { return {
error: undefined, error: undefined,
parsedCustomAmount: undefined, parsedCustomAmount: undefined,
@ -379,6 +413,13 @@ function AmountPicker({
}); });
if (parseResult != null) { if (parseResult != null) {
if (parseResult > maximumAmount) {
return {
error: 'amount-above-maximum',
parsedCustomAmount: undefined,
};
}
if (parseResult >= minimumAmount) { if (parseResult >= minimumAmount) {
// Valid input // Valid input
return { return {
@ -397,7 +438,7 @@ function AmountPicker({
error: 'invalid', error: 'invalid',
parsedCustomAmount: undefined, parsedCustomAmount: undefined,
}; };
}, [currency, customAmount, minimumAmount]); }, [currency, currencyFormat, customAmount, minimumAmount, maximumAmount]);
const handleCurrencyChanged = useCallback( const handleCurrencyChanged = useCallback(
(value: string) => { (value: string) => {
@ -412,6 +453,14 @@ function AmountPicker({
setPresetAmount(undefined); 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) => { const handleCustomAmountChanged = useCallback((value: string) => {
// Custom amount overrides any selected preset amount // Custom amount overrides any selected preset amount
setPresetAmount(undefined); setPresetAmount(undefined);
@ -429,8 +478,15 @@ function AmountPicker({
onSubmit({ amount, currency }); onSubmit({ amount, currency });
}, [amount, currency, isContinueEnabled, onSubmit]); }, [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; let customInputClassName;
if (error) { if (error && isCustomAmountErrorVisible) {
customInputClassName = 'DonationAmountPicker__CustomInput--with-error'; customInputClassName = 'DonationAmountPicker__CustomInput--with-error';
} else if (parsedCustomAmount) { } else if (parsedCustomAmount) {
customInputClassName = 'DonationAmountPicker__CustomInput--selected'; customInputClassName = 'DonationAmountPicker__CustomInput--selected';
@ -438,6 +494,27 @@ function AmountPicker({
customInputClassName = 'DonationAmountPicker__CustomInput'; 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 = ( const continueButton = (
<Button <Button
className="PreferencesDonations__PrimaryButton" className="PreferencesDonations__PrimaryButton"
@ -445,7 +522,7 @@ function AmountPicker({
onClick={handleContinueClicked} onClick={handleContinueClicked}
variant={isOnline ? ButtonVariant.Primary : ButtonVariant.Secondary} variant={isOnline ? ButtonVariant.Primary : ButtonVariant.Secondary}
> >
Continue {i18n('icu:DonateFlow__continue')}
</Button> </Button>
); );
@ -498,7 +575,11 @@ function AmountPicker({
}} }}
type="button" type="button"
> >
{toHumanCurrencyString({ amount: value, currency })} {toHumanCurrencyString({
amount: value,
currency,
symbol: 'narrowSymbol',
})}
</button> </button>
))} ))}
<DonateInputAmount <DonateInputAmount
@ -507,11 +588,13 @@ function AmountPicker({
id="customAmount" id="customAmount"
onValueChange={handleCustomAmountChanged} onValueChange={handleCustomAmountChanged}
onFocus={handleCustomAmountFocus} onFocus={handleCustomAmountFocus}
onBlur={handleCustomAmountBlur}
placeholder={i18n( placeholder={i18n(
'icu:DonateFlow__amount-picker-custom-amount-placeholder' 'icu:DonateFlow__amount-picker-custom-amount-placeholder'
)} )}
value={customAmount} value={customAmount}
/> />
{customInputError}
</div> </div>
<div className="DonationAmountPicker__PrimaryButtonContainer"> <div className="DonationAmountPicker__PrimaryButtonContainer">
{continueButtonWithTooltip ?? continueButton} {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 type { Formatter, FormatterToken } from '@signalapp/minimask';
import { useInputMask } from '../../../hooks/useInputMask'; import { useInputMask } from '../../../hooks/useInputMask';
import type { CurrencyFormatResult } from '../../../util/currency'; import type { CurrencyFormatResult } from '../../../util/currency';
import { getCurrencyFormat } from '../../../util/currency'; import {
getCurrencyFormat,
ZERO_DECIMAL_CURRENCIES,
} from '../../../util/currency';
export type DonateInputAmountProps = Readonly<{ export type DonateInputAmountProps = Readonly<{
className: string; className: string;
@ -18,14 +21,21 @@ export type DonateInputAmountProps = Readonly<{
onFocus?: () => void; onFocus?: () => void;
}>; }>;
const AMOUNT_MAX_DIGITS_STRIPE = 8;
const getAmountFormatter = ( const getAmountFormatter = (
currencyFormat: CurrencyFormatResult | undefined currencyFormat: CurrencyFormatResult | undefined
): Formatter => { ): Formatter => {
return (input: string) => { 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> = []; const tokens: Array<FormatterToken> = [];
let isDecimalPresent = false; let isDecimalPresent = false;
let isDigitPresent = false; let firstDigitWasZero = false;
let digitCount = 0;
let decimalLength = 0; let decimalLength = 0;
if (symbolPrefix) { if (symbolPrefix) {
@ -35,22 +45,34 @@ const getAmountFormatter = (
} }
for (const [index, char] of input.split('').entries()) { for (const [index, char] of input.split('').entries()) {
if (/[\d., ']/.test(char) || (group && char === group)) { const isCharDigit = /\d/.test(char);
if (decimal && char === decimal) { const isCharGroup = group && char === group;
// Prevent multiple decimal separators const isCharDecimal = decimal && char === decimal;
if (isDecimalPresent) { if (isCharDigit || isCharGroup || isCharDecimal) {
if (isCharDecimal) {
// Prevent multiple decimal separators and decimals for zero decimal currencies
if (isDecimalPresent || isZeroDecimal) {
continue; continue;
} else { } else {
isDecimalPresent = true; isDecimalPresent = true;
// Force leading 0 for decimal-only values (for parseCurrencyString) // Force leading 0 for decimal-only values (for parseCurrencyString)
if (!isDigitPresent) { if (digitCount === 0) {
tokens.push({ char: '0', index, mask: false }); tokens.push({ char: '0', index, mask: false });
} }
} }
} }
if (!isDigitPresent && /\d/.test(char)) { if (/\d/.test(char)) {
isDigitPresent = true; // 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 // Prevent over 2 decimal digits due to issues with parsing
@ -95,14 +117,31 @@ export const DonateInputAmount = memo(function DonateInputAmount(
); );
useInputMask(inputRef, amountFormatter); useInputMask(inputRef, amountFormatter);
const handleInput = useCallback( const inputMaxLength = useMemo<number | undefined>(() => {
(event: FormEvent<HTMLInputElement>) => { if (!currencyFormat) {
onValueChange(event.currentTarget.value); return;
}, }
[onValueChange]
);
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; const input = inputRef.current;
if (!input) { if (!input) {
return; 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 // 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. // 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 { symbolPrefix, symbolSuffix } = currencyFormat ?? {};
const lastIndex = value.length; if (symbolPrefix && inputValue === symbolPrefix) {
if (symbolPrefix && value === symbolPrefix) {
// Prefix, set selection to the end // Prefix, set selection to the end
input.setSelectionRange(lastIndex, lastIndex); input.setSelectionRange(lastIndex, lastIndex);
} else if (symbolSuffix && value.includes(symbolSuffix)) { } else if (symbolSuffix && inputValue.includes(symbolSuffix)) {
// Suffix, set selection to before symbol // Suffix, set selection to before symbol
if ( if (
input.selectionStart === input.selectionEnd && input.selectionStart === input.selectionEnd &&
@ -125,11 +165,27 @@ export const DonateInputAmount = memo(function DonateInputAmount(
input.setSelectionRange(indexBeforeSymbol, indexBeforeSymbol); 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 // 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, // tries to delete it, or goes forward to the payment card form then goes back,
// prefilling the last custom amount // prefilling the last custom amount
if (value || document.activeElement === input) { if (value || document.activeElement === input) {
const { symbolPrefix, symbolSuffix } = currencyFormat ?? {};
if (symbolPrefix && !value.includes(symbolPrefix)) { if (symbolPrefix && !value.includes(symbolPrefix)) {
onValueChange(`${symbolPrefix}${value}`); onValueChange(`${symbolPrefix}${value}`);
} }
@ -137,7 +193,9 @@ export const DonateInputAmount = memo(function DonateInputAmount(
onValueChange(`${value}${symbolSuffix}`); onValueChange(`${value}${symbolSuffix}`);
} }
} }
}, [currencyFormat, onValueChange, value]);
ensureInputCaretPosition();
}, [currencyFormat, ensureInputCaretPosition, onValueChange, value]);
useEffect(() => { useEffect(() => {
const input = inputRef.current; const input = inputRef.current;
@ -198,6 +256,8 @@ export const DonateInputAmount = memo(function DonateInputAmount(
type="text" type="text"
inputMode="decimal" inputMode="decimal"
autoComplete="transaction-amount" autoComplete="transaction-amount"
spellCheck={false}
maxLength={inputMaxLength}
value={value} value={value}
onInput={handleInput} onInput={handleInput}
onFocus={onFocusWithCurrencyHandler} onFocus={onFocusWithCurrencyHandler}

View file

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

View file

@ -157,9 +157,9 @@ function submitDonation({
void, void,
RootStateType, RootStateType,
unknown, unknown,
UpdateWorkflowAction UpdateWorkflowAction | UpdateLastErrorAction
> { > {
return async (_dispatch, getState) => { return async (dispatch, getState) => {
try { try {
const { currentWorkflow } = getState().donations; const { currentWorkflow } = getState().donations;
if ( if (
@ -178,7 +178,11 @@ function submitDonation({
await donations.finishDonationWithCard(paymentDetail); await donations.finishDonationWithCard(paymentDetail);
} catch (error) { } 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, amount,
currency, currency,
symbol = 'symbol',
showInsignificantFractionDigits = false, showInsignificantFractionDigits = false,
}: { }: {
amount: number; amount: number;
currency: string; currency: string;
symbol?: 'symbol' | 'narrowSymbol' | 'none';
showInsignificantFractionDigits?: boolean; showInsignificantFractionDigits?: boolean;
}, },
expectedOutput: string | undefined expectedOutput: string | undefined
@ -142,6 +144,7 @@ describe('toHumanCurrencyString', () => {
toHumanCurrencyString({ toHumanCurrencyString({
amount: humanAmount, amount: humanAmount,
currency, currency,
symbol,
showInsignificantFractionDigits, showInsignificantFractionDigits,
}), }),
expectedOutput 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.5, currency: 'USD' }, '$10.50'); testFn({ amount: 10.5, currency: 'USD' }, '$10.50');
testFn({ amount: 10.69, currency: 'USD' }, '$10.69'); 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', () => { it('handles EUR', () => {

View file

@ -12,6 +12,7 @@ import {
stripeDonationAmountSchema, stripeDonationAmountSchema,
} from '../types/Donations'; } from '../types/Donations';
import { parseStrict, safeParseStrict } from './schemas'; import { parseStrict, safeParseStrict } from './schemas';
import { missingCaseError } from './missingCaseError';
// See: https://docs.stripe.com/currencies?presentment-currency=US // See: https://docs.stripe.com/currencies?presentment-currency=US
export const ZERO_DECIMAL_CURRENCIES = new Set([ export const ZERO_DECIMAL_CURRENCIES = new Set([
@ -72,10 +73,12 @@ function getLocales(): Intl.LocalesArgument {
export function toHumanCurrencyString({ export function toHumanCurrencyString({
amount, amount,
currency, currency,
symbol = 'symbol',
showInsignificantFractionDigits = false, showInsignificantFractionDigits = false,
}: { }: {
amount: HumanDonationAmount | undefined; amount: HumanDonationAmount | undefined;
currency: string | undefined; currency: string | undefined;
symbol?: 'symbol' | 'narrowSymbol' | 'none';
showInsignificantFractionDigits?: boolean; showInsignificantFractionDigits?: boolean;
}): string { }): string {
if (amount == null || currency == null) { if (amount == null || currency == null) {
@ -87,18 +90,38 @@ export function toHumanCurrencyString({
showInsignificantFractionDigits || amount % 1 !== 0 showInsignificantFractionDigits || amount % 1 !== 0
? {} ? {}
: { minimumFractionDigits: 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(), { const formatter = new Intl.NumberFormat(getLocales(), {
style: 'currency', style: 'currency',
currency, currency,
currencyDisplay,
...fractionOptions, ...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 { } catch {
return ''; return '';
} }
} }
export type CurrencyFormatResult = { export type CurrencyFormatResult = {
currency: string;
decimal: string | undefined; decimal: string | undefined;
group: string | undefined; group: string | undefined;
symbol: string; symbol: string;
@ -114,12 +137,14 @@ export function getCurrencyFormat(
} }
try { try {
const currencyLowercase = currency.toLowerCase();
const formatter = new Intl.NumberFormat(getLocales(), { const formatter = new Intl.NumberFormat(getLocales(), {
style: 'currency', style: 'currency',
currency, currency,
currencyDisplay: 'narrowSymbol', currencyDisplay: 'narrowSymbol',
}); });
let isDigitPresent = false;
let symbol = ''; let symbol = '';
let symbolPrefix = ''; let symbolPrefix = '';
let symbolSuffix = ''; let symbolSuffix = '';
@ -132,18 +157,36 @@ export function getCurrencyFormat(
if (type === 'currency') { if (type === 'currency') {
symbol += value; symbol += value;
if (index === 0) { if (index === 0) {
symbolPrefix = part.value; symbolPrefix += part.value;
} else { } 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') { } else if (type === 'group') {
group = value; group = value;
} else if (type === 'decimal') { } else if (type === 'decimal') {
decimal = value; 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 { } catch {
return undefined; return undefined;
} }
@ -209,3 +252,12 @@ export function getHumanDonationAmount(
const amount = brandStripeDonationAmount(paymentAmount); const amount = brandStripeDonationAmount(paymentAmount);
return toHumanDonationAmount({ amount, currency }); 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,
});
}