Add in progress donation placeholder badge to donations home

This commit is contained in:
ayumi-signal
2025-08-12 13:49:40 -07:00
committed by GitHub
parent 509777e9a8
commit ae3e7cfc41
5 changed files with 216 additions and 8 deletions

View File

@@ -8850,6 +8850,14 @@
"messageformat": "My Support",
"description": "Section header for the user's current donations"
},
"icu:PreferencesDonations__badge-label-one-time": {
"messageformat": "{formattedCurrencyAmount} one-time",
"description": "Label next to the in-app badge associated with a donation amount and type. Amount includes the currency symbol and is formatted in the locale's standard format. The hyphen between one-time is optional depending on language norms. Examples: $10 one-time; ¥1000 one-time; €10 one-time"
},
"icu:PreferencesDonations__badge-processing-donation": {
"messageformat": "Processing donation...",
"description": "Label next to the in-app badge associated with a donation while the donation is still processing and the badge is not yet available to the user."
},
"icu:PreferencesDonations__donate-button-with-amount": {
"messageformat": "Donate {formattedCurrencyAmount}",
"description": "Button text to make a donation after selecting a currency amount. Amount includes the currency symbol and is formatted in the locale's standard format. Examples: Donate $10; Donate ¥1000; Donate €10"
@@ -9059,7 +9067,7 @@
"description": "Explainer text for donation progress dialog"
},
"icu:Donations__StillProcessing": {
"messageformat": "Still processing",
"messageformat": "Still processing donation",
"description": "Title of the dialog shown when the user has made a donation and it's taking some time to complete the process"
},
"icu:Donations__StillProcessing__Description": {

View File

@@ -420,3 +420,68 @@
border: 0.5px solid variables.$color-black-alpha-16;
border-radius: 6px;
}
.PreferencesDonations__badge-list {
width: 100%;
margin-block: 4px 8px;
}
.PreferencesDonations__badge {
&:hover {
background: light-dark(variables.$color-gray-04, variables.$color-gray-80);
}
@include mixins.keyboard-mode {
&:focus {
outline: 2px solid variables.$color-ultramarine;
}
}
display: flex;
gap: 12px;
width: 100%;
height: 58px;
padding-block: 8px;
padding-inline: 12px 8px;
background: light-dark(variables.$color-gray-02, variables.$color-gray-85);
border: 0.5px solid;
border-color: light-dark(
variables.$color-black-alpha-12,
variables.$color-white-alpha-12
);
border-radius: 6px;
cursor: pointer;
}
.PreferencesDonations__badge-icon {
display: flex;
width: 40px;
height: 40px;
background-size: 100%;
// Currently only one time donations are possible
background-image: url('../images/rocket-160.svg');
}
.PreferencesDonations__badge-info {
display: flex;
flex-direction: column;
align-self: center;
align-items: start;
}
.PreferencesDonations__badge-label {
@include mixins.font-body-1;
color: light-dark(
variables.$color-black-alpha-85,
variables.$color-white-alpha-85
);
}
.PreferencesDonations__badge-processing-info {
@include mixins.font-body-small;
color: light-dark(
variables.$color-black-alpha-50,
variables.$color-white-alpha-55
);
}

View File

