Initial donation amount picker
Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
parent
233d5e6e28
commit
42bcec03cc
16 changed files with 855 additions and 186 deletions
|
@ -8822,6 +8822,10 @@
|
||||||
"messageformat": "Donate",
|
"messageformat": "Donate",
|
||||||
"description": "Button text to make a donation"
|
"description": "Button text to make a donation"
|
||||||
},
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
"icu:PreferencesDonations__mobile-info": {
|
"icu:PreferencesDonations__mobile-info": {
|
||||||
"messageformat": "Badges and monthly donations can be managed on your mobile device.",
|
"messageformat": "Badges and monthly donations can be managed on your mobile device.",
|
||||||
"description": "(Deleted 2025/07/09) Information about donations receipt syncing limitations"
|
"description": "(Deleted 2025/07/09) Information about donations receipt syncing limitations"
|
||||||
|
|
|
@ -15,7 +15,7 @@ const log = createLogger('parseBadgesFromServer');
|
||||||
|
|
||||||
const MAX_BADGES = 1000;
|
const MAX_BADGES = 1000;
|
||||||
|
|
||||||
const badgeFromServerSchema = z.object({
|
export const badgeFromServerSchema = z.object({
|
||||||
category: z.string(),
|
category: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
@ -27,7 +27,7 @@ const badgeFromServerSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /v1/subscription/configuration
|
// GET /v1/subscription/configuration
|
||||||
const boostBadgesFromServerSchema = z.object({
|
export const boostBadgesFromServerSchema = z.object({
|
||||||
levels: z.record(
|
levels: z.record(
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
|
|
|
@ -31,7 +31,10 @@ import type { WidthBreakpoint } from './_util';
|
||||||
import type { MessageAttributesType } from '../model-types';
|
import type { MessageAttributesType } from '../model-types';
|
||||||
import { PreferencesDonations } from './PreferencesDonations';
|
import { PreferencesDonations } from './PreferencesDonations';
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
import type { DonationReceipt } from '../types/Donations';
|
import type {
|
||||||
|
DonationReceipt,
|
||||||
|
OneTimeDonationHumanAmounts,
|
||||||
|
} from '../types/Donations';
|
||||||
import type { AnyToast } from '../types/Toast';
|
import type { AnyToast } from '../types/Toast';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
@ -106,6 +109,23 @@ const exportLocalBackupResult = {
|
||||||
snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169',
|
snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const donationAmountsConfig = {
|
||||||
|
jpy: {
|
||||||
|
minimum: 400,
|
||||||
|
oneTime: {
|
||||||
|
'1': [500, 1000, 2000, 3000, 5000, 10000],
|
||||||
|
'100': [500],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
usd: {
|
||||||
|
minimum: 3,
|
||||||
|
oneTime: {
|
||||||
|
1: [5, 10, 20, 30, 50, 100],
|
||||||
|
100: [5],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OneTimeDonationHumanAmounts;
|
||||||
|
|
||||||
function renderUpdateDialog(
|
function renderUpdateDialog(
|
||||||
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
|
@ -200,6 +220,8 @@ function RenderDonationsPane(props: {
|
||||||
color={props.me.color}
|
color={props.me.color}
|
||||||
firstName={props.me.firstName}
|
firstName={props.me.firstName}
|
||||||
profileAvatarUrl={props.me.profileAvatarUrl}
|
profileAvatarUrl={props.me.profileAvatarUrl}
|
||||||
|
donationAmountsConfig={donationAmountsConfig}
|
||||||
|
validCurrencies={Object.keys(donationAmountsConfig)}
|
||||||
donationReceipts={props.donationReceipts}
|
donationReceipts={props.donationReceipts}
|
||||||
saveAttachmentToDisk={props.saveAttachmentToDisk}
|
saveAttachmentToDisk={props.saveAttachmentToDisk}
|
||||||
generateDonationReceiptBlob={props.generateDonationReceiptBlob}
|
generateDonationReceiptBlob={props.generateDonationReceiptBlob}
|
||||||
|
|
|
@ -1,35 +1,63 @@
|
||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useCallback, useRef, useState } from 'react';
|
import type { MutableRefObject } from 'react';
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
|
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
|
||||||
import { Button, ButtonVariant } from './Button';
|
import { Button, ButtonVariant } from './Button';
|
||||||
import type { CardDetail, DonationWorkflow } from '../types/Donations';
|
import type { HumanDonationAmount } from '../types/Donations';
|
||||||
|
import {
|
||||||
|
ONE_TIME_DONATION_CONFIG_ID,
|
||||||
|
type DonationWorkflow,
|
||||||
|
type OneTimeDonationHumanAmounts,
|
||||||
|
} from '../types/Donations';
|
||||||
|
import {
|
||||||
|
brandHumanDonationAmount,
|
||||||
|
parseCurrencyString,
|
||||||
|
toHumanCurrencyString,
|
||||||
|
toStripeDonationAmount,
|
||||||
|
} from '../util/currency';
|
||||||
import { Input } from './Input';
|
import { Input } from './Input';
|
||||||
|
import { PreferencesContent } from './Preferences';
|
||||||
|
import type { SubmitDonationType } from '../state/ducks/donations';
|
||||||
|
import { Select } from './Select';
|
||||||
|
|
||||||
export type PropsDataType = {
|
export type PropsDataType = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
|
||||||
|
validCurrencies: ReadonlyArray<string>;
|
||||||
workflow: DonationWorkflow | undefined;
|
workflow: DonationWorkflow | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PropsHousekeepingType = {
|
||||||
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
};
|
||||||
|
|
||||||
type PropsActionType = {
|
type PropsActionType = {
|
||||||
clearWorkflow: () => void;
|
clearWorkflow: () => void;
|
||||||
submitDonation: (options: {
|
submitDonation: (payload: SubmitDonationType) => void;
|
||||||
currencyType: string;
|
onBack: () => void;
|
||||||
paymentAmount: number;
|
|
||||||
paymentDetail: CardDetail;
|
|
||||||
}) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropsType = PropsDataType & PropsActionType;
|
export type PropsType = PropsDataType & PropsActionType & PropsHousekeepingType;
|
||||||
|
|
||||||
export function PreferencesDonateFlow({
|
export function PreferencesDonateFlow({
|
||||||
|
contentsRef,
|
||||||
i18n,
|
i18n,
|
||||||
|
donationAmountsConfig,
|
||||||
|
validCurrencies,
|
||||||
workflow,
|
workflow,
|
||||||
clearWorkflow,
|
clearWorkflow,
|
||||||
submitDonation,
|
submitDonation,
|
||||||
|
onBack,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const tryClose = useRef<() => void | undefined>();
|
const tryClose = useRef<() => void | undefined>();
|
||||||
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
|
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
|
||||||
|
@ -38,20 +66,35 @@ export function PreferencesDonateFlow({
|
||||||
tryClose,
|
tryClose,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [amount, setAmount] = useState('10.00');
|
const [step, setStep] = useState<'amount' | 'paymentDetails'>('amount');
|
||||||
|
|
||||||
|
const [amount, setAmount] = useState<HumanDonationAmount>();
|
||||||
|
const [currency, setCurrency] = useState<string>();
|
||||||
const [cardExpirationMonth, setCardExpirationMonth] = useState('');
|
const [cardExpirationMonth, setCardExpirationMonth] = useState('');
|
||||||
const [cardExpirationYear, setCardExpirationYear] = useState('');
|
const [cardExpirationYear, setCardExpirationYear] = useState('');
|
||||||
const [cardNumber, setCardNumber] = useState('');
|
const [cardNumber, setCardNumber] = useState('');
|
||||||
const [cardCvc, setCardCvc] = useState('');
|
const [cardCvc, setCardCvc] = useState('');
|
||||||
|
|
||||||
|
const formattedCurrencyAmount = useMemo<string>(() => {
|
||||||
|
return toHumanCurrencyString({ amount, currency });
|
||||||
|
}, [amount, currency]);
|
||||||
|
|
||||||
|
const handleAmountPickerResult = useCallback((result: AmountPickerResult) => {
|
||||||
|
const { currency: pickedCurrency, amount: pickedAmount } = result;
|
||||||
|
setAmount(pickedAmount);
|
||||||
|
setCurrency(pickedCurrency);
|
||||||
|
setStep('paymentDetails');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDonateClicked = useCallback(() => {
|
const handleDonateClicked = useCallback(() => {
|
||||||
const parsedAmount = parseFloat(amount);
|
if (amount == null || currency == null) {
|
||||||
// Note: Whether to multiply by 100 depends on the specific currency
|
return;
|
||||||
// e.g. JPY is not multipled by 100
|
}
|
||||||
const paymentAmount = parsedAmount * 100;
|
|
||||||
|
const paymentAmount = toStripeDonationAmount({ amount, currency });
|
||||||
|
|
||||||
submitDonation({
|
submitDonation({
|
||||||
currencyType: 'USD',
|
currencyType: currency,
|
||||||
paymentAmount,
|
paymentAmount,
|
||||||
paymentDetail: {
|
paymentDetail: {
|
||||||
expirationMonth: cardExpirationMonth,
|
expirationMonth: cardExpirationMonth,
|
||||||
|
@ -66,6 +109,7 @@ export function PreferencesDonateFlow({
|
||||||
cardExpirationMonth,
|
cardExpirationMonth,
|
||||||
cardExpirationYear,
|
cardExpirationYear,
|
||||||
cardNumber,
|
cardNumber,
|
||||||
|
currency,
|
||||||
submitDonation,
|
submitDonation,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -80,73 +124,277 @@ export function PreferencesDonateFlow({
|
||||||
}, [confirmDiscardIf]);
|
}, [confirmDiscardIf]);
|
||||||
tryClose.current = onTryClose;
|
tryClose.current = onTryClose;
|
||||||
|
|
||||||
const content = (
|
let innerContent: JSX.Element;
|
||||||
<div className="PreferencesDonations">
|
let handleBack: () => void;
|
||||||
{workflow && (
|
|
||||||
<div>
|
|
||||||
<h2>Current Workflow</h2>
|
|
||||||
<blockquote>{JSON.stringify(workflow)}</blockquote>
|
|
||||||
<Button onClick={clearWorkflow} variant={ButtonVariant.Destructive}>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label htmlFor="amount">Amount (USD)</label>
|
if (step === 'amount') {
|
||||||
<Input
|
innerContent = (
|
||||||
id="amount"
|
<AmountPicker
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onChange={value => setAmount(value)}
|
initialAmount={amount}
|
||||||
placeholder="5"
|
initialCurrency={currency}
|
||||||
value={amount}
|
donationAmountsConfig={donationAmountsConfig}
|
||||||
|
validCurrencies={validCurrencies}
|
||||||
|
onSubmit={handleAmountPickerResult}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="cardNumber">Card Number</label>
|
);
|
||||||
<Input
|
// Dismiss DonateFlow and return to Donations home
|
||||||
id="cardNumber"
|
handleBack = () => onBack();
|
||||||
i18n={i18n}
|
} else {
|
||||||
onChange={value => setCardNumber(value)}
|
innerContent = (
|
||||||
placeholder="0000000000000000"
|
<div className="PreferencesDonations">
|
||||||
maxLengthCount={16}
|
{workflow && (
|
||||||
value={cardNumber}
|
<div>
|
||||||
/>
|
<h2>Current Workflow</h2>
|
||||||
<label htmlFor="cardExpirationMonth">Expiration Month</label>
|
<blockquote>{JSON.stringify(workflow)}</blockquote>
|
||||||
<Input
|
<Button onClick={clearWorkflow} variant={ButtonVariant.Destructive}>
|
||||||
id="cardExpirationMonth"
|
Reset
|
||||||
i18n={i18n}
|
</Button>
|
||||||
onChange={value => setCardExpirationMonth(value)}
|
</div>
|
||||||
placeholder="MM"
|
)}
|
||||||
value={cardExpirationMonth}
|
|
||||||
/>
|
<label htmlFor="amount">Amount</label>
|
||||||
<label htmlFor="cardExpirationYear">Expiration Year</label>
|
<pre>
|
||||||
<Input
|
{amount} {currency}
|
||||||
id="cardExpirationYear"
|
</pre>
|
||||||
i18n={i18n}
|
<label htmlFor="cardNumber">Card Number</label>
|
||||||
onChange={value => setCardExpirationYear(value)}
|
<Input
|
||||||
placeholder="YY"
|
id="cardNumber"
|
||||||
value={cardExpirationYear}
|
i18n={i18n}
|
||||||
/>
|
onChange={value => setCardNumber(value)}
|
||||||
<label htmlFor="cardCvc">Cvc</label>
|
placeholder="0000000000000000"
|
||||||
<Input
|
maxLengthCount={16}
|
||||||
id="cardCvc"
|
value={cardNumber}
|
||||||
i18n={i18n}
|
/>
|
||||||
onChange={value => setCardCvc(value)}
|
<label htmlFor="cardExpirationMonth">Expiration Month</label>
|
||||||
placeholder="123"
|
<Input
|
||||||
value={cardCvc}
|
id="cardExpirationMonth"
|
||||||
/>
|
i18n={i18n}
|
||||||
<Button
|
onChange={value => setCardExpirationMonth(value)}
|
||||||
disabled={isDonateDisabled}
|
placeholder="MM"
|
||||||
onClick={handleDonateClicked}
|
value={cardExpirationMonth}
|
||||||
variant={ButtonVariant.Primary}
|
/>
|
||||||
>
|
<label htmlFor="cardExpirationYear">Expiration Year</label>
|
||||||
Donate $10
|
<Input
|
||||||
</Button>
|
id="cardExpirationYear"
|
||||||
</div>
|
i18n={i18n}
|
||||||
|
onChange={value => setCardExpirationYear(value)}
|
||||||
|
placeholder="YY"
|
||||||
|
value={cardExpirationYear}
|
||||||
|
/>
|
||||||
|
<label htmlFor="cardCvc">Cvc</label>
|
||||||
|
<Input
|
||||||
|
id="cardCvc"
|
||||||
|
i18n={i18n}
|
||||||
|
onChange={value => setCardCvc(value)}
|
||||||
|
placeholder="123"
|
||||||
|
value={cardCvc}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={isDonateDisabled}
|
||||||
|
onClick={handleDonateClicked}
|
||||||
|
variant={ButtonVariant.Primary}
|
||||||
|
>
|
||||||
|
{i18n('icu:PreferencesDonations__donate-button-with-amount', {
|
||||||
|
formattedCurrencyAmount,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
handleBack = () => {
|
||||||
|
setStep('amount');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const backButton = (
|
||||||
|
<button
|
||||||
|
aria-label={i18n('icu:goBack')}
|
||||||
|
className="Preferences__back-icon"
|
||||||
|
onClick={handleBack}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{confirmDiscardModal}
|
||||||
|
{innerContent}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<PreferencesContent
|
||||||
{confirmDiscardModal}
|
backButton={backButton}
|
||||||
{content}
|
contents={content}
|
||||||
</>
|
contentsRef={contentsRef}
|
||||||
|
title={undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AmountPickerResult = {
|
||||||
|
amount: HumanDonationAmount;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AmountPickerProps = {
|
||||||
|
i18n: LocalizerType;
|
||||||
|
initialAmount: HumanDonationAmount | undefined;
|
||||||
|
initialCurrency: string | undefined;
|
||||||
|
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
|
||||||
|
validCurrencies: ReadonlyArray<string>;
|
||||||
|
onSubmit: (result: AmountPickerResult) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AmountPicker({
|
||||||
|
donationAmountsConfig,
|
||||||
|
i18n,
|
||||||
|
initialAmount,
|
||||||
|
initialCurrency,
|
||||||
|
validCurrencies,
|
||||||
|
onSubmit,
|
||||||
|
}: AmountPickerProps): JSX.Element {
|
||||||
|
const [currency, setCurrency] = useState(initialCurrency ?? 'usd');
|
||||||
|
|
||||||
|
const [presetAmount, setPresetAmount] = useState<
|
||||||
|
HumanDonationAmount | undefined
|
||||||
|
>(initialAmount);
|
||||||
|
const [customAmount, setCustomAmount] = useState<string>();
|
||||||
|
|
||||||
|
// Reset amount selections when API donation config or selected currency changes
|
||||||
|
// Memo here so preset options instantly load when component mounts.
|
||||||
|
const presetAmountOptions = useMemo(() => {
|
||||||
|
if (!donationAmountsConfig || !donationAmountsConfig[currency]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencyAmounts = donationAmountsConfig[currency];
|
||||||
|
const presets = currencyAmounts.oneTime[ONE_TIME_DONATION_CONFIG_ID] ?? [];
|
||||||
|
return presets;
|
||||||
|
}, [donationAmountsConfig, currency]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomAmount(undefined);
|
||||||
|
setPresetAmount(undefined);
|
||||||
|
}, [donationAmountsConfig, currency]);
|
||||||
|
|
||||||
|
const minimumAmount = useMemo<HumanDonationAmount>(() => {
|
||||||
|
if (!donationAmountsConfig || !donationAmountsConfig[currency]) {
|
||||||
|
return brandHumanDonationAmount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencyAmounts = donationAmountsConfig[currency];
|
||||||
|
return currencyAmounts.minimum;
|
||||||
|
}, [donationAmountsConfig, currency]);
|
||||||
|
|
||||||
|
const currencyOptionsForSelect = useMemo(() => {
|
||||||
|
return validCurrencies.map((currencyString: string) => {
|
||||||
|
return { text: currencyString.toUpperCase(), value: currencyString };
|
||||||
|
});
|
||||||
|
}, [validCurrencies]);
|
||||||
|
|
||||||
|
const { error, parsedCustomAmount } = useMemo<{
|
||||||
|
error: 'invalid' | 'amount-below-minimum' | undefined;
|
||||||
|
parsedCustomAmount: HumanDonationAmount | undefined;
|
||||||
|
}>(() => {
|
||||||
|
if (customAmount === '' || customAmount == null) {
|
||||||
|
return {
|
||||||
|
error: undefined,
|
||||||
|
parsedCustomAmount: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResult = parseCurrencyString({
|
||||||
|
currency,
|
||||||
|
value: customAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parseResult != null) {
|
||||||
|
if (parseResult >= minimumAmount) {
|
||||||
|
// Valid input
|
||||||
|
return {
|
||||||
|
error: undefined,
|
||||||
|
parsedCustomAmount: parseResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: 'amount-below-minimum',
|
||||||
|
parsedCustomAmount: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: 'invalid',
|
||||||
|
parsedCustomAmount: undefined,
|
||||||
|
};
|
||||||
|
}, [currency, customAmount, minimumAmount]);
|
||||||
|
|
||||||
|
const handleCurrencyChanged = useCallback((value: string) => {
|
||||||
|
setCurrency(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCustomAmountChanged = useCallback((value: string) => {
|
||||||
|
// Custom amount overrides any selected preset amount
|
||||||
|
setPresetAmount(undefined);
|
||||||
|
setCustomAmount(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const amount = parsedCustomAmount ?? presetAmount;
|
||||||
|
const isContinueEnabled = currency != null && amount != null;
|
||||||
|
|
||||||
|
const handleContinueClicked = useCallback(() => {
|
||||||
|
if (!isContinueEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({ amount, currency });
|
||||||
|
}, [amount, currency, isContinueEnabled, onSubmit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
id="currency"
|
||||||
|
options={currencyOptionsForSelect}
|
||||||
|
onChange={handleCurrencyChanged}
|
||||||
|
value={currency}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
{presetAmountOptions.map(value => (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
onClick={() => {
|
||||||
|
setCustomAmount(undefined);
|
||||||
|
setPresetAmount(value);
|
||||||
|
}}
|
||||||
|
variant={
|
||||||
|
presetAmount === value
|
||||||
|
? ButtonVariant.SecondaryAffirmative
|
||||||
|
: ButtonVariant.Secondary
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{toHumanCurrencyString({ amount: value, currency })}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label htmlFor="customAmount">Custom Amount</label>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
id="customAmount"
|
||||||
|
i18n={i18n}
|
||||||
|
onChange={handleCustomAmountChanged}
|
||||||
|
placeholder="Enter Custom Amount"
|
||||||
|
value={customAmount}
|
||||||
|
/>
|
||||||
|
<span>{currency.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
{error && <div>Error: {error}</div>}
|
||||||
|
<Button
|
||||||
|
disabled={!isContinueEnabled}
|
||||||
|
onClick={handleContinueClicked}
|
||||||
|
variant={ButtonVariant.Primary}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { groupBy, sortBy } from 'lodash';
|
import { groupBy, sortBy } from 'lodash';
|
||||||
|
|
||||||
import type { MutableRefObject, ReactNode } from 'react';
|
import type { MutableRefObject, ReactNode } from 'react';
|
||||||
|
@ -12,9 +12,9 @@ import type { LocalizerType } from '../types/Util';
|
||||||
import { Page, PreferencesContent } from './Preferences';
|
import { Page, PreferencesContent } from './Preferences';
|
||||||
import { PreferencesDonateFlow } from './PreferencesDonateFlow';
|
import { PreferencesDonateFlow } from './PreferencesDonateFlow';
|
||||||
import type {
|
import type {
|
||||||
CardDetail,
|
|
||||||
DonationWorkflow,
|
DonationWorkflow,
|
||||||
DonationReceipt,
|
DonationReceipt,
|
||||||
|
OneTimeDonationHumanAmounts,
|
||||||
} from '../types/Donations';
|
} from '../types/Donations';
|
||||||
import type { AvatarColorType } from '../types/Colors';
|
import type { AvatarColorType } from '../types/Colors';
|
||||||
import type { AvatarDataType } from '../types/Avatar';
|
import type { AvatarDataType } from '../types/Avatar';
|
||||||
|
@ -27,6 +27,8 @@ import { ToastType } from '../types/Toast';
|
||||||
import { createLogger } from '../logging/log';
|
import { createLogger } from '../logging/log';
|
||||||
import { toLogFormat } from '../types/errors';
|
import { toLogFormat } from '../types/errors';
|
||||||
import { I18n } from './I18n';
|
import { I18n } from './I18n';
|
||||||
|
import type { SubmitDonationType } from '../state/ducks/donations';
|
||||||
|
import { getHumanDonationAmount } from '../util/currency';
|
||||||
|
|
||||||
const log = createLogger('PreferencesDonations');
|
const log = createLogger('PreferencesDonations');
|
||||||
|
|
||||||
|
@ -43,6 +45,8 @@ export type PropsDataType = {
|
||||||
color?: AvatarColorType;
|
color?: AvatarColorType;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
profileAvatarUrl?: string;
|
profileAvatarUrl?: string;
|
||||||
|
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
|
||||||
|
validCurrencies: ReadonlyArray<string>;
|
||||||
donationReceipts: ReadonlyArray<DonationReceipt>;
|
donationReceipts: ReadonlyArray<DonationReceipt>;
|
||||||
saveAttachmentToDisk: (options: {
|
saveAttachmentToDisk: (options: {
|
||||||
data: Uint8Array;
|
data: Uint8Array;
|
||||||
|
@ -59,11 +63,7 @@ export type PropsDataType = {
|
||||||
type PropsActionType = {
|
type PropsActionType = {
|
||||||
clearWorkflow: () => void;
|
clearWorkflow: () => void;
|
||||||
setPage: (page: Page) => void;
|
setPage: (page: Page) => void;
|
||||||
submitDonation: (options: {
|
submitDonation: (payload: SubmitDonationType) => void;
|
||||||
currencyType: string;
|
|
||||||
paymentAmount: number;
|
|
||||||
paymentDetail: CardDetail;
|
|
||||||
}) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
|
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
|
||||||
|
@ -318,7 +318,7 @@ function PreferencesReceiptList({
|
||||||
</div>
|
</div>
|
||||||
<div className="PreferencesDonations--receiptList__receipt-item__amount">
|
<div className="PreferencesDonations--receiptList__receipt-item__amount">
|
||||||
{getCurrencyFormatter(receipt.currencyType).format(
|
{getCurrencyFormatter(receipt.currencyType).format(
|
||||||
receipt.paymentAmount / 100
|
getHumanDonationAmount(receipt)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ListBoxItem>
|
</ListBoxItem>
|
||||||
|
@ -366,7 +366,7 @@ function PreferencesReceiptList({
|
||||||
</div>
|
</div>
|
||||||
<div className="PreferencesDonations__ReceiptModal__amount">
|
<div className="PreferencesDonations__ReceiptModal__amount">
|
||||||
{getCurrencyFormatter(selectedReceipt.currencyType).format(
|
{getCurrencyFormatter(selectedReceipt.currencyType).format(
|
||||||
selectedReceipt.paymentAmount / 100
|
getHumanDonationAmount(selectedReceipt)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<hr className="PreferencesDonations__ReceiptModal__separator" />
|
<hr className="PreferencesDonations__ReceiptModal__separator" />
|
||||||
|
@ -410,30 +410,13 @@ export function PreferencesDonations({
|
||||||
color,
|
color,
|
||||||
firstName,
|
firstName,
|
||||||
profileAvatarUrl,
|
profileAvatarUrl,
|
||||||
|
donationAmountsConfig,
|
||||||
|
validCurrencies,
|
||||||
donationReceipts,
|
donationReceipts,
|
||||||
saveAttachmentToDisk,
|
saveAttachmentToDisk,
|
||||||
generateDonationReceiptBlob,
|
generateDonationReceiptBlob,
|
||||||
showToast,
|
showToast,
|
||||||
}: PropsType): JSX.Element | null {
|
}: PropsType): JSX.Element | null {
|
||||||
const PAGE_CONFIG = useMemo<
|
|
||||||
Record<DonationPage, { title: string | undefined; goBackTo: Page | null }>
|
|
||||||
>(() => {
|
|
||||||
return {
|
|
||||||
[Page.Donations]: {
|
|
||||||
title: i18n('icu:Preferences__DonateTitle'),
|
|
||||||
goBackTo: null,
|
|
||||||
},
|
|
||||||
[Page.DonationsReceiptList]: {
|
|
||||||
title: i18n('icu:PreferencesDonations__receipts'),
|
|
||||||
goBackTo: Page.Donations,
|
|
||||||
},
|
|
||||||
[Page.DonationsDonateFlow]: {
|
|
||||||
title: undefined,
|
|
||||||
goBackTo: Page.Donations,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
}, [i18n]);
|
|
||||||
|
|
||||||
const navigateToPage = useCallback(
|
const navigateToPage = useCallback(
|
||||||
(newPage: Page) => {
|
(newPage: Page) => {
|
||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
|
@ -441,31 +424,23 @@ export function PreferencesDonations({
|
||||||
[setPage]
|
[setPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
|
||||||
if (!isDonationPage(page)) {
|
|
||||||
log.error(
|
|
||||||
'Donations page back button tried to go to a non-donations page, ignoring'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { goBackTo } = PAGE_CONFIG[page];
|
|
||||||
if (goBackTo) {
|
|
||||||
setPage(goBackTo);
|
|
||||||
}
|
|
||||||
}, [PAGE_CONFIG, page, setPage]);
|
|
||||||
|
|
||||||
if (!isDonationPage(page)) {
|
if (!isDonationPage(page)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
if (page === Page.DonationsDonateFlow) {
|
if (page === Page.DonationsDonateFlow) {
|
||||||
content = (
|
// DonateFlow has to control Back button to switch between CC form and Amount picker
|
||||||
|
return (
|
||||||
<PreferencesDonateFlow
|
<PreferencesDonateFlow
|
||||||
|
contentsRef={contentsRef}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
donationAmountsConfig={donationAmountsConfig}
|
||||||
|
validCurrencies={validCurrencies}
|
||||||
workflow={workflow}
|
workflow={workflow}
|
||||||
clearWorkflow={clearWorkflow}
|
clearWorkflow={clearWorkflow}
|
||||||
submitDonation={submitDonation}
|
submitDonation={submitDonation}
|
||||||
|
onBack={() => setPage(Page.Donations)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -480,6 +455,8 @@ export function PreferencesDonations({
|
||||||
profileAvatarUrl={profileAvatarUrl}
|
profileAvatarUrl={profileAvatarUrl}
|
||||||
navigateToPage={navigateToPage}
|
navigateToPage={navigateToPage}
|
||||||
donationReceipts={donationReceipts}
|
donationReceipts={donationReceipts}
|
||||||
|
donationAmountsConfig={donationAmountsConfig}
|
||||||
|
validCurrencies={validCurrencies}
|
||||||
saveAttachmentToDisk={saveAttachmentToDisk}
|
saveAttachmentToDisk={saveAttachmentToDisk}
|
||||||
generateDonationReceiptBlob={generateDonationReceiptBlob}
|
generateDonationReceiptBlob={generateDonationReceiptBlob}
|
||||||
showToast={showToast}
|
showToast={showToast}
|
||||||
|
@ -503,22 +480,28 @@ export function PreferencesDonations({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show back button based on page configuration
|
let title: string | undefined;
|
||||||
const backButton = PAGE_CONFIG[page].goBackTo ? (
|
let backButton: JSX.Element | undefined;
|
||||||
<button
|
if (page === Page.Donations) {
|
||||||
aria-label={i18n('icu:goBack')}
|
title = i18n('icu:Preferences__DonateTitle');
|
||||||
className="Preferences__back-icon"
|
} else if (page === Page.DonationsReceiptList) {
|
||||||
onClick={handleBack}
|
title = i18n('icu:PreferencesDonations__receipts');
|
||||||
type="button"
|
backButton = (
|
||||||
/>
|
<button
|
||||||
) : undefined;
|
aria-label={i18n('icu:goBack')}
|
||||||
|
className="Preferences__back-icon"
|
||||||
|
onClick={() => setPage(Page.Donations)}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PreferencesContent
|
<PreferencesContent
|
||||||
backButton={backButton}
|
backButton={backButton}
|
||||||
contents={content}
|
contents={content}
|
||||||
contentsRef={contentsRef}
|
contentsRef={contentsRef}
|
||||||
title={PAGE_CONFIG[page].title}
|
title={title}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import type { MessageAttributesType } from '../model-types';
|
||||||
import type { DonationReceipt } from '../types/Donations';
|
import type { DonationReceipt } from '../types/Donations';
|
||||||
import { createLogger } from '../logging/log';
|
import { createLogger } from '../logging/log';
|
||||||
import { isStagingServer } from '../util/isStagingServer';
|
import { isStagingServer } from '../util/isStagingServer';
|
||||||
|
import { getHumanDonationAmount } from '../util/currency';
|
||||||
|
|
||||||
const log = createLogger('PreferencesInternal');
|
const log = createLogger('PreferencesInternal');
|
||||||
|
|
||||||
|
@ -398,8 +399,7 @@ export function PreferencesInternal({
|
||||||
{new Date(receipt.timestamp).toLocaleDateString()}
|
{new Date(receipt.timestamp).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px' }}>
|
<td style={{ padding: '8px' }}>
|
||||||
${(receipt.paymentAmount / 100).toFixed(2)}{' '}
|
{getHumanDonationAmount(receipt)} {receipt.currencyType}
|
||||||
{receipt.currencyType}
|
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
|
|
|
@ -64,6 +64,7 @@ import type {
|
||||||
} from '../textsecure/Types';
|
} from '../textsecure/Types';
|
||||||
import type { ServiceIdString } from '../types/ServiceId';
|
import type { ServiceIdString } from '../types/ServiceId';
|
||||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||||
|
import { getCachedSubscriptionConfiguration } from '../util/subscriptionConfiguration';
|
||||||
|
|
||||||
const log = createLogger('handleDataMessage');
|
const log = createLogger('handleDataMessage');
|
||||||
|
|
||||||
|
@ -760,11 +761,7 @@ export async function handleDataMessage(
|
||||||
typeof updatesUrl === 'string',
|
typeof updatesUrl === 'string',
|
||||||
'getProfile: expected updatesUrl to be a defined string'
|
'getProfile: expected updatesUrl to be a defined string'
|
||||||
);
|
);
|
||||||
const { messaging } = window.textsecure;
|
const response = await getCachedSubscriptionConfiguration();
|
||||||
if (!messaging) {
|
|
||||||
throw new Error(`${idLog}: messaging is not available`);
|
|
||||||
}
|
|
||||||
const response = await messaging.server.getSubscriptionConfiguration();
|
|
||||||
const boostBadgesByLevel = parseBoostBadgeListFromServer(
|
const boostBadgesByLevel = parseBoostBadgeListFromServer(
|
||||||
response,
|
response,
|
||||||
updatesUrl
|
updatesUrl
|
||||||
|
|
|
@ -38,6 +38,7 @@ import type {
|
||||||
DonationReceipt,
|
DonationReceipt,
|
||||||
DonationWorkflow,
|
DonationWorkflow,
|
||||||
ReceiptContext,
|
ReceiptContext,
|
||||||
|
StripeDonationAmount,
|
||||||
} from '../types/Donations';
|
} from '../types/Donations';
|
||||||
|
|
||||||
const { createDonationReceipt } = DataWriter;
|
const { createDonationReceipt } = DataWriter;
|
||||||
|
@ -73,7 +74,7 @@ export async function startDonation({
|
||||||
paymentAmount,
|
paymentAmount,
|
||||||
}: {
|
}: {
|
||||||
currencyType: string;
|
currencyType: string;
|
||||||
paymentAmount: number;
|
paymentAmount: StripeDonationAmount;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const workflow = await _createPaymentIntent({
|
const workflow = await _createPaymentIntent({
|
||||||
currencyType,
|
currencyType,
|
||||||
|
@ -145,7 +146,7 @@ export async function _internalDoDonation({
|
||||||
paymentDetail,
|
paymentDetail,
|
||||||
}: {
|
}: {
|
||||||
currencyType: string;
|
currencyType: string;
|
||||||
paymentAmount: number;
|
paymentAmount: StripeDonationAmount;
|
||||||
paymentDetail: CardDetail;
|
paymentDetail: CardDetail;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (isInternalDonationInProgress) {
|
if (isInternalDonationInProgress) {
|
||||||
|
@ -365,7 +366,7 @@ export async function _createPaymentIntent({
|
||||||
workflow,
|
workflow,
|
||||||
}: {
|
}: {
|
||||||
currencyType: string;
|
currencyType: string;
|
||||||
paymentAmount: number;
|
paymentAmount: StripeDonationAmount;
|
||||||
workflow: DonationWorkflow | undefined;
|
workflow: DonationWorkflow | undefined;
|
||||||
}): Promise<DonationWorkflow> {
|
}): Promise<DonationWorkflow> {
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
DonationErrorType,
|
DonationErrorType,
|
||||||
DonationReceipt,
|
DonationReceipt,
|
||||||
DonationWorkflow,
|
DonationWorkflow,
|
||||||
|
StripeDonationAmount,
|
||||||
} from '../../types/Donations';
|
} from '../../types/Donations';
|
||||||
import type { StateType as RootStateType } from '../reducer';
|
import type { StateType as RootStateType } from '../reducer';
|
||||||
import { DataWriter } from '../../sql/Client';
|
import { DataWriter } from '../../sql/Client';
|
||||||
|
@ -44,11 +45,7 @@ export type AddReceiptAction = ReadonlyDeep<{
|
||||||
|
|
||||||
export type SubmitDonationAction = ReadonlyDeep<{
|
export type SubmitDonationAction = ReadonlyDeep<{
|
||||||
type: typeof SUBMIT_DONATION;
|
type: typeof SUBMIT_DONATION;
|
||||||
payload: {
|
payload: SubmitDonationType;
|
||||||
currencyType: string;
|
|
||||||
amount: number;
|
|
||||||
paymentDetail: CardDetail;
|
|
||||||
};
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type UpdateLastErrorAction = ReadonlyDeep<{
|
export type UpdateLastErrorAction = ReadonlyDeep<{
|
||||||
|
@ -100,15 +97,22 @@ function internalAddDonationReceipt(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SubmitDonationType = ReadonlyDeep<{
|
||||||
|
currencyType: string;
|
||||||
|
paymentAmount: StripeDonationAmount;
|
||||||
|
paymentDetail: CardDetail;
|
||||||
|
}>;
|
||||||
|
|
||||||
function submitDonation({
|
function submitDonation({
|
||||||
currencyType,
|
currencyType,
|
||||||
paymentAmount,
|
paymentAmount,
|
||||||
paymentDetail,
|
paymentDetail,
|
||||||
}: {
|
}: SubmitDonationType): ThunkAction<
|
||||||
currencyType: string;
|
void,
|
||||||
paymentAmount: number;
|
RootStateType,
|
||||||
paymentDetail: CardDetail;
|
unknown,
|
||||||
}): ThunkAction<void, RootStateType, unknown, UpdateWorkflowAction> {
|
UpdateWorkflowAction
|
||||||
|
> {
|
||||||
return async () => {
|
return async () => {
|
||||||
if (!isStagingServer()) {
|
if (!isStagingServer()) {
|
||||||
log.error('internalAddDonationReceipt: Only available on staging server');
|
log.error('internalAddDonationReceipt: Only available on staging server');
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
import React, { memo, useEffect, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import type { MutableRefObject } from 'react';
|
import type { MutableRefObject } from 'react';
|
||||||
|
@ -15,6 +15,9 @@ import type { StateType } from '../reducer';
|
||||||
import { isStagingServer } from '../../util/isStagingServer';
|
import { isStagingServer } from '../../util/isStagingServer';
|
||||||
import { generateDonationReceiptBlob } from '../../util/generateDonationReceipt';
|
import { generateDonationReceiptBlob } from '../../util/generateDonationReceipt';
|
||||||
import { useToastActions } from '../ducks/toast';
|
import { useToastActions } from '../ducks/toast';
|
||||||
|
import { getDonationHumanAmounts } from '../../util/subscriptionConfiguration';
|
||||||
|
import { drop } from '../../util/drop';
|
||||||
|
import type { OneTimeDonationHumanAmounts } from '../../types/Donations';
|
||||||
|
|
||||||
export const SmartPreferencesDonations = memo(
|
export const SmartPreferencesDonations = memo(
|
||||||
function SmartPreferencesDonations({
|
function SmartPreferencesDonations({
|
||||||
|
@ -26,6 +29,12 @@ export const SmartPreferencesDonations = memo(
|
||||||
page: Page;
|
page: Page;
|
||||||
setPage: (page: Page) => void;
|
setPage: (page: Page) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [validCurrencies, setValidCurrencies] = useState<
|
||||||
|
ReadonlyArray<string>
|
||||||
|
>([]);
|
||||||
|
const [donationAmountsConfig, setDonationAmountsConfig] =
|
||||||
|
useState<OneTimeDonationHumanAmounts>();
|
||||||
|
|
||||||
const isStaging = isStagingServer();
|
const isStaging = isStagingServer();
|
||||||
const i18n = useSelector(getIntl);
|
const i18n = useSelector(getIntl);
|
||||||
|
|
||||||
|
@ -46,6 +55,18 @@ export const SmartPreferencesDonations = memo(
|
||||||
);
|
);
|
||||||
const { saveAttachmentToDisk } = window.Signal.Migrations;
|
const { saveAttachmentToDisk } = window.Signal.Migrations;
|
||||||
|
|
||||||
|
// Eagerly load donation config from API when entering Donations Home so the
|
||||||
|
// Amount picker loads instantly
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadDonationAmounts() {
|
||||||
|
const amounts = await getDonationHumanAmounts();
|
||||||
|
setDonationAmountsConfig(amounts);
|
||||||
|
const currencies = Object.keys(amounts);
|
||||||
|
setValidCurrencies(currencies);
|
||||||
|
}
|
||||||
|
drop(loadDonationAmounts());
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PreferencesDonations
|
<PreferencesDonations
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
@ -56,6 +77,8 @@ export const SmartPreferencesDonations = memo(
|
||||||
donationReceipts={donationReceipts}
|
donationReceipts={donationReceipts}
|
||||||
saveAttachmentToDisk={saveAttachmentToDisk}
|
saveAttachmentToDisk={saveAttachmentToDisk}
|
||||||
generateDonationReceiptBlob={generateDonationReceiptBlob}
|
generateDonationReceiptBlob={generateDonationReceiptBlob}
|
||||||
|
donationAmountsConfig={donationAmountsConfig}
|
||||||
|
validCurrencies={validCurrencies}
|
||||||
showToast={showToast}
|
showToast={showToast}
|
||||||
contentsRef={contentsRef}
|
contentsRef={contentsRef}
|
||||||
isStaging={isStaging}
|
isStaging={isStaging}
|
||||||
|
|
166
ts/test-node/util/currency_test.ts
Normal file
166
ts/test-node/util/currency_test.ts
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import {
|
||||||
|
brandHumanDonationAmount,
|
||||||
|
brandStripeDonationAmount,
|
||||||
|
parseCurrencyString,
|
||||||
|
toHumanDonationAmount,
|
||||||
|
toHumanCurrencyString,
|
||||||
|
toStripeDonationAmount,
|
||||||
|
} from '../../util/currency';
|
||||||
|
|
||||||
|
describe('parseCurrencyString', () => {
|
||||||
|
function testFn(
|
||||||
|
{ currency, value }: { currency: string; value: string },
|
||||||
|
expectedOutput: number | undefined
|
||||||
|
): void {
|
||||||
|
const brandedOutput =
|
||||||
|
expectedOutput == null
|
||||||
|
? undefined
|
||||||
|
: brandHumanDonationAmount(expectedOutput);
|
||||||
|
assert.equal(parseCurrencyString({ currency, value }), brandedOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('handles USD', () => {
|
||||||
|
testFn({ currency: 'usd', value: '10' }, 10);
|
||||||
|
testFn({ currency: 'usd', value: '10.0' }, 10);
|
||||||
|
testFn({ currency: 'usd', value: '10.00' }, 10);
|
||||||
|
testFn({ currency: 'usd', value: '10.000' }, 10);
|
||||||
|
testFn({ currency: 'usd', value: '10.50' }, 10.5);
|
||||||
|
testFn({ currency: 'usd', value: '10.6969' }, 10.69);
|
||||||
|
testFn({ currency: 'usd', value: '.69' }, 0.69);
|
||||||
|
testFn({ currency: 'usd', value: '0.69' }, 0.69);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles JPY', () => {
|
||||||
|
testFn({ currency: 'jpy', value: '1000' }, 1000);
|
||||||
|
testFn({ currency: 'jpy', value: '1000.0' }, 1000);
|
||||||
|
testFn({ currency: 'jpy', value: '1000.5' }, 1000);
|
||||||
|
testFn({ currency: 'jpy', value: '1000.5555' }, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles malformed input', () => {
|
||||||
|
testFn({ currency: 'usd', value: '' }, undefined);
|
||||||
|
testFn({ currency: 'usd', value: '??' }, undefined);
|
||||||
|
testFn({ currency: 'usd', value: '-50' }, undefined);
|
||||||
|
testFn({ currency: 'usd', value: 'abc' }, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toHumanDonationAmount', () => {
|
||||||
|
function testFn(
|
||||||
|
{ amount, currency }: { amount: number; currency: string },
|
||||||
|
expectedOutput: number
|
||||||
|
): void {
|
||||||
|
const stripeAmount = brandStripeDonationAmount(amount);
|
||||||
|
const brandedOutput = brandHumanDonationAmount(expectedOutput);
|
||||||
|
assert.equal(
|
||||||
|
toHumanDonationAmount({ amount: stripeAmount, currency }),
|
||||||
|
brandedOutput
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('handles USD', () => {
|
||||||
|
testFn({ amount: 1000, currency: 'usd' }, 10);
|
||||||
|
testFn({ amount: 1000, currency: 'USD' }, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles JPY', () => {
|
||||||
|
testFn({ amount: 1000, currency: 'jpy' }, 1000);
|
||||||
|
testFn({ amount: 1000, currency: 'JPY' }, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles KRW', () => {
|
||||||
|
testFn({ amount: 10000, currency: 'krw' }, 10000);
|
||||||
|
testFn({ amount: 10000, currency: 'KRW' }, 10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toStripeDonationAmount', () => {
|
||||||
|
function testFn(
|
||||||
|
{ amount, currency }: { amount: number; currency: string },
|
||||||
|
expectedOutput: number
|
||||||
|
): void {
|
||||||
|
const humanAmount = brandHumanDonationAmount(amount);
|
||||||
|
const brandedOutput = brandStripeDonationAmount(expectedOutput);
|
||||||
|
assert.equal(
|
||||||
|
toStripeDonationAmount({ amount: humanAmount, currency }),
|
||||||
|
brandedOutput
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('handles USD', () => {
|
||||||
|
testFn({ amount: 10, currency: 'usd' }, 1000);
|
||||||
|
testFn({ amount: 10, currency: 'USD' }, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles JPY', () => {
|
||||||
|
testFn({ amount: 1000, currency: 'jpy' }, 1000);
|
||||||
|
testFn({ amount: 1000, currency: 'JPY' }, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles KRW', () => {
|
||||||
|
testFn({ amount: 10000, currency: 'krw' }, 10000);
|
||||||
|
testFn({ amount: 10000, currency: 'KRW' }, 10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toHumanCurrencyString', () => {
|
||||||
|
function testFn(
|
||||||
|
{
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
showInsignificantFractionDigits = false,
|
||||||
|
}: {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
showInsignificantFractionDigits?: boolean;
|
||||||
|
},
|
||||||
|
expectedOutput: string | undefined
|
||||||
|
): void {
|
||||||
|
const humanAmount = brandHumanDonationAmount(amount);
|
||||||
|
assert.equal(
|
||||||
|
toHumanCurrencyString({
|
||||||
|
amount: humanAmount,
|
||||||
|
currency,
|
||||||
|
showInsignificantFractionDigits,
|
||||||
|
}),
|
||||||
|
expectedOutput
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('handles USD', () => {
|
||||||
|
testFn(
|
||||||
|
{ amount: 10, currency: 'usd', showInsignificantFractionDigits: true },
|
||||||
|
'$10.00'
|
||||||
|
);
|
||||||
|
testFn({ amount: 10, currency: 'USD' }, '$10');
|
||||||
|
testFn({ amount: 10.5, currency: 'USD' }, '$10.50');
|
||||||
|
testFn({ amount: 10.5, currency: 'USD' }, '$10.50');
|
||||||
|
testFn({ amount: 10.69, currency: 'USD' }, '$10.69');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles EUR', () => {
|
||||||
|
testFn(
|
||||||
|
{ amount: 10, currency: 'eur', showInsignificantFractionDigits: true },
|
||||||
|
'€10.00'
|
||||||
|
);
|
||||||
|
testFn({ amount: 10, currency: 'eur' }, '€10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles JPY', () => {
|
||||||
|
testFn({ amount: 1000, currency: 'jpy' }, '¥1,000');
|
||||||
|
testFn(
|
||||||
|
{ amount: 1000, currency: 'JPY', showInsignificantFractionDigits: true },
|
||||||
|
'¥1,000'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for bad inputs', () => {
|
||||||
|
testFn({ amount: 10, currency: '420' }, '');
|
||||||
|
testFn({ amount: 10, currency: '' }, '');
|
||||||
|
});
|
||||||
|
});
|
|
@ -92,7 +92,13 @@ import type { ServerAlert } from '../util/handleServerAlerts';
|
||||||
import { isAbortError } from '../util/isAbortError';
|
import { isAbortError } from '../util/isAbortError';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import { drop } from '../util/drop';
|
import { drop } from '../util/drop';
|
||||||
import type { CardDetail } from '../types/Donations';
|
import type { StripeDonationAmount } from '../types/Donations';
|
||||||
|
import {
|
||||||
|
subscriptionConfigurationCurrencyZod,
|
||||||
|
type CardDetail,
|
||||||
|
} from '../types/Donations';
|
||||||
|
import { badgeFromServerSchema } from '../badges/parseBadgesFromServer';
|
||||||
|
import { ZERO_DECIMAL_CURRENCIES } from '../util/currency';
|
||||||
|
|
||||||
const log = createLogger('WebAPI');
|
const log = createLogger('WebAPI');
|
||||||
|
|
||||||
|
@ -1108,6 +1114,20 @@ const linkDeviceResultZod = z.object({
|
||||||
});
|
});
|
||||||
export type LinkDeviceResultType = z.infer<typeof linkDeviceResultZod>;
|
export type LinkDeviceResultType = z.infer<typeof linkDeviceResultZod>;
|
||||||
|
|
||||||
|
const subscriptionConfigurationResultZod = z.object({
|
||||||
|
currencies: z.record(z.string(), subscriptionConfigurationCurrencyZod),
|
||||||
|
levels: z.record(
|
||||||
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
badge: badgeFromServerSchema,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
export type SubscriptionConfigurationResultType = z.infer<
|
||||||
|
typeof subscriptionConfigurationResultZod
|
||||||
|
>;
|
||||||
|
|
||||||
export type ReportMessageOptionsType = Readonly<{
|
export type ReportMessageOptionsType = Readonly<{
|
||||||
senderAci: AciString;
|
senderAci: AciString;
|
||||||
serverGuid: string;
|
serverGuid: string;
|
||||||
|
@ -1150,7 +1170,7 @@ export type CreateAccountResultType = Readonly<{
|
||||||
|
|
||||||
export type CreateBoostOptionsType = Readonly<{
|
export type CreateBoostOptionsType = Readonly<{
|
||||||
currency: string;
|
currency: string;
|
||||||
amount: number;
|
amount: StripeDonationAmount;
|
||||||
level: number;
|
level: number;
|
||||||
paymentMethod: string;
|
paymentMethod: string;
|
||||||
}>;
|
}>;
|
||||||
|
@ -1499,24 +1519,6 @@ const backupFileHeadersSchema = z.object({
|
||||||
|
|
||||||
type BackupFileHeadersType = z.infer<typeof backupFileHeadersSchema>;
|
type BackupFileHeadersType = z.infer<typeof backupFileHeadersSchema>;
|
||||||
|
|
||||||
// See: https://docs.stripe.com/currencies?presentment-currency=US
|
|
||||||
const ZERO_DECIMAL_CURRENCIES = new Set([
|
|
||||||
'bif',
|
|
||||||
'clp',
|
|
||||||
'djf',
|
|
||||||
'gnf',
|
|
||||||
'jpy',
|
|
||||||
'kmf',
|
|
||||||
'krw',
|
|
||||||
'mga',
|
|
||||||
'pyg',
|
|
||||||
'rwf',
|
|
||||||
'vnd',
|
|
||||||
'vuv',
|
|
||||||
'xaf',
|
|
||||||
'xof',
|
|
||||||
'xpf',
|
|
||||||
]);
|
|
||||||
const secondsTimestampToDate = z.coerce
|
const secondsTimestampToDate = z.coerce
|
||||||
.number()
|
.number()
|
||||||
.transform(sec => new Date(sec * 1_000));
|
.transform(sec => new Date(sec * 1_000));
|
||||||
|
@ -1642,7 +1644,7 @@ export type WebAPIType = {
|
||||||
options: ProfileFetchUnauthRequestOptions
|
options: ProfileFetchUnauthRequestOptions
|
||||||
) => Promise<ProfileType>;
|
) => Promise<ProfileType>;
|
||||||
getBadgeImageFile: (imageUrl: string) => Promise<Uint8Array>;
|
getBadgeImageFile: (imageUrl: string) => Promise<Uint8Array>;
|
||||||
getSubscriptionConfiguration: () => Promise<unknown>;
|
getSubscriptionConfiguration: () => Promise<SubscriptionConfigurationResultType>;
|
||||||
getSubscription: (
|
getSubscription: (
|
||||||
subscriberId: Uint8Array
|
subscriberId: Uint8Array
|
||||||
) => Promise<SubscriptionResponseType>;
|
) => Promise<SubscriptionResponseType>;
|
||||||
|
@ -2941,14 +2943,13 @@ export function initialize({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSubscriptionConfiguration(): Promise<unknown> {
|
async function getSubscriptionConfiguration(): Promise<SubscriptionConfigurationResultType> {
|
||||||
return _ajax({
|
return _ajax({
|
||||||
host: 'chatService',
|
host: 'chatService',
|
||||||
call: 'subscriptionConfiguration',
|
call: 'subscriptionConfiguration',
|
||||||
httpType: 'GET',
|
httpType: 'GET',
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
// TODO DESKTOP-8719
|
zodSchema: subscriptionConfigurationResultZod,
|
||||||
zodSchema: z.unknown(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ONE_TIME_DONATION_CONFIG_ID = '1';
|
||||||
|
|
||||||
export const donationStateSchema = z.enum([
|
export const donationStateSchema = z.enum([
|
||||||
'INTENT',
|
'INTENT',
|
||||||
'INTENT_METHOD',
|
'INTENT_METHOD',
|
||||||
|
@ -156,3 +158,33 @@ export const donationWorkflowSchema = z.discriminatedUnion('type', [
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type DonationWorkflow = z.infer<typeof donationWorkflowSchema>;
|
export type DonationWorkflow = z.infer<typeof donationWorkflowSchema>;
|
||||||
|
|
||||||
|
export const humanDonationAmountSchema = z
|
||||||
|
.number()
|
||||||
|
.nonnegative()
|
||||||
|
.brand('humanAmount');
|
||||||
|
|
||||||
|
export type HumanDonationAmount = z.infer<typeof humanDonationAmountSchema>;
|
||||||
|
|
||||||
|
// Always in currency minor units e.g. 1000 for 10 USD, 10 for 10 JPY
|
||||||
|
// https://docs.stripe.com/currencies#minor-units
|
||||||
|
export const stripeDonationAmountSchema = z
|
||||||
|
.number()
|
||||||
|
.nonnegative()
|
||||||
|
.brand('stripeAmount');
|
||||||
|
|
||||||
|
export type StripeDonationAmount = z.infer<typeof stripeDonationAmountSchema>;
|
||||||
|
|
||||||
|
export const subscriptionConfigurationCurrencyZod = z.object({
|
||||||
|
minimum: humanDonationAmountSchema,
|
||||||
|
oneTime: z.record(z.string(), humanDonationAmountSchema.array()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const oneTimeDonationAmountsZod = z.record(
|
||||||
|
z.string(),
|
||||||
|
subscriptionConfigurationCurrencyZod
|
||||||
|
);
|
||||||
|
|
||||||
|
export type OneTimeDonationHumanAmounts = z.infer<
|
||||||
|
typeof oneTimeDonationAmountsZod
|
||||||
|
>;
|
||||||
|
|
150
ts/util/currency.ts
Normal file
150
ts/util/currency.ts
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HumanDonationAmount,
|
||||||
|
DonationReceipt,
|
||||||
|
StripeDonationAmount,
|
||||||
|
} from '../types/Donations';
|
||||||
|
import {
|
||||||
|
humanDonationAmountSchema,
|
||||||
|
stripeDonationAmountSchema,
|
||||||
|
} from '../types/Donations';
|
||||||
|
import { parseStrict, safeParseStrict } from './schemas';
|
||||||
|
|
||||||
|
// See: https://docs.stripe.com/currencies?presentment-currency=US
|
||||||
|
export const ZERO_DECIMAL_CURRENCIES = new Set([
|
||||||
|
'bif',
|
||||||
|
'clp',
|
||||||
|
'djf',
|
||||||
|
'gnf',
|
||||||
|
'jpy',
|
||||||
|
'kmf',
|
||||||
|
'krw',
|
||||||
|
'mga',
|
||||||
|
'pyg',
|
||||||
|
'rwf',
|
||||||
|
'vnd',
|
||||||
|
'vuv',
|
||||||
|
'xaf',
|
||||||
|
'xof',
|
||||||
|
'xpf',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function parseCurrencyString({
|
||||||
|
currency,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
currency: string;
|
||||||
|
value: string;
|
||||||
|
}): HumanDonationAmount | undefined {
|
||||||
|
const valueAsFloat = parseFloat(value);
|
||||||
|
const truncatedAmount = ZERO_DECIMAL_CURRENCIES.has(currency.toLowerCase())
|
||||||
|
? Math.trunc(valueAsFloat)
|
||||||
|
: Math.trunc(valueAsFloat * 100) / 100;
|
||||||
|
const parsed = safeParseStrict(humanDonationAmountSchema, truncatedAmount);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Takes a donation amount and currency and returns a human readable currency string
|
||||||
|
// formatted in the locale's format using Intl.NumberFormat. e.g. $10; ¥1000; 10 €
|
||||||
|
// In case of error, returns empty string.
|
||||||
|
export function toHumanCurrencyString({
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
showInsignificantFractionDigits = false,
|
||||||
|
}: {
|
||||||
|
amount: HumanDonationAmount | undefined;
|
||||||
|
currency: string | undefined;
|
||||||
|
showInsignificantFractionDigits?: boolean;
|
||||||
|
}): string {
|
||||||
|
if (amount == null || currency == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const preferredSystemLocales =
|
||||||
|
window.SignalContext.getPreferredSystemLocales();
|
||||||
|
const localeOverride = window.SignalContext.getLocaleOverride();
|
||||||
|
const locales =
|
||||||
|
localeOverride != null ? [localeOverride] : preferredSystemLocales;
|
||||||
|
|
||||||
|
const fractionOptions =
|
||||||
|
showInsignificantFractionDigits || amount % 1 !== 0
|
||||||
|
? {}
|
||||||
|
: { minimumFractionDigits: 0 };
|
||||||
|
const formatter = new Intl.NumberFormat(locales, {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
...fractionOptions,
|
||||||
|
});
|
||||||
|
return formatter.format(amount);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a number and brands as HumanDonationAmount type, which indicates actual
|
||||||
|
* units (e.g. 10 for 10 USD; 1000 for 1000 JPY).
|
||||||
|
* Only use this when directly handling amounts from the chat server.
|
||||||
|
* To convert from stripe to chat server amount, use toHumanDonationAmount().
|
||||||
|
* @param amount - number expressing value as actual currency units (e.g. 10 for 10 USD)
|
||||||
|
* @returns HumanDonationAmount - branded number type
|
||||||
|
*/
|
||||||
|
export function brandHumanDonationAmount(amount: number): HumanDonationAmount {
|
||||||
|
return parseStrict(humanDonationAmountSchema, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toHumanDonationAmount({
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
amount: StripeDonationAmount;
|
||||||
|
currency: string;
|
||||||
|
}): HumanDonationAmount {
|
||||||
|
const transformedAmount = ZERO_DECIMAL_CURRENCIES.has(currency.toLowerCase())
|
||||||
|
? amount
|
||||||
|
: amount / 100;
|
||||||
|
return parseStrict(humanDonationAmountSchema, transformedAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a number and brands as StripeDonationAmount type, which is in the currency
|
||||||
|
* minor unit (e.g. 1000 for 10 USD) and the expected format for the Stripe API.
|
||||||
|
* Only use this when directly handling amounts from Stripe.
|
||||||
|
* To convert from chat server to stripe amount, use toStripeDonationAmount().
|
||||||
|
* @param amount - number expressing value as currency minor units (e.g. 1000 for 10 USD)
|
||||||
|
* @returns StripeDonationAmount - branded number type
|
||||||
|
*/
|
||||||
|
export function brandStripeDonationAmount(
|
||||||
|
amount: number
|
||||||
|
): StripeDonationAmount {
|
||||||
|
return parseStrict(stripeDonationAmountSchema, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toStripeDonationAmount({
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
amount: HumanDonationAmount;
|
||||||
|
currency: string;
|
||||||
|
}): StripeDonationAmount {
|
||||||
|
const transformedAmount = ZERO_DECIMAL_CURRENCIES.has(currency.toLowerCase())
|
||||||
|
? amount
|
||||||
|
: amount * 100;
|
||||||
|
return parseStrict(humanDonationAmountSchema, transformedAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHumanDonationAmount(
|
||||||
|
receipt: DonationReceipt
|
||||||
|
): HumanDonationAmount {
|
||||||
|
// We store receipt.paymentAmount as the Stripe value
|
||||||
|
const { currencyType: currency, paymentAmount } = receipt;
|
||||||
|
const amount = brandStripeDonationAmount(paymentAmount);
|
||||||
|
return toHumanDonationAmount({ amount, currency });
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import type { LocalizerType } from '../types/Util';
|
||||||
import { strictAssert } from './assert';
|
import { strictAssert } from './assert';
|
||||||
import { getDateTimeFormatter } from './formatTimestamp';
|
import { getDateTimeFormatter } from './formatTimestamp';
|
||||||
import { isStagingServer } from './isStagingServer';
|
import { isStagingServer } from './isStagingServer';
|
||||||
|
import { getHumanDonationAmount, toHumanCurrencyString } from './currency';
|
||||||
|
|
||||||
const SCALING_FACTOR = 4.17;
|
const SCALING_FACTOR = 4.17;
|
||||||
|
|
||||||
|
@ -185,18 +186,12 @@ export async function generateDonationReceiptBlob(
|
||||||
);
|
);
|
||||||
canvas.add(amountLabel);
|
canvas.add(amountLabel);
|
||||||
|
|
||||||
// Format currency
|
const humanAmount = getHumanDonationAmount(receipt);
|
||||||
const preferredSystemLocales =
|
const amountStr = toHumanCurrencyString({
|
||||||
window.SignalContext.getPreferredSystemLocales();
|
amount: humanAmount,
|
||||||
const localeOverride = window.SignalContext.getLocaleOverride();
|
|
||||||
const locales =
|
|
||||||
localeOverride != null ? [localeOverride] : preferredSystemLocales;
|
|
||||||
|
|
||||||
const formatter = new Intl.NumberFormat(locales, {
|
|
||||||
style: 'currency',
|
|
||||||
currency: receipt.currencyType,
|
currency: receipt.currencyType,
|
||||||
|
showInsignificantFractionDigits: true,
|
||||||
});
|
});
|
||||||
const amountStr = formatter.format(receipt.paymentAmount / 100);
|
|
||||||
const amountValue = new fabric.Text(amountStr, {
|
const amountValue = new fabric.Text(amountStr, {
|
||||||
left: width - paddingX,
|
left: width - paddingX,
|
||||||
top: currentY,
|
top: currentY,
|
||||||
|
|
43
ts/util/subscriptionConfiguration.ts
Normal file
43
ts/util/subscriptionConfiguration.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { SubscriptionConfigurationResultType } from '../textsecure/WebAPI';
|
||||||
|
import type { OneTimeDonationHumanAmounts } from '../types/Donations';
|
||||||
|
import { HOUR } from './durations';
|
||||||
|
import { isInPast } from './timestamp';
|
||||||
|
|
||||||
|
const SUBSCRIPTION_CONFIG_CACHE_TIME = HOUR;
|
||||||
|
|
||||||
|
let cachedSubscriptionConfig: SubscriptionConfigurationResultType | undefined;
|
||||||
|
let cachedSubscriptionConfigExpiresAt: number | undefined;
|
||||||
|
|
||||||
|
export async function getCachedSubscriptionConfiguration(): Promise<SubscriptionConfigurationResultType> {
|
||||||
|
if (
|
||||||
|
cachedSubscriptionConfigExpiresAt != null &&
|
||||||
|
isInPast(cachedSubscriptionConfigExpiresAt)
|
||||||
|
) {
|
||||||
|
cachedSubscriptionConfig = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedSubscriptionConfig != null) {
|
||||||
|
return cachedSubscriptionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { server } = window.textsecure;
|
||||||
|
if (!server) {
|
||||||
|
throw new Error('getSubscriptionConfiguration: server is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await server.getSubscriptionConfiguration();
|
||||||
|
|
||||||
|
cachedSubscriptionConfig = response;
|
||||||
|
cachedSubscriptionConfigExpiresAt =
|
||||||
|
Date.now() + SUBSCRIPTION_CONFIG_CACHE_TIME;
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDonationHumanAmounts(): Promise<OneTimeDonationHumanAmounts> {
|
||||||
|
const { currencies } = await getCachedSubscriptionConfiguration();
|
||||||
|
return currencies;
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue