feat(client): Unify donation loading state (#43179)

* initial loading state unification

* feat(client): show buttons after 3 seconds

* fix: use window.setInterval explicitly

Otherwise TS assumes that it's node's setInterval

* feat(client): remove spinner when first button load

* feat(client): move the loader to the donate page button area

* feat(client): extract grid from modal donation form

* feat(client): remove duplicate donation forms

* feat(client):extract unused components from donationForm

* feat(client): load paypal on load not onInit (for perf)

* feat(client): set paypal loading state if stripe already loaded

* feat(clinet):make lpaypal oading condition strickt.

* Apply suggestions from code review

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* feat: clean up

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2021-08-19 22:47:25 +03:00
committed by GitHub
parent 72529a8050
commit 1fec73cdf7
12 changed files with 241 additions and 185 deletions

View File

@ -265,7 +265,7 @@ const ShowCertification = (props: IShowCertificationProps): JSX.Element => {
</Row>
)}
<Row>
<Col md={8} mdOffset={2} xs={12}>
<Col lg={8} lgOffset={2} sm={10} smOffset={1} xs={12}>
<DonateForm
defaultTheme='default'
handleProcessing={handleProcessing}

View File

@ -24,6 +24,7 @@ function DonateCompletion({
const { t } = useTranslation();
const style =
processing || redirecting ? 'info' : success ? 'success' : 'danger';
const heading = redirecting
? `${t('donate.redirecting')}`
: processing
@ -31,6 +32,7 @@ function DonateCompletion({
: success
? `${t('donate.thank-you')}`
: `${t('donate.error')}`;
return (
<Alert bsStyle={style} className='donation-completion'>
<h4>

View File

@ -2,19 +2,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable no-nested-ternary */
import {
Col,
Row,
Tab,
Tabs,
ToggleButton,
ToggleButtonGroup
} from '@freecodecamp/react-bootstrap';
import type { Token } from '@stripe/stripe-js';
import React, { Component } from 'react';
import { withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import Spinner from 'react-spinkit';
import { createSelector } from 'reselect';
import {
@ -48,12 +41,19 @@ const numToCommas = (num: number): string =>
num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,');
type DonateFormState = {
donationAmount: number;
donationDuration: string;
processing: boolean;
redirecting: boolean;
success: boolean;
error: string;
loading: {
stripe: boolean;
paypal: boolean;
};
};
type DonateFromComponentState = {
donationAmount: number;
donationDuration: string;
};
type DonateFormProps = {
@ -99,7 +99,7 @@ const mapDispatchToProps = {
postChargeStripe
};
class DonateForm extends Component<DonateFormProps, DonateFormState> {
class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
static displayName = 'DonateForm';
durations: { month: 'monthly'; onetime: 'one-time' };
amounts: { month: number[]; onetime: number[] };
@ -116,22 +116,16 @@ class DonateForm extends Component<DonateFormProps, DonateFormState> {
? modalDefaultDonation
: defaultDonation;
this.state = {
...initialAmountAndDuration,
processing: false,
redirecting: false,
success: false,
error: ''
};
this.state = { ...initialAmountAndDuration };
this.onDonationStateChange = this.onDonationStateChange.bind(this);
this.getActiveDonationAmount = this.getActiveDonationAmount.bind(this);
this.getDonationButtonLabel = this.getDonationButtonLabel.bind(this);
this.handleSelectAmount = this.handleSelectAmount.bind(this);
this.handleSelectDuration = this.handleSelectDuration.bind(this);
this.hideAmountOptionsCB = this.hideAmountOptionsCB.bind(this);
this.resetDonation = this.resetDonation.bind(this);
this.postStripeDonation = this.postStripeDonation.bind(this);
this.handlePaymentButtonLoad = this.handlePaymentButtonLoad.bind(this);
}
componentWillUnmount() {
@ -141,9 +135,23 @@ class DonateForm extends Component<DonateFormProps, DonateFormState> {
onDonationStateChange(donationState: AddDonationData) {
// scroll to top
window.scrollTo(0, 0);
this.props.updateDonationFormState(donationState);
this.props.updateDonationFormState({
...this.props.donationFormState,
...donationState
});
}
handlePaymentButtonLoad(provider: 'stripe' | 'paypal') {
this.props.updateDonationFormState({
...this.props.donationFormState,
loading: {
...this.props.donationFormState.loading,
[provider]: false
}
});
}
// onload
getActiveDonationAmount(
durationSelected: 'month' | 'onetime',
amountSelected: number
@ -213,19 +221,6 @@ class DonateForm extends Component<DonateFormProps, DonateFormState> {
this.setState({ donationAmount });
}
renderAmountButtons(duration: 'month' | 'onetime') {
return this.amounts[duration].map((amount: number) => (
<ToggleButton
className='amount-value'
id={`${this.durations[duration]}-donation-${amount}`}
key={`${this.durations[duration]}-donation-${amount}`}
value={amount}
>
{this.getFormattedAmountLabel(amount)}
</ToggleButton>
));
}
renderDonationDescription() {
const { donationAmount, donationDuration } = this.state;
const { t } = this.props;
@ -243,113 +238,22 @@ class DonateForm extends Component<DonateFormProps, DonateFormState> {
);
}
renderDurationAmountOptions() {
const { donationAmount, donationDuration, processing } = this.state;
const { t } = this.props;
return !processing ? (
<div>
<h3>{t('donate.gift-frequency')}</h3>
<Tabs
activeKey={donationDuration}
animation={false}
bsStyle='pills'
className='donate-tabs'
id='Duration'
onSelect={this.handleSelectDuration}
>
{(Object.keys(this.durations) as ['month' | 'onetime']).map(
duration => (
<Tab
eventKey={duration}
key={duration}
title={this.durations[duration]}
>
<Spacer />
<h3>{t('donate.gift-amount')}</h3>
<div>
<ToggleButtonGroup
animation={`false`}
className='amount-values'
name='amounts'
onChange={this.handleSelectAmount}
type='radio'
value={this.getActiveDonationAmount(
duration,
donationAmount
)}
>
{this.renderAmountButtons(duration)}
</ToggleButtonGroup>
<Spacer />
{this.renderDonationDescription()}
</div>
</Tab>
)
)}
</Tabs>
</div>
) : null;
}
hideAmountOptionsCB(hide: boolean) {
this.setState({ processing: hide });
}
renderDonationOptions() {
const {
handleProcessing,
isSignedIn,
addDonation,
t,
defaultTheme,
theme
} = this.props;
const { donationAmount, donationDuration } = this.state;
const isOneTime = donationDuration === 'onetime';
const formlabel = `${t(
isOneTime ? 'donate.confirm-2' : 'donate.confirm-3',
{ usd: donationAmount / 100 }
)}:`;
const walletlabel = `${t(
isOneTime ? 'donate.wallet-label' : 'donate.wallet-label-1',
{ usd: donationAmount / 100 }
)}:`;
const priorityTheme = defaultTheme ? defaultTheme : theme;
return (
<div>
<b>{formlabel}</b>
<Spacer />
<div className='donate-btn-group'>
<WalletsWrapper
amount={donationAmount}
label={walletlabel}
onDonationStateChange={this.onDonationStateChange}
postStripeDonation={this.postStripeDonation}
refreshErrorMessage={t('donate.refresh-needed')}
theme={priorityTheme}
/>
<PaypalButton
addDonation={addDonation}
donationAmount={donationAmount}
donationDuration={donationDuration}
handleProcessing={handleProcessing}
isSubscription={isOneTime ? false : true}
onDonationStateChange={this.onDonationStateChange}
skipAddDonation={!isSignedIn}
theme={priorityTheme}
/>
</div>
</div>
);
}
resetDonation() {
return this.props.updateDonationFormState({ ...defaultDonationFormState });
}
paymentButtonsLoader() {
return (
<div className=' donation-completion donation-completion-loading'>
<Spinner
className='script-loading-spinner'
fadeIn='none'
name='line-scale'
/>
</div>
);
}
renderCompletion(props: {
processing: boolean;
redirecting: boolean;
@ -360,50 +264,63 @@ class DonateForm extends Component<DonateFormProps, DonateFormState> {
return <DonateCompletion {...props} />;
}
renderModalForm() {
renderButtonGroup() {
const { donationAmount, donationDuration } = this.state;
const { handleProcessing, addDonation, defaultTheme, theme, t } =
this.props;
const {
donationFormState: { loading },
handleProcessing,
addDonation,
defaultTheme,
theme,
t,
isMinimalForm
} = this.props;
const paymentButtonsLoading = loading.stripe && loading.paypal;
const priorityTheme = defaultTheme ? defaultTheme : theme;
const isOneTime = donationDuration === 'onetime';
const walletlabel = `${t(
isOneTime ? 'donate.wallet-label' : 'donate.wallet-label-1',
{ usd: donationAmount / 100 }
)}:`;
return (
<Row>
<Col lg={8} lgOffset={2} sm={10} smOffset={1} xs={12}>
<b className='donation-label'>{this.getDonationButtonLabel()}:</b>
<Spacer />
<div className='donate-btn-group'>
<WalletsWrapper
amount={donationAmount}
label={walletlabel}
onDonationStateChange={this.onDonationStateChange}
postStripeDonation={this.postStripeDonation}
refreshErrorMessage={t('donate.refresh-needed')}
theme={priorityTheme}
/>
<PaypalButton
addDonation={addDonation}
donationAmount={donationAmount}
donationDuration={donationDuration}
handleProcessing={handleProcessing}
onDonationStateChange={this.onDonationStateChange}
theme={defaultTheme ? defaultTheme : theme}
/>
</div>
</Col>
</Row>
<>
<b className={isMinimalForm ? 'donation-label-modal' : ''}>
{this.getDonationButtonLabel()}:
</b>
<Spacer />
{paymentButtonsLoading && this.paymentButtonsLoader()}
<div className={'donate-btn-group'}>
<WalletsWrapper
amount={donationAmount}
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
label={walletlabel}
onDonationStateChange={this.onDonationStateChange}
postStripeDonation={this.postStripeDonation}
refreshErrorMessage={t('donate.refresh-needed')}
theme={priorityTheme}
/>
<PaypalButton
addDonation={addDonation}
donationAmount={donationAmount}
donationDuration={donationDuration}
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
handleProcessing={handleProcessing}
isPaypalLoading={loading.paypal}
onDonationStateChange={this.onDonationStateChange}
theme={defaultTheme ? defaultTheme : theme}
/>
</div>
</>
);
}
renderPageForm() {
return (
<Row>
<Col xs={12}>{this.renderDonationDescription()}</Col>
<Col xs={12}>{this.renderDonationOptions()}</Col>
</Row>
<>
<div>{this.renderDonationDescription()}</div>
<div>{this.renderButtonGroup()}</div>
</>
);
}
@ -412,6 +329,7 @@ class DonateForm extends Component<DonateFormProps, DonateFormState> {
donationFormState: { processing, success, error, redirecting },
isMinimalForm
} = this.props;
if (success || error) {
return this.renderCompletion({
processing,
@ -434,7 +352,7 @@ class DonateForm extends Component<DonateFormProps, DonateFormState> {
reset: this.resetDonation
})}
<div className={processing || redirecting ? 'hide' : ''}>
{isMinimalForm ? this.renderModalForm() : this.renderPageForm()}
{isMinimalForm ? this.renderButtonGroup() : this.renderPageForm()}
</div>
</>
);

View File

@ -25,6 +25,9 @@
align-items: center;
text-align: center;
}
.donation-completion-loading {
min-height: 154px;
}
.donation-completion-buttons {
display: flex;
@ -245,7 +248,7 @@ li.disabled > a {
font-weight: 600;
font-size: 1.2rem;
}
.donation-label,
.donation-label-modal,
.donation-modal p,
.donation-modal b {
text-align: center;

View File

@ -158,7 +158,14 @@ function DonateModal({
<Modal.Body>
{recentlyClaimedBlock ? blockDonationText : progressDonationText}
<Spacer />
<DonateForm handleProcessing={handleProcessing} isMinimalForm={true} />
<Row>
<Col lg={8} lgOffset={2} sm={10} smOffset={1} xs={12}>
<DonateForm
handleProcessing={handleProcessing}
isMinimalForm={true}
/>
</Col>
</Row>
<Spacer />
<Row>
<Col sm={4} smOffset={4} xs={8} xsOffset={2}>

View File

@ -2,7 +2,6 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Loader } from '../../components/helpers';
import { scriptLoader, scriptRemover } from '../../utils/script-loaders';
import type { AddDonationData } from './PaypalButton';
@ -32,9 +31,15 @@ type PayPalButtonScriptLoaderProps = {
data: AddDonationData,
actions?: { order: { capture: () => Promise<unknown> } }
) => unknown;
isPaypalLoading: boolean;
onCancel: () => unknown;
onError: () => unknown;
style: unknown;
onLoad: () => void;
style: {
color: string;
height: number;
tagline: boolean;
};
planId: string | null;
};
@ -81,16 +86,24 @@ export class PayPalButtonScriptLoader extends Component<
componentDidMount(): void {
if (!window.paypal) {
this.loadScript(this.props.isSubscription, false);
} else if (this.props.isPaypalLoading) {
this.props.onLoad();
}
}
componentDidUpdate(prevProps: {
isSubscription: boolean;
style: unknown;
style: {
color: string;
height: number;
tagline: boolean;
};
}): void {
if (
prevProps.isSubscription !== this.state.isSubscription ||
prevProps.style !== this.props.style
prevProps.style.color !== this.props.style.color ||
prevProps.style.tagline !== this.props.style.tagline ||
prevProps.style.height !== this.props.style.height
) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ isSdkLoaded: false });
@ -114,6 +127,7 @@ export class PayPalButtonScriptLoader extends Component<
onScriptLoad = (): void => {
this.setState({ isSdkLoaded: true });
this.props.onLoad();
};
captureOneTimePayment(
@ -144,7 +158,7 @@ export class PayPalButtonScriptLoader extends Component<
style
} = this.props;
if (!isSdkLoaded) return <Loader />;
if (!isSdkLoaded) return <></>;
// TODO: fill in the full list of props instead of any
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -36,10 +36,12 @@ type PaypalButtonProps = {
success: boolean;
error: string | null;
}) => void;
isPaypalLoading: boolean;
skipAddDonation?: boolean;
t: (label: string) => string;
theme: string;
isSubscription?: boolean;
handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void;
};
type PaypalButtonState = {
@ -53,6 +55,10 @@ export interface AddDonationData {
processing: boolean;
success: boolean;
error: string | null;
loading?: {
stripe: boolean;
paypal: boolean;
};
}
const {
@ -120,7 +126,7 @@ export class PaypalButton extends Component<
render(): JSX.Element | null {
const { duration, planId, amount } = this.state;
const { t, theme } = this.props;
const { t, theme, isPaypalLoading } = this.props;
const isSubscription = duration !== 'onetime';
const buttonColor = theme === 'night' ? 'white' : 'gold';
if (!paypalClientId) {
@ -167,6 +173,7 @@ export class PaypalButton extends Component<
plan_id: planId
});
}}
isPaypalLoading={isPaypalLoading}
isSubscription={isSubscription}
onApprove={(data: AddDonationData) => {
this.handleApproval(data, isSubscription);
@ -179,14 +186,16 @@ export class PaypalButton extends Component<
error: t('donate.failed-pay')
});
}}
onError={() =>
onError={() => {
this.props.handlePaymentButtonLoad('paypal');
this.props.onDonationStateChange({
redirecting: false,
processing: false,
success: false,
error: t('donate.try-again')
})
}
});
}}
onLoad={() => this.props.handlePaymentButtonLoad('paypal')}
planId={planId}
style={{
tagline: false,

View File

@ -0,0 +1,90 @@
import {
Tab,
Tabs,
ToggleButton,
ToggleButtonGroup
} from '@freecodecamp/react-bootstrap';
import React from 'react';
import Spacer from '../helpers/spacer';
interface OptionsProps {
amounts: { month: []; onetime: [] };
month: [];
onetime: [];
durations: { month: string; onetime: string };
getFormattedAmountLabel: (amount: number) => string;
getActiveDonationAmount: (
duration: 'month' | 'onetime',
donationAmount: number
) => number;
handleSelectDuration: unknown;
handleSelectAmount: unknown;
donationDuration: 'month' | 'onetime';
donationAmount: 500 | 1000;
t: (
label: string,
{ usd, hours }?: { usd?: string | number; hours?: string }
) => string;
}
const DurationAmountOptions = ({
donationDuration,
donationAmount,
amounts,
durations,
getFormattedAmountLabel,
getActiveDonationAmount,
handleSelectDuration,
handleSelectAmount,
t
}: OptionsProps): JSX.Element => {
const renderAmountButtons = (duration: 'month' | 'onetime'): unknown => {
return amounts[duration].map((amount: number) => (
<ToggleButton
className='amount-value'
id={`${durations[duration]}-donation-${amount}`}
key={`${durations[duration]}-donation-${amount}`}
value={amount}
>
{getFormattedAmountLabel(amount)}
</ToggleButton>
));
};
return (
<div>
s<h3>{t('donate.gift-frequency')}</h3>
<Tabs
activeKey={donationDuration}
animation={false}
bsStyle='pills'
className='donate-tabs'
id='Duration'
onSelect={handleSelectDuration}
>
{(Object.keys(durations) as ['month' | 'onetime']).map(duration => (
<Tab eventKey={duration} key={duration} title={durations[duration]}>
<Spacer />
<h3>{t('donate.gift-amount')}</h3>
<div>
<ToggleButtonGroup
animation={`false`}
className='amount-values'
name='amounts'
onChange={handleSelectAmount}
type='radio'
value={getActiveDonationAmount(duration, donationAmount)}
>
{renderAmountButtons(duration)}
</ToggleButtonGroup>
<Spacer />
</div>
</Tab>
))}
</Tabs>
</div>
);
};
export default DurationAmountOptions;

View File

@ -22,6 +22,7 @@ interface WrapperProps {
) => void;
onDonationStateChange: (donationState: AddDonationData) => void;
refreshErrorMessage: string;
handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void;
}
interface WalletsButtonProps extends WrapperProps {
stripe: Stripe | null;
@ -34,7 +35,8 @@ const WalletsButton = ({
theme,
refreshErrorMessage,
postStripeDonation,
onDonationStateChange
onDonationStateChange,
handlePaymentButtonLoad
}: WalletsButtonProps) => {
const [token, setToken] = useState<Token | null>(null);
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest | null>(
@ -71,7 +73,7 @@ const WalletsButton = ({
checkpaymentPossiblity(false);
}
});
}, [label, amount, stripe, postStripeDonation]);
}, [label, amount, stripe, postStripeDonation, handlePaymentButtonLoad]);
const displayRefreshError = (): void => {
onDonationStateChange({
@ -87,10 +89,12 @@ const WalletsButton = ({
{canMakePayment && paymentRequest && (
<PaymentRequestButtonElement
onClick={() => {
console.log('click');
if (token) {
displayRefreshError();
}
}}
onReady={() => handlePaymentButtonLoad('stripe')}
options={{
style: {
paymentRequestButton: {

View File

@ -6,7 +6,8 @@
align-items: center;
}
.fcc-loader .sk-spinner {
.fcc-loader .sk-spinner,
.script-loading-spinner {
color: var(--secondary-color);
}

View File

@ -113,7 +113,11 @@ function DonatePage({
</Alert>
) : null}
<DonationText />
<DonateForm handleProcessing={handleProcessing} />
<Row>
<Col xs={12}>
<DonateForm handleProcessing={handleProcessing} />
</Col>
</Row>
<Row className='donate-support'>
<Col xs={12}>
<hr />

View File

@ -33,7 +33,11 @@ export const defaultDonationFormState = {
redirecting: false,
processing: false,
success: false,
error: ''
error: '',
loading: {
stripe: true,
paypal: true
}
};
const initialState = {