From 3106fe804f0193af20f739c2d16fe78c1a2213a6 Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Wed, 14 Oct 2020 13:23:26 +0300 Subject: [PATCH] fix(client): unify client donations methods (#39562) Co-authored-by: Oliver Eyton-Williams --- .../client-only-routes/ShowCertification.js | 5 +- client/src/components/Donation/DonateForm.js | 223 ++++++++++++++---- .../Donation/DonateFormChildViewForHOC.js | 106 ++------- client/src/components/Donation/Donation.css | 4 + .../src/components/Donation/DonationModal.js | 40 ++-- .../components/Donation/MinimalDonateForm.js | 174 -------------- .../src/components/Donation/PaypalButton.js | 61 ++--- client/src/pages/donate.js | 35 +-- client/src/redux/donation-saga.js | 55 ++++- client/src/redux/index.js | 81 ++++++- client/src/utils/ajax.js | 2 +- config/donation-settings.js | 8 +- 12 files changed, 377 insertions(+), 417 deletions(-) delete mode 100644 client/src/components/Donation/MinimalDonateForm.js diff --git a/client/src/client-only-routes/ShowCertification.js b/client/src/client-only-routes/ShowCertification.js index aa861a912f..c3df1079bd 100644 --- a/client/src/client-only-routes/ShowCertification.js +++ b/client/src/client-only-routes/ShowCertification.js @@ -8,7 +8,7 @@ import format from 'date-fns/format'; import { Grid, Row, Col, Image, Button } from '@freecodecamp/react-bootstrap'; import FreeCodeCampLogo from '../assets/icons/freeCodeCampLogo'; // eslint-disable-next-line max-len -import MinimalDonateForm from '../components/Donation/MinimalDonateForm'; +import DonateForm from '../components/Donation/DonateForm'; import { showCertSelector, @@ -233,9 +233,10 @@ class ShowCertification extends Component { )} - diff --git a/client/src/components/Donation/DonateForm.js b/client/src/components/Donation/DonateForm.js index 4703ebfd4e..5d5e6bd6e8 100644 --- a/client/src/components/Donation/DonateForm.js +++ b/client/src/components/Donation/DonateForm.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; +import { StripeProvider, Elements } from 'react-stripe-elements'; import { Button, Col, @@ -18,15 +19,28 @@ import { amountsConfig, durationsConfig, defaultAmount, - defaultStateConfig, + defaultDonation, onetimeSKUConfig, - donationUrls + donationUrls, + modalDefaultDonation } 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 Spacer from '../helpers/Spacer'; import PaypalButton from './PaypalButton'; 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'; @@ -34,32 +48,35 @@ const numToCommas = num => num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); const propTypes = { + addDonation: PropTypes.func, + defaultTheme: PropTypes.string, + donationFormState: PropTypes.object, handleProcessing: PropTypes.func, isDonating: PropTypes.bool, + isMinimalForm: PropTypes.bool, isSignedIn: PropTypes.bool, navigate: PropTypes.func.isRequired, + postChargeStripe: PropTypes.func.isRequired, showLoading: PropTypes.bool.isRequired, - stripe: PropTypes.shape({ - createToken: PropTypes.func.isRequired, - redirectToCheckout: PropTypes.func.isRequired - }) + updateDonationFormState: PropTypes.func }; const mapStateToProps = createSelector( signInLoadingSelector, isSignedInSelector, - (showLoading, isSignedIn) => ({ + donationFormStateSelector, + (showLoading, isSignedIn, donationFormState) => ({ isSignedIn, - showLoading + showLoading, + donationFormState }) ); -const initialState = { - donationState: { - processing: false, - success: false, - error: '' - } +const mapDispatchToProps = { + addDonation, + navigate, + postChargeStripe, + updateDonationFormState }; class DonateForm extends Component { @@ -69,12 +86,17 @@ class DonateForm extends Component { this.durations = durationsConfig; this.amounts = amountsConfig; + const initialAmountAndDuration = this.props.isMinimalForm + ? modalDefaultDonation + : defaultDonation; + this.state = { - ...initialState, - ...defaultStateConfig, - processing: false + ...initialAmountAndDuration, + processing: false, + stripe: null }; + this.handleStripeLoad = this.handleStripeLoad.bind(this); this.onDonationStateChange = this.onDonationStateChange.bind(this); this.getActiveDonationAmount = this.getActiveDonationAmount.bind(this); this.getDonationButtonLabel = this.getDonationButtonLabel.bind(this); @@ -87,17 +109,43 @@ class DonateForm extends Component { this.resetDonation = this.resetDonation.bind(this); } - onDonationStateChange(success, processing, error) { - this.setState(state => ({ - ...state, - donationState: { - ...state.donationState, - processing: processing, - success: success, - error: error - } - })); - if (success) { + 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); + } + 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); } } @@ -126,7 +174,7 @@ class DonateForm extends Component { } else { donationBtnLabel = `Confirm your donation of ${this.getFormatedAmountLabel( donationAmount - )} ${donationDuration === 'month' ? 'per month' : 'per year'}`; + )} ${donationDuration === 'month' ? ' / month' : ' / year'}`; } return donationBtnLabel; } @@ -140,10 +188,16 @@ class DonateForm extends Component { this.setState({ donationAmount }); } - async handleStripeCheckoutRedirect(e) { - const { stripe } = this.props; + async handleStripeCheckoutRedirect(e, paymentMethod) { + const { stripe } = this.state; const { donationAmount, donationDuration } = this.state; + this.props.handleProcessing( + donationDuration, + donationAmount, + `stripe (${paymentMethod}) button click` + ); + const isOneTime = donationDuration === 'onetime'; const getSKUId = () => { const { id } = onetimeSKUConfig[deploymentEnv || 'staging'].find( @@ -236,7 +290,7 @@ class DonateForm extends Component { } renderDonationOptions() { - const { handleProcessing, isSignedIn } = this.props; + const { handleProcessing, isSignedIn, addDonation } = this.props; const { donationAmount, donationDuration } = this.state; const isOneTime = donationDuration === 'onetime'; @@ -257,7 +311,7 @@ class DonateForm extends Component { bsStyle='primary' className='btn-cta' id='confirm-donation-btn' - onClick={this.handleStripeCheckoutRedirect} + onClick={e => this.handleStripeCheckoutRedirect(e, 'apple pay')} > Donate with Apple Pay @@ -269,7 +323,7 @@ class DonateForm extends Component { bsStyle='primary' className='btn-cta' id='confirm-donation-btn' - onClick={this.handleStripeCheckoutRedirect} + onClick={e => this.handleStripeCheckoutRedirect(e, 'google pay')} > Donate with Google Pay @@ -280,7 +334,7 @@ class DonateForm extends Component { bsStyle='primary' className='btn-cta' id='confirm-donation-btn' - onClick={this.handleStripeCheckoutRedirect} + onClick={e => this.handleStripeCheckoutRedirect(e, 'credit card')} > Donate with Card @@ -292,6 +346,7 @@ class DonateForm extends Component { ; } - render() { + renderModalForm() { + const { donationAmount, donationDuration, stripe } = this.state; const { - donationState: { processing, success, error } - } = this.state; - if (processing || success || error) { - return this.renderCompletion({ - processing, - success, - error, - reset: this.resetDonation - }); - } + handleProcessing, + defaultTheme, + addDonation, + postChargeStripe + } = this.props; + + return ( + + + + {this.getDonationButtonLabel()} with PayPal: + + + + + + Or donate with a credit card: + + + + + + + + + ); + } + + renderPageForm() { return ( @@ -335,9 +424,45 @@ class DonateForm extends Component { ); } + + 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 + })} +
+ {isMinimalForm + ? this.renderModalForm(processing) + : this.renderPageForm(processing)} +
+ + ); + } } DonateForm.displayName = 'DonateForm'; DonateForm.propTypes = propTypes; -export default connect(mapStateToProps)(DonateForm); +export default connect( + mapStateToProps, + mapDispatchToProps +)(DonateForm); diff --git a/client/src/components/Donation/DonateFormChildViewForHOC.js b/client/src/components/Donation/DonateFormChildViewForHOC.js index 2df8259ddd..223a799462 100644 --- a/client/src/components/Donation/DonateFormChildViewForHOC.js +++ b/client/src/components/Donation/DonateFormChildViewForHOC.js @@ -15,7 +15,6 @@ import { injectStripe } from 'react-stripe-elements'; import StripeCardForm from './StripeCardForm'; import DonateCompletion from './DonateCompletion'; -import { postChargeStripe } from '../../utils/ajax'; import { userSelector } from '../../redux'; const propTypes = { @@ -26,19 +25,14 @@ const propTypes = { getDonationButtonLabel: PropTypes.func.isRequired, handleProcessing: PropTypes.func, isSignedIn: PropTypes.bool, + onDonationStateChange: PropTypes.func, + postChargeStripe: PropTypes.func, showCloseBtn: PropTypes.func, stripe: PropTypes.shape({ createToken: PropTypes.func.isRequired }), theme: PropTypes.string }; -const initialState = { - donationState: { - processing: false, - success: false, - error: '' - } -}; const mapStateToProps = createSelector( userSelector, @@ -50,9 +44,6 @@ class DonateFormChildViewForHOC extends Component { super(...args); this.state = { - ...initialState, - donationAmount: this.props.donationAmount, - donationDuration: this.props.donationDuration, isSubmissionValid: null, email: null, isEmailValid: true, @@ -63,7 +54,6 @@ class DonateFormChildViewForHOC extends Component { this.handleEmailChange = this.handleEmailChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); this.postDonation = this.postDonation.bind(this); - this.resetDonation = this.resetDonation.bind(this); this.handleEmailBlur = this.handleEmailBlur.bind(this); } @@ -106,41 +96,26 @@ class DonateFormChildViewForHOC extends Component { const email = this.getUserEmail(); if (!email || !isEmail(email)) { - return this.setState(state => ({ - ...state, - donationState: { - ...state.donationState, - error: - 'We need a valid email address to which we can send your' + - ' donation tax receipt.' - } - })); + return this.props.onDonationStateChange({ + 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 }) => { if (error) { - return this.setState(state => ({ - ...state, - donationState: { - ...state.donationState, - error: - 'Something went wrong processing your donation. Your card' + - ' has not been charged.' - } - })); + return this.props.onDonationStateChange({ + error: + 'Something went wrong processing your donation. Your card' + + ' has not been charged.' + }); } return this.postDonation(token); }); } postDonation(token) { - const { donationAmount: amount, donationDuration: duration } = this.state; - this.setState(state => ({ - ...state, - donationState: { - ...state.donationState, - processing: true - } - })); + const { donationAmount: amount, donationDuration: duration } = this.props; // scroll to top window.scrollTo(0, 0); @@ -150,50 +125,15 @@ class DonateFormChildViewForHOC extends Component { if (this.props.handleProcessing) { this.props.handleProcessing( this.state.donationDuration, - Math.round(this.state.donationAmount / 100) + Math.round(amount / 100) ); } - return postChargeStripe({ + return this.props.postChargeStripe({ token, amount, 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) { @@ -267,25 +207,13 @@ class DonateFormChildViewForHOC extends Component { ); } - componentWillReceiveProps({ donationAmount, donationDuration, email }) { - this.setState({ donationAmount, donationDuration }); + componentWillReceiveProps({ email }) { if (this.state.email === null && email) { this.setState({ email }); } } render() { - const { - donationState: { processing, success, error } - } = this.state; - if (processing || success || error) { - return this.renderCompletion({ - processing, - success, - error, - reset: this.resetDonation - }); - } return this.renderDonateForm(); } } diff --git a/client/src/components/Donation/Donation.css b/client/src/components/Donation/Donation.css index 90a84f3e68..8f49fc6648 100644 --- a/client/src/components/Donation/Donation.css +++ b/client/src/components/Donation/Donation.css @@ -348,3 +348,7 @@ li.disabled > a { justify-content: center; align-content: center; } + +.hide { + display: none; +} diff --git a/client/src/components/Donation/DonationModal.js b/client/src/components/Donation/DonationModal.js index a8dfe8d1c9..aa3e9412b6 100644 --- a/client/src/components/Donation/DonationModal.js +++ b/client/src/components/Donation/DonationModal.js @@ -1,5 +1,5 @@ /* eslint-disable max-len */ -import React from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; @@ -9,8 +9,8 @@ import { Spacer } from '../helpers'; import { blockNameify } from '../../../../utils/block-nameify'; import Heart from '../../assets/icons/Heart'; import Cup from '../../assets/icons/Cup'; -import MinimalDonateForm from './MinimalDonateForm'; -import { modalDefaultStateConfig } from '../../../../config/donation-settings'; +import DonateForm from './DonateForm'; +import { modalDefaultDonation } from '../../../../config/donation-settings'; import { closeDonationModal, @@ -77,19 +77,21 @@ function DonateModal({ setCloseLabel(true); }; - if (show) { - executeGA({ type: 'modal', data: '/donation-modal' }); - executeGA({ - type: 'event', - data: { - category: 'Donation', - action: `Displayed ${ - isBlockDonation ? 'block' : 'progress' - } donation modal`, - nonInteraction: true - } - }); - } + useEffect(() => { + if (show) { + executeGA({ type: 'modal', data: '/donation-modal' }); + executeGA({ + type: 'event', + data: { + category: 'Donation', + action: `Displayed ${ + isBlockDonation ? 'block' : 'progress' + } donation modal`, + nonInteraction: true + } + }); + } + }, [show, isBlockDonation, executeGA]); const durationToText = donationDuration => { if (donationDuration === 'onetime') return 'a one-time'; @@ -100,8 +102,8 @@ function DonateModal({ const donationText = ( - Become {durationToText(modalDefaultStateConfig.donationDuration)}{' '} - supporter of our nonprofit. + Become {durationToText(modalDefaultDonation.donationDuration)} supporter + of our nonprofit. ); const blockDonationText = ( @@ -141,7 +143,7 @@ function DonateModal({ {isBlockDonation ? blockDonationText : progressDonationText} - + diff --git a/client/src/components/Donation/MinimalDonateForm.js b/client/src/components/Donation/MinimalDonateForm.js deleted file mode 100644 index 5a58e78808..0000000000 --- a/client/src/components/Donation/MinimalDonateForm.js +++ /dev/null @@ -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 ; - } - - 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 ( - - - - Confirm your donation of {donationPlan} with PayPal: - - - - - - Or donate with a credit card: - - - - - `Confirm your donation of ${donationPlan}` - } - handleProcessing={handleProcessing} - /> - - - - - ); - } -} - -MinimalDonateForm.displayName = 'MinimalDonateForm'; -MinimalDonateForm.propTypes = propTypes; - -export default connect( - mapStateToProps, - null -)(MinimalDonateForm); diff --git a/client/src/components/Donation/PaypalButton.js b/client/src/components/Donation/PaypalButton.js index 97ffabc25b..a0c351606e 100644 --- a/client/src/components/Donation/PaypalButton.js +++ b/client/src/components/Donation/PaypalButton.js @@ -6,7 +6,6 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import PayPalButtonScriptLoader from './PayPalButtonScriptLoader'; import { paypalClientId, deploymentEnv } from '../../../config/env.json'; -import { verifySubscriptionPaypal } from '../../utils/ajax'; import { paypalConfigurator, paypalConfigTypes @@ -38,39 +37,20 @@ export class PaypalButton extends Component { handleApproval = (data, isSubscription) => { const { amount, duration } = this.state; const { skipAddDonation = false } = this.props; + // Skip the api if user is not signed in or if its a one-time donation - if (skipAddDonation || !isSubscription) { - this.props.onDonationStateChange( - 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); - }); + if (!skipAddDonation || isSubscription) { + this.props.addDonation(data); } + + 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() { @@ -107,14 +87,18 @@ export class PaypalButton extends Component { this.handleApproval(data, isSubscription); }} onCancel={() => { - this.props.onDonationStateChange( - false, - false, - `Uh - oh. It looks like your transaction didn't go through. Could you please try again?` - ); + this.props.onDonationStateChange({ + processing: false, + success: false, + error: `Uh - oh. It looks like your transaction didn't go through. Could you please try again?` + }); }} onError={() => - this.props.onDonationStateChange(false, false, 'Please try again.') + this.props.onDonationStateChange({ + processing: false, + success: false, + error: 'Please try again.' + }) } plantId={planId} style={{ @@ -127,6 +111,7 @@ export class PaypalButton extends Component { } const propTypes = { + addDonation: PropTypes.func, donationAmount: PropTypes.number, donationDuration: PropTypes.string, handleProcessing: PropTypes.func, diff --git a/client/src/pages/donate.js b/client/src/pages/donate.js index 04115bdb3d..c6b6a7a510 100644 --- a/client/src/pages/donate.js +++ b/client/src/pages/donate.js @@ -6,12 +6,10 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { Grid, Row, Col, Alert } from '@freecodecamp/react-bootstrap'; -import { stripePublicKey } from '../../config/env.json'; import { Spacer, Loader } from '../components/helpers'; import DonateForm from '../components/Donation/DonateForm'; import DonateText from '../components/Donation/DonateText'; import { signInLoadingSelector, userSelector, executeGA } from '../redux'; -import { stripeScriptLoader } from '../utils/scriptLoaders'; const propTypes = { executeGA: PropTypes.func, @@ -40,11 +38,9 @@ export class DonatePage extends Component { constructor(...props) { super(...props); this.state = { - stripe: null, enableSettings: false }; this.handleProcessing = this.handleProcessing.bind(this); - this.handleStripeLoad = this.handleStripeLoad.bind(this); } componentDidMount() { @@ -56,47 +52,21 @@ export class DonatePage extends Component { 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() { - const stripeMountPoint = document.querySelector('#stripe-js'); - if (stripeMountPoint) { - stripeMountPoint.removeEventListener('load', this.handleStripeLoad); - } - } - - handleProcessing(duration, amount) { + handleProcessing(duration, amount, action = 'stripe button click') { this.props.executeGA({ type: 'event', data: { category: 'donation', - action: 'donate page stripe form submission', + action: `donate page ${action}`, label: duration, value: amount } }); } - handleStripeLoad() { - // Create Stripe instance once Stripe.js loads - console.info('stripe has loaded'); - this.setState(state => ({ - ...state, - stripe: window.Stripe(stripePublicKey) - })); - } - render() { - const { stripe } = this.state; const { showLoading, isDonating } = this.props; if (showLoading) { @@ -141,7 +111,6 @@ export class DonatePage extends Component { diff --git a/client/src/redux/donation-saga.js b/client/src/redux/donation-saga.js index a8d3ef06a6..4d3fa981e4 100644 --- a/client/src/redux/donation-saga.js +++ b/client/src/redux/donation-saga.js @@ -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 { openDonationModal, preventBlockDonationRequests, shouldRequestDonationSelector, preventProgressDonationRequests, - canRequestBlockDonationSelector + canRequestBlockDonationSelector, + addDonationComplete, + addDonationError, + postChargeStripeComplete, + postChargeStripeError } from './'; +import { addDonation, postChargeStripe } from '../utils/ajax'; + +const defaultDonationError = `Something is not right. Please contact donors@freecodecamp.org`; + function* showDonateModalSaga() { let shouldRequestDonation = yield select(shouldRequestDonationSelector); if (shouldRequestDonation) { @@ -22,6 +37,38 @@ function* showDonateModalSaga() { } } -export function createDonationSaga(types) { - return [takeEvery(types.tryToShowDonationModal, showDonateModalSaga)]; +function* addDonationSaga({ payload }) { + 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) + ]; } diff --git a/client/src/redux/index.js b/client/src/redux/index.js index 227c6f1b77..528bef8465 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -31,6 +31,12 @@ export const defaultFetchState = { error: null }; +export const defaultDonationFormState = { + processing: false, + success: false, + error: '' +}; + const initialState = { appUsername: '', canRequestBlockDonation: false, @@ -51,7 +57,10 @@ const initialState = { sessionMeta: { activeDonations: 0 }, showDonationModal: false, isBlockDonationModal: false, - isOnline: true + isOnline: true, + donationFormState: { + ...defaultDonationFormState + } }; export const types = createTypes( @@ -71,7 +80,10 @@ export const types = createTypes( 'updateComplete', 'updateCurrentChallengeId', 'updateFailed', + 'updateDonationFormState', ...createAsyncTypes('fetchUser'), + ...createAsyncTypes('addDonation'), + ...createAsyncTypes('postChargeStripe'), ...createAsyncTypes('fetchProfileForUser'), ...createAsyncTypes('acceptTerms'), ...createAsyncTypes('showCert'), @@ -112,6 +124,9 @@ export const preventBlockDonationRequests = createAction( export const preventProgressDonationRequests = createAction( types.preventProgressDonationRequests ); +export const updateDonationFormState = createAction( + types.updateDonationFormState +); export const onlineStatusChange = createAction(types.onlineStatusChange); @@ -133,6 +148,16 @@ export const fetchUser = createAction(types.fetchUser); export const fetchUserComplete = createAction(types.fetchUserComplete); 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 fetchProfileForUserComplete = createAction( types.fetchProfileForUserComplete @@ -160,7 +185,6 @@ export const completedChallengesSelector = state => export const completionCountSelector = state => state[ns].completionCount; export const currentChallengeIdSelector = state => state[ns].currentChallengeId; export const isDonatingSelector = state => userSelector(state).isDonating; - export const isOnlineSelector = state => state[ns].isOnline; export const isSignedInSelector = state => !!state[ns].appUsername; export const isDonationModalOpenSelector = state => state[ns].showDonationModal; @@ -168,12 +192,11 @@ export const canRequestBlockDonationSelector = state => state[ns].canRequestBlockDonation; export const isBlockDonationModalSelector = state => state[ns].isBlockDonationModal; - +export const donationFormStateSelector = state => state[ns].donationFormState; export const signInLoadingSelector = state => userFetchStateSelector(state).pending; export const showCertSelector = state => state[ns].showCert; export const showCertFetchStateSelector = state => state[ns].showCertFetchState; - export const shouldRequestDonationSelector = state => { const completedChallenges = completedChallengesSelector(state); const completionCount = completionCountSelector(state); @@ -389,6 +412,56 @@ export const reducer = handleActions( ...state, 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 => ({ ...state, userFetchState: { ...defaultFetchState } diff --git a/client/src/utils/ajax.js b/client/src/utils/ajax.js index 091a8c1f55..7f6931cdad 100644 --- a/client/src/utils/ajax.js +++ b/client/src/utils/ajax.js @@ -62,7 +62,7 @@ export function postChargeStripe(body) { return post('/donate/charge-stripe', body); } -export function verifySubscriptionPaypal(body) { +export function addDonation(body) { return post('/donate/add-donation', body); } diff --git a/config/donation-settings.js b/config/donation-settings.js index 5d17d4e3ac..d4d979d9b0 100644 --- a/config/donation-settings.js +++ b/config/donation-settings.js @@ -14,11 +14,11 @@ const defaultAmount = { month: 500, onetime: 25000 }; -const defaultStateConfig = { +const defaultDonation = { donationAmount: defaultAmount['month'], donationDuration: 'month' }; -const modalDefaultStateConfig = { +const modalDefaultDonation = { donationAmount: 500, donationDuration: 'month' }; @@ -123,11 +123,11 @@ module.exports = { durationsConfig, amountsConfig, defaultAmount, - defaultStateConfig, + defaultDonation, durationKeysConfig, donationOneTimeConfig, donationSubscriptionConfig, - modalDefaultStateConfig, + modalDefaultDonation, onetimeSKUConfig, paypalConfigTypes, paypalConfigurator,