817 lines
25 KiB
TypeScript
817 lines
25 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { groupBy, sortBy } from 'lodash';
|
|
|
|
import type { MutableRefObject, ReactNode } from 'react';
|
|
import { ListBox, ListBoxItem } from 'react-aria-components';
|
|
import { getDateTimeFormatter } from '../util/formatTimestamp.js';
|
|
|
|
import type { LocalizerType, ThemeType } from '../types/Util.js';
|
|
import { PreferencesContent } from './Preferences.js';
|
|
import { SettingsPage } from '../types/Nav.js';
|
|
import { PreferencesDonateFlow } from './PreferencesDonateFlow.js';
|
|
import type {
|
|
DonationWorkflow,
|
|
DonationReceipt,
|
|
OneTimeDonationHumanAmounts,
|
|
DonationErrorType,
|
|
} from '../types/Donations.js';
|
|
import {
|
|
donationErrorTypeSchema,
|
|
donationStateSchema,
|
|
} from '../types/Donations.js';
|
|
import type { AvatarColorType } from '../types/Colors.js';
|
|
import { Button, ButtonSize, ButtonVariant } from './Button.js';
|
|
import { Modal } from './Modal.js';
|
|
import { Spinner } from './Spinner.js';
|
|
import type { AnyToast } from '../types/Toast.js';
|
|
import { ToastType } from '../types/Toast.js';
|
|
import { createLogger } from '../logging/log.js';
|
|
import { toLogFormat } from '../types/errors.js';
|
|
import { I18n } from './I18n.js';
|
|
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser.js';
|
|
import { DonationPrivacyInformationModal } from './DonationPrivacyInformationModal.js';
|
|
import type { SubmitDonationType } from '../state/ducks/donations.js';
|
|
import {
|
|
getHumanDonationAmount,
|
|
toHumanCurrencyString,
|
|
} from '../util/currency.js';
|
|
import { Avatar, AvatarSize } from './Avatar.js';
|
|
import type { BadgeType } from '../badges/types.js';
|
|
import { DonationInterruptedModal } from './DonationInterruptedModal.js';
|
|
import { DonationErrorModal } from './DonationErrorModal.js';
|
|
import { DonationVerificationModal } from './DonationVerificationModal.js';
|
|
import { DonationProgressModal } from './DonationProgressModal.js';
|
|
import { DonationStillProcessingModal } from './DonationStillProcessingModal.js';
|
|
import { DonationThanksModal } from './DonationThanksModal.js';
|
|
import type {
|
|
ConversationType,
|
|
ProfileDataType,
|
|
} from '../state/ducks/conversations.js';
|
|
import type { AvatarUpdateOptionsType } from '../types/Avatar.js';
|
|
import { drop } from '../util/drop.js';
|
|
import { DonationsOfflineTooltip } from './conversation/DonationsOfflineTooltip.js';
|
|
import { getInProgressDonation } from '../util/donations.js';
|
|
|
|
const log = createLogger('PreferencesDonations');
|
|
|
|
type PropsExternalType = {
|
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
|
};
|
|
|
|
export type PropsDataType = {
|
|
i18n: LocalizerType;
|
|
initialCurrency: string;
|
|
isOnline: boolean;
|
|
page: SettingsPage;
|
|
didResumeWorkflowAtStartup: boolean;
|
|
lastError: DonationErrorType | undefined;
|
|
workflow: DonationWorkflow | undefined;
|
|
badge: BadgeType | undefined;
|
|
color: AvatarColorType | undefined;
|
|
firstName: string | undefined;
|
|
profileAvatarUrl?: string;
|
|
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
|
|
validCurrencies: ReadonlyArray<string>;
|
|
donationReceipts: ReadonlyArray<DonationReceipt>;
|
|
theme: ThemeType;
|
|
saveAttachmentToDisk: (options: {
|
|
data: Uint8Array;
|
|
name: string;
|
|
baseDir?: string | undefined;
|
|
}) => Promise<{ fullPath: string; name: string } | null>;
|
|
generateDonationReceiptBlob: (
|
|
receipt: DonationReceipt,
|
|
i18n: LocalizerType
|
|
) => Promise<Blob>;
|
|
showToast: (toast: AnyToast) => void;
|
|
donationBadge: BadgeType | undefined;
|
|
fetchBadgeData: () => Promise<BadgeType | undefined>;
|
|
me: ConversationType;
|
|
myProfileChanged: (
|
|
profileData: ProfileDataType,
|
|
avatarUpdateOptions: AvatarUpdateOptionsType
|
|
) => void;
|
|
};
|
|
|
|
type PropsActionType = {
|
|
applyDonationBadge: (args: {
|
|
badge: BadgeType | undefined;
|
|
applyBadge: boolean;
|
|
onComplete: (error?: Error) => void;
|
|
}) => void;
|
|
clearWorkflow: () => void;
|
|
resumeWorkflow: () => void;
|
|
setPage: (page: SettingsPage) => void;
|
|
showToast: (toast: AnyToast) => void;
|
|
submitDonation: (payload: SubmitDonationType) => void;
|
|
updateLastError: (error: DonationErrorType | undefined) => void;
|
|
};
|
|
|
|
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
|
|
|
|
type DonationPage =
|
|
| SettingsPage.Donations
|
|
| SettingsPage.DonationsDonateFlow
|
|
| SettingsPage.DonationsReceiptList;
|
|
|
|
type PreferencesHomeProps = Pick<
|
|
PropsType,
|
|
| 'contentsRef'
|
|
| 'i18n'
|
|
| 'setPage'
|
|
| 'isOnline'
|
|
| 'donationReceipts'
|
|
| 'workflow'
|
|
> & {
|
|
navigateToPage: (newPage: SettingsPage) => void;
|
|
renderDonationHero: () => JSX.Element;
|
|
};
|
|
|
|
function isDonationPage(page: SettingsPage): page is DonationPage {
|
|
return (
|
|
page === SettingsPage.Donations ||
|
|
page === SettingsPage.DonationsDonateFlow ||
|
|
page === SettingsPage.DonationsReceiptList
|
|
);
|
|
}
|
|
|
|
type DonationHeroProps = Pick<
|
|
PropsDataType,
|
|
'badge' | 'color' | 'firstName' | 'i18n' | 'profileAvatarUrl' | 'theme'
|
|
> & {
|
|
showPrivacyModal: () => void;
|
|
};
|
|
|
|
function DonationHero({
|
|
badge,
|
|
color,
|
|
firstName,
|
|
i18n,
|
|
profileAvatarUrl,
|
|
theme,
|
|
showPrivacyModal,
|
|
}: DonationHeroProps): JSX.Element {
|
|
const privacyReadMoreLink = useCallback(
|
|
(parts: ReactNode): JSX.Element => {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="PreferencesDonations__description__read-more"
|
|
onClick={showPrivacyModal}
|
|
>
|
|
{parts}
|
|
</button>
|
|
);
|
|
},
|
|
[showPrivacyModal]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="PreferencesDonations__avatar">
|
|
<Avatar
|
|
avatarUrl={profileAvatarUrl}
|
|
badge={badge}
|
|
color={color}
|
|
conversationType="direct"
|
|
title={firstName ?? ''}
|
|
i18n={i18n}
|
|
sharedGroupNames={[]}
|
|
size={AvatarSize.SEVENTY_TWO}
|
|
theme={theme}
|
|
/>
|
|
</div>
|
|
<div className="PreferencesDonations__title">
|
|
{i18n('icu:PreferencesDonations__title')}
|
|
</div>
|
|
<div className="PreferencesDonations__description">
|
|
<I18n
|
|
components={{
|
|
readMoreLink: privacyReadMoreLink,
|
|
}}
|
|
i18n={i18n}
|
|
id="icu:PreferencesDonations__description"
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function DonationsHome({
|
|
i18n,
|
|
renderDonationHero,
|
|
navigateToPage,
|
|
setPage,
|
|
isOnline,
|
|
donationReceipts,
|
|
workflow,
|
|
}: PreferencesHomeProps): JSX.Element {
|
|
const [isInProgressModalVisible, setIsInProgressVisible] = useState(false);
|
|
|
|
const inProgressDonationAmount = useMemo<string | undefined>(() => {
|
|
const inProgressDonation = getInProgressDonation(workflow);
|
|
return inProgressDonation
|
|
? toHumanCurrencyString(inProgressDonation)
|
|
: undefined;
|
|
}, [workflow]);
|
|
|
|
const handleDonateButtonClicked = useCallback(() => {
|
|
if (inProgressDonationAmount) {
|
|
setIsInProgressVisible(true);
|
|
} else {
|
|
setPage(SettingsPage.DonationsDonateFlow);
|
|
}
|
|
}, [inProgressDonationAmount, setPage]);
|
|
|
|
const handleInProgressDonationClicked = useCallback(() => {
|
|
setIsInProgressVisible(true);
|
|
}, []);
|
|
|
|
const hasReceipts = donationReceipts.length > 0;
|
|
|
|
const donateButton = (
|
|
<Button
|
|
className="PreferencesDonations__PrimaryButton PreferencesDonations__donate-button"
|
|
disabled={!isOnline}
|
|
variant={isOnline ? ButtonVariant.Primary : ButtonVariant.Secondary}
|
|
size={ButtonSize.Medium}
|
|
onClick={handleDonateButtonClicked}
|
|
>
|
|
{i18n('icu:PreferencesDonations__donate-button')}
|
|
</Button>
|
|
);
|
|
|
|
return (
|
|
<div className="PreferencesDonations">
|
|
{isInProgressModalVisible && (
|
|
<DonationStillProcessingModal
|
|
i18n={i18n}
|
|
onClose={() => setIsInProgressVisible(false)}
|
|
/>
|
|
)}
|
|
|
|
{renderDonationHero()}
|
|
|
|
{isOnline ? (
|
|
donateButton
|
|
) : (
|
|
<DonationsOfflineTooltip i18n={i18n}>
|
|
{donateButton}
|
|
</DonationsOfflineTooltip>
|
|
)}
|
|
|
|
<hr className="PreferencesDonations__separator" />
|
|
|
|
{(hasReceipts || inProgressDonationAmount) && (
|
|
<div className="PreferencesDonations__section-header PreferencesDonations__section-header--my-support">
|
|
{i18n('icu:PreferencesDonations__my-support')}
|
|
</div>
|
|
)}
|
|
|
|
{inProgressDonationAmount && (
|
|
<ListBox className="PreferencesDonations__badge-list">
|
|
<ListBoxItem
|
|
className="PreferencesDonations__badge"
|
|
onAction={handleInProgressDonationClicked}
|
|
>
|
|
<div className="PreferencesDonations__badge-icon PreferencesDonations__badge-icon--one-time" />
|
|
<div className="PreferencesDonations__badge-info">
|
|
<div className="PreferencesDonations__badge-label">
|
|
{i18n('icu:PreferencesDonations__badge-label-one-time', {
|
|
formattedCurrencyAmount: inProgressDonationAmount,
|
|
})}
|
|
</div>
|
|
<div className="PreferencesDonations__badge-processing-info">
|
|
{i18n('icu:PreferencesDonations__badge-processing-donation')}
|
|
</div>
|
|
</div>
|
|
</ListBoxItem>
|
|
</ListBox>
|
|
)}
|
|
|
|
<ListBox className="PreferencesDonations__list">
|
|
{hasReceipts && (
|
|
<ListBoxItem
|
|
className="PreferencesDonations__list-item"
|
|
onAction={() => {
|
|
navigateToPage(SettingsPage.DonationsReceiptList);
|
|
}}
|
|
>
|
|
<span className="PreferencesDonations__list-item__icon PreferencesDonations__list-item__icon--receipts" />
|
|
<span className="PreferencesDonations__list-item__text">
|
|
{i18n('icu:PreferencesDonations__receipts')}
|
|
</span>
|
|
<span className="PreferencesDonations__list-item__chevron" />
|
|
</ListBoxItem>
|
|
)}
|
|
<ListBoxItem
|
|
className="PreferencesDonations__list-item"
|
|
onAction={() => {
|
|
openLinkInWebBrowser(
|
|
'https://support.signal.org/hc/articles/360031949872-Donor-FAQs'
|
|
);
|
|
}}
|
|
>
|
|
<span className="PreferencesDonations__list-item__icon PreferencesDonations__list-item__icon--faqs" />
|
|
<span className="PreferencesDonations__list-item__text">
|
|
{i18n('icu:PreferencesDonations__faqs')}
|
|
</span>
|
|
<span className="PreferencesDonations__list-item__open" />
|
|
</ListBoxItem>
|
|
</ListBox>
|
|
|
|
<div className="PreferencesDonations__mobile-info">
|
|
{i18n('icu:PreferencesDonations__mobile-info')}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PreferencesReceiptList({
|
|
i18n,
|
|
donationReceipts,
|
|
saveAttachmentToDisk,
|
|
generateDonationReceiptBlob,
|
|
showToast,
|
|
}: {
|
|
i18n: LocalizerType;
|
|
donationReceipts: ReadonlyArray<DonationReceipt>;
|
|
saveAttachmentToDisk: (options: {
|
|
data: Uint8Array;
|
|
name: string;
|
|
baseDir?: string | undefined;
|
|
}) => Promise<{ fullPath: string; name: string } | null>;
|
|
generateDonationReceiptBlob: (
|
|
receipt: DonationReceipt,
|
|
i18n: LocalizerType
|
|
) => Promise<Blob>;
|
|
showToast: (toast: AnyToast) => void;
|
|
}): JSX.Element {
|
|
const [selectedReceipt, setSelectedReceipt] =
|
|
useState<DonationReceipt | null>(null);
|
|
const [isDownloading, setIsDownloading] = useState(false);
|
|
|
|
const hasReceipts = useMemo(
|
|
() => donationReceipts.length > 0,
|
|
[donationReceipts]
|
|
);
|
|
|
|
const receiptsByYear = useMemo(() => {
|
|
const sortedReceipts = sortBy(
|
|
donationReceipts,
|
|
receipt => -receipt.timestamp
|
|
);
|
|
return groupBy(sortedReceipts, receipt =>
|
|
new Date(receipt.timestamp).getFullYear()
|
|
);
|
|
}, [donationReceipts]);
|
|
|
|
const handleDownloadReceipt = useCallback(async () => {
|
|
if (!selectedReceipt) {
|
|
return;
|
|
}
|
|
|
|
setIsDownloading(true);
|
|
try {
|
|
const blob = await generateDonationReceiptBlob(selectedReceipt, i18n);
|
|
const buffer = await blob.arrayBuffer();
|
|
|
|
const result = await saveAttachmentToDisk({
|
|
name: `Signal_Receipt_${new Date(selectedReceipt.timestamp).toISOString().split('T')[0]}.png`,
|
|
data: new Uint8Array(buffer),
|
|
});
|
|
|
|
if (result) {
|
|
setSelectedReceipt(null);
|
|
showToast({
|
|
toastType: ToastType.ReceiptSaved,
|
|
parameters: { fullPath: result.fullPath },
|
|
});
|
|
}
|
|
} catch (error) {
|
|
log.error('Failed to generate receipt: ', toLogFormat(error));
|
|
showToast({
|
|
toastType: ToastType.ReceiptSaveFailed,
|
|
});
|
|
} finally {
|
|
setIsDownloading(false);
|
|
}
|
|
}, [
|
|
selectedReceipt,
|
|
generateDonationReceiptBlob,
|
|
i18n,
|
|
saveAttachmentToDisk,
|
|
showToast,
|
|
]);
|
|
|
|
const dateFormatter = getDateTimeFormatter({
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
});
|
|
|
|
return (
|
|
<div className="PreferencesDonations PreferencesDonations--receiptList">
|
|
{hasReceipts ? (
|
|
<>
|
|
<div className="PreferencesDonations--receiptList__info">
|
|
<div className="PreferencesDonations--receiptList__info__text">
|
|
{i18n('icu:PreferencesDonations--receiptList__info')}
|
|
</div>
|
|
</div>
|
|
|
|
{Object.entries(receiptsByYear).map(([year, receipts]) => (
|
|
<div
|
|
key={year}
|
|
className="PreferencesDonations--receiptList-yearContainer"
|
|
>
|
|
<div className="PreferencesDonations--receiptList__year-header">
|
|
{year}
|
|
</div>
|
|
<div className="PreferencesDonations--receiptList__list">
|
|
{receipts.map(receipt => (
|
|
<button
|
|
aria-label={i18n(
|
|
'icu:PreferencesDonations__receipt-details-button-aria'
|
|
)}
|
|
key={receipt.id}
|
|
className="PreferencesDonations--receiptList__receipt-item"
|
|
onClick={() => setSelectedReceipt(receipt)}
|
|
type="button"
|
|
>
|
|
<div className="PreferencesDonations--receiptList__receipt-item__icon" />
|
|
<div className="PreferencesDonations--receiptList__receipt-item__details">
|
|
<div className="PreferencesDonations--receiptList__receipt-item__date">
|
|
{dateFormatter.format(new Date(receipt.timestamp))}
|
|
</div>
|
|
<div className="PreferencesDonations--receiptList__receipt-item__type">
|
|
{i18n('icu:DonationReceipt__type-value--one-time')}
|
|
</div>
|
|
</div>
|
|
<div className="PreferencesDonations--receiptList__receipt-item__amount">
|
|
{toHumanCurrencyString({
|
|
amount: getHumanDonationAmount(receipt),
|
|
currency: receipt.currencyType,
|
|
})}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
) : (
|
|
<div className="PreferencesDonations--receiptList__empty-state">
|
|
<div className="PreferencesDonations--receiptList__empty-state__title">
|
|
{i18n('icu:PreferencesDonations--receiptList__empty-title')}
|
|
</div>
|
|
<div className="PreferencesDonations--receiptList__empty-state__description">
|
|
{i18n('icu:PreferencesDonations--receiptList__info')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedReceipt && (
|
|
<Modal
|
|
i18n={i18n}
|
|
modalName="ReceiptDetailsModal"
|
|
moduleClassName="PreferencesDonations__ReceiptModal"
|
|
hasXButton
|
|
padded={false}
|
|
onClose={() => setSelectedReceipt(null)}
|
|
modalFooter={
|
|
<Button
|
|
variant={ButtonVariant.Primary}
|
|
size={ButtonSize.Small}
|
|
onClick={handleDownloadReceipt}
|
|
disabled={isDownloading}
|
|
>
|
|
{isDownloading ? (
|
|
<Spinner size="24px" svgSize="small" />
|
|
) : (
|
|
i18n('icu:PreferencesDonations__ReceiptModal--download')
|
|
)}
|
|
</Button>
|
|
}
|
|
>
|
|
<div className="PreferencesDonations__ReceiptModal__content">
|
|
<div className="PreferencesDonations__ReceiptModal__logo-container">
|
|
<div className="PreferencesDonations__ReceiptModal__logo" />
|
|
</div>
|
|
<div className="PreferencesDonations__ReceiptModal__amount">
|
|
{toHumanCurrencyString({
|
|
amount: getHumanDonationAmount(selectedReceipt),
|
|
currency: selectedReceipt.currencyType,
|
|
})}
|
|
</div>
|
|
<hr className="PreferencesDonations__ReceiptModal__separator" />
|
|
<div className="PreferencesDonations__ReceiptModal__details">
|
|
<div className="PreferencesDonations__ReceiptModal__detail-item">
|
|
<div className="PreferencesDonations__ReceiptModal__detail-label">
|
|
{i18n('icu:PreferencesDonations__ReceiptModal--type-label')}
|
|
</div>
|
|
<div className="PreferencesDonations__ReceiptModal__detail-value">
|
|
{i18n('icu:DonationReceipt__type-value--one-time')}
|
|
</div>
|
|
</div>
|
|
<div className="PreferencesDonations__ReceiptModal__detail-item">
|
|
<div className="PreferencesDonations__ReceiptModal__detail-label">
|
|
{i18n(
|
|
'icu:PreferencesDonations__ReceiptModal--date-paid-label'
|
|
)}
|
|
</div>
|
|
<div className="PreferencesDonations__ReceiptModal__detail-value">
|
|
{dateFormatter.format(new Date(selectedReceipt.timestamp))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PreferencesDonations({
|
|
contentsRef,
|
|
i18n,
|
|
initialCurrency,
|
|
isOnline,
|
|
page,
|
|
workflow,
|
|
didResumeWorkflowAtStartup,
|
|
lastError,
|
|
applyDonationBadge,
|
|
clearWorkflow,
|
|
resumeWorkflow,
|
|
setPage,
|
|
submitDonation,
|
|
badge,
|
|
color,
|
|
firstName,
|
|
profileAvatarUrl,
|
|
donationAmountsConfig,
|
|
validCurrencies,
|
|
donationReceipts,
|
|
theme,
|
|
saveAttachmentToDisk,
|
|
generateDonationReceiptBlob,
|
|
showToast,
|
|
updateLastError,
|
|
donationBadge,
|
|
fetchBadgeData,
|
|
}: PropsType): JSX.Element | null {
|
|
const [hasProcessingExpired, setHasProcessingExpired] = useState(false);
|
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
|
|
const [isPrivacyModalVisible, setIsPrivacyModalVisible] = useState(false);
|
|
|
|
// Fetch badge data when we're about to show the badge modal
|
|
useEffect(() => {
|
|
if (
|
|
workflow?.type === donationStateSchema.Enum.DONE &&
|
|
page === SettingsPage.Donations &&
|
|
!donationBadge
|
|
) {
|
|
drop(fetchBadgeData());
|
|
}
|
|
}, [workflow, page, donationBadge, fetchBadgeData]);
|
|
|
|
const navigateToPage = useCallback(
|
|
(newPage: SettingsPage) => {
|
|
setPage(newPage);
|
|
},
|
|
[setPage]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (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
|
|
badge={badge}
|
|
color={color}
|
|
firstName={firstName}
|
|
i18n={i18n}
|
|
profileAvatarUrl={profileAvatarUrl}
|
|
theme={theme}
|
|
showPrivacyModal={() => setIsPrivacyModalVisible(true)}
|
|
/>
|
|
),
|
|
[badge, color, firstName, i18n, profileAvatarUrl, theme]
|
|
);
|
|
|
|
if (!isDonationPage(page)) {
|
|
return null;
|
|
}
|
|
|
|
let dialog: ReactNode | undefined;
|
|
if (lastError) {
|
|
dialog = (
|
|
<DonationErrorModal
|
|
errorType={lastError}
|
|
i18n={i18n}
|
|
onClose={() => {
|
|
setIsSubmitted(false);
|
|
if (
|
|
workflow?.type === 'DONE' &&
|
|
lastError === donationErrorTypeSchema.Enum.BadgeApplicationFailed
|
|
) {
|
|
clearWorkflow();
|
|
}
|
|
updateLastError(undefined);
|
|
}}
|
|
/>
|
|
);
|
|
} else if (
|
|
didResumeWorkflowAtStartup &&
|
|
workflow?.type === donationStateSchema.Enum.INTENT_METHOD
|
|
) {
|
|
dialog = (
|
|
<DonationInterruptedModal
|
|
i18n={i18n}
|
|
onCancelDonation={() => {
|
|
clearWorkflow();
|
|
setPage(SettingsPage.Donations);
|
|
showToast({ toastType: ToastType.DonationCanceled });
|
|
}}
|
|
onRetryDonation={() => {
|
|
resumeWorkflow();
|
|
}}
|
|
/>
|
|
);
|
|
} else if (workflow?.type === donationStateSchema.Enum.INTENT_REDIRECT) {
|
|
dialog = (
|
|
<DonationVerificationModal
|
|
i18n={i18n}
|
|
onCancelDonation={() => {
|
|
clearWorkflow();
|
|
setPage(SettingsPage.Donations);
|
|
showToast({ toastType: ToastType.DonationCanceled });
|
|
}}
|
|
onOpenBrowser={() => {
|
|
openLinkInWebBrowser(workflow.redirectTarget);
|
|
}}
|
|
onTimedOut={() => {
|
|
clearWorkflow();
|
|
updateLastError(donationErrorTypeSchema.Enum.TimedOut);
|
|
setPage(SettingsPage.Donations);
|
|
}}
|
|
/>
|
|
);
|
|
} else if (workflow?.type === donationStateSchema.Enum.DONE) {
|
|
dialog = (
|
|
<DonationThanksModal
|
|
i18n={i18n}
|
|
badge={donationBadge}
|
|
applyDonationBadge={applyDonationBadge}
|
|
onClose={(error?: Error) => {
|
|
if (error) {
|
|
log.error('Badge application failed:', error.message);
|
|
updateLastError(
|
|
donationErrorTypeSchema.Enum.BadgeApplicationFailed
|
|
);
|
|
} else {
|
|
clearWorkflow();
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
} 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)}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
const privacyModal = isPrivacyModalVisible ? (
|
|
<DonationPrivacyInformationModal
|
|
i18n={i18n}
|
|
onClose={() => setIsPrivacyModalVisible(false)}
|
|
/>
|
|
) : null;
|
|
|
|
let content;
|
|
if (page === SettingsPage.DonationsDonateFlow) {
|
|
// DonateFlow has to control Back button to switch between CC form and Amount picker
|
|
return (
|
|
<>
|
|
{dialog}
|
|
{privacyModal}
|
|
<PreferencesDonateFlow
|
|
contentsRef={contentsRef}
|
|
i18n={i18n}
|
|
isOnline={isOnline}
|
|
initialCurrency={initialCurrency}
|
|
donationAmountsConfig={donationAmountsConfig}
|
|
lastError={lastError}
|
|
validCurrencies={validCurrencies}
|
|
workflow={workflow}
|
|
clearWorkflow={clearWorkflow}
|
|
renderDonationHero={renderDonationHero}
|
|
submitDonation={details => {
|
|
setIsSubmitted(true);
|
|
submitDonation(details);
|
|
}}
|
|
showPrivacyModal={() => setIsPrivacyModalVisible(true)}
|
|
onBack={() => setPage(SettingsPage.Donations)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
if (page === SettingsPage.Donations) {
|
|
content = (
|
|
<DonationsHome
|
|
contentsRef={contentsRef}
|
|
i18n={i18n}
|
|
isOnline={isOnline}
|
|
navigateToPage={navigateToPage}
|
|
donationReceipts={donationReceipts}
|
|
renderDonationHero={renderDonationHero}
|
|
setPage={setPage}
|
|
workflow={workflow}
|
|
/>
|
|
);
|
|
} else if (page === SettingsPage.DonationsReceiptList) {
|
|
content = (
|
|
<PreferencesReceiptList
|
|
i18n={i18n}
|
|
donationReceipts={donationReceipts}
|
|
saveAttachmentToDisk={saveAttachmentToDisk}
|
|
generateDonationReceiptBlob={generateDonationReceiptBlob}
|
|
showToast={showToast}
|
|
/>
|
|
);
|
|
}
|
|
|
|
let title: string | undefined;
|
|
let backButton: JSX.Element | undefined;
|
|
if (page === SettingsPage.Donations) {
|
|
title = i18n('icu:Preferences__DonateTitle');
|
|
} else if (page === SettingsPage.DonationsReceiptList) {
|
|
title = i18n('icu:PreferencesDonations__receipts');
|
|
backButton = (
|
|
<button
|
|
aria-label={i18n('icu:goBack')}
|
|
className="Preferences__back-icon"
|
|
onClick={() => setPage(SettingsPage.Donations)}
|
|
type="button"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{dialog}
|
|
{privacyModal}
|
|
<PreferencesContent
|
|
backButton={backButton}
|
|
contents={content}
|
|
contentsRef={contentsRef}
|
|
title={title}
|
|
/>
|
|
</>
|
|
);
|
|
}
|