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 Stripe from 'stripe';
|
||||
|
||||
import { donationSubscriptionConfig } from '../../../../config/donation-settings';
|
||||
import keys from '../../../../config/secrets';
|
||||
import {
|
||||
getAsyncPaypalToken,
|
||||
verifyWebHook,
|
||||
updateUser,
|
||||
verifyWebHookType
|
||||
verifyWebHookType,
|
||||
createStripeCardDonation
|
||||
} from '../utils/donation';
|
||||
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) {
|
||||
const { user, body } = req;
|
||||
|
||||
@ -184,7 +198,6 @@ export default function donateBoot(app, done) {
|
||||
const stripeSecretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard';
|
||||
const stripPublicInvalid =
|
||||
!stripeKey || stripeKey === 'pk_from_stripe_dashboard';
|
||||
|
||||
const paypalSecretInvalid =
|
||||
!paypalKey || paypalKey === 'id_from_paypal_dashboard';
|
||||
const paypalPublicInvalid =
|
||||
@ -201,6 +214,7 @@ export default function donateBoot(app, done) {
|
||||
done();
|
||||
} else {
|
||||
api.post('/charge-stripe', createStripeDonation);
|
||||
api.post('/charge-stripe-card', handleStripeCardDonation);
|
||||
api.post('/add-donation', addDonation);
|
||||
hooks.post('/update-paypal', updatePaypal);
|
||||
donateRouter.use('/donate', api);
|
||||
|
@ -1,6 +1,7 @@
|
||||
/* eslint-disable camelcase */
|
||||
import axios from 'axios';
|
||||
import debug from 'debug';
|
||||
import { donationSubscriptionConfig } from '../../../../config/donation-settings';
|
||||
import keys from '../../../../config/secrets';
|
||||
|
||||
const log = debug('fcc:boot:donate');
|
||||
@ -171,3 +172,79 @@ export async function updateUser(body, app) {
|
||||
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}}.",
|
||||
"credit-card": "Credit Card",
|
||||
"credit-card-2": "Or donate with a credit card:",
|
||||
"or-card": "Or donate with card",
|
||||
"paypal": "with PayPal:",
|
||||
"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.",
|
||||
|
@ -1,8 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* 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 React, { Component } from 'react';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
@ -25,14 +22,14 @@ import {
|
||||
updateDonationFormState,
|
||||
defaultDonationFormState,
|
||||
userSelector,
|
||||
postChargeStripe
|
||||
postChargeStripe,
|
||||
postChargeStripeCard
|
||||
} from '../../redux';
|
||||
import Spacer from '../helpers/spacer';
|
||||
|
||||
import DonateCompletion from './DonateCompletion';
|
||||
|
||||
import type { AddDonationData } from './PaypalButton';
|
||||
import PaypalButton from './PaypalButton';
|
||||
import StripeCardForm from './stripe-card-form';
|
||||
import WalletsWrapper from './walletsButton';
|
||||
|
||||
import './Donation.css';
|
||||
@ -51,7 +48,7 @@ type DonateFormState = {
|
||||
};
|
||||
};
|
||||
|
||||
type DonateFromComponentState = {
|
||||
type DonateFormComponentState = {
|
||||
donationAmount: number;
|
||||
donationDuration: string;
|
||||
};
|
||||
@ -59,6 +56,11 @@ type DonateFromComponentState = {
|
||||
type DonateFormProps = {
|
||||
addDonation: (data: unknown) => unknown;
|
||||
postChargeStripe: (data: unknown) => unknown;
|
||||
postChargeStripeCard: (data: {
|
||||
token: Token;
|
||||
amount: number;
|
||||
duration: string;
|
||||
}) => void;
|
||||
defaultTheme?: string;
|
||||
email: string;
|
||||
handleProcessing: (duration: string, amount: number, action: string) => void;
|
||||
@ -96,10 +98,11 @@ const mapStateToProps = createSelector(
|
||||
const mapDispatchToProps = {
|
||||
addDonation,
|
||||
updateDonationFormState,
|
||||
postChargeStripe
|
||||
postChargeStripe,
|
||||
postChargeStripeCard
|
||||
};
|
||||
|
||||
class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
||||
class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
static displayName = 'DonateForm';
|
||||
durations: { month: 'monthly'; onetime: 'one-time' };
|
||||
amounts: { month: number[]; onetime: number[] };
|
||||
@ -125,6 +128,7 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
||||
this.handleSelectDuration = this.handleSelectDuration.bind(this);
|
||||
this.resetDonation = this.resetDonation.bind(this);
|
||||
this.postStripeDonation = this.postStripeDonation.bind(this);
|
||||
this.postStripeCardDonation = this.postStripeCardDonation.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) {
|
||||
this.setState({ donationAmount });
|
||||
}
|
||||
@ -227,15 +245,15 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
||||
const usd = this.getFormattedAmountLabel(donationAmount);
|
||||
const hours = this.convertToTimeContributed(donationAmount);
|
||||
|
||||
return (
|
||||
<p className='donation-description'>
|
||||
{donationDuration === 'onetime'
|
||||
? t('donate.your-donation', { usd: usd, hours: hours })
|
||||
: donationDuration === 'month'
|
||||
? t('donate.your-donation-2', { usd: usd, hours: hours })
|
||||
: t('donate.your-donation-3', { usd: usd, hours: hours })}
|
||||
</p>
|
||||
);
|
||||
let donationDescription = t('donate.your-donation-3', { usd, hours });
|
||||
|
||||
if (donationDuration === 'onetime') {
|
||||
donationDescription = t('donate.your-donation', { usd, hours });
|
||||
} else if (donationDuration === 'month') {
|
||||
donationDescription = t('donate.your-donation-2', { usd, hours });
|
||||
}
|
||||
|
||||
return <p className='donation-description'>{donationDescription}</p>;
|
||||
}
|
||||
|
||||
resetDonation() {
|
||||
@ -267,7 +285,7 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
||||
renderButtonGroup() {
|
||||
const { donationAmount, donationDuration } = this.state;
|
||||
const {
|
||||
donationFormState: { loading },
|
||||
donationFormState: { loading, processing },
|
||||
handleProcessing,
|
||||
addDonation,
|
||||
defaultTheme,
|
||||
@ -276,7 +294,6 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
||||
isMinimalForm,
|
||||
isSignedIn
|
||||
} = this.props;
|
||||
const paymentButtonsLoading = loading.stripe && loading.paypal;
|
||||
const priorityTheme = defaultTheme ? defaultTheme : theme;
|
||||
const isOneTime = donationDuration === 'onetime';
|
||||
const walletlabel = `${t(
|
||||
@ -290,8 +307,8 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
||||
{this.getDonationButtonLabel()}:
|
||||
</b>
|
||||
<Spacer />
|
||||
{paymentButtonsLoading && this.paymentButtonsLoader()}
|
||||
<div className={'donate-btn-group'}>
|
||||
{loading.stripe && loading.paypal && this.paymentButtonsLoader()}
|
||||
<WalletsWrapper
|
||||
amount={donationAmount}
|
||||
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
|
||||
@ -307,11 +324,24 @@ class DonateForm extends Component<DonateFormProps, DonateFromComponentState> {
|
||||
donationDuration={donationDuration}
|
||||
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
|
||||
handleProcessing={handleProcessing}
|
||||
isMinimalForm={isMinimalForm}
|
||||
isPaypalLoading={loading.paypal}
|
||||
isSignedIn={isSignedIn}
|
||||
onDonationStateChange={this.onDonationStateChange}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
|
@ -15,6 +15,45 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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,
|
||||
@ -52,7 +91,7 @@
|
||||
}
|
||||
|
||||
.paypal-buttons-container {
|
||||
min-height: 142px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.donate-input-element {
|
||||
@ -366,7 +405,7 @@ li.disabled > a {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
button#confirm-donation-btn {
|
||||
button.confirm-donation-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
@ -376,10 +415,15 @@ button#confirm-donation-btn {
|
||||
border-color: var(--yellow-light);
|
||||
color: black;
|
||||
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:focus,
|
||||
button#confirm-donation-btn:hover {
|
||||
button.confirm-donation-btn:active,
|
||||
button.confirm-donation-btn:active:focus,
|
||||
button.confirm-donation-btn:hover {
|
||||
color: black;
|
||||
background-color: #f2ba38;
|
||||
border-color: #f2ba38;
|
||||
@ -394,6 +438,8 @@ button#confirm-donation-btn:hover {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 350px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.donate-btn-group > * {
|
||||
@ -404,13 +450,45 @@ button#confirm-donation-btn:hover {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 500px) {
|
||||
.donate-btn-group > * {
|
||||
width: 49%;
|
||||
}
|
||||
.form-status {
|
||||
min-height: 35px;
|
||||
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 {
|
||||
color: #327290;
|
||||
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}
|
||||
<Spacer />
|
||||
<Row>
|
||||
<Col lg={8} lgOffset={2} sm={10} smOffset={1} xs={12}>
|
||||
<Col xs={12}>
|
||||
<DonateForm
|
||||
handleProcessing={handleProcessing}
|
||||
isMinimalForm={true}
|
||||
|
@ -7,6 +7,7 @@ import { scriptLoader, scriptRemover } from '../../utils/script-loaders';
|
||||
import type { AddDonationData } from './PaypalButton';
|
||||
|
||||
type PayPalButtonScriptLoaderProps = {
|
||||
isMinimalForm: boolean | undefined;
|
||||
clientId: string;
|
||||
createOrder: (
|
||||
data: unknown,
|
||||
@ -68,7 +69,10 @@ export class PayPalButtonScriptLoader extends Component<
|
||||
PayPalButtonScriptLoaderState
|
||||
> {
|
||||
// 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';
|
||||
|
||||
@ -84,11 +88,11 @@ export class PayPalButtonScriptLoader extends Component<
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
if (!window.paypal) {
|
||||
this.loadScript(this.props.isSubscription, false);
|
||||
} else if (this.props.isPaypalLoading) {
|
||||
this.props.onLoad();
|
||||
}
|
||||
this.loadScript(this.props.isSubscription, true);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
scriptRemover('paypal-sdk');
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: {
|
||||
@ -98,12 +102,15 @@ export class PayPalButtonScriptLoader extends Component<
|
||||
height: number;
|
||||
tagline: boolean;
|
||||
};
|
||||
isMinimalForm: boolean | undefined;
|
||||
}): void {
|
||||
// We need to load a new script if any of the following changes.
|
||||
if (
|
||||
prevProps.isSubscription !== this.state.isSubscription ||
|
||||
prevProps.style.color !== this.props.style.color ||
|
||||
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
|
||||
this.setState({ isSdkLoaded: false });
|
||||
@ -113,7 +120,8 @@ export class PayPalButtonScriptLoader extends Component<
|
||||
|
||||
loadScript(subscription: boolean, deleteScript: boolean | undefined): void {
|
||||
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';
|
||||
|
||||
scriptLoader(
|
||||
|
@ -44,6 +44,7 @@ type PaypalButtonProps = {
|
||||
theme: string;
|
||||
isSubscription?: boolean;
|
||||
handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void;
|
||||
isMinimalForm: boolean | undefined;
|
||||
};
|
||||
|
||||
type PaypalButtonState = {
|
||||
@ -128,7 +129,7 @@ export class PaypalButton extends Component<
|
||||
|
||||
render(): JSX.Element | null {
|
||||
const { duration, planId, amount } = this.state;
|
||||
const { t, theme, isPaypalLoading } = this.props;
|
||||
const { t, theme, isPaypalLoading, isMinimalForm } = this.props;
|
||||
const isSubscription = duration !== 'onetime';
|
||||
const buttonColor = theme === 'night' ? 'white' : 'gold';
|
||||
if (!paypalClientId) {
|
||||
@ -175,6 +176,7 @@ export class PaypalButton extends Component<
|
||||
plan_id: planId
|
||||
});
|
||||
}}
|
||||
isMinimalForm={isMinimalForm}
|
||||
isPaypalLoading={isPaypalLoading}
|
||||
isSubscription={isSubscription}
|
||||
onApprove={(data: AddDonationData) => {
|
||||
|
@ -12,7 +12,8 @@ const commonProps = {
|
||||
isPaypalLoading: true,
|
||||
t: jest.fn(),
|
||||
theme: 'night',
|
||||
handlePaymentButtonLoad: jest.fn()
|
||||
handlePaymentButtonLoad: jest.fn(),
|
||||
isMinimalForm: true
|
||||
};
|
||||
|
||||
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('showCert'),
|
||||
...createAsyncTypes('reportUser'),
|
||||
...createAsyncTypes('postChargeStripe')
|
||||
...createAsyncTypes('postChargeStripeCard')
|
||||
],
|
||||
ns
|
||||
);
|
||||
|
@ -7,7 +7,11 @@ import {
|
||||
call,
|
||||
take
|
||||
} 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 {
|
||||
@ -19,10 +23,12 @@ import {
|
||||
addDonationComplete,
|
||||
addDonationError,
|
||||
postChargeStripeComplete,
|
||||
postChargeStripeError
|
||||
postChargeStripeError,
|
||||
postChargeStripeCardComplete,
|
||||
postChargeStripeCardError
|
||||
} 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() {
|
||||
let shouldRequestDonation = yield select(shouldRequestDonationSelector);
|
||||
@ -48,7 +54,7 @@ function* addDonationSaga({ payload }) {
|
||||
error.response && error.response.data
|
||||
? error.response.data
|
||||
: {
|
||||
message: defaultDonationError
|
||||
message: defaultDonationErrorMessage
|
||||
};
|
||||
yield put(addDonationError(data.message));
|
||||
}
|
||||
@ -62,15 +68,27 @@ function* postChargeStripeSaga({ payload }) {
|
||||
const err =
|
||||
error.response && error.response.data
|
||||
? error.response.data.error
|
||||
: defaultDonationError;
|
||||
: defaultDonationErrorMessage;
|
||||
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) {
|
||||
return [
|
||||
takeEvery(types.tryToShowDonationModal, showDonateModalSaga),
|
||||
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(
|
||||
actionTypes.postChargeStripeError
|
||||
);
|
||||
export const postChargeStripeCard = createAction(
|
||||
actionTypes.postChargeStripeCard
|
||||
);
|
||||
export const postChargeStripeCardComplete = createAction(
|
||||
actionTypes.postChargeStripeCardComplete
|
||||
);
|
||||
export const postChargeStripeCardError = createAction(
|
||||
actionTypes.postChargeStripeCardError
|
||||
);
|
||||
|
||||
export const fetchProfileForUser = createAction(
|
||||
actionTypes.fetchProfileForUser
|
||||
@ -450,6 +459,29 @@ export const reducer = handleActions(
|
||||
...state,
|
||||
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 => ({
|
||||
...state,
|
||||
userFetchState: { ...defaultFetchState }
|
||||
|
@ -186,6 +186,10 @@ export function addDonation(body: Donation): Promise<void> {
|
||||
export function postChargeStripe(body: Donation): Promise<void> {
|
||||
return post('/donate/charge-stripe', body);
|
||||
}
|
||||
|
||||
export function postChargeStripeCard(body: Donation): Promise<void> {
|
||||
return post('/donate/charge-stripe-card', body);
|
||||
}
|
||||
interface Report {
|
||||
username: string;
|
||||
reportDescription: string;
|
||||
|
Reference in New Issue
Block a user