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:
@ -1,5 +1,6 @@
|
|||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
|
import { isEmail, isNumeric } from 'validator';
|
||||||
|
|
||||||
import keys from '../../../config/secrets';
|
import keys from '../../../config/secrets';
|
||||||
|
|
||||||
@ -10,41 +11,79 @@ export default function donateBoot(app, done) {
|
|||||||
const { User } = app.models;
|
const { User } = app.models;
|
||||||
const api = app.loopback.Router();
|
const api = app.loopback.Router();
|
||||||
const donateRouter = app.loopback.Router();
|
const donateRouter = app.loopback.Router();
|
||||||
const subscriptionPlans = [500, 1000, 3500, 5000, 25000].reduce(
|
|
||||||
(accu, current) => ({
|
const durationKeys = ['year', 'month', 'onetime'];
|
||||||
...accu,
|
const donationOneTimeConfig = [100000, 25000, 3500];
|
||||||
[current]: {
|
const donationSubscriptionConfig = {
|
||||||
amount: current,
|
duration: {
|
||||||
interval: 'month',
|
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: {
|
product: {
|
||||||
name:
|
name: `${
|
||||||
'Monthly Donation to freeCodeCamp.org - ' +
|
donationSubscriptionConfig.duration[duration]
|
||||||
`Thank you ($${current / 100})`
|
} 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',
|
currency: 'usd',
|
||||||
id: `monthly-donation-${current}`
|
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() {
|
function connectToStripe() {
|
||||||
return new Promise(function(resolve) {
|
return new Promise(function(resolve) {
|
||||||
// connect to stripe API
|
// connect to stripe API
|
||||||
stripe = Stripe(keys.stripe.secret);
|
stripe = Stripe(keys.stripe.secret);
|
||||||
// parse stripe plans
|
// parse stripe plans
|
||||||
stripe.plans.list({}, function(err, plans) {
|
stripe.plans.list({}, function(err, stripePlans) {
|
||||||
if (err) {
|
if (err) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const requiredPlans = Object.keys(subscriptionPlans).map(
|
const requiredPlans = subscriptionPlans.map(plan => plan.id);
|
||||||
key => subscriptionPlans[key].id
|
const availablePlans = stripePlans.data.map(plan => plan.id);
|
||||||
|
requiredPlans.forEach(requiredPlan => {
|
||||||
|
if (!availablePlans.includes(requiredPlan)) {
|
||||||
|
createStripePlan(
|
||||||
|
subscriptionPlans.find(plan => plan.id === requiredPlan)
|
||||||
);
|
);
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -53,12 +92,12 @@ export default function donateBoot(app, done) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createStripePlan(plan) {
|
function createStripePlan(plan) {
|
||||||
|
log(`Creating subscription plan: ${plan.product.name}`);
|
||||||
stripe.plans.create(plan, function(err) {
|
stripe.plans.create(plan, function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.log(err);
|
log(err);
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
console.log(`${plan.id} created`);
|
log(`Created plan with plan id: ${plan.id}`);
|
||||||
return;
|
return;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -66,15 +105,24 @@ export default function donateBoot(app, done) {
|
|||||||
function createStripeDonation(req, res) {
|
function createStripeDonation(req, res) {
|
||||||
const { user, body } = req;
|
const { user, body } = req;
|
||||||
|
|
||||||
if (!body || !body.amount) {
|
if (!body || !body.amount || !body.duration) {
|
||||||
return res.status(400).send({ error: 'Amount Required' });
|
return res.status(400).send({ error: 'Amount and duration Required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
amount,
|
amount,
|
||||||
|
duration,
|
||||||
token: { email, id }
|
token: { email, id }
|
||||||
} = body;
|
} = 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
|
const fccUser = user
|
||||||
? Promise.resolve(user)
|
? Promise.resolve(user)
|
||||||
: new Promise((resolve, reject) =>
|
: new Promise((resolve, reject) =>
|
||||||
@ -95,46 +143,85 @@ export default function donateBoot(app, done) {
|
|||||||
let donation = {
|
let donation = {
|
||||||
email,
|
email,
|
||||||
amount,
|
amount,
|
||||||
|
duration,
|
||||||
provider: 'stripe',
|
provider: 'stripe',
|
||||||
startDate: new Date(Date.now()).toISOString()
|
startDate: new Date(Date.now()).toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
return fccUser
|
const createCustomer = user => {
|
||||||
.then(user => {
|
|
||||||
donatingUser = user;
|
donatingUser = user;
|
||||||
return stripe.customers.create({
|
return stripe.customers.create({
|
||||||
email,
|
email,
|
||||||
card: id
|
card: id
|
||||||
});
|
});
|
||||||
})
|
};
|
||||||
.then(customer => {
|
|
||||||
|
const createSubscription = customer => {
|
||||||
donation.customerId = customer.id;
|
donation.customerId = customer.id;
|
||||||
return stripe.subscriptions.create({
|
return stripe.subscriptions.create({
|
||||||
customer: customer.id,
|
customer: customer.id,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
plan: `monthly-donation-${amount}`
|
plan: `${donationSubscriptionConfig.duration[
|
||||||
|
duration
|
||||||
|
].toLowerCase()}-donation-${amount}`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
})
|
};
|
||||||
.then(subscription => {
|
|
||||||
donation.subscriptionId = subscription.id;
|
const createOneTimeCharge = customer => {
|
||||||
return res.send(subscription);
|
donation.customerId = customer.id;
|
||||||
})
|
return stripe.charges.create({
|
||||||
.then(() => {
|
amount: amount,
|
||||||
|
currency: 'usd',
|
||||||
|
customer: customer.id
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAsyncUserDonation = () => {
|
||||||
donatingUser
|
donatingUser
|
||||||
.createDonation(donation)
|
.createDonation(donation)
|
||||||
.toPromise()
|
.toPromise()
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
throw new Error(err);
|
throw new Error(err);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return fccUser
|
||||||
|
.then(user => {
|
||||||
|
const { isDonating } = user;
|
||||||
|
if (isDonating) {
|
||||||
|
throw {
|
||||||
|
message: `User already has active donation(s).`,
|
||||||
|
type: 'AlreadyDonatingError'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return user;
|
||||||
})
|
})
|
||||||
|
.then(createCustomer)
|
||||||
|
.then(customer => {
|
||||||
|
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 => {
|
.catch(err => {
|
||||||
if (err.type === 'StripeCardError') {
|
if (
|
||||||
|
err.type === 'StripeCardError' ||
|
||||||
|
err.type === 'AlreadyDonatingError'
|
||||||
|
) {
|
||||||
return res.status(402).send({ error: err.message });
|
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.' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,6 +28,9 @@
|
|||||||
"required": true,
|
"required": true,
|
||||||
"description": "The donation amount in cents"
|
"description": "The donation amount in cents"
|
||||||
},
|
},
|
||||||
|
"duration": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"startDate": {
|
"startDate": {
|
||||||
"type": "DateString",
|
"type": "DateString",
|
||||||
"required": true
|
"required": true
|
||||||
|
@ -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 {
|
.donation-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -63,21 +45,6 @@
|
|||||||
white-space: normal;
|
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 {
|
.donate-input-element {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
@ -97,6 +64,92 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donate-other .btn-cta {
|
.donate-tabs > .nav-pills {
|
||||||
margin: 7px 0px;
|
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;
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ function DonateCompletion({ processing, reset, success, error = null }) {
|
|||||||
const heading = processing
|
const heading = processing
|
||||||
? 'We are processing your donation.'
|
? 'We are processing your donation.'
|
||||||
: success
|
: success
|
||||||
? 'Your donation was successful.'
|
? 'Thank you for being a supporter.'
|
||||||
: 'Something went wrong with your donation.';
|
: 'Something went wrong with your donation.';
|
||||||
return (
|
return (
|
||||||
<Alert bsStyle={style} className='donation-completion'>
|
<Alert bsStyle={style} className='donation-completion'>
|
||||||
@ -37,12 +37,12 @@ function DonateCompletion({ processing, reset, success, error = null }) {
|
|||||||
{success && (
|
{success && (
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
You can update your supporter status at any time from your account
|
Your donation will support free technology education for people
|
||||||
settings.
|
all over the world.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Thank you for supporting free technology education for people all
|
You can update your supporter status at any time from the 'manage
|
||||||
over the world.
|
your existing donation' section on this page.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -2,9 +2,19 @@ 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 { 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 { StripeProvider, Elements } from 'react-stripe-elements';
|
||||||
import { apiLocation } from '../../../../config/env.json';
|
import { apiLocation } from '../../../../config/env.json';
|
||||||
|
import Spacer from '../../helpers/Spacer';
|
||||||
import DonateFormChildViewForHOC from './DonateFormChildViewForHOC';
|
import DonateFormChildViewForHOC from './DonateFormChildViewForHOC';
|
||||||
import {
|
import {
|
||||||
userSelector,
|
userSelector,
|
||||||
@ -14,8 +24,13 @@ import {
|
|||||||
} from '../../../redux';
|
} from '../../../redux';
|
||||||
|
|
||||||
import '../Donation.css';
|
import '../Donation.css';
|
||||||
|
import DonateCompletion from './DonateCompletion.js';
|
||||||
|
|
||||||
|
const numToCommas = num =>
|
||||||
|
num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,');
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
|
isDonating: PropTypes.bool,
|
||||||
isSignedIn: PropTypes.bool,
|
isSignedIn: PropTypes.bool,
|
||||||
navigate: PropTypes.func.isRequired,
|
navigate: PropTypes.func.isRequired,
|
||||||
showLoading: PropTypes.bool.isRequired,
|
showLoading: PropTypes.bool.isRequired,
|
||||||
@ -28,11 +43,10 @@ const mapStateToProps = createSelector(
|
|||||||
userSelector,
|
userSelector,
|
||||||
signInLoadingSelector,
|
signInLoadingSelector,
|
||||||
isSignedInSelector,
|
isSignedInSelector,
|
||||||
({ email, theme }, showLoading, isSignedIn) => ({
|
({ isDonating }, showLoading, isSignedIn) => ({
|
||||||
email,
|
isDonating,
|
||||||
theme,
|
isSignedIn,
|
||||||
showLoading,
|
showLoading
|
||||||
isSignedIn
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -44,47 +58,222 @@ const createOnClick = navigate => e => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return navigate(`${apiLocation}/signin?returnTo=donate`);
|
return navigate(`${apiLocation}/signin?returnTo=donate`);
|
||||||
};
|
};
|
||||||
|
|
||||||
class DonateForm extends Component {
|
class DonateForm extends Component {
|
||||||
constructor(...args) {
|
constructor(...args) {
|
||||||
super(...args);
|
super(...args);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
donationAmount: 500
|
processing: false,
|
||||||
|
isDonating: this.props.isDonating,
|
||||||
|
donationAmount: 5000,
|
||||||
|
donationDuration: 'month',
|
||||||
|
paymentType: 'Card'
|
||||||
};
|
};
|
||||||
|
|
||||||
this.buttonSingleAmounts = [2500, 5000, 10000, 25000];
|
this.durations = {
|
||||||
this.buttonMonthlyAmounts = [500, 1000, 2000];
|
year: 'yearly',
|
||||||
this.buttonAnnualAmounts = [6000, 10000, 25000, 50000];
|
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.getActiveDonationAmount = this.getActiveDonationAmount.bind(this);
|
||||||
this.renderAmountButtons = this.renderAmountButtons.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) {
|
getActiveDonationAmount(durationSelected, amountSelected) {
|
||||||
return this.state.donationAmount === amount;
|
return this.amounts[durationSelected].includes(amountSelected)
|
||||||
|
? amountSelected
|
||||||
|
: this.amounts[durationSelected][0];
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAmountButtons() {
|
convertToTimeContributed(amount) {
|
||||||
return this.buttonAnnualAmounts.map(amount => (
|
return `${numToCommas((amount / 100) * 50 * 60)} minutes`;
|
||||||
<Button
|
}
|
||||||
className={`amount-value ${this.isActive(amount) ? 'active' : ''}`}
|
|
||||||
href=''
|
getFormatedAmountLabel(amount) {
|
||||||
id={amount}
|
return `$${numToCommas(amount / 100)}`;
|
||||||
key={'amount-' + amount}
|
}
|
||||||
onClick={this.handleAmountClick}
|
|
||||||
tabIndex='-1'
|
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}`}
|
{this.getFormatedAmountLabel(amount)}
|
||||||
</Button>
|
</ToggleButton>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
renderDurationAmountOptions() {
|
||||||
const { isSignedIn, navigate, showLoading, stripe } = this.props;
|
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 (
|
return (
|
||||||
<div>
|
<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 (
|
||||||
|
<Row>
|
||||||
|
<Col sm={10} smOffset={1} xs={12}>
|
||||||
|
<DonateCompletion success={true} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row>
|
||||||
|
<Col sm={10} smOffset={1} xs={12}>
|
||||||
|
{this.renderDurationAmountOptions()}
|
||||||
|
</Col>
|
||||||
|
<Col sm={10} smOffset={1} xs={12}>
|
||||||
|
{!showLoading && !isSignedIn ? (
|
||||||
<Button
|
<Button
|
||||||
bsStyle='default'
|
bsStyle='default'
|
||||||
className='btn btn-block'
|
className='btn btn-block'
|
||||||
@ -92,18 +281,23 @@ class DonateForm extends Component {
|
|||||||
>
|
>
|
||||||
Become a supporter
|
Become a supporter
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
) : (
|
||||||
);
|
this.renderDonationOptions()
|
||||||
}
|
)}
|
||||||
|
</Col>
|
||||||
return (
|
<Col sm={10} smOffset={1} xs={12}>
|
||||||
<div>
|
<Spacer size={2} />
|
||||||
<StripeProvider stripe={stripe}>
|
<h3 className='text-center'>Manage your existing donation</h3>
|
||||||
<Elements>
|
<Button block={true} bsStyle='primary' disabled={true}>
|
||||||
<DonateFormChildViewForHOC />
|
Update your existing donation
|
||||||
</Elements>
|
</Button>
|
||||||
</StripeProvider>
|
<Spacer />
|
||||||
</div>
|
<Button block={true} bsStyle='primary' disabled={true}>
|
||||||
|
Download donation receipts
|
||||||
|
</Button>
|
||||||
|
<Spacer />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,14 +12,17 @@ import {
|
|||||||
} from '@freecodecamp/react-bootstrap';
|
} from '@freecodecamp/react-bootstrap';
|
||||||
import { injectStripe } from 'react-stripe-elements';
|
import { injectStripe } from 'react-stripe-elements';
|
||||||
|
|
||||||
import Spacer from '../../helpers/Spacer';
|
|
||||||
import StripeCardForm from './StripeCardForm';
|
import StripeCardForm from './StripeCardForm';
|
||||||
import DonateCompletion from './DonateCompletion';
|
import DonateCompletion from './DonateCompletion';
|
||||||
import { postChargeStripe } from '../../../utils/ajax';
|
import { postChargeStripe } from '../../../utils/ajax';
|
||||||
import { userSelector, isSignedInSelector } from '../../../redux';
|
import { userSelector, isSignedInSelector } from '../../../redux';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
|
donationAmount: PropTypes.number.isRequired,
|
||||||
|
donationDuration: PropTypes.string.isRequired,
|
||||||
email: PropTypes.string,
|
email: PropTypes.string,
|
||||||
|
getDonationButtonLabel: PropTypes.func.isRequired,
|
||||||
|
hideAmountOptionsCB: PropTypes.func.isRequired,
|
||||||
isSignedIn: PropTypes.bool,
|
isSignedIn: PropTypes.bool,
|
||||||
stripe: PropTypes.shape({
|
stripe: PropTypes.shape({
|
||||||
createToken: PropTypes.func.isRequired
|
createToken: PropTypes.func.isRequired
|
||||||
@ -27,7 +30,6 @@ const propTypes = {
|
|||||||
theme: PropTypes.string
|
theme: PropTypes.string
|
||||||
};
|
};
|
||||||
const initialState = {
|
const initialState = {
|
||||||
donationAmount: 500,
|
|
||||||
donationState: {
|
donationState: {
|
||||||
processing: false,
|
processing: false,
|
||||||
success: false,
|
success: false,
|
||||||
@ -47,6 +49,8 @@ class DonateFormChildViewForHOC extends Component {
|
|||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
...initialState,
|
...initialState,
|
||||||
|
donationAmount: this.props.donationAmount,
|
||||||
|
donationDuration: this.props.donationDuration,
|
||||||
email: null,
|
email: null,
|
||||||
isFormValid: false
|
isFormValid: false
|
||||||
};
|
};
|
||||||
@ -82,6 +86,7 @@ class DonateFormChildViewForHOC extends Component {
|
|||||||
|
|
||||||
handleSubmit(e) {
|
handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
this.hideAmountOptions(true);
|
||||||
const email = this.getUserEmail();
|
const email = this.getUserEmail();
|
||||||
if (!email || !isEmail(email)) {
|
if (!email || !isEmail(email)) {
|
||||||
return this.setState(state => ({
|
return this.setState(state => ({
|
||||||
@ -110,8 +115,13 @@ class DonateFormChildViewForHOC extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hideAmountOptions(hide) {
|
||||||
|
const { hideAmountOptionsCB } = this.props;
|
||||||
|
hideAmountOptionsCB(hide);
|
||||||
|
}
|
||||||
|
|
||||||
postDonation(token) {
|
postDonation(token) {
|
||||||
const { donationAmount: amount } = this.state;
|
const { donationAmount: amount, donationDuration: duration } = this.state;
|
||||||
const { isSignedIn } = this.props;
|
const { isSignedIn } = this.props;
|
||||||
this.setState(state => ({
|
this.setState(state => ({
|
||||||
...state,
|
...state,
|
||||||
@ -123,7 +133,8 @@ class DonateFormChildViewForHOC extends Component {
|
|||||||
|
|
||||||
return postChargeStripe(isSignedIn, {
|
return postChargeStripe(isSignedIn, {
|
||||||
token,
|
token,
|
||||||
amount
|
amount,
|
||||||
|
duration
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(response => {
|
||||||
const data = response && response.data;
|
const data = response && response.data;
|
||||||
@ -167,7 +178,7 @@ class DonateFormChildViewForHOC extends Component {
|
|||||||
|
|
||||||
renderDonateForm() {
|
renderDonateForm() {
|
||||||
const { isFormValid } = this.state;
|
const { isFormValid } = this.state;
|
||||||
const { theme } = this.props;
|
const { theme, getDonationButtonLabel } = this.props;
|
||||||
return (
|
return (
|
||||||
<Form className='donation-form' onSubmit={this.handleSubmit}>
|
<Form className='donation-form' onSubmit={this.handleSubmit}>
|
||||||
<FormGroup className='donation-email-container'>
|
<FormGroup className='donation-email-container'>
|
||||||
@ -193,13 +204,16 @@ class DonateFormChildViewForHOC extends Component {
|
|||||||
id='confirm-donation-btn'
|
id='confirm-donation-btn'
|
||||||
type='submit'
|
type='submit'
|
||||||
>
|
>
|
||||||
Confirm your donation of $5 / month
|
{getDonationButtonLabel()}
|
||||||
</Button>
|
</Button>
|
||||||
<Spacer />
|
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps({ donationAmount, donationDuration }) {
|
||||||
|
this.setState({ donationAmount, donationDuration });
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
donationState: { processing, success, error }
|
donationState: { processing, success, error }
|
||||||
@ -212,6 +226,7 @@ class DonateFormChildViewForHOC extends Component {
|
|||||||
reset: this.resetDonation
|
reset: this.resetDonation
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this.hideAmountOptions(false);
|
||||||
return this.renderDonateForm();
|
return this.renderDonateForm();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Row, Col } from '@freecodecamp/react-bootstrap';
|
||||||
|
|
||||||
const DonateText = () => {
|
const DonateText = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<Row>
|
||||||
|
<Col sm={10} smOffset={1} xs={12}>
|
||||||
<p>freeCodeCamp is a highly efficient education nonprofit.</p>
|
<p>freeCodeCamp is a highly efficient education nonprofit.</p>
|
||||||
<p>
|
<p>
|
||||||
In 2019 alone, we provided 1,100,000,000 minutes of free education to
|
In 2019 alone, we provided 1,100,000,000 minutes of free education to
|
||||||
@ -18,10 +20,11 @@ const DonateText = () => {
|
|||||||
provide for their families.
|
provide for their families.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
You also help us create new resources for you to use to expand your own
|
You also help us create new resources for you to use to expand your
|
||||||
technology skills.
|
own technology skills.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</Col>
|
||||||
|
</Row>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -72,21 +72,19 @@ export class DonatePage extends Component {
|
|||||||
<Fragment>
|
<Fragment>
|
||||||
<Helmet title='Support our nonprofit | freeCodeCamp.org' />
|
<Helmet title='Support our nonprofit | freeCodeCamp.org' />
|
||||||
<Grid>
|
<Grid>
|
||||||
|
<Spacer />
|
||||||
<Row>
|
<Row>
|
||||||
<Col sm={10} smOffset={1} xs={12}>
|
<Col sm={10} smOffset={1} xs={12}>
|
||||||
<Spacer />
|
<h1 className='text-center'>Become a Supporter</h1>
|
||||||
<h2 className='text-center'>Become a Supporter</h2>
|
|
||||||
<Spacer />
|
<Spacer />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<Col sm={8} smOffset={2} xs={12}>
|
<Col md={6}>
|
||||||
<DonateText />
|
|
||||||
<Spacer />
|
|
||||||
</Col>
|
|
||||||
<Col sm={6} smOffset={3} xs={12}>
|
|
||||||
<DonateForm stripe={stripe} />
|
<DonateForm stripe={stripe} />
|
||||||
<Spacer />
|
</Col>
|
||||||
|
<Col md={6}>
|
||||||
|
<DonateText />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
Reference in New Issue
Block a user