Restore Patron features, context to TSX

This commit is contained in:
Alex Gleason 2022-04-24 17:01:57 -05:00
parent 57e5d81e33
commit 3f9cc3cd04
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
14 changed files with 184 additions and 154 deletions

View file

@ -0,0 +1,17 @@
{
"funding": {
"amount": 3500,
"patrons": 3,
"currency": "usd",
"interval": "monthly"
},
"goals": [
{
"amount": 20000,
"currency": "usd",
"interval": "monthly",
"text": "I'll be able to afford an avocado."
}
],
"url": "https://patron.gleasonator.com"
}

View file

@ -0,0 +1,4 @@
{
"is_patron": true,
"url": "https://gleasonator.com/users/dave"
}

View file

@ -1,13 +1,25 @@
import PropTypes from 'prop-types'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
const Badge = (props: any) => ( interface IBadge {
<span data-testid='badge' className={'badge badge--' + props.slug}>{props.title}</span> title: string,
slug: 'patron' | 'admin' | 'moderator' | 'bot' | 'opaque',
}
/** Badge to display on a user's profile. */
const Badge: React.FC<IBadge> = ({ title, slug }) => (
<span
data-testid='badge'
className={classNames('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white', {
'bg-fuchsia-700': slug === 'patron',
'bg-black': slug === 'admin',
'bg-cyan-600': slug === 'moderator',
'bg-gray-100 text-gray-800': slug === 'bot',
'bg-white bg-opacity-75 text-gray-900': slug === 'opaque',
})}
>
{title}
</span>
); );
Badge.propTypes = {
title: PropTypes.string.isRequired,
slug: PropTypes.string.isRequired,
};
export default Badge; export default Badge;

View file

@ -5,8 +5,8 @@ interface IProgressBar {
} }
const ProgressBar: React.FC<IProgressBar> = ({ progress }) => ( const ProgressBar: React.FC<IProgressBar> = ({ progress }) => (
<div className='progress-bar'> <div className='h-2 w-full rounded-md bg-gray-300 dark:bg-slate-700 overflow-hidden'>
<div className='progress-bar__progress' style={{ width: `${Math.floor(progress*100)}%` }} /> <div className='h-full bg-primary-500' style={{ width: `${Math.floor(progress*100)}%` }} />
</div> </div>
); );

View file

@ -1,78 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { fetchPatronInstance } from 'soapbox/actions/patron';
import Icon from 'soapbox/components/icon';
import ProgressBar from '../../../components/progress_bar';
const moneyFormat = amount => (
new Intl
.NumberFormat('en-US', {
style: 'currency',
currency: 'usd',
notation: 'compact',
})
.format(amount/100)
);
class FundingPanel extends ImmutablePureComponent {
componentDidMount() {
this.props.dispatch(fetchPatronInstance());
}
render() {
const { patron } = this.props;
if (patron.isEmpty()) return null;
const amount = patron.getIn(['funding', 'amount']);
const goal = patron.getIn(['goals', '0', 'amount']);
const goal_text = patron.getIn(['goals', '0', 'text']);
const goal_reached = amount >= goal;
let ratio_text;
if (goal_reached) {
ratio_text = <><strong>{moneyFormat(goal)}</strong> per month<span className='funding-panel__reached'>&mdash; reached!</span></>;
} else {
ratio_text = <><strong>{moneyFormat(amount)} out of {moneyFormat(goal)}</strong> per month</>;
}
return (
<div className='wtf-panel funding-panel'>
<div className='wtf-panel-header'>
<Icon src={require('@tabler/icons/icons/chart-line.svg')} className='wtf-panel-header__icon' />
<span className='wtf-panel-header__label'>
<span>Funding Goal</span>
</span>
</div>
<div className='wtf-panel__content'>
<div className='funding-panel__ratio'>
{ratio_text}
</div>
<ProgressBar progress={amount/goal} />
<div className='funding-panel__description'>
{goal_text}
</div>
<a className='button' href={patron.get('url')}>Donate</a>
</div>
</div>
);
}
}
const mapStateToProps = state => {
return {
patron: state.getIn(['patron', 'instance'], ImmutableMap()),
};
};
export default injectIntl(
connect(mapStateToProps, null, null, {
forwardRef: true,
},
)(FundingPanel));

View file

