allow uncontrolled input and add a back button on checkout.

This commit is contained in:
ChaosExAnima 2025-09-12 19:28:18 +02:00
parent a3cf9c669d
commit caec59c46d
No known key found for this signature in database
GPG Key ID: 8F2B333100FB6117
4 changed files with 83 additions and 34 deletions

View File

@ -2,26 +2,36 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { Button } from '@/mastodon/components/button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
export const DonateCheckoutHint: FC<{ donateUrl?: string }> = ({
donateUrl,
}) => {
export const DonateCheckoutHint: FC<{
donateUrl?: string;
onBack: () => void;
}> = ({ donateUrl, onBack }) => {
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'
/>
<>
<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'
/>
<Button secondary onClick={onBack}>
<FormattedMessage
id='donate.checkout.back'
defaultMessage='Edit donation'
/>
</Button>
</>
);
};

View File

@ -137,6 +137,13 @@
font-size: 0.9em;
}
.checkout {
a {
text-decoration: underline;
color: inherit;
}
}
.success {
.illustration {
width: 100%;

View File

@ -61,6 +61,11 @@ const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
[],
);
const handleCheckoutBack = useCallback(() => {
setState('start');
setDonateUrl(undefined);
}, []);
// Check response from opened page
const [state, setState] = useState<'start' | 'checkout' | 'success'>('start');
useEffect(() => {
@ -98,7 +103,12 @@ const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
})}
>
{state === 'start' && <DonateForm onSubmit={handleCheckout} />}
{state === 'checkout' && <DonateCheckoutHint donateUrl={donateUrl} />}
{state === 'checkout' && (
<DonateCheckoutHint
donateUrl={donateUrl}
onBack={handleCheckoutBack}
/>
)}
{state === 'success' && <DonateSuccess onClose={onClose} />}
</div>
</div>

View File

@ -1,11 +1,14 @@
import type { FC, SyntheticEvent } from 'react';
import type { FC, FocusEventHandler, SyntheticEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import type { DonationFrequency } from '@/mastodon/api_types/donate';
import type {
DonateServerResponse,
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';
@ -26,65 +29,84 @@ interface DonateFormProps {
onSubmit: (args: DonateCheckoutArgs) => void;
}
export const DonateForm: FC<Required<DonateFormProps>> = ({ onSubmit }) => {
const DefaultAmount = 1000; // 10.00
export const DonateForm: FC<DonateFormProps> = (props) => {
const donateData = useAppSelector((state) => state.donate.apiResponse);
if (!donateData) {
return <LoadingIndicator />;
}
return <DonateFormInner {...props} data={donateData} />;
};
export const DonateFormInner: FC<
DonateFormProps & { data: DonateServerResponse }
> = ({ onSubmit, data: donateData }) => {
const intl = useIntl();
const donateData = useAppSelector((state) => state.donate.apiResponse);
const [frequency, setFrequency] = useState<DonationFrequency>('one_time');
// Nested function to allow passing parameters in onClick.
const handleFrequencyToggle = useCallback((value: DonationFrequency) => {
return () => {
setFrequency(value);
};
}, []);
const [currency, setCurrency] = useState<string>(
donateData?.default_currency ?? 'EUR',
);
const [currency, setCurrency] = useState(donateData.default_currency);
const currencyOptions: SelectItem[] = useMemo(
() =>
Object.keys(donateData?.amounts.one_time ?? []).map((code) => ({
Object.keys(donateData.amounts.one_time).map((code) => ({
value: code,
text: code,
})),
[donateData?.amounts],
[donateData.amounts],
);
// Amounts handling
const [amount, setAmount] = useState(
() =>
donateData?.amounts[frequency][donateData.default_currency]?.[0] ?? 1000,
donateData.amounts[frequency][donateData.default_currency]?.[0] ??
DefaultAmount,
);
const handleAmountChange = useCallback((event: SyntheticEvent) => {
// Coerce the value into a valid amount depending on the source of the event.
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;
}
// If invalid, just use the default.
if (Number.isNaN(newAmount) || newAmount < 1) {
newAmount = DefaultAmount;
}
setAmount(newAmount);
}, []);
// The input field is uncontrolled to not interfere with user input, but set the value to the state on blue.
const handleAmountBlur: FocusEventHandler<HTMLInputElement> = useCallback(
(event) => {
event.target.value = (amount / 100).toFixed(2);
},
[amount],
);
const amountOptions: SelectItem[] = useMemo(() => {
const formatter = new Intl.NumberFormat('en', {
style: 'currency',
currency,
maximumFractionDigits: 0,
});
return Object.values(donateData?.amounts[frequency][currency] ?? {}).map(
return Object.values(donateData.amounts[frequency][currency] ?? {}).map(
(value) => ({
value: value.toString(),
text: formatter.format(value / 100),
}),
);
}, [currency, donateData?.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'>
@ -111,8 +133,8 @@ export const DonateForm: FC<Required<DonateFormProps>> = ({ onSubmit }) => {
type='number'
min='1'
step='0.01'
value={(amount / 100).toFixed(2)}
onChange={handleAmountChange}
onBlur={handleAmountBlur}
/>
</div>