Compare commits
14 Commits
main
...
feat/donat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5899ae83f | ||
|
|
5c4b222dee | ||
|
|
4c9e6c9a3c | ||
|
|
255545385f | ||
|
|
caec59c46d | ||
|
|
a3cf9c669d | ||
|
|
40894760d1 | ||
|
|
e22184c20e | ||
|
|
aa99edaf6d | ||
|
|
2f1aae289e | ||
|
|
d8522e4c5c | ||
|
|
1dcdcf404c | ||
|
|
3c7e612d41 | ||
|
|
89e4fb2f0f |
BIN
app/javascript/images/donation_banner.png
Normal file
BIN
app/javascript/images/donation_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
app/javascript/images/donation_successful.png
Normal file
BIN
app/javascript/images/donation_successful.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 714 KiB |
68
app/javascript/mastodon/actions/donate.ts
Normal file
68
app/javascript/mastodon/actions/donate.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { apiGetDonateData } from '../api/donate';
|
||||
import { createAppAsyncThunk, createAppThunk } from '../store/typed_functions';
|
||||
|
||||
import { focusCompose, resetCompose } from './compose';
|
||||
import { closeModal, openModal } from './modal';
|
||||
|
||||
export const setDonateSeed = createAction<number>('donate/setSeed');
|
||||
|
||||
export const initializeDonate = createAppThunk(
|
||||
(_arg, { dispatch, getState }) => {
|
||||
if (!getState().donate.seed) {
|
||||
let seed = Math.floor(Math.random() * 99) + 1;
|
||||
try {
|
||||
const storedSeed = localStorage.getItem('donate_seed');
|
||||
if (storedSeed) {
|
||||
seed = Number.parseInt(storedSeed, 10);
|
||||
} else {
|
||||
localStorage.setItem('donate_seed', seed.toString());
|
||||
}
|
||||
} catch {
|
||||
// No local storage available, just set a seed for this session.
|
||||
}
|
||||
dispatch(setDonateSeed(seed));
|
||||
}
|
||||
void dispatch(fetchDonateData());
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchDonateData = createAppAsyncThunk(
|
||||
'donate/fetch',
|
||||
(_args, { getState }) => {
|
||||
const state = getState();
|
||||
return apiGetDonateData({
|
||||
locale: state.meta.get('locale', 'en') as string,
|
||||
seed: state.donate.seed ?? 1, // If we somehow don't have the seed, just set it to 1.
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const showDonateModal = createAppThunk(
|
||||
(_arg, { dispatch, getState }) => {
|
||||
const state = getState();
|
||||
const lastPoll = state.donate.nextPoll;
|
||||
if (!lastPoll || Date.now() >= lastPoll) {
|
||||
void dispatch(fetchDonateData());
|
||||
}
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'DONATE',
|
||||
modalProps: {},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const composeDonateShare = createAppThunk(
|
||||
(_arg, { dispatch, getState }) => {
|
||||
const state = getState();
|
||||
const shareText = state.donate.apiResponse?.donation_success_post;
|
||||
if (shareText) {
|
||||
dispatch(resetCompose());
|
||||
dispatch(focusCompose(shareText));
|
||||
}
|
||||
dispatch(closeModal({ modalType: 'DONATE', ignoreFocus: false }));
|
||||
},
|
||||
);
|
||||
30
app/javascript/mastodon/api/donate.ts
Normal file
30
app/javascript/mastodon/api/donate.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
DonateServerRequest,
|
||||
DonateServerResponse,
|
||||
} from '../api_types/donate';
|
||||
|
||||
// TODO: Proxy this through the backend.
|
||||
const API_URL = 'https://api.joinmastodon.org/v1/donations/campaigns/active';
|
||||
|
||||
export const apiGetDonateData = async ({
|
||||
locale,
|
||||
seed,
|
||||
}: DonateServerRequest) => {
|
||||
// Create the URL with query parameters.
|
||||
const params = new URLSearchParams({
|
||||
locale,
|
||||
seed: seed.toString(),
|
||||
platform: 'web',
|
||||
source: 'menu',
|
||||
});
|
||||
const url = new URL(`${API_URL}?${params.toString()}`);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching donation campaign: ${response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
return response.json() as Promise<DonateServerResponse>;
|
||||
};
|
||||
23
app/javascript/mastodon/api_types/donate.ts
Normal file
23
app/javascript/mastodon/api_types/donate.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const donationFrequencyTypes = [
|
||||
'one_time',
|
||||
'monthly',
|
||||
'yearly',
|
||||
] as const;
|
||||
export type DonationFrequency = (typeof donationFrequencyTypes)[number];
|
||||
|
||||
export interface DonateServerRequest {
|
||||
locale: string;
|
||||
seed: number;
|
||||
}
|
||||
|
||||
export interface DonateServerResponse {
|
||||
id: string;
|
||||
amounts: Record<DonationFrequency, Record<string, number[]>>;
|
||||
donation_url: string;
|
||||
banner_message: string;
|
||||
banner_button_text: string;
|
||||
donation_message: string;
|
||||
donation_button_text: string;
|
||||
donation_success_post: string;
|
||||
default_currency: string;
|
||||
}
|
||||
@@ -24,13 +24,13 @@ interface PropsWithText extends BaseProps {
|
||||
children?: undefined;
|
||||
}
|
||||
|
||||
type Props = PropsWithText | PropsChildren;
|
||||
export type ButtonProps = PropsWithText | PropsChildren;
|
||||
|
||||
/**
|
||||
* Primary UI component for user interaction that doesn't result in navigation.
|
||||
*/
|
||||
|
||||
export const Button: React.FC<Props> = ({
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
type = 'button',
|
||||
onClick,
|
||||
disabled,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useCallback, useState, useEffect } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { changeSetting } from 'mastodon/actions/settings';
|
||||
import { bannerSettings } from 'mastodon/settings';
|
||||
@@ -16,6 +18,7 @@ const messages = defineMessages({
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function useDismissableBannerState({ id }: Props) {
|
||||
@@ -53,6 +56,7 @@ export function useDismissableBannerState({ id }: Props) {
|
||||
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
||||
id,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { wasDismissed, dismiss } = useDismissableBannerState({
|
||||
@@ -64,7 +68,7 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='dismissable-banner'>
|
||||
<div className={classNames('dismissable-banner', className)}>
|
||||
<div className='dismissable-banner__action'>
|
||||
<IconButton
|
||||
icon='times'
|
||||
|
||||
@@ -21,7 +21,7 @@ interface DropdownProps {
|
||||
items: SelectItem[];
|
||||
onChange: (value: string) => void;
|
||||
current: string;
|
||||
labelId: string;
|
||||
labelId?: string;
|
||||
descriptionId?: string;
|
||||
emptyText?: MessageDescriptor;
|
||||
classPrefix: string;
|
||||
@@ -79,7 +79,7 @@ export const Dropdown: FC<
|
||||
type='button'
|
||||
{...buttonProps}
|
||||
id={buttonId}
|
||||
aria-labelledby={`${labelId} ${buttonId}`}
|
||||
aria-labelledby={classNames(labelId, buttonId)}
|
||||
aria-describedby={descriptionId}
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
|
||||
43
app/javascript/mastodon/features/donate/banner.tsx
Normal file
43
app/javascript/mastodon/features/donate/banner.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import donateBannerImage from '@/images/donation_banner.png';
|
||||
import { showDonateModal } from '@/mastodon/actions/donate';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { DismissableBanner } from '@/mastodon/components/dismissable_banner';
|
||||
import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from '@/mastodon/store/typed_functions';
|
||||
|
||||
import './styles.scss';
|
||||
|
||||
export const DonateBanner: FC = () => {
|
||||
const donationData = useAppSelector((state) => state.donate.apiResponse);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
dispatch(showDonateModal());
|
||||
}, [dispatch]);
|
||||
|
||||
if (!donationData) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DismissableBanner
|
||||
id={`donate-${donationData.id}`}
|
||||
className='donate_banner'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='donate.banner.title'
|
||||
defaultMessage='Support Mastodon'
|
||||
tagName='h2'
|
||||
/>
|
||||
<p>{donationData.banner_message}</p>
|
||||
<Button text={donationData.banner_button_text} onClick={handleClick} />
|
||||
<img src={donateBannerImage} alt='' role='presentation' />
|
||||
</DismissableBanner>
|
||||
);
|
||||
};
|
||||
37
app/javascript/mastodon/features/donate/checkout.tsx
Normal file
37
app/javascript/mastodon/features/donate/checkout.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
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'
|
||||
/>
|
||||
<Button secondary onClick={onBack}>
|
||||
<FormattedMessage
|
||||
id='donate.checkout.back'
|
||||
defaultMessage='Edit donation'
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
121
app/javascript/mastodon/features/donate/donate_modal.tsx
Normal file
121
app/javascript/mastodon/features/donate/donate_modal.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { FC } from 'react';
|
||||
import { forwardRef, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { DonationFrequency } from '@/mastodon/api_types/donate';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
|
||||
import { DonateCheckoutHint } from './checkout';
|
||||
import { DonateForm } from './form';
|
||||
import { DonateSuccess } from './success';
|
||||
|
||||
import './styles.scss';
|
||||
|
||||
interface DonateModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface DonateCheckoutArgs {
|
||||
frequency: DonationFrequency;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
// 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<DonateModalProps> = forwardRef(({ onClose }, ref) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const donationData = useAppSelector((state) => state.donate.apiResponse);
|
||||
|
||||
const [donateUrl, setDonateUrl] = useState<string | undefined>();
|
||||
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);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCheckoutBack = useCallback(() => {
|
||||
setState('start');
|
||||
setDonateUrl(undefined);
|
||||
}, []);
|
||||
|
||||
// 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 (
|
||||
<div className='modal-root__modal dialog-modal donate_modal'>
|
||||
<div className='dialog-modal__content'>
|
||||
<header className='row'>
|
||||
<span className='dialog-modal__header__title title'>
|
||||
{state === 'start' && donationData?.donation_message}
|
||||
</span>
|
||||
<IconButton
|
||||
className='dialog-modal__header__close'
|
||||
title={intl.formatMessage(messages.close)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
className={classNames('dialog-modal__content__form', 'body', {
|
||||
initial: state === 'start',
|
||||
checkout: state === 'checkout',
|
||||
success: state === 'success',
|
||||
})}
|
||||
>
|
||||
{state === 'start' && <DonateForm onSubmit={handleCheckout} />}
|
||||
{state === 'checkout' && (
|
||||
<DonateCheckoutHint
|
||||
donateUrl={donateUrl}
|
||||
onBack={handleCheckoutBack}
|
||||
/>
|
||||
)}
|
||||
{state === 'success' && <DonateSuccess onClose={onClose} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
DonateModal.displayName = 'DonateModal';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export -- modal_root expects a default export.
|
||||
export default DonateModal;
|
||||
182
app/javascript/mastodon/features/donate/form.tsx
Normal file
182
app/javascript/mastodon/features/donate/form.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
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 {
|
||||
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';
|
||||
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
import ExternalLinkIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
||||
|
||||
import type { DonateCheckoutArgs } from './donate_modal';
|
||||
|
||||
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 {
|
||||
onSubmit: (args: DonateCheckoutArgs) => void;
|
||||
}
|
||||
|
||||
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 [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(donateData.default_currency);
|
||||
const currencyOptions: SelectItem[] = useMemo(
|
||||
() =>
|
||||
Object.keys(donateData.amounts.one_time).map((code) => ({
|
||||
value: code,
|
||||
text: code,
|
||||
})),
|
||||
[donateData.amounts],
|
||||
);
|
||||
|
||||
// Amounts handling
|
||||
const [amount, setAmount] = useState(
|
||||
() =>
|
||||
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(
|
||||
(value) => ({
|
||||
value: value.toString(),
|
||||
text: formatter.format(value / 100),
|
||||
}),
|
||||
);
|
||||
}, [currency, donateData.amounts, frequency]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onSubmit({ frequency, amount, currency });
|
||||
}, [amount, currency, frequency, onSubmit]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='row'>
|
||||
{(Object.keys(donateData.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'
|
||||
onChange={handleAmountChange}
|
||||
onBlur={handleAmountBlur}
|
||||
/>
|
||||
</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 })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
220
app/javascript/mastodon/features/donate/styles.scss
Normal file
220
app/javascript/mastodon/features/donate/styles.scss
Normal file
@@ -0,0 +1,220 @@
|
||||
@use '../../../styles/mastodon/variables' as vars;
|
||||
|
||||
.donate_modal {
|
||||
.dialog-modal__header {
|
||||
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%;
|
||||
|
||||
button {
|
||||
background-color: var(--button-color);
|
||||
border: none;
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--button-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
flex-grow: 1;
|
||||
background: none;
|
||||
padding: 0.5rem;
|
||||
color: var(--on-input-color);
|
||||
border: 1px solid var(--button-color);
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__dropdown {
|
||||
// TODO: Move these to CSS variables
|
||||
--dropdown-text-color: #{vars.$primary-text-color};
|
||||
--dropdown-active-color: #{vars.$ui-highlight-color};
|
||||
|
||||
box-shadow: var(--dropdown-shadow);
|
||||
background: var(--dropdown-background-color);
|
||||
backdrop-filter: #{vars.$backdrop-blur-filter};
|
||||
border: 1px solid var(--dropdown-border-color);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__option {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.25px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 4px;
|
||||
color: var(--dropdown-text-color);
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background: var(--dropdown-border-color);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&.active {
|
||||
background: var(--dropdown-active-color);
|
||||
color: var(--dropdown-text-color);
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
border: 2px solid transparent;
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
|
||||
.submit {
|
||||
padding: 0.6rem 1rem;
|
||||
|
||||
> svg {
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
button > svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--input-placeholder-color);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: var(--input-placeholder-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.checkout {
|
||||
a {
|
||||
text-decoration: underline;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.success {
|
||||
.illustration {
|
||||
width: 100%;
|
||||
max-width: 350px;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.donate_banner {
|
||||
background-color: #1b001f;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
border: none;
|
||||
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: rgb(255, 255, 255, 80%);
|
||||
}
|
||||
|
||||
> button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
top: -1rem;
|
||||
width: 400px;
|
||||
right: -200px;
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
right: -250px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 350px) {
|
||||
padding-right: 50px;
|
||||
|
||||
img {
|
||||
right: -350px;
|
||||
}
|
||||
}
|
||||
|
||||
.dismissable-banner__action {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
float: none;
|
||||
|
||||
> button {
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
padding: 0.3rem;
|
||||
color: hsla(247deg, 12%, 31%, 100%);
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
app/javascript/mastodon/features/donate/success.tsx
Normal file
65
app/javascript/mastodon/features/donate/success.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import donateIllustration from '@/images/donation_successful.png';
|
||||
import { composeDonateShare } from '@/mastodon/actions/donate';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
|
||||
interface DonateSuccessProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DonateSuccess: FC<DonateSuccessProps> = ({ onClose }) => {
|
||||
const hasComposerContent = useAppSelector(
|
||||
(state) => !!state.compose.get('text'),
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
const handleShare = useCallback(() => {
|
||||
dispatch(composeDonateShare());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={donateIllustration}
|
||||
alt=''
|
||||
role='presentation'
|
||||
className='illustration'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='donate.success.title'
|
||||
defaultMessage='Thanks for your donation!'
|
||||
tagName='h2'
|
||||
/>
|
||||
<p className='muted'>
|
||||
<FormattedMessage
|
||||
id='donate.success.subtitle'
|
||||
defaultMessage='You should receive an email confirming your donation soon.'
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchTrendingHashtags } from 'mastodon/actions/trends';
|
||||
import { DismissableBanner } from 'mastodon/components/dismissable_banner';
|
||||
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
@@ -27,6 +27,7 @@ import StatusListContainer from '../ui/containers/status_list_container';
|
||||
|
||||
import { ColumnSettings } from './components/column_settings';
|
||||
import { CriticalUpdateBanner } from './components/critical_update_banner';
|
||||
import { DonateBanner } from '../donate/banner';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||
@@ -127,7 +128,9 @@ class HomeTimeline extends PureComponent {
|
||||
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props;
|
||||
const pinned = !!columnId;
|
||||
const { signedIn } = this.props.identity;
|
||||
const banners = [];
|
||||
const banners = [
|
||||
<DonateBanner key="donate-banner" />
|
||||
];
|
||||
|
||||
let announcementsButton;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ClosedRegistrationsModal,
|
||||
IgnoreNotificationsModal,
|
||||
AnnualReportModal,
|
||||
DonateModal,
|
||||
} from 'mastodon/features/ui/util/async-components';
|
||||
|
||||
import BundleContainer from '../containers/bundle_container';
|
||||
@@ -80,6 +81,7 @@ export const MODAL_COMPONENTS = {
|
||||
'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal,
|
||||
'ANNUAL_REPORT': AnnualReportModal,
|
||||
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
|
||||
'DONATE': DonateModal,
|
||||
};
|
||||
|
||||
export default class ModalRoot extends PureComponent {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { connect } from 'react-redux';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
|
||||
import { initializeDonate } from '@/mastodon/actions/donate';
|
||||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
|
||||
import { fetchNotifications } from 'mastodon/actions/notification_groups';
|
||||
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
|
||||
@@ -394,6 +395,7 @@ class UI extends PureComponent {
|
||||
this.props.dispatch(expandHomeTimeline());
|
||||
this.props.dispatch(fetchNotifications());
|
||||
this.props.dispatch(fetchServerTranslationLanguages());
|
||||
this.props.dispatch(initializeDonate());
|
||||
|
||||
setTimeout(() => this.props.dispatch(fetchServer()), 3000);
|
||||
}
|
||||
|
||||
@@ -230,6 +230,8 @@ export function AnnualReportModal () {
|
||||
return import('../components/annual_report_modal');
|
||||
}
|
||||
|
||||
export const DonateModal = () => import('../../donate/donate_modal');
|
||||
|
||||
export function ListEdit () {
|
||||
return import('../../lists/new');
|
||||
}
|
||||
|
||||
36
app/javascript/mastodon/reducers/donate.ts
Normal file
36
app/javascript/mastodon/reducers/donate.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
|
||||
import { fetchDonateData, setDonateSeed } from '../actions/donate';
|
||||
import type { DonateServerResponse } from '../api_types/donate';
|
||||
|
||||
interface DonateState {
|
||||
apiResponse?: DonateServerResponse;
|
||||
nextPoll?: number;
|
||||
isFetching: boolean;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
const initialState: DonateState = {
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
export const donateReducer = createReducer(initialState, (builder) => {
|
||||
builder
|
||||
.addCase(setDonateSeed, (state, action) => {
|
||||
state.seed = action.payload;
|
||||
})
|
||||
.addCase(fetchDonateData.pending, (state) => {
|
||||
state.isFetching = true;
|
||||
})
|
||||
.addCase(fetchDonateData.rejected, (state) => {
|
||||
state.isFetching = false;
|
||||
})
|
||||
.addCase(fetchDonateData.fulfilled, (state, action) => {
|
||||
if (action.payload) {
|
||||
state.apiResponse = action.payload;
|
||||
}
|
||||
// If we have data, poll in four hours, otherwise try again in one hour.
|
||||
state.nextPoll = Date.now() + 1000 * 60 * 60 * (action.payload ? 4 : 1);
|
||||
state.isFetching = false;
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { composeReducer } from './compose';
|
||||
import { contextsReducer } from './contexts';
|
||||
import conversations from './conversations';
|
||||
import custom_emojis from './custom_emojis';
|
||||
import { donateReducer } from './donate';
|
||||
import { dropdownMenuReducer } from './dropdown_menu';
|
||||
import filters from './filters';
|
||||
import height_cache from './height_cache';
|
||||
@@ -43,6 +44,7 @@ import user_lists from './user_lists';
|
||||
|
||||
const reducers = {
|
||||
announcements,
|
||||
donate: donateReducer,
|
||||
dropdownMenu: dropdownMenuReducer,
|
||||
timelines,
|
||||
meta,
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
--input-placeholder-color: #{$dark-text-color};
|
||||
--input-background-color: var(--surface-variant-background-color);
|
||||
--on-input-color: #{$secondary-text-color};
|
||||
--button-color: #{$ui-button-background-color};
|
||||
--button-hover-color: #{$ui-button-focus-background-color};
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
Reference in New Issue
Block a user