@ -0,0 +1,79 @@
import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { fetchPatronInstance } from 'soapbox/actions/patron';
import { Widget, Button, Text } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import ProgressBar from '../../../components/progress_bar';
/** Open link in a new tab. */
// https://stackoverflow.com/a/28374344/8811886
const openInNewTab = (href: string): void => {
Object.assign(document.createElement('a'), {
target: '_blank',
href: href,
}).click();
};
/** Formats integer to USD string. */
const moneyFormat = (amount: number): string => (
new Intl
.NumberFormat('en-US', {
style: 'currency',
currency: 'usd',
notation: 'compact',
})
.format(amount/100)
);
const FundingPanel: React.FC = () => {
const history = useHistory();
const dispatch = useAppDispatch();
const patron = useAppSelector(state => state.patron.instance);
useEffect(() => {
dispatch(fetchPatronInstance());
}, []);
if (patron.funding.isEmpty() || patron.goals.isEmpty()) return null;
const amount = patron.getIn(['funding', 'amount']) as number;
const goal = patron.getIn(['goals', '0', 'amount']) as number;
const goalText = patron.getIn(['goals', '0', 'text']) as string;
const goalReached = amount >= goal;
let ratioText;
if (goalReached) {
ratioText = <><strong>{moneyFormat(goal)}</strong> per month<span className='funding-panel__reached'>&mdash; reached!</span></>;
} else {
ratioText = <><strong>{moneyFormat(amount)} out of {moneyFormat(goal)}</strong> per month</>;
}
const handleDonateClick = () => {
openInNewTab(patron.url);
};
return (
<Widget
title={<FormattedMessage id='patron.title' defaultMessage='Funding Goal' />}
onActionClick={handleDonateClick}
>
<div className='funding-panel__ratio'>
<Text>{ratioText}</Text>
</div>
<ProgressBar progress={amount/goal} />
<div className='funding-panel__description'>
<Text>{goalText}</Text>
</div>
<div>
<Button theme='ghost' onClick={handleDonateClick}>
<FormattedMessage id='patron.donate' defaultMessage='Donate' />
</Button>
</div>
</Widget>
);
};
export default FundingPanel;

View file

