set up success screen

This commit is contained in:
ChaosExAnima 2025-09-12 14:48:10 +02:00
parent 2f1aae289e
commit aa99edaf6d
No known key found for this signature in database
GPG Key ID: 8F2B333100FB6117
6 changed files with 341 additions and 149 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

View File

@ -58,6 +58,12 @@ export interface DonateServerResponse {
default_currency: string; default_currency: string;
} }
export interface DonateCheckoutArgs {
frequency: DonationFrequency;
amount: number;
currency: string;
}
type DonateAmount = Record<string, number[]>; type DonateAmount = Record<string, number[]>;
async function fetchCampaign( async function fetchCampaign(

View File

@ -31,6 +31,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.5rem; padding: 0.5rem;
&:hover,
&:focus {
background-color: var(--button-hover-color);
}
} }
input { input {
@ -52,12 +57,17 @@
flex-grow: 1; flex-grow: 1;
} }
.button.toggle:not(.active) { .toggle {
background-color: inherit; border: 2px solid transparent;
border: 2px solid var(--button-color);
color: var(--button-color);
&:hover { &:not(.active) {
background-color: inherit;
border-color: var(--button-color);
color: var(--button-color);
}
&:hover,
&:focus {
border-color: var(--button-hover-color); border-color: var(--button-hover-color);
background-color: var(--button-hover-color); background-color: var(--button-hover-color);
color: white; color: white;
@ -66,11 +76,11 @@
.submit { .submit {
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
}
svg { button > svg {
fill: currentColor; fill: currentColor;
height: 1em; height: 1em;
}
} }
.footer { .footer {
@ -78,4 +88,18 @@
color: var(--input-placeholder-color); color: var(--input-placeholder-color);
font-size: 0.9em; 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;
}
}
} }

View File

@ -1,22 +1,20 @@
import type { FC, SyntheticEvent } from 'react'; import type { FC } from 'react';
import { forwardRef, useCallback, useMemo, useState } 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 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 { IconButton } from '@/mastodon/components/icon_button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; 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 './donate_modal.scss';
import type { DonateServerResponse, DonationFrequency } from './api';
import { useDonateApi } from './api';
interface DonateModalProps { interface DonateModalProps {
onClose: () => void; onClose: () => void;
@ -24,23 +22,59 @@ interface DonateModalProps {
const messages = defineMessages({ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, 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. // eslint-disable-next-line @typescript-eslint/no-unused-vars -- React throws a warning if not set.
const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => { const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
const intl = useIntl(); 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 ( return (
<div className='modal-root__modal dialog-modal donate_modal'> <div className='modal-root__modal dialog-modal donate_modal'>
<div className='dialog-modal__content'> <div className='dialog-modal__content'>
<header className='row'> <header className='row'>
<span className='dialog-modal__header__title title'> <span className='dialog-modal__header__title title'>
{donationData?.donation_message} {state === 'start' && donationData?.donation_message}
</span> </span>
<IconButton <IconButton
className='dialog-modal__header__close' className='dialog-modal__header__close'
@ -50,140 +84,44 @@ const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
onClick={onClose} onClick={onClose}
/> />
</header> </header>
<form <div
className={classNames('dialog-modal__content__form', { className={classNames('dialog-modal__content__form', {
loading: !donationData, initial: state === 'start',
checkout: state === 'checkout',
success: state === 'success',
})} })}
> >
{!donationData ? ( {state === 'start' &&
<LoadingIndicator /> (donationData ? (
) : ( <DonateForm data={donationData} onSubmit={handleCheckout} />
<DonateForm data={donationData} /> ) : (
<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>
)} )}
</form> {state === 'success' && donationData && (
<DonateSuccess data={donationData} onClose={onClose} />
)}
</div>
</div> </div>
</div> </div>
); );
}); });
DonateModal.displayName = 'DonateModal'; 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. // eslint-disable-next-line import/no-default-export -- modal_root expects a default export.
export default DonateModal; export default DonateModal;

View 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 })}
/>
);
};

View 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>
)}
</>
);
};