From 004dfb0af429e3311b15b63197b8ff7693b6f9f2 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 30 Jul 2025 07:35:10 +1000 Subject: [PATCH] Donations: Show confirmation toast on startup at INTENT_METHOD Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> --- _locales/en/messages.json | 16 +++++++ .../components/DonationInterruptedModal.scss | 17 +++++++ stylesheets/manifest.scss | 1 + .../DonationInterruptedModal.stories.tsx | 26 ++++++++++ ts/components/DonationInterruptedModal.tsx | 47 +++++++++++++++++++ ts/components/Preferences.stories.tsx | 2 + ts/components/PreferencesDonations.tsx | 44 ++++++++++------- ts/components/ToastManager.stories.tsx | 2 + ts/components/ToastManager.tsx | 5 ++ ts/services/donations.ts | 37 ++++++++++++++- ts/state/ducks/donations.ts | 24 +++++++++- ts/state/smart/PreferencesDonations.tsx | 4 +- ts/types/Toast.tsx | 2 + 13 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 stylesheets/components/DonationInterruptedModal.scss create mode 100644 ts/components/DonationInterruptedModal.stories.tsx create mode 100644 ts/components/DonationInterruptedModal.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index bf3f8dac40..7cb8d39f7b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -8926,6 +8926,22 @@ "messageformat": "You have a donation in progress that needs additional verification.", "description": "Shown when the user is not on the Preferences/Donations screen, and donation verification is needed. Like when resuming from startup." }, + "icu:Donations__Toast__ConfirmationNeeded": { + "messageformat": "You have a donation in progress that requires confirmation.", + "description": "Shown when the user is not on the Preferences/Donations screen, and donation verification is needed. Like when resuming from startup." + }, + "icu:Donations__DonationInterrupted": { + "messageformat": "Donation interrupted", + "description": "Title of the dialog shown when starting up if a donation had been started, and we've saved payment information, but the charge hasn't happened yet" + }, + "icu:Donations__DonationInterrupted__Description": { + "messageformat": "Your card was not charged. Do you want to retry the donation?", + "description": "An explanation for the 'donation interrupted' dialog" + }, + "icu:Donations__DonationInterrupted__RetryButton": { + "messageformat": "Try again", + "description": "The button in the 'donation interrupted' dialog which allows the user to move forward with the donation." + }, "icu:Donations__PaymentMethodDeclined": { "messageformat": "Payment method declined", "description": "Title of the dialog shown with the user's provided payment method has not worked" diff --git a/stylesheets/components/DonationInterruptedModal.scss b/stylesheets/components/DonationInterruptedModal.scss new file mode 100644 index 0000000000..461b90dc7f --- /dev/null +++ b/stylesheets/components/DonationInterruptedModal.scss @@ -0,0 +1,17 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@use '../mixins'; + +.DonationInterruptedModal__width-container { + max-width: 420px; +} + +// We include both for specificity +.module-Modal__title.DonationInterruptedModal__title { + @include mixins.font-title-medium; +} + +.DonationInterruptedModal__body_inner { + @include mixins.font-body-2; +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 1a37fa252d..7388a64835 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -98,6 +98,7 @@ @use 'components/DisappearingTimerSelect.scss'; @use 'components/DonationErrorModal.scss'; @use 'components/DonationForm.scss'; +@use 'components/DonationInterruptedModal.scss'; @use 'components/DonationProgressModal.scss'; @use 'components/DonationStillProcessingModal.scss'; @use 'components/DonationVerificationModal.scss'; diff --git a/ts/components/DonationInterruptedModal.stories.tsx b/ts/components/DonationInterruptedModal.stories.tsx new file mode 100644 index 0000000000..e9866104f1 --- /dev/null +++ b/ts/components/DonationInterruptedModal.stories.tsx @@ -0,0 +1,26 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { action } from '@storybook/addon-actions'; + +import type { Meta } from '@storybook/react'; +import type { PropsType } from './DonationInterruptedModal'; +import { DonationInterruptedModal } from './DonationInterruptedModal'; + +const { i18n } = window.SignalContext; + +export default { + title: 'Components/DonationInterruptedModal', +} satisfies Meta; + +const defaultProps = { + i18n, + onCancelDonation: action('onCancelDonation'), + onRetryDonation: action('onRetryDonation'), +}; + +export function Default(): JSX.Element { + return ; +} diff --git a/ts/components/DonationInterruptedModal.tsx b/ts/components/DonationInterruptedModal.tsx new file mode 100644 index 0000000000..17488818aa --- /dev/null +++ b/ts/components/DonationInterruptedModal.tsx @@ -0,0 +1,47 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import type { LocalizerType } from '../types/Util'; +import { Modal } from './Modal'; +import { Button, ButtonVariant } from './Button'; + +export type PropsType = { + i18n: LocalizerType; + onCancelDonation: () => unknown; + onRetryDonation: () => unknown; +}; + +export function DonationInterruptedModal(props: PropsType): JSX.Element { + const { i18n, onCancelDonation, onRetryDonation } = props; + + const footer = ( + <> + + + + ); + + return ( + + {i18n('icu:Donations__DonationInterrupted__Description')} + + ); +} diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index ad2343a9e5..8e1d83b73c 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -219,12 +219,14 @@ function renderDonationsPane(props: { contentsRef={props.contentsRef} clearWorkflow={action('clearWorkflow')} initialCurrency="USD" + resumeWorkflow={action('resumeWorkflow')} isStaging page={props.page} setPage={props.setPage} submitDonation={action('submitDonation')} lastError={undefined} workflow={undefined} + didResumeWorkflowAtStartup={false} badge={undefined} color={props.me.color} firstName={props.me.firstName} diff --git a/ts/components/PreferencesDonations.tsx b/ts/components/PreferencesDonations.tsx index d1718cb7bc..ab93857bf5 100644 --- a/ts/components/PreferencesDonations.tsx +++ b/ts/components/PreferencesDonations.tsx @@ -34,6 +34,7 @@ import type { SubmitDonationType } from '../state/ducks/donations'; import { getHumanDonationAmount } from '../util/currency'; import { Avatar, AvatarSize } from './Avatar'; import type { BadgeType } from '../badges/types'; +import { DonationInterruptedModal } from './DonationInterruptedModal'; import { DonationErrorModal } from './DonationErrorModal'; import { DonationVerificationModal } from './DonationVerificationModal'; import { DonationProgressModal } from './DonationProgressModal'; @@ -50,6 +51,7 @@ export type PropsDataType = { initialCurrency: string; isStaging: boolean; page: SettingsPage; + didResumeWorkflowAtStartup: boolean; lastError: DonationErrorType | undefined; workflow: DonationWorkflow | undefined; badge: BadgeType | undefined; @@ -69,12 +71,13 @@ export type PropsDataType = { receipt: DonationReceipt, i18n: LocalizerType ) => Promise; - showToast: (toast: AnyToast) => void; }; type PropsActionType = { clearWorkflow: () => void; + resumeWorkflow: () => void; setPage: (page: SettingsPage) => void; + showToast: (toast: AnyToast) => void; submitDonation: (payload: SubmitDonationType) => void; updateLastError: (error: DonationErrorType | undefined) => void; }; @@ -86,7 +89,10 @@ type DonationPage = | SettingsPage.DonationsDonateFlow | SettingsPage.DonationsReceiptList; -type PreferencesHomeProps = Omit & { +type PreferencesHomeProps = Pick< + PropsType, + 'contentsRef' | 'i18n' | 'setPage' | 'isStaging' | 'donationReceipts' +> & { navigateToPage: (newPage: SettingsPage) => void; renderDonationHero: () => JSX.Element; }; @@ -457,8 +463,10 @@ export function PreferencesDonations({ isStaging, page, workflow, + didResumeWorkflowAtStartup, lastError, clearWorkflow, + resumeWorkflow, setPage, submitDonation, badge, @@ -526,6 +534,23 @@ export function PreferencesDonations({ }} /> ); + } else if ( + didResumeWorkflowAtStartup && + workflow?.type === donationStateSchema.Enum.INTENT_METHOD + ) { + dialog = ( + { + clearWorkflow(); + setPage(SettingsPage.Donations); + showToast({ toastType: ToastType.DonationCancelled }); + }} + onRetryDonation={() => { + resumeWorkflow(); + }} + /> + ); } else if (workflow?.type === donationStateSchema.Enum.INTENT_REDIRECT) { dialog = ( ); } else if (page === SettingsPage.DonationsReceiptList) { diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index beb4c7c0d8..1f948e8376 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -104,6 +104,8 @@ function getToast(toastType: ToastType): AnyToast { return { toastType: ToastType.DonationCancelled }; case ToastType.DonationCompleted: return { toastType: ToastType.DonationCompleted }; + case ToastType.DonationConfirmationNeeded: + return { toastType: ToastType.DonationConfirmationNeeded }; case ToastType.DonationError: return { toastType: ToastType.DonationError }; case ToastType.DonationProcessing: diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index a96dbbd928..1dd66e9675 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -337,11 +337,15 @@ export function renderToast({ } if ( + toastType === ToastType.DonationConfirmationNeeded || toastType === ToastType.DonationError || toastType === ToastType.DonationVerificationFailed || toastType === ToastType.DonationVerificationNeeded ) { const mapping = { + [ToastType.DonationConfirmationNeeded]: i18n( + 'icu:Donations__Toast__ConfirmationNeeded' + ), [ToastType.DonationError]: i18n('icu:Donations__Toast__Error'), [ToastType.DonationVerificationFailed]: i18n( 'icu:Donations__Toast__VerificationFailed' @@ -355,6 +359,7 @@ export function renderToast({ return ( { return; } - if (didResumeWorkflowAtStartup() && !isDonationPageVisible()) { + const shouldShowToast = + didResumeWorkflowAtStartup() && !isDonationPageVisible(); + + if (workflow.type === donationStateSchema.Enum.INTENT_METHOD) { + if (shouldShowToast) { + log.info( + 'initialize: Showing confirmation toast, workflow is at INTENT_METHOD.' + ); + window.reduxActions.toast.showToast({ + toastType: ToastType.DonationConfirmationNeeded, + }); + } + + // Note that we are not starting the workflow here + return; + } + + if (shouldShowToast) { log.info( 'initialize: We resumed at startup and donation page not visible. Showing processing toast.' ); @@ -97,7 +114,7 @@ export async function initialize(): Promise { await _runDonationWorkflow(); } -// These are the four moments the user provides input to the donation workflow. So, +// These are the five moments the user provides input to the donation workflow. So, // UI calls these methods directly; everything else happens automatically. export async function startDonation({ @@ -169,6 +186,15 @@ export async function clearDonation(): Promise { await _saveWorkflow(undefined); } +export async function resumeDonation(): Promise { + const existing = _getWorkflowFromRedux(); + if (!existing) { + throw new Error('resumeDonation: Cannot finish nonexistent workflow!'); + } + + await _saveAndRunWorkflow(existing); +} + // For testing export async function _internalDoDonation({ @@ -295,6 +321,13 @@ export async function _runDonationWorkflow(): Promise { return; } if (type === donationStateSchema.Enum.INTENT_METHOD) { + if (didResumeWorkflowAtStartup()) { + log.info( + `${logId}: Resumed after startup and haven't charged payment method. Waiting for user confirmation.` + ); + return; + } + log.info(`${logId}: Attempting to confirm payment`); updated = await _confirmPayment(existing); // continuing diff --git a/ts/state/ducks/donations.ts b/ts/state/ducks/donations.ts index f6c30b5cf4..fab7ad0397 100644 --- a/ts/state/ducks/donations.ts +++ b/ts/state/ducks/donations.ts @@ -114,6 +114,27 @@ function setDidResume(didResume: boolean): SetDidResumeAction { }; } +function resumeWorkflow(): ThunkAction< + void, + RootStateType, + unknown, + SetDidResumeAction +> { + return async dispatch => { + try { + dispatch({ + type: SET_DID_RESUME, + payload: false, + }); + + await donations.resumeDonation(); + } catch (error) { + log.error('Error resuming workflow', Errors.toLogFormat(error)); + throw error; + } + }; +} + export type SubmitDonationType = ReadonlyDeep<{ currencyType: string; paymentAmount: StripeDonationAmount; @@ -132,7 +153,7 @@ function submitDonation({ > { return async (_dispatch, getState) => { if (!isStagingServer()) { - log.error('internalAddDonationReceipt: Only available on staging server'); + log.error('submitDonation: Only available on staging server'); return; } @@ -191,6 +212,7 @@ export const actions = { clearWorkflow, internalAddDonationReceipt, setDidResume, + resumeWorkflow, submitDonation, updateLastError, updateWorkflow, diff --git a/ts/state/smart/PreferencesDonations.tsx b/ts/state/smart/PreferencesDonations.tsx index 5365884f37..b63aa44f48 100644 --- a/ts/state/smart/PreferencesDonations.tsx +++ b/ts/state/smart/PreferencesDonations.tsx @@ -43,7 +43,7 @@ export const SmartPreferencesDonations = memo( const theme = useSelector(getTheme); const donationsState = useSelector((state: StateType) => state.donations); - const { clearWorkflow, submitDonation, updateLastError } = + const { clearWorkflow, resumeWorkflow, submitDonation, updateLastError } = useDonationsActions(); const ourNumber = useSelector(getUserNumber); @@ -93,9 +93,11 @@ export const SmartPreferencesDonations = memo( initialCurrency={initialCurrency} isStaging={isStaging} page={page} + didResumeWorkflowAtStartup={donationsState.didResumeWorkflowAtStartup} lastError={donationsState.lastError} workflow={donationsState.currentWorkflow} clearWorkflow={clearWorkflow} + resumeWorkflow={resumeWorkflow} updateLastError={updateLastError} submitDonation={submitDonation} setPage={setPage} diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index e92ee19592..66809a3f41 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -33,6 +33,7 @@ export enum ToastType { DeleteForEveryoneFailed = 'DeleteForEveryoneFailed', DonationCancelled = 'DonationCancelled', DonationCompleted = 'DonationCompleted', + DonationConfirmationNeeded = 'DonationConfirmationNeeded', DonationError = 'DonationError', DonationProcessing = 'DonationProcessing', DonationVerificationNeeded = 'DonationVerificationNeeded', @@ -128,6 +129,7 @@ export type AnyToast = | { toastType: ToastType.DeleteForEveryoneFailed } | { toastType: ToastType.DonationCancelled } | { toastType: ToastType.DonationCompleted } + | { toastType: ToastType.DonationConfirmationNeeded } | { toastType: ToastType.DonationError } | { toastType: ToastType.DonationProcessing } | { toastType: ToastType.DonationVerificationFailed }