diff --git a/app/javascript/images/donation_successful.png b/app/javascript/images/donation_successful.png new file mode 100644 index 00000000000..ad10f9db7fd Binary files /dev/null and b/app/javascript/images/donation_successful.png differ diff --git a/app/javascript/mastodon/features/donate/api.ts b/app/javascript/mastodon/features/donate/api.ts index 7a721a17daf..4ed80e843ff 100644 --- a/app/javascript/mastodon/features/donate/api.ts +++ b/app/javascript/mastodon/features/donate/api.ts @@ -58,6 +58,12 @@ export interface DonateServerResponse { default_currency: string; } +export interface DonateCheckoutArgs { + frequency: DonationFrequency; + amount: number; + currency: string; +} + type DonateAmount = Record; async function fetchCampaign( diff --git a/app/javascript/mastodon/features/donate/donate_modal.scss b/app/javascript/mastodon/features/donate/donate_modal.scss index 4be265c2fdc..d64a42520da 100644 --- a/app/javascript/mastodon/features/donate/donate_modal.scss +++ b/app/javascript/mastodon/features/donate/donate_modal.scss @@ -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,11 +76,11 @@ .submit { padding: 0.6rem 1rem; + } - svg { - fill: currentColor; - height: 1em; - } + button > svg { + fill: currentColor; + height: 1em; } .footer { @@ -78,4 +88,18 @@ 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; + } + } } diff --git a/app/javascript/mastodon/features/donate/donate_modal.tsx b/app/javascript/mastodon/features/donate/donate_modal.tsx index ad98a660b3e..b6a4751e212 100644 --- a/app/javascript/mastodon/features/donate/donate_modal.tsx +++ b/app/javascript/mastodon/features/donate/donate_modal.tsx @@ -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 = forwardRef(({ onClose }, ref) => { const intl = useIntl(); - const donationData = useDonateApi(); + const donationData = useDonateApi() ?? undefined; + + const [donateUrl, setDonateUrl] = useState(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 (
- {donationData?.donation_message} + {state === 'start' && donationData?.donation_message} = forwardRef(({ onClose }, ref) => { onClick={onClose} />
-
- {!donationData ? ( - - ) : ( - + {state === 'start' && + (donationData ? ( + + ) : ( + + ))} + {state === 'checkout' && ( +

+ 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. */} + + click here + + . + + )} +

)} - + {state === 'success' && donationData && ( + + )} +
); }); DonateModal.displayName = 'DonateModal'; -const DonateForm: FC<{ data: DonateServerResponse }> = ({ data }) => { - const intl = useIntl(); - - 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] ?? 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 ( - <> -
- {(Object.keys(data.amounts) as DonationFrequency[]).map((freq) => ( - - ))} -
- -
- - -
- -
- {amountOptions.map((option) => ( - - ))} -
- - - -

- -

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

+ +

+ + ); +}; + +const ToggleButton: FC = ({ + active, + ...props +}) => { + return ( + + + {hasComposerContent && ( +

+ +

+ )} + + ); +};