From e13f35171ce62daa5f65d76314690fe4a74d9de7 Mon Sep 17 00:00:00 2001 From: Mrugesh Mohapatra Date: Wed, 6 Nov 2019 19:02:20 +0530 Subject: [PATCH] feat(donate): updated donate page and plans - [x] two column layout for the page. - [x] amount to learning minutes mapping for contribution impact. - [x] handle one-time and recurring stripe subscription charges. - [x] server side validation of donate forms. - [x] prevent multiple subscriptions and onetime donations per user. --- api-server/server/boot/donate.js | 203 +++++++++---- api-server/server/models/donation.json | 3 + client/src/components/Donation/Donation.css | 123 +++++--- .../Donation/components/DonateCompletion.js | 10 +- .../Donation/components/DonateForm.js | 282 +++++++++++++++--- .../components/DonateFormChildViewForHOC.js | 29 +- .../Donation/components/DonateText.js | 43 +-- client/src/pages/donate.js | 14 +- 8 files changed, 530 insertions(+), 177 deletions(-) diff --git a/api-server/server/boot/donate.js b/api-server/server/boot/donate.js index 1833434029..2f6d751614 100644 --- a/api-server/server/boot/donate.js +++ b/api-server/server/boot/donate.js @@ -1,5 +1,6 @@ import Stripe from 'stripe'; import debug from 'debug'; +import { isEmail, isNumeric } from 'validator'; import keys from '../../../config/secrets'; @@ -10,41 +11,79 @@ export default function donateBoot(app, done) { const { User } = app.models; const api = app.loopback.Router(); const donateRouter = app.loopback.Router(); - const subscriptionPlans = [500, 1000, 3500, 5000, 25000].reduce( - (accu, current) => ({ - ...accu, - [current]: { - amount: current, - interval: 'month', - product: { - name: - 'Monthly Donation to freeCodeCamp.org - ' + - `Thank you ($${current / 100})` - }, - currency: 'usd', - id: `monthly-donation-${current}` - } - }), - {} + + const durationKeys = ['year', 'month', 'onetime']; + const donationOneTimeConfig = [100000, 25000, 3500]; + const donationSubscriptionConfig = { + duration: { + year: 'Yearly', + month: 'Monthly' + }, + plans: { + year: [100000, 25000, 3500], + month: [5000, 3500, 500] + } + }; + + const subscriptionPlans = Object.keys( + donationSubscriptionConfig.plans + ).reduce( + (prevDuration, duration) => + prevDuration.concat( + donationSubscriptionConfig.plans[duration].reduce( + (prevAmount, amount) => + prevAmount.concat({ + amount: amount, + interval: duration, + product: { + name: `${ + donationSubscriptionConfig.duration[duration] + } Donation to freeCodeCamp.org - Thank you ($${amount / 100})`, + metadata: { + /* eslint-disable camelcase */ + sb_service: `freeCodeCamp.org`, + sb_tier: `${ + donationSubscriptionConfig.duration[duration] + } $${amount / 100} Donation` + /* eslint-enable camelcase */ + } + }, + currency: 'usd', + id: `${donationSubscriptionConfig.duration[ + duration + ].toLowerCase()}-donation-${amount}` + }), + [] + ) + ), + [] ); + function validStripeForm(amount, duration, email) { + return isEmail('' + email) && + isNumeric('' + amount) && + durationKeys.includes(duration) && + duration === 'onetime' + ? donationOneTimeConfig.includes(amount) + : donationSubscriptionConfig.plans[duration]; + } + function connectToStripe() { return new Promise(function(resolve) { // connect to stripe API stripe = Stripe(keys.stripe.secret); // parse stripe plans - stripe.plans.list({}, function(err, plans) { + stripe.plans.list({}, function(err, stripePlans) { if (err) { throw err; } - const requiredPlans = Object.keys(subscriptionPlans).map( - key => subscriptionPlans[key].id - ); - const availablePlans = plans.data.map(plan => plan.id); - requiredPlans.forEach(planId => { - if (!availablePlans.includes(planId)) { - const key = planId.split('-').slice(-1)[0]; - createStripePlan(subscriptionPlans[key]); + const requiredPlans = subscriptionPlans.map(plan => plan.id); + const availablePlans = stripePlans.data.map(plan => plan.id); + requiredPlans.forEach(requiredPlan => { + if (!availablePlans.includes(requiredPlan)) { + createStripePlan( + subscriptionPlans.find(plan => plan.id === requiredPlan) + ); } }); }); @@ -53,12 +92,12 @@ export default function donateBoot(app, done) { } function createStripePlan(plan) { + log(`Creating subscription plan: ${plan.product.name}`); stripe.plans.create(plan, function(err) { if (err) { - console.log(err); - throw err; + log(err); } - console.log(`${plan.id} created`); + log(`Created plan with plan id: ${plan.id}`); return; }); } @@ -66,15 +105,24 @@ export default function donateBoot(app, done) { function createStripeDonation(req, res) { const { user, body } = req; - if (!body || !body.amount) { - return res.status(400).send({ error: 'Amount Required' }); + if (!body || !body.amount || !body.duration) { + return res.status(400).send({ error: 'Amount and duration Required.' }); } const { amount, + duration, token: { email, id } } = body; + if (!validStripeForm(amount, duration, email)) { + return res + .status(500) + .send({ error: 'Invalid donation form values submitted' }); + } + + const isOneTime = duration === 'onetime' ? true : false; + const fccUser = user ? Promise.resolve(user) : new Promise((resolve, reject) => @@ -95,46 +143,85 @@ export default function donateBoot(app, done) { let donation = { email, amount, + duration, provider: 'stripe', startDate: new Date(Date.now()).toISOString() }; + const createCustomer = user => { + donatingUser = user; + return stripe.customers.create({ + email, + card: id + }); + }; + + const createSubscription = customer => { + donation.customerId = customer.id; + return stripe.subscriptions.create({ + customer: customer.id, + items: [ + { + plan: `${donationSubscriptionConfig.duration[ + duration + ].toLowerCase()}-donation-${amount}` + } + ] + }); + }; + + const createOneTimeCharge = customer => { + donation.customerId = customer.id; + return stripe.charges.create({ + amount: amount, + currency: 'usd', + customer: customer.id + }); + }; + + const createAsyncUserDonation = () => { + donatingUser + .createDonation(donation) + .toPromise() + .catch(err => { + throw new Error(err); + }); + }; + return fccUser .then(user => { - donatingUser = user; - return stripe.customers.create({ - email, - card: id - }); + const { isDonating } = user; + if (isDonating) { + throw { + message: `User already has active donation(s).`, + type: 'AlreadyDonatingError' + }; + } + return user; }) + .then(createCustomer) .then(customer => { - donation.customerId = customer.id; - return stripe.subscriptions.create({ - customer: customer.id, - items: [ - { - plan: `monthly-donation-${amount}` - } - ] - }); - }) - .then(subscription => { - donation.subscriptionId = subscription.id; - return res.send(subscription); - }) - .then(() => { - donatingUser - .createDonation(donation) - .toPromise() - .catch(err => { - throw new Error(err); - }); + return isOneTime + ? createOneTimeCharge(customer).then(charge => { + donation.subscriptionId = 'one-time-charge-prefix-' + charge.id; + return res.send(charge); + }) + : createSubscription(customer).then(subscription => { + donation.subscriptionId = subscription.id; + return res.send(subscription); + }); }) + .then(createAsyncUserDonation) .catch(err => { - if (err.type === 'StripeCardError') { + if ( + err.type === 'StripeCardError' || + err.type === 'AlreadyDonatingError' + ) { return res.status(402).send({ error: err.message }); } - return res.status(500).send({ error: 'Donation Failed' }); + return res + .status(500) + .send({ error: 'Donation failed due to a server error.' }); }); } diff --git a/api-server/server/models/donation.json b/api-server/server/models/donation.json index 2b55245edb..1ae4684821 100644 --- a/api-server/server/models/donation.json +++ b/api-server/server/models/donation.json @@ -28,6 +28,9 @@ "required": true, "description": "The donation amount in cents" }, + "duration": { + "type": "string" + }, "startDate": { "type": "DateString", "required": true diff --git a/client/src/components/Donation/Donation.css b/client/src/components/Donation/Donation.css index 9941ffd1a5..9ff7590e5b 100644 --- a/client/src/components/Donation/Donation.css +++ b/client/src/components/Donation/Donation.css @@ -1,21 +1,3 @@ -.donation-modal { - overflow-y: auto; -} - -.donation-modal p { - margin-left: auto; - margin-right: auto; -} - -.donation-modal .alert { - width: 60%; - margin: 0 auto 0; -} - -.donation-modal .modal-title { - font-size: 1.2rem; -} - .donation-form { display: flex; flex-direction: column; @@ -63,21 +45,6 @@ white-space: normal; } -.modal-close-btn-container { - display: flex; - justify-content: center; -} - -.modal-close-btn-container a { - font-size: 18px; -} - -.modal-close-btn-container a:hover { - text-decoration: none; - font-size: 18px; - cursor: pointer; -} - .donate-input-element { padding-top: 8px; } @@ -97,6 +64,92 @@ color: inherit; } -.donate-other .btn-cta { - margin: 7px 0px; +.donate-tabs > .nav-pills { + display: flex; + align-content: space-between; +} + +.donate-tabs > .nav-pills > li { + width: 100%; + text-align: center; +} + +.donate-tabs > .nav-pills > li > a { + text-transform: capitalize; + text-decoration: none; + border: 2px solid var(--yellow-light); + border-radius: 0px; + color: var(--gray-85); + margin: 0 1px; +} + +.donate-tabs > .nav-pills > li > a:hover, +.donate-tabs > .nav-pills > li > a:focus { + background-color: #ffe18f; + cursor: pointer; +} + +.donate-tabs > .nav-pills > li.active > a, +.donate-tabs > .nav-pills > li.active > a:hover, +.donate-tabs > .nav-pills > li.active > a:focus { + color: var(--gray-85); + background-color: var(--yellow-light); + border: 2px solid var(--yellow-light); + text-decoration: none; + border-radius: 0px; + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); +} + +.amount-values { + display: flex; + align-content: space-between; +} + +.amount-value { + width: 100%; +} + +.amount-values > label { + margin: 0 2px !important; + color: var(--gray-85); + border: 2px solid var(--yellow-light); + border-radius: 0px; + background-color: transparent; +} + +.amount-values > label:hover, +.amount-values > label:focus, +.amount-values > label:active:hover { + background-color: #ffe18f; + cursor: pointer; + color: var(--gray-85); + border-color: var(--yellow-light); +} + +.amount-values > label:focus, +.amount-values > label:active:hover { + outline: 5px auto -webkit-focus-ring-color; + outline-color: -webkit-focus-ring-color; + outline-style: auto; + outline-width: 5px; + outline-offset: -2px; +} + +.amount-values > label.active, +.amount-values > label.active:hover, +.amount-values > label.active:focus { + color: var(--gray-85); + background-color: var(--yellow-light); + border: 2px solid var(--yellow-light); + border-radius: 0px; + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); +} + +li.disabled { + cursor: not-allowed; +} + +li.disabled > a { + border: 2px solid var(--gray-15) !important; + color: var(--gray-15) !important; } diff --git a/client/src/components/Donation/components/DonateCompletion.js b/client/src/components/Donation/components/DonateCompletion.js index ef3d49418e..99718a160a 100644 --- a/client/src/components/Donation/components/DonateCompletion.js +++ b/client/src/components/Donation/components/DonateCompletion.js @@ -18,7 +18,7 @@ function DonateCompletion({ processing, reset, success, error = null }) { const heading = processing ? 'We are processing your donation.' : success - ? 'Your donation was successful.' + ? 'Thank you for being a supporter.' : 'Something went wrong with your donation.'; return ( @@ -37,12 +37,12 @@ function DonateCompletion({ processing, reset, success, error = null }) { {success && (

- You can update your supporter status at any time from your account - settings. + Your donation will support free technology education for people + all over the world.

- Thank you for supporting free technology education for people all - over the world. + You can update your supporter status at any time from the 'manage + your existing donation' section on this page.

)} diff --git a/client/src/components/Donation/components/DonateForm.js b/client/src/components/Donation/components/DonateForm.js index 09242ca055..630dd50780 100644 --- a/client/src/components/Donation/components/DonateForm.js +++ b/client/src/components/Donation/components/DonateForm.js @@ -2,9 +2,19 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { Button } from '@freecodecamp/react-bootstrap'; +import { + Button, + Tabs, + Tab, + Row, + Col, + ToggleButtonGroup, + ToggleButton, + Radio +} from '@freecodecamp/react-bootstrap'; import { StripeProvider, Elements } from 'react-stripe-elements'; import { apiLocation } from '../../../../config/env.json'; +import Spacer from '../../helpers/Spacer'; import DonateFormChildViewForHOC from './DonateFormChildViewForHOC'; import { userSelector, @@ -14,8 +24,13 @@ import { } from '../../../redux'; import '../Donation.css'; +import DonateCompletion from './DonateCompletion.js'; + +const numToCommas = num => + num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); const propTypes = { + isDonating: PropTypes.bool, isSignedIn: PropTypes.bool, navigate: PropTypes.func.isRequired, showLoading: PropTypes.bool.isRequired, @@ -28,11 +43,10 @@ const mapStateToProps = createSelector( userSelector, signInLoadingSelector, isSignedInSelector, - ({ email, theme }, showLoading, isSignedIn) => ({ - email, - theme, - showLoading, - isSignedIn + ({ isDonating }, showLoading, isSignedIn) => ({ + isDonating, + isSignedIn, + showLoading }) ); @@ -44,66 +58,246 @@ const createOnClick = navigate => e => { e.preventDefault(); return navigate(`${apiLocation}/signin?returnTo=donate`); }; + class DonateForm extends Component { constructor(...args) { super(...args); this.state = { - donationAmount: 500 + processing: false, + isDonating: this.props.isDonating, + donationAmount: 5000, + donationDuration: 'month', + paymentType: 'Card' }; - this.buttonSingleAmounts = [2500, 5000, 10000, 25000]; - this.buttonMonthlyAmounts = [500, 1000, 2000]; - this.buttonAnnualAmounts = [6000, 10000, 25000, 50000]; + this.durations = { + year: 'yearly', + month: 'monthly', + onetime: 'one-time' + }; + this.amounts = { + year: [100000, 25000, 3500], + month: [5000, 3500, 500], + onetime: [100000, 25000, 3500] + }; - this.isActive = this.isActive.bind(this); - this.renderAmountButtons = this.renderAmountButtons.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.handleSelectPaymentType = this.handleSelectPaymentType.bind(this); + this.hideAmountOptionsCB = this.hideAmountOptionsCB.bind(this); } - isActive(amount) { - return this.state.donationAmount === amount; + getActiveDonationAmount(durationSelected, amountSelected) { + return this.amounts[durationSelected].includes(amountSelected) + ? amountSelected + : this.amounts[durationSelected][0]; } - renderAmountButtons() { - return this.buttonAnnualAmounts.map(amount => ( - + {this.getFormatedAmountLabel(amount)} + )); } - render() { - const { isSignedIn, navigate, showLoading, stripe } = this.props; + renderDurationAmountOptions() { + const { donationAmount, donationDuration, processing } = this.state; + return !processing ? ( +
+

Duration and amount:

+ + {Object.keys(this.durations).map(duration => ( + + +
+ + {this.renderAmountButtons(duration)} + + +

+ {`Your `} + {this.getFormatedAmountLabel(donationAmount)} + {` donation will provide `} + {this.convertToTimeContributed(donationAmount)} + {` of learning to people around the world `} + {duration === 'one-time' ? `for one ` : `each `} + {duration === 'monthly' ? `month.` : `year.`} +

+
+
+ ))} +
+
+ ) : null; + } - if (!showLoading && !isSignedIn) { + hideAmountOptionsCB(hide) { + this.setState({ processing: hide }); + } + + renderDonationOptions() { + const { stripe } = this.props; + const { + donationAmount, + donationDuration, + paymentType, + processing + } = this.state; + return ( +
+ {!processing ? ( +
+ + Donate using a Credit/Debit Card. + + + Donate using PayPal. (Coming soon) + + +
+ ) : null} + {paymentType === 'Card' ? ( + + + + + + ) : ( +

+ PayPal is currently unavailable. Please use a Credit/Debit card + instead. +

+ )} +
+ ); + } + + render() { + const { isSignedIn, navigate, showLoading, isDonating } = this.props; + + if (isDonating) { return ( -
- -
+ + + + + ); } return ( -
- - - - - -
+ + + {this.renderDurationAmountOptions()} + + + {!showLoading && !isSignedIn ? ( + + ) : ( + this.renderDonationOptions() + )} + + + +

Manage your existing donation

+ + + + + +
); } } diff --git a/client/src/components/Donation/components/DonateFormChildViewForHOC.js b/client/src/components/Donation/components/DonateFormChildViewForHOC.js index b6b2f53cb1..dd040493ae 100644 --- a/client/src/components/Donation/components/DonateFormChildViewForHOC.js +++ b/client/src/components/Donation/components/DonateFormChildViewForHOC.js @@ -12,14 +12,17 @@ import { } from '@freecodecamp/react-bootstrap'; import { injectStripe } from 'react-stripe-elements'; -import Spacer from '../../helpers/Spacer'; import StripeCardForm from './StripeCardForm'; import DonateCompletion from './DonateCompletion'; import { postChargeStripe } from '../../../utils/ajax'; import { userSelector, isSignedInSelector } from '../../../redux'; const propTypes = { + donationAmount: PropTypes.number.isRequired, + donationDuration: PropTypes.string.isRequired, email: PropTypes.string, + getDonationButtonLabel: PropTypes.func.isRequired, + hideAmountOptionsCB: PropTypes.func.isRequired, isSignedIn: PropTypes.bool, stripe: PropTypes.shape({ createToken: PropTypes.func.isRequired @@ -27,7 +30,6 @@ const propTypes = { theme: PropTypes.string }; const initialState = { - donationAmount: 500, donationState: { processing: false, success: false, @@ -47,6 +49,8 @@ class DonateFormChildViewForHOC extends Component { this.state = { ...initialState, + donationAmount: this.props.donationAmount, + donationDuration: this.props.donationDuration, email: null, isFormValid: false }; @@ -82,6 +86,7 @@ class DonateFormChildViewForHOC extends Component { handleSubmit(e) { e.preventDefault(); + this.hideAmountOptions(true); const email = this.getUserEmail(); if (!email || !isEmail(email)) { return this.setState(state => ({ @@ -110,8 +115,13 @@ class DonateFormChildViewForHOC extends Component { }); } + hideAmountOptions(hide) { + const { hideAmountOptionsCB } = this.props; + hideAmountOptionsCB(hide); + } + postDonation(token) { - const { donationAmount: amount } = this.state; + const { donationAmount: amount, donationDuration: duration } = this.state; const { isSignedIn } = this.props; this.setState(state => ({ ...state, @@ -123,7 +133,8 @@ class DonateFormChildViewForHOC extends Component { return postChargeStripe(isSignedIn, { token, - amount + amount, + duration }) .then(response => { const data = response && response.data; @@ -167,7 +178,7 @@ class DonateFormChildViewForHOC extends Component { renderDonateForm() { const { isFormValid } = this.state; - const { theme } = this.props; + const { theme, getDonationButtonLabel } = this.props; return (
@@ -193,13 +204,16 @@ class DonateFormChildViewForHOC extends Component { id='confirm-donation-btn' type='submit' > - Confirm your donation of $5 / month + {getDonationButtonLabel()} - ); } + componentWillReceiveProps({ donationAmount, donationDuration }) { + this.setState({ donationAmount, donationDuration }); + } + render() { const { donationState: { processing, success, error } @@ -212,6 +226,7 @@ class DonateFormChildViewForHOC extends Component { reset: this.resetDonation }); } + this.hideAmountOptions(false); return this.renderDonateForm(); } } diff --git a/client/src/components/Donation/components/DonateText.js b/client/src/components/Donation/components/DonateText.js index a67825f96b..4856e5c87f 100644 --- a/client/src/components/Donation/components/DonateText.js +++ b/client/src/components/Donation/components/DonateText.js @@ -1,27 +1,30 @@ import React from 'react'; +import { Row, Col } from '@freecodecamp/react-bootstrap'; const DonateText = () => { return ( - <> -

freeCodeCamp is a highly efficient education nonprofit.

-

- In 2019 alone, we provided 1,100,000,000 minutes of free education to - people around the world. -

-

- Since freeCodeCamp's total budget is only $373,000, that means every - dollar you donate to freeCodeCamp translates into 50 hours worth of - technology education. -

-

- When you donate to freeCodeCamp, you help people learn new skills and - provide for their families. -

-

- You also help us create new resources for you to use to expand your own - technology skills. -

- + + +

freeCodeCamp is a highly efficient education nonprofit.

+

+ In 2019 alone, we provided 1,100,000,000 minutes of free education to + people around the world. +

+

+ Since freeCodeCamp's total budget is only $373,000, that means every + dollar you donate to freeCodeCamp translates into 50 hours worth of + technology education. +

+

+ When you donate to freeCodeCamp, you help people learn new skills and + provide for their families. +

+

+ You also help us create new resources for you to use to expand your + own technology skills. +

+ +
); }; diff --git a/client/src/pages/donate.js b/client/src/pages/donate.js index e3251d3152..8fbb5797a4 100644 --- a/client/src/pages/donate.js +++ b/client/src/pages/donate.js @@ -72,21 +72,19 @@ export class DonatePage extends Component { + - -

Become a Supporter

+

Become a Supporter

- - - - - + - + + +