create donation state

This commit is contained in:
ChaosExAnima 2025-09-12 18:28:34 +02:00
parent aa99edaf6d
commit e22184c20e
No known key found for this signature in database
GPG Key ID: 8F2B333100FB6117
6 changed files with 165 additions and 0 deletions

View File

@ -0,0 +1,72 @@
import { createAction } from '@reduxjs/toolkit';
import { apiGetDonateData } from '../api/donate';
import {
createAppThunk,
createDataLoadingThunk,
} 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 = createDataLoadingThunk(
'donate/fetch',
(_args: unknown, { 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.
});
},
(data) => data, // This is needed for TypeScript to infer the correct overload.
);
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 }));
},
);

View 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>;
};

View 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;
}

View File

@ -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);
}

View 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;
});
});

View File

@ -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,