mirror of
https://github.com/mastodon/mastodon.git
synced 2025-11-29 02:50:46 +00:00
use new Redux state for donation modal
This commit is contained in:
parent
e22184c20e
commit
40894760d1
|
|
@ -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;
|
||||
}
|
||||
27
app/javascript/mastodon/features/donate/checkout.tsx
Normal file
27
app/javascript/mastodon/features/donate/checkout.tsx
Normal 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'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<ToggleButton
|
||||
key={freq}
|
||||
active={frequency === freq}
|
||||
onClick={handleFrequencyToggle(freq)}
|
||||
text={intl.formatMessage(messages[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'>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='donate.success.subtitle'
|
||||
defaultMessage='You should receive an email confirming your donation soon.'
|
||||
tagName='p'
|
||||
/>
|
||||
<p className='muted'>
|
||||
<FormattedMessage
|
||||
id='donate.success.subtitle'
|
||||
defaultMessage='You should receive an email confirming your donation soon.'
|
||||
/>
|
||||
</p>
|
||||
|
||||
<Button block onClick={handleShare}>
|
||||
<ShareIcon />
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user