feat(client): AB test adding mutitier donation modal (#51539)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2023-09-14 18:29:07 +03:00
committed by GitHub
parent 3123db7728
commit 31f97afb2e
9 changed files with 281 additions and 141 deletions

View File

@@ -474,6 +474,8 @@
"confirm-one-time": "Confirm your one-time donation of ${{usd}}:",
"confirm-monthly": "Confirm your donation of ${{usd}} / month:",
"confirm-yearly": "Confirm your donation of ${{usd}} / year:",
"confirm-multitier": "Donating ${{usd}} / month:",
"edit-amount": "edit amount",
"wallet-label": "${{usd}} donation to freeCodeCamp",
"wallet-label-1": "${{usd}} / month donation to freeCodeCamp",
"your-donation": "Your ${{usd}} donation will provide {{hours}} hours of learning to people around the world.",
@@ -490,6 +492,7 @@
"progress-modal-cta-8": "Donate now to help us develop new courses on emerging tools and programming concepts.",
"progress-modal-cta-9": "Donate now to support our math for developers curriculum.",
"progress-modal-cta-10": "Donate now to help us develop free professional programming certifications for all.",
"help-us-develop": "Help us develop free professional programming certifications for all.",
"nicely-done": "Nicely done. You just completed {{block}}.",
"credit-card": "Credit Card",
"credit-card-2": "Or donate with a credit card:",

View File

@@ -267,6 +267,7 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => {
/>
</Col>
</Row>
<Spacer size='medium' />
<Row>
<Col sm={4} smOffset={4} xs={6} xsOffset={3}>
{isDonationSubmitted && donationCloseBtn}

View File

@@ -6,12 +6,11 @@ import { connect } from 'react-redux';
import Spinner from 'react-spinkit';
import { createSelector } from 'reselect';
import type { TFunction } from 'i18next';
import { Button } from '@freecodecamp/react-bootstrap';
import {
amountsConfig,
durationsConfig,
defaultDonation,
modalDefaultDonation,
DonationAmount,
type DonationConfig
} from '../../../../shared/config/donation-settings';
import { defaultDonationFormState } from '../../redux';
@@ -25,6 +24,12 @@ import {
} from '../../redux/selectors';
import Spacer from '../helpers/spacer';
import { Themes } from '../settings/theme';
import { DonateFormState } from '../../redux/types';
import {
CENTS_IN_DOLLAR,
convertToTimeContributed,
formattedAmountLabel
} from './utils';
import DonateCompletion from './donate-completion';
import PatreonButton from './patreon-button';
import PaypalButton from './paypal-button';
@@ -41,28 +46,6 @@ import {
import './donation.css';
const numToCommas = (num: number) =>
num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,');
// the number is used to indicate to the doner about how much hours of free education their dontation will provide.
const contributedHoursOfFreeEduction = 50;
const convertAmountToUSD = 100;
const convertToTimeContributed = (amount: number) =>
numToCommas((amount / convertAmountToUSD) * contributedHoursOfFreeEduction);
const formattedAmountLabel = (amount: number) =>
numToCommas(amount / convertAmountToUSD);
type DonateFormState = {
processing: boolean;
redirecting: boolean;
success: boolean;
error: string;
loading: {
stripe: boolean;
paypal: boolean;
};
};
type DonateFormComponentState = DonationConfig;
type PostCharge = (data: {
@@ -83,6 +66,8 @@ type DonateFormProps = {
defaultTheme?: Themes;
email: string;
handleProcessing?: () => void;
editAmount?: () => void;
selectedDonationAmount?: DonationAmount;
donationFormState: DonateFormState;
isMinimalForm?: boolean;
isSignedIn: boolean;
@@ -135,17 +120,10 @@ const PaymentButtonsLoader = () => {
class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
static displayName = 'DonateForm';
durations: { month: 'monthly'; onetime: 'one-time' };
amounts: { month: number[]; onetime: number[] };
constructor(props: DonateFormProps) {
super(props);
this.durations = durationsConfig;
this.amounts = amountsConfig;
const initialAmountAndDuration: DonationConfig = this.props.isMinimalForm
? modalDefaultDonation
: defaultDonation;
const initialAmountAndDuration: DonationConfig = defaultDonation;
this.state = { ...initialAmountAndDuration };
@@ -187,8 +165,9 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
paymentMethodId,
handleAuthentication
}: PostPayment): void => {
const { donationAmount: amount, donationDuration: duration } = this.state;
const { paymentContext, email } = this.props;
const { donationAmount, donationDuration: duration } = this.state;
const { paymentContext, email, selectedDonationAmount } = this.props;
const amount = selectedDonationAmount || donationAmount;
this.props.postCharge({
paymentProvider,
@@ -210,7 +189,7 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
}
renderButtonGroup() {
const { donationAmount, donationDuration } = this.state;
const { donationAmount: defaultAmount, donationDuration } = this.state;
const {
donationFormState: { loading, processing },
defaultTheme,
@@ -218,24 +197,44 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
t,
isMinimalForm,
isSignedIn,
isDonating
isDonating,
editAmount,
selectedDonationAmount
} = this.props;
const donationAmount: DonationAmount =
selectedDonationAmount || defaultAmount;
const priorityTheme = defaultTheme ? defaultTheme : theme;
const isOneTime = donationDuration === 'one-time';
const walletlabel = `${t(
isOneTime ? 'donate.wallet-label' : 'donate.wallet-label-1',
{ usd: donationAmount / convertAmountToUSD }
)}:`;
const walletlabel = `${t('donate.wallet-label-1', {
usd: donationAmount / CENTS_IN_DOLLAR
})}:`;
console.log(formattedAmountLabel(donationAmount));
const showMinimalPayments = isSignedIn && (isMinimalForm || !isDonating);
const confirmationMessage = t('donate.confirm-monthly', {
usd: formattedAmountLabel(donationAmount)
});
const confirmationWithEditAmount = (
<>
{t('donate.confirm-multitier', {
usd: formattedAmountLabel(donationAmount)
})}
<Button bsStyle='primary' className='btn-link' onClick={editAmount}>
{t('donate.edit-amount')}
</Button>
</>
);
const confirmationClass = () => {
if (editAmount) return 'edit-amount-confirmation';
if (isMinimalForm) return 'donation-label-modal';
return '';
};
return (
<>
<b className={isMinimalForm ? 'donation-label-modal' : ''}>
{t('donate.confirm-monthly', {
usd: formattedAmountLabel(donationAmount)
})}
<b className={confirmationClass()}>
{editAmount ? confirmationWithEditAmount : confirmationMessage}
</b>
<Spacer size='medium' />
<Spacer size={editAmount ? 'small' : 'medium'} />
<fieldset className={'donate-btn-group security-legend'}>
<legend>
<SecurityLockIcon />
@@ -263,7 +262,10 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
theme={priorityTheme}
/>
{(!loading.stripe || !loading.paypal) && (
<PatreonButton postPayment={this.postPayment} />
<PatreonButton
postPayment={this.postPayment}
donationAmount={donationAmount}
/>
)}
{showMinimalPayments && (
<>
@@ -283,18 +285,12 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
}
renderPageForm() {
const { donationAmount, donationDuration } = this.state;
const { donationAmount } = this.state;
const { t } = this.props;
const usd = formattedAmountLabel(donationAmount);
const hours = convertToTimeContributed(donationAmount);
const donationDescription = t('donate.your-donation-2', { usd, hours });
let donationDescription = t('donate.your-donation-3', { usd, hours });
if (donationDuration === 'one-time') {
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>

View File

@@ -1,4 +1,5 @@
import { Modal, Button, Col, Row } from '@freecodecamp/react-bootstrap';
import { Tabs, TabsContent, TabsTrigger, TabsList } from '@freecodecamp/ui';
import { WindowLocation } from '@reach/router';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -7,10 +8,14 @@ import { useFeature } from '@growthbook/growthbook-react';
import { goToAnchor } from 'react-scrollable-anchor';
import { bindActionCreators, Dispatch, AnyAction } from 'redux';
import { createSelector } from 'reselect';
import { PaymentContext } from '../../../../shared/config/donation-settings';
import {
PaymentContext,
subscriptionAmounts,
defaultDonation,
defaultTierAmount
} from '../../../../shared/config/donation-settings';
import BearProgressModal from '../../assets/images/components/bear-progress-modal';
import BearBlockCompletion from '../../assets/images/components/bear-block-completion-modal';
import { closeDonationModal, executeGA } from '../../redux/actions';
import {
isDonationModalOpenSelector,
@@ -20,6 +25,7 @@ import { isLocationSuperBlock } from '../../utils/path-parsers';
import { playTone } from '../../utils/tone';
import { Spacer } from '../helpers';
import DonateForm from './donate-form';
import { formattedAmountLabel, convertToTimeContributed } from './utils';
type RecentlyClaimedBlock = null | { block: string; superBlock: string };
@@ -79,7 +85,12 @@ function DonateModal({
const [ctaNumber, setCtaNumber] = useState(0);
const [isDisabled, setIsDisabled] = useState(true);
const [showSkipButton, setShowSkipButton] = useState(false);
const [showDonateForm, setShowDonateForm] = useState(true);
const [donationAmount, setDonationAmount] = useState(
defaultDonation.donationAmount
);
const loadElementsIndividually = useFeature('load_elements_individually').on;
const showMultiTier = useFeature('multi-tier').on;
const { t } = useTranslation();
// test wheather the conversions are being distributed properly
@@ -119,6 +130,13 @@ function DonateModal({
if (show) setCtaNumber(getctaNumberBetween1To10());
}, [show]);
useEffect(() => {
if (showMultiTier) {
setShowDonateForm(false);
setDonationAmount(defaultTierAmount);
}
}, [showMultiTier]);
const handleModalHide = () => {
// If modal is open on a SuperBlock page
if (isLocationSuperBlock(location)) {
@@ -126,11 +144,8 @@ function DonateModal({
}
};
const donationText = (
const modalHeader = (
<div className=' text-center block-modal-text'>
<div className='donation-icon-container'>
<RenderIlustration recentlyClaimedBlock={recentlyClaimedBlock} />
</div>
<Row>
{!closeLabel && (
<Col sm={10} smOffset={1} xs={12}>
@@ -143,13 +158,146 @@ function DonateModal({
})}
</b>
)}
<b>{t(`donate.progress-modal-cta-${ctaNumber}`)}</b>
{showMultiTier ? (
<h1>{t('donate.help-us-develop')}</h1>
) : (
<b>{t(`donate.progress-modal-cta-${ctaNumber}`)}</b>
)}
</Col>
)}
</Row>
<Spacer size='small' />
</div>
);
const closeButtonRow = (
<>
<Row>
<Col
sm={4}
smOffset={4}
xs={8}
xsOffset={2}
className={showSkipButton ? 'no-delay-fade-in' : 'no-opacity'}
>
<Button
bsSize='sm'
bsStyle='primary'
className='btn-link close-button'
onClick={closeDonationModal}
tabIndex='0'
disabled={isDisabled}
>
{closeLabel ? t('buttons.close') : t('buttons.ask-later')}
</Button>
</Col>
</Row>
</>
);
const selectionTabs = (
<Row className={'donate-btn-group'}>
<Col
xs={12}
className={loadElementsIndividually && 'two-seconds-delay-fade-in'}
>
<b>
{t('donate.confirm-monthly', {
usd: formattedAmountLabel(donationAmount)
})}
</b>
<Spacer size='small' />
<Tabs
className={'donate-btn-group'}
defaultValue={donationAmount.toString()}
>
<TabsList className='nav-lists'>
{subscriptionAmounts.map(value => (
<TabsTrigger
key={value}
value={value.toString()}
onClick={() => setDonationAmount(value)}
>
${formattedAmountLabel(value)}
</TabsTrigger>
))}
</TabsList>
<Spacer size='small' />
{subscriptionAmounts.map(value => {
const usd = formattedAmountLabel(donationAmount);
const hours = convertToTimeContributed(donationAmount);
const donationDescription = t('donate.your-donation-2', {
usd,
hours
});
return (
<TabsContent
key={value}
className='tab-content'
value={value.toString()}
>
<p>{donationDescription}</p>
</TabsContent>
);
})}
</Tabs>
<Button
block={true}
bsStyle='primary'
className='text-center confirm-donation-btn donate-btn-group'
type='submit'
onClick={() => setShowDonateForm(true)}
>
{t('buttons.donate')}
</Button>
<Spacer size='medium' />
</Col>
</Row>
);
const donationFormRow = (
<Row>
<Col
xs={12}
className={loadElementsIndividually && 'two-seconds-delay-fade-in'}
>
<DonateForm
handleProcessing={handleProcessing}
isMinimalForm={true}
paymentContext={PaymentContext.Modal}
editAmount={
showMultiTier ? () => setShowDonateForm(false) : undefined
}
selectedDonationAmount={donationAmount}
/>
<Spacer size='medium' />
</Col>
</Row>
);
const multiTierModalBody = (
<>
<div className={showDonateForm ? 'hide' : ''}>
{modalHeader}
{selectionTabs}
{closeButtonRow}
</div>
<div className={!showDonateForm ? 'hide' : ''}>
{donationFormRow}
{closeLabel && closeButtonRow}
</div>
</>
);
const defaultModalBody = (
<>
{modalHeader}
{donationFormRow}
{closeButtonRow}
</>
);
return (
<Modal
bsSize='lg'
@@ -158,42 +306,10 @@ function DonateModal({
show={show}
>
<Modal.Body className={'no-delay-fade-in'}>
{donationText}
<Spacer size='medium' />
<Row>
<Col
xs={12}
className={loadElementsIndividually && 'two-seconds-delay-fade-in'}
>
<DonateForm
handleProcessing={handleProcessing}
isMinimalForm={true}
paymentContext={PaymentContext.Modal}
/>
</Col>
</Row>
<Spacer size='medium' />
<Row>
<Col
sm={4}
smOffset={4}
xs={8}
xsOffset={2}
className={showSkipButton ? 'no-delay-fade-in' : 'no-opacity'}
>
<Button
block={true}
bsSize='sm'
bsStyle='primary'
className='btn-link'
onClick={closeDonationModal}
tabIndex='0'
disabled={isDisabled}
>
{closeLabel ? t('buttons.close') : t('buttons.ask-later')}
</Button>
</Col>
</Row>
<div className='donation-icon-container'>
<RenderIlustration recentlyClaimedBlock={recentlyClaimedBlock} />
</div>
{showMultiTier ? multiTierModalBody : defaultModalBody}
</Modal.Body>
</Modal>
);

View File

@@ -286,15 +286,48 @@ li.disabled > a {
font-size: 1.2rem;
}
.donation-modal p,
.donation-modal b {
text-align: center;
font-size: 1rem;
width: 100%;
display: inline-block;
}
.donation-modal p {
font-size: 0.9rem;
}
.donation-label-modal {
font-weight: normal;
text-align: center;
}
.edit-amount-confirmation {
width: 350px !important;
margin: 0 auto;
display: flex !important;
flex-direction: row;
justify-content: space-between;
}
.close-button {
text-align: center;
margin: 0 auto;
display: block;
padding: 0 10px;
}
.donation-modal h1 {
font-family: var(--font-family-sans-serif);
}
.donation-modal [role='tablist'] button {
background-color: transparent;
}
.donation-modal [role='tablist'] button:hover:not([data-state='active']) {
background-color: var(--quaternary-background);
color: var(--quaternary-color);
}
.donation-modal [role='tablist'] button[data-state='active'] {
background-color: var(--quaternary-color);
}
.donation-icon-container {
@@ -350,10 +383,6 @@ li.disabled > a {
margin: 40px;
}
.donation-modal p {
font-size: 1.1rem;
}
.donation-modal .modal-title {
font-weight: 700;
font-size: 1.5rem;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import {
DonationAmount,
donationUrls,
patreonDefaultPledgeAmount,
PaymentProvider
} from '../../../../shared/config/donation-settings';
import envData from '../../../config/env.json';
@@ -14,21 +14,21 @@ const { patreonClientId }: { patreonClientId: string | null } = envData as {
interface PatreonButtonProps {
postPayment: (arg0: PostPayment) => void;
donationAmount: DonationAmount;
}
const PatreonButton = ({
postPayment
postPayment,
donationAmount
}: PatreonButtonProps): JSX.Element | null => {
if (
!patreonClientId ||
!patreonDefaultPledgeAmount ||
!donationUrls.successUrl
) {
if (!patreonClientId || !donationAmount || !donationUrls.successUrl) {
return null;
}
const clientId = `&client_id=${patreonClientId}`;
const pledgeLevel = `$&min_cents=${patreonDefaultPledgeAmount}`;
// current Patreon pledge flow does not support custom amounts, it must be a tier
const pledgeLevel = `$&min_cents=${donationAmount}`;
const v2Params = '&scope=identity%20identity[email]';
const redirectUri = `&redirect_uri=${donationUrls.successUrl}`;
const href = `https://www.patreon.com/oauth2/become-patron?response_type=code${pledgeLevel}${clientId}${redirectUri}${v2Params}`;

View File

@@ -0,0 +1,7 @@
const numToCommas = (num: number) => Intl.NumberFormat('en-US').format(num);
const EDUCATION_HOURS_PER_DOLLAR = 50;
export const CENTS_IN_DOLLAR = 100;
export const convertToTimeContributed = (amount: number) =>
numToCommas((amount / CENTS_IN_DOLLAR) * EDUCATION_HOURS_PER_DOLLAR);
export const formattedAmountLabel = (amount: number) =>
numToCommas(amount / CENTS_IN_DOLLAR);

View File

@@ -47,3 +47,14 @@ interface DefaultDonationFormState {
success: boolean;
error: null | string;
}
export interface DonateFormState {
processing: boolean;
redirecting: boolean;
success: boolean;
error: string;
loading: {
stripe: boolean;
paypal: boolean;
};
}

View File

@@ -1,37 +1,21 @@
// Configuration for client side
export type DonationAmount = 500 | 1000 | 2000 | 3000 | 4000 | 5000;
export type DonationAmount = 500 | 1000 | 2000 | 4000;
export type DonationDuration = 'one-time' | 'month';
export interface DonationConfig {
donationAmount: DonationAmount;
donationDuration: DonationDuration;
}
export const durationsConfig: {
month: 'monthly';
onetime: 'one-time';
} = {
month: 'monthly',
onetime: 'one-time'
};
export const subscriptionAmounts: DonationAmount[] = [500, 1000, 2000, 4000];
export const amountsConfig = {
month: [1000, 2000, 3000, 4000, 5000],
onetime: [2500, 5000, 7500, 10000, 15000]
};
export const defaultAmount: { month: 500; onetime: 7500 } = {
month: 500,
onetime: 7500
};
export const defaultDonation: DonationConfig = {
donationAmount: defaultAmount.month,
donationDuration: 'month'
};
export const modalDefaultDonation: DonationConfig = {
donationAmount: 500,
donationDuration: 'month'
};
export const defaultTierAmount = 2000;
export const onetimeSKUConfig = {
live: [
{ amount: '15000', id: 'sku_IElisJHup0nojP' },
@@ -127,13 +111,6 @@ export const donationUrls = {
cancelUrl: 'https://freecodecamp.org/donate'
};
export const patreonDefaultPledgeAmount = 500;
export const aBTestConfig = {
isTesting: true,
type: 'secureIconButtonOnly'
};
export enum PaymentContext {
Modal = 'modal',
DonatePage = 'donate page',