mirror of
https://github.com/mastodon/mastodon.git
synced 2025-11-29 10:53:39 +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 {
|
.submit {
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button > svg {
|
button > svg {
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
height: 1em;
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--input-placeholder-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
|
|
@ -91,15 +98,15 @@
|
||||||
|
|
||||||
.success {
|
.success {
|
||||||
.illustration {
|
.illustration {
|
||||||
|
width: 100%;
|
||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
margin: 0 auto 1rem;
|
margin: 0 auto 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 16px;
|
font-size: 1.4rem;
|
||||||
line-height: 24px;
|
line-height: 1;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.15px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { DonationFrequency } from '@/mastodon/api_types/donate';
|
||||||
import { IconButton } from '@/mastodon/components/icon_button';
|
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 CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
|
||||||
import type { DonateCheckoutArgs } from './api';
|
import { DonateCheckoutHint } from './checkout';
|
||||||
import { useDonateApi } from './api';
|
|
||||||
import { DonateForm } from './form';
|
import { DonateForm } from './form';
|
||||||
import { DonateSuccess } from './success';
|
import { DonateSuccess } from './success';
|
||||||
|
|
||||||
|
|
@ -20,6 +20,12 @@ interface DonateModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DonateCheckoutArgs {
|
||||||
|
frequency: DonationFrequency;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
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 DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
|
||||||
const intl = useIntl();
|
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(
|
const handleCheckout = useCallback(
|
||||||
({ frequency, amount, currency }: DonateCheckoutArgs) => {
|
({ frequency, amount, currency }: DonateCheckoutArgs) => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
|
|
@ -85,37 +91,15 @@ const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div
|
||||||
className={classNames('dialog-modal__content__form', {
|
className={classNames('dialog-modal__content__form', 'body', {
|
||||||
initial: state === 'start',
|
initial: state === 'start',
|
||||||
checkout: state === 'checkout',
|
checkout: state === 'checkout',
|
||||||
success: state === 'success',
|
success: state === 'success',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{state === 'start' &&
|
{state === 'start' && <DonateForm onSubmit={handleCheckout} />}
|
||||||
(donationData ? (
|
{state === 'checkout' && <DonateCheckoutHint donateUrl={donateUrl} />}
|
||||||
<DonateForm data={donationData} onSubmit={handleCheckout} />
|
{state === 'success' && <DonateSuccess onClose={onClose} />}
|
||||||
) : (
|
|
||||||
<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} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,16 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { DonationFrequency } from '@/mastodon/api_types/donate';
|
||||||
import type { ButtonProps } from '@/mastodon/components/button';
|
import type { ButtonProps } from '@/mastodon/components/button';
|
||||||
import { Button } from '@/mastodon/components/button';
|
import { Button } from '@/mastodon/components/button';
|
||||||
import { Dropdown } from '@/mastodon/components/dropdown';
|
import { Dropdown } from '@/mastodon/components/dropdown';
|
||||||
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
|
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 ExternalLinkIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
||||||
|
|
||||||
import type {
|
import type { DonateCheckoutArgs } from './donate_modal';
|
||||||
DonateCheckoutArgs,
|
|
||||||
DonateServerResponse,
|
|
||||||
DonationFrequency,
|
|
||||||
} from './api';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
one_time: { id: 'donate.frequency.one_time', defaultMessage: 'Just once' },
|
one_time: { id: 'donate.frequency.one_time', defaultMessage: 'Just once' },
|
||||||
|
|
@ -24,16 +23,14 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
interface DonateFormProps {
|
interface DonateFormProps {
|
||||||
data?: DonateServerResponse;
|
|
||||||
onSubmit: (args: DonateCheckoutArgs) => void;
|
onSubmit: (args: DonateCheckoutArgs) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DonateForm: FC<Required<DonateFormProps>> = ({
|
export const DonateForm: FC<Required<DonateFormProps>> = ({ onSubmit }) => {
|
||||||
data,
|
|
||||||
onSubmit,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const donateData = useAppSelector((state) => state.donate.apiResponse);
|
||||||
|
|
||||||
const [frequency, setFrequency] = useState<DonationFrequency>('one_time');
|
const [frequency, setFrequency] = useState<DonationFrequency>('one_time');
|
||||||
const handleFrequencyToggle = useCallback((value: DonationFrequency) => {
|
const handleFrequencyToggle = useCallback((value: DonationFrequency) => {
|
||||||
return () => {
|
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(
|
const currencyOptions: SelectItem[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.keys(data.amounts.one_time).map((code) => ({
|
Object.keys(donateData?.amounts.one_time ?? []).map((code) => ({
|
||||||
value: code,
|
value: code,
|
||||||
text: code,
|
text: code,
|
||||||
})),
|
})),
|
||||||
[data.amounts],
|
[donateData?.amounts],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [amount, setAmount] = useState(
|
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) => {
|
const handleAmountChange = useCallback((event: SyntheticEvent) => {
|
||||||
let newAmount = 1;
|
let newAmount = 1;
|
||||||
|
|
@ -69,29 +69,35 @@ export const DonateForm: FC<Required<DonateFormProps>> = ({
|
||||||
currency,
|
currency,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
});
|
});
|
||||||
return Object.values(data.amounts[frequency][currency] ?? {}).map(
|
return Object.values(donateData?.amounts[frequency][currency] ?? {}).map(
|
||||||
(value) => ({
|
(value) => ({
|
||||||
value: value.toString(),
|
value: value.toString(),
|
||||||
text: formatter.format(value / 100),
|
text: formatter.format(value / 100),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [currency, data.amounts, frequency]);
|
}, [currency, donateData?.amounts, frequency]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
onSubmit({ frequency, amount, currency });
|
onSubmit({ frequency, amount, currency });
|
||||||
}, [amount, currency, frequency, onSubmit]);
|
}, [amount, currency, frequency, onSubmit]);
|
||||||
|
|
||||||
|
if (!donateData) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='row'>
|
<div className='row'>
|
||||||
{(Object.keys(data.amounts) as DonationFrequency[]).map((freq) => (
|
{(Object.keys(donateData.amounts) as DonationFrequency[]).map(
|
||||||
<ToggleButton
|
(freq) => (
|
||||||
key={freq}
|
<ToggleButton
|
||||||
active={frequency === freq}
|
key={freq}
|
||||||
onClick={handleFrequencyToggle(freq)}
|
active={frequency === freq}
|
||||||
text={intl.formatMessage(messages[freq])}
|
onClick={handleFrequencyToggle(freq)}
|
||||||
/>
|
text={intl.formatMessage(messages[freq])}
|
||||||
))}
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='row row--select'>
|
<div className='row row--select'>
|
||||||
|
|
|
||||||
|
|
@ -4,29 +4,23 @@ import type { FC } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import donateIllustration from '@/images/donation_successful.png';
|
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 { Button } from '@/mastodon/components/button';
|
||||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||||
|
|
||||||
import type { DonateServerResponse } from './api';
|
|
||||||
|
|
||||||
interface DonateSuccessProps {
|
interface DonateSuccessProps {
|
||||||
data: DonateServerResponse;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DonateSuccess: FC<DonateSuccessProps> = ({ data, onClose }) => {
|
export const DonateSuccess: FC<DonateSuccessProps> = ({ onClose }) => {
|
||||||
const hasComposerContent = useAppSelector(
|
const hasComposerContent = useAppSelector(
|
||||||
(state) => !!state.compose.get('text'),
|
(state) => !!state.compose.get('text'),
|
||||||
);
|
);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const handleShare = useCallback(() => {
|
const handleShare = useCallback(() => {
|
||||||
const shareText = data.donation_success_post;
|
dispatch(composeDonateShare());
|
||||||
dispatch(resetCompose());
|
}, [dispatch]);
|
||||||
dispatch(focusCompose(shareText));
|
|
||||||
onClose();
|
|
||||||
}, [data.donation_success_post, dispatch, onClose]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -41,11 +35,12 @@ export const DonateSuccess: FC<DonateSuccessProps> = ({ data, onClose }) => {
|
||||||
defaultMessage='Thanks for your donation!'
|
defaultMessage='Thanks for your donation!'
|
||||||
tagName='h2'
|
tagName='h2'
|
||||||
/>
|
/>
|
||||||
<FormattedMessage
|
<p className='muted'>
|
||||||
id='donate.success.subtitle'
|
<FormattedMessage
|
||||||
defaultMessage='You should receive an email confirming your donation soon.'
|
id='donate.success.subtitle'
|
||||||
tagName='p'
|
defaultMessage='You should receive an email confirming your donation soon.'
|
||||||
/>
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
<Button block onClick={handleShare}>
|
<Button block onClick={handleShare}>
|
||||||
<ShareIcon />
|
<ShareIcon />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user