Fixes for donation amount picker
This commit is contained in:
parent
c09dc17867
commit
14e0086943
9 changed files with 286 additions and 39 deletions
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 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,
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue