Donations: Show progress, error and verify dialogs

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
automated-signal 2025-07-22 10:12:01 -05:00 committed by GitHub
parent 9c1b6ff01a
commit 47b4cb98a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 400 additions and 103 deletions

View file

@ -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 youre 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",

View file

@ -11,3 +11,7 @@
.module-Modal__title.DonationVerificationModal__title {
@include mixins.font-title-medium;
}
.DonationVerificationModal__body_inner {
@include mixins.font-body-2;
}

View file

@ -21,15 +21,6 @@ const defaultProps = {
onClose: action('onClose'),
};
export function DonationProcessingError(): JSX.Element {
return (
<DonationErrorModal
{...defaultProps}
errorType={donationErrorTypeSchema.Enum.DonationProcessingError}
/>
);
}
export function Failed3dsValidation(): JSX.Element {
return (
<DonationErrorModal

View file

@ -25,11 +25,6 @@ export function DonationErrorModal(props: PropsType): JSX.Element {
let body: ReactNode;
switch (props.errorType) {
case donationErrorTypeSchema.Enum.DonationProcessingError: {
title = i18n('icu:Donations__ErrorProcessingDonation');
body = i18n('icu:Donations__ErrorProcessingDonation__Description');
break;
}
case donationErrorTypeSchema.Enum.Failed3dsValidation: {
title = i18n('icu:Donations__Failed3dsValidation');
body = i18n('icu:Donations__Failed3dsValidation__Description');
@ -52,13 +47,14 @@ export function DonationErrorModal(props: PropsType): JSX.Element {
return (
<Modal
hasXButton
i18n={i18n}
modalFooter={
<Button onClick={onClose}>{i18n('icu:Confirmation--confirm')}</Button>
}
hasXButton
moduleClassName="DonationErrorModal"
modalName="DonationErrorModal"
noMouseClose
onClose={onClose}
title={title}
>

View file

@ -17,7 +17,7 @@ export default {
const defaultProps = {
i18n,
onClose: action('onClose'),
onWaitedTooLong: action('onWaitedTooLong'),
};
export function Default(): JSX.Element {

View file

@ -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 (
<Modal
i18n={i18n}
moduleClassName="DonationProgressModal"
modalName="DonationProgressModal"
onClose={onClose}
noMouseClose
onClose={() => undefined}
>
<SpinnerV2 size={58} strokeWidth={8} />
<div className="DonationProgressModal__text">

View file

@ -17,13 +17,14 @@ export function DonationStillProcessingModal(props: PropsType): JSX.Element {
return (
<Modal
hasXButton
i18n={i18n}
modalFooter={
<Button onClick={onClose}>{i18n('icu:Confirmation--confirm')}</Button>
}
hasXButton
moduleClassName="DonationStillProcessingModal"
modalName="DonationStillProcessingModal"
moduleClassName="DonationStillProcessingModal"
noMouseClose
onClose={onClose}
title={i18n('icu:Donations__StillProcessing')}
>

View file

@ -17,7 +17,7 @@ export default {
const defaultProps = {
i18n,
onCancel: action('onCancel'),
onCancelDonation: action('onCancelDonation'),
onOpenBrowser: action('onOpenBrowser'),
};

View file

@ -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 = (
<>
<Button variant={ButtonVariant.Secondary} onClick={onCancel}>
{i18n('icu:confirmation-dialog--Cancel')}
<Button variant={ButtonVariant.Secondary} onClick={onCancelDonation}>
{i18n('icu:Donations__3dsValidationNeeded__CancelDonation')}
</Button>
<Button onClick={onOpenBrowser}>
{i18n('icu:Donations__3dsValidationNeeded__OpenBrowser')}
<Button
onClick={() => {
setHasOpenedBrowser(true);
onOpenBrowser();
}}
>
{openBrowserText}
</Button>
</>
);
return (
<Modal
hasXButton
i18n={i18n}
modalFooter={footer}
moduleClassName="DonationVerificationModal"
modalName="DonationVerificationModal"
onClose={onCancel}
title={i18n('icu:Donations__3dsValidationNeeded')}
noMouseClose
onClose={onCancelDonation}
title={titleText}
>
{i18n('icu:Donations__3dsValidationNeeded__Description')}
</Modal>

View file

@ -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<HTMLDivElement | null>(null);
function renderProfileEditor({
contentsRef,
}: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
}): JSX.Element {
return (
<ProfileEditor
aboutEmoji={undefined}
@ -192,10 +196,12 @@ function RenderProfileEditor(): JSX.Element {
);
}
function RenderDonationsPane(props: {
function renderDonationsPane(props: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage;
setPage: (page: SettingsPage) => void;
me: typeof me;
donationReceipts: ReadonlyArray<DonationReceipt>;
page: SettingsPage;
saveAttachmentToDisk: (options: {
data: Uint8Array;
name: string;
@ -207,16 +213,16 @@ function RenderDonationsPane(props: {
) => Promise<Blob>;
showToast: (toast: AnyToast) => void;
}): JSX.Element {
const contentsRef = useRef<HTMLDivElement | null>(null);
return (
<PreferencesDonations
i18n={i18n}
contentsRef={contentsRef}
contentsRef={props.contentsRef}
clearWorkflow={action('clearWorkflow')}
isStaging
page={props.page}
setPage={action('setPage')}
setPage={props.setPage}
submitDonation={action('submitDonation')}
lastError={undefined}
workflow={undefined}
badge={undefined}
color={props.me.color}
@ -229,6 +235,7 @@ function RenderDonationsPane(props: {
generateDonationReceiptBlob={props.generateDonationReceiptBlob}
showToast={props.showToast}
theme={ThemeType.light}
updateLastError={action('updateLastError')}
/>
);
}
@ -351,11 +358,21 @@ export default {
whoCanSeeMe: PhoneNumberSharingMode.Everybody,
zoomFactor: 1,
renderDonationsPane: () =>
RenderDonationsPane({
renderDonationsPane: ({
contentsRef,
page,
setPage,
}: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
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<PropsType> = args => {
const [page, setPage] = useState(args.page);
return <Preferences {...args} page={page} setPage={setPage} />;
return (
<Preferences
{...args}
page={page}
setPage={(newPage: SettingsPage, profilePage?: ProfileEditorPage) => {
// 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<HTMLDivElement | null>;
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' };

View file

@ -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<T = string | number> = (value: T) => unknown;
@ -210,7 +211,7 @@ type PropsFunctionType = {
renderDonationsPane: (options: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage;
setPage: (page: SettingsPage) => void;
setPage: (page: SettingsPage, profilePage?: ProfileEditorPage) => void;
}) => JSX.Element;
renderProfileEditor: (options: {
contentsRef: MutableRefObject<HTMLDivElement | null>;

View file

@ -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<string>;
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<CardNumberError | null>(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;

View file

@ -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(
() => (
<DonationHero
@ -487,21 +513,87 @@ export function PreferencesDonations({
return null;
}
let dialog: ReactNode | undefined;
if (lastError) {
dialog = (
<DonationErrorModal
errorType={lastError}
i18n={i18n}
onClose={() => {
updateLastError(undefined);
}}
/>
);
} else if (workflow?.type === donationStateSchema.Enum.INTENT_REDIRECT) {
dialog = (
<DonationVerificationModal
i18n={i18n}
onCancelDonation={() => {
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 = (
<DonationStillProcessingModal
i18n={i18n}
onClose={() => {
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 = (
<DonationProgressModal
i18n={i18n}
onWaitedTooLong={() => setHasProcessingExpired(true)}
/>
);
}
}
let content;
if (page === SettingsPage.DonationsDonateFlow) {
// DonateFlow has to control Back button to switch between CC form and Amount picker
return (
<PreferencesDonateFlow
contentsRef={contentsRef}
i18n={i18n}
donationAmountsConfig={donationAmountsConfig}
validCurrencies={validCurrencies}
workflow={workflow}
clearWorkflow={clearWorkflow}
renderDonationHero={renderDonationHero}
submitDonation={submitDonation}
onBack={() => setPage(SettingsPage.Donations)}
/>
<>
{dialog}
<PreferencesDonateFlow
contentsRef={contentsRef}
i18n={i18n}
donationAmountsConfig={donationAmountsConfig}
lastError={lastError}
validCurrencies={validCurrencies}
workflow={workflow}
clearWorkflow={clearWorkflow}
renderDonationHero={renderDonationHero}
submitDonation={details => {
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 (
<PreferencesContent
backButton={backButton}
contents={content}
contentsRef={contentsRef}
title={title}
/>
<>
{dialog}
<PreferencesContent
backButton={backButton}
contents={content}
contentsRef={contentsRef}
title={title}
/>
</>
);
}

View file

@ -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:

View file

@ -280,6 +280,14 @@ export function renderToast({
);
}
if (toastType === ToastType.DonationCancelled) {
return (
<Toast onClose={hideToast}>
{i18n('icu:Donations__Toast__Cancelled')}
</Toast>
);
}
if (toastType === ToastType.DonationCompleted) {
return (
<Toast
@ -328,6 +336,43 @@ export function renderToast({
);
}
if (
toastType === ToastType.DonationError ||
toastType === ToastType.DonationVerificationFailed ||
toastType === ToastType.DonationVerificationNeeded
) {
const mapping = {
[ToastType.DonationError]: i18n('icu:Donations__Toast__Error'),
[ToastType.DonationVerificationFailed]: i18n(
'icu:Donations__Toast__VerificationFailed'
),
[ToastType.DonationVerificationNeeded]: i18n(
'icu:Donations__Toast__VerificationNeeded'
),
};
const text = mapping[toastType];
return (
<Toast
onClose={hideToast}
toastAction={{
label: i18n('icu:view'),
onClick: () => {
changeLocation({
tab: NavTab.Settings,
details: {
page: SettingsPage.Donations,
},
});
},
}}
>
{text}
</Toast>
);
}
if (toastType === ToastType.Error) {
return (
<Toast

View file

@ -300,6 +300,14 @@ export async function _runDonationWorkflow(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
}
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(

View file

@ -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<DonationReceipt>;
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));
}

View file

@ -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}

View file

@ -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

View file

@ -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 }

View file

@ -126,8 +126,8 @@ window.testUtilities = {
stories: [],
storyDistributionLists: [],
donations: {
didResumeWorkflowAtStartup: false,
currentWorkflow: undefined,
didResumeWorkflowAtStartup: false,
lastError: undefined,
receipts: [],
},