@ -16,6 +16,7 @@ import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { unescapeHTML } from 'soapbox/utils/html'; import { unescapeHTML } from 'soapbox/utils/html';
import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers'; import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers';
import type { PatronAccount } from 'soapbox/reducers/patron';
import type { Emoji, Field, EmbeddedEntity } from 'soapbox/types/entities'; import type { Emoji, Field, EmbeddedEntity } from 'soapbox/types/entities';
// https://docs.joinmastodon.org/entities/account/ // https://docs.joinmastodon.org/entities/account/
@ -57,7 +58,7 @@ export const AccountRecord = ImmutableRecord({
moderator: false, moderator: false,
note_emojified: '', note_emojified: '',
note_plain: '', note_plain: '',
patron: ImmutableMap<string, any>(), patron: null as PatronAccount | null,
relationship: ImmutableList<ImmutableMap<string, any>>(), relationship: ImmutableList<ImmutableMap<string, any>>(),
should_refetch: false, should_refetch: false,
staff: false, staff: false,

View file

@ -11,6 +11,7 @@ import {
TrendsPanel, TrendsPanel,
SignUpPanel, SignUpPanel,
PromoPanel, PromoPanel,
FundingPanel,
CryptoDonatePanel, CryptoDonatePanel,
BirthdayPanel, BirthdayPanel,
} from 'soapbox/features/ui/util/async-components'; } from 'soapbox/features/ui/util/async-components';
@ -33,7 +34,7 @@ const mapStateToProps = state => {
return { return {
me, me,
account: state.getIn(['accounts', me]), account: state.getIn(['accounts', me]),
showFundingPanel: hasPatron, hasPatron,
hasCrypto, hasCrypto,
cryptoLimit, cryptoLimit,
features, features,
@ -49,7 +50,7 @@ class HomePage extends ImmutablePureComponent {
} }
render() { render() {
const { me, children, account, features, hasCrypto, cryptoLimit } = this.props; const { me, children, account, features, hasPatron, hasCrypto, cryptoLimit } = this.props;
const acct = account ? account.get('acct') : ''; const acct = account ? account.get('acct') : '';
@ -90,6 +91,11 @@ class HomePage extends ImmutablePureComponent {
{Component => <Component limit={3} />} {Component => <Component limit={3} />}
</BundleContainer> </BundleContainer>
)} )}
{hasPatron && (
<BundleContainer fetchComponent={FundingPanel}>
{Component => <Component />}
</BundleContainer>
)}
{hasCrypto && cryptoLimit > 0 && ( {hasCrypto && cryptoLimit > 0 && (
<BundleContainer fetchComponent={CryptoDonatePanel}> <BundleContainer fetchComponent={CryptoDonatePanel}>
{Component => <Component limit={cryptoLimit} />} {Component => <Component limit={cryptoLimit} />}

View file

@ -1,24 +0,0 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import {
PATRON_INSTANCE_FETCH_SUCCESS,
PATRON_ACCOUNT_FETCH_SUCCESS,
} from '../actions/patron';
const initialState = ImmutableMap();
const normalizePatronAccount = (state, account) => {
const normalized = fromJS(account).deleteAll(['url']);
return state.setIn(['accounts', account.url], normalized);
};
export default function patron(state = initialState, action) {
switch(action.type) {
case PATRON_INSTANCE_FETCH_SUCCESS:
return state.set('instance', fromJS(action.instance));
case PATRON_ACCOUNT_FETCH_SUCCESS:
return normalizePatronAccount(state, action.account);
default:
return state;
}
}

View file

@ -0,0 +1,50 @@
import {
Map as ImmutableMap,
List as ImmutableList,
Record as ImmutableRecord,
fromJS,
} from 'immutable';
import {
PATRON_INSTANCE_FETCH_SUCCESS,
PATRON_ACCOUNT_FETCH_SUCCESS,
} from '../actions/patron';
import type { AnyAction } from 'redux';
const PatronAccountRecord = ImmutableRecord({
is_patron: false,
url: '',
});
const PatronInstanceRecord = ImmutableRecord({
funding: ImmutableMap(),
goals: ImmutableList(),
url: '',
});
const ReducerRecord = ImmutableRecord({
instance: PatronInstanceRecord() as PatronInstance,
accounts: ImmutableMap<string, PatronAccount>(),
});
type State = ReturnType<typeof ReducerRecord>;
export type PatronAccount = ReturnType<typeof PatronAccountRecord>;
export type PatronInstance = ReturnType<typeof PatronInstanceRecord>;
const normalizePatronAccount = (state: State, account: Record<string, any>) => {
const normalized = PatronAccountRecord(account);
return state.setIn(['accounts', normalized.url], normalized);
};
export default function patron(state = ReducerRecord(), action: AnyAction) {
switch(action.type) {
case PATRON_INSTANCE_FETCH_SUCCESS:
return state.set('instance', PatronInstanceRecord(ImmutableMap(fromJS(action.instance))));
case PATRON_ACCOUNT_FETCH_SUCCESS:
return normalizePatronAccount(state, action.account);
default:
return state;
}
}

View file

@ -26,7 +26,7 @@ const getAccountMeta = (state: RootState, id: string) => state.accounts_
const getAccountAdminData = (state: RootState, id: string) => state.admin.users.get(id); const getAccountAdminData = (state: RootState, id: string) => state.admin.users.get(id);
const getAccountPatron = (state: RootState, id: string) => { const getAccountPatron = (state: RootState, id: string) => {
const url = state.accounts.get(id)?.url; const url = state.accounts.get(id)?.url;
return state.patron.getIn(['accounts', url]); return url ? state.patron.accounts.get(url) : null;
}; };
export const makeGetAccount = () => { export const makeGetAccount = () => {
@ -47,7 +47,7 @@ export const makeGetAccount = () => {
map.set('pleroma', meta.get('pleroma', ImmutableMap()).merge(base.get('pleroma', ImmutableMap()))); // Lol, thanks Pleroma map.set('pleroma', meta.get('pleroma', ImmutableMap()).merge(base.get('pleroma', ImmutableMap()))); // Lol, thanks Pleroma
map.set('relationship', relationship); map.set('relationship', relationship);
map.set('moved', moved || null); map.set('moved', moved || null);
map.set('patron', patron); map.set('patron', patron || null);
map.setIn(['pleroma', 'admin'], admin); map.setIn(['pleroma', 'admin'], admin);
}); });
}); });

View file

@ -63,7 +63,6 @@
@import 'components/getting-started'; @import 'components/getting-started';
@import 'components/promo-panel'; @import 'components/promo-panel';
@import 'components/still-image'; @import 'components/still-image';
@import 'components/badge';
@import 'components/theme-toggle'; @import 'components/theme-toggle';
@import 'components/trends'; @import 'components/trends';
@import 'components/wtf-panel'; @import 'components/wtf-panel';

View file

@ -1,23 +0,0 @@
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-primary-600 text-white;
&--patron {
@apply bg-primary-600 text-white;
}
&--admin {
@apply bg-black;
}
&--moderator {
@apply bg-cyan-600 text-white;
}
&--bot {
@apply bg-gray-100 text-gray-800;
}
&--opaque {
@apply bg-white bg-opacity-75 text-gray-900;
}
}

View file

@ -198,16 +198,3 @@ body.admin {
padding: 15px; padding: 15px;
} }
} }
.progress-bar {
height: 8px;
width: 100%;
border-radius: 4px;
background: var(--background-color);
overflow: hidden;
&__progress {
height: 100%;
background: var(--brand-color);
}
}