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:
Ahmad Abdolsaheb
2021-09-17 22:15:56 +03:00
committed by GitHub
parent 2dd106eb2f
commit e5523bf16e
14 changed files with 476 additions and 51 deletions

View File

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

View File

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

View File

@ -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.",

View File

@ -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>
</> </>
); );

View File

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

View File

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

View File

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

View File

@ -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) => {

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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