diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 797fa4c337..51a22146e8 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -8902,14 +8902,30 @@ "messageformat": "Thank you for supporting Signal. Your contribution helps fuel the mission of protecting free expression and enabling secure global communication for millions around the world, through open source privacy technology. If you’re a resident of the United States, please retain this receipt for your tax records. Signal Technology Foundation is a tax-exempt nonprofit organization in the United States under section 501c3 of the Internal Revenue Code. Our Federal Tax ID is 82-4506840.", "description": "Footer text shown on donation receipts explaining tax deductibility and Signal's mission" }, + "icu:Donations__Toast__Cancelled": { + "messageformat": "Donation cancelled", + "description": "Toast shown when a donation was manually cancelled by the user" + }, "icu:Donations__Toast__Completed": { "messageformat": "Donation completed", "description": "Toast shown when a donation started processing after resuming on startup, and it completed successfully when the user is not on the Preferences/Donations screen" }, + "icu:Donations__Toast__Error": { + "messageformat": "Error processing donation", + "description": "Toast shown when a donation fails when the user is not on the Preferences/Donations screen" + }, "icu:Donations__Toast__Processing": { "messageformat": "Processing donation", "description": "Toast shown when a donation starts processing again after resuming on startup" }, + "icu:Donations__Toast__VerificationFailed": { + "messageformat": "Verification failed", + "description": "Shown when the user is not on the Preferences/Donations screen, and for some reason their attempt to complete verification failed" + }, + "icu:Donations__Toast__VerificationNeeded": { + "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__PaymentMethodDeclined": { "messageformat": "Payment method declined", "description": "Title of the dialog shown with the user's provided payment method has not worked" @@ -8918,14 +8934,6 @@ "messageformat": "Try another payment method or contact your bank for more information.", "description": "An explanation for the 'payment declined' dialog" }, - "icu:Donations__ErrorProcessingDonation": { - "messageformat": "Error processing donation", - "description": "Title of the dialog shown when a user's donation didn't seem to complete" - }, - "icu:Donations__ErrorProcessingDonation__Description": { - "messageformat": "Try another payment method or contact your bank for more information.", - "description": "An explanation for the 'error processing' dialog" - }, "icu:Donations__Failed3dsValidation": { "messageformat": "Verification Failed", "description": "Title of the dialog shown when something went wrong processing a user's 3ds verification with their bank" @@ -8958,6 +8966,10 @@ "messageformat": "Additional verification required", "description": "Title of the dialog shown when the user's payment method requires a redirect to a verification website" }, + "icu:Donations__3dsValidationNeeded--waiting": { + "messageformat": "Waiting for verification", + "description": "Title of the dialog shown when the user's payment method requires a redirect to a verification website" + }, "icu:Donations__3dsValidationNeeded__Description": { "messageformat": "Your credit card issuer requires an additional verification step in a web browser.", "description": "An explanation for the 'verification required' dialog" @@ -8966,6 +8978,14 @@ "messageformat": "Open browser", "description": "When external payment method validation is required, this button will open that external verification website" }, + "icu:Donations__3dsValidationNeeded__OpenBrowser--opened": { + "messageformat": "Re-open browser", + "description": "When the user has started external payment method validation, the button switches to this text. It allows them to kick off the process again if necessary." + }, + "icu:Donations__3dsValidationNeeded__CancelDonation": { + "messageformat": "Cancel donation", + "description": "When external payment method validation is required, this button will open that external verification website" + }, "icu:WhatsNew__bugfixes": { "messageformat": "This version contains a number of small tweaks and bug fixes to keep Signal running smoothly.", "description": "Release notes for releases that only include bug fixes", diff --git a/stylesheets/components/DonationVerificationModal.scss b/stylesheets/components/DonationVerificationModal.scss index cb4c5b645d..e2adda0629 100644 --- a/stylesheets/components/DonationVerificationModal.scss +++ b/stylesheets/components/DonationVerificationModal.scss @@ -11,3 +11,7 @@ .module-Modal__title.DonationVerificationModal__title { @include mixins.font-title-medium; } + +.DonationVerificationModal__body_inner { + @include mixins.font-body-2; +} diff --git a/ts/components/DonationError.stories.tsx b/ts/components/DonationError.stories.tsx index 6e66d0b6ae..09102ce374 100644 --- a/ts/components/DonationError.stories.tsx +++ b/ts/components/DonationError.stories.tsx @@ -21,15 +21,6 @@ const defaultProps = { onClose: action('onClose'), }; -export function DonationProcessingError(): JSX.Element { - return ( - - ); -} - export function Failed3dsValidation(): JSX.Element { return ( {i18n('icu:Confirmation--confirm')} } - hasXButton moduleClassName="DonationErrorModal" modalName="DonationErrorModal" + noMouseClose onClose={onClose} title={title} > diff --git a/ts/components/DonationProgressModal.stories.tsx b/ts/components/DonationProgressModal.stories.tsx index 1c25531c69..1f3b301399 100644 --- a/ts/components/DonationProgressModal.stories.tsx +++ b/ts/components/DonationProgressModal.stories.tsx @@ -17,7 +17,7 @@ export default { const defaultProps = { i18n, - onClose: action('onClose'), + onWaitedTooLong: action('onWaitedTooLong'), }; export function Default(): JSX.Element { diff --git a/ts/components/DonationProgressModal.tsx b/ts/components/DonationProgressModal.tsx index 6f0797a464..9e99e2a803 100644 --- a/ts/components/DonationProgressModal.tsx +++ b/ts/components/DonationProgressModal.tsx @@ -1,26 +1,41 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { useEffect } from 'react'; import type { LocalizerType } from '../types/Util'; import { Modal } from './Modal'; import { SpinnerV2 } from './SpinnerV2'; +import { SECOND } from '../util/durations'; export type PropsType = { i18n: LocalizerType; - onClose: () => void; + onWaitedTooLong: () => void; }; export function DonationProgressModal(props: PropsType): JSX.Element { - const { i18n, onClose } = props; + const { i18n, onWaitedTooLong } = props; + + useEffect(() => { + let timeout: NodeJS.Timeout | undefined = setTimeout(() => { + timeout = undefined; + onWaitedTooLong(); + }, SECOND * 30); + + return () => { + if (timeout) { + clearTimeout(timeout); + } + }; + }, [onWaitedTooLong]); return ( undefined} >
diff --git a/ts/components/DonationStillProcessingModal.tsx b/ts/components/DonationStillProcessingModal.tsx index 81061345c6..0b66e19d8b 100644 --- a/ts/components/DonationStillProcessingModal.tsx +++ b/ts/components/DonationStillProcessingModal.tsx @@ -17,13 +17,14 @@ export function DonationStillProcessingModal(props: PropsType): JSX.Element { return ( {i18n('icu:Confirmation--confirm')} } - hasXButton - moduleClassName="DonationStillProcessingModal" modalName="DonationStillProcessingModal" + moduleClassName="DonationStillProcessingModal" + noMouseClose onClose={onClose} title={i18n('icu:Donations__StillProcessing')} > diff --git a/ts/components/DonationVerificationModal.stories.tsx b/ts/components/DonationVerificationModal.stories.tsx index 4048b68bbb..8baaa987db 100644 --- a/ts/components/DonationVerificationModal.stories.tsx +++ b/ts/components/DonationVerificationModal.stories.tsx @@ -17,7 +17,7 @@ export default { const defaultProps = { i18n, - onCancel: action('onCancel'), + onCancelDonation: action('onCancelDonation'), onOpenBrowser: action('onOpenBrowser'), }; diff --git a/ts/components/DonationVerificationModal.tsx b/ts/components/DonationVerificationModal.tsx index 45a62c71fe..16473710c8 100644 --- a/ts/components/DonationVerificationModal.tsx +++ b/ts/components/DonationVerificationModal.tsx @@ -1,7 +1,7 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { useState } from 'react'; import type { LocalizerType } from '../types/Util'; import { Modal } from './Modal'; @@ -9,33 +9,46 @@ import { Button, ButtonVariant } from './Button'; export type PropsType = { i18n: LocalizerType; - onCancel: () => unknown; + onCancelDonation: () => unknown; onOpenBrowser: () => unknown; }; export function DonationVerificationModal(props: PropsType): JSX.Element { - const { i18n, onCancel, onOpenBrowser } = props; + const { i18n, onCancelDonation, onOpenBrowser } = props; + const [hasOpenedBrowser, setHasOpenedBrowser] = useState(false); + + const titleText = hasOpenedBrowser + ? i18n('icu:Donations__3dsValidationNeeded--waiting') + : i18n('icu:Donations__3dsValidationNeeded'); + const openBrowserText = hasOpenedBrowser + ? i18n('icu:Donations__3dsValidationNeeded__OpenBrowser--opened') + : i18n('icu:Donations__3dsValidationNeeded__OpenBrowser'); const footer = ( <> - - ); return ( {i18n('icu:Donations__3dsValidationNeeded__Description')} diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 8136c59b80..43f0443d4f 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -2,7 +2,8 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryFn } from '@storybook/react'; -import React, { useRef, useState } from 'react'; +import React, { useState } from 'react'; +import type { MutableRefObject } from 'react'; import { action } from '@storybook/addon-actions'; import { shuffle } from 'lodash'; @@ -146,8 +147,11 @@ function renderUpdateDialog( /> ); } -function RenderProfileEditor(): JSX.Element { - const contentsRef = useRef(null); +function renderProfileEditor({ + contentsRef, +}: { + contentsRef: MutableRefObject; +}): JSX.Element { return ( ; + page: SettingsPage; + setPage: (page: SettingsPage) => void; me: typeof me; donationReceipts: ReadonlyArray; - page: SettingsPage; saveAttachmentToDisk: (options: { data: Uint8Array; name: string; @@ -207,16 +213,16 @@ function RenderDonationsPane(props: { ) => Promise; showToast: (toast: AnyToast) => void; }): JSX.Element { - const contentsRef = useRef(null); return ( ); } @@ -351,11 +358,21 @@ export default { whoCanSeeMe: PhoneNumberSharingMode.Everybody, zoomFactor: 1, - renderDonationsPane: () => - RenderDonationsPane({ + renderDonationsPane: ({ + contentsRef, + page, + setPage, + }: { + contentsRef: MutableRefObject; + page: SettingsPage; + setPage: (page: SettingsPage) => void; + }) => + renderDonationsPane({ + contentsRef, + page, + setPage, me, donationReceipts: [], - page: SettingsPage.Donations, saveAttachmentToDisk: async () => { action('saveAttachmentToDisk')(); return { fullPath: '/mock/path/to/file.png', name: 'file.png' }; @@ -366,7 +383,7 @@ export default { }, showToast: action('showToast'), }), - renderProfileEditor: RenderProfileEditor, + renderProfileEditor, renderToastManager, renderUpdateDialog, getConversationsWithCustomColor: () => [], @@ -473,7 +490,17 @@ export default { // eslint-disable-next-line react/function-component-definition const Template: StoryFn = args => { const [page, setPage] = useState(args.page); - return ; + return ( + { + // eslint-disable-next-line no-console + console.log('setPage:', newPage, profilePage); + setPage(newPage); + }} + /> + ); }; export const _Preferences = Template.bind({}); @@ -523,11 +550,19 @@ export const DonationsDonateFlow = Template.bind({}); DonationsDonateFlow.args = { donationsFeatureEnabled: true, page: SettingsPage.DonationsDonateFlow, - renderDonationsPane: () => - RenderDonationsPane({ + renderDonationsPane: ({ + contentsRef, + }: { + contentsRef: MutableRefObject; + page: SettingsPage; + setPage: (page: SettingsPage) => void; + }) => + renderDonationsPane({ + contentsRef, me, donationReceipts: [], page: SettingsPage.DonationsDonateFlow, + setPage: action('setPage'), saveAttachmentToDisk: async () => { action('saveAttachmentToDisk')(); return { fullPath: '/mock/path/to/file.png', name: 'file.png' }; diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index da09d5cab5..f21caa7444 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -103,6 +103,7 @@ import { isChatFoldersEnabled, } from '../types/ChatFolder'; import type { GetConversationByIdType } from '../state/selectors/conversations'; +import type { ProfileEditorPage } from '../types/Nav'; type CheckboxChangeHandlerType = (value: boolean) => unknown; type SelectChangeHandlerType = (value: T) => unknown; @@ -210,7 +211,7 @@ type PropsFunctionType = { renderDonationsPane: (options: { contentsRef: MutableRefObject; page: SettingsPage; - setPage: (page: SettingsPage) => void; + setPage: (page: SettingsPage, profilePage?: ProfileEditorPage) => void; }) => JSX.Element; renderProfileEditor: (options: { contentsRef: MutableRefObject; diff --git a/ts/components/PreferencesDonateFlow.tsx b/ts/components/PreferencesDonateFlow.tsx index a8325e7182..7849d3f5b5 100644 --- a/ts/components/PreferencesDonateFlow.tsx +++ b/ts/components/PreferencesDonateFlow.tsx @@ -14,7 +14,10 @@ import classNames from 'classnames'; import type { LocalizerType } from '../types/Util'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; import { Button, ButtonVariant } from './Button'; -import type { HumanDonationAmount } from '../types/Donations'; +import type { + DonationErrorType, + HumanDonationAmount, +} from '../types/Donations'; import { ONE_TIME_DONATION_CONFIG_ID, type DonationWorkflow, @@ -63,6 +66,7 @@ const SUPPORT_URL = 'https://support.signal.org/hc/requests/new?desktop'; export type PropsDataType = { i18n: LocalizerType; donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; + lastError: DonationErrorType | undefined; validCurrencies: ReadonlyArray; workflow: DonationWorkflow | undefined; renderDonationHero: () => JSX.Element; @@ -84,6 +88,7 @@ export function PreferencesDonateFlow({ contentsRef, i18n, donationAmountsConfig, + lastError, validCurrencies, workflow, clearWorkflow, @@ -105,6 +110,7 @@ export function PreferencesDonateFlow({ const [cardExpiration, setCardExpiration] = useState(''); const [cardNumber, setCardNumber] = useState(''); const [cardCvc, setCardCvc] = useState(''); + const [isDonateDisabled, setIsDonateDisabled] = useState(false); const [cardNumberError, setCardNumberError] = useState(null); @@ -183,22 +189,45 @@ export function PreferencesDonateFlow({ return; } + setIsDonateDisabled(true); submitDonation({ currencyType: currency, paymentAmount, paymentDetail: cardDetail, }); - }, [amount, cardCvc, cardExpiration, cardNumber, currency, submitDonation]); + }, [ + amount, + cardCvc, + cardExpiration, + cardNumber, + currency, + setIsDonateDisabled, + submitDonation, + ]); - const isDonateDisabled = workflow !== undefined; + useEffect(() => { + if (!workflow || lastError) { + setIsDonateDisabled(false); + } + }, [lastError, setIsDonateDisabled, workflow]); const onTryClose = useCallback(() => { const onDiscard = () => { - // TODO: DESKTOP-8950 + clearWorkflow(); }; + const isDirty = Boolean( + (cardExpiration || cardNumber || cardCvc) && !isDonateDisabled + ); - confirmDiscardIf(step === 'paymentDetails', onDiscard); - }, [confirmDiscardIf, step]); + confirmDiscardIf(isDirty, onDiscard); + }, [ + cardCvc, + cardExpiration, + cardNumber, + clearWorkflow, + confirmDiscardIf, + isDonateDisabled, + ]); tryClose.current = onTryClose; let innerContent: JSX.Element; diff --git a/ts/components/PreferencesDonations.tsx b/ts/components/PreferencesDonations.tsx index b489c0613f..3cc29a7cf7 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, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { groupBy, sortBy } from 'lodash'; import type { MutableRefObject, ReactNode } from 'react'; @@ -16,7 +16,9 @@ import type { DonationWorkflow, DonationReceipt, OneTimeDonationHumanAmounts, + DonationErrorType, } from '../types/Donations'; +import { donationStateSchema } from '../types/Donations'; import type { AvatarColorType } from '../types/Colors'; import { Button, ButtonSize, ButtonVariant } from './Button'; import { Modal } from './Modal'; @@ -32,6 +34,10 @@ import type { SubmitDonationType } from '../state/ducks/donations'; import { getHumanDonationAmount } from '../util/currency'; import { Avatar, AvatarSize } from './Avatar'; import type { BadgeType } from '../badges/types'; +import { DonationErrorModal } from './DonationErrorModal'; +import { DonationVerificationModal } from './DonationVerificationModal'; +import { DonationProgressModal } from './DonationProgressModal'; +import { DonationStillProcessingModal } from './DonationStillProcessingModal'; const log = createLogger('PreferencesDonations'); @@ -43,6 +49,7 @@ export type PropsDataType = { i18n: LocalizerType; isStaging: boolean; page: SettingsPage; + lastError: DonationErrorType | undefined; workflow: DonationWorkflow | undefined; badge: BadgeType | undefined; color: AvatarColorType | undefined; @@ -68,6 +75,7 @@ type PropsActionType = { clearWorkflow: () => void; setPage: (page: SettingsPage) => void; submitDonation: (payload: SubmitDonationType) => void; + updateLastError: (error: DonationErrorType | undefined) => void; }; export type PropsType = PropsDataType & PropsActionType & PropsExternalType; @@ -447,6 +455,7 @@ export function PreferencesDonations({ isStaging, page, workflow, + lastError, clearWorkflow, setPage, submitDonation, @@ -461,7 +470,10 @@ export function PreferencesDonations({ saveAttachmentToDisk, generateDonationReceiptBlob, showToast, + updateLastError, }: PropsType): JSX.Element | null { + const [hasProcessingExpired, setHasProcessingExpired] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); const navigateToPage = useCallback( (newPage: SettingsPage) => { setPage(newPage); @@ -469,6 +481,20 @@ export function PreferencesDonations({ [setPage] ); + useEffect(() => { + if (!workflow || lastError) { + setIsSubmitted(false); + } + + if ( + workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED || + workflow?.type === donationStateSchema.Enum.RECEIPT || + workflow?.type === donationStateSchema.Enum.DONE + ) { + setIsSubmitted(false); + } + }, [lastError, workflow, setIsSubmitted]); + const renderDonationHero = useCallback( () => ( { + updateLastError(undefined); + }} + /> + ); + } else if (workflow?.type === donationStateSchema.Enum.INTENT_REDIRECT) { + dialog = ( + { + clearWorkflow(); + setPage(SettingsPage.Donations); + showToast({ toastType: ToastType.DonationCancelled }); + }} + onOpenBrowser={() => { + openLinkInWebBrowser(workflow.redirectTarget); + }} + /> + ); + } else if ( + page === SettingsPage.DonationsDonateFlow && + (isSubmitted || + workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED || + workflow?.type === donationStateSchema.Enum.RECEIPT) + ) { + // We can't transition away from the payment screen until that payment information + // has been accepted. Even if it takes more than 30 seconds. + if ( + hasProcessingExpired && + (workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED || + workflow?.type === donationStateSchema.Enum.RECEIPT) + ) { + dialog = ( + { + setPage(SettingsPage.Donations); + // We need to delay until we've transitioned away from this page, or we'll + // go back to showing the spinner. + setTimeout(() => setHasProcessingExpired(false), 500); + }} + /> + ); + } else { + dialog = ( + setHasProcessingExpired(true)} + /> + ); + } + } + let content; if (page === SettingsPage.DonationsDonateFlow) { // DonateFlow has to control Back button to switch between CC form and Amount picker return ( - setPage(SettingsPage.Donations)} - /> + <> + {dialog} + { + setIsSubmitted(true); + submitDonation(details); + }} + onBack={() => setPage(SettingsPage.Donations)} + /> + ); } if (page === SettingsPage.Donations) { @@ -521,11 +613,13 @@ export function PreferencesDonations({ showToast={showToast} isStaging={isStaging} page={page} + lastError={lastError} workflow={workflow} clearWorkflow={clearWorkflow} renderDonationHero={renderDonationHero} setPage={setPage} submitDonation={submitDonation} + updateLastError={updateLastError} /> ); } else if (page === SettingsPage.DonationsReceiptList) { @@ -557,11 +651,14 @@ export function PreferencesDonations({ } return ( - + <> + {dialog} + + ); } diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index e9690bdc40..beb4c7c0d8 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -100,10 +100,18 @@ function getToast(toastType: ToastType): AnyToast { }; case ToastType.DeleteForEveryoneFailed: return { toastType: ToastType.DeleteForEveryoneFailed }; + case ToastType.DonationCancelled: + return { toastType: ToastType.DonationCancelled }; case ToastType.DonationCompleted: return { toastType: ToastType.DonationCompleted }; + case ToastType.DonationError: + return { toastType: ToastType.DonationError }; case ToastType.DonationProcessing: return { toastType: ToastType.DonationProcessing }; + case ToastType.DonationVerificationFailed: + return { toastType: ToastType.DonationVerificationFailed }; + case ToastType.DonationVerificationNeeded: + return { toastType: ToastType.DonationVerificationNeeded }; case ToastType.Error: return { toastType: ToastType.Error }; case ToastType.Expired: diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index 361b03dcb6..a96dbbd928 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -280,6 +280,14 @@ export function renderToast({ ); } + if (toastType === ToastType.DonationCancelled) { + return ( + + {i18n('icu:Donations__Toast__Cancelled')} + + ); + } + if (toastType === ToastType.DonationCompleted) { return ( { + changeLocation({ + tab: NavTab.Settings, + details: { + page: SettingsPage.Donations, + }, + }); + }, + }} + > + {text} + + ); + } + if (toastType === ToastType.Error) { return ( { log.info( `${logId}: Waiting for user to return from confirmation URL. Returning.` ); + if (!isDonationPageVisible()) { + log.info( + `${logId}: Donation page not visible. Showing verification needed toast.` + ); + window.reduxActions.toast.showToast({ + toastType: ToastType.DonationVerificationNeeded, + }); + } return; } else if (type === donationStateSchema.Enum.INTENT_CONFIRMED) { log.info(`${logId}: Attempting to get receipt`); @@ -346,9 +354,7 @@ export async function _runDonationWorkflow(): Promise { if (type === donationStateSchema.Enum.INTENT_METHOD) { await failDonation(donationErrorTypeSchema.Enum.PaymentDeclined); } else { - await failDonation( - donationErrorTypeSchema.Enum.DonationProcessingError - ); + await failDonation(donationErrorTypeSchema.Enum.GeneralError); } throw error; } @@ -360,9 +366,7 @@ export async function _runDonationWorkflow(): Promise { log.warn( `${logId}: Donation step threw unexpectedly. Failing donation. ${Errors.toLogFormat(error)}` ); - await failDonation( - donationErrorTypeSchema.Enum.DonationProcessingError - ); + await failDonation(donationErrorTypeSchema.Enum.GeneralError); throw error; } } @@ -723,6 +727,7 @@ export async function _redeemReceipt( async function failDonation(errorType: DonationErrorType): Promise { const workflow = _getWorkflowFromRedux(); + const logId = `failDonation(${workflow?.id ? redactId(workflow.id) : 'NONE'})`; // We clear the workflow if we didn't just get user input if ( @@ -735,6 +740,24 @@ async function failDonation(errorType: DonationErrorType): Promise { } log.info(`failDonation: Failing with type ${errorType}`); + if (!isDonationPageVisible()) { + if (errorType === donationErrorTypeSchema.Enum.Failed3dsValidation) { + log.info( + `${logId}: Donation page not visible. Showing 'verification failed' toast.` + ); + window.reduxActions.toast.showToast({ + toastType: ToastType.DonationVerificationFailed, + }); + } else { + log.info( + `${logId}: Donation page not visible. Showing 'error processing donation' toast.` + ); + window.reduxActions.toast.showToast({ + toastType: ToastType.DonationError, + }); + } + } + window.reduxActions.donations.updateLastError(errorType); } async function _saveWorkflow( diff --git a/ts/state/ducks/donations.ts b/ts/state/ducks/donations.ts index b952a3c845..f6c30b5cf4 100644 --- a/ts/state/ducks/donations.ts +++ b/ts/state/ducks/donations.ts @@ -10,6 +10,8 @@ import * as Errors from '../../types/errors'; import { isStagingServer } from '../../util/isStagingServer'; import { DataWriter } from '../../sql/Client'; import * as donations from '../../services/donations'; +import { donationStateSchema } from '../../types/Donations'; +import { drop } from '../../util/drop'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { @@ -20,7 +22,6 @@ import type { StripeDonationAmount, } from '../../types/Donations'; import type { StateType as RootStateType } from '../reducer'; -import { drop } from '../../util/drop'; const log = createLogger('donations'); @@ -28,9 +29,9 @@ const log = createLogger('donations'); export type DonationsStateType = ReadonlyDeep<{ currentWorkflow: DonationWorkflow | undefined; + didResumeWorkflowAtStartup: boolean; lastError: DonationErrorType | undefined; receipts: Array; - didResumeWorkflowAtStartup: boolean; }>; // Actions @@ -129,18 +130,29 @@ function submitDonation({ unknown, UpdateWorkflowAction > { - return async () => { + return async (_dispatch, getState) => { if (!isStagingServer()) { log.error('internalAddDonationReceipt: Only available on staging server'); return; } try { - await donations._internalDoDonation({ - currencyType, - paymentAmount, - paymentDetail, - }); + const { currentWorkflow } = getState().donations; + if ( + currentWorkflow?.type === donationStateSchema.Enum.INTENT && + currentWorkflow.paymentAmount === paymentAmount && + currentWorkflow.currencyType === currencyType + ) { + // we can proceed without starting afresh + } else { + await donations.clearDonation(); + await donations.startDonation({ + currencyType, + paymentAmount, + }); + } + + await donations.finishDonationWithCard(paymentDetail); } catch (error) { log.warn('submitDonation failed', Errors.toLogFormat(error)); } diff --git a/ts/state/smart/PreferencesDonations.tsx b/ts/state/smart/PreferencesDonations.tsx index d87bd46e5b..6f90b68872 100644 --- a/ts/state/smart/PreferencesDonations.tsx +++ b/ts/state/smart/PreferencesDonations.tsx @@ -41,10 +41,9 @@ export const SmartPreferencesDonations = memo( const i18n = useSelector(getIntl); const theme = useSelector(getTheme); - const workflow = useSelector( - (state: StateType) => state.donations.currentWorkflow - ); - const { clearWorkflow, submitDonation } = useDonationsActions(); + const donationsState = useSelector((state: StateType) => state.donations); + const { clearWorkflow, submitDonation, updateLastError } = + useDonationsActions(); const { badges, color, firstName, profileAvatarUrl } = useSelector(getMe); const badge = getPreferredBadge(badges); @@ -84,8 +83,10 @@ export const SmartPreferencesDonations = memo( contentsRef={contentsRef} isStaging={isStaging} page={page} - workflow={workflow} + lastError={donationsState.lastError} + workflow={donationsState.currentWorkflow} clearWorkflow={clearWorkflow} + updateLastError={updateLastError} submitDonation={submitDonation} setPage={setPage} theme={theme} diff --git a/ts/types/Donations.ts b/ts/types/Donations.ts index 58f6917b11..8a90c39337 100644 --- a/ts/types/Donations.ts +++ b/ts/types/Donations.ts @@ -15,8 +15,6 @@ export const donationStateSchema = z.enum([ ]); export const donationErrorTypeSchema = z.enum([ - // Any other HTTPError during the process - 'DonationProcessingError', // Used if the user is redirected back from validation, but continuing forward fails 'Failed3dsValidation', // Any other error diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index 848e7f43d2..e92ee19592 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -31,8 +31,12 @@ export enum ToastType { DecryptionError = 'DecryptionError', DebugLogError = 'DebugLogError', DeleteForEveryoneFailed = 'DeleteForEveryoneFailed', + DonationCancelled = 'DonationCancelled', DonationCompleted = 'DonationCompleted', + DonationError = 'DonationError', DonationProcessing = 'DonationProcessing', + DonationVerificationNeeded = 'DonationVerificationNeeded', + DonationVerificationFailed = 'DonationVerificationFailed', Error = 'Error', Expired = 'Expired', FailedToDeleteUsername = 'FailedToDeleteUsername', @@ -122,8 +126,12 @@ export type AnyToast = | { toastType: ToastType.DangerousFileType } | { toastType: ToastType.DebugLogError } | { toastType: ToastType.DeleteForEveryoneFailed } + | { toastType: ToastType.DonationCancelled } | { toastType: ToastType.DonationCompleted } + | { toastType: ToastType.DonationError } | { toastType: ToastType.DonationProcessing } + | { toastType: ToastType.DonationVerificationFailed } + | { toastType: ToastType.DonationVerificationNeeded } | { toastType: ToastType.Error } | { toastType: ToastType.Expired } | { toastType: ToastType.FailedToDeleteUsername } diff --git a/ts/windows/main/preload_test.ts b/ts/windows/main/preload_test.ts index df32391c82..c54bb3a79c 100644 --- a/ts/windows/main/preload_test.ts +++ b/ts/windows/main/preload_test.ts @@ -126,8 +126,8 @@ window.testUtilities = { stories: [], storyDistributionLists: [], donations: { - didResumeWorkflowAtStartup: false, currentWorkflow: undefined, + didResumeWorkflowAtStartup: false, lastError: undefined, receipts: [], },