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.
This commit is contained in:
Mrugesh Mohapatra
2019-11-06 19:02:20 +05:30
parent 6921c3fecc
commit e13f35171c
8 changed files with 530 additions and 177 deletions

View File

@ -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.' });
});
}

View File

@ -28,6 +28,9 @@
"required": true,
"description": "The donation amount in cents"
},
"duration": {
"type": "string"
},
"startDate": {
"type": "DateString",
"required": true

View File

@ -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;
}

View File

@ -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 (
<Alert bsStyle={style} className='donation-completion'>
@ -37,12 +37,12 @@ function DonateCompletion({ processing, reset, success, error = null }) {
{success && (
<div>
<p>
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.
</p>
<p>
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.
</p>
</div>
)}

View File

@ -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 => (
<Button
className={`amount-value ${this.isActive(amount) ? 'active' : ''}`}
href=''
id={amount}
key={'amount-' + amount}
onClick={this.handleAmountClick}
tabIndex='-1'
convertToTimeContributed(amount) {
return `${numToCommas((amount / 100) * 50 * 60)} minutes`;
}
getFormatedAmountLabel(amount) {
return `$${numToCommas(amount / 100)}`;
}
getDonationButtonLabel() {
const { donationAmount, donationDuration } = this.state;
let donationBtnLabel = `Confirm your donation`;
if (donationDuration === 'onetime') {
donationBtnLabel = `Confirm your one-time donation of ${this.getFormatedAmountLabel(
donationAmount
)}`;
} else {
donationBtnLabel = `Confirm your donation of ${this.getFormatedAmountLabel(
donationAmount
)} ${donationDuration === 'month' ? 'per month' : 'per year'}`;
}
return donationBtnLabel;
}
handleSelectDuration(donationDuration) {
const donationAmount = this.getActiveDonationAmount(donationDuration, 0);
this.setState({ donationDuration, donationAmount });
}
handleSelectAmount(donationAmount) {
this.setState({ donationAmount });
}
handleSelectPaymentType(e) {
this.setState({
paymentType: e.currentTarget.value
});
}
renderAmountButtons(duration) {
return this.amounts[duration].map(amount => (
<ToggleButton
className='amount-value'
id={`${this.durations[duration]}-donation-${amount}`}
key={`${this.durations[duration]}-donation-${amount}`}
value={amount}
>
{`$${amount / 100}`}
</Button>
{this.getFormatedAmountLabel(amount)}
</ToggleButton>
));
}
render() {
const { isSignedIn, navigate, showLoading, stripe } = this.props;
renderDurationAmountOptions() {
const { donationAmount, donationDuration, processing } = this.state;
return !processing ? (
<div>
<h3>Duration and amount:</h3>
<Tabs
activeKey={donationDuration}
animation={false}
bsStyle='pills'
className='donate-tabs'
id='Duration'
onSelect={this.handleSelectDuration}
>
{Object.keys(this.durations).map(duration => (
<Tab
eventKey={duration}
key={duration}
title={this.durations[duration]}
>
<Spacer />
<div>
<ToggleButtonGroup
animation={`false`}
className='amount-values'
name='amounts'
onChange={this.handleSelectAmount}
type='radio'
value={this.getActiveDonationAmount(duration, donationAmount)}
>
{this.renderAmountButtons(duration)}
</ToggleButtonGroup>
<Spacer />
<p>
{`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.`}
</p>
</div>
</Tab>
))}
</Tabs>
</div>
) : null;
}
if (!showLoading && !isSignedIn) {
hideAmountOptionsCB(hide) {
this.setState({ processing: hide });
}
renderDonationOptions() {
const { stripe } = this.props;
const {
donationAmount,
donationDuration,
paymentType,
processing
} = this.state;
return (
<div>
{!processing ? (
<div>
<Radio
checked={paymentType === 'Card'}
name='payment-method'
onChange={this.handleSelectPaymentType}
value='Card'
>
Donate using a Credit/Debit Card.
</Radio>
<Radio
checked={paymentType === 'PayPal'}
// disable the paypal integration for now
disabled={true}
name='payment-method'
onChange={this.handleSelectPaymentType}
value='PayPal'
>
Donate using PayPal. (Coming soon)
</Radio>
<Spacer />
</div>
) : null}
{paymentType === 'Card' ? (
<StripeProvider stripe={stripe}>
<Elements>
<DonateFormChildViewForHOC
donationAmount={donationAmount}
donationDuration={donationDuration}
getDonationButtonLabel={this.getDonationButtonLabel}
hideAmountOptionsCB={this.hideAmountOptionsCB}
/>
</Elements>
</StripeProvider>
) : (
<p>
PayPal is currently unavailable. Please use a Credit/Debit card
instead.
</p>
)}
</div>
);
}
render() {
const { isSignedIn, navigate, showLoading, isDonating } = this.props;
if (isDonating) {
return (
<div>
<Button
bsStyle='default'
className='btn btn-block'
onClick={createOnClick(navigate)}
>
Become a supporter
</Button>
</div>
<Row>
<Col sm={10} smOffset={1} xs={12}>
<DonateCompletion success={true} />
</Col>
</Row>
);
}
return (
<div>
<StripeProvider stripe={stripe}>
<Elements>
<DonateFormChildViewForHOC />
</Elements>
</StripeProvider>
</div>
<Row>
<Col sm={10} smOffset={1} xs={12}>
{this.renderDurationAmountOptions()}
</Col>
<Col sm={10} smOffset={1} xs={12}>
{!showLoading && !isSignedIn ? (
<Button
bsStyle='default'
className='btn btn-block'
onClick={createOnClick(navigate)}
>
Become a supporter
</Button>
) : (
this.renderDonationOptions()
)}
</Col>
<Col sm={10} smOffset={1} xs={12}>
<Spacer size={2} />
<h3 className='text-center'>Manage your existing donation</h3>
<Button block={true} bsStyle='primary' disabled={true}>
Update your existing donation
</Button>
<Spacer />
<Button block={true} bsStyle='primary' disabled={true}>
Download donation receipts
</Button>
<Spacer />
</Col>
</Row>
);
}
}

View File

@ -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 (
<Form className='donation-form' onSubmit={this.handleSubmit}>
<FormGroup className='donation-email-container'>
@ -193,13 +204,16 @@ class DonateFormChildViewForHOC extends Component {
id='confirm-donation-btn'
type='submit'
>
Confirm your donation of $5 / month
{getDonationButtonLabel()}
</Button>
<Spacer />
</Form>
);
}
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();
}
}

View File

@ -1,27 +1,30 @@
import React from 'react';
import { Row, Col } from '@freecodecamp/react-bootstrap';
const DonateText = () => {
return (
<>
<p>freeCodeCamp is a highly efficient education nonprofit.</p>
<p>
In 2019 alone, we provided 1,100,000,000 minutes of free education to
people around the world.
</p>
<p>
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.
</p>
<p>
When you donate to freeCodeCamp, you help people learn new skills and
provide for their families.
</p>
<p>
You also help us create new resources for you to use to expand your own
technology skills.
</p>
</>
<Row>
<Col sm={10} smOffset={1} xs={12}>
<p>freeCodeCamp is a highly efficient education nonprofit.</p>
<p>
In 2019 alone, we provided 1,100,000,000 minutes of free education to
people around the world.
</p>
<p>
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.
</p>
<p>
When you donate to freeCodeCamp, you help people learn new skills and
provide for their families.
</p>
<p>
You also help us create new resources for you to use to expand your
own technology skills.
</p>
</Col>
</Row>
);
};

View File

@ -72,21 +72,19 @@ export class DonatePage extends Component {
<Fragment>
<Helmet title='Support our nonprofit | freeCodeCamp.org' />
<Grid>
<Spacer />
<Row>
<Col sm={10} smOffset={1} xs={12}>
<Spacer />
<h2 className='text-center'>Become a Supporter</h2>
<h1 className='text-center'>Become a Supporter</h1>
<Spacer />
</Col>
</Row>
<Row>
<Col sm={8} smOffset={2} xs={12}>
<DonateText />
<Spacer />
</Col>
<Col sm={6} smOffset={3} xs={12}>
<Col md={6}>
<DonateForm stripe={stripe} />
<Spacer />
</Col>
<Col md={6}>
<DonateText />
</Col>
</Row>
</Grid>