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 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',
product: { month: 'Monthly'
name: },
'Monthly Donation to freeCodeCamp.org - ' + plans: {
`Thank you ($${current / 100})` year: [100000, 25000, 3500],
}, month: [5000, 3500, 500]
currency: 'usd', }
id: `monthly-donation-${current}` };
}
}), 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() { 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 => {
const availablePlans = plans.data.map(plan => plan.id); if (!availablePlans.includes(requiredPlan)) {
requiredPlans.forEach(planId => { createStripePlan(
if (!availablePlans.includes(planId)) { subscriptionPlans.find(plan => plan.id === requiredPlan)
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()
}; };
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 return fccUser
.then(user => { .then(user => {
donatingUser = user; const { isDonating } = user;
return stripe.customers.create({ if (isDonating) {
email, throw {
card: id message: `User already has active donation(s).`,
}); type: 'AlreadyDonatingError'
};
}
return user;
}) })
.then(createCustomer)
.then(customer => { .then(customer => {
donation.customerId = customer.id; return isOneTime
return stripe.subscriptions.create({ ? createOneTimeCharge(customer).then(charge => {
customer: customer.id, donation.subscriptionId = 'one-time-charge-prefix-' + charge.id;
items: [ return res.send(charge);
{ })
plan: `monthly-donation-${amount}` : createSubscription(customer).then(subscription => {
} donation.subscriptionId = subscription.id;
] return res.send(subscription);
}); });
})
.then(subscription => {
donation.subscriptionId = subscription.id;
return res.send(subscription);
})
.then(() => {
donatingUser
.createDonation(donation)
.toPromise()
.catch(err => {
throw new Error(err);
});
}) })
.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.' });
}); });
} }

View File

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

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 { .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;
} }

View File

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

View File

@ -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,66 +58,246 @@ 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 (
<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 ( return (
<div> <Row>
<Button <Col sm={10} smOffset={1} xs={12}>
bsStyle='default' <DonateCompletion success={true} />
className='btn btn-block' </Col>
onClick={createOnClick(navigate)} </Row>
>
Become a supporter
</Button>
</div>
); );
} }
return ( return (
<div> <Row>
<StripeProvider stripe={stripe}> <Col sm={10} smOffset={1} xs={12}>
<Elements> {this.renderDurationAmountOptions()}
<DonateFormChildViewForHOC /> </Col>
</Elements> <Col sm={10} smOffset={1} xs={12}>
</StripeProvider> {!showLoading && !isSignedIn ? (
</div> <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'; } 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();
} }
} }

View File

@ -1,27 +1,30 @@
import React from 'react'; import React from 'react';
import { Row, Col } from '@freecodecamp/react-bootstrap';
const DonateText = () => { const DonateText = () => {
return ( return (
<> <Row>
<p>freeCodeCamp is a highly efficient education nonprofit.</p> <Col sm={10} smOffset={1} xs={12}>
<p> <p>freeCodeCamp is a highly efficient education nonprofit.</p>
In 2019 alone, we provided 1,100,000,000 minutes of free education to <p>
people around the world. In 2019 alone, we provided 1,100,000,000 minutes of free education to
</p> people around the world.
<p> </p>
Since freeCodeCamp's total budget is only $373,000, that means every <p>
dollar you donate to freeCodeCamp translates into 50 hours worth of Since freeCodeCamp's total budget is only $373,000, that means every
technology education. dollar you donate to freeCodeCamp translates into 50 hours worth of
</p> technology education.
<p> </p>
When you donate to freeCodeCamp, you help people learn new skills and <p>
provide for their families. When you donate to freeCodeCamp, you help people learn new skills and
</p> provide for their families.
<p> </p>
You also help us create new resources for you to use to expand your own <p>
technology skills. You also help us create new resources for you to use to expand your
</p> own technology skills.
</> </p>
</Col>
</Row>
); );
}; };

View File

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