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",
|
"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"
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue