set up API for donations

This commit is contained in:
ChaosExAnima 2025-09-11 18:57:31 +02:00
parent 1dcdcf404c
commit d8522e4c5c
No known key found for this signature in database
GPG Key ID: 8F2B333100FB6117
3 changed files with 213 additions and 108 deletions

View File

@ -0,0 +1,88 @@
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, source: 'web' })
.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;
}
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', 'web');
const response = await fetch(url);
if (!response.ok) {
return null;
}
return response.json() as Promise<DonateServerResponse>;
}
interface DonateServerRequest {
locale: string;
seed: number;
source: string;
return_url?: string;
}

View File

@ -3,16 +3,21 @@
gap: 1rem;
}
.row {
display: flex;
gap: 1rem;
}
header {
padding: 24px 24px 0;
align-items: start;
}
form {
min-height: 200px;
position: relative;
}
.row {
display: flex;
gap: 1rem;
}
.row--select {
gap: 0;
width: 100%;

View File

@ -1,5 +1,5 @@
import type { FC, SyntheticEvent } from 'react';
import { forwardRef, useCallback, useState } from 'react';
import { forwardRef, useCallback, useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -10,72 +10,27 @@ 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 './donate_modal.scss';
import type { DonateServerResponse, DonationFrequency } from './api';
import { useDonateApi } from './api';
interface DonateModalProps {
onClose: () => void;
}
type Frequency = 'one_time' | 'monthly';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
const currencyOptions = [
{
value: 'usd',
text: 'USD',
},
{
value: 'eur',
text: 'EUR',
},
] as const satisfies SelectItem[];
type Currency = (typeof currencyOptions)[number]['value'];
function isCurrency(value: string): value is Currency {
return currencyOptions.some((option) => option.value === value);
}
const amountOptions = [
{ value: '2000', text: '€20' },
{ value: '1000', text: '€10' },
{ value: '500', text: '€5' },
{ value: '300', text: '€3' },
] as const satisfies SelectItem[];
// 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 [frequency, setFrequency] = useState<Frequency>('one_time');
const handleFrequencyToggle = useCallback((value: Frequency) => {
return () => {
setFrequency(value);
};
}, []);
const [currency, setCurrency] = useState<Currency>('usd');
const handleCurrencyChange = useCallback((value: string) => {
if (isCurrency(value)) {
setCurrency(value);
}
}, []);
const [amount, setAmount] = useState(() =>
Number.parseInt(amountOptions[0].value),
);
const handleAmountChange = useCallback((event: SyntheticEvent) => {
if (
event.target instanceof HTMLButtonElement ||
event.target instanceof HTMLInputElement
) {
setAmount(Number.parseInt(event.target.value));
}
}, []);
const donationData = useDonateApi();
return (
<div className='modal-root__modal dialog-modal donate_modal'>
@ -93,7 +48,67 @@ const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
onClick={onClose}
/>
</header>
<div className='dialog-modal__content__form'>
<form
className={classNames('dialog-modal__content__form', {
loading: !donationData,
})}
>
{!donationData ? (
<LoadingIndicator />
) : (
<DonateForm data={donationData} />
)}
</form>
</div>
</div>
);
});
DonateModal.displayName = 'DonateModal';
const DonateForm: FC<{ data: DonateServerResponse }> = ({ data }) => {
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] ?? 'EUR',
);
const handleAmountChange = useCallback((event: SyntheticEvent) => {
if (
event.target instanceof HTMLButtonElement ||
event.target instanceof HTMLInputElement
) {
setAmount(Number.parseInt(event.target.value));
}
}, []);
const amountOptions: SelectItem[] = useMemo(() => {
const formatter = new Intl.NumberFormat('en', {
style: 'currency',
currency,
});
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'>
<ToggleButton
active={frequency === 'one_time'}
@ -114,7 +129,7 @@ const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
items={currencyOptions}
current={currency}
classPrefix='donate_modal'
onChange={handleCurrencyChange}
onChange={setCurrency}
/>
<input
type='number'
@ -137,7 +152,7 @@ const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
))}
</div>
<Button className='submit' block>
<Button className='submit' block type='submit'>
Continue to payment
<ExternalLinkIcon />
</Button>
@ -145,12 +160,9 @@ const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
<p className='footer'>
You will be redirected to joinmastodon.org for secure payment
</p>
</div>
</div>
</div>
</>
);
});
DonateModal.displayName = 'DonateModal';
};
const ToggleButton: FC<ButtonProps & { active: boolean }> = ({
active,