diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 3fe62f2e756..ae02eee8e7d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/stylesheets/components/DonationForm.scss b/stylesheets/components/DonationForm.scss index 2db60566e47..f3ab55f1246 100644 --- a/stylesheets/components/DonationForm.scss +++ b/stylesheets/components/DonationForm.scss @@ -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; diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index b486c7d9c35..8fea92242c6 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -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( diff --git a/ts/components/PreferencesDonateFlow.tsx b/ts/components/PreferencesDonateFlow.tsx index 9e6b5938d2b..21c5b3c1329 100644 --- a/ts/components/PreferencesDonateFlow.tsx +++ b/ts/components/PreferencesDonateFlow.tsx @@ -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( - initialAmount?.toString() ?? '' + toHumanCurrencyString({ + amount: initialAmount, + currency, + symbol: 'none', + }) ); + const [isCustomAmountErrorVisible, setIsCustomAmountErrorVisible] = + useState(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(() => { + return getMaximumStripeAmount(currency); + }, [currency]); + + const formattedMaximumAmount = useMemo(() => { + 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( + () => 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 = ( +
+ {i18n('icu:DonateFlow__custom-amount-below-minimum-error', { + formattedCurrencyAmount: formattedMinimumAmount, + })} +
+ ); + } else if (error === 'amount-above-maximum') { + customInputError = ( +
+ {i18n('icu:DonateFlow__custom-amount-above-maximum-error', { + formattedCurrencyAmount: formattedMaximumAmount, + })} +
+ ); + } + } + const continueButton = ( ); @@ -498,7 +575,11 @@ function AmountPicker({ }} type="button" > - {toHumanCurrencyString({ amount: value, currency })} + {toHumanCurrencyString({ + amount: value, + currency, + symbol: 'narrowSymbol', + })} ))} + {customInputError}
{continueButtonWithTooltip ?? continueButton} diff --git a/ts/components/preferences/donations/DonateInputAmount.tsx b/ts/components/preferences/donations/DonateInputAmount.tsx index 44905337717..fbf61b43fa8 100644 --- a/ts/components/preferences/donations/DonateInputAmount.tsx +++ b/ts/components/preferences/donations/DonateInputAmount.tsx @@ -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 = []; 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) => { - onValueChange(event.currentTarget.value); - }, - [onValueChange] - ); + const inputMaxLength = useMemo(() => { + 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) => { + 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} diff --git a/ts/services/donations.ts b/ts/services/donations.ts index d587d4bf63d..ebeb11e3e62 100644 --- a/ts/services/donations.ts +++ b/ts/services/donations.ts @@ -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; } diff --git a/ts/state/ducks/donations.ts b/ts/state/ducks/donations.ts index 237b549e968..d7dcc104ddb 100644 --- a/ts/state/ducks/donations.ts +++ b/ts/state/ducks/donations.ts @@ -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' }, + }); } }; } diff --git a/ts/test-node/util/currency_test.ts b/ts/test-node/util/currency_test.ts index 217f4d37bcb..729a435bac1 100644 --- a/ts/test-node/util/currency_test.ts +++ b/ts/test-node/util/currency_test.ts @@ -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', () => { diff --git a/ts/util/currency.ts b/ts/util/currency.ts index 97b714212d0..1b5383a4a0e 100644 --- a/ts/util/currency.ts +++ b/ts/util/currency.ts @@ -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, + }); +}