feat: add Stripe card form (#43433)
* eat: add Stripe card form * Apply suggestions from code review Co-authored-by: Shaun Hamilton <shauhami020@gmail.com> * feat: adjust payload and error handling * feat: readjust error handling * Apply suggestions from code review Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> Co-authored-by: Shaun Hamilton <shauhami020@gmail.com> * feat: refactors from comments * feat: prevent submition during processing * feat: redefine isSubmitting Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> * fix: show the proper paypal button on donate page * fix: handle errors from stripe Co-authored-by: Shaun Hamilton <shauhami020@gmail.com> Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
This commit is contained in:
@ -1,12 +1,14 @@
|
|||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
import { donationSubscriptionConfig } from '../../../../config/donation-settings';
|
import { donationSubscriptionConfig } from '../../../../config/donation-settings';
|
||||||
import keys from '../../../../config/secrets';
|
import keys from '../../../../config/secrets';
|
||||||
import {
|
import {
|
||||||
getAsyncPaypalToken,
|
getAsyncPaypalToken,
|
||||||
verifyWebHook,
|
verifyWebHook,
|
||||||
updateUser,
|
updateUser,
|
||||||
verifyWebHookType
|
verifyWebHookType,
|
||||||
|
createStripeCardDonation
|
||||||
} from '../utils/donation';
|
} from '../utils/donation';
|
||||||
import { validStripeForm } from '../utils/stripeHelpers';
|
import { validStripeForm } from '../utils/stripeHelpers';
|
||||||
|
|
||||||
@ -26,6 +28,18 @@ export default function donateBoot(app, done) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleStripeCardDonation(req, res) {
|
||||||
|
return createStripeCardDonation(req, res, stripe, app).catch(err => {
|
||||||
|
if (err.type === 'AlreadyDonatingError')
|
||||||
|
return res.status(402).send({ error: err });
|
||||||
|
if (err.type === 'InvalidRequest')
|
||||||
|
return res.status(400).send({ error: err });
|
||||||
|
return res.status(500).send({
|
||||||
|
error: 'Donation failed due to a server error.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createStripeDonation(req, res) {
|
function createStripeDonation(req, res) {
|
||||||
const { user, body } = req;
|
const { user, body } = req;
|
||||||
|
|
||||||
@ -184,7 +198,6 @@ export default function donateBoot(app, done) {
|
|||||||
const stripeSecretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard';
|
const stripeSecretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard';
|
||||||
const stripPublicInvalid =
|
const stripPublicInvalid =
|
||||||
!stripeKey || stripeKey === 'pk_from_stripe_dashboard';
|
!stripeKey || stripeKey === 'pk_from_stripe_dashboard';
|
||||||
|
|
||||||
const paypalSecretInvalid =
|
const paypalSecretInvalid =
|
||||||
!paypalKey || paypalKey === 'id_from_paypal_dashboard';
|
!paypalKey || paypalKey === 'id_from_paypal_dashboard';
|
||||||
const paypalPublicInvalid =
|
const paypalPublicInvalid =
|
||||||
@ -201,6 +214,7 @@ export default function donateBoot(app, done) {
|
|||||||
done();
|
done();
|
||||||
} else {
|
} else {
|
||||||
api.post('/charge-stripe', createStripeDonation);
|
api.post('/charge-stripe', createStripeDonation);
|
||||||
|
api.post('/charge-stripe-card', handleStripeCardDonation);
|
||||||
api.post('/add-donation', addDonation);
|
api.post('/add-donation', addDonation);
|
||||||
hooks.post('/update-paypal', updatePaypal);
|
hooks.post('/update-paypal', updatePaypal);
|
||||||
donateRouter.use('/donate', api);
|
donateRouter.use('/donate', api);
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
|
import { donationSubscriptionConfig } from '../../../../config/donation-settings';
|
||||||
import keys from '../../../../config/secrets';
|
import keys from '../../../../config/secrets';
|
||||||
|
|
||||||
const log = debug('fcc:boot:donate');
|
const log = debug('fcc:boot:donate');
|
||||||
@ -171,3 +172,79 @@ export async function updateUser(body, app) {
|
|||||||
type: 'UnsupportedWebhookType'
|
type: 'UnsupportedWebhookType'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createStripeCardDonation(req, res, stripe) {
|
||||||
|
const {
|
||||||
|
body: {
|
||||||
|
token: { id: tokenId },
|
||||||
|
amount,
|
||||||
|
duration
|
||||||
|
},
|
||||||
|
user: { name, id: userId, email },
|
||||||
|
user
|
||||||
|
} = req;
|
||||||
|
|
||||||
|
if (!tokenId || !amount || !duration || !name || !userId || !email) {
|
||||||
|
throw {
|
||||||
|
message: 'Request is not valid',
|
||||||
|
type: 'InvalidRequest'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isDonating && duration !== 'onetime') {
|
||||||
|
throw {
|
||||||
|
message: `User already has active recurring donation(s).`,
|
||||||
|
type: 'AlreadyDonatingError'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let customerId;
|
||||||
|
try {
|
||||||
|
const customer = await stripe.customers.create({
|
||||||
|
email,
|
||||||
|
card: tokenId,
|
||||||
|
name
|
||||||
|
});
|
||||||
|
customerId = customer?.id;
|
||||||
|
} catch {
|
||||||
|
throw {
|
||||||
|
type: 'customerCreationFailed',
|
||||||
|
message: 'Failed to create stripe customer'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
log(`Stripe customer with id ${customerId} created`);
|
||||||
|
|
||||||
|
let subscriptionId;
|
||||||
|
try {
|
||||||
|
const subscription = await stripe.subscriptions.create({
|
||||||
|
customer: customerId,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
plan: `${donationSubscriptionConfig.duration[
|
||||||
|
duration
|
||||||
|
].toLowerCase()}-donation-${amount}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
subscriptionId = subscription?.id;
|
||||||
|
} catch {
|
||||||
|
throw {
|
||||||
|
type: 'subscriptionCreationFailed',
|
||||||
|
message: 'Failed to create stripe subscription'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
log(`Stripe subscription with id ${subscriptionId} created`);
|
||||||
|
|
||||||
|
// save Donation
|
||||||
|
let donation = {
|
||||||
|
email,
|
||||||
|
amount,
|
||||||
|
duration,
|
||||||
|
provider: 'stripe',
|
||||||
|
subscriptionId,
|
||||||
|
customerId,
|
||||||
|
startDate: new Date().toISOString()
|
||||||
|
};
|
||||||
|
await createAsyncUserDonation(user, donation);
|
||||||
|
return res.status(200).json({ isDonating: true });
|
||||||
|
}
|
||||||
|
@ -319,6 +319,7 @@
|
|||||||
"nicely-done": "Nicely done. You just completed {{block}}.",
|
"nicely-done": "Nicely done. You just completed {{block}}.",
|
||||||
"credit-card": "Credit Card",
|
"credit-card": "Credit Card",
|
||||||
"credit-card-2": "Or donate with a credit card:",
|
"credit-card-2": "Or donate with a credit card:",
|
||||||
|
"or-card": "Or donate with card",
|
||||||
"paypal": "with PayPal:",
|
"paypal": "with PayPal:",
|
||||||
"need-email": "We need a valid email address to which we can send your donation tax receipt.",
|
"need-email": "We need a valid email address to which we can send your donation tax receipt.",
|
||||||
"went-wrong": "Something went wrong processing your donation. Your card has not been charged.",
|
"went-wrong": "Something went wrong processing your donation. Your card has not been charged.",
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
/* eslint-disable @typescript-eslint/unbound-method */
|
/* eslint-disable @typescript-eslint/unbound-method */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
||||||
/* eslint-disable no-nested-ternary */
|
|
||||||
|
|
||||||
import type { Token } from '@stripe/stripe-js';
|
import type { Token } from '@stripe/stripe-js';
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { withTranslation } from 'react-i18next';
|
import { withTranslation } from 'react-i18next';
|
||||||
@ -25,14 +22,14 @@ import {
|
|||||||
updateDonationFormState,
|
updateDonationFormState,
|
||||||
defaultDonationFormState,
|
defaultDonationFormState,
|
||||||
userSelector,
|
userSelector,
|
||||||
postChargeStripe
|
postChargeStripe,
|
||||||
|
postChargeStripeCard
|
||||||
} from '../../redux';
|
} from '../../redux';
|
||||||
import Spacer from '../helpers/spacer';
|
import Spacer from '../helpers/spacer';
|
||||||
|
|
||||||
import DonateCompletion from './DonateCompletion';
|
import DonateCompletion from './DonateCompletion';
|
||||||
|
|
||||||
import type { AddDonationData } from './PaypalButton';
|
import type { AddDonationData } from './PaypalButton';
|
||||||
import PaypalButton from './PaypalButton';
|
import PaypalButton from './PaypalButton';
|
||||||
|
import StripeCardForm from './stripe-card-form';
|
||||||
import WalletsWrapper from './walletsButton';
|
import WalletsWrapper from './walletsButton';
|
||||||
|
|
||||||
import './Donation.css';
|
import './Donation.css';
|
||||||
@ -51,7 +48,7 @@ type DonateFormState = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type DonateFromComponentState = {
|
type DonateFormComponentState = {
|
||||||
donationAmount: number;
|
donationAmount: number;
|
||||||
donationDuration: string;
|
donationDuration: string;
|
||||||
};
|
};
|
||||||
@ -59,6 +56,11 @@ type DonateFromComponentState = {
|
|||||||
type DonateFormProps = {
|
type DonateFormProps = {
|
||||||
addDonation: (data: unknown) => unknown;
|
addDonation: (data: unknown) => unknown;
|
||||||
postChargeStripe: (data: unknown) => unknown;
|
postChargeStripe: (data: unknown) => unknown;
|
||||||
|
postChargeStripeCard: (data: {
|
||||||
|
token: Token;
|
||||||
|
amount: number;
|
||||||
|
duration: string;
|
||||||
|
}) => void;
|
||||||
defaultTheme?: string;
|
defaultTheme?: string;
|
||||||
email: string;
|
email: string;
|
||||||
handleProcessing: (duration: string, amount: number, action: string) => void;
|
handleProcessing: (duration: string, amount: number, action: string) => void;
|
||||||
@ -96,10 +98,11 @@ const mapStateToProps = createSelector(
|
|||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
addDonation,
|
addDonation,
|
||||||
updateDonationFormState,
|
updateDonationFormState,
|
||||||
postChargeStripe
|
postChargeStripe,
|
||||||
|
postChargeStripeCard
|
||||||
};
|
};
|
||||||
|
|
||||||
class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||||
static displayName = 'DonateForm';
|
static displayName = 'DonateForm';
|
||||||
durations: { month: 'monthly'; onetime: 'one-time' };
|
durations: { month: 'monthly'; onetime: 'one-time' };
|
||||||
amounts: { month: number[]; onetime: number[] };
|
amounts: { month: number[]; onetime: number[] };
|
||||||
@ -125,6 +128,7 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
|||||||
this.handleSelectDuration = this.handleSelectDuration.bind(this);
|
this.handleSelectDuration = this.handleSelectDuration.bind(this);
|
||||||
this.resetDonation = this.resetDonation.bind(this);
|
this.resetDonation = this.resetDonation.bind(this);
|
||||||
this.postStripeDonation = this.postStripeDonation.bind(this);
|
this.postStripeDonation = this.postStripeDonation.bind(this);
|
||||||
|
this.postStripeCardDonation = this.postStripeCardDonation.bind(this);
|
||||||
this.handlePaymentButtonLoad = this.handlePaymentButtonLoad.bind(this);
|
this.handlePaymentButtonLoad = this.handlePaymentButtonLoad.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,6 +221,20 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
postStripeCardDonation(token: Token) {
|
||||||
|
const { donationAmount: amount, donationDuration: duration } = this.state;
|
||||||
|
this.props.handleProcessing(
|
||||||
|
duration,
|
||||||
|
amount,
|
||||||
|
'Stripe card payment submission'
|
||||||
|
);
|
||||||
|
this.props.postChargeStripeCard({
|
||||||
|
token,
|
||||||
|
amount,
|
||||||
|
duration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handleSelectAmount(donationAmount: number) {
|
handleSelectAmount(donationAmount: number) {
|
||||||
this.setState({ donationAmount });
|
this.setState({ donationAmount });
|
||||||
}
|
}
|
||||||
@ -227,15 +245,15 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
|||||||
const usd = this.getFormattedAmountLabel(donationAmount);
|
const usd = this.getFormattedAmountLabel(donationAmount);
|
||||||
const hours = this.convertToTimeContributed(donationAmount);
|
const hours = this.convertToTimeContributed(donationAmount);
|
||||||
|
|
||||||
return (
|
let donationDescription = t('donate.your-donation-3', { usd, hours });
|
||||||
<p className='donation-description'>
|
|
||||||
{donationDuration === 'onetime'
|
if (donationDuration === 'onetime') {
|
||||||
? t('donate.your-donation', { usd: usd, hours: hours })
|
donationDescription = t('donate.your-donation', { usd, hours });
|
||||||
: donationDuration === 'month'
|
} else if (donationDuration === 'month') {
|
||||||
? t('donate.your-donation-2', { usd: usd, hours: hours })
|
donationDescription = t('donate.your-donation-2', { usd, hours });
|
||||||
: t('donate.your-donation-3', { usd: usd, hours: hours })}
|
}
|
||||||
</p>
|
|
||||||
);
|
return <p className='donation-description'>{donationDescription}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetDonation() {
|
resetDonation() {
|
||||||
@ -267,7 +285,7 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
|||||||
renderButtonGroup() {
|
renderButtonGroup() {
|
||||||
const { donationAmount, donationDuration } = this.state;
|
const { donationAmount, donationDuration } = this.state;
|
||||||
const {
|
const {
|
||||||
donationFormState: { loading },
|
donationFormState: { loading, processing },
|
||||||
handleProcessing,
|
handleProcessing,
|
||||||
addDonation,
|
addDonation,
|
||||||
defaultTheme,
|
defaultTheme,
|
||||||
@ -276,7 +294,6 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
|||||||
isMinimalForm,
|
isMinimalForm,
|
||||||
isSignedIn
|
isSignedIn
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const paymentButtonsLoading = loading.stripe && loading.paypal;
|
|
||||||
const priorityTheme = defaultTheme ? defaultTheme : theme;
|
const priorityTheme = defaultTheme ? defaultTheme : theme;
|
||||||
const isOneTime = donationDuration === 'onetime';
|
const isOneTime = donationDuration === 'onetime';
|
||||||
const walletlabel = `${t(
|
const walletlabel = `${t(
|
||||||
@ -290,8 +307,8 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
|||||||
{this.getDonationButtonLabel()}:
|
{this.getDonationButtonLabel()}:
|
||||||
</b>
|
</b>
|
||||||
<Spacer />
|
<Spacer />
|
||||||
{paymentButtonsLoading && this.paymentButtonsLoader()}
|
|
||||||
<div className={'donate-btn-group'}>
|
<div className={'donate-btn-group'}>
|
||||||
|
{loading.stripe && loading.paypal && this.paymentButtonsLoader()}
|
||||||
<WalletsWrapper
|
<WalletsWrapper
|
||||||
amount={donationAmount}
|
amount={donationAmount}
|
||||||
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
|
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
|
||||||
@ -307,11 +324,24 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
|||||||
donationDuration={donationDuration}
|
donationDuration={donationDuration}
|
||||||
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
|
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
|
||||||
handleProcessing={handleProcessing}
|
handleProcessing={handleProcessing}
|
||||||
|
isMinimalForm={isMinimalForm}
|
||||||
isPaypalLoading={loading.paypal}
|
isPaypalLoading={loading.paypal}
|
||||||
isSignedIn={isSignedIn}
|
isSignedIn={isSignedIn}
|
||||||
onDonationStateChange={this.onDonationStateChange}
|
onDonationStateChange={this.onDonationStateChange}
|
||||||
theme={defaultTheme ? defaultTheme : theme}
|
theme={defaultTheme ? defaultTheme : theme}
|
||||||
/>
|
/>
|
||||||
|
{isMinimalForm && (
|
||||||
|
<>
|
||||||
|
<div className='separator'>{t('donate.or-card')}</div>
|
||||||
|
<StripeCardForm
|
||||||
|
onDonationStateChange={this.onDonationStateChange}
|
||||||
|
postStripeCardDonation={this.postStripeCardDonation}
|
||||||
|
processing={processing}
|
||||||
|
t={t}
|
||||||
|
theme={defaultTheme ? defaultTheme : theme}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -15,6 +15,45 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: solid 1px var(--gray-45);
|
||||||
|
background: var(--gray-0);
|
||||||
|
}
|
||||||
|
.donation-elements div:nth-child(1) {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 5px 5px 0px 0px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donation-elements div:nth-child(2) {
|
||||||
|
flex: 1;
|
||||||
|
border-color: var(--gray-45);
|
||||||
|
border-radius: 0px 0px 5px 5px;
|
||||||
|
border-right: none;
|
||||||
|
border-left: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.donation-elements.failed-submition {
|
||||||
|
border: solid 3px #eb1c26;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 380px) {
|
||||||
|
.donation-elements {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.donation-elements div:nth-child(1) {
|
||||||
|
flex: 4 4 80%;
|
||||||
|
border-radius: 5px 0px 0px 5px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.donation-elements div:nth-child(2) {
|
||||||
|
flex: 1 1 100px;
|
||||||
|
border-radius: 0px 5px 5px 0px;
|
||||||
|
border: 1px solid var(--gray-45);
|
||||||
|
border-right: none;
|
||||||
|
border-top: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.donation-completion,
|
.donation-completion,
|
||||||
@ -52,7 +91,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.paypal-buttons-container {
|
.paypal-buttons-container {
|
||||||
min-height: 142px;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donate-input-element {
|
.donate-input-element {
|
||||||
@ -366,7 +405,7 @@ li.disabled > a {
|
|||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
button#confirm-donation-btn {
|
button.confirm-donation-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -376,10 +415,15 @@ button#confirm-donation-btn {
|
|||||||
border-color: var(--yellow-light);
|
border-color: var(--yellow-light);
|
||||||
color: black;
|
color: black;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
border: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
button#confirm-donation-btn:active,
|
button.confirm-donation-btn:active,
|
||||||
button#confirm-donation-btn:active:focus,
|
button.confirm-donation-btn:active:focus,
|
||||||
button#confirm-donation-btn:hover {
|
button.confirm-donation-btn:hover {
|
||||||
color: black;
|
color: black;
|
||||||
background-color: #f2ba38;
|
background-color: #f2ba38;
|
||||||
border-color: #f2ba38;
|
border-color: #f2ba38;
|
||||||
@ -394,6 +438,8 @@ button#confirm-donation-btn:hover {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
max-width: 350px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donate-btn-group > * {
|
.donate-btn-group > * {
|
||||||
@ -404,13 +450,45 @@ button#confirm-donation-btn:hover {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 500px) {
|
.form-status {
|
||||||
.donate-btn-group > * {
|
min-height: 35px;
|
||||||
width: 49%;
|
padding: 5px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-status p {
|
||||||
|
font-family: 'Lato', sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #eb1c26;
|
||||||
|
text-align: left;
|
||||||
|
margin: 0px;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donate-page-wrapper .alert.alert-info a:hover {
|
.donate-page-wrapper .alert.alert-info a:hover {
|
||||||
color: #327290;
|
color: #327290;
|
||||||
background-color: #acdef3;
|
background-color: #acdef3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin: 20px 0;
|
||||||
|
color: var(--quaternary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator::before,
|
||||||
|
.separator::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
border-bottom: 1px solid var(--quaternary-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator:not(:empty)::before {
|
||||||
|
margin-right: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator:not(:empty)::after {
|
||||||
|
margin-left: 0.25em;
|
||||||
|
}
|
||||||
|
@ -155,7 +155,7 @@ function DonateModal({
|
|||||||
{recentlyClaimedBlock ? blockDonationText : progressDonationText}
|
{recentlyClaimedBlock ? blockDonationText : progressDonationText}
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<Row>
|
<Row>
|
||||||
<Col lg={8} lgOffset={2} sm={10} smOffset={1} xs={12}>
|
<Col xs={12}>
|
||||||
<DonateForm
|
<DonateForm
|
||||||
handleProcessing={handleProcessing}
|
handleProcessing={handleProcessing}
|
||||||
isMinimalForm={true}
|
isMinimalForm={true}
|
||||||
|
@ -7,6 +7,7 @@ import { scriptLoader, scriptRemover } from '../../utils/script-loaders';
|
|||||||
import type { AddDonationData } from './PaypalButton';
|
import type { AddDonationData } from './PaypalButton';
|
||||||
|
|
||||||
type PayPalButtonScriptLoaderProps = {
|
type PayPalButtonScriptLoaderProps = {
|
||||||
|
isMinimalForm: boolean | undefined;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
createOrder: (
|
createOrder: (
|
||||||
data: unknown,
|
data: unknown,
|
||||||
@ -68,7 +69,10 @@ export class PayPalButtonScriptLoader extends Component<
|
|||||||
PayPalButtonScriptLoaderState
|
PayPalButtonScriptLoaderState
|
||||||
> {
|
> {
|
||||||
// Lint says that paypal does not exist on window
|
// Lint says that paypal does not exist on window
|
||||||
state = { isSdkLoaded: window.paypal ? true : false, isSubscription: true };
|
state = {
|
||||||
|
isSdkLoaded: window.paypal ? true : false,
|
||||||
|
isSubscription: true
|
||||||
|
};
|
||||||
|
|
||||||
static displayName = 'PayPalButtonScriptLoader';
|
static displayName = 'PayPalButtonScriptLoader';
|
||||||
|
|
||||||
@ -84,11 +88,11 @@ export class PayPalButtonScriptLoader extends Component<
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount(): void {
|
componentDidMount(): void {
|
||||||
if (!window.paypal) {
|
this.loadScript(this.props.isSubscription, true);
|
||||||
this.loadScript(this.props.isSubscription, false);
|
}
|
||||||
} else if (this.props.isPaypalLoading) {
|
|
||||||
this.props.onLoad();
|
componentWillUnmount(): void {
|
||||||
}
|
scriptRemover('paypal-sdk');
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: {
|
componentDidUpdate(prevProps: {
|
||||||
@ -98,12 +102,15 @@ export class PayPalButtonScriptLoader extends Component<
|
|||||||
height: number;
|
height: number;
|
||||||
tagline: boolean;
|
tagline: boolean;
|
||||||
};
|
};
|
||||||
|
isMinimalForm: boolean | undefined;
|
||||||
}): void {
|
}): void {
|
||||||
|
// We need to load a new script if any of the following changes.
|
||||||
if (
|
if (
|
||||||
prevProps.isSubscription !== this.state.isSubscription ||
|
prevProps.isSubscription !== this.state.isSubscription ||
|
||||||
prevProps.style.color !== this.props.style.color ||
|
prevProps.style.color !== this.props.style.color ||
|
||||||
prevProps.style.tagline !== this.props.style.tagline ||
|
prevProps.style.tagline !== this.props.style.tagline ||
|
||||||
prevProps.style.height !== this.props.style.height
|
prevProps.style.height !== this.props.style.height ||
|
||||||
|
prevProps.isMinimalForm !== this.props.isMinimalForm
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line react/no-did-update-set-state
|
// eslint-disable-next-line react/no-did-update-set-state
|
||||||
this.setState({ isSdkLoaded: false });
|
this.setState({ isSdkLoaded: false });
|
||||||
@ -113,7 +120,8 @@ export class PayPalButtonScriptLoader extends Component<
|
|||||||
|
|
||||||
loadScript(subscription: boolean, deleteScript: boolean | undefined): void {
|
loadScript(subscription: boolean, deleteScript: boolean | undefined): void {
|
||||||
if (deleteScript) scriptRemover('paypal-sdk');
|
if (deleteScript) scriptRemover('paypal-sdk');
|
||||||
let queries = `?client-id=${this.props.clientId}&disable-funding=credit,bancontact,blik,eps,giropay,ideal,mybank,p24,sepa,sofort,venmo`;
|
const allowCardPayment = this.props.isMinimalForm ? 'card,' : '';
|
||||||
|
let queries = `?client-id=${this.props.clientId}&disable-funding=${allowCardPayment}credit,bancontact,blik,eps,giropay,ideal,mybank,p24,sepa,sofort,venmo`;
|
||||||
if (subscription) queries += '&vault=true&intent=subscription';
|
if (subscription) queries += '&vault=true&intent=subscription';
|
||||||
|
|
||||||
scriptLoader(
|
scriptLoader(
|
||||||
|
@ -44,6 +44,7 @@ type PaypalButtonProps = {
|
|||||||
theme: string;
|
theme: string;
|
||||||
isSubscription?: boolean;
|
isSubscription?: boolean;
|
||||||
handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void;
|
handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void;
|
||||||
|
isMinimalForm: boolean | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PaypalButtonState = {
|
type PaypalButtonState = {
|
||||||
@ -128,7 +129,7 @@ export class PaypalButton extends Component<
|
|||||||
|
|
||||||
render(): JSX.Element | null {
|
render(): JSX.Element | null {
|
||||||
const { duration, planId, amount } = this.state;
|
const { duration, planId, amount } = this.state;
|
||||||
const { t, theme, isPaypalLoading } = this.props;
|
const { t, theme, isPaypalLoading, isMinimalForm } = this.props;
|
||||||
const isSubscription = duration !== 'onetime';
|
const isSubscription = duration !== 'onetime';
|
||||||
const buttonColor = theme === 'night' ? 'white' : 'gold';
|
const buttonColor = theme === 'night' ? 'white' : 'gold';
|
||||||
if (!paypalClientId) {
|
if (!paypalClientId) {
|
||||||
@ -175,6 +176,7 @@ export class PaypalButton extends Component<
|
|||||||
plan_id: planId
|
plan_id: planId
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
isMinimalForm={isMinimalForm}
|
||||||
isPaypalLoading={isPaypalLoading}
|
isPaypalLoading={isPaypalLoading}
|
||||||
isSubscription={isSubscription}
|
isSubscription={isSubscription}
|
||||||
onApprove={(data: AddDonationData) => {
|
onApprove={(data: AddDonationData) => {
|
||||||
|
@ -12,7 +12,8 @@ const commonProps = {
|
|||||||
isPaypalLoading: true,
|
isPaypalLoading: true,
|
||||||
t: jest.fn(),
|
t: jest.fn(),
|
||||||
theme: 'night',
|
theme: 'night',
|
||||||
handlePaymentButtonLoad: jest.fn()
|
handlePaymentButtonLoad: jest.fn(),
|
||||||
|
isMinimalForm: true
|
||||||
};
|
};
|
||||||
|
|
||||||
const donationData = {
|
const donationData = {
|
||||||
|
160
client/src/components/Donation/stripe-card-form.tsx
Normal file
160
client/src/components/Donation/stripe-card-form.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
/* eslint-disable no-undefined */
|
||||||
|
import { Button, Form } from '@freecodecamp/react-bootstrap';
|
||||||
|
import {
|
||||||
|
CardNumberElement,
|
||||||
|
CardExpiryElement,
|
||||||
|
useStripe,
|
||||||
|
useElements,
|
||||||
|
Elements
|
||||||
|
} from '@stripe/react-stripe-js';
|
||||||
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
|
import type {
|
||||||
|
Token,
|
||||||
|
StripeCardNumberElementChangeEvent,
|
||||||
|
StripeCardExpiryElementChangeEvent
|
||||||
|
} from '@stripe/stripe-js';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import envData from '../../../../config/env.json';
|
||||||
|
import { AddDonationData } from './PaypalButton';
|
||||||
|
|
||||||
|
const { stripePublicKey }: { stripePublicKey: string | null } = envData;
|
||||||
|
interface FormPropTypes {
|
||||||
|
onDonationStateChange: (donationState: AddDonationData) => void;
|
||||||
|
postStripeCardDonation: (token: Token) => void;
|
||||||
|
t: (label: string) => string;
|
||||||
|
theme: string;
|
||||||
|
processing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Element {
|
||||||
|
elementType: 'cardNumber' | 'cardExpiry';
|
||||||
|
complete: boolean;
|
||||||
|
error?: null | { type: 'validation_error'; code: string; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaymentInfoValidation = Element[];
|
||||||
|
|
||||||
|
const StripeCardForm = ({
|
||||||
|
theme,
|
||||||
|
t,
|
||||||
|
onDonationStateChange,
|
||||||
|
postStripeCardDonation,
|
||||||
|
processing
|
||||||
|
}: FormPropTypes): JSX.Element => {
|
||||||
|
const [isSubmissionValid, setSubmissionValidity] = useState(true);
|
||||||
|
const [isTokenizing, setTokenizing] = useState(false);
|
||||||
|
const [paymentInfoValidation, setPaymentValidity] =
|
||||||
|
useState<PaymentInfoValidation>([
|
||||||
|
{
|
||||||
|
elementType: 'cardNumber',
|
||||||
|
complete: false,
|
||||||
|
error: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
elementType: 'cardExpiry',
|
||||||
|
complete: false,
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const isPaymentInfoValid = paymentInfoValidation.every(
|
||||||
|
({ complete, error }) => complete && !error
|
||||||
|
);
|
||||||
|
const isSubmitting = isTokenizing || processing;
|
||||||
|
const stripe = useStripe();
|
||||||
|
const elements = useElements();
|
||||||
|
|
||||||
|
function handleInputChange(
|
||||||
|
event:
|
||||||
|
| StripeCardNumberElementChangeEvent
|
||||||
|
| StripeCardExpiryElementChangeEvent
|
||||||
|
) {
|
||||||
|
const { elementType, error, complete } = event;
|
||||||
|
setPaymentValidity(
|
||||||
|
paymentInfoValidation.map(element => {
|
||||||
|
if (element.elementType === elementType)
|
||||||
|
return { elementType, error, complete };
|
||||||
|
return element;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
style: {
|
||||||
|
base: {
|
||||||
|
fontSize: '18px',
|
||||||
|
color: `${theme === 'night' ? '#fff' : '#0a0a23'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isPaymentInfoValid) return setSubmissionValidity(false);
|
||||||
|
else setSubmissionValidity(true);
|
||||||
|
|
||||||
|
if (!isSubmitting && stripe && elements) {
|
||||||
|
const cardElement = elements.getElement(CardNumberElement);
|
||||||
|
if (cardElement) {
|
||||||
|
setTokenizing(true);
|
||||||
|
const { error, token } = await stripe.createToken(cardElement);
|
||||||
|
if (error) {
|
||||||
|
onDonationStateChange({
|
||||||
|
redirecting: false,
|
||||||
|
processing: false,
|
||||||
|
success: false,
|
||||||
|
error: t('donate.went-wrong')
|
||||||
|
});
|
||||||
|
} else if (token) postStripeCardDonation(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return setTokenizing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form className='donation-form' onSubmit={handleSubmit}>
|
||||||
|
<div
|
||||||
|
className={`donation-elements${
|
||||||
|
!isSubmissionValid ? ' failed-submition' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CardNumberElement
|
||||||
|
className='form-control donate-input-element'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
<CardExpiryElement
|
||||||
|
className='form-control donate-input-element'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={'form-status'}>
|
||||||
|
{!isSubmissionValid && <p>{t('donate.valid-card')}</p>}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
block={true}
|
||||||
|
bsStyle='primary'
|
||||||
|
className='confirm-donation-btn'
|
||||||
|
disabled={!stripe || !elements || isSubmitting}
|
||||||
|
type='submit'
|
||||||
|
>
|
||||||
|
Donate
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CardFormWrapper = (props: FormPropTypes): JSX.Element | null => {
|
||||||
|
if (!stripePublicKey) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Elements stripe={loadStripe(stripePublicKey)}>
|
||||||
|
<StripeCardForm {...props} />
|
||||||
|
</Elements>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardFormWrapper;
|
@ -28,7 +28,7 @@ export const actionTypes = createTypes(
|
|||||||
...createAsyncTypes('acceptTerms'),
|
...createAsyncTypes('acceptTerms'),
|
||||||
...createAsyncTypes('showCert'),
|
...createAsyncTypes('showCert'),
|
||||||
...createAsyncTypes('reportUser'),
|
...createAsyncTypes('reportUser'),
|
||||||
...createAsyncTypes('postChargeStripe')
|
...createAsyncTypes('postChargeStripeCard')
|
||||||
],
|
],
|
||||||
ns
|
ns
|
||||||
);
|
);
|
||||||
|
@ -7,7 +7,11 @@ import {
|
|||||||
call,
|
call,
|
||||||
take
|
take
|
||||||
} from 'redux-saga/effects';
|
} from 'redux-saga/effects';
|
||||||
import { addDonation, postChargeStripe } from '../utils/ajax';
|
import {
|
||||||
|
addDonation,
|
||||||
|
postChargeStripe,
|
||||||
|
postChargeStripeCard
|
||||||
|
} from '../utils/ajax';
|
||||||
import { actionTypes as appTypes } from './action-types';
|
import { actionTypes as appTypes } from './action-types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -19,10 +23,12 @@ import {
|
|||||||
addDonationComplete,
|
addDonationComplete,
|
||||||
addDonationError,
|
addDonationError,
|
||||||
postChargeStripeComplete,
|
postChargeStripeComplete,
|
||||||
postChargeStripeError
|
postChargeStripeError,
|
||||||
|
postChargeStripeCardComplete,
|
||||||
|
postChargeStripeCardError
|
||||||
} from './';
|
} from './';
|
||||||
|
|
||||||
const defaultDonationError = `Something is not right. Please contact donors@freecodecamp.org`;
|
const defaultDonationErrorMessage = `Something is not right. Please contact donors@freecodecamp.org`;
|
||||||
|
|
||||||
function* showDonateModalSaga() {
|
function* showDonateModalSaga() {
|
||||||
let shouldRequestDonation = yield select(shouldRequestDonationSelector);
|
let shouldRequestDonation = yield select(shouldRequestDonationSelector);
|
||||||
@ -48,7 +54,7 @@ function* addDonationSaga({ payload }) {
|
|||||||
error.response && error.response.data
|
error.response && error.response.data
|
||||||
? error.response.data
|
? error.response.data
|
||||||
: {
|
: {
|
||||||
message: defaultDonationError
|
message: defaultDonationErrorMessage
|
||||||
};
|
};
|
||||||
yield put(addDonationError(data.message));
|
yield put(addDonationError(data.message));
|
||||||
}
|
}
|
||||||
@ -62,15 +68,27 @@ function* postChargeStripeSaga({ payload }) {
|
|||||||
const err =
|
const err =
|
||||||
error.response && error.response.data
|
error.response && error.response.data
|
||||||
? error.response.data.error
|
? error.response.data.error
|
||||||
: defaultDonationError;
|
: defaultDonationErrorMessage;
|
||||||
yield put(postChargeStripeError(err));
|
yield put(postChargeStripeError(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function* postChargeStripeCardSaga({ payload }) {
|
||||||
|
try {
|
||||||
|
const { error } = yield call(postChargeStripeCard, payload);
|
||||||
|
if (error) throw error;
|
||||||
|
yield put(postChargeStripeCardComplete());
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error.message || defaultDonationErrorMessage;
|
||||||
|
yield put(postChargeStripeCardError(errorMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createDonationSaga(types) {
|
export function createDonationSaga(types) {
|
||||||
return [
|
return [
|
||||||
takeEvery(types.tryToShowDonationModal, showDonateModalSaga),
|
takeEvery(types.tryToShowDonationModal, showDonateModalSaga),
|
||||||
takeEvery(types.addDonation, addDonationSaga),
|
takeEvery(types.addDonation, addDonationSaga),
|
||||||
takeLeading(types.postChargeStripe, postChargeStripeSaga)
|
takeLeading(types.postChargeStripe, postChargeStripeSaga),
|
||||||
|
takeLeading(types.postChargeStripeCard, postChargeStripeCardSaga)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -136,6 +136,15 @@ export const postChargeStripeComplete = createAction(
|
|||||||
export const postChargeStripeError = createAction(
|
export const postChargeStripeError = createAction(
|
||||||
actionTypes.postChargeStripeError
|
actionTypes.postChargeStripeError
|
||||||
);
|
);
|
||||||
|
export const postChargeStripeCard = createAction(
|
||||||
|
actionTypes.postChargeStripeCard
|
||||||
|
);
|
||||||
|
export const postChargeStripeCardComplete = createAction(
|
||||||
|
actionTypes.postChargeStripeCardComplete
|
||||||
|
);
|
||||||
|
export const postChargeStripeCardError = createAction(
|
||||||
|
actionTypes.postChargeStripeCardError
|
||||||
|
);
|
||||||
|
|
||||||
export const fetchProfileForUser = createAction(
|
export const fetchProfileForUser = createAction(
|
||||||
actionTypes.fetchProfileForUser
|
actionTypes.fetchProfileForUser
|
||||||
@ -450,6 +459,29 @@ export const reducer = handleActions(
|
|||||||
...state,
|
...state,
|
||||||
donationFormState: { ...defaultDonationFormState, error: payload }
|
donationFormState: { ...defaultDonationFormState, error: payload }
|
||||||
}),
|
}),
|
||||||
|
[actionTypes.postChargeStripeCard]: state => ({
|
||||||
|
...state,
|
||||||
|
donationFormState: { ...defaultDonationFormState, processing: true }
|
||||||
|
}),
|
||||||
|
[actionTypes.postChargeStripeCardComplete]: state => {
|
||||||
|
const { appUsername } = state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
|
[appUsername]: {
|
||||||
|
...state.user[appUsername],
|
||||||
|
isDonating: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
donationFormState: { ...defaultDonationFormState, success: true }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actionTypes.postChargeStripeCardError]: (state, { payload }) => ({
|
||||||
|
...state,
|
||||||
|
donationFormState: { ...defaultDonationFormState, error: payload }
|
||||||
|
}),
|
||||||
[actionTypes.fetchUser]: state => ({
|
[actionTypes.fetchUser]: state => ({
|
||||||
...state,
|
...state,
|
||||||
userFetchState: { ...defaultFetchState }
|
userFetchState: { ...defaultFetchState }
|
||||||
|
@ -186,6 +186,10 @@ export function addDonation(body: Donation): Promise<void> {
|
|||||||
export function postChargeStripe(body: Donation): Promise<void> {
|
export function postChargeStripe(body: Donation): Promise<void> {
|
||||||
return post('/donate/charge-stripe', body);
|
return post('/donate/charge-stripe', body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function postChargeStripeCard(body: Donation): Promise<void> {
|
||||||
|
return post('/donate/charge-stripe-card', body);
|
||||||
|
}
|
||||||
interface Report {
|
interface Report {
|
||||||
username: string;
|
username: string;
|
||||||
reportDescription: string;
|
reportDescription: string;
|
||||||
|
Reference in New Issue
Block a user