diff --git a/client/src/client-only-routes/show-certification.tsx b/client/src/client-only-routes/show-certification.tsx index d75653a2674..90fc17cd9c1 100644 --- a/client/src/client-only-routes/show-certification.tsx +++ b/client/src/client-only-routes/show-certification.tsx @@ -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' > - { 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 { }; return ( <> - - {editAmount ? confirmationWithEditAmount : confirmationMessage} - + {confirmationWithEditAmount} -
+
{t('donate.secure-donation')} @@ -296,15 +290,8 @@ class DonateForm extends Component { } 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 ( <> -

{donationDescription}

{this.renderButtonGroup()}
); diff --git a/client/src/components/Donation/donation-modal.tsx b/client/src/components/Donation/donation-modal.tsx index 3c458dfcb26..af16bdea636 100644 --- a/client/src/components/Donation/donation-modal.tsx +++ b/client/src/components/Donation/donation-modal.tsx @@ -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 ( -
- - {!closeLabel && ( - - {recentlyClaimedBlock !== null && ( - - {t('donate.nicely-done', { - block: t( - `intro:${recentlyClaimedBlock.superBlock}.blocks.${recentlyClaimedBlock.block}.title` - ) - })} - - )} - {showMultiTier ? ( -

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

- ) : ( - {t(`donate.progress-modal-cta-${ctaNumber}`)} - )} - + + + {recentlyClaimedBlock !== null && ( + + {t('donate.nicely-done', { + block: t( + `intro:${recentlyClaimedBlock.superBlock}.blocks.${recentlyClaimedBlock.block}.title` + ) + })} + )} - - -
- ); -} - -function SelectionTabs({ - loadElementsIndividually, - donationAmount, - setDonationAmount, - setShowDonateForm -}: { - loadElementsIndividually: boolean; - donationAmount: DonationAmount; - setDonationAmount: React.Dispatch>; - setShowDonateForm: React.Dispatch>; -}) { - const { t } = useTranslation(); - return ( - - - - {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}

-
- ); - })} -
- - +

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

); } function CloseButtonRow({ - showSkipButton, - isDisabled, - closeLabel, + donationAttempted, closeDonationModal }: { - showSkipButton: boolean; - isDisabled: boolean; - closeLabel: boolean; + donationAttempted: boolean; closeDonationModal: () => void; }) { const { t } = useTranslation(); return ( - + ); } -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>; - closeDonationModal: () => void; - isDisabled: boolean; - showSkipButton: boolean; -}) { - return ( - <> - - - - - ); -} - -function DonationFormRow({ - handleProcessing, - loadElementsIndividually, - showMultiTier, - setShowDonateForm, - donationAmount -}: { - handleProcessing: () => void; - loadElementsIndividually: boolean; - showMultiTier: boolean; - setShowDonateForm: React.Dispatch>; - donationAmount: DonationAmount; -}) { - return ( - - - setShowDonateForm(false) : undefined - } - selectedDonationAmount={donationAmount} - /> - - - - ); -} - -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>; - setShowDonateForm: React.Dispatch>; - closeDonationModal: () => void; - isDisabled: boolean; - showSkipButton: boolean; - showDonateForm: boolean; - handleProcessing: () => void; -}) { - return ( - <> -
- - - -
-
- - {closeLabel && ( - - )} -
- - ); -} - 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({
- {showMultiTier ? ( - + )} + + + {(showHeaderAndFooter || donationAttempted) && ( + - ) : ( - )} diff --git a/client/src/components/Donation/donation.css b/client/src/components/Donation/donation.css index 0beaa6cea1c..2b8346cd088 100644 --- a/client/src/components/Donation/donation.css +++ b/client/src/components/Donation/donation.css @@ -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); } diff --git a/client/src/components/Donation/multi-tier-donation-form.tsx b/client/src/components/Donation/multi-tier-donation-form.tsx new file mode 100644 index 00000000000..b69488c53ee --- /dev/null +++ b/client/src/components/Donation/multi-tier-donation-form.tsx @@ -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>; + setShowDonateForm: React.Dispatch>; +}) { + const { t } = useTranslation(); + const switchTab = (value: string): void => { + setDonationAmount(Number(value) as DonationAmount); + }; + + return ( + + + + {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}

+
+ ); + })} +
+ + + +
+ ); +} + +function DonationFormRow({ + handleProcessing, + isMinimalForm, + setShowDonateForm, + donationAmount +}: { + handleProcessing?: () => void; + isMinimalForm?: boolean; + setShowDonateForm: React.Dispatch>; + donationAmount: DonationAmount; +}) { + return ( + + + setShowDonateForm(false)} + selectedDonationAmount={donationAmount} + /> + + + + ); +} + +const MultiTierDonationForm: React.FC = ({ + handleProcessing, + setShowHeaderAndFooter, + isMinimalForm +}) => { + const [donationAmount, setDonationAmount] = useState(defaultTierAmount); + + const [showDonateForm, setShowDonateForm] = useState(false); + + useEffect(() => { + if (setShowHeaderAndFooter) setShowHeaderAndFooter(!showDonateForm); + }, [showDonateForm, setShowHeaderAndFooter]); + + return ( + <> +
+ +
+
+ +
+ + ); +}; + +export default MultiTierDonationForm; diff --git a/client/src/components/Donation/stripe-card-form.tsx b/client/src/components/Donation/stripe-card-form.tsx index 4cb0b56e399..3340d6b8447 100644 --- a/client/src/components/Donation/stripe-card-form.tsx +++ b/client/src/components/Donation/stripe-card-form.tsx @@ -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')} diff --git a/client/src/components/layouts/global.css b/client/src/components/layouts/global.css index 2d1eed25317..8a435130558 100644 --- a/client/src/components/layouts/global.css +++ b/client/src/components/layouts/global.css @@ -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 { diff --git a/client/src/pages/donate.tsx b/client/src/pages/donate.tsx index 3597c9eb7ec..9d700231d49 100644 --- a/client/src/pages/donate.tsx +++ b/client/src/pages/donate.tsx @@ -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({ ) : null} - +

diff --git a/cypress/e2e/default/learn/donate/donate-page-default.ts b/cypress/e2e/default/learn/donate/donate-page-default.ts index b9c9b1cea4a..9d9d56fdaf1 100644 --- a/cypress/e2e/default/learn/donate/donate-page-default.ts +++ b/cypress/e2e/default/learn/donate/donate-page-default.ts @@ -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?'); diff --git a/cypress/e2e/third-party/donate-page.ts b/cypress/e2e/third-party/donate-page.ts index 89172e1c07e..efc7d7f5459 100644 --- a/cypress/e2e/third-party/donate-page.ts +++ b/cypress/e2e/third-party/donate-page.ts @@ -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' diff --git a/e2e/certification.spec.ts b/e2e/certification.spec.ts index a0cd6412bb8..1ae9d25a919 100644 --- a/e2e/certification.spec.ts +++ b/e2e/certification.spec.ts @@ -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(); }); diff --git a/e2e/donate-page-default.spec.ts b/e2e/donate-page-default.spec.ts index 61b7c4c0273..d96ff309b67 100644 --- a/e2e/donate-page-default.spec.ts +++ b/e2e/donate-page-default.spec.ts @@ -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']); diff --git a/shared/config/donation-settings.ts b/shared/config/donation-settings.ts index b6e0ed31150..084538d35a5 100644 --- a/shared/config/donation-settings.ts +++ b/shared/config/donation-settings.ts @@ -14,7 +14,7 @@ export const defaultDonation: DonationConfig = { donationDuration: 'month' }; -export const defaultTierAmount = 2000; +export const defaultTierAmount: DonationAmount = 2000; export const onetimeSKUConfig = { live: [