@@ -35,6 +35,7 @@ import type { WidthBreakpoint } from './_util';
import type { MessageAttributesType } from '../model-types';
import type {
DonationReceipt,
DonationWorkflow,
OneTimeDonationHumanAmounts,
} from '../types/Donations';
import type { AnyToast } from '../types/Toast';
@@ -212,6 +213,7 @@ function renderDonationsPane(props: {
i18n: LocalizerType
) => Promise<Blob>;
showToast: (toast: AnyToast) => void;
workflow?: DonationWorkflow;
}): JSX.Element {
return (
<PreferencesDonations
@@ -226,7 +228,7 @@ function renderDonationsPane(props: {
setPage={props.setPage}
submitDonation={action('submitDonation')}
lastError={undefined}
workflow={undefined}
workflow={props.workflow}
didResumeWorkflowAtStartup={false}
badge={undefined}
color={props.me.color}
@@ -625,6 +627,43 @@ DonationReceipts.args = {
showToast: action('showToast'),
}),
};
export const DonationsHomeWithInProgressDonation = Template.bind({});
DonationsHomeWithInProgressDonation.args = {
donationsFeatureEnabled: true,
page: SettingsPage.Donations,
renderDonationsPane: ({
contentsRef,
}: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
}) =>
renderDonationsPane({
contentsRef,
me,
donationReceipts: [],
page: SettingsPage.Donations,
setPage: action('setPage'),
saveAttachmentToDisk: async () => {
action('saveAttachmentToDisk')();
return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
},
generateDonationReceiptBlob: async () => {
action('generateDonationReceiptBlob')();
return new Blob();
},
showToast: action('showToast'),
workflow: {
type: 'INTENT_METHOD',
timestamp: Date.now() - 60,
paymentMethodId: 'a',
paymentAmount: 500,
currencyType: 'USD',
clientSecret: 'a',
paymentIntentId: 'a',
id: 'a',
returnToken: 'a',
},
}),
};
export const Internal = Template.bind({});
Internal.args = {
page: SettingsPage.Internal,

View File

@@ -1,7 +1,7 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { groupBy, sortBy } from 'lodash';
import type { MutableRefObject, ReactNode } from 'react';
@@ -34,7 +34,10 @@ import { I18n } from './I18n';
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
import { DonationPrivacyInformationModal } from './DonationPrivacyInformationModal';
import type { SubmitDonationType } from '../state/ducks/donations';
import { getHumanDonationAmount } from '../util/currency';
import {
getHumanDonationAmount,
toHumanCurrencyString,
} from '../util/currency';
import { Avatar, AvatarSize } from './Avatar';
import type { BadgeType } from '../badges/types';
import { DonationInterruptedModal } from './DonationInterruptedModal';
@@ -43,6 +46,7 @@ import { DonationVerificationModal } from './DonationVerificationModal';
import { DonationProgressModal } from './DonationProgressModal';
import { DonationStillProcessingModal } from './DonationStillProcessingModal';
import { DonationsOfflineTooltip } from './conversation/DonationsOfflineTooltip';
import { getInProgressDonation } from '../util/donations';
const log = createLogger('PreferencesDonations');
@@ -102,6 +106,7 @@ type PreferencesHomeProps = Pick<
| 'isOnline'
| 'isStaging'
| 'donationReceipts'
| 'workflow'
> & {
navigateToPage: (newPage: SettingsPage) => void;
renderDonationHero: () => JSX.Element;
@@ -185,7 +190,29 @@ function DonationsHome({
isOnline,
isStaging,
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 = (
@@ -194,9 +221,7 @@ function DonationsHome({
disabled={!isOnline}
variant={isOnline ? ButtonVariant.Primary : ButtonVariant.Secondary}
size={ButtonSize.Medium}
onClick={() => {
setPage(SettingsPage.DonationsDonateFlow);
}}
onClick={handleDonateButtonClicked}
>
{i18n('icu:PreferencesDonations__donate-button')}
</Button>
@@ -204,7 +229,15 @@ function DonationsHome({
return (
<div className="PreferencesDonations">
{isInProgressModalVisible && (
<DonationStillProcessingModal
i18n={i18n}
onClose={() => setIsInProgressVisible(false)}
/>
)}
{renderDonationHero()}
{isStaging && isOnline ? (
donateButton
) : (
@@ -215,12 +248,33 @@ function DonationsHome({
<hr className="PreferencesDonations__separator" />
{hasReceipts && (
{(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
@@ -666,6 +720,7 @@ export function PreferencesDonations({
isStaging={isStaging}
renderDonationHero={renderDonationHero}
setPage={setPage}
workflow={workflow}
/>
);
} else if (page === SettingsPage.DonationsReceiptList) {

41
ts/util/donations.ts Normal file
View File

@@ -0,0 +1,41 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { DonationWorkflow, HumanDonationAmount } from '../types/Donations';
import { donationStateSchema } from '../types/Donations';
import { brandStripeDonationAmount, toHumanDonationAmount } from './currency';
import { missingCaseError } from './missingCaseError';
// Donation where we started backend processing, but did not redeem a badge yet.
// Note we skip workflows in the INTENT state because it requires user confirmation
// to proceed.
export function getInProgressDonation(workflow: DonationWorkflow | undefined):
| {
amount: HumanDonationAmount;
currency: string;
}
| undefined {
if (workflow == null) {
return;
}
const { type } = workflow;
switch (type) {
case donationStateSchema.Enum.INTENT_METHOD:
case donationStateSchema.Enum.INTENT_REDIRECT:
case donationStateSchema.Enum.INTENT_CONFIRMED:
case donationStateSchema.Enum.RECEIPT: {
const { currencyType: currency, paymentAmount } = workflow;
const amount = brandStripeDonationAmount(paymentAmount);
return {
amount: toHumanDonationAmount({ amount, currency }),
currency,
};
}
case donationStateSchema.Enum.INTENT:
case donationStateSchema.Enum.DONE:
return;
default:
throw missingCaseError(type);
}
}