mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 20:26:24 +00:00
Add in progress donation placeholder badge to donations home
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
41
ts/util/donations.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user