mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat: replace donate-form with multi-tier-donation-form (#52091)
This commit is contained in:
@@ -12,8 +12,6 @@ import envData from '../../config/env.json';
|
||||
import { getLangCode } from '../../../shared/config/i18n';
|
||||
import FreeCodeCampLogo from '../assets/icons/freecodecamp';
|
||||
import MicrosoftLogo from '../assets/icons/microsoft-logo';
|
||||
import DonateForm from '../components/Donation/donate-form';
|
||||
|
||||
import { createFlashMessage } from '../components/Flash/redux';
|
||||
import { Loader, Spacer } from '../components/helpers';
|
||||
import RedirectHome from '../components/redirect-home';
|
||||
@@ -41,6 +39,7 @@ import {
|
||||
certTypes,
|
||||
certTypeTitleMap
|
||||
} from '../../../shared/config/certification-settings';
|
||||
import MultiTierDonationForm from '../components/Donation/multi-tier-donation-form';
|
||||
import ShowProjectLinks from './show-project-links';
|
||||
|
||||
const { clientLocale } = envData;
|
||||
@@ -271,7 +270,7 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => {
|
||||
xs={12}
|
||||
data-playwright-test-label='donation-form'
|
||||
>
|
||||
<DonateForm
|
||||
<MultiTierDonationForm
|
||||
defaultTheme={Themes.Default}
|
||||
handleProcessing={handleProcessing}
|
||||
isMinimalForm={true}
|
||||
|
||||
@@ -27,11 +27,7 @@ import Spacer from '../helpers/spacer';
|
||||
import { Themes } from '../settings/theme';
|
||||
import { DonateFormState } from '../../redux/types';
|
||||
import type { CompletedChallenge } from '../../redux/prop-types';
|
||||
import {
|
||||
CENTS_IN_DOLLAR,
|
||||
convertToTimeContributed,
|
||||
formattedAmountLabel
|
||||
} from './utils';
|
||||
import { CENTS_IN_DOLLAR, formattedAmountLabel } from './utils';
|
||||
import DonateCompletion from './donate-completion';
|
||||
import PatreonButton from './patreon-button';
|
||||
import PaypalButton from './paypal-button';
|
||||
@@ -220,9 +216,6 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
const showMinimalPayments =
|
||||
isSignedIn && (isMinimalForm || !isDonating) && threeChallengesCompleted;
|
||||
|
||||
const confirmationMessage = t('donate.confirm-monthly', {
|
||||
usd: formattedAmountLabel(donationAmount)
|
||||
});
|
||||
const confirmationWithEditAmount = (
|
||||
<>
|
||||
{t('donate.confirm-multitier', {
|
||||
@@ -242,11 +235,12 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<b className={confirmationClass()}>
|
||||
{editAmount ? confirmationWithEditAmount : confirmationMessage}
|
||||
</b>
|
||||
<b className={confirmationClass()}>{confirmationWithEditAmount}</b>
|
||||
<Spacer size={editAmount ? 'small' : 'medium'} />
|
||||
<fieldset className={'donate-btn-group security-legend'}>
|
||||
<fieldset
|
||||
data-playwright-test-label='donation-form'
|
||||
className={'donate-btn-group security-legend'}
|
||||
>
|
||||
<legend>
|
||||
<SecurityLockIcon />
|
||||
{t('donate.secure-donation')}
|
||||
@@ -296,15 +290,8 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
||||
}
|
||||
|
||||
renderPageForm() {
|
||||
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 });
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className='donation-description'>{donationDescription}</p>
|
||||
<div>{this.renderButtonGroup()}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import { Modal, Button } from '@freecodecamp/react-bootstrap';
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsTrigger,
|
||||
TabsList,
|
||||
Col,
|
||||
Row
|
||||
} from '@freecodecamp/ui';
|
||||
import { Col, Row } from '@freecodecamp/ui';
|
||||
import { WindowLocation } from '@reach/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
import { useFeature } from '@growthbook/growthbook-react';
|
||||
import { goToAnchor } from 'react-scrollable-anchor';
|
||||
import { bindActionCreators, Dispatch, AnyAction } from 'redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import {
|
||||
PaymentContext,
|
||||
subscriptionAmounts,
|
||||
defaultDonation,
|
||||
defaultTierAmount,
|
||||
type DonationAmount
|
||||
} 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';
|
||||
@@ -33,8 +18,8 @@ import {
|
||||
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';
|
||||
import { PaymentContext } from '../../../../shared/config/donation-settings'; //
|
||||
import MultiTierDonationForm from './multi-tier-donation-form';
|
||||
|
||||
type RecentlyClaimedBlock = null | { block: string; superBlock: string };
|
||||
|
||||
@@ -77,321 +62,55 @@ const Illustration = ({
|
||||
);
|
||||
};
|
||||
|
||||
function getctaNumberBetween1To10() {
|
||||
const min = 1;
|
||||
const max = 10;
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function ModalHeader({
|
||||
closeLabel,
|
||||
ctaNumber,
|
||||
showMultiTier,
|
||||
recentlyClaimedBlock
|
||||
}: {
|
||||
closeLabel: boolean;
|
||||
ctaNumber: number;
|
||||
showMultiTier: boolean;
|
||||
recentlyClaimedBlock: RecentlyClaimedBlock;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='text-center block-modal-text'>
|
||||
<Row>
|
||||
{!closeLabel && (
|
||||
<Col sm={10} smOffset={1} xs={12}>
|
||||
{recentlyClaimedBlock !== null && (
|
||||
<b>
|
||||
{t('donate.nicely-done', {
|
||||
block: t(
|
||||
`intro:${recentlyClaimedBlock.superBlock}.blocks.${recentlyClaimedBlock.block}.title`
|
||||
)
|
||||
})}
|
||||
</b>
|
||||
)}
|
||||
{showMultiTier ? (
|
||||
<h2>{t('donate.help-us-develop')}</h2>
|
||||
) : (
|
||||
<b>{t(`donate.progress-modal-cta-${ctaNumber}`)}</b>
|
||||
)}
|
||||
</Col>
|
||||
<Row className='text-center block-modal-text'>
|
||||
<Col sm={10} smOffset={1} xs={12}>
|
||||
{recentlyClaimedBlock !== null && (
|
||||
<b>
|
||||
{t('donate.nicely-done', {
|
||||
block: t(
|
||||
`intro:${recentlyClaimedBlock.superBlock}.blocks.${recentlyClaimedBlock.block}.title`
|
||||
)
|
||||
})}
|
||||
</b>
|
||||
)}
|
||||
</Row>
|
||||
<Spacer size='small' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionTabs({
|
||||
loadElementsIndividually,
|
||||
donationAmount,
|
||||
setDonationAmount,
|
||||
setShowDonateForm
|
||||
}: {
|
||||
loadElementsIndividually: boolean;
|
||||
donationAmount: DonationAmount;
|
||||
setDonationAmount: React.Dispatch<React.SetStateAction<DonationAmount>>;
|
||||
setShowDonateForm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Row className={'donate-btn-group'}>
|
||||
<Col
|
||||
xs={12}
|
||||
{...(loadElementsIndividually && {
|
||||
className: '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' />
|
||||
<h2>{t('donate.help-us-develop')}</h2>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseButtonRow({
|
||||
showSkipButton,
|
||||
isDisabled,
|
||||
closeLabel,
|
||||
donationAttempted,
|
||||
closeDonationModal
|
||||
}: {
|
||||
showSkipButton: boolean;
|
||||
isDisabled: boolean;
|
||||
closeLabel: boolean;
|
||||
donationAttempted: boolean;
|
||||
closeDonationModal: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Row>
|
||||
<Col
|
||||
sm={4}
|
||||
smOffset={4}
|
||||
xs={8}
|
||||
xsOffset={2}
|
||||
className={showSkipButton ? 'no-delay-fade-in' : 'no-opacity'}
|
||||
>
|
||||
<Col sm={4} smOffset={4} xs={8} xsOffset={2}>
|
||||
<Button
|
||||
bsSize='sm'
|
||||
bsStyle='primary'
|
||||
className='btn-link close-button'
|
||||
onClick={closeDonationModal}
|
||||
tabIndex='0'
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{closeLabel ? t('buttons.close') : t('buttons.ask-later')}
|
||||
{donationAttempted ? t('buttons.close') : t('buttons.ask-later')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function DonationBody({
|
||||
closeLabel,
|
||||
ctaNumber,
|
||||
showMultiTier,
|
||||
recentlyClaimedBlock,
|
||||
donationAmount,
|
||||
loadElementsIndividually,
|
||||
handleProcessing,
|
||||
setShowDonateForm,
|
||||
closeDonationModal,
|
||||
isDisabled,
|
||||
showSkipButton
|
||||
}: {
|
||||
closeLabel: boolean;
|
||||
ctaNumber: number;
|
||||
showMultiTier: boolean;
|
||||
recentlyClaimedBlock: RecentlyClaimedBlock;
|
||||
donationAmount: DonationAmount;
|
||||
loadElementsIndividually: boolean;
|
||||
handleProcessing: () => void;
|
||||
setShowDonateForm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
closeDonationModal: () => void;
|
||||
isDisabled: boolean;
|
||||
showSkipButton: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ModalHeader
|
||||
closeLabel={closeLabel}
|
||||
ctaNumber={ctaNumber}
|
||||
recentlyClaimedBlock={recentlyClaimedBlock}
|
||||
showMultiTier={showMultiTier}
|
||||
/>
|
||||
<DonationFormRow
|
||||
donationAmount={donationAmount}
|
||||
handleProcessing={handleProcessing}
|
||||
showMultiTier={showMultiTier}
|
||||
loadElementsIndividually={loadElementsIndividually}
|
||||
setShowDonateForm={setShowDonateForm}
|
||||
/>
|
||||
<CloseButtonRow
|
||||
closeDonationModal={closeDonationModal}
|
||||
closeLabel={closeLabel}
|
||||
isDisabled={isDisabled}
|
||||
showSkipButton={showSkipButton}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DonationFormRow({
|
||||
handleProcessing,
|
||||
loadElementsIndividually,
|
||||
showMultiTier,
|
||||
setShowDonateForm,
|
||||
donationAmount
|
||||
}: {
|
||||
handleProcessing: () => void;
|
||||
loadElementsIndividually: boolean;
|
||||
showMultiTier: boolean;
|
||||
setShowDonateForm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
donationAmount: DonationAmount;
|
||||
}) {
|
||||
return (
|
||||
<Row>
|
||||
<Col
|
||||
xs={12}
|
||||
{...(loadElementsIndividually && {
|
||||
className: '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>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiTierDonationBody({
|
||||
closeLabel,
|
||||
ctaNumber,
|
||||
showMultiTier,
|
||||
recentlyClaimedBlock,
|
||||
donationAmount,
|
||||
loadElementsIndividually,
|
||||
setDonationAmount,
|
||||
setShowDonateForm,
|
||||
closeDonationModal,
|
||||
isDisabled,
|
||||
showSkipButton,
|
||||
showDonateForm,
|
||||
handleProcessing
|
||||
}: {
|
||||
closeLabel: boolean;
|
||||
ctaNumber: number;
|
||||
showMultiTier: boolean;
|
||||
recentlyClaimedBlock: RecentlyClaimedBlock;
|
||||
donationAmount: DonationAmount;
|
||||
loadElementsIndividually: boolean;
|
||||
setDonationAmount: React.Dispatch<React.SetStateAction<DonationAmount>>;
|
||||
setShowDonateForm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
closeDonationModal: () => void;
|
||||
isDisabled: boolean;
|
||||
showSkipButton: boolean;
|
||||
showDonateForm: boolean;
|
||||
handleProcessing: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div {...(showDonateForm && { className: 'hide' })}>
|
||||
<ModalHeader
|
||||
closeLabel={closeLabel}
|
||||
ctaNumber={ctaNumber}
|
||||
recentlyClaimedBlock={recentlyClaimedBlock}
|
||||
showMultiTier={showMultiTier}
|
||||
/>
|
||||
<SelectionTabs
|
||||
donationAmount={donationAmount}
|
||||
loadElementsIndividually={loadElementsIndividually}
|
||||
setDonationAmount={setDonationAmount}
|
||||
setShowDonateForm={setShowDonateForm}
|
||||
/>
|
||||
<CloseButtonRow
|
||||
closeDonationModal={closeDonationModal}
|
||||
closeLabel={closeLabel}
|
||||
isDisabled={isDisabled}
|
||||
showSkipButton={showSkipButton}
|
||||
/>
|
||||
</div>
|
||||
<div {...(!showDonateForm && { className: 'hide' })}>
|
||||
<DonationFormRow
|
||||
donationAmount={donationAmount}
|
||||
handleProcessing={handleProcessing}
|
||||
showMultiTier={showMultiTier}
|
||||
loadElementsIndividually={loadElementsIndividually}
|
||||
setShowDonateForm={setShowDonateForm}
|
||||
/>
|
||||
{closeLabel && (
|
||||
<CloseButtonRow
|
||||
closeDonationModal={closeDonationModal}
|
||||
closeLabel={closeLabel}
|
||||
isDisabled={isDisabled}
|
||||
showSkipButton={showSkipButton}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DonateModal({
|
||||
show,
|
||||
closeDonationModal,
|
||||
@@ -399,37 +118,13 @@ function DonateModal({
|
||||
location,
|
||||
recentlyClaimedBlock
|
||||
}: DonateModalProps): JSX.Element {
|
||||
const [closeLabel, setCloseLabel] = useState(false);
|
||||
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;
|
||||
|
||||
// test whether the conversions are being distributed properly
|
||||
useFeature('aa-test-in-component');
|
||||
const [donationAttempted, setDonationAttempted] = useState(false);
|
||||
const [showHeaderAndFooter, setShowHeaderAndFooter] = useState(true);
|
||||
|
||||
const handleProcessing = () => {
|
||||
setCloseLabel(true);
|
||||
setDonationAttempted(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (loadElementsIndividually) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsDisabled(false);
|
||||
setShowSkipButton(true);
|
||||
}, 4000);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setIsDisabled(false);
|
||||
setShowSkipButton(true);
|
||||
}
|
||||
}, [loadElementsIndividually]);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
void playTone('donation');
|
||||
@@ -443,17 +138,6 @@ function DonateModal({
|
||||
}
|
||||
}, [show, recentlyClaimedBlock, executeGA]);
|
||||
|
||||
useEffect(() => {
|
||||
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)) {
|
||||
@@ -472,35 +156,20 @@ function DonateModal({
|
||||
<div className='donation-icon-container'>
|
||||
<Illustration recentlyClaimedBlock={recentlyClaimedBlock} />
|
||||
</div>
|
||||
{showMultiTier ? (
|
||||
<MultiTierDonationBody
|
||||
recentlyClaimedBlock={recentlyClaimedBlock}
|
||||
setDonationAmount={setDonationAmount}
|
||||
setShowDonateForm={setShowDonateForm}
|
||||
showDonateForm={showDonateForm}
|
||||
showMultiTier={showMultiTier}
|
||||
showSkipButton={showSkipButton}
|
||||
{showHeaderAndFooter && !donationAttempted && (
|
||||
<ModalHeader recentlyClaimedBlock={recentlyClaimedBlock} />
|
||||
)}
|
||||
<Spacer size='small' />
|
||||
<MultiTierDonationForm
|
||||
setShowHeaderAndFooter={setShowHeaderAndFooter}
|
||||
handleProcessing={handleProcessing}
|
||||
paymentContext={PaymentContext.Modal}
|
||||
isMinimalForm={true}
|
||||
/>
|
||||
{(showHeaderAndFooter || donationAttempted) && (
|
||||
<CloseButtonRow
|
||||
closeDonationModal={closeDonationModal}
|
||||
closeLabel={closeLabel}
|
||||
ctaNumber={ctaNumber}
|
||||
donationAmount={donationAmount}
|
||||
handleProcessing={handleProcessing}
|
||||
isDisabled={isDisabled}
|
||||
loadElementsIndividually={loadElementsIndividually}
|
||||
/>
|
||||
) : (
|
||||
<DonationBody
|
||||
closeDonationModal={closeDonationModal}
|
||||
closeLabel={closeLabel}
|
||||
ctaNumber={ctaNumber}
|
||||
donationAmount={donationAmount}
|
||||
isDisabled={isDisabled}
|
||||
handleProcessing={handleProcessing}
|
||||
loadElementsIndividually={loadElementsIndividually}
|
||||
recentlyClaimedBlock={recentlyClaimedBlock}
|
||||
setShowDonateForm={setShowDonateForm}
|
||||
showMultiTier={showMultiTier}
|
||||
showSkipButton={showSkipButton}
|
||||
donationAttempted={donationAttempted}
|
||||
/>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
@@ -261,13 +261,6 @@ li.disabled > a {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.two-seconds-delay-fade-in {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
animation: opacity-animation 1s linear 100ms forwards;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.no-delay-fade-in {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -285,11 +278,6 @@ li.disabled > a {
|
||||
}
|
||||
}
|
||||
|
||||
.donation-modal .btn-link:focus {
|
||||
outline-width: 1px;
|
||||
outline-style: solid;
|
||||
}
|
||||
|
||||
.donation-modal .modal-title {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
@@ -335,15 +323,17 @@ li.disabled > a {
|
||||
}
|
||||
}
|
||||
|
||||
.donation-modal [role='tablist'] button {
|
||||
.donation-tier-selection [role='tablist'] button {
|
||||
background-color: transparent;
|
||||
}
|
||||
.donation-modal [role='tablist'] button:hover:not([data-state='active']) {
|
||||
.donation-tier-selection
|
||||
[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'] {
|
||||
.donation-tier-selection [role='tablist'] button[data-state='active'] {
|
||||
background-color: var(--quaternary-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '@freecodecamp/react-bootstrap';
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsTrigger,
|
||||
TabsList,
|
||||
Col,
|
||||
Row
|
||||
} from '@freecodecamp/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Spacer } from '../helpers';
|
||||
import {
|
||||
PaymentContext,
|
||||
subscriptionAmounts,
|
||||
defaultTierAmount,
|
||||
type DonationAmount
|
||||
} from '../../../../shared/config/donation-settings'; // You can further extract these into separate components and import them
|
||||
import { Themes } from '../settings/theme';
|
||||
import { formattedAmountLabel, convertToTimeContributed } from './utils';
|
||||
import DonateForm from './donate-form';
|
||||
|
||||
type MultiTierDonationFormProps = {
|
||||
setShowHeaderAndFooter?: (show: boolean) => void;
|
||||
handleProcessing?: () => void;
|
||||
paymentContext: PaymentContext;
|
||||
isMinimalForm?: boolean;
|
||||
defaultTheme?: Themes;
|
||||
};
|
||||
function SelectionTabs({
|
||||
donationAmount,
|
||||
setDonationAmount,
|
||||
setShowDonateForm
|
||||
}: {
|
||||
donationAmount: DonationAmount;
|
||||
setDonationAmount: React.Dispatch<React.SetStateAction<DonationAmount>>;
|
||||
setShowDonateForm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const switchTab = (value: string): void => {
|
||||
setDonationAmount(Number(value) as DonationAmount);
|
||||
};
|
||||
|
||||
return (
|
||||
<Row
|
||||
className={'donate-btn-group donation-tier-selection'}
|
||||
data-playwright-test-label='donation-tier-selector'
|
||||
>
|
||||
<Col xs={12}>
|
||||
<b>
|
||||
{t('donate.confirm-monthly', {
|
||||
usd: formattedAmountLabel(donationAmount)
|
||||
})}
|
||||
</b>
|
||||
<Spacer size='small' />
|
||||
<Tabs
|
||||
className={'donate-btn-group'}
|
||||
defaultValue={donationAmount.toString()}
|
||||
onValueChange={switchTab}
|
||||
>
|
||||
<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'
|
||||
data-cy='donation-tier-selection-button'
|
||||
onClick={() => setShowDonateForm(true)}
|
||||
>
|
||||
{t('buttons.donate')}
|
||||
</Button>
|
||||
<Spacer size='medium' />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function DonationFormRow({
|
||||
handleProcessing,
|
||||
isMinimalForm,
|
||||
setShowDonateForm,
|
||||
donationAmount
|
||||
}: {
|
||||
handleProcessing?: () => void;
|
||||
isMinimalForm?: boolean;
|
||||
setShowDonateForm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
donationAmount: DonationAmount;
|
||||
}) {
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<DonateForm
|
||||
handleProcessing={handleProcessing}
|
||||
isMinimalForm={isMinimalForm}
|
||||
paymentContext={PaymentContext.Modal}
|
||||
editAmount={() => setShowDonateForm(false)}
|
||||
selectedDonationAmount={donationAmount}
|
||||
/>
|
||||
<Spacer size='medium' />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
const MultiTierDonationForm: React.FC<MultiTierDonationFormProps> = ({
|
||||
handleProcessing,
|
||||
setShowHeaderAndFooter,
|
||||
isMinimalForm
|
||||
}) => {
|
||||
const [donationAmount, setDonationAmount] = useState(defaultTierAmount);
|
||||
|
||||
const [showDonateForm, setShowDonateForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (setShowHeaderAndFooter) setShowHeaderAndFooter(!showDonateForm);
|
||||
}, [showDonateForm, setShowHeaderAndFooter]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...(showDonateForm && { className: 'hide' })}>
|
||||
<SelectionTabs
|
||||
donationAmount={donationAmount}
|
||||
setDonationAmount={setDonationAmount}
|
||||
setShowDonateForm={setShowDonateForm}
|
||||
/>
|
||||
</div>
|
||||
<div {...(!showDonateForm && { className: 'hide' })}>
|
||||
<DonationFormRow
|
||||
donationAmount={donationAmount}
|
||||
handleProcessing={handleProcessing}
|
||||
setShowDonateForm={setShowDonateForm}
|
||||
isMinimalForm={isMinimalForm}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiTierDonationForm;
|
||||
@@ -163,6 +163,7 @@ const StripeCardForm = ({
|
||||
bsStyle='primary'
|
||||
className='confirm-donation-btn'
|
||||
disabled={!stripe || !elements || isSubmitting}
|
||||
data-cy='donation-confirmation-button'
|
||||
type='submit'
|
||||
>
|
||||
{t('buttons.donate')}
|
||||
|
||||
@@ -310,7 +310,6 @@ fieldset[disabled] .btn-primary.focus {
|
||||
text-decoration: underline;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
outline-color: transparent;
|
||||
}
|
||||
.btn-link:hover,
|
||||
.btn-link:focus:active {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { bindActionCreators } from 'redux';
|
||||
import type { Dispatch } from 'redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import DonateForm from '../components/Donation/donate-form';
|
||||
import MultiTierDonationForm from '../components/Donation/multi-tier-donation-form';
|
||||
import {
|
||||
DonationText,
|
||||
DonationOptionsAlertText,
|
||||
@@ -93,7 +93,7 @@ function DonatePage({
|
||||
</Alert>
|
||||
) : null}
|
||||
<DonationText />
|
||||
<DonateForm paymentContext={PaymentContext.DonatePage} />
|
||||
<MultiTierDonationForm paymentContext={PaymentContext.DonatePage} />
|
||||
<Spacer size='exLarge' />
|
||||
<hr />
|
||||
<h2 data-playwright-test-label='faq-head' className={'text-center'}>
|
||||
|
||||
@@ -3,7 +3,10 @@ describe('Donate page', () => {
|
||||
cy.visit('/donate');
|
||||
cy.title().should('eq', 'Support our charity | freeCodeCamp.org');
|
||||
|
||||
cy.contains('Confirm your donation of $5 / month:').should('be.visible');
|
||||
cy.contains('Confirm your donation of $20 / month:').should('be.visible');
|
||||
cy.contains(
|
||||
'Your $20 donation will provide 1,000 hours of learning to people around the world each month.'
|
||||
).should('be.visible');
|
||||
|
||||
cy.contains('Frequently asked questions');
|
||||
cy.contains('How can I get help with my donations?');
|
||||
|
||||
+3
-1
@@ -3,11 +3,13 @@ describe('Donate page', () => {
|
||||
cy.task('seed', ['certified-user']);
|
||||
cy.login('certified-user');
|
||||
cy.visit('/donate');
|
||||
cy.get("[data-cy='donation-tier-selection-button']").click();
|
||||
|
||||
cy.get('.donation-elements', { timeout: 10000 }).within(() => {
|
||||
cy.fillElementsInput('cardNumber', '4242424242424242');
|
||||
cy.fillElementsInput('cardExpiry', '1025');
|
||||
});
|
||||
cy.get('.confirm-donation-btn').click();
|
||||
cy.get("[data-cy='donation-confirmation-button']").click();
|
||||
cy.contains('We are processing your donation.').should('be.visible');
|
||||
cy.contains('Thank you for being a supporter.', { timeout: 10000 }).should(
|
||||
'be.visible'
|
||||
|
||||
@@ -15,7 +15,7 @@ test.describe('Certification page - Non Microsoft', () => {
|
||||
const donationText = donationSection.getByTestId('donation-text');
|
||||
await expect(donationText).toHaveText(translations.donate['only-you']);
|
||||
|
||||
const donationForm = donationSection.getByTestId('donation-form');
|
||||
const donationForm = donationSection.getByTestId('donation-tier-selector');
|
||||
await expect(donationForm).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ test.describe('Certification page - Microsoft', () => {
|
||||
const donationText = donationSection.getByTestId('donation-text');
|
||||
await expect(donationText).toHaveText(translations.donate['only-you']);
|
||||
|
||||
const donationForm = donationSection.getByTestId('donation-form');
|
||||
const donationForm = donationSection.getByTestId('donation-tier-selector');
|
||||
await expect(donationForm).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
test.use({ storageState: 'playwright/.auth/certified-user.json' });
|
||||
|
||||
const pageElements = {
|
||||
mainHeading: 'main-head',
|
||||
donateText1: 'donate-text-1',
|
||||
@@ -25,6 +27,30 @@ const frequentlyAskedQuestions = [
|
||||
translations.donate['anything-else']
|
||||
];
|
||||
|
||||
const donationStringReplacements = {
|
||||
usdPlaceHolder: '{{usd}}',
|
||||
hoursPlaceHolder: '{{hours}}'
|
||||
};
|
||||
|
||||
const donationFormStrings = {
|
||||
conformTwentyDollar: translations.donate['confirm-monthly'].replace(
|
||||
donationStringReplacements.usdPlaceHolder,
|
||||
'20'
|
||||
),
|
||||
confirmFiveDollars: translations.donate['confirm-monthly'].replace(
|
||||
donationStringReplacements.usdPlaceHolder,
|
||||
'5'
|
||||
),
|
||||
twentyDollarsLearningContribution: translations.donate['your-donation-2']
|
||||
.replace(donationStringReplacements.usdPlaceHolder, '20')
|
||||
.replace(donationStringReplacements.hoursPlaceHolder, '1,000'),
|
||||
fiveDollarsLearningContribution: translations.donate['your-donation-2']
|
||||
.replace(donationStringReplacements.usdPlaceHolder, '5')
|
||||
.replace(donationStringReplacements.hoursPlaceHolder, '250'),
|
||||
editAmount: translations.donate['edit-amount'],
|
||||
donate: translations.buttons.donate
|
||||
};
|
||||
|
||||
let page: Page;
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
@@ -38,6 +64,71 @@ test.describe('Donate Page', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should select $20 tier by default', async () => {
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.conformTwentyDollar)
|
||||
).toBeVisible();
|
||||
|
||||
const tabs = await page.$$('[role="tab"]');
|
||||
expect(tabs.length).toBe(4);
|
||||
|
||||
for (const tab of tabs) {
|
||||
const tabText = await tab.innerText();
|
||||
expect(['$5', '$10', '$20', '$40']).toContain(tabText);
|
||||
|
||||
if (tabText === '$20') {
|
||||
const isActive = await tab.getAttribute('data-state');
|
||||
expect(isActive).toBe('active');
|
||||
} else {
|
||||
const isActive = await tab.getAttribute('data-state');
|
||||
expect(isActive).not.toBe('active');
|
||||
}
|
||||
}
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.twentyDollarsLearningContribution)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should make $5 tier selectable', async () => {
|
||||
await page.click('[role="tab"]:has-text("$5")');
|
||||
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.confirmFiveDollars)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.fiveDollarsLearningContribution)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should switch between tier selection and payment options', async () => {
|
||||
// Tier selection
|
||||
await page.click('[role="tab"]:has-text("$5")');
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.confirmFiveDollars)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.fiveDollarsLearningContribution)
|
||||
).toBeVisible();
|
||||
await page.click(`button:has-text("${donationFormStrings.donate}")`);
|
||||
|
||||
// Donation form
|
||||
const isEditButtonVisible = await page.isVisible(
|
||||
`button:has-text("${donationFormStrings.editAmount}")`
|
||||
);
|
||||
expect(isEditButtonVisible).toBeTruthy();
|
||||
await expect(page.getByTestId('donation-form')).toBeVisible();
|
||||
await page.click(`button:has-text("${donationFormStrings.editAmount}")`);
|
||||
|
||||
// Tier selection
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.confirmFiveDollars)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.fiveDollarsLearningContribution)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display the main heading', async () => {
|
||||
const mainHeading = page.getByTestId(pageElements.mainHeading);
|
||||
await expect(mainHeading).toHaveText(translations.donate['help-more']);
|
||||
|
||||
@@ -14,7 +14,7 @@ export const defaultDonation: DonationConfig = {
|
||||
donationDuration: 'month'
|
||||
};
|
||||
|
||||
export const defaultTierAmount = 2000;
|
||||
export const defaultTierAmount: DonationAmount = 2000;
|
||||
|
||||
export const onetimeSKUConfig = {
|
||||
live: [
|
||||
|
||||
Reference in New Issue
Block a user