From 42bcec03cc31d052f2f3c644ac8b8c56d7685add Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:31:07 -0500 Subject: [PATCH] Initial donation amount picker Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- _locales/en/messages.json | 4 + ts/badges/parseBadgesFromServer.ts | 4 +- ts/components/Preferences.stories.tsx | 24 +- ts/components/PreferencesDonateFlow.tsx | 400 +++++++++++++++++++----- ts/components/PreferencesDonations.tsx | 87 +++--- ts/components/PreferencesInternal.tsx | 4 +- ts/messages/handleDataMessage.ts | 7 +- ts/services/donations.ts | 7 +- ts/state/ducks/donations.ts | 24 +- ts/state/smart/PreferencesDonations.tsx | 25 +- ts/test-node/util/currency_test.ts | 166 ++++++++++ ts/textsecure/WebAPI.ts | 49 +-- ts/types/Donations.ts | 32 ++ ts/util/currency.ts | 150 +++++++++ ts/util/generateDonationReceipt.ts | 15 +- ts/util/subscriptionConfiguration.ts | 43 +++ 16 files changed, 855 insertions(+), 186 deletions(-) create mode 100644 ts/test-node/util/currency_test.ts create mode 100644 ts/util/currency.ts create mode 100644 ts/util/subscriptionConfiguration.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d129fe2b08..cbbf130f3d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -8822,6 +8822,10 @@ "messageformat": "Donate", "description": "Button text to make a donation" }, + "icu:PreferencesDonations__donate-button-with-amount": { + "messageformat": "Donate {formattedCurrencyAmount}", + "description": "Button text to make a donation after selecting a currency amount. Amount includes the currency symbol and is formatted in the locale's standard format. Examples: Donate $10; Donate ¥1000; Donate €10" + }, "icu:PreferencesDonations__mobile-info": { "messageformat": "Badges and monthly donations can be managed on your mobile device.", "description": "(Deleted 2025/07/09) Information about donations receipt syncing limitations" diff --git a/ts/badges/parseBadgesFromServer.ts b/ts/badges/parseBadgesFromServer.ts index 274099ac18..b78f2056ea 100644 --- a/ts/badges/parseBadgesFromServer.ts +++ b/ts/badges/parseBadgesFromServer.ts @@ -15,7 +15,7 @@ const log = createLogger('parseBadgesFromServer'); const MAX_BADGES = 1000; -const badgeFromServerSchema = z.object({ +export const badgeFromServerSchema = z.object({ category: z.string(), description: z.string(), id: z.string(), @@ -27,7 +27,7 @@ const badgeFromServerSchema = z.object({ }); // GET /v1/subscription/configuration -const boostBadgesFromServerSchema = z.object({ +export const boostBadgesFromServerSchema = z.object({ levels: z.record( z .object({ diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 9f3e571539..ce4013b7be 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -31,7 +31,10 @@ import type { WidthBreakpoint } from './_util'; import type { MessageAttributesType } from '../model-types'; import { PreferencesDonations } from './PreferencesDonations'; import { strictAssert } from '../util/assert'; -import type { DonationReceipt } from '../types/Donations'; +import type { + DonationReceipt, + OneTimeDonationHumanAmounts, +} from '../types/Donations'; import type { AnyToast } from '../types/Toast'; const { i18n } = window.SignalContext; @@ -106,6 +109,23 @@ const exportLocalBackupResult = { snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169', }; +const donationAmountsConfig = { + jpy: { + minimum: 400, + oneTime: { + '1': [500, 1000, 2000, 3000, 5000, 10000], + '100': [500], + }, + }, + usd: { + minimum: 3, + oneTime: { + 1: [5, 10, 20, 30, 50, 100], + 100: [5], + }, + }, +} as unknown as OneTimeDonationHumanAmounts; + function renderUpdateDialog( props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }> ): JSX.Element { @@ -200,6 +220,8 @@ function RenderDonationsPane(props: { color={props.me.color} firstName={props.me.firstName} profileAvatarUrl={props.me.profileAvatarUrl} + donationAmountsConfig={donationAmountsConfig} + validCurrencies={Object.keys(donationAmountsConfig)} donationReceipts={props.donationReceipts} saveAttachmentToDisk={props.saveAttachmentToDisk} generateDonationReceiptBlob={props.generateDonationReceiptBlob} diff --git a/ts/components/PreferencesDonateFlow.tsx b/ts/components/PreferencesDonateFlow.tsx index 9ed174ee7e..1b0488aac0 100644 --- a/ts/components/PreferencesDonateFlow.tsx +++ b/ts/components/PreferencesDonateFlow.tsx @@ -1,35 +1,63 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useRef, useState } from 'react'; +import type { MutableRefObject } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { LocalizerType } from '../types/Util'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; import { Button, ButtonVariant } from './Button'; -import type { CardDetail, DonationWorkflow } from '../types/Donations'; +import type { HumanDonationAmount } from '../types/Donations'; +import { + ONE_TIME_DONATION_CONFIG_ID, + type DonationWorkflow, + type OneTimeDonationHumanAmounts, +} from '../types/Donations'; +import { + brandHumanDonationAmount, + parseCurrencyString, + toHumanCurrencyString, + toStripeDonationAmount, +} from '../util/currency'; import { Input } from './Input'; +import { PreferencesContent } from './Preferences'; +import type { SubmitDonationType } from '../state/ducks/donations'; +import { Select } from './Select'; export type PropsDataType = { i18n: LocalizerType; + donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; + validCurrencies: ReadonlyArray; workflow: DonationWorkflow | undefined; }; +type PropsHousekeepingType = { + contentsRef: MutableRefObject; +}; + type PropsActionType = { clearWorkflow: () => void; - submitDonation: (options: { - currencyType: string; - paymentAmount: number; - paymentDetail: CardDetail; - }) => void; + submitDonation: (payload: SubmitDonationType) => void; + onBack: () => void; }; -export type PropsType = PropsDataType & PropsActionType; +export type PropsType = PropsDataType & PropsActionType & PropsHousekeepingType; export function PreferencesDonateFlow({ + contentsRef, i18n, + donationAmountsConfig, + validCurrencies, workflow, clearWorkflow, submitDonation, + onBack, }: PropsType): JSX.Element { const tryClose = useRef<() => void | undefined>(); const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ @@ -38,20 +66,35 @@ export function PreferencesDonateFlow({ tryClose, }); - const [amount, setAmount] = useState('10.00'); + const [step, setStep] = useState<'amount' | 'paymentDetails'>('amount'); + + const [amount, setAmount] = useState(); + const [currency, setCurrency] = useState(); const [cardExpirationMonth, setCardExpirationMonth] = useState(''); const [cardExpirationYear, setCardExpirationYear] = useState(''); const [cardNumber, setCardNumber] = useState(''); const [cardCvc, setCardCvc] = useState(''); + const formattedCurrencyAmount = useMemo(() => { + return toHumanCurrencyString({ amount, currency }); + }, [amount, currency]); + + const handleAmountPickerResult = useCallback((result: AmountPickerResult) => { + const { currency: pickedCurrency, amount: pickedAmount } = result; + setAmount(pickedAmount); + setCurrency(pickedCurrency); + setStep('paymentDetails'); + }, []); + const handleDonateClicked = useCallback(() => { - const parsedAmount = parseFloat(amount); - // Note: Whether to multiply by 100 depends on the specific currency - // e.g. JPY is not multipled by 100 - const paymentAmount = parsedAmount * 100; + if (amount == null || currency == null) { + return; + } + + const paymentAmount = toStripeDonationAmount({ amount, currency }); submitDonation({ - currencyType: 'USD', + currencyType: currency, paymentAmount, paymentDetail: { expirationMonth: cardExpirationMonth, @@ -66,6 +109,7 @@ export function PreferencesDonateFlow({ cardExpirationMonth, cardExpirationYear, cardNumber, + currency, submitDonation, ]); @@ -80,73 +124,277 @@ export function PreferencesDonateFlow({ }, [confirmDiscardIf]); tryClose.current = onTryClose; - const content = ( -
- {workflow && ( -
-

Current Workflow

-
{JSON.stringify(workflow)}
- -
- )} + let innerContent: JSX.Element; + let handleBack: () => void; - - setAmount(value)} - placeholder="5" - value={amount} + initialAmount={amount} + initialCurrency={currency} + donationAmountsConfig={donationAmountsConfig} + validCurrencies={validCurrencies} + onSubmit={handleAmountPickerResult} /> - - setCardNumber(value)} - placeholder="0000000000000000" - maxLengthCount={16} - value={cardNumber} - /> - - setCardExpirationMonth(value)} - placeholder="MM" - value={cardExpirationMonth} - /> - - setCardExpirationYear(value)} - placeholder="YY" - value={cardExpirationYear} - /> - - setCardCvc(value)} - placeholder="123" - value={cardCvc} - /> - -
+ ); + // Dismiss DonateFlow and return to Donations home + handleBack = () => onBack(); + } else { + innerContent = ( +
+ {workflow && ( +
+

Current Workflow

+
{JSON.stringify(workflow)}
+ +
+ )} + + +
+          {amount} {currency}
+        
+ + setCardNumber(value)} + placeholder="0000000000000000" + maxLengthCount={16} + value={cardNumber} + /> + + setCardExpirationMonth(value)} + placeholder="MM" + value={cardExpirationMonth} + /> + + setCardExpirationYear(value)} + placeholder="YY" + value={cardExpirationYear} + /> + + setCardCvc(value)} + placeholder="123" + value={cardCvc} + /> + +
+ ); + handleBack = () => { + setStep('amount'); + }; + } + + const backButton = ( + + ); } diff --git a/ts/components/PreferencesDonations.tsx b/ts/components/PreferencesDonations.tsx index beabe1ad33..ce7531d139 100644 --- a/ts/components/PreferencesDonations.tsx +++ b/ts/components/PreferencesDonations.tsx @@ -1,7 +1,7 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { groupBy, sortBy } from 'lodash'; import type { MutableRefObject, ReactNode } from 'react'; @@ -12,9 +12,9 @@ import type { LocalizerType } from '../types/Util'; import { Page, PreferencesContent } from './Preferences'; import { PreferencesDonateFlow } from './PreferencesDonateFlow'; import type { - CardDetail, DonationWorkflow, DonationReceipt, + OneTimeDonationHumanAmounts, } from '../types/Donations'; import type { AvatarColorType } from '../types/Colors'; import type { AvatarDataType } from '../types/Avatar'; @@ -27,6 +27,8 @@ import { ToastType } from '../types/Toast'; import { createLogger } from '../logging/log'; import { toLogFormat } from '../types/errors'; import { I18n } from './I18n'; +import type { SubmitDonationType } from '../state/ducks/donations'; +import { getHumanDonationAmount } from '../util/currency'; const log = createLogger('PreferencesDonations'); @@ -43,6 +45,8 @@ export type PropsDataType = { color?: AvatarColorType; firstName?: string; profileAvatarUrl?: string; + donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; + validCurrencies: ReadonlyArray; donationReceipts: ReadonlyArray; saveAttachmentToDisk: (options: { data: Uint8Array; @@ -59,11 +63,7 @@ export type PropsDataType = { type PropsActionType = { clearWorkflow: () => void; setPage: (page: Page) => void; - submitDonation: (options: { - currencyType: string; - paymentAmount: number; - paymentDetail: CardDetail; - }) => void; + submitDonation: (payload: SubmitDonationType) => void; }; export type PropsType = PropsDataType & PropsActionType & PropsExternalType; @@ -318,7 +318,7 @@ function PreferencesReceiptList({
{getCurrencyFormatter(receipt.currencyType).format( - receipt.paymentAmount / 100 + getHumanDonationAmount(receipt) )}
@@ -366,7 +366,7 @@ function PreferencesReceiptList({
{getCurrencyFormatter(selectedReceipt.currencyType).format( - selectedReceipt.paymentAmount / 100 + getHumanDonationAmount(selectedReceipt) )}

@@ -410,30 +410,13 @@ export function PreferencesDonations({ color, firstName, profileAvatarUrl, + donationAmountsConfig, + validCurrencies, donationReceipts, saveAttachmentToDisk, generateDonationReceiptBlob, showToast, }: PropsType): JSX.Element | null { - const PAGE_CONFIG = useMemo< - Record - >(() => { - return { - [Page.Donations]: { - title: i18n('icu:Preferences__DonateTitle'), - goBackTo: null, - }, - [Page.DonationsReceiptList]: { - title: i18n('icu:PreferencesDonations__receipts'), - goBackTo: Page.Donations, - }, - [Page.DonationsDonateFlow]: { - title: undefined, - goBackTo: Page.Donations, - }, - } as const; - }, [i18n]); - const navigateToPage = useCallback( (newPage: Page) => { setPage(newPage); @@ -441,31 +424,23 @@ export function PreferencesDonations({ [setPage] ); - const handleBack = useCallback(() => { - if (!isDonationPage(page)) { - log.error( - 'Donations page back button tried to go to a non-donations page, ignoring' - ); - return; - } - const { goBackTo } = PAGE_CONFIG[page]; - if (goBackTo) { - setPage(goBackTo); - } - }, [PAGE_CONFIG, page, setPage]); - if (!isDonationPage(page)) { return null; } let content; if (page === Page.DonationsDonateFlow) { - content = ( + // DonateFlow has to control Back button to switch between CC form and Amount picker + return ( setPage(Page.Donations)} /> ); } @@ -480,6 +455,8 @@ export function PreferencesDonations({ profileAvatarUrl={profileAvatarUrl} navigateToPage={navigateToPage} donationReceipts={donationReceipts} + donationAmountsConfig={donationAmountsConfig} + validCurrencies={validCurrencies} saveAttachmentToDisk={saveAttachmentToDisk} generateDonationReceiptBlob={generateDonationReceiptBlob} showToast={showToast} @@ -503,22 +480,28 @@ export function PreferencesDonations({ ); } - // Show back button based on page configuration - const backButton = PAGE_CONFIG[page].goBackTo ? ( -