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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,8 @@ const commonProps = {
isPaypalLoading: true,
t: jest.fn(),
theme: 'night',
handlePaymentButtonLoad: jest.fn()
handlePaymentButtonLoad: jest.fn(),
isMinimalForm: true
};
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('showCert'),
...createAsyncTypes('reportUser'),
...createAsyncTypes('postChargeStripe')
...createAsyncTypes('postChargeStripeCard')
],
ns
);

View File

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

View File

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

View File

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