fix(client): unify client donations methods (#39562)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2020-10-14 13:23:26 +03:00
committed by GitHub
parent 1d2ff7aef6
commit 3106fe804f
12 changed files with 377 additions and 417 deletions

View File

@ -8,7 +8,7 @@ import format from 'date-fns/format';
import { Grid, Row, Col, Image, Button } from '@freecodecamp/react-bootstrap'; import { Grid, Row, Col, Image, Button } from '@freecodecamp/react-bootstrap';
import FreeCodeCampLogo from '../assets/icons/freeCodeCampLogo'; import FreeCodeCampLogo from '../assets/icons/freeCodeCampLogo';
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
import MinimalDonateForm from '../components/Donation/MinimalDonateForm'; import DonateForm from '../components/Donation/DonateForm';
import { import {
showCertSelector, showCertSelector,
@ -233,9 +233,10 @@ class ShowCertification extends Component {
</Col> </Col>
</Row> </Row>
)} )}
<MinimalDonateForm <DonateForm
handleProcessing={this.handleProcessing} handleProcessing={this.handleProcessing}
defaultTheme='light' defaultTheme='light'
isMinimalForm={true}
/> />
<Row> <Row>
<Col sm={4} smOffset={4} xs={6} xsOffset={3}> <Col sm={4} smOffset={4} xs={6} xsOffset={3}>

View File

@ -2,6 +2,7 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { StripeProvider, Elements } from 'react-stripe-elements';
import { import {
Button, Button,
Col, Col,
@ -18,15 +19,28 @@ import {
amountsConfig, amountsConfig,
durationsConfig, durationsConfig,
defaultAmount, defaultAmount,
defaultStateConfig, defaultDonation,
onetimeSKUConfig, onetimeSKUConfig,
donationUrls donationUrls,
modalDefaultDonation
} from '../../../../config/donation-settings'; } from '../../../../config/donation-settings';
import { stripePublicKey } from '../../../../config/env.json';
import { stripeScriptLoader } from '../../utils/scriptLoaders';
import DonateFormChildViewForHOC from './DonateFormChildViewForHOC';
import { deploymentEnv } from '../../../config/env.json'; import { deploymentEnv } from '../../../config/env.json';
import Spacer from '../helpers/Spacer'; import Spacer from '../helpers/Spacer';
import PaypalButton from './PaypalButton'; import PaypalButton from './PaypalButton';
import DonateCompletion from './DonateCompletion'; import DonateCompletion from './DonateCompletion';
import { isSignedInSelector, signInLoadingSelector } from '../../redux'; import {
isSignedInSelector,
signInLoadingSelector,
donationFormStateSelector,
hardGoTo as navigate,
addDonation,
postChargeStripe,
updateDonationFormState,
defaultDonationFormState
} from '../../redux';
import './Donation.css'; import './Donation.css';
@ -34,32 +48,35 @@ const numToCommas = num =>
num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,');
const propTypes = { const propTypes = {
addDonation: PropTypes.func,
defaultTheme: PropTypes.string,
donationFormState: PropTypes.object,
handleProcessing: PropTypes.func, handleProcessing: PropTypes.func,
isDonating: PropTypes.bool, isDonating: PropTypes.bool,
isMinimalForm: PropTypes.bool,
isSignedIn: PropTypes.bool, isSignedIn: PropTypes.bool,
navigate: PropTypes.func.isRequired, navigate: PropTypes.func.isRequired,
postChargeStripe: PropTypes.func.isRequired,
showLoading: PropTypes.bool.isRequired, showLoading: PropTypes.bool.isRequired,
stripe: PropTypes.shape({ updateDonationFormState: PropTypes.func
createToken: PropTypes.func.isRequired,
redirectToCheckout: PropTypes.func.isRequired
})
}; };
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
signInLoadingSelector, signInLoadingSelector,
isSignedInSelector, isSignedInSelector,
(showLoading, isSignedIn) => ({ donationFormStateSelector,
(showLoading, isSignedIn, donationFormState) => ({
isSignedIn, isSignedIn,
showLoading showLoading,
donationFormState
}) })
); );
const initialState = { const mapDispatchToProps = {
donationState: { addDonation,
processing: false, navigate,
success: false, postChargeStripe,
error: '' updateDonationFormState
}
}; };
class DonateForm extends Component { class DonateForm extends Component {
@ -69,12 +86,17 @@ class DonateForm extends Component {
this.durations = durationsConfig; this.durations = durationsConfig;
this.amounts = amountsConfig; this.amounts = amountsConfig;
const initialAmountAndDuration = this.props.isMinimalForm
? modalDefaultDonation
: defaultDonation;
this.state = { this.state = {
...initialState, ...initialAmountAndDuration,
...defaultStateConfig, processing: false,
processing: false stripe: null
}; };
this.handleStripeLoad = this.handleStripeLoad.bind(this);
this.onDonationStateChange = this.onDonationStateChange.bind(this); this.onDonationStateChange = this.onDonationStateChange.bind(this);
this.getActiveDonationAmount = this.getActiveDonationAmount.bind(this); this.getActiveDonationAmount = this.getActiveDonationAmount.bind(this);
this.getDonationButtonLabel = this.getDonationButtonLabel.bind(this); this.getDonationButtonLabel = this.getDonationButtonLabel.bind(this);
@ -87,17 +109,43 @@ class DonateForm extends Component {
this.resetDonation = this.resetDonation.bind(this); this.resetDonation = this.resetDonation.bind(this);
} }
onDonationStateChange(success, processing, error) { componentDidMount() {
this.setState(state => ({ if (window.Stripe) {
...state, this.handleStripeLoad();
donationState: { } else if (document.querySelector('#stripe-js')) {
...state.donationState, document
processing: processing, .querySelector('#stripe-js')
success: success, .addEventListener('load', this.handleStripeLoad);
error: error } else {
} stripeScriptLoader(this.handleStripeLoad);
})); }
if (success) { }
componentWillUnmount() {
const stripeMountPoint = document.querySelector('#stripe-js');
if (stripeMountPoint) {
stripeMountPoint.removeEventListener('load', this.handleStripeLoad);
}
this.resetDonation();
}
handleStripeLoad() {
// Create Stripe instance once Stripe.js loads
if (stripePublicKey) {
this.setState(state => ({
...state,
stripe: window.Stripe(stripePublicKey)
}));
}
}
onDonationStateChange(donationState) {
// scroll to top
window.scrollTo(0, 0);
this.props.updateDonationFormState(donationState);
// send donation made on the donate page to related news article
if (donationState.success && !this.props.isMinimalForm) {
this.props.navigate(donationUrls.successUrl); this.props.navigate(donationUrls.successUrl);
} }
} }
@ -126,7 +174,7 @@ class DonateForm extends Component {
} else { } else {
donationBtnLabel = `Confirm your donation of ${this.getFormatedAmountLabel( donationBtnLabel = `Confirm your donation of ${this.getFormatedAmountLabel(
donationAmount donationAmount
)} ${donationDuration === 'month' ? 'per month' : 'per year'}`; )} ${donationDuration === 'month' ? ' / month' : ' / year'}`;
} }
return donationBtnLabel; return donationBtnLabel;
} }
@ -140,10 +188,16 @@ class DonateForm extends Component {
this.setState({ donationAmount }); this.setState({ donationAmount });
} }
async handleStripeCheckoutRedirect(e) { async handleStripeCheckoutRedirect(e, paymentMethod) {
const { stripe } = this.props; const { stripe } = this.state;
const { donationAmount, donationDuration } = this.state; const { donationAmount, donationDuration } = this.state;
this.props.handleProcessing(
donationDuration,
donationAmount,
`stripe (${paymentMethod}) button click`
);
const isOneTime = donationDuration === 'onetime'; const isOneTime = donationDuration === 'onetime';
const getSKUId = () => { const getSKUId = () => {
const { id } = onetimeSKUConfig[deploymentEnv || 'staging'].find( const { id } = onetimeSKUConfig[deploymentEnv || 'staging'].find(
@ -236,7 +290,7 @@ class DonateForm extends Component {
} }
renderDonationOptions() { renderDonationOptions() {
const { handleProcessing, isSignedIn } = this.props; const { handleProcessing, isSignedIn, addDonation } = this.props;
const { donationAmount, donationDuration } = this.state; const { donationAmount, donationDuration } = this.state;
const isOneTime = donationDuration === 'onetime'; const isOneTime = donationDuration === 'onetime';
@ -257,7 +311,7 @@ class DonateForm extends Component {
bsStyle='primary' bsStyle='primary'
className='btn-cta' className='btn-cta'
id='confirm-donation-btn' id='confirm-donation-btn'
onClick={this.handleStripeCheckoutRedirect} onClick={e => this.handleStripeCheckoutRedirect(e, 'apple pay')}
> >
<span>Donate with Apple Pay</span> <span>Donate with Apple Pay</span>
@ -269,7 +323,7 @@ class DonateForm extends Component {
bsStyle='primary' bsStyle='primary'
className='btn-cta' className='btn-cta'
id='confirm-donation-btn' id='confirm-donation-btn'
onClick={this.handleStripeCheckoutRedirect} onClick={e => this.handleStripeCheckoutRedirect(e, 'google pay')}
> >
<span>Donate with Google Pay</span> <span>Donate with Google Pay</span>
<GooglePay className='google-pay-logo' /> <GooglePay className='google-pay-logo' />
@ -280,7 +334,7 @@ class DonateForm extends Component {
bsStyle='primary' bsStyle='primary'
className='btn-cta' className='btn-cta'
id='confirm-donation-btn' id='confirm-donation-btn'
onClick={this.handleStripeCheckoutRedirect} onClick={e => this.handleStripeCheckoutRedirect(e, 'credit card')}
> >
<span>Donate with Card</span> <span>Donate with Card</span>
@ -292,6 +346,7 @@ class DonateForm extends Component {
</Button> </Button>
<Spacer /> <Spacer />
<PaypalButton <PaypalButton
addDonation={addDonation}
donationAmount={donationAmount} donationAmount={donationAmount}
donationDuration={donationDuration} donationDuration={donationDuration}
handleProcessing={handleProcessing} handleProcessing={handleProcessing}
@ -305,25 +360,59 @@ class DonateForm extends Component {
} }
resetDonation() { resetDonation() {
return this.setState({ ...initialState }); return this.props.updateDonationFormState({ ...defaultDonationFormState });
} }
renderCompletion(props) { renderCompletion(props) {
return <DonateCompletion {...props} />; return <DonateCompletion {...props} />;
} }
render() { renderModalForm() {
const { donationAmount, donationDuration, stripe } = this.state;
const { const {
donationState: { processing, success, error } handleProcessing,
} = this.state; defaultTheme,
if (processing || success || error) { addDonation,
return this.renderCompletion({ postChargeStripe
processing, } = this.props;
success,
error, return (
reset: this.resetDonation <Row>
}); <Col lg={8} lgOffset={2} sm={10} smOffset={1} xs={12}>
} <Spacer />
<b>{this.getDonationButtonLabel()} with PayPal:</b>
<Spacer />
<PaypalButton
addDonation={addDonation}
donationAmount={donationAmount}
donationDuration={donationDuration}
handleProcessing={handleProcessing}
onDonationStateChange={this.onDonationStateChange}
/>
</Col>
<Col lg={8} lgOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer />
<b>Or donate with a credit card:</b>
<Spacer />
<StripeProvider stripe={stripe}>
<Elements>
<DonateFormChildViewForHOC
defaultTheme={defaultTheme}
donationAmount={donationAmount}
donationDuration={donationDuration}
getDonationButtonLabel={this.getDonationButtonLabel}
handleProcessing={handleProcessing}
onDonationStateChange={this.onDonationStateChange}
postChargeStripe={postChargeStripe}
/>
</Elements>
</StripeProvider>
</Col>
</Row>
);
}
renderPageForm() {
return ( return (
<Row> <Row>
<Col sm={10} smOffset={1} xs={12}> <Col sm={10} smOffset={1} xs={12}>
@ -335,9 +424,45 @@ class DonateForm extends Component {
</Row> </Row>
); );
} }
render() {
const {
donationFormState: { processing, success, error },
isMinimalForm
} = this.props;
if (success || error) {
return this.renderCompletion({
processing,
success,
error,
reset: this.resetDonation
});
}
// keep payment provider elements on DOM during processing to avoid errors.
return (
<>
{processing &&
this.renderCompletion({
processing,
success,
error,
reset: this.resetDonation
})}
<div className={processing ? 'hide' : ''}>
{isMinimalForm
? this.renderModalForm(processing)
: this.renderPageForm(processing)}
</div>
</>
);
}
} }
DonateForm.displayName = 'DonateForm'; DonateForm.displayName = 'DonateForm';
DonateForm.propTypes = propTypes; DonateForm.propTypes = propTypes;
export default connect(mapStateToProps)(DonateForm); export default connect(
mapStateToProps,
mapDispatchToProps
)(DonateForm);

View File

@ -15,7 +15,6 @@ import { injectStripe } from 'react-stripe-elements';
import StripeCardForm from './StripeCardForm'; import StripeCardForm from './StripeCardForm';
import DonateCompletion from './DonateCompletion'; import DonateCompletion from './DonateCompletion';
import { postChargeStripe } from '../../utils/ajax';
import { userSelector } from '../../redux'; import { userSelector } from '../../redux';
const propTypes = { const propTypes = {
@ -26,19 +25,14 @@ const propTypes = {
getDonationButtonLabel: PropTypes.func.isRequired, getDonationButtonLabel: PropTypes.func.isRequired,
handleProcessing: PropTypes.func, handleProcessing: PropTypes.func,
isSignedIn: PropTypes.bool, isSignedIn: PropTypes.bool,
onDonationStateChange: PropTypes.func,
postChargeStripe: PropTypes.func,
showCloseBtn: PropTypes.func, showCloseBtn: PropTypes.func,
stripe: PropTypes.shape({ stripe: PropTypes.shape({
createToken: PropTypes.func.isRequired createToken: PropTypes.func.isRequired
}), }),
theme: PropTypes.string theme: PropTypes.string
}; };
const initialState = {
donationState: {
processing: false,
success: false,
error: ''
}
};
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
userSelector, userSelector,
@ -50,9 +44,6 @@ class DonateFormChildViewForHOC extends Component {
super(...args); super(...args);
this.state = { this.state = {
...initialState,
donationAmount: this.props.donationAmount,
donationDuration: this.props.donationDuration,
isSubmissionValid: null, isSubmissionValid: null,
email: null, email: null,
isEmailValid: true, isEmailValid: true,
@ -63,7 +54,6 @@ class DonateFormChildViewForHOC extends Component {
this.handleEmailChange = this.handleEmailChange.bind(this); this.handleEmailChange = this.handleEmailChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.postDonation = this.postDonation.bind(this); this.postDonation = this.postDonation.bind(this);
this.resetDonation = this.resetDonation.bind(this);
this.handleEmailBlur = this.handleEmailBlur.bind(this); this.handleEmailBlur = this.handleEmailBlur.bind(this);
} }
@ -106,41 +96,26 @@ class DonateFormChildViewForHOC extends Component {
const email = this.getUserEmail(); const email = this.getUserEmail();
if (!email || !isEmail(email)) { if (!email || !isEmail(email)) {
return this.setState(state => ({ return this.props.onDonationStateChange({
...state, error:
donationState: { 'We need a valid email address to which we can send your' +
...state.donationState, ' donation tax receipt.'
error: });
'We need a valid email address to which we can send your' +
' donation tax receipt.'
}
}));
} }
return this.props.stripe.createToken({ email }).then(({ error, token }) => { return this.props.stripe.createToken({ email }).then(({ error, token }) => {
if (error) { if (error) {
return this.setState(state => ({ return this.props.onDonationStateChange({
...state, error:
donationState: { 'Something went wrong processing your donation. Your card' +
...state.donationState, ' has not been charged.'
error: });
'Something went wrong processing your donation. Your card' +
' has not been charged.'
}
}));
} }
return this.postDonation(token); return this.postDonation(token);
}); });
} }
postDonation(token) { postDonation(token) {
const { donationAmount: amount, donationDuration: duration } = this.state; const { donationAmount: amount, donationDuration: duration } = this.props;
this.setState(state => ({
...state,
donationState: {
...state.donationState,
processing: true
}
}));
// scroll to top // scroll to top
window.scrollTo(0, 0); window.scrollTo(0, 0);
@ -150,50 +125,15 @@ class DonateFormChildViewForHOC extends Component {
if (this.props.handleProcessing) { if (this.props.handleProcessing) {
this.props.handleProcessing( this.props.handleProcessing(
this.state.donationDuration, this.state.donationDuration,
Math.round(this.state.donationAmount / 100) Math.round(amount / 100)
); );
} }
return postChargeStripe({ return this.props.postChargeStripe({
token, token,
amount, amount,
duration duration
}) });
.then(response => {
const data = response && response.data;
this.setState(state => ({
...state,
donationState: {
...state.donationState,
processing: false,
success: true,
error: data.error ? data.error : null
}
}));
})
.catch(error => {
const data =
error.response && error.response.data
? error.response.data
: {
error:
'Something is not right. ' +
'Please contact donors@freecodecamp.org.'
};
this.setState(state => ({
...state,
donationState: {
...state.donationState,
processing: false,
success: false,
error: data.error
}
}));
});
}
resetDonation() {
return this.setState({ ...initialState });
} }
renderCompletion(props) { renderCompletion(props) {
@ -267,25 +207,13 @@ class DonateFormChildViewForHOC extends Component {
); );
} }
componentWillReceiveProps({ donationAmount, donationDuration, email }) { componentWillReceiveProps({ email }) {
this.setState({ donationAmount, donationDuration });
if (this.state.email === null && email) { if (this.state.email === null && email) {
this.setState({ email }); this.setState({ email });
} }
} }
render() { render() {
const {
donationState: { processing, success, error }
} = this.state;
if (processing || success || error) {
return this.renderCompletion({
processing,
success,
error,
reset: this.resetDonation
});
}
return this.renderDonateForm(); return this.renderDonateForm();
} }
} }

View File

@ -348,3 +348,7 @@ li.disabled > a {
justify-content: center; justify-content: center;
align-content: center; align-content: center;
} }
.hide {
display: none;
}

View File

@ -1,5 +1,5 @@
/* eslint-disable max-len */ /* eslint-disable max-len */
import React from 'react'; import React, { useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -9,8 +9,8 @@ import { Spacer } from '../helpers';
import { blockNameify } from '../../../../utils/block-nameify'; import { blockNameify } from '../../../../utils/block-nameify';
import Heart from '../../assets/icons/Heart'; import Heart from '../../assets/icons/Heart';
import Cup from '../../assets/icons/Cup'; import Cup from '../../assets/icons/Cup';
import MinimalDonateForm from './MinimalDonateForm'; import DonateForm from './DonateForm';
import { modalDefaultStateConfig } from '../../../../config/donation-settings'; import { modalDefaultDonation } from '../../../../config/donation-settings';
import { import {
closeDonationModal, closeDonationModal,
@ -77,19 +77,21 @@ function DonateModal({
setCloseLabel(true); setCloseLabel(true);
}; };
if (show) { useEffect(() => {
executeGA({ type: 'modal', data: '/donation-modal' }); if (show) {
executeGA({ executeGA({ type: 'modal', data: '/donation-modal' });
type: 'event', executeGA({
data: { type: 'event',
category: 'Donation', data: {
action: `Displayed ${ category: 'Donation',
isBlockDonation ? 'block' : 'progress' action: `Displayed ${
} donation modal`, isBlockDonation ? 'block' : 'progress'
nonInteraction: true } donation modal`,
} nonInteraction: true
}); }
} });
}
}, [show, isBlockDonation, executeGA]);
const durationToText = donationDuration => { const durationToText = donationDuration => {
if (donationDuration === 'onetime') return 'a one-time'; if (donationDuration === 'onetime') return 'a one-time';
@ -100,8 +102,8 @@ function DonateModal({
const donationText = ( const donationText = (
<b> <b>
Become {durationToText(modalDefaultStateConfig.donationDuration)}{' '} Become {durationToText(modalDefaultDonation.donationDuration)} supporter
supporter of our nonprofit. of our nonprofit.
</b> </b>
); );
const blockDonationText = ( const blockDonationText = (
@ -141,7 +143,7 @@ function DonateModal({
<Modal.Body> <Modal.Body>
{isBlockDonation ? blockDonationText : progressDonationText} {isBlockDonation ? blockDonationText : progressDonationText}
<Spacer /> <Spacer />
<MinimalDonateForm handleProcessing={handleProcessing} /> <DonateForm handleProcessing={handleProcessing} isMinimalForm={true} />
<Spacer /> <Spacer />
<Row> <Row>
<Col sm={4} smOffset={4} xs={8} xsOffset={2}> <Col sm={4} smOffset={4} xs={8} xsOffset={2}>

View File

@ -1,174 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Row, Col } from '@freecodecamp/react-bootstrap';
import { StripeProvider, Elements } from 'react-stripe-elements';
import {
amountsConfig,
durationsConfig,
modalDefaultStateConfig
} from '../../../../config/donation-settings';
import { stripePublicKey } from '../../../../config/env.json';
import { stripeScriptLoader } from '../../utils/scriptLoaders';
import DonateFormChildViewForHOC from './DonateFormChildViewForHOC';
import DonateCompletion from './DonateCompletion';
import PaypalButton from './PaypalButton';
import { userSelector } from '../../redux';
import { Spacer } from '../../components/helpers';
import './Donation.css';
const propTypes = {
defaultTheme: PropTypes.string,
handleProcessing: PropTypes.func,
isDonating: PropTypes.bool,
stripe: PropTypes.shape({
createToken: PropTypes.func.isRequired
})
};
const mapStateToProps = createSelector(
userSelector,
({ isDonating }) => ({
isDonating
})
);
const initialState = {
donationState: {
processing: false,
success: false,
error: ''
}
};
class MinimalDonateForm extends Component {
constructor(...args) {
super(...args);
this.durations = durationsConfig;
this.amounts = amountsConfig;
this.state = {
...modalDefaultStateConfig,
...initialState,
isDonating: this.props.isDonating,
stripe: null
};
this.handleStripeLoad = this.handleStripeLoad.bind(this);
this.onDonationStateChange = this.onDonationStateChange.bind(this);
this.resetDonation = this.resetDonation.bind(this);
}
componentDidMount() {
if (window.Stripe) {
this.handleStripeLoad();
} else if (document.querySelector('#stripe-js')) {
document
.querySelector('#stripe-js')
.addEventListener('load', this.handleStripeLoad);
} else {
stripeScriptLoader(this.handleStripeLoad);
}
}
componentWillUnmount() {
const stripeMountPoint = document.querySelector('#stripe-js');
if (stripeMountPoint) {
stripeMountPoint.removeEventListener('load', this.handleStripeLoad);
}
}
handleStripeLoad() {
// Create Stripe instance once Stripe.js loads
if (stripePublicKey) {
this.setState(state => ({
...state,
stripe: window.Stripe(stripePublicKey)
}));
}
}
resetDonation() {
return this.setState({ ...initialState });
}
onDonationStateChange(success, processing, error) {
this.setState(state => ({
...state,
donationState: {
...state.donationState,
processing: processing,
success: success,
error: error
}
}));
}
renderCompletion(props) {
return <DonateCompletion {...props} />;
}
render() {
const { donationAmount, donationDuration, stripe } = this.state;
const { handleProcessing, defaultTheme } = this.props;
const {
donationState: { processing, success, error }
} = this.state;
const donationPlan = `$${donationAmount / 100} / ${donationDuration}`;
if (processing || success || error) {
return this.renderCompletion({
processing,
success,
error,
reset: this.resetDonation
});
}
return (
<Row>
<Col lg={8} lgOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer />
<b>Confirm your donation of {donationPlan} with PayPal:</b>
<Spacer />
<PaypalButton
donationAmount={donationAmount}
donationDuration={donationDuration}
handleProcessing={handleProcessing}
onDonationStateChange={this.onDonationStateChange}
/>
</Col>
<Col lg={8} lgOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer />
<b>Or donate with a credit card:</b>
<Spacer />
<StripeProvider stripe={stripe}>
<Elements>
<DonateFormChildViewForHOC
defaultTheme={defaultTheme}
donationAmount={donationAmount}
donationDuration={donationDuration}
getDonationButtonLabel={() =>
`Confirm your donation of ${donationPlan}`
}
handleProcessing={handleProcessing}
/>
</Elements>
</StripeProvider>
</Col>
</Row>
);
}
}
MinimalDonateForm.displayName = 'MinimalDonateForm';
MinimalDonateForm.propTypes = propTypes;
export default connect(
mapStateToProps,
null
)(MinimalDonateForm);

View File

@ -6,7 +6,6 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import PayPalButtonScriptLoader from './PayPalButtonScriptLoader'; import PayPalButtonScriptLoader from './PayPalButtonScriptLoader';
import { paypalClientId, deploymentEnv } from '../../../config/env.json'; import { paypalClientId, deploymentEnv } from '../../../config/env.json';
import { verifySubscriptionPaypal } from '../../utils/ajax';
import { import {
paypalConfigurator, paypalConfigurator,
paypalConfigTypes paypalConfigTypes
@ -38,39 +37,20 @@ export class PaypalButton extends Component {
handleApproval = (data, isSubscription) => { handleApproval = (data, isSubscription) => {
const { amount, duration } = this.state; const { amount, duration } = this.state;
const { skipAddDonation = false } = this.props; const { skipAddDonation = false } = this.props;
// Skip the api if user is not signed in or if its a one-time donation // Skip the api if user is not signed in or if its a one-time donation
if (skipAddDonation || !isSubscription) { if (!skipAddDonation || isSubscription) {
this.props.onDonationStateChange( this.props.addDonation(data);
true,
false,
data.error ? data.error : ''
);
} else {
this.props.handleProcessing(
duration,
amount,
'Paypal payment submission'
);
this.props.onDonationStateChange(false, true, '');
verifySubscriptionPaypal(data)
.then(response => {
const data = response && response.data;
this.props.onDonationStateChange(
true,
false,
data.error ? data.error : ''
);
})
.catch(error => {
const data =
error.response && error.response.data
? error.response.data
: {
error: `Something is not right. Please contact team@freecodecamp.org`
};
this.props.onDonationStateChange(false, false, data.error);
});
} }
this.props.handleProcessing(duration, amount, 'Paypal payment submission');
// Show success anytime because the payment has gone through paypal
this.props.onDonationStateChange({
processing: false,
success: true,
error: data.error ? data.error : null
});
}; };
render() { render() {
@ -107,14 +87,18 @@ export class PaypalButton extends Component {
this.handleApproval(data, isSubscription); this.handleApproval(data, isSubscription);
}} }}
onCancel={() => { onCancel={() => {
this.props.onDonationStateChange( this.props.onDonationStateChange({
false, processing: false,
false, success: false,
`Uh - oh. It looks like your transaction didn't go through. Could you please try again?` error: `Uh - oh. It looks like your transaction didn't go through. Could you please try again?`
); });
}} }}
onError={() => onError={() =>
this.props.onDonationStateChange(false, false, 'Please try again.') this.props.onDonationStateChange({
processing: false,
success: false,
error: 'Please try again.'
})
} }
plantId={planId} plantId={planId}
style={{ style={{
@ -127,6 +111,7 @@ export class PaypalButton extends Component {
} }
const propTypes = { const propTypes = {
addDonation: PropTypes.func,
donationAmount: PropTypes.number, donationAmount: PropTypes.number,
donationDuration: PropTypes.string, donationDuration: PropTypes.string,
handleProcessing: PropTypes.func, handleProcessing: PropTypes.func,

View File

@ -6,12 +6,10 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { Grid, Row, Col, Alert } from '@freecodecamp/react-bootstrap'; import { Grid, Row, Col, Alert } from '@freecodecamp/react-bootstrap';
import { stripePublicKey } from '../../config/env.json';
import { Spacer, Loader } from '../components/helpers'; import { Spacer, Loader } from '../components/helpers';
import DonateForm from '../components/Donation/DonateForm'; import DonateForm from '../components/Donation/DonateForm';
import DonateText from '../components/Donation/DonateText'; import DonateText from '../components/Donation/DonateText';
import { signInLoadingSelector, userSelector, executeGA } from '../redux'; import { signInLoadingSelector, userSelector, executeGA } from '../redux';
import { stripeScriptLoader } from '../utils/scriptLoaders';
const propTypes = { const propTypes = {
executeGA: PropTypes.func, executeGA: PropTypes.func,
@ -40,11 +38,9 @@ export class DonatePage extends Component {
constructor(...props) { constructor(...props) {
super(...props); super(...props);
this.state = { this.state = {
stripe: null,
enableSettings: false enableSettings: false
}; };
this.handleProcessing = this.handleProcessing.bind(this); this.handleProcessing = this.handleProcessing.bind(this);
this.handleStripeLoad = this.handleStripeLoad.bind(this);
} }
componentDidMount() { componentDidMount() {
@ -56,47 +52,21 @@ export class DonatePage extends Component {
nonInteraction: true nonInteraction: true
} }
}); });
if (window.Stripe) {
this.handleStripeLoad();
} else if (document.querySelector('#stripe-js')) {
document
.querySelector('#stripe-js')
.addEventListener('load', this.handleStripeLoad);
} else {
stripeScriptLoader(this.handleStripeLoad);
}
} }
componentWillUnmount() { handleProcessing(duration, amount, action = 'stripe button click') {
const stripeMountPoint = document.querySelector('#stripe-js');
if (stripeMountPoint) {
stripeMountPoint.removeEventListener('load', this.handleStripeLoad);
}
}
handleProcessing(duration, amount) {
this.props.executeGA({ this.props.executeGA({
type: 'event', type: 'event',
data: { data: {
category: 'donation', category: 'donation',
action: 'donate page stripe form submission', action: `donate page ${action}`,
label: duration, label: duration,
value: amount value: amount
} }
}); });
} }
handleStripeLoad() {
// Create Stripe instance once Stripe.js loads
console.info('stripe has loaded');
this.setState(state => ({
...state,
stripe: window.Stripe(stripePublicKey)
}));
}
render() { render() {
const { stripe } = this.state;
const { showLoading, isDonating } = this.props; const { showLoading, isDonating } = this.props;
if (showLoading) { if (showLoading) {
@ -141,7 +111,6 @@ export class DonatePage extends Component {
<DonateForm <DonateForm
enableDonationSettingsPage={this.enableDonationSettingsPage} enableDonationSettingsPage={this.enableDonationSettingsPage}
handleProcessing={this.handleProcessing} handleProcessing={this.handleProcessing}
stripe={stripe}
/> />
</Col> </Col>
<Col md={6}> <Col md={6}>

View File

@ -1,13 +1,28 @@
import { put, select, takeEvery, delay } from 'redux-saga/effects'; import {
put,
select,
takeEvery,
takeLeading,
delay,
call
} from 'redux-saga/effects';
import { import {
openDonationModal, openDonationModal,
preventBlockDonationRequests, preventBlockDonationRequests,
shouldRequestDonationSelector, shouldRequestDonationSelector,
preventProgressDonationRequests, preventProgressDonationRequests,
canRequestBlockDonationSelector canRequestBlockDonationSelector,
addDonationComplete,
addDonationError,
postChargeStripeComplete,
postChargeStripeError
} from './'; } from './';
import { addDonation, postChargeStripe } from '../utils/ajax';
const defaultDonationError = `Something is not right. Please contact donors@freecodecamp.org`;
function* showDonateModalSaga() { function* showDonateModalSaga() {
let shouldRequestDonation = yield select(shouldRequestDonationSelector); let shouldRequestDonation = yield select(shouldRequestDonationSelector);
if (shouldRequestDonation) { if (shouldRequestDonation) {
@ -22,6 +37,38 @@ function* showDonateModalSaga() {
} }
} }
export function createDonationSaga(types) { function* addDonationSaga({ payload }) {
return [takeEvery(types.tryToShowDonationModal, showDonateModalSaga)]; try {
yield call(addDonation, payload);
yield put(addDonationComplete());
} catch (error) {
const data =
error.response && error.response.data
? error.response.data
: {
message: defaultDonationError
};
yield put(addDonationError(data.message));
}
}
function* postChargeStripeSaga({ payload }) {
try {
yield call(postChargeStripe, payload);
yield put(postChargeStripeComplete());
} catch (error) {
const err =
error.response && error.response.data
? error.response.data.error
: defaultDonationError;
yield put(postChargeStripeError(err));
}
}
export function createDonationSaga(types) {
return [
takeEvery(types.tryToShowDonationModal, showDonateModalSaga),
takeEvery(types.addDonation, addDonationSaga),
takeLeading(types.postChargeStripe, postChargeStripeSaga)
];
} }

View File

@ -31,6 +31,12 @@ export const defaultFetchState = {
error: null error: null
}; };
export const defaultDonationFormState = {
processing: false,
success: false,
error: ''
};
const initialState = { const initialState = {
appUsername: '', appUsername: '',
canRequestBlockDonation: false, canRequestBlockDonation: false,
@ -51,7 +57,10 @@ const initialState = {
sessionMeta: { activeDonations: 0 }, sessionMeta: { activeDonations: 0 },
showDonationModal: false, showDonationModal: false,
isBlockDonationModal: false, isBlockDonationModal: false,
isOnline: true isOnline: true,
donationFormState: {
...defaultDonationFormState
}
}; };
export const types = createTypes( export const types = createTypes(
@ -71,7 +80,10 @@ export const types = createTypes(
'updateComplete', 'updateComplete',
'updateCurrentChallengeId', 'updateCurrentChallengeId',
'updateFailed', 'updateFailed',
'updateDonationFormState',
...createAsyncTypes('fetchUser'), ...createAsyncTypes('fetchUser'),
...createAsyncTypes('addDonation'),
...createAsyncTypes('postChargeStripe'),
...createAsyncTypes('fetchProfileForUser'), ...createAsyncTypes('fetchProfileForUser'),
...createAsyncTypes('acceptTerms'), ...createAsyncTypes('acceptTerms'),
...createAsyncTypes('showCert'), ...createAsyncTypes('showCert'),
@ -112,6 +124,9 @@ export const preventBlockDonationRequests = createAction(
export const preventProgressDonationRequests = createAction( export const preventProgressDonationRequests = createAction(
types.preventProgressDonationRequests types.preventProgressDonationRequests
); );
export const updateDonationFormState = createAction(
types.updateDonationFormState
);
export const onlineStatusChange = createAction(types.onlineStatusChange); export const onlineStatusChange = createAction(types.onlineStatusChange);
@ -133,6 +148,16 @@ export const fetchUser = createAction(types.fetchUser);
export const fetchUserComplete = createAction(types.fetchUserComplete); export const fetchUserComplete = createAction(types.fetchUserComplete);
export const fetchUserError = createAction(types.fetchUserError); export const fetchUserError = createAction(types.fetchUserError);
export const addDonation = createAction(types.addDonation);
export const addDonationComplete = createAction(types.addDonationComplete);
export const addDonationError = createAction(types.addDonationError);
export const postChargeStripe = createAction(types.postChargeStripe);
export const postChargeStripeComplete = createAction(
types.postChargeStripeComplete
);
export const postChargeStripeError = createAction(types.postChargeStripeError);
export const fetchProfileForUser = createAction(types.fetchProfileForUser); export const fetchProfileForUser = createAction(types.fetchProfileForUser);
export const fetchProfileForUserComplete = createAction( export const fetchProfileForUserComplete = createAction(
types.fetchProfileForUserComplete types.fetchProfileForUserComplete
@ -160,7 +185,6 @@ export const completedChallengesSelector = state =>
export const completionCountSelector = state => state[ns].completionCount; export const completionCountSelector = state => state[ns].completionCount;
export const currentChallengeIdSelector = state => state[ns].currentChallengeId; export const currentChallengeIdSelector = state => state[ns].currentChallengeId;
export const isDonatingSelector = state => userSelector(state).isDonating; export const isDonatingSelector = state => userSelector(state).isDonating;
export const isOnlineSelector = state => state[ns].isOnline; export const isOnlineSelector = state => state[ns].isOnline;
export const isSignedInSelector = state => !!state[ns].appUsername; export const isSignedInSelector = state => !!state[ns].appUsername;
export const isDonationModalOpenSelector = state => state[ns].showDonationModal; export const isDonationModalOpenSelector = state => state[ns].showDonationModal;
@ -168,12 +192,11 @@ export const canRequestBlockDonationSelector = state =>
state[ns].canRequestBlockDonation; state[ns].canRequestBlockDonation;
export const isBlockDonationModalSelector = state => export const isBlockDonationModalSelector = state =>
state[ns].isBlockDonationModal; state[ns].isBlockDonationModal;
export const donationFormStateSelector = state => state[ns].donationFormState;
export const signInLoadingSelector = state => export const signInLoadingSelector = state =>
userFetchStateSelector(state).pending; userFetchStateSelector(state).pending;
export const showCertSelector = state => state[ns].showCert; export const showCertSelector = state => state[ns].showCert;
export const showCertFetchStateSelector = state => state[ns].showCertFetchState; export const showCertFetchStateSelector = state => state[ns].showCertFetchState;
export const shouldRequestDonationSelector = state => { export const shouldRequestDonationSelector = state => {
const completedChallenges = completedChallengesSelector(state); const completedChallenges = completedChallengesSelector(state);
const completionCount = completionCountSelector(state); const completionCount = completionCountSelector(state);
@ -389,6 +412,56 @@ export const reducer = handleActions(
...state, ...state,
canRequestBlockDonation: true canRequestBlockDonation: true
}), }),
[types.updateDonationFormState]: (state, { payload }) => ({
...state,
donationFormState: { ...state.donationFormState, ...payload }
}),
[types.addDonation]: state => ({
...state,
donationFormState: { ...defaultDonationFormState, processing: true }
}),
[types.addDonationComplete]: state => {
const { appUsername } = state;
return {
...state,
user: {
...state.user,
[appUsername]: {
...state.user[appUsername],
isDonating: true
}
},
donationFormState: { ...defaultDonationFormState, success: true }
};
},
[types.addDonationError]: (state, { payload }) => ({
...state,
donationFormState: { ...defaultDonationFormState, error: payload }
}),
[types.postChargeStripe]: state => ({
...state,
donationFormState: { ...defaultDonationFormState, processing: true }
}),
[types.postChargeStripeComplete]: state => {
const { appUsername } = state;
return {
...state,
user: {
...state.user,
[appUsername]: {
...state.user[appUsername],
isDonating: true
}
},
donationFormState: { ...defaultDonationFormState, success: true }
};
},
[types.postChargeStripeError]: (state, { payload }) => ({
...state,
donationFormState: { ...defaultDonationFormState, error: payload }
}),
[types.fetchUser]: state => ({ [types.fetchUser]: state => ({
...state, ...state,
userFetchState: { ...defaultFetchState } userFetchState: { ...defaultFetchState }

View File

@ -62,7 +62,7 @@ export function postChargeStripe(body) {
return post('/donate/charge-stripe', body); return post('/donate/charge-stripe', body);
} }
export function verifySubscriptionPaypal(body) { export function addDonation(body) {
return post('/donate/add-donation', body); return post('/donate/add-donation', body);
} }

View File

@ -14,11 +14,11 @@ const defaultAmount = {
month: 500, month: 500,
onetime: 25000 onetime: 25000
}; };
const defaultStateConfig = { const defaultDonation = {
donationAmount: defaultAmount['month'], donationAmount: defaultAmount['month'],
donationDuration: 'month' donationDuration: 'month'
}; };
const modalDefaultStateConfig = { const modalDefaultDonation = {
donationAmount: 500, donationAmount: 500,
donationDuration: 'month' donationDuration: 'month'
}; };
@ -123,11 +123,11 @@ module.exports = {
durationsConfig, durationsConfig,
amountsConfig, amountsConfig,
defaultAmount, defaultAmount,
defaultStateConfig, defaultDonation,
durationKeysConfig, durationKeysConfig,
donationOneTimeConfig, donationOneTimeConfig,
donationSubscriptionConfig, donationSubscriptionConfig,
modalDefaultStateConfig, modalDefaultDonation,
onetimeSKUConfig, onetimeSKUConfig,
paypalConfigTypes, paypalConfigTypes,
paypalConfigurator, paypalConfigurator,