From d8522e4c5c51a923b548233bc0306b878c7e3394 Mon Sep 17 00:00:00 2001 From: ChaosExAnima Date: Thu, 11 Sep 2025 18:57:31 +0200 Subject: [PATCH] set up API for donations --- .../mastodon/features/donate/api.ts | 88 +++++++ .../features/donate/donate_modal.scss | 15 +- .../mastodon/features/donate/donate_modal.tsx | 218 +++++++++--------- 3 files changed, 213 insertions(+), 108 deletions(-) create mode 100644 app/javascript/mastodon/features/donate/api.ts diff --git a/app/javascript/mastodon/features/donate/api.ts b/app/javascript/mastodon/features/donate/api.ts new file mode 100644 index 00000000000..fee0687e13b --- /dev/null +++ b/app/javascript/mastodon/features/donate/api.ts @@ -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(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; + 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; + +async function fetchCampaign( + params: DonateServerRequest, +): Promise { + // 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; +} + +interface DonateServerRequest { + locale: string; + seed: number; + source: string; + return_url?: string; +} diff --git a/app/javascript/mastodon/features/donate/donate_modal.scss b/app/javascript/mastodon/features/donate/donate_modal.scss index d8137750164..6495b391eea 100644 --- a/app/javascript/mastodon/features/donate/donate_modal.scss +++ b/app/javascript/mastodon/features/donate/donate_modal.scss @@ -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%; diff --git a/app/javascript/mastodon/features/donate/donate_modal.tsx b/app/javascript/mastodon/features/donate/donate_modal.tsx index abeb9ce85be..00d2a16250a 100644 --- a/app/javascript/mastodon/features/donate/donate_modal.tsx +++ b/app/javascript/mastodon/features/donate/donate_modal.tsx @@ -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 = forwardRef(({ onClose }, ref) => { const intl = useIntl(); - const [frequency, setFrequency] = useState('one_time'); - const handleFrequencyToggle = useCallback((value: Frequency) => { - return () => { - setFrequency(value); - }; - }, []); - - const [currency, setCurrency] = useState('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 (
@@ -93,65 +48,122 @@ const DonateModal: FC = forwardRef(({ onClose }, ref) => { onClick={onClose} /> -
-
- - One Time - - - Monthly - -
- -
- - -
- -
- {amountOptions.map((option) => ( - - ))} -
- - - -

- You will be redirected to joinmastodon.org for secure payment -

-
+
+ {!donationData ? ( + + ) : ( + + )} +
); }); DonateModal.displayName = 'DonateModal'; +const DonateForm: FC<{ data: DonateServerResponse }> = ({ data }) => { + const [frequency, setFrequency] = useState('one_time'); + const handleFrequencyToggle = useCallback((value: DonationFrequency) => { + return () => { + setFrequency(value); + }; + }, []); + + const [currency, setCurrency] = useState(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 ( + <> +
+ + One Time + + + Monthly + +
+ +
+ + +
+ +
+ {amountOptions.map((option) => ( + + ))} +
+ + + +

+ You will be redirected to joinmastodon.org for secure payment +

+ + ); +}; + const ToggleButton: FC = ({ active, ...props