mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 18:18:27 -05:00
feat(donation): simplify donation client (#46379)
* feat: unify post payment actions * feat: handle stripe card error and add donation after auth * feat: add donation saga stripe test * feat: add more coverage to stripe tests * feat: add initial stripe card saga test * feat: finalize initial stripe card saga test * feat: add patreon test saga * feat: test clean up * feat: do not show processing for Patreon * feat: normalize donation settings * feat: turn payment provider/contex to enum * feat: remove donation-settings.js * fix: git ignore generated config * fix: ignore the generate config from everything * fix: remove types.js * fix: update linting to include types.js Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
This commit is contained in:
@@ -5,6 +5,7 @@ api-server/src/public/**
|
||||
api-server/lib/**
|
||||
config/i18n.js
|
||||
config/certification-settings.js
|
||||
config/donation-settings.js
|
||||
config/superblock-order.js
|
||||
web/**
|
||||
docs/**/*.md
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -164,6 +164,7 @@ config/client/test-evaluator.json
|
||||
config/curriculum.json
|
||||
config/i18n.js
|
||||
config/certification-settings.js
|
||||
config/donation-settings.js
|
||||
config/superblock-order.js
|
||||
config/superblock-order.test.js
|
||||
|
||||
@@ -212,6 +213,7 @@ client/static/_redirects
|
||||
client/static/mobile
|
||||
client/static/curriculum-data
|
||||
client/i18n/locales/**/trending.json
|
||||
client/src/components/Donation/types.js
|
||||
|
||||
### UI Components ###
|
||||
tools/ui-components/dist
|
||||
|
||||
@@ -8,6 +8,7 @@ curriculum/challenges/**/*
|
||||
config/**/*.json
|
||||
config/i18n.js
|
||||
config/certification-settings.js
|
||||
config/donation-settings.js
|
||||
config/superblock-order.js
|
||||
config/superblock-order.test.js
|
||||
utils/block-nameify.js
|
||||
@@ -19,3 +20,4 @@ utils/index.js
|
||||
web/.next
|
||||
curriculum-server/data/curriculum.json
|
||||
docs/**/*.md
|
||||
client/src/components/Donation/types.js
|
||||
@@ -217,7 +217,7 @@ export async function createStripeCardDonation(req, res, stripe) {
|
||||
* if user is already donating and the donation isn't one time only,
|
||||
* throw error
|
||||
*/
|
||||
if (user.isDonating && duration !== 'onetime') {
|
||||
if (user.isDonating && duration !== 'one-time') {
|
||||
throw {
|
||||
message: `User already has active recurring donation(s).`,
|
||||
type: 'AlreadyDonatingError'
|
||||
|
||||
@@ -9,7 +9,7 @@ export function validStripeForm(amount, duration, email) {
|
||||
return isEmail('' + email) &&
|
||||
isNumeric('' + amount) &&
|
||||
durationKeysConfig.includes(duration) &&
|
||||
duration === 'onetime'
|
||||
duration === 'one-time'
|
||||
? donationOneTimeConfig.includes(amount)
|
||||
: donationSubscriptionConfig.plans[duration];
|
||||
}
|
||||
|
||||
@@ -361,7 +361,6 @@
|
||||
"become-supporter": "Become a Supporter",
|
||||
"duration": "Become a one-time supporter of our charity.",
|
||||
"duration-2": "Become a monthly supporter of our charity.",
|
||||
"duration-3": "Become an annual supporter of our charity",
|
||||
"duration-4": "Become a supporter of our charity",
|
||||
"nicely-done": "Nicely done. You just completed {{block}}.",
|
||||
"credit-card": "Credit Card",
|
||||
|
||||
@@ -31,6 +31,7 @@ import certificateMissingMessage from '../utils/certificate-missing-message';
|
||||
import reallyWeirdErrorMessage from '../utils/really-weird-error-message';
|
||||
import standardErrorMessage from '../utils/standard-error-message';
|
||||
|
||||
import { PaymentContext } from '../../../config/donation-settings';
|
||||
import ShowProjectLinks from './show-project-links';
|
||||
|
||||
const { clientLocale } = envData;
|
||||
@@ -177,20 +178,7 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => {
|
||||
setIsDonationClosed(true);
|
||||
};
|
||||
|
||||
const handleProcessing = (
|
||||
duration: string,
|
||||
amount: number,
|
||||
action: string
|
||||
) => {
|
||||
props.executeGA({
|
||||
type: 'event',
|
||||
data: {
|
||||
category: 'Donation',
|
||||
action: `certificate ${action}`,
|
||||
label: duration,
|
||||
value: amount
|
||||
}
|
||||
});
|
||||
const handleProcessing = () => {
|
||||
setIsDonationSubmitted(true);
|
||||
};
|
||||
|
||||
@@ -270,6 +258,7 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => {
|
||||
defaultTheme={Themes.Default}
|
||||
handleProcessing={handleProcessing}
|
||||
isMinimalForm={true}
|
||||
paymentContext={PaymentContext.Certificate}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -9,17 +9,11 @@ import { createSelector } from 'reselect';
|
||||
import {
|
||||
amountsConfig,
|
||||
durationsConfig,
|
||||
defaultAmount,
|
||||
defaultDonation,
|
||||
modalDefaultDonation
|
||||
} from '../../../../config/donation-settings';
|
||||
import { defaultDonationFormState } from '../../redux';
|
||||
import {
|
||||
addDonation,
|
||||
updateDonationFormState,
|
||||
postChargeStripe,
|
||||
postChargeStripeCard
|
||||
} from '../../redux/actions';
|
||||
import { updateDonationFormState, postCharge } from '../../redux/actions';
|
||||
import {
|
||||
isSignedInSelector,
|
||||
userSelector,
|
||||
@@ -32,11 +26,19 @@ import Spacer from '../helpers/spacer';
|
||||
import { Themes } from '../settings/theme';
|
||||
import DonateCompletion from './donate-completion';
|
||||
import PatreonButton from './patreon-button';
|
||||
import type { AddDonationData } from './paypal-button';
|
||||
import PaypalButton from './paypal-button';
|
||||
import StripeCardForm, { HandleAuthentication } from './stripe-card-form';
|
||||
import StripeCardForm from './stripe-card-form';
|
||||
import WalletsWrapper from './walletsButton';
|
||||
import SecurityLockIcon from './security-lock-icon';
|
||||
import {
|
||||
PaymentProvider,
|
||||
PaymentContext,
|
||||
PostPayment,
|
||||
HandleAuthentication,
|
||||
DonationApprovalData,
|
||||
DonationAmount,
|
||||
DonationConfig
|
||||
} from './types';
|
||||
|
||||
import './donation.css';
|
||||
|
||||
@@ -54,23 +56,26 @@ type DonateFormState = {
|
||||
};
|
||||
};
|
||||
|
||||
type DonateFormComponentState = {
|
||||
donationAmount: number;
|
||||
donationDuration: string;
|
||||
};
|
||||
type DonateFormComponentState = DonationConfig;
|
||||
|
||||
type PostCharge = (data: {
|
||||
paymentProvider: PaymentProvider;
|
||||
paymentContext: PaymentContext;
|
||||
amount: number;
|
||||
duration: string;
|
||||
data?: DonationApprovalData;
|
||||
token?: Token;
|
||||
email?: string;
|
||||
name?: string | undefined;
|
||||
paymentMethodId?: string;
|
||||
handleAuthentication?: HandleAuthentication;
|
||||
}) => void;
|
||||
|
||||
type DonateFormProps = {
|
||||
addDonation: (data: unknown) => unknown;
|
||||
postChargeStripe: (data: unknown) => unknown;
|
||||
postChargeStripeCard: (data: {
|
||||
paymentMethodId: string;
|
||||
amount: number;
|
||||
duration: string;
|
||||
handleAuthentication: HandleAuthentication;
|
||||
}) => void;
|
||||
postCharge: PostCharge;
|
||||
defaultTheme?: Themes;
|
||||
email: string;
|
||||
handleProcessing: (duration: string, amount: number, action: string) => void;
|
||||
handleProcessing?: () => void;
|
||||
donationFormState: DonateFormState;
|
||||
isMinimalForm?: boolean;
|
||||
isSignedIn: boolean;
|
||||
@@ -81,8 +86,9 @@ type DonateFormProps = {
|
||||
{ usd, hours }?: { usd?: string | number; hours?: string }
|
||||
) => string;
|
||||
theme: Themes;
|
||||
updateDonationFormState: (state: AddDonationData) => unknown;
|
||||
updateDonationFormState: (state: DonationApprovalData) => unknown;
|
||||
isVariantA: boolean;
|
||||
paymentContext: PaymentContext;
|
||||
};
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
@@ -111,10 +117,8 @@ const mapStateToProps = createSelector(
|
||||
);
|
||||
|
||||
const mapDispatchToProps = {
|
||||
addDonation,
|
||||
updateDonationFormState,
|
||||
postChargeStripe,
|
||||
postChargeStripeCard
|
||||
postCharge,
|
||||
updateDonationFormState
|
||||
};
|
||||
|
||||
class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
@@ -124,27 +128,20 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
constructor(props: DonateFormProps) {
|
||||
super(props);
|
||||
|
||||
this.durations = durationsConfig as {
|
||||
month: 'monthly';
|
||||
onetime: 'one-time';
|
||||
};
|
||||
this.durations = durationsConfig;
|
||||
this.amounts = amountsConfig;
|
||||
|
||||
const initialAmountAndDuration = this.props.isMinimalForm
|
||||
const initialAmountAndDuration: DonationConfig = this.props.isMinimalForm
|
||||
? modalDefaultDonation
|
||||
: defaultDonation;
|
||||
|
||||
this.state = { ...initialAmountAndDuration };
|
||||
|
||||
this.onDonationStateChange = this.onDonationStateChange.bind(this);
|
||||
this.getActiveDonationAmount = this.getActiveDonationAmount.bind(this);
|
||||
this.getDonationButtonLabel = this.getDonationButtonLabel.bind(this);
|
||||
this.handleSelectAmount = this.handleSelectAmount.bind(this);
|
||||
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.postPatreonRedirect = this.postPatreonRedirect.bind(this);
|
||||
this.postPayment = this.postPayment.bind(this);
|
||||
this.handlePaymentButtonLoad = this.handlePaymentButtonLoad.bind(this);
|
||||
}
|
||||
|
||||
@@ -152,7 +149,7 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
this.resetDonation();
|
||||
}
|
||||
|
||||
onDonationStateChange(donationState: AddDonationData) {
|
||||
onDonationStateChange(donationState: DonationApprovalData) {
|
||||
// scroll to top
|
||||
window.scrollTo(0, 0);
|
||||
this.props.updateDonationFormState({
|
||||
@@ -171,16 +168,6 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
});
|
||||
}
|
||||
|
||||
// onload
|
||||
getActiveDonationAmount(
|
||||
durationSelected: 'month' | 'onetime',
|
||||
amountSelected: number
|
||||
): number {
|
||||
return this.amounts[durationSelected].includes(amountSelected)
|
||||
? amountSelected
|
||||
: defaultAmount[durationSelected] || this.amounts[durationSelected][0];
|
||||
}
|
||||
|
||||
convertToTimeContributed(amount: number) {
|
||||
return numToCommas((amount / 100) * 50);
|
||||
}
|
||||
@@ -194,7 +181,7 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
const { t } = this.props;
|
||||
const usd = this.getFormattedAmountLabel(donationAmount);
|
||||
let donationBtnLabel = t('donate.confirm');
|
||||
if (donationDuration === 'onetime') {
|
||||
if (donationDuration === 'one-time') {
|
||||
donationBtnLabel = t('donate.confirm-2', {
|
||||
usd: usd
|
||||
});
|
||||
@@ -209,62 +196,34 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
return donationBtnLabel;
|
||||
}
|
||||
|
||||
handleSelectDuration(donationDuration: 'month' | 'onetime') {
|
||||
const donationAmount = this.getActiveDonationAmount(donationDuration, 0);
|
||||
this.setState({ donationDuration, donationAmount });
|
||||
}
|
||||
|
||||
postStripeDonation(
|
||||
token: Token,
|
||||
payerEmail: string | undefined,
|
||||
payerName: string | undefined
|
||||
) {
|
||||
const { email } = this.props;
|
||||
postPayment = ({
|
||||
paymentProvider,
|
||||
data,
|
||||
payerEmail,
|
||||
payerName,
|
||||
token,
|
||||
paymentMethodId,
|
||||
handleAuthentication
|
||||
}: PostPayment): void => {
|
||||
const { donationAmount: amount, donationDuration: duration } = this.state;
|
||||
payerEmail = email ? email : payerEmail;
|
||||
window.scrollTo(0, 0);
|
||||
// change the donation modal button label to close
|
||||
// or display the close button for the cert donation section
|
||||
if (this.props.handleProcessing) {
|
||||
this.props.handleProcessing(duration, amount, 'Stripe payment submition');
|
||||
}
|
||||
this.props.postChargeStripe({
|
||||
const { paymentContext, email } = this.props;
|
||||
|
||||
this.props.postCharge({
|
||||
paymentProvider,
|
||||
paymentContext,
|
||||
amount,
|
||||
duration,
|
||||
data,
|
||||
token,
|
||||
amount,
|
||||
duration,
|
||||
email: payerEmail,
|
||||
name: payerName
|
||||
});
|
||||
}
|
||||
|
||||
postStripeCardDonation(
|
||||
paymentMethodId: string,
|
||||
handleAuthentication: HandleAuthentication
|
||||
) {
|
||||
const { donationAmount: amount, donationDuration: duration } = this.state;
|
||||
this.props.handleProcessing(
|
||||
duration,
|
||||
amount,
|
||||
'Stripe card payment submission'
|
||||
);
|
||||
this.props.postChargeStripeCard({
|
||||
email: email || payerEmail,
|
||||
name: payerName,
|
||||
paymentMethodId,
|
||||
amount,
|
||||
duration,
|
||||
handleAuthentication
|
||||
});
|
||||
}
|
||||
if (this.props.handleProcessing) this.props.handleProcessing();
|
||||
};
|
||||
|
||||
postPatreonRedirect() {
|
||||
const { donationAmount: amount, donationDuration: duration } = this.state;
|
||||
this.props.handleProcessing(
|
||||
duration,
|
||||
amount,
|
||||
'Patreon payment redirection'
|
||||
);
|
||||
}
|
||||
|
||||
handleSelectAmount(donationAmount: number) {
|
||||
handleSelectAmount(donationAmount: DonationAmount) {
|
||||
this.setState({ donationAmount });
|
||||
}
|
||||
|
||||
@@ -276,7 +235,7 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
|
||||
let donationDescription = t('donate.your-donation-3', { usd, hours });
|
||||
|
||||
if (donationDuration === 'onetime') {
|
||||
if (donationDuration === 'one-time') {
|
||||
donationDescription = t('donate.your-donation', { usd, hours });
|
||||
} else if (donationDuration === 'month') {
|
||||
donationDescription = t('donate.your-donation-2', { usd, hours });
|
||||
@@ -316,8 +275,6 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
const { donationAmount, donationDuration } = this.state;
|
||||
const {
|
||||
donationFormState: { loading, processing },
|
||||
handleProcessing,
|
||||
addDonation,
|
||||
defaultTheme,
|
||||
theme,
|
||||
t,
|
||||
@@ -327,7 +284,7 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
isVariantA
|
||||
} = this.props;
|
||||
const priorityTheme = defaultTheme ? defaultTheme : theme;
|
||||
const isOneTime = donationDuration === 'onetime';
|
||||
const isOneTime = donationDuration === 'one-time';
|
||||
const walletlabel = `${t(
|
||||
isOneTime ? 'donate.wallet-label' : 'donate.wallet-label-1',
|
||||
{ usd: donationAmount / 100 }
|
||||
@@ -352,31 +309,29 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
|
||||
label={walletlabel}
|
||||
onDonationStateChange={this.onDonationStateChange}
|
||||
postStripeDonation={this.postStripeDonation}
|
||||
postPayment={this.postPayment}
|
||||
refreshErrorMessage={t('donate.refresh-needed')}
|
||||
theme={priorityTheme}
|
||||
/>
|
||||
<PaypalButton
|
||||
addDonation={addDonation}
|
||||
donationAmount={donationAmount}
|
||||
donationDuration={donationDuration}
|
||||
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
|
||||
handleProcessing={handleProcessing}
|
||||
postPayment={this.postPayment}
|
||||
isMinimalForm={showMinimalPayments}
|
||||
isPaypalLoading={loading.paypal}
|
||||
isSignedIn={isSignedIn}
|
||||
onDonationStateChange={this.onDonationStateChange}
|
||||
theme={priorityTheme}
|
||||
/>
|
||||
{(!loading.stripe || !loading.paypal) && (
|
||||
<PatreonButton postPatreonRedirect={this.postPatreonRedirect} />
|
||||
<PatreonButton postPayment={this.postPayment} />
|
||||
)}
|
||||
{showMinimalPayments && (
|
||||
<>
|
||||
<div className='separator'>{t('donate.or-card')}</div>
|
||||
<StripeCardForm
|
||||
onDonationStateChange={this.onDonationStateChange}
|
||||
postStripeCardDonation={this.postStripeCardDonation}
|
||||
postPayment={this.postPayment}
|
||||
processing={processing}
|
||||
t={t}
|
||||
theme={priorityTheme}
|
||||
|
||||
@@ -6,7 +6,10 @@ import { connect } from 'react-redux';
|
||||
import { goToAnchor } from 'react-scrollable-anchor';
|
||||
import { bindActionCreators, Dispatch, AnyAction } from 'redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { modalDefaultDonation } from '../../../../config/donation-settings';
|
||||
import {
|
||||
modalDefaultDonation,
|
||||
PaymentContext
|
||||
} from '../../../../config/donation-settings';
|
||||
import Cup from '../../assets/icons/cup';
|
||||
import Heart from '../../assets/icons/heart';
|
||||
|
||||
@@ -56,20 +59,7 @@ function DonateModal({
|
||||
}: DonateModalProps): JSX.Element {
|
||||
const [closeLabel, setCloseLabel] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
const handleProcessing = (
|
||||
duration: string,
|
||||
amount: number,
|
||||
action: string
|
||||
) => {
|
||||
executeGA({
|
||||
type: 'event',
|
||||
data: {
|
||||
category: 'Donation',
|
||||
action: `Modal ${action}`,
|
||||
label: duration,
|
||||
value: amount
|
||||
}
|
||||
});
|
||||
const handleProcessing = () => {
|
||||
setCloseLabel(true);
|
||||
};
|
||||
|
||||
@@ -93,12 +83,10 @@ function DonateModal({
|
||||
const getDonationText = () => {
|
||||
const donationDuration = modalDefaultDonation.donationDuration;
|
||||
switch (donationDuration) {
|
||||
case 'onetime':
|
||||
case 'one-time':
|
||||
return <b>{t('donate.duration')}</b>;
|
||||
case 'month':
|
||||
return <b>{t('donate.duration-2')}</b>;
|
||||
case 'year':
|
||||
return <b>{t('donate.duration-3')}</b>;
|
||||
default:
|
||||
return <b>{t('donate.duration-4')}</b>;
|
||||
}
|
||||
@@ -158,6 +146,7 @@ function DonateModal({
|
||||
<DonateForm
|
||||
handleProcessing={handleProcessing}
|
||||
isMinimalForm={true}
|
||||
paymentContext={PaymentContext.Modal}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
donationUrls,
|
||||
patreonDefaultPledgeAmount
|
||||
patreonDefaultPledgeAmount,
|
||||
PaymentProvider
|
||||
} from '../../../../config/donation-settings';
|
||||
import envData from '../../../../config/env.json';
|
||||
import PatreonLogo from '../../assets/images/components/patreon-logo';
|
||||
import { PostPayment } from './types';
|
||||
|
||||
const { patreonClientId }: { patreonClientId: string | null } = envData as {
|
||||
patreonClientId: string | null;
|
||||
};
|
||||
|
||||
interface PatreonButtonProps {
|
||||
postPatreonRedirect: () => void;
|
||||
postPayment: (arg0: PostPayment) => void;
|
||||
}
|
||||
|
||||
const PatreonButton = ({
|
||||
postPatreonRedirect
|
||||
postPayment
|
||||
}: PatreonButtonProps): JSX.Element | null => {
|
||||
if (
|
||||
!patreonClientId ||
|
||||
@@ -36,7 +38,7 @@ const PatreonButton = ({
|
||||
className='patreon-button link-button'
|
||||
data-patreon-widget-type='become-patron-button'
|
||||
href={href}
|
||||
onClick={postPatreonRedirect}
|
||||
onClick={() => postPayment({ paymentProvider: PaymentProvider.Patreon })}
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import ReactDOM from 'react-dom';
|
||||
|
||||
import { scriptLoader, scriptRemover } from '../../utils/script-loaders';
|
||||
|
||||
import type { AddDonationData } from './paypal-button';
|
||||
import type { DonationApprovalData } from './types';
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
type PayPalButtonScriptLoaderProps = {
|
||||
@@ -30,7 +30,7 @@ type PayPalButtonScriptLoaderProps = {
|
||||
) => unknown;
|
||||
isSubscription: boolean;
|
||||
onApprove: (
|
||||
data: AddDonationData,
|
||||
data: DonationApprovalData,
|
||||
actions?: { order: { capture: () => Promise<unknown> } }
|
||||
) => unknown;
|
||||
isPaypalLoading: boolean;
|
||||
@@ -66,7 +66,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export class PayPalButtonScriptLoader extends Component<
|
||||
export default class PayPalButtonScriptLoader extends Component<
|
||||
PayPalButtonScriptLoaderProps,
|
||||
PayPalButtonScriptLoaderState
|
||||
> {
|
||||
@@ -188,7 +188,7 @@ export class PayPalButtonScriptLoader extends Component<
|
||||
onApprove={
|
||||
isSubscription
|
||||
? (
|
||||
data: AddDonationData,
|
||||
data: DonationApprovalData,
|
||||
actions: { order: { capture: () => Promise<unknown> } }
|
||||
) => onApprove(data, actions)
|
||||
: (
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Themes } from '../settings/theme';
|
||||
|
||||
import { PaypalButton } from './paypal-button';
|
||||
|
||||
const commonProps = {
|
||||
donationAmount: 500,
|
||||
donationDuration: 'month',
|
||||
handleProcessing: () => null,
|
||||
isDonating: false,
|
||||
onDonationStateChange: () => null,
|
||||
isPaypalLoading: true,
|
||||
t: jest.fn(),
|
||||
theme: Themes.Night,
|
||||
handlePaymentButtonLoad: jest.fn(),
|
||||
isMinimalForm: true
|
||||
};
|
||||
|
||||
const donationData = {
|
||||
redirecting: false,
|
||||
processing: false,
|
||||
success: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
jest.mock('../../analytics');
|
||||
|
||||
describe('<Paypal Button/>', () => {
|
||||
it('does not call addDonate api on payment approval when user is not signed ', () => {
|
||||
const ref = React.createRef<PaypalButton>();
|
||||
const isSubscription = true;
|
||||
const addDonation = jest.fn();
|
||||
render(
|
||||
<PaypalButton
|
||||
{...commonProps}
|
||||
addDonation={addDonation}
|
||||
isSignedIn={false}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
|
||||
ref.current?.handleApproval(donationData, isSubscription);
|
||||
expect(addDonation).toBeCalledTimes(0);
|
||||
});
|
||||
it('calls addDonate api on payment approval when user is signed in', () => {
|
||||
const ref = React.createRef<PaypalButton>();
|
||||
const isSubscription = true;
|
||||
const addDonation = jest.fn();
|
||||
render(
|
||||
<PaypalButton
|
||||
{...commonProps}
|
||||
addDonation={addDonation}
|
||||
isSignedIn={true}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
|
||||
ref.current?.handleApproval(donationData, isSubscription);
|
||||
expect(addDonation).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -5,23 +5,23 @@ import { createSelector } from 'reselect';
|
||||
import {
|
||||
paypalConfigurator,
|
||||
paypalConfigTypes,
|
||||
defaultDonation
|
||||
defaultDonation,
|
||||
PaymentProvider
|
||||
} from '../../../../config/donation-settings';
|
||||
import envData from '../../../../config/env.json';
|
||||
import { userSelector, signInLoadingSelector } from '../../redux/selectors';
|
||||
import { Themes } from '../settings/theme';
|
||||
import { PayPalButtonScriptLoader } from './paypal-button-script-loader';
|
||||
import {
|
||||
DonationApprovalData,
|
||||
PostPayment,
|
||||
DonationDuration,
|
||||
DonationAmount
|
||||
} from './types';
|
||||
import PayPalButtonScriptLoader from './paypal-button-script-loader';
|
||||
|
||||
type PaypalButtonProps = {
|
||||
addDonation: (data: AddDonationData) => void;
|
||||
isSignedIn: boolean;
|
||||
donationAmount: number;
|
||||
donationDuration: string;
|
||||
handleProcessing: (
|
||||
duration: string,
|
||||
amount: number,
|
||||
action: string
|
||||
) => unknown;
|
||||
donationAmount: DonationAmount;
|
||||
donationDuration: DonationDuration;
|
||||
isDonating: boolean;
|
||||
onDonationStateChange: ({
|
||||
redirecting,
|
||||
@@ -35,13 +35,13 @@ type PaypalButtonProps = {
|
||||
error: string | null;
|
||||
}) => void;
|
||||
isPaypalLoading: boolean;
|
||||
skipAddDonation?: boolean;
|
||||
t: (label: string) => string;
|
||||
ref?: Ref<PaypalButton>;
|
||||
theme: Themes;
|
||||
isSubscription?: boolean;
|
||||
handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void;
|
||||
isMinimalForm: boolean | undefined;
|
||||
postPayment: (arg0: PostPayment) => void;
|
||||
};
|
||||
|
||||
type PaypalButtonState = {
|
||||
@@ -50,17 +50,6 @@ type PaypalButtonState = {
|
||||
planId: string | null;
|
||||
};
|
||||
|
||||
export interface AddDonationData {
|
||||
redirecting: boolean;
|
||||
processing: boolean;
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
loading?: {
|
||||
stripe: boolean;
|
||||
paypal: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
paypalClientId,
|
||||
deploymentEnv
|
||||
@@ -82,7 +71,6 @@ export class PaypalButton extends Component<
|
||||
};
|
||||
constructor(props: PaypalButtonProps) {
|
||||
super(props);
|
||||
this.handleApproval = this.handleApproval.bind(this);
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(
|
||||
@@ -90,8 +78,8 @@ export class PaypalButton extends Component<
|
||||
): PaypalButtonState {
|
||||
const { donationAmount, donationDuration } = props;
|
||||
const configurationObj: {
|
||||
amount: number;
|
||||
duration: string;
|
||||
amount: DonationAmount;
|
||||
duration: DonationDuration;
|
||||
planId: string | null;
|
||||
} = paypalConfigurator(
|
||||
donationAmount,
|
||||
@@ -105,30 +93,10 @@ export class PaypalButton extends Component<
|
||||
return { ...configurationObj };
|
||||
}
|
||||
|
||||
handleApproval = (data: AddDonationData, isSubscription: boolean): void => {
|
||||
const { amount, duration } = this.state;
|
||||
const { isSignedIn = false } = this.props;
|
||||
|
||||
// If the user is signed in and the payment is subscritipn call the api
|
||||
if (isSignedIn && isSubscription) {
|
||||
this.props.addDonation(data);
|
||||
}
|
||||
|
||||
this.props.handleProcessing(duration, amount, 'Paypal payment submission');
|
||||
|
||||
// Show success anytime because the payment has gone through paypal
|
||||
this.props.onDonationStateChange({
|
||||
redirecting: false,
|
||||
processing: false,
|
||||
success: true,
|
||||
error: data.error ? data.error : null
|
||||
});
|
||||
};
|
||||
|
||||
render(): JSX.Element | null {
|
||||
const { duration, planId, amount } = this.state;
|
||||
const { t, theme, isPaypalLoading, isMinimalForm } = this.props;
|
||||
const isSubscription = duration !== 'onetime';
|
||||
const isSubscription = duration !== 'one-time';
|
||||
const buttonColor = theme === Themes.Night ? 'white' : 'gold';
|
||||
if (!paypalClientId) {
|
||||
return null;
|
||||
@@ -177,8 +145,11 @@ export class PaypalButton extends Component<
|
||||
isMinimalForm={isMinimalForm}
|
||||
isPaypalLoading={isPaypalLoading}
|
||||
isSubscription={isSubscription}
|
||||
onApprove={(data: AddDonationData) => {
|
||||
this.handleApproval(data, isSubscription);
|
||||
onApprove={(data: DonationApprovalData) => {
|
||||
this.props.postPayment({
|
||||
paymentProvider: PaymentProvider.Paypal,
|
||||
data
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
this.props.onDonationStateChange({
|
||||
|
||||
@@ -9,29 +9,21 @@ import {
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import type {
|
||||
StripeCardNumberElementChangeEvent,
|
||||
StripeCardExpiryElementChangeEvent,
|
||||
PaymentIntentResult
|
||||
StripeCardExpiryElementChangeEvent
|
||||
} from '@stripe/stripe-js';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { PaymentProvider } from '../../../../config/donation-settings';
|
||||
import envData from '../../../../config/env.json';
|
||||
import { Themes } from '../settings/theme';
|
||||
import { AddDonationData } from './paypal-button';
|
||||
import { DonationApprovalData, PostPayment } from './types';
|
||||
import SecurityLockIcon from './security-lock-icon';
|
||||
|
||||
const { stripePublicKey }: { stripePublicKey: string | null } = envData;
|
||||
|
||||
export type HandleAuthentication = (
|
||||
clientSecret: string,
|
||||
paymentMethod: string
|
||||
) => Promise<PaymentIntentResult | { error: { type: string } }>;
|
||||
|
||||
interface FormPropTypes {
|
||||
onDonationStateChange: (donationState: AddDonationData) => void;
|
||||
postStripeCardDonation: (
|
||||
paymentMethodId: string,
|
||||
handleAuthentication: HandleAuthentication
|
||||
) => void;
|
||||
onDonationStateChange: (donationState: DonationApprovalData) => void;
|
||||
postPayment: (arg0: PostPayment) => void;
|
||||
t: (label: string) => string;
|
||||
theme: Themes;
|
||||
processing: boolean;
|
||||
@@ -50,7 +42,7 @@ const StripeCardForm = ({
|
||||
theme,
|
||||
t,
|
||||
onDonationStateChange,
|
||||
postStripeCardDonation,
|
||||
postPayment,
|
||||
processing,
|
||||
isVariantA
|
||||
}: FormPropTypes): JSX.Element => {
|
||||
@@ -124,7 +116,11 @@ const StripeCardForm = ({
|
||||
error: t('donate.went-wrong')
|
||||
});
|
||||
} else if (paymentMethod)
|
||||
postStripeCardDonation(paymentMethod.id, handleAuthentication);
|
||||
postPayment({
|
||||
paymentProvider: PaymentProvider.StripeCard,
|
||||
paymentMethodId: paymentMethod.id,
|
||||
handleAuthentication
|
||||
});
|
||||
}
|
||||
}
|
||||
return setTokenizing(false);
|
||||
|
||||
37
client/src/components/Donation/types.ts
Normal file
37
client/src/components/Donation/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Token, PaymentIntentResult } from '@stripe/stripe-js';
|
||||
|
||||
export type PaymentContext = 'modal' | 'donate page' | 'certificate';
|
||||
export type PaymentProvider = 'patreon' | 'paypal' | 'stripe' | 'stripe card';
|
||||
|
||||
export type HandleAuthentication = (
|
||||
clientSecret: string,
|
||||
paymentMethod: string
|
||||
) => Promise<PaymentIntentResult | { error: { type: string } }>;
|
||||
|
||||
export type DonationAmount = 500 | 1000 | 2000 | 3000 | 4000 | 5000;
|
||||
export type DonationDuration = 'one-time' | 'month';
|
||||
export interface DonationConfig {
|
||||
donationAmount: DonationAmount;
|
||||
donationDuration: DonationDuration;
|
||||
}
|
||||
|
||||
export interface PostPayment {
|
||||
paymentProvider: PaymentProvider;
|
||||
data?: DonationApprovalData;
|
||||
token?: Token;
|
||||
payerEmail?: string | undefined;
|
||||
payerName?: string | undefined;
|
||||
paymentMethodId?: string;
|
||||
handleAuthentication?: HandleAuthentication;
|
||||
}
|
||||
|
||||
export interface DonationApprovalData {
|
||||
redirecting: boolean;
|
||||
processing: boolean;
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
loading?: {
|
||||
stripe: boolean;
|
||||
paypal: boolean;
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import type { Token, PaymentRequest } from '@stripe/stripe-js';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import envData from '../../../../config/env.json';
|
||||
import { Themes } from '../settings/theme';
|
||||
import { AddDonationData } from './paypal-button';
|
||||
import { PaymentProvider } from '../../../../config/donation-settings';
|
||||
import { DonationApprovalData, PostPayment } from './types';
|
||||
|
||||
const { stripePublicKey }: { stripePublicKey: string | null } = envData;
|
||||
|
||||
@@ -16,12 +17,8 @@ interface WrapperProps {
|
||||
label: string;
|
||||
amount: number;
|
||||
theme: Themes;
|
||||
postStripeDonation: (
|
||||
token: Token,
|
||||
payerEmail: string | undefined,
|
||||
payerName: string | undefined
|
||||
) => void;
|
||||
onDonationStateChange: (donationState: AddDonationData) => void;
|
||||
postPayment: (arg0: PostPayment) => void;
|
||||
onDonationStateChange: (donationState: DonationApprovalData) => void;
|
||||
refreshErrorMessage: string;
|
||||
handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void;
|
||||
}
|
||||
@@ -35,7 +32,7 @@ const WalletsButton = ({
|
||||
amount,
|
||||
theme,
|
||||
refreshErrorMessage,
|
||||
postStripeDonation,
|
||||
postPayment,
|
||||
onDonationStateChange,
|
||||
handlePaymentButtonLoad
|
||||
}: WalletsButtonProps) => {
|
||||
@@ -63,7 +60,12 @@ const WalletsButton = ({
|
||||
const { token, payerEmail, payerName } = event;
|
||||
setToken(token);
|
||||
event.complete('success');
|
||||
postStripeDonation(token, payerEmail, payerName);
|
||||
postPayment({
|
||||
paymentProvider: PaymentProvider.Stripe,
|
||||
token,
|
||||
payerEmail,
|
||||
payerName
|
||||
});
|
||||
});
|
||||
|
||||
void pr.canMakePayment().then(canMakePaymentRes => {
|
||||
@@ -74,7 +76,7 @@ const WalletsButton = ({
|
||||
checkpaymentPossiblity(false);
|
||||
}
|
||||
});
|
||||
}, [label, amount, stripe, postStripeDonation, handlePaymentButtonLoad]);
|
||||
}, [label, amount, stripe, postPayment, handlePaymentButtonLoad]);
|
||||
|
||||
const displayRefreshError = (): void => {
|
||||
onDonationStateChange({
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Spacer, Loader } from '../components/helpers';
|
||||
import CampersImage from '../components/landing/components/campers-image';
|
||||
import { executeGA } from '../redux/actions';
|
||||
import { signInLoadingSelector, userSelector } from '../redux/selectors';
|
||||
import { PaymentContext } from '../../../config/donation-settings';
|
||||
|
||||
export interface ExecuteGaArg {
|
||||
type: string;
|
||||
@@ -68,18 +69,6 @@ function DonatePage({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function handleProcessing(duration: string, amount: number, action: string) {
|
||||
executeGA({
|
||||
type: 'event',
|
||||
data: {
|
||||
category: 'Donation',
|
||||
action: `donate page ${action}`,
|
||||
label: duration,
|
||||
value: amount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return showLoading ? (
|
||||
<Loader fullScreen={true} />
|
||||
) : (
|
||||
@@ -110,7 +99,7 @@ function DonatePage({
|
||||
<DonationText />
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<DonateForm handleProcessing={handleProcessing} />
|
||||
<DonateForm paymentContext={PaymentContext.DonatePage} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Spacer size={3} />
|
||||
|
||||
@@ -27,15 +27,13 @@ export const actionTypes = createTypes(
|
||||
'updateFailed',
|
||||
'updateDonationFormState',
|
||||
'updateUserToken',
|
||||
'postChargeProcessing',
|
||||
...createAsyncTypes('fetchUser'),
|
||||
...createAsyncTypes('addDonation'),
|
||||
...createAsyncTypes('createStripeSession'),
|
||||
...createAsyncTypes('postChargeStripe'),
|
||||
...createAsyncTypes('postCharge'),
|
||||
...createAsyncTypes('fetchProfileForUser'),
|
||||
...createAsyncTypes('acceptTerms'),
|
||||
...createAsyncTypes('showCert'),
|
||||
...createAsyncTypes('reportUser'),
|
||||
...createAsyncTypes('postChargeStripeCard'),
|
||||
...createAsyncTypes('deleteUserToken'),
|
||||
...createAsyncTypes('saveChallenge')
|
||||
],
|
||||
|
||||
@@ -53,28 +53,12 @@ export const fetchUser = createAction(actionTypes.fetchUser);
|
||||
export const fetchUserComplete = createAction(actionTypes.fetchUserComplete);
|
||||
export const fetchUserError = createAction(actionTypes.fetchUserError);
|
||||
|
||||
export const addDonation = createAction(actionTypes.addDonation);
|
||||
export const addDonationComplete = createAction(
|
||||
actionTypes.addDonationComplete
|
||||
);
|
||||
export const addDonationError = createAction(actionTypes.addDonationError);
|
||||
|
||||
export const postChargeStripe = createAction(actionTypes.postChargeStripe);
|
||||
export const postChargeStripeComplete = createAction(
|
||||
actionTypes.postChargeStripeComplete
|
||||
);
|
||||
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 postCharge = createAction(actionTypes.postCharge);
|
||||
export const postChargeProcessing = createAction(
|
||||
actionTypes.postChargeProcessing
|
||||
);
|
||||
export const postChargeComplete = createAction(actionTypes.postChargeComplete);
|
||||
export const postChargeError = createAction(actionTypes.postChargeError);
|
||||
|
||||
export const fetchProfileForUser = createAction(
|
||||
actionTypes.fetchProfileForUser
|
||||
|
||||
@@ -14,22 +14,23 @@ import {
|
||||
postChargeStripe,
|
||||
postChargeStripeCard
|
||||
} from '../utils/ajax';
|
||||
import { stringifyDonationEvents } from '../utils/analyticsStrings';
|
||||
import { PaymentProvider } from '../../../config/donation-settings';
|
||||
import { actionTypes as appTypes } from './action-types';
|
||||
import {
|
||||
addDonationComplete,
|
||||
addDonationError,
|
||||
openDonationModal,
|
||||
postChargeStripeCardComplete,
|
||||
postChargeStripeCardError,
|
||||
postChargeStripeComplete,
|
||||
postChargeStripeError,
|
||||
postChargeComplete,
|
||||
postChargeProcessing,
|
||||
postChargeError,
|
||||
preventBlockDonationRequests,
|
||||
preventProgressDonationRequests
|
||||
preventProgressDonationRequests,
|
||||
executeGA
|
||||
} from './actions';
|
||||
import {
|
||||
isDonatingSelector,
|
||||
recentlyClaimedBlockSelector,
|
||||
shouldRequestDonationSelector
|
||||
shouldRequestDonationSelector,
|
||||
isSignedInSelector
|
||||
} from './selectors';
|
||||
|
||||
const defaultDonationErrorMessage = i18next.t('donate.error-2');
|
||||
@@ -49,33 +50,77 @@ function* showDonateModalSaga() {
|
||||
}
|
||||
}
|
||||
|
||||
function* addDonationSaga({ payload }) {
|
||||
try {
|
||||
yield call(addDonation, payload);
|
||||
yield put(addDonationComplete());
|
||||
yield call(setDonationCookie);
|
||||
} catch (error) {
|
||||
const data =
|
||||
error.response && error.response.data
|
||||
? error.response.data
|
||||
: {
|
||||
message: defaultDonationErrorMessage
|
||||
};
|
||||
yield put(addDonationError(data.message));
|
||||
export function* postChargeSaga({
|
||||
payload,
|
||||
payload: {
|
||||
paymentProvider,
|
||||
paymentContext,
|
||||
amount,
|
||||
duration,
|
||||
handleAuthentication,
|
||||
paymentMethodId
|
||||
}
|
||||
}
|
||||
|
||||
function* postChargeStripeSaga({ payload }) {
|
||||
}) {
|
||||
try {
|
||||
yield call(postChargeStripe, payload);
|
||||
yield put(postChargeStripeComplete());
|
||||
yield call(setDonationCookie);
|
||||
if (paymentProvider !== PaymentProvider.Patreon) {
|
||||
yield put(postChargeProcessing());
|
||||
}
|
||||
|
||||
if (paymentProvider === PaymentProvider.Stripe) {
|
||||
yield call(postChargeStripe, payload);
|
||||
} else if (paymentProvider === PaymentProvider.StripeCard) {
|
||||
const optimizedPayload = { paymentMethodId, amount, duration };
|
||||
const response = yield call(postChargeStripeCard, optimizedPayload);
|
||||
const error = response?.data?.error;
|
||||
if (error) {
|
||||
yield stripeCardErrorHandler(
|
||||
error,
|
||||
handleAuthentication,
|
||||
error.client_secret,
|
||||
response.paymentMethodId,
|
||||
optimizedPayload
|
||||
);
|
||||
|
||||
//if the authentication does not throw an error, add a donation
|
||||
yield call(addDonation, { amount, duration });
|
||||
}
|
||||
} else if (paymentProvider === PaymentProvider.Paypal) {
|
||||
// If the user is signed in and the payment goes through call api
|
||||
let isSignedIn = yield select(isSignedInSelector);
|
||||
// look into skip add donation
|
||||
// what to do with "data" that comes throug
|
||||
if (isSignedIn) yield call(addDonation, { amount, duration });
|
||||
}
|
||||
if (
|
||||
[
|
||||
PaymentProvider.Paypal,
|
||||
PaymentProvider.Stripe,
|
||||
PaymentProvider.StripeCard
|
||||
].includes(paymentProvider)
|
||||
) {
|
||||
yield put(postChargeComplete());
|
||||
yield call(setDonationCookie);
|
||||
}
|
||||
yield put(
|
||||
executeGA({
|
||||
type: 'event',
|
||||
data: {
|
||||
category:
|
||||
paymentProvider === PaymentProvider.Patreon
|
||||
? 'Donation Related'
|
||||
: 'Donation',
|
||||
action: stringifyDonationEvents(paymentContext, paymentProvider),
|
||||
label: duration,
|
||||
value: amount
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
const err =
|
||||
error.response && error.response.data
|
||||
? error.response.data.error
|
||||
: defaultDonationErrorMessage;
|
||||
yield put(postChargeStripeError(err));
|
||||
yield put(postChargeError(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,40 +144,16 @@ function* stripeCardErrorHandler(
|
||||
}
|
||||
}
|
||||
|
||||
function* postChargeStripeCardSaga({
|
||||
payload: { paymentMethodId, amount, duration, handleAuthentication }
|
||||
}) {
|
||||
try {
|
||||
const optimizedPayload = { paymentMethodId, amount, duration };
|
||||
const {
|
||||
data: { error }
|
||||
} = yield call(postChargeStripeCard, optimizedPayload);
|
||||
if (error) {
|
||||
yield stripeCardErrorHandler(
|
||||
error,
|
||||
handleAuthentication,
|
||||
error.client_secret,
|
||||
paymentMethodId,
|
||||
optimizedPayload
|
||||
);
|
||||
}
|
||||
yield call(addDonation, optimizedPayload);
|
||||
yield put(postChargeStripeCardComplete());
|
||||
yield call(setDonationCookie);
|
||||
} catch (error) {
|
||||
const errorMessage = error.message || defaultDonationErrorMessage;
|
||||
yield put(postChargeStripeCardError(errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
function* setDonationCookie() {
|
||||
const isDonating = yield select(isDonatingSelector);
|
||||
const isDonorCookieSet = document.cookie
|
||||
.split(';')
|
||||
.some(item => item.trim().startsWith('isDonor=true'));
|
||||
if (isDonating) {
|
||||
if (!isDonorCookieSet) {
|
||||
document.cookie = 'isDonor=true';
|
||||
export function* setDonationCookie() {
|
||||
if (document?.cookie) {
|
||||
const isDonating = yield select(isDonatingSelector);
|
||||
const isDonorCookieSet = document.cookie
|
||||
.split(';')
|
||||
.some(item => item.trim().startsWith('isDonor=true'));
|
||||
if (isDonating) {
|
||||
if (!isDonorCookieSet) {
|
||||
document.cookie = 'isDonor=true';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,9 +161,7 @@ function* setDonationCookie() {
|
||||
export function createDonationSaga(types) {
|
||||
return [
|
||||
takeEvery(types.tryToShowDonationModal, showDonateModalSaga),
|
||||
takeEvery(types.addDonation, addDonationSaga),
|
||||
takeLeading(types.postChargeStripe, postChargeStripeSaga),
|
||||
takeLeading(types.postChargeStripeCard, postChargeStripeCardSaga),
|
||||
takeLeading(types.postCharge, postChargeSaga),
|
||||
takeEvery(types.fetchUserComplete, setDonationCookie)
|
||||
];
|
||||
}
|
||||
|
||||
130
client/src/redux/donation-saga.test.js
Normal file
130
client/src/redux/donation-saga.test.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import { expectSaga } from 'redux-saga-test-plan';
|
||||
import {
|
||||
postChargeStripe,
|
||||
postChargeStripeCard,
|
||||
addDonation
|
||||
} from '../utils/ajax';
|
||||
import { postChargeSaga, setDonationCookie } from './donation-saga.js';
|
||||
import { postChargeComplete, postChargeProcessing, executeGA } from './actions';
|
||||
|
||||
jest.mock('../utils/ajax');
|
||||
jest.mock('../analytics');
|
||||
|
||||
const postChargeDataMock = {
|
||||
payload: {
|
||||
paymentProvider: 'stripe',
|
||||
paymentContext: 'donate page',
|
||||
amount: '500',
|
||||
duration: 'monthly',
|
||||
handleAuthentication: jest.fn(),
|
||||
paymentMethodId: '123456'
|
||||
}
|
||||
};
|
||||
|
||||
const analyticsDataMock = {
|
||||
type: 'event',
|
||||
data: {
|
||||
category: 'Donation',
|
||||
action: 'Donate Page Stripe Payment Submission',
|
||||
label: 'monthly',
|
||||
value: '500'
|
||||
}
|
||||
};
|
||||
|
||||
describe('donation-saga', () => {
|
||||
it('calls postChargeStrip for Stripe', () => {
|
||||
return expectSaga(postChargeSaga, postChargeDataMock)
|
||||
.put(postChargeProcessing())
|
||||
.call(postChargeStripe, postChargeDataMock.payload)
|
||||
.put(postChargeComplete())
|
||||
.call(setDonationCookie)
|
||||
.put(executeGA(analyticsDataMock))
|
||||
.run();
|
||||
});
|
||||
|
||||
it('calls postChargeStripCard for Stripe Card', () => {
|
||||
const stripeCardDataMock = {
|
||||
payload: { ...postChargeDataMock.payload, paymentProvider: 'stripe card' }
|
||||
};
|
||||
|
||||
const stripeCardAnalyticsDataMock = analyticsDataMock;
|
||||
stripeCardAnalyticsDataMock.data.action =
|
||||
'Donate Page Stripe Card Payment Submission';
|
||||
|
||||
const { paymentMethodId, amount, duration } = stripeCardDataMock.payload;
|
||||
const optimizedPayload = { paymentMethodId, amount, duration };
|
||||
return expectSaga(postChargeSaga, stripeCardDataMock)
|
||||
.put(postChargeProcessing())
|
||||
.call(postChargeStripeCard, optimizedPayload)
|
||||
.put(postChargeComplete())
|
||||
.call(setDonationCookie)
|
||||
.put(executeGA(stripeCardAnalyticsDataMock))
|
||||
.run();
|
||||
});
|
||||
|
||||
it('calls addDonate for Paypal if user signed in', () => {
|
||||
const paypalDataMock = {
|
||||
payload: { ...postChargeDataMock.payload, paymentProvider: 'paypal' }
|
||||
};
|
||||
|
||||
const paypalAnalyticsDataMock = analyticsDataMock;
|
||||
paypalAnalyticsDataMock.data.action =
|
||||
'Donate Page Paypal Payment Submission';
|
||||
|
||||
const storeMock = {
|
||||
app: {
|
||||
appUsername: 'devuser'
|
||||
}
|
||||
};
|
||||
|
||||
const { amount, duration } = paypalDataMock.payload;
|
||||
return expectSaga(postChargeSaga, paypalDataMock)
|
||||
.withState(storeMock)
|
||||
.put(postChargeProcessing())
|
||||
.call(addDonation, { amount, duration })
|
||||
.put(postChargeComplete())
|
||||
.call(setDonationCookie)
|
||||
.put(executeGA(paypalAnalyticsDataMock))
|
||||
.run();
|
||||
});
|
||||
|
||||
it('does not call addDonate for Paypal if user not signed in', () => {
|
||||
const paypalDataMock = {
|
||||
payload: { ...postChargeDataMock.payload, paymentProvider: 'paypal' }
|
||||
};
|
||||
|
||||
const paypalAnalyticsDataMock = analyticsDataMock;
|
||||
paypalAnalyticsDataMock.data.action =
|
||||
'Donate Page Paypal Payment Submission';
|
||||
|
||||
const storeMock = {
|
||||
app: {}
|
||||
};
|
||||
|
||||
return expectSaga(postChargeSaga, paypalDataMock)
|
||||
.withState(storeMock)
|
||||
.put(postChargeProcessing())
|
||||
.not.call.fn(addDonation)
|
||||
.put(postChargeComplete())
|
||||
.call(setDonationCookie)
|
||||
.put(executeGA(paypalAnalyticsDataMock))
|
||||
.run();
|
||||
});
|
||||
|
||||
it('does not call api for Patreon', () => {
|
||||
const patreonDataMock = {
|
||||
payload: { ...postChargeDataMock.payload, paymentProvider: 'patreon' }
|
||||
};
|
||||
|
||||
const patreonAnalyticsDataMock = analyticsDataMock;
|
||||
patreonAnalyticsDataMock.data.action =
|
||||
'Donate Page Patreon Payment Redirection';
|
||||
patreonAnalyticsDataMock.data.category = 'Donation Related';
|
||||
return expectSaga(postChargeSaga, patreonDataMock)
|
||||
.not.call.fn(addDonation)
|
||||
.not.call.fn(postChargeStripeCard)
|
||||
.not.call.fn(postChargeStripe)
|
||||
.put(executeGA(patreonAnalyticsDataMock))
|
||||
.run();
|
||||
});
|
||||
});
|
||||
@@ -130,11 +130,11 @@ export const reducer = handleActions(
|
||||
...state,
|
||||
donationFormState: { ...state.donationFormState, ...payload }
|
||||
}),
|
||||
[actionTypes.addDonation]: state => ({
|
||||
[actionTypes.postChargeProcessing]: state => ({
|
||||
...state,
|
||||
donationFormState: { ...defaultDonationFormState, processing: true }
|
||||
}),
|
||||
[actionTypes.addDonationComplete]: state => {
|
||||
[actionTypes.postChargeComplete]: state => {
|
||||
const { appUsername } = state;
|
||||
return {
|
||||
...state,
|
||||
@@ -149,56 +149,11 @@ export const reducer = handleActions(
|
||||
donationFormState: { ...defaultDonationFormState, success: true }
|
||||
};
|
||||
},
|
||||
[actionTypes.addDonationError]: (state, { payload }) => ({
|
||||
[actionTypes.postChargeError]: (state, { payload }) => ({
|
||||
...state,
|
||||
donationFormState: { ...defaultDonationFormState, error: payload }
|
||||
}),
|
||||
[actionTypes.postChargeStripe]: state => ({
|
||||
...state,
|
||||
donationFormState: { ...defaultDonationFormState, processing: true }
|
||||
}),
|
||||
[actionTypes.postChargeStripeComplete]: state => {
|
||||
const { appUsername } = state;
|
||||
return {
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
[appUsername]: {
|
||||
...state.user[appUsername],
|
||||
isDonating: true
|
||||
}
|
||||
},
|
||||
|
||||
donationFormState: { ...defaultDonationFormState, success: true }
|
||||
};
|
||||
},
|
||||
[actionTypes.postChargeStripeError]: (state, { payload }) => ({
|
||||
...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 }
|
||||
|
||||
14
client/src/utils/analyticsStrings.test.ts
Normal file
14
client/src/utils/analyticsStrings.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { stringifyDonationEvents } from './analyticsStrings';
|
||||
|
||||
describe('Analytics donation strings', () => {
|
||||
it('Should return correct string for modal patreon payment', () => {
|
||||
expect(stringifyDonationEvents('modal', 'patreon')).toEqual(
|
||||
'Modal Patreon Payment Redirection'
|
||||
);
|
||||
});
|
||||
it('Should return correct string for modal donate page stripe card payment', () => {
|
||||
expect(stringifyDonationEvents('donate page', 'stripe card')).toEqual(
|
||||
'Donate Page Stripe Card Payment Submission'
|
||||
);
|
||||
});
|
||||
});
|
||||
15
client/src/utils/analyticsStrings.ts
Normal file
15
client/src/utils/analyticsStrings.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PaymentContext, PaymentProvider } from '../components/Donation/types';
|
||||
|
||||
export function stringifyDonationEvents(
|
||||
paymentContext: PaymentContext,
|
||||
paymentProvider: PaymentProvider
|
||||
): string {
|
||||
const donationString = `${paymentContext} ${paymentProvider} payment ${
|
||||
paymentProvider === 'patreon' ? 'redirection' : 'submission'
|
||||
}`;
|
||||
|
||||
// return title case
|
||||
return donationString.replace(/(^\w{1})|(\s+\w{1})/g, letter =>
|
||||
letter.toUpperCase()
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,32 @@
|
||||
// Configuration for client side
|
||||
const durationsConfig = {
|
||||
import { DonationConfig } from '../client/src/components/Donation/types';
|
||||
|
||||
export const durationsConfig: {
|
||||
month: 'monthly';
|
||||
onetime: 'one-time';
|
||||
} = {
|
||||
month: 'monthly',
|
||||
onetime: 'one-time'
|
||||
};
|
||||
const amountsConfig = {
|
||||
|
||||
export const amountsConfig = {
|
||||
month: [1000, 2000, 3000, 4000, 5000],
|
||||
onetime: [2500, 5000, 7500, 10000, 15000]
|
||||
};
|
||||
const defaultAmount = {
|
||||
export const defaultAmount: { month: 500; onetime: 7500 } = {
|
||||
month: 500,
|
||||
onetime: 7500
|
||||
};
|
||||
const defaultDonation = {
|
||||
donationAmount: defaultAmount['month'],
|
||||
export const defaultDonation: DonationConfig = {
|
||||
donationAmount: defaultAmount.month,
|
||||
donationDuration: 'month'
|
||||
};
|
||||
const modalDefaultDonation = {
|
||||
export const modalDefaultDonation: DonationConfig = {
|
||||
donationAmount: 500,
|
||||
donationDuration: 'month'
|
||||
};
|
||||
|
||||
const onetimeSKUConfig = {
|
||||
export const onetimeSKUConfig = {
|
||||
live: [
|
||||
{ amount: '15000', id: 'sku_IElisJHup0nojP' },
|
||||
{ amount: '10000', id: 'sku_IEliodY88lglPk' },
|
||||
@@ -38,9 +44,9 @@ const onetimeSKUConfig = {
|
||||
};
|
||||
|
||||
// Configuration for server side
|
||||
const durationKeysConfig = ['month', 'onetime'];
|
||||
const donationOneTimeConfig = [100000, 25000, 6000];
|
||||
const donationSubscriptionConfig = {
|
||||
export const durationKeysConfig = ['month', 'one-time'];
|
||||
export const donationOneTimeConfig = [100000, 25000, 6000];
|
||||
export const donationSubscriptionConfig = {
|
||||
duration: {
|
||||
month: 'Monthly'
|
||||
},
|
||||
@@ -51,7 +57,7 @@ const donationSubscriptionConfig = {
|
||||
|
||||
// Shared paypal configuration
|
||||
// keep the 5 dollars for the modal
|
||||
const paypalConfigTypes = {
|
||||
export const paypalConfigTypes = {
|
||||
live: {
|
||||
month: {
|
||||
500: { planId: 'P-1L11422374370240ULZKX3PA' },
|
||||
@@ -74,42 +80,51 @@ const paypalConfigTypes = {
|
||||
}
|
||||
};
|
||||
|
||||
const paypalConfigurator = (donationAmount, donationDuration, paypalConfig) => {
|
||||
if (donationDuration === 'onetime') {
|
||||
export const paypalConfigurator = (
|
||||
donationAmount: 500 | 1000 | 2000 | 3000 | 4000 | 5000,
|
||||
donationDuration: 'one-time' | 'month',
|
||||
paypalConfig: {
|
||||
month: {
|
||||
500: { planId: string };
|
||||
1000: { planId: string };
|
||||
2000: { planId: string };
|
||||
3000: { planId: string };
|
||||
4000: { planId: string };
|
||||
5000: { planId: string };
|
||||
};
|
||||
}
|
||||
) => {
|
||||
if (donationDuration === 'one-time') {
|
||||
return { amount: donationAmount, duration: donationDuration, planId: null };
|
||||
}
|
||||
return {
|
||||
amount: donationAmount,
|
||||
duration: donationDuration,
|
||||
planId: paypalConfig[donationDuration]['' + donationAmount].planId
|
||||
planId: paypalConfig[donationDuration][donationAmount].planId
|
||||
};
|
||||
};
|
||||
|
||||
const donationUrls = {
|
||||
export const donationUrls = {
|
||||
successUrl: 'https://www.freecodecamp.org/news/thank-you-for-donating/',
|
||||
cancelUrl: 'https://freecodecamp.org/donate'
|
||||
};
|
||||
|
||||
const patreonDefaultPledgeAmount = 500;
|
||||
export const patreonDefaultPledgeAmount = 500;
|
||||
|
||||
const aBTestConfig = {
|
||||
export const aBTestConfig = {
|
||||
isTesting: true,
|
||||
type: 'secureIconButtonOnly'
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
durationsConfig,
|
||||
amountsConfig,
|
||||
defaultAmount,
|
||||
defaultDonation,
|
||||
durationKeysConfig,
|
||||
donationOneTimeConfig,
|
||||
donationSubscriptionConfig,
|
||||
modalDefaultDonation,
|
||||
onetimeSKUConfig,
|
||||
paypalConfigTypes,
|
||||
paypalConfigurator,
|
||||
donationUrls,
|
||||
patreonDefaultPledgeAmount,
|
||||
aBTestConfig
|
||||
};
|
||||
export enum PaymentContext {
|
||||
Modal = 'modal',
|
||||
DonatePage = 'donate page',
|
||||
Certificate = 'certificate'
|
||||
}
|
||||
|
||||
export enum PaymentProvider {
|
||||
Paypal = 'paypal',
|
||||
Patreon = 'patreon',
|
||||
Stripe = 'stripe',
|
||||
StripeCard = 'stripe card'
|
||||
}
|
||||
Reference in New Issue
Block a user