mirror of
https://github.com/mastodon/mastodon.git
synced 2025-10-05 08:33:00 +00:00
set up success screen
This commit is contained in:
parent
2f1aae289e
commit
aa99edaf6d
BIN
app/javascript/images/donation_successful.png
Normal file
BIN
app/javascript/images/donation_successful.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 714 KiB |
|
@ -58,6 +58,12 @@ export interface DonateServerResponse {
|
|||
default_currency: string;
|
||||
}
|
||||
|
||||
export interface DonateCheckoutArgs {
|
||||
frequency: DonationFrequency;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
type DonateAmount = Record<string, number[]>;
|
||||
|
||||
async function fetchCampaign(
|
||||
|
|
|
@ -31,6 +31,11 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--button-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
|
@ -52,12 +57,17 @@
|
|||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.button.toggle:not(.active) {
|
||||
background-color: inherit;
|
||||
border: 2px solid var(--button-color);
|
||||
color: var(--button-color);
|
||||
.toggle {
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
&:not(.active) {
|
||||
background-color: inherit;
|
||||
border-color: var(--button-color);
|
||||
color: var(--button-color);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: var(--button-hover-color);
|
||||
background-color: var(--button-hover-color);
|
||||
color: white;
|
||||
|
@ -66,16 +76,30 @@
|
|||
|
||||
.submit {
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
button > svg {
|
||||
fill: currentColor;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: var(--input-placeholder-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.success {
|
||||
.illustration {
|
||||
max-width: 350px;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,22 +1,20 @@
|
|||
import type { FC, SyntheticEvent } from 'react';
|
||||
import { forwardRef, useCallback, useMemo, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { forwardRef, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
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 { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import ExternalLinkIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
||||
|
||||
import type { DonateCheckoutArgs } from './api';
|
||||
import { useDonateApi } from './api';
|
||||
import { DonateForm } from './form';
|
||||
import { DonateSuccess } from './success';
|
||||
|
||||
import './donate_modal.scss';
|
||||
import type { DonateServerResponse, DonationFrequency } from './api';
|
||||
import { useDonateApi } from './api';
|
||||
|
||||
interface DonateModalProps {
|
||||
onClose: () => void;
|
||||
|
@ -24,23 +22,59 @@ interface DonateModalProps {
|
|||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
one_time: { id: 'donate.frequency.one_time', defaultMessage: 'Just once' },
|
||||
monthly: { id: 'donate.frequency.monthly', defaultMessage: 'Monthly' },
|
||||
yearly: { id: 'donate.frequency.yearly', defaultMessage: 'Yearly' },
|
||||
});
|
||||
|
||||
// TODO: Use environment variable
|
||||
const CHECKOUT_URL = 'http://localhost:3001/donate/checkout';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- React throws a warning if not set.
|
||||
const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const donationData = useDonateApi();
|
||||
const donationData = useDonateApi() ?? undefined;
|
||||
|
||||
const [donateUrl, setDonateUrl] = useState<null | string>(null);
|
||||
const handleCheckout = useCallback(
|
||||
({ frequency, amount, currency }: DonateCheckoutArgs) => {
|
||||
const params = new URLSearchParams({
|
||||
frequency,
|
||||
amount: amount.toString(),
|
||||
currency,
|
||||
source: window.location.origin,
|
||||
});
|
||||
setState('checkout');
|
||||
|
||||
const url = `${CHECKOUT_URL}?${params.toString()}`;
|
||||
setDonateUrl(url);
|
||||
try {
|
||||
window.open(url);
|
||||
} catch (err) {
|
||||
console.warn('Error opening checkout window:', err);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Check response from opened page
|
||||
const [state, setState] = useState<'start' | 'checkout' | 'success'>('start');
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.data === 'payment_success' && state === 'checkout') {
|
||||
setState('success');
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
return () => {
|
||||
window.removeEventListener('message', handler);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal donate_modal'>
|
||||
<div className='dialog-modal__content'>
|
||||
<header className='row'>
|
||||
<span className='dialog-modal__header__title title'>
|
||||
{donationData?.donation_message}
|
||||
{state === 'start' && donationData?.donation_message}
|
||||
</span>
|
||||
<IconButton
|
||||
className='dialog-modal__header__close'
|
||||
|
@ -50,140 +84,44 @@ const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
|
|||
onClick={onClose}
|
||||
/>
|
||||
</header>
|
||||
<form
|
||||
<div
|
||||
className={classNames('dialog-modal__content__form', {
|
||||
loading: !donationData,
|
||||
initial: state === 'start',
|
||||
checkout: state === 'checkout',
|
||||
success: state === 'success',
|
||||
})}
|
||||
>
|
||||
{!donationData ? (
|
||||
<LoadingIndicator />
|
||||
{state === 'start' &&
|
||||
(donationData ? (
|
||||
<DonateForm data={donationData} onSubmit={handleCheckout} />
|
||||
) : (
|
||||
<DonateForm data={donationData} />
|
||||
<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>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</p>
|
||||
)}
|
||||
{state === 'success' && donationData && (
|
||||
<DonateSuccess data={donationData} onClose={onClose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
DonateModal.displayName = 'DonateModal';
|
||||
|
||||
const DonateForm: FC<{ data: DonateServerResponse }> = ({ data }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [frequency, setFrequency] = useState<DonationFrequency>('one_time');
|
||||
const handleFrequencyToggle = useCallback((value: DonationFrequency) => {
|
||||
return () => {
|
||||
setFrequency(value);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [currency, setCurrency] = useState<string>(data.default_currency);
|
||||
const currencyOptions: SelectItem[] = useMemo(
|
||||
() =>
|
||||
Object.keys(data.amounts.one_time).map((code) => ({
|
||||
value: code,
|
||||
text: code,
|
||||
})),
|
||||
[data.amounts],
|
||||
);
|
||||
|
||||
const [amount, setAmount] = useState(
|
||||
() => data.amounts[frequency][data.default_currency]?.[0] ?? 1000,
|
||||
);
|
||||
const handleAmountChange = useCallback((event: SyntheticEvent) => {
|
||||
let newAmount = 1;
|
||||
if (event.target instanceof HTMLButtonElement) {
|
||||
newAmount = Number.parseInt(event.target.value);
|
||||
} else if (event.target instanceof HTMLInputElement) {
|
||||
newAmount = event.target.valueAsNumber * 100;
|
||||
}
|
||||
setAmount(newAmount);
|
||||
}, []);
|
||||
const amountOptions: SelectItem[] = useMemo(() => {
|
||||
const formatter = new Intl.NumberFormat('en', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
return Object.values(data.amounts[frequency][currency] ?? {}).map(
|
||||
(value) => ({
|
||||
value: value.toString(),
|
||||
text: formatter.format(value / 100),
|
||||
}),
|
||||
);
|
||||
}, [currency, data.amounts, frequency]);
|
||||
|
||||
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])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='row row--select'>
|
||||
<Dropdown
|
||||
items={currencyOptions}
|
||||
current={currency}
|
||||
classPrefix='donate_modal'
|
||||
onChange={setCurrency}
|
||||
/>
|
||||
<input
|
||||
type='number'
|
||||
min='1'
|
||||
step='0.01'
|
||||
value={(amount / 100).toFixed(2)}
|
||||
onChange={handleAmountChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='row'>
|
||||
{amountOptions.map((option) => (
|
||||
<ToggleButton
|
||||
key={option.value}
|
||||
onClick={handleAmountChange}
|
||||
active={amount === Number.parseInt(option.value)}
|
||||
value={option.value}
|
||||
text={option.text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button className='submit' block type='submit'>
|
||||
<FormattedMessage
|
||||
id='donate.continue'
|
||||
defaultMessage='Continue to payment'
|
||||
/>
|
||||
<ExternalLinkIcon />
|
||||
</Button>
|
||||
|
||||
<p className='footer'>
|
||||
<FormattedMessage
|
||||
id='donate.redirect_notice'
|
||||
defaultMessage='You will be redirected to joinmastodon.org for secure payment'
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ToggleButton: FC<ButtonProps & { active: boolean }> = ({
|
||||
active,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
block
|
||||
{...props}
|
||||
className={classNames('toggle', props.className, { active })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export -- modal_root expects a default export.
|
||||
export default DonateModal;
|
||||
|
|
154
app/javascript/mastodon/features/donate/form.tsx
Normal file
154
app/javascript/mastodon/features/donate/form.tsx
Normal file
|
@ -0,0 +1,154 @@
|
|||
import type { FC, SyntheticEvent } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
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 ExternalLinkIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
||||
|
||||
import type {
|
||||
DonateCheckoutArgs,
|
||||
DonateServerResponse,
|
||||
DonationFrequency,
|
||||
} from './api';
|
||||
|
||||
const messages = defineMessages({
|
||||
one_time: { id: 'donate.frequency.one_time', defaultMessage: 'Just once' },
|
||||
monthly: { id: 'donate.frequency.monthly', defaultMessage: 'Monthly' },
|
||||
yearly: { id: 'donate.frequency.yearly', defaultMessage: 'Yearly' },
|
||||
});
|
||||
|
||||
interface DonateFormProps {
|
||||
data?: DonateServerResponse;
|
||||
onSubmit: (args: DonateCheckoutArgs) => void;
|
||||
}
|
||||
|
||||
export const DonateForm: FC<Required<DonateFormProps>> = ({
|
||||
data,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [frequency, setFrequency] = useState<DonationFrequency>('one_time');
|
||||
const handleFrequencyToggle = useCallback((value: DonationFrequency) => {
|
||||
return () => {
|
||||
setFrequency(value);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [currency, setCurrency] = useState<string>(data.default_currency);
|
||||
const currencyOptions: SelectItem[] = useMemo(
|
||||
() =>
|
||||
Object.keys(data.amounts.one_time).map((code) => ({
|
||||
value: code,
|
||||
text: code,
|
||||
})),
|
||||
[data.amounts],
|
||||
);
|
||||
|
||||
const [amount, setAmount] = useState(
|
||||
() => data.amounts[frequency][data.default_currency]?.[0] ?? 1000,
|
||||
);
|
||||
const handleAmountChange = useCallback((event: SyntheticEvent) => {
|
||||
let newAmount = 1;
|
||||
if (event.target instanceof HTMLButtonElement) {
|
||||
newAmount = Number.parseInt(event.target.value);
|
||||
} else if (event.target instanceof HTMLInputElement) {
|
||||
newAmount = event.target.valueAsNumber * 100;
|
||||
}
|
||||
setAmount(newAmount);
|
||||
}, []);
|
||||
const amountOptions: SelectItem[] = useMemo(() => {
|
||||
const formatter = new Intl.NumberFormat('en', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
return Object.values(data.amounts[frequency][currency] ?? {}).map(
|
||||
(value) => ({
|
||||
value: value.toString(),
|
||||
text: formatter.format(value / 100),
|
||||
}),
|
||||
);
|
||||
}, [currency, data.amounts, frequency]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onSubmit({ frequency, amount, currency });
|
||||
}, [amount, currency, frequency, onSubmit]);
|
||||
|
||||
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])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='row row--select'>
|
||||
<Dropdown
|
||||
items={currencyOptions}
|
||||
current={currency}
|
||||
classPrefix='donate_modal'
|
||||
onChange={setCurrency}
|
||||
/>
|
||||
<input
|
||||
type='number'
|
||||
min='1'
|
||||
step='0.01'
|
||||
value={(amount / 100).toFixed(2)}
|
||||
onChange={handleAmountChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='row'>
|
||||
{amountOptions.map((option) => (
|
||||
<ToggleButton
|
||||
key={option.value}
|
||||
onClick={handleAmountChange}
|
||||
active={amount === Number.parseInt(option.value)}
|
||||
value={option.value}
|
||||
text={option.text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button className='submit' onClick={handleSubmit} block>
|
||||
<FormattedMessage
|
||||
id='donate.continue'
|
||||
defaultMessage='Continue to payment'
|
||||
/>
|
||||
<ExternalLinkIcon />
|
||||
</Button>
|
||||
|
||||
<p className='footer'>
|
||||
<FormattedMessage
|
||||
id='donate.redirect_notice'
|
||||
defaultMessage='You will be redirected to joinmastodon.org for secure payment'
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ToggleButton: FC<ButtonProps & { active: boolean }> = ({
|
||||
active,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
block
|
||||
{...props}
|
||||
className={classNames('toggle', props.className, { active })}
|
||||
/>
|
||||
);
|
||||
};
|
70
app/javascript/mastodon/features/donate/success.tsx
Normal file
70
app/javascript/mastodon/features/donate/success.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { useCallback } from 'react';
|
||||
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 { 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 }) => {
|
||||
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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={donateIllustration}
|
||||
alt=''
|
||||
role='presentation'
|
||||
className='illustration'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='donate.success.title'
|
||||
defaultMessage='Thanks for your donation!'
|
||||
tagName='h2'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='donate.success.subtitle'
|
||||
defaultMessage='You should receive an email confirming your donation soon.'
|
||||
tagName='p'
|
||||
/>
|
||||
|
||||
<Button block onClick={handleShare}>
|
||||
<ShareIcon />
|
||||
<FormattedMessage
|
||||
id='donate.success.share'
|
||||
defaultMessage='Spread the word'
|
||||
/>
|
||||
</Button>
|
||||
<Button secondary block onClick={onClose}>
|
||||
<FormattedMessage id='lightbox.close' defaultMessage='Close' />
|
||||
</Button>
|
||||
{hasComposerContent && (
|
||||
<p className='footer'>
|
||||
<FormattedMessage
|
||||
id='donate.success.footer'
|
||||
defaultMessage='Sharing will overwrite your current post draft.'
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user