Donations: Show progress, error and verify dialogs
Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
parent
9c1b6ff01a
commit
47b4cb98a9
21 changed files with 400 additions and 103 deletions
|
@ -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",
|
||||
|
|
|
@ -11,3 +11,7 @@
|
|||
.module-Modal__title.DonationVerificationModal__title {
|
||||
@include mixins.font-title-medium;
|
||||
}
|
||||
|
||||
.DonationVerificationModal__body_inner {
|
||||
@include mixins.font-body-2;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -17,7 +17,7 @@ export default {
|
|||
|
||||
const defaultProps = {
|
||||
i18n,
|
||||
onClose: action('onClose'),
|
||||
onWaitedTooLong: action('onWaitedTooLong'),
|
||||
};
|
||||
|
||||
export function Default(): JSX.Element {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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')}
|
||||
>
|
||||
|
|
|
@ -17,7 +17,7 @@ export default {
|
|||
|
||||
const defaultProps = {
|
||||
i18n,
|
||||
onCancel: action('onCancel'),
|
||||
onCancelDonation: action('onCancelDonation'),
|
||||
onOpenBrowser: action('onOpenBrowser'),
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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' };
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -126,8 +126,8 @@ window.testUtilities = {
|
|||
stories: [],
|
||||
storyDistributionLists: [],
|
||||
donations: {
|
||||
didResumeWorkflowAtStartup: false,
|
||||
currentWorkflow: undefined,
|
||||
didResumeWorkflowAtStartup: false,
|
||||
lastError: undefined,
|
||||
receipts: [],
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue