use new Redux state for donation modal

This commit is contained in:
ChaosExAnima 2025-09-12 18:30:49 +02:00
parent e22184c20e
commit 40894760d1
No known key found for this signature in database
GPG Key ID: 8F2B333100FB6117
6 changed files with 93 additions and 168 deletions

View File

@ -1,94 +0,0 @@
import { useEffect, useState } from 'react';
import initialState from '@/mastodon/initial_state';
// TODO: Proxy this through at least an env var.
const API_URL = 'https://api.joinmastodon.org/v1/donations/campaigns/active';
export const DONATION_FREQUENCIES = ['one_time', 'monthly', 'yearly'] as const;
export type DonationFrequency = (typeof DONATION_FREQUENCIES)[number];
export const LOCALE = initialState?.meta.locale ?? 'en';
export function useDonateApi() {
const [response, setResponse] = useState<DonateServerResponse | null>(null);
const [seed, setSeed] = useState(0);
useEffect(() => {
try {
const storedSeed = localStorage.getItem('donate_seed');
if (storedSeed) {
setSeed(Number.parseInt(storedSeed, 10));
return;
}
const newSeed = Math.floor(Math.random() * 99) + 1;
localStorage.setItem('donate_seed', newSeed.toString());
setSeed(newSeed);
} catch {
// No local storage available, just set a seed for this session.
setSeed(Math.floor(Math.random() * 99) + 1);
}
}, []);
useEffect(() => {
if (!seed) {
return;
}
fetchCampaign({ locale: LOCALE, seed })
.then((res) => {
setResponse(res);
})
.catch((reason: unknown) => {
console.warn('Error fetching donation campaign:', reason);
});
}, [seed]);
return response;
}
export interface DonateServerResponse {
id: string;
amounts: Record<DonationFrequency, DonateAmount>;
donation_url: string;
banner_message: string;
banner_button_text: string;
donation_message: string;
donation_button_text: string;
donation_success_post: string;
default_currency: string;
}
export interface DonateCheckoutArgs {
frequency: DonationFrequency;
amount: number;
currency: string;
}
type DonateAmount = Record<string, number[]>;
async function fetchCampaign(
params: DonateServerRequest,
): Promise<DonateServerResponse | null> {
// Create the URL with query parameters.
const url = new URL(API_URL);
for (const [key, value] of Object.entries(params)) {
// Check to make TS happy.
if (typeof value === 'string' || typeof value === 'number') {
url.searchParams.append(key, value.toString());
}
}
url.searchParams.append('platform', 'android');
url.searchParams.append('source', 'menu');
const response = await fetch(url);
if (!response.ok) {
return null;
}
return response.json() as Promise<DonateServerResponse>;
}
interface DonateServerRequest {
locale: string;
seed: number;
return_url?: string;
}

View File

@ -0,0 +1,27 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
export const DonateCheckoutHint: FC<{ donateUrl?: string }> = ({
donateUrl,
}) => {
if (!donateUrl) {
return <LoadingIndicator />;
}
return (
<FormattedMessage
id='donate.checkout.instructions'
defaultMessage="Your checkout session is opened in another tab. If you don't see it, <link>click here</link>."
values={{
link: (chunks) => (
<a href={donateUrl} target='_blank' rel='noopener'>
{chunks}
</a>
),
}}
tagName='p'
/>
);
};

View File

@ -76,11 +76,18 @@
.submit {
padding: 0.6rem 1rem;
> svg {
height: 1em;
}
}
button > svg {
fill: currentColor;
height: 1em;
}
.muted {
color: var(--input-placeholder-color);
}
.footer {
@ -91,15 +98,15 @@
.success {
.illustration {
width: 100%;
max-width: 350px;
margin: 0 auto 1rem;
}
h2 {
font-size: 16px;
line-height: 24px;
font-size: 1.4rem;
line-height: 1;
font-weight: 500;
letter-spacing: 0.15px;
}
}
}

View File

@ -5,12 +5,12 @@ import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import type { DonationFrequency } from '@/mastodon/api_types/donate';
import { IconButton } from '@/mastodon/components/icon_button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { useAppSelector } from '@/mastodon/store';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import type { DonateCheckoutArgs } from './api';
import { useDonateApi } from './api';
import { DonateCheckoutHint } from './checkout';
import { DonateForm } from './form';
import { DonateSuccess } from './success';
@ -20,6 +20,12 @@ interface DonateModalProps {
onClose: () => void;
}
export interface DonateCheckoutArgs {
frequency: DonationFrequency;
amount: number;
currency: string;
}
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
@ -31,9 +37,9 @@ const CHECKOUT_URL = 'http://localhost:3001/donate/checkout';
const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
const intl = useIntl();
const donationData = useDonateApi() ?? undefined;
const donationData = useAppSelector((state) => state.donate.apiResponse);
const [donateUrl, setDonateUrl] = useState<null | string>(null);
const [donateUrl, setDonateUrl] = useState<string | undefined>();
const handleCheckout = useCallback(
({ frequency, amount, currency }: DonateCheckoutArgs) => {
const params = new URLSearchParams({
@ -85,37 +91,15 @@ const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
/>
</header>
<div
className={classNames('dialog-modal__content__form', {
className={classNames('dialog-modal__content__form', 'body', {
initial: state === 'start',
checkout: state === 'checkout',
success: state === 'success',
})}
>
{state === 'start' &&
(donationData ? (
<DonateForm data={donationData} onSubmit={handleCheckout} />
) : (
<LoadingIndicator />
))}
{state === 'checkout' && (
<p>
Your session is opened in another tab.
{donateUrl && (
<>
{' '}
If you don&apos;t see it,
{/* eslint-disable-next-line react/jsx-no-target-blank -- We want access to the opener in order to detect success. */}
<a href={donateUrl} target='_blank'>
click here
</a>
.
</>
)}
</p>
)}
{state === 'success' && donationData && (
<DonateSuccess data={donationData} onClose={onClose} />
)}
{state === 'start' && <DonateForm onSubmit={handleCheckout} />}
{state === 'checkout' && <DonateCheckoutHint donateUrl={donateUrl} />}
{state === 'success' && <DonateSuccess onClose={onClose} />}
</div>
</div>
</div>

View File

@ -5,17 +5,16 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import type { DonationFrequency } from '@/mastodon/api_types/donate';
import type { ButtonProps } from '@/mastodon/components/button';
import { Button } from '@/mastodon/components/button';
import { Dropdown } from '@/mastodon/components/dropdown';
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { useAppSelector } from '@/mastodon/store';
import ExternalLinkIcon from '@/material-icons/400-24px/open_in_new.svg?react';
import type {
DonateCheckoutArgs,
DonateServerResponse,
DonationFrequency,
} from './api';
import type { DonateCheckoutArgs } from './donate_modal';
const messages = defineMessages({
one_time: { id: 'donate.frequency.one_time', defaultMessage: 'Just once' },
@ -24,16 +23,14 @@ const messages = defineMessages({
});
interface DonateFormProps {
data?: DonateServerResponse;
onSubmit: (args: DonateCheckoutArgs) => void;
}
export const DonateForm: FC<Required<DonateFormProps>> = ({
data,
onSubmit,
}) => {
export const DonateForm: FC<Required<DonateFormProps>> = ({ onSubmit }) => {
const intl = useIntl();
const donateData = useAppSelector((state) => state.donate.apiResponse);
const [frequency, setFrequency] = useState<DonationFrequency>('one_time');
const handleFrequencyToggle = useCallback((value: DonationFrequency) => {
return () => {
@ -41,18 +38,21 @@ export const DonateForm: FC<Required<DonateFormProps>> = ({
};
}, []);
const [currency, setCurrency] = useState<string>(data.default_currency);
const [currency, setCurrency] = useState<string>(
donateData?.default_currency ?? 'EUR',
);
const currencyOptions: SelectItem[] = useMemo(
() =>
Object.keys(data.amounts.one_time).map((code) => ({
Object.keys(donateData?.amounts.one_time ?? []).map((code) => ({
value: code,
text: code,
})),
[data.amounts],
[donateData?.amounts],
);
const [amount, setAmount] = useState(
() => data.amounts[frequency][data.default_currency]?.[0] ?? 1000,
() =>
donateData?.amounts[frequency][donateData.default_currency]?.[0] ?? 1000,
);
const handleAmountChange = useCallback((event: SyntheticEvent) => {
let newAmount = 1;
@ -69,29 +69,35 @@ export const DonateForm: FC<Required<DonateFormProps>> = ({
currency,
maximumFractionDigits: 0,
});
return Object.values(data.amounts[frequency][currency] ?? {}).map(
return Object.values(donateData?.amounts[frequency][currency] ?? {}).map(
(value) => ({
value: value.toString(),
text: formatter.format(value / 100),
}),
);
}, [currency, data.amounts, frequency]);
}, [currency, donateData?.amounts, frequency]);
const handleSubmit = useCallback(() => {
onSubmit({ frequency, amount, currency });
}, [amount, currency, frequency, onSubmit]);
if (!donateData) {
return <LoadingIndicator />;
}
return (
<>
<div className='row'>
{(Object.keys(data.amounts) as DonationFrequency[]).map((freq) => (
{(Object.keys(donateData.amounts) as DonationFrequency[]).map(
(freq) => (
<ToggleButton
key={freq}
active={frequency === freq}
onClick={handleFrequencyToggle(freq)}
text={intl.formatMessage(messages[freq])}
/>
))}
),
)}
</div>
<div className='row row--select'>

View File

@ -4,29 +4,23 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import donateIllustration from '@/images/donation_successful.png';
import { focusCompose, resetCompose } from '@/mastodon/actions/compose';
import { composeDonateShare } from '@/mastodon/actions/donate';
import { Button } from '@/mastodon/components/button';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import type { DonateServerResponse } from './api';
interface DonateSuccessProps {
data: DonateServerResponse;
onClose: () => void;
}
export const DonateSuccess: FC<DonateSuccessProps> = ({ data, onClose }) => {
export const DonateSuccess: FC<DonateSuccessProps> = ({ onClose }) => {
const hasComposerContent = useAppSelector(
(state) => !!state.compose.get('text'),
);
const dispatch = useAppDispatch();
const handleShare = useCallback(() => {
const shareText = data.donation_success_post;
dispatch(resetCompose());
dispatch(focusCompose(shareText));
onClose();
}, [data.donation_success_post, dispatch, onClose]);
dispatch(composeDonateShare());
}, [dispatch]);
return (
<>
@ -41,11 +35,12 @@ export const DonateSuccess: FC<DonateSuccessProps> = ({ data, onClose }) => {
defaultMessage='Thanks for your donation!'
tagName='h2'
/>
<p className='muted'>
<FormattedMessage
id='donate.success.subtitle'
defaultMessage='You should receive an email confirming your donation soon.'
tagName='p'
/>
</p>
<Button block onClick={handleShare}>
<ShareIcon />