From 31f97afb2e4d02e9c9455cc11840f03d9632ec2a Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Thu, 14 Sep 2023 18:29:07 +0300 Subject: [PATCH] feat(client): AB test adding mutitier donation modal (#51539) Co-authored-by: Oliver Eyton-Williams --- client/i18n/locales/english/translations.json | 3 + .../client-only-routes/show-certification.tsx | 1 + .../src/components/Donation/donate-form.tsx | 108 +++++----- .../components/Donation/donation-modal.tsx | 202 ++++++++++++++---- client/src/components/Donation/donation.css | 43 +++- .../components/Donation/patreon-button.tsx | 16 +- client/src/components/Donation/utils.ts | 7 + client/src/redux/types.ts | 11 + shared/config/donation-settings.ts | 31 +-- 9 files changed, 281 insertions(+), 141 deletions(-) create mode 100644 client/src/components/Donation/utils.ts diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index e7a8dcba38c..0a3eb776f9c 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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:", diff --git a/client/src/client-only-routes/show-certification.tsx b/client/src/client-only-routes/show-certification.tsx index e4e9483d911..8e21c096660 100644 --- a/client/src/client-only-routes/show-certification.tsx +++ b/client/src/client-only-routes/show-certification.tsx @@ -267,6 +267,7 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => { /> + {isDonationSubmitted && donationCloseBtn} diff --git a/client/src/components/Donation/donate-form.tsx b/client/src/components/Donation/donate-form.tsx index 0ba25ddcb34..993fe7c908d 100644 --- a/client/src/components/Donation/donate-form.tsx +++ b/client/src/components/Donation/donate-form.tsx @@ -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 { 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 { 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 { } 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 { 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) + })} + + + ); + + const confirmationClass = () => { + if (editAmount) return 'edit-amount-confirmation'; + if (isMinimalForm) return 'donation-label-modal'; + return ''; + }; return ( <> - - {t('donate.confirm-monthly', { - usd: formattedAmountLabel(donationAmount) - })} + + {editAmount ? confirmationWithEditAmount : confirmationMessage} - +
@@ -263,7 +262,10 @@ class DonateForm extends Component { theme={priorityTheme} /> {(!loading.stripe || !loading.paypal) && ( - + )} {showMinimalPayments && ( <> @@ -283,18 +285,12 @@ class DonateForm extends Component { } 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 ( <>

{donationDescription}

diff --git a/client/src/components/Donation/donation-modal.tsx b/client/src/components/Donation/donation-modal.tsx index 6702437dad8..db509e61833 100644 --- a/client/src/components/Donation/donation-modal.tsx +++ b/client/src/components/Donation/donation-modal.tsx @@ -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 = (
-
- -
{!closeLabel && ( @@ -143,13 +158,146 @@ function DonateModal({ })}
)} - {t(`donate.progress-modal-cta-${ctaNumber}`)} + {showMultiTier ? ( +

{t('donate.help-us-develop')}

+ ) : ( + {t(`donate.progress-modal-cta-${ctaNumber}`)} + )} )} +
); + const closeButtonRow = ( + <> + + + + + + + ); + + const selectionTabs = ( + + + + {t('donate.confirm-monthly', { + usd: formattedAmountLabel(donationAmount) + })} + + + + + {subscriptionAmounts.map(value => ( + setDonationAmount(value)} + > + ${formattedAmountLabel(value)} + + ))} + + + {subscriptionAmounts.map(value => { + const usd = formattedAmountLabel(donationAmount); + const hours = convertToTimeContributed(donationAmount); + const donationDescription = t('donate.your-donation-2', { + usd, + hours + }); + + return ( + +

{donationDescription}

+
+ ); + })} +
+ + + +
+ ); + + const donationFormRow = ( + + + setShowDonateForm(false) : undefined + } + selectedDonationAmount={donationAmount} + /> + + + + ); + + const multiTierModalBody = ( + <> +
+ {modalHeader} + {selectionTabs} + {closeButtonRow} +
+
+ {donationFormRow} + {closeLabel && closeButtonRow} +
+ + ); + + const defaultModalBody = ( + <> + {modalHeader} + {donationFormRow} + {closeButtonRow} + + ); + return ( - {donationText} - - - - - - - - - - - - +
+ +
+ {showMultiTier ? multiTierModalBody : defaultModalBody}
); diff --git a/client/src/components/Donation/donation.css b/client/src/components/Donation/donation.css index 59d88904e9b..ffb31b9baf1 100644 --- a/client/src/components/Donation/donation.css +++ b/client/src/components/Donation/donation.css @@ -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; diff --git a/client/src/components/Donation/patreon-button.tsx b/client/src/components/Donation/patreon-button.tsx index 8507cd777c3..ac39b456af8 100644 --- a/client/src/components/Donation/patreon-button.tsx +++ b/client/src/components/Donation/patreon-button.tsx @@ -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}`; diff --git a/client/src/components/Donation/utils.ts b/client/src/components/Donation/utils.ts new file mode 100644 index 00000000000..e2d4519c971 --- /dev/null +++ b/client/src/components/Donation/utils.ts @@ -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); diff --git a/client/src/redux/types.ts b/client/src/redux/types.ts index c1f0cbf7fa5..2a0177fb1e3 100644 --- a/client/src/redux/types.ts +++ b/client/src/redux/types.ts @@ -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; + }; +} diff --git a/shared/config/donation-settings.ts b/shared/config/donation-settings.ts index 3b08af927c1..b6e0ed31150 100644 --- a/shared/config/donation-settings.ts +++ b/shared/config/donation-settings.ts @@ -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',