mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-20 12:03:11 -04:00
feat: add update-stripe-card route (#52389)
Co-authored-by: Naomi Carrigan <nhcarrigan@gmail.com>
This commit is contained in:
@@ -8,7 +8,8 @@ import {
|
||||
verifyWebHook,
|
||||
updateUser,
|
||||
verifyWebHookType,
|
||||
createStripeCardDonation
|
||||
createStripeCardDonation,
|
||||
handleStripeCardUpdateSession
|
||||
} from '../utils/donation';
|
||||
import { validStripeForm } from '../utils/stripeHelpers';
|
||||
|
||||
@@ -181,6 +182,19 @@ export default function donateBoot(app, done) {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStripeCardUpdate(req, res, next) {
|
||||
try {
|
||||
const sessionIdObj = await handleStripeCardUpdateSession(
|
||||
req,
|
||||
app,
|
||||
stripe
|
||||
);
|
||||
return res.status(200).json(sessionIdObj);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePaypal(req, res) {
|
||||
const { headers, body } = req;
|
||||
return Promise.resolve(req)
|
||||
@@ -220,6 +234,7 @@ export default function donateBoot(app, done) {
|
||||
} else {
|
||||
api.post('/charge-stripe', createStripeDonation);
|
||||
api.post('/charge-stripe-card', handleStripeCardDonation);
|
||||
api.put('/update-stripe-card', handleStripeCardUpdate);
|
||||
api.post('/add-donation', addDonation);
|
||||
hooks.post('/update-paypal', updatePaypal);
|
||||
donateRouter.use('/donate', api);
|
||||
|
||||
@@ -314,3 +314,40 @@ export async function createStripeCardDonation(req, res, stripe) {
|
||||
await createAsyncUserDonation(user, donation);
|
||||
return res.status(200).json({ isDonating: true });
|
||||
}
|
||||
|
||||
export async function handleStripeCardUpdateSession(req, app, stripe) {
|
||||
const {
|
||||
user: { id }
|
||||
} = req;
|
||||
|
||||
const { Donation } = app.models;
|
||||
log('Updating stripe card for user: ', id);
|
||||
|
||||
// multiple donations support should be added
|
||||
const donation = await Donation.findOne({
|
||||
where: { userId: id, provider: 'stripe' }
|
||||
});
|
||||
|
||||
if (!donation) throw Error('Stripe donation record not found');
|
||||
|
||||
const { customerId, subscriptionId } = donation;
|
||||
|
||||
log(subscriptionId);
|
||||
|
||||
// Create a Stripe checkout session
|
||||
// updating customer payment method is handled by webhook handler
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
mode: 'setup',
|
||||
customer: customerId,
|
||||
setup_intent_data: {
|
||||
metadata: {
|
||||
customer_id: customerId,
|
||||
subscription_id: subscriptionId
|
||||
}
|
||||
},
|
||||
success_url: `${process.env.HOME_LOCATION}/update-stripe-card?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.HOME_LOCATION}/update-stripe-card`
|
||||
});
|
||||
return { sessionId: session.id };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* eslint-disable camelcase */
|
||||
import axios from 'axios';
|
||||
import stripe from 'stripe';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import keys from '../../../config/secrets';
|
||||
import {
|
||||
mockApp,
|
||||
@@ -14,10 +16,18 @@ import {
|
||||
verifyWebHook,
|
||||
updateUser,
|
||||
capitalizeKeys,
|
||||
createDonationObj
|
||||
createDonationObj,
|
||||
handleStripeCardUpdateSession
|
||||
} from './donation';
|
||||
|
||||
jest.mock('axios');
|
||||
jest.mock('stripe', () => ({
|
||||
checkout: {
|
||||
sessions: {
|
||||
create: jest.fn()
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const verificationUrl = `https://api.sandbox.paypal.com/v1/notifications/verify-webhook-signature`;
|
||||
const tokenUrl = `https://api.sandbox.paypal.com/v1/oauth2/token`;
|
||||
@@ -155,4 +165,49 @@ describe('donation', () => {
|
||||
expect(updateUserAttr).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleStripeCardUpdateSession', () => {
|
||||
const mockUserId = ObjectId('507f1f77bcf86cd799439011');
|
||||
const mockDonation = {
|
||||
customerId: 'customer_123',
|
||||
subscriptionId: 'sub_123'
|
||||
};
|
||||
const req = { user: { id: mockUserId } };
|
||||
const app = {
|
||||
models: {
|
||||
Donation: { findOne: jest.fn().mockResolvedValue(mockDonation) }
|
||||
}
|
||||
};
|
||||
|
||||
stripe.checkout.sessions.create.mockResolvedValue({ id: 'session_123' });
|
||||
|
||||
it('creates a session successfully', async () => {
|
||||
const result = await handleStripeCardUpdateSession(req, app, stripe);
|
||||
expect(app.models.Donation.findOne).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId, provider: 'stripe' }
|
||||
});
|
||||
expect(stripe.checkout.sessions.create).toHaveBeenCalled();
|
||||
expect(result).toEqual({ sessionId: 'session_123' });
|
||||
});
|
||||
|
||||
it('throws an error when donation not found', async () => {
|
||||
const app = {
|
||||
models: { Donation: { findOne: jest.fn().mockResolvedValue(null) } }
|
||||
};
|
||||
|
||||
await expect(
|
||||
handleStripeCardUpdateSession(req, app, stripe)
|
||||
).rejects.toThrow('Stripe donation record not found');
|
||||
});
|
||||
|
||||
it('handles stripe session creation failure', async () => {
|
||||
stripe.checkout.sessions.create.mockRejectedValue(
|
||||
new Error('Stripe error')
|
||||
);
|
||||
|
||||
await expect(
|
||||
handleStripeCardUpdateSession(req, app, stripe)
|
||||
).rejects.toThrow('Stripe error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,8 @@
|
||||
"submit-exam-results": "Submit my results",
|
||||
"verify-trophy": "Verify Trophy",
|
||||
"link-account": "Link Account",
|
||||
"unlink-account": "Unlink Account"
|
||||
"unlink-account": "Unlink Account",
|
||||
"update-card": "Update your card"
|
||||
},
|
||||
"landing": {
|
||||
"big-heading-1": "Learn to code — for free.",
|
||||
@@ -378,6 +379,10 @@
|
||||
"reset-warn-2": "This cannot be undone",
|
||||
"scrimba-tip": "Tip: If the mini-browser is covering the code, click and drag to move it. Also, feel free to stop and edit the code in the video at any time.",
|
||||
"chal-preview": "Challenge Preview",
|
||||
"donation-record-not-found": "Your donation record has not been found.",
|
||||
"sign-in-card-update": "Sign in to update your card",
|
||||
"card-has-been-updated": "Your card has been updated successfully.",
|
||||
"contact-support-mistake": "If you think there has been a mistake, please contact us at donors@freecodecamp.org",
|
||||
"cert-map-estimates": {
|
||||
"certs": "{{title}} Certification"
|
||||
},
|
||||
@@ -465,10 +470,13 @@
|
||||
"redirecting": "Redirecting...",
|
||||
"thanks": "Thanks for donating",
|
||||
"thank-you": "Thank you for being a supporter.",
|
||||
"success-card-update": "Your card has been updated successfully.",
|
||||
"additional": "You can make an additional one-time donation of any amount using this link: <0>{{url}}</0>",
|
||||
"help-more": "Help us do more",
|
||||
"error": "Something went wrong with your donation.",
|
||||
"error-card-update": "Something went wrong with updating your card.",
|
||||
"error-2": "Something is not right. Please contact donors@freecodecamp.org",
|
||||
"error-3": "Please try again or contact donors@freecodecamp.org",
|
||||
"free-tech": "Your donations will support free technology education for people all over the world.",
|
||||
"no-halo": "If you don't see a gold halo around your profile picture, contact donors@freecodecamp.org.",
|
||||
"gift-frequency": "Select gift frequency:",
|
||||
@@ -596,6 +604,7 @@
|
||||
"update-email-2": "Update your email address here:",
|
||||
"email": "Email",
|
||||
"and": "and",
|
||||
"update-your-card": "Update your card",
|
||||
"change-theme": "Sign in to change theme.",
|
||||
"translation-pending": "Help us translate",
|
||||
"certification-project": "Certification Project",
|
||||
|
||||
63
client/src/components/Donation/card-update-alert-handler.tsx
Normal file
63
client/src/components/Donation/card-update-alert-handler.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Alert, Button } from '@freecodecamp/react-bootstrap';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Spinner from 'react-spinkit';
|
||||
|
||||
type CardUpdateAlertHandlerProps = {
|
||||
error: string | null;
|
||||
redirecting: boolean;
|
||||
reset: () => void;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
function CardUpdateAlertHandler({
|
||||
reset,
|
||||
success,
|
||||
redirecting,
|
||||
error = null
|
||||
}: CardUpdateAlertHandlerProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const style = redirecting ? 'info' : success ? 'success' : 'danger';
|
||||
|
||||
const heading = redirecting
|
||||
? `${t('donate.redirecting')}`
|
||||
: success
|
||||
? `${t('donate.success-card-update')}`
|
||||
: `${t('donate.error-card-update')}`;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
bsStyle={style}
|
||||
className='donation-completion'
|
||||
closeLabel={t('buttons.close')}
|
||||
>
|
||||
<h4>
|
||||
<b>{heading}</b>
|
||||
</h4>
|
||||
<div className='donation-completion-body'>
|
||||
{redirecting && (
|
||||
<Spinner
|
||||
className='user-state-spinner'
|
||||
color='#0a0a23'
|
||||
fadeIn='none'
|
||||
name='line-scale'
|
||||
/>
|
||||
)}
|
||||
{error && <p>{error}</p>}
|
||||
</div>
|
||||
<div className='donation-completion-buttons'>
|
||||
{error && (
|
||||
<div>
|
||||
<Button bsStyle='primary' onClick={reset}>
|
||||
{t('buttons.try-again')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
CardUpdateAlertHandler.displayName = 'CardUpdateAlertHandler';
|
||||
|
||||
export default CardUpdateAlertHandler;
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Login from '../../Header/components/login';
|
||||
|
||||
const BigCallToAction = (): JSX.Element => {
|
||||
const BigCallToAction = ({ text }: { text?: string }): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -11,7 +11,7 @@ const BigCallToAction = (): JSX.Element => {
|
||||
data-test-label='landing-big-cta'
|
||||
data-playwright-test-label='landing-big-cta'
|
||||
>
|
||||
{t('buttons.logged-in-cta-btn')}
|
||||
{text ? text : t('buttons.logged-in-cta-btn')}
|
||||
</Login>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,6 +27,10 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page-wrapper-80 {
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
.codeally-frame {
|
||||
display: block;
|
||||
height: calc(100vh - var(--header-height));
|
||||
|
||||
151
client/src/pages/update-stripe-card.tsx
Normal file
151
client/src/pages/update-stripe-card.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Row, Col, Button } from '@freecodecamp/react-bootstrap';
|
||||
import { useLocation } from '@reach/router';
|
||||
|
||||
import React, { type FormEvent, useEffect } from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import { withTranslation, useTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { Container } from '@freecodecamp/ui';
|
||||
import BigCallToAction from '../components/landing/components/big-call-to-action';
|
||||
|
||||
import { Spacer } from '../components/helpers';
|
||||
import './update-email.css';
|
||||
import {
|
||||
isSignedInSelector,
|
||||
isDonatingSelector,
|
||||
updateCardStateSelector
|
||||
} from '../redux/selectors';
|
||||
import { updateCard, updateCardComplete } from '../redux/actions';
|
||||
import { UpdateCardState } from '../redux/types';
|
||||
import CardUpdateAlertHandler from '../components/Donation/card-update-alert-handler';
|
||||
|
||||
interface UpdateStripeCardProps {
|
||||
isNewEmail: boolean;
|
||||
resetDonationFormState: () => void;
|
||||
isSignedIn: boolean;
|
||||
isDonating: boolean;
|
||||
updateCardState: UpdateCardState;
|
||||
updateCard: () => void;
|
||||
updateCardComplete: () => void;
|
||||
}
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
isSignedInSelector,
|
||||
isDonatingSelector,
|
||||
updateCardStateSelector,
|
||||
(
|
||||
isSignedIn: boolean,
|
||||
isDonating: boolean,
|
||||
updateCardState: UpdateCardState
|
||||
) => ({
|
||||
isSignedIn,
|
||||
isDonating,
|
||||
updateCardState
|
||||
})
|
||||
);
|
||||
|
||||
const mapDispatchToProps = { updateCard, updateCardComplete };
|
||||
|
||||
function ConditionalContent({
|
||||
isSignedIn,
|
||||
isDonating,
|
||||
handleClick,
|
||||
updateCardState
|
||||
}: {
|
||||
isSignedIn: boolean;
|
||||
isDonating: boolean;
|
||||
handleClick: (e?: FormEvent) => void;
|
||||
updateCardState: UpdateCardState;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isSignedIn && !isDonating) {
|
||||
return (
|
||||
<>
|
||||
<h1 className='text-center'>{t('learn.donation-record-not-found')}</h1>
|
||||
<Spacer size='medium' />
|
||||
<p className='text-center'>{t('learn.contact-support-mistake')}</p>
|
||||
</>
|
||||
);
|
||||
} else if (isSignedIn && isDonating) {
|
||||
const { success, error, redirecting } = updateCardState;
|
||||
if (redirecting || error || success) {
|
||||
return (
|
||||
<CardUpdateAlertHandler
|
||||
success={success}
|
||||
error={error}
|
||||
redirecting={redirecting}
|
||||
reset={handleClick}
|
||||
/>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<>
|
||||
<Spacer size='medium' />
|
||||
<Button block={true} onClick={handleClick}>
|
||||
{t('buttons.update-card')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<>
|
||||
<h1 className='text-center'>{t('learn.sign-in-card-update')}</h1>
|
||||
<Spacer size='medium' />
|
||||
<BigCallToAction text={t('buttons.sign-in')} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateStripeCard({
|
||||
isSignedIn,
|
||||
isDonating,
|
||||
updateCardState,
|
||||
updateCard,
|
||||
updateCardComplete
|
||||
}: UpdateStripeCardProps) {
|
||||
function handleClick(event?: FormEvent) {
|
||||
updateCard();
|
||||
if (event) event.preventDefault();
|
||||
}
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const isUpdateSuccessful = searchParams.get('session_id') !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (isUpdateSuccessful) {
|
||||
updateCardComplete();
|
||||
}
|
||||
}, [isUpdateSuccessful, updateCardComplete]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('misc.update-your-card')} | freeCodeCamp.org</title>
|
||||
</Helmet>
|
||||
<Container className='page-wrapper-80'>
|
||||
<Row>
|
||||
<Col sm={6} smOffset={3}>
|
||||
<Spacer size='large' />
|
||||
<ConditionalContent
|
||||
isSignedIn={isSignedIn}
|
||||
isDonating={isDonating}
|
||||
handleClick={handleClick}
|
||||
updateCardState={updateCardState}
|
||||
/>
|
||||
<Spacer size='large' />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
UpdateStripeCard.displayName = 'Update-Stripe-Card';
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withTranslation()(UpdateStripeCard));
|
||||
@@ -39,6 +39,8 @@ export const actionTypes = createTypes(
|
||||
'updateUserToken',
|
||||
'postChargeProcessing',
|
||||
'updateAllChallengesInfo',
|
||||
'updateCardRedirecting',
|
||||
...createAsyncTypes('updateCard'),
|
||||
...createAsyncTypes('fetchUser'),
|
||||
...createAsyncTypes('postCharge'),
|
||||
...createAsyncTypes('fetchProfileForUser'),
|
||||
|
||||
@@ -68,6 +68,13 @@ export const postChargeProcessing = createAction(
|
||||
export const postChargeComplete = createAction(actionTypes.postChargeComplete);
|
||||
export const postChargeError = createAction(actionTypes.postChargeError);
|
||||
|
||||
export const updateCard = createAction(actionTypes.updateCard);
|
||||
export const updateCardError = createAction(actionTypes.updateCardError);
|
||||
export const updateCardComplete = createAction(actionTypes.updateCardComplete);
|
||||
export const updateCardRedirecting = createAction(
|
||||
actionTypes.updateCardRedirecting
|
||||
);
|
||||
|
||||
export const fetchProfileForUser = createAction(
|
||||
actionTypes.fetchProfileForUser
|
||||
);
|
||||
|
||||
@@ -9,10 +9,14 @@ import {
|
||||
takeLeading
|
||||
} from 'redux-saga/effects';
|
||||
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import envData from '../../config/env.json';
|
||||
|
||||
import {
|
||||
addDonation,
|
||||
postChargeStripe,
|
||||
postChargeStripeCard
|
||||
postChargeStripeCard,
|
||||
updateStripeCard
|
||||
} from '../utils/ajax';
|
||||
import { stringifyDonationEvents } from '../utils/analytics-strings';
|
||||
import { PaymentProvider } from '../../../shared/config/donation-settings';
|
||||
@@ -24,7 +28,9 @@ import {
|
||||
postChargeError,
|
||||
preventBlockDonationRequests,
|
||||
setCompletionCountWhenShownProgressModal,
|
||||
executeGA
|
||||
executeGA,
|
||||
updateCardError,
|
||||
updateCardRedirecting
|
||||
} from './actions';
|
||||
import {
|
||||
isDonatingSelector,
|
||||
@@ -34,6 +40,9 @@ import {
|
||||
} from './selectors';
|
||||
|
||||
const defaultDonationErrorMessage = i18next.t('donate.error-2');
|
||||
const updateCardErrorMessage = i18next.t('donate.error-3');
|
||||
|
||||
const { stripePublicKey } = envData;
|
||||
|
||||
function* showDonateModalSaga() {
|
||||
let shouldRequestDonation = yield select(shouldRequestDonationSelector);
|
||||
@@ -155,10 +164,26 @@ export function* setDonationCookie() {
|
||||
}
|
||||
}
|
||||
|
||||
export function* updateCardSaga() {
|
||||
yield put(updateCardRedirecting());
|
||||
try {
|
||||
const {
|
||||
data: { sessionId }
|
||||
} = yield call(updateStripeCard);
|
||||
|
||||
if (!sessionId) throw new Error('No sessionId');
|
||||
const stripe = yield call(loadStripe, stripePublicKey);
|
||||
stripe.redirectToCheckout({ sessionId });
|
||||
} catch (error) {
|
||||
yield put(updateCardError(updateCardErrorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
export function createDonationSaga(types) {
|
||||
return [
|
||||
takeEvery(types.tryToShowDonationModal, showDonateModalSaga),
|
||||
takeLeading(types.postCharge, postChargeSaga),
|
||||
takeEvery(types.fetchUserComplete, setDonationCookie)
|
||||
takeEvery(types.fetchUserComplete, setDonationCookie),
|
||||
takeLeading(types.updateCard, updateCardSaga)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,10 +2,21 @@ import { expectSaga } from 'redux-saga-test-plan';
|
||||
import {
|
||||
postChargeStripe,
|
||||
postChargeStripeCard,
|
||||
addDonation
|
||||
addDonation,
|
||||
updateStripeCard
|
||||
} from '../utils/ajax';
|
||||
import { postChargeSaga, setDonationCookie } from './donation-saga.js';
|
||||
import { postChargeComplete, postChargeProcessing, executeGA } from './actions';
|
||||
import {
|
||||
postChargeSaga,
|
||||
setDonationCookie,
|
||||
updateCardSaga
|
||||
} from './donation-saga.js';
|
||||
import {
|
||||
postChargeComplete,
|
||||
postChargeProcessing,
|
||||
executeGA,
|
||||
updateCardRedirecting,
|
||||
updateCardError
|
||||
} from './actions';
|
||||
|
||||
jest.mock('../utils/ajax');
|
||||
jest.mock('../analytics');
|
||||
@@ -121,4 +132,28 @@ describe('donation-saga', () => {
|
||||
.put(executeGA(patreonAnalyticsDataMock))
|
||||
.run();
|
||||
});
|
||||
|
||||
it('handles successful card update', () => {
|
||||
updateStripeCard.mockResolvedValue({
|
||||
data: { sessionId: 'expected data' }
|
||||
});
|
||||
|
||||
return expectSaga(updateCardSaga)
|
||||
.put(updateCardRedirecting())
|
||||
.call(updateStripeCard)
|
||||
.not.put(updateCardError())
|
||||
.run();
|
||||
});
|
||||
|
||||
it('handles errors correctly for card update', () => {
|
||||
updateStripeCard.mockResolvedValue({
|
||||
data: 'unexpected data'
|
||||
});
|
||||
|
||||
return expectSaga(updateCardSaga)
|
||||
.put(updateCardRedirecting())
|
||||
.call(updateStripeCard)
|
||||
.put(updateCardError())
|
||||
.run();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,12 @@ const defaultFetchState = {
|
||||
error: null
|
||||
};
|
||||
|
||||
const updateCardDefaultState = {
|
||||
redirecting: false,
|
||||
success: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
export const defaultDonationFormState = {
|
||||
redirecting: false,
|
||||
processing: false,
|
||||
@@ -76,6 +82,9 @@ const initialState = {
|
||||
renderStartTime: null,
|
||||
donationFormState: {
|
||||
...defaultDonationFormState
|
||||
},
|
||||
updateCardState: {
|
||||
...updateCardDefaultState
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,6 +153,18 @@ export const reducer = handleActions(
|
||||
renderStartTime: payload
|
||||
};
|
||||
},
|
||||
[actionTypes.updateCardError]: (state, { payload }) => ({
|
||||
...state,
|
||||
updateCardState: { ...updateCardDefaultState, error: payload }
|
||||
}),
|
||||
[actionTypes.updateCardRedirecting]: state => ({
|
||||
...state,
|
||||
updateCardState: { ...updateCardDefaultState, redirecting: true }
|
||||
}),
|
||||
[actionTypes.updateCardComplete]: state => ({
|
||||
...state,
|
||||
updateCardState: { ...updateCardDefaultState, success: true }
|
||||
}),
|
||||
[actionTypes.updateDonationFormState]: (state, { payload }) => ({
|
||||
...state,
|
||||
donationFormState: { ...state.donationFormState, ...payload }
|
||||
|
||||
@@ -28,6 +28,7 @@ export const recentlyClaimedBlockSelector = state =>
|
||||
state[MainApp].recentlyClaimedBlock;
|
||||
export const donationFormStateSelector = state =>
|
||||
state[MainApp].donationFormState;
|
||||
export const updateCardStateSelector = state => state[MainApp].updateCardState;
|
||||
export const signInLoadingSelector = state =>
|
||||
userFetchStateSelector(state).pending;
|
||||
export const showCertSelector = state => state[MainApp].showCert;
|
||||
|
||||
@@ -58,3 +58,9 @@ export interface DonateFormState {
|
||||
paypal: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateCardState {
|
||||
redirecting: boolean;
|
||||
success: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function post<T = void>(
|
||||
|
||||
function put<T = void>(
|
||||
path: string,
|
||||
body: unknown
|
||||
body?: unknown
|
||||
): Promise<ResponseWithData<T>> {
|
||||
return request('PUT', path, body);
|
||||
}
|
||||
@@ -238,6 +238,10 @@ export function addDonation(body: Donation): Promise<ResponseWithData<void>> {
|
||||
return post('/donate/add-donation', body);
|
||||
}
|
||||
|
||||
export function updateStripeCard() {
|
||||
return put('/donate/update-stripe-card');
|
||||
}
|
||||
|
||||
export function postChargeStripe(
|
||||
body: Donation
|
||||
): Promise<ResponseWithData<void>> {
|
||||
|
||||
18
e2e/update-stripe-card-default.spec.ts
Normal file
18
e2e/update-stripe-card-default.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
test.describe('Update Card Page for Non-Donor Authenticated User', () => {
|
||||
test.use({ storageState: 'playwright/.auth/certified-user.json' });
|
||||
test('should render correctly', async ({ page }) => {
|
||||
await page.goto('/update-stripe-card');
|
||||
await expect(page).toHaveTitle(
|
||||
`${translations.misc['update-your-card']} | freeCodeCamp.org`
|
||||
);
|
||||
const h1 = page.locator('h1');
|
||||
await expect(h1).toHaveText(
|
||||
`${translations.learn['donation-record-not-found']}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Unauthrorized, and donor user states should be added here in upcoming PRs
|
||||
Reference in New Issue
Block a user