fix(client): show donation modal on module completion (#57583)

Co-authored-by: ahmad abdolsaheb <ahmad.abdolsaheb@gmail.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Huyen Nguyen
2025-04-10 01:32:02 +07:00
committed by GitHub
parent 118b8d2e12
commit 07e708890a
20 changed files with 328 additions and 80 deletions
+2
View File
@@ -112,6 +112,8 @@ exports.createPages = async function createPages({
superOrder
template
usesMultifileEditor
chapter
module
}
}
}
@@ -3,20 +3,19 @@ import { useTranslation } from 'react-i18next';
import { useFeature } from '@growthbook/growthbook-react';
import { Col, Row, Modal, Spacer } from '@freecodecamp/ui';
import { closeDonationModal } from '../../redux/actions';
import { PaymentContext } from '../../../../shared/config/donation-settings'; //
import { PaymentContext } from '../../../../shared/config/donation-settings';
import donationAnimation from '../../assets/images/donation-bear-animation.svg';
import donationAnimationB from '../../assets/images/new-bear-animation.svg';
import supporterBear from '../../assets/images/supporter-bear.svg';
import callGA from '../../analytics/call-ga';
import MultiTierDonationForm from './multi-tier-donation-form';
import { ModalBenefitList } from './donation-text-components';
type RecentlyClaimedBlock = null | { block: string; superBlock: string };
import { DonatableSectionRecentlyCompleted } from './types';
type DonationModalBodyProps = {
activeDonors?: number;
closeDonationModal: typeof closeDonationModal;
recentlyClaimedBlock: RecentlyClaimedBlock;
donatableSectionRecentlyCompleted: DonatableSectionRecentlyCompleted;
setCanClose: (canClose: boolean) => void;
};
@@ -32,17 +31,19 @@ const Illustration = () => {
};
function ModalHeader({
recentlyClaimedBlock,
showHeaderAndFooter,
donationAttempted,
showForm
showForm,
donatableSectionRecentlyCompleted
}: {
recentlyClaimedBlock: RecentlyClaimedBlock;
donatableSectionRecentlyCompleted: DonatableSectionRecentlyCompleted;
showHeaderAndFooter: boolean;
donationAttempted: boolean;
showForm: boolean;
}) {
const { t } = useTranslation();
const { section, superBlock, title } =
donatableSectionRecentlyCompleted || {};
if (!showHeaderAndFooter || donationAttempted) {
return null;
@@ -50,19 +51,19 @@ function ModalHeader({
return (
<Row className='text-center'>
<Col sm={10} smOffset={1} xs={12}>
{recentlyClaimedBlock !== null && (
{donatableSectionRecentlyCompleted && (
<>
<b>
{t('donate.nicely-done', {
block: t(
`intro:${recentlyClaimedBlock.superBlock}.blocks.${recentlyClaimedBlock.block}.title`
)
block:
section === 'module'
? t(`intro:${superBlock}.${section}s.${title}`)
: t(`intro:${superBlock}.${section}s.${title}.title`)
})}
</b>
<Spacer size='xs' />
</>
)}
<Modal.Header showCloseButton={false} borderless>
{t('donate.modal-benefits-title')}
</Modal.Header>
@@ -188,16 +189,16 @@ const AnimationContainer = ({
};
const BecomeASupporterConfirmation = ({
recentlyClaimedBlock,
showHeaderAndFooter,
closeDonationModal,
donationAttempted,
showForm,
setShowHeaderAndFooter,
handleProcessing,
setShowForm
setShowForm,
donatableSectionRecentlyCompleted
}: {
recentlyClaimedBlock: RecentlyClaimedBlock;
donatableSectionRecentlyCompleted: DonatableSectionRecentlyCompleted;
showHeaderAndFooter: boolean;
closeDonationModal: () => void;
donationAttempted: boolean;
@@ -212,7 +213,7 @@ const BecomeASupporterConfirmation = ({
<Illustration />
</div>
<ModalHeader
recentlyClaimedBlock={recentlyClaimedBlock}
donatableSectionRecentlyCompleted={donatableSectionRecentlyCompleted}
showHeaderAndFooter={showHeaderAndFooter}
donationAttempted={donationAttempted}
showForm={showForm}
@@ -240,7 +241,7 @@ const BecomeASupporterConfirmation = ({
function DonationModalBody({
closeDonationModal,
recentlyClaimedBlock,
donatableSectionRecentlyCompleted,
setCanClose
}: DonationModalBodyProps): JSX.Element {
const [donationAttempted, setDonationAttempted] = useState(false);
@@ -273,7 +274,9 @@ function DonationModalBody({
<AnimationContainer secondsRemaining={secondsRemaining} />
) : (
<BecomeASupporterConfirmation
recentlyClaimedBlock={recentlyClaimedBlock}
donatableSectionRecentlyCompleted={
donatableSectionRecentlyCompleted
}
showHeaderAndFooter={showHeaderAndFooter}
closeDonationModal={closeDonationModal}
donationAttempted={donationAttempted}
@@ -10,21 +10,24 @@ import { useFeature } from '@growthbook/growthbook-react';
import { closeDonationModal } from '../../redux/actions';
import {
isDonationModalOpenSelector,
recentlyClaimedBlockSelector
donatableSectionRecentlyCompletedSelector
} from '../../redux/selectors';
import { isLocationSuperBlock } from '../../utils/path-parsers';
import { playTone } from '../../utils/tone';
import callGA from '../../analytics/call-ga';
import { DonatableSectionRecentlyCompleted } from './types';
import DonationModalBody from './donation-modal-body';
type RecentlyClaimedBlock = null | { block: string; superBlock: string };
const mapStateToProps = createSelector(
isDonationModalOpenSelector,
recentlyClaimedBlockSelector,
(show: boolean, recentlyClaimedBlock: RecentlyClaimedBlock) => ({
donatableSectionRecentlyCompletedSelector,
(
show: boolean,
donatableSectionRecentlyCompleted: DonatableSectionRecentlyCompleted
) => ({
show,
recentlyClaimedBlock
donatableSectionRecentlyCompleted
})
);
@@ -35,7 +38,7 @@ type DonateModalProps = {
activeDonors?: number;
closeDonationModal: typeof closeDonationModal;
location?: WindowLocation;
recentlyClaimedBlock: RecentlyClaimedBlock;
donatableSectionRecentlyCompleted: DonatableSectionRecentlyCompleted;
show: boolean;
};
@@ -43,7 +46,7 @@ function DonateModal({
show,
closeDonationModal,
location,
recentlyClaimedBlock
donatableSectionRecentlyCompleted
}: DonateModalProps): JSX.Element {
const [canClose, setCanClose] = useState(false);
const isA11yFeatureEnabled = useFeature('a11y-donation-modal').on;
@@ -55,11 +58,11 @@ function DonateModal({
callGA({
event: 'donation_view',
action: `Displayed ${
recentlyClaimedBlock !== null ? 'Block' : 'Progress'
donatableSectionRecentlyCompleted !== null ? 'Block' : 'Progress'
} Donation Modal`
});
}
}, [show, recentlyClaimedBlock]);
}, [show, donatableSectionRecentlyCompleted]);
const handleModalHide = () => {
// If modal is open on a SuperBlock page
@@ -76,7 +79,7 @@ function DonateModal({
<Modal size='large' onClose={handleModalHide} open={show}>
<DonationModalBody
closeDonationModal={closeDonationModal}
recentlyClaimedBlock={recentlyClaimedBlock}
donatableSectionRecentlyCompleted={donatableSectionRecentlyCompleted}
setCanClose={setCanClose}
/>
</Modal>
+7
View File
@@ -1,4 +1,5 @@
import type { PaymentIntentResult } from '@stripe/stripe-js';
import { SuperBlocks } from '../../../../shared/config/curriculum';
export type PaymentContext = 'modal' | 'donate page' | 'certificate';
export type PaymentProvider = 'patreon' | 'paypal' | 'stripe' | 'stripe card';
@@ -28,3 +29,9 @@ export interface DonationApprovalData {
paypal: boolean;
};
}
export type DonatableSectionRecentlyCompleted = null | {
section: string;
title: string;
superBlock: SuperBlocks;
};
+2 -2
View File
@@ -21,7 +21,8 @@ import './map.css';
import {
isSignedInSelector,
currentCertsSelector
currentCertsSelector,
completedChallengesIdsSelector
} from '../../redux/selectors';
import { RibbonIcon } from '../../assets/icons/completion-ribbon';
@@ -31,7 +32,6 @@ import {
certSlugTypeMap,
superBlockCertTypeMap
} from '../../../../shared/config/certification-settings';
import { completedChallengesIdsSelector } from '../../templates/Challenges/redux/selectors';
interface MapProps {
forLanding?: boolean;
+2 -2
View File
@@ -9,9 +9,9 @@ export const actionTypes = createTypes(
'toggleTheme',
'appMount',
'hardGoTo',
'allowBlockDonationRequests',
'allowSectionDonationRequests',
'setRenderStartTime',
'preventBlockDonationRequests',
'preventSectionDonationRequests',
'setIsRandomCompletionThreshold',
'openDonationModal',
'closeDonationModal',
+4 -4
View File
@@ -8,14 +8,14 @@ export const tryToShowDonationModal = createAction(
actionTypes.tryToShowDonationModal
);
export const allowBlockDonationRequests = createAction(
actionTypes.allowBlockDonationRequests
export const allowSectionDonationRequests = createAction(
actionTypes.allowSectionDonationRequests
);
export const setRenderStartTime = createAction(actionTypes.setRenderStartTime);
export const closeDonationModal = createAction(actionTypes.closeDonationModal);
export const openDonationModal = createAction(actionTypes.openDonationModal);
export const preventBlockDonationRequests = createAction(
actionTypes.preventBlockDonationRequests
export const preventSectionDonationRequests = createAction(
actionTypes.preventSectionDonationRequests
);
export const setIsRandomCompletionThreshold = createAction(
actionTypes.setIsRandomCompletionThreshold
+19 -14
View File
@@ -23,19 +23,20 @@ import {
getSessionChallengeData,
saveCurrentCount
} from '../utils/session-storage';
import { actionTypes as appTypes } from './action-types';
import {
openDonationModal,
postChargeComplete,
postChargeProcessing,
postChargeError,
preventBlockDonationRequests,
preventSectionDonationRequests,
updateCardError,
updateCardRedirecting
} from './actions';
import {
isDonatingSelector,
recentlyClaimedBlockSelector,
donatableSectionRecentlyCompletedSelector,
shouldRequestDonationSelector,
isSignedInSelector,
completedChallengesSelector
@@ -46,27 +47,31 @@ const updateCardErrorMessage = i18next.t('donate.error-3');
function* showDonateModalSaga() {
let shouldRequestDonation = yield select(shouldRequestDonationSelector);
const recentlyClaimedBlock = yield select(recentlyClaimedBlockSelector);
const MODAL_SHOWN_KEY = 'modalShownTimestamp';
const modalShownTimestamp = sessionStorage.getItem(MODAL_SHOWN_KEY);
const isModalRecentlyShown = Date.now() - modalShownTimestamp < 20000;
if (
shouldRequestDonation &&
recentlyClaimedBlock &&
chapterBasedSuperBlocks.includes(recentlyClaimedBlock.superBlock)
) {
yield put(preventBlockDonationRequests());
} else if (shouldRequestDonation || isModalRecentlyShown) {
// If the modal has been shown in the last 20 seconds, the animation should
// still be running:
const isAnimationRunning = Date.now() - modalShownTimestamp < 20000;
const shouldShowModal = shouldRequestDonation || isAnimationRunning;
const donatableSectionRecentlyCompleted = yield select(
donatableSectionRecentlyCompletedSelector
);
if (shouldShowModal) {
yield delay(200);
yield put(openDonationModal());
sessionStorage.setItem(MODAL_SHOWN_KEY, Date.now());
yield take(appTypes.closeDonationModal);
if (recentlyClaimedBlock) {
yield put(preventBlockDonationRequests());
} else {
if (!donatableSectionRecentlyCompleted) {
yield call(saveCurrentCount);
}
}
/* users can complete donatable section but have less than 10 completed challenge
to show the donation modal.*/
if (donatableSectionRecentlyCompleted) {
yield put(preventSectionDonationRequests());
}
}
export function* postChargeSaga({
+9 -5
View File
@@ -52,7 +52,7 @@ export const defaultDonationFormState = {
const initialState = {
appUsername: '',
isRandomCompletionThreshold: false,
recentlyClaimedBlock: null,
donatableSectionRecentlyCompleted: null,
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
examInProgress: false,
isProcessing: false,
@@ -138,10 +138,14 @@ export const reducer = handleActions(
}
};
},
[actionTypes.allowBlockDonationRequests]: (state, { payload }) => {
[actionTypes.allowSectionDonationRequests]: (state, { payload }) => {
return {
...state,
recentlyClaimedBlock: payload
donatableSectionRecentlyCompleted: {
block: payload.block,
module: payload.module,
superBlock: payload.superBlock
}
};
},
[actionTypes.setRenderStartTime]: (state, { payload }) => {
@@ -277,9 +281,9 @@ export const reducer = handleActions(
...state,
showDonationModal: true
}),
[actionTypes.preventBlockDonationRequests]: state => ({
[actionTypes.preventSectionDonationRequests]: state => ({
...state,
recentlyClaimedBlock: null
donatableSectionRecentlyCompleted: null
}),
[actionTypes.setIsRandomCompletionThreshold]: (state, { payload }) => ({
...state,
+1
View File
@@ -391,6 +391,7 @@ export type ChallengeMeta = {
superBlock: SuperBlocks;
title?: string;
challengeType?: number;
blockType?: BlockTypes;
helpCategory: string;
disableLoopProtectTests: boolean;
disableLoopProtectPreview: boolean;
+90 -5
View File
@@ -1,4 +1,7 @@
import { createSelector } from 'reselect';
import { Certification } from '../../../shared/config/certification-settings';
import superBlockStructure from '../../../curriculum/superblock-structure/full-stack.json';
import { randomBetween } from '../utils/random-between';
import { getSessionChallengeData } from '../utils/session-storage';
import { ns as MainApp } from './action-types';
@@ -22,8 +25,20 @@ export const isDonationModalOpenSelector = state =>
state[MainApp].showDonationModal;
export const isSignoutModalOpenSelector = state =>
state[MainApp].showSignoutModal;
export const recentlyClaimedBlockSelector = state =>
state[MainApp].recentlyClaimedBlock;
export const donatableSectionRecentlyCompletedSelector = state => {
const donatableSectionRecentlyCompletedState =
state[MainApp].donatableSectionRecentlyCompleted;
if (donatableSectionRecentlyCompletedState) {
const { block, module, superBlock } =
donatableSectionRecentlyCompletedState;
if (module) return { section: 'module', title: module, superBlock };
else if (block) return { section: 'block', title: block, superBlock };
}
return null;
};
export const donationFormStateSelector = state =>
state[MainApp].donationFormState;
export const updateCardStateSelector = state => state[MainApp].updateCardState;
@@ -35,7 +50,8 @@ export const showCertFetchStateSelector = state =>
export const shouldRequestDonationSelector = state => {
const completedChallengeCount = completedChallengesSelector(state).length;
const isDonating = isDonatingSelector(state);
const recentlyClaimedBlock = recentlyClaimedBlockSelector(state);
const donatableSectionRecentlyCompleted =
donatableSectionRecentlyCompletedSelector(state);
const isRandomCompletionThreshold =
isRandomCompletionThresholdSelector(state);
@@ -46,8 +62,8 @@ export const shouldRequestDonationSelector = state => {
// not before the 11th challenge has mounted)
if (completedChallengeCount < 10) return false;
// a block has been completed
if (recentlyClaimedBlock) return true;
// a block or module has been completed
if (donatableSectionRecentlyCompleted) return true;
const sessionChallengeData = getSessionChallengeData();
/*
@@ -256,6 +272,75 @@ export const certificatesByNameSelector = username => state => {
export const userFetchStateSelector = state => state[MainApp].userFetchState;
export const allChallengesInfoSelector = state =>
state[MainApp].allChallengesInfo;
export const completedChallengesIdsSelector = createSelector(
completedChallengesSelector,
completedChallenges => completedChallenges.map(node => node.id)
);
export const completionStateSelector = createSelector(
[allChallengesInfoSelector, completedChallengesIdsSelector],
(allChallengesInfo, completedChallengesIds) => {
const chapters = superBlockStructure.chapters;
const { challengeNodes } = allChallengesInfo;
const getCompletionState = ({
chapters,
challenges,
completedChallengesIds
}) => {
const populateBlocks = blocks =>
blocks.map(block => {
const blockChallenges = challenges.filter(
({ block: blockName }) => blockName === block.dashedName
);
const completedBlockChallenges = blockChallenges.every(({ id }) =>
completedChallengesIds.includes(id)
);
return {
name: block.dashedName,
isCompleted:
completedBlockChallenges.length === blockChallenges.length
};
});
const populateModules = modules =>
modules.map(module => {
const blocks = populateBlocks(module.blocks);
const isCompleted = blocks.every(block => block.isCompleted === true);
return {
name: module.dashedName,
blocks,
isCompleted
};
});
const allChapters = chapters.map(chapter => {
const modules = populateModules(chapter.modules);
const isCompleted = modules.every(
module => module.isCompleted === true
);
return {
name: chapter.dashedName,
modules: populateModules(chapter.modules),
isCompleted
};
});
return allChapters;
};
return getCompletionState({
chapters,
challenges: challengeNodes.map(({ challenge }) => challenge),
completedChallengesIds
});
}
);
export const userProfileFetchStateSelector = state =>
state[MainApp].userProfileFetchState;
export const usernameSelector = state => state[MainApp].appUsername;
@@ -7,11 +7,13 @@ import { createSelector } from 'reselect';
import { Button, Modal, Spacer } from '@freecodecamp/ui';
import Login from '../../../components/Header/components/login';
import { isSignedInSelector } from '../../../redux/selectors';
import {
isSignedInSelector,
completedChallengesIdsSelector
} from '../../../redux/selectors';
import { ChallengeFiles } from '../../../redux/prop-types';
import { closeModal, submitChallenge } from '../redux/actions';
import {
completedChallengesIdsSelector,
isCompletionModalOpenSelector,
successMessageSelector,
challengeFilesSelector,
@@ -118,6 +118,7 @@ const ShowGeneric = ({
title,
challengeType,
helpCategory,
blockType,
...challengePaths
});
challengeMounted(challengeMeta.id);
@@ -21,7 +21,7 @@ import {
} from '../../../../../shared/config/challenge-types';
import { actionTypes as submitActionTypes } from '../../../redux/action-types';
import {
allowBlockDonationRequests,
allowSectionDonationRequests,
setIsProcessing,
setRenderStartTime,
submitComplete,
@@ -32,6 +32,7 @@ import { isSignedInSelector, userSelector } from '../../../redux/selectors';
import { mapFilesToChallengeFiles } from '../../../utils/ajax';
import { standardizeRequestBody } from '../../../utils/challenge-request-helpers';
import postUpdate$ from '../utils/post-update';
import { SuperBlocks } from '../../../../../shared/config/curriculum';
import { actionTypes } from './action-types';
import {
closeModal,
@@ -46,7 +47,8 @@ import {
challengeTestsSelector,
userCompletedExamSelector,
projectFormValuesSelector,
isBlockNewlyCompletedSelector
isBlockNewlyCompletedSelector,
isModuleNewlyCompletedSelector
} from './selectors';
function postChallenge(update) {
@@ -230,7 +232,9 @@ export default function completionEpic(action$, state$) {
nextChallengePath,
challengeType,
superBlock,
blockType,
block,
module,
blockHashSlug
} = challengeMetaSelector(state);
// Default to submitChallengeComplete since we do not want the user to
@@ -254,19 +258,30 @@ export default function completionEpic(action$, state$) {
? blockHashSlug
: nextChallengePath;
const canAllowDonationRequest = (state, action) =>
isBlockNewlyCompletedSelector(state) &&
action.type === submitActionTypes.submitComplete;
const canAllowDonationRequest = (state, action) => {
if (action.type !== submitActionTypes.submitComplete) return null;
const donationData =
superBlock === SuperBlocks.FullStackDeveloper &&
blockType !== 'review' &&
isModuleNewlyCompletedSelector(state)
? { module, superBlock }
: superBlock !== SuperBlocks.FullStackDeveloper &&
isBlockNewlyCompletedSelector(state)
? { block, superBlock }
: null;
return donationData ? allowSectionDonationRequests(donationData) : null;
};
return submitter(type, state).pipe(
concat(
of(setIsAdvancing(!isLastChallengeInBlock), setIsProcessing(false))
),
mergeMap(x =>
canAllowDonationRequest(state, x)
? of(x, allowBlockDonationRequests({ superBlock, block }))
: of(x)
),
mergeMap(x => {
const donationAction = canAllowDonationRequest(state, x);
return donationAction ? of(x, donationAction) : of(x);
}),
mergeMap(x => of(x, setRenderStartTime(Date.now()))),
tap(res => {
if (res.type !== submitActionTypes.updateFailed) {
@@ -22,6 +22,7 @@ const initialState = {
superBlock: '',
block: '',
blockHashSlug: '/',
blockType: '',
id: '',
isLastChallengeInBlock: false,
nextChallengePath: '/',
@@ -3,7 +3,9 @@ import { challengeTypes } from '../../../../../shared/config/challenge-types';
import {
completedChallengesSelector,
allChallengesInfoSelector,
isSignedInSelector
isSignedInSelector,
completionStateSelector,
completedChallengesIdsSelector
} from '../../../redux/selectors';
import {
getCurrentBlockIds,
@@ -17,10 +19,6 @@ export const challengeMetaSelector = state => state[ns].challengeMeta;
export const challengeHooksSelector = state => state[ns].challengeHooks;
export const challengeTestsSelector = state => state[ns].challengeTests;
export const consoleOutputSelector = state => state[ns].consoleOut;
export const completedChallengesIdsSelector = createSelector(
completedChallengesSelector,
completedChallenges => completedChallenges.map(node => node.id)
);
export const isChallengeCompletedSelector = createSelector(
[completedChallengesIdsSelector, challengeMetaSelector],
(ids, meta) => ids.includes(meta.id)
@@ -152,6 +150,27 @@ export const isBlockNewlyCompletedSelector = state => {
return completedPercentage === 100 && !completedChallengesIds.includes(id);
};
export const isModuleNewlyCompletedSelector = state => {
const isBlockNewlyCompleted = isBlockNewlyCompletedSelector(state);
const { chapter, module, block } = challengeMetaSelector(state);
if (!isBlockNewlyCompleted || !chapter || !module) return;
const completionState = completionStateSelector(state);
const incompleteBlocksInModule = completionState
.find(({ name }) => name === chapter)
?.modules.find(({ name }) => name === module)
?.blocks.filter(({ isCompleted }) => !isCompleted);
// The module is completed if the newly completed block
// is the last block that has `isCompleted === false`.
return (
incompleteBlocksInModule?.length === 1 &&
incompleteBlocksInModule.some(({ name }) => name === block)
);
};
export const attemptsSelector = state => state[ns].attempts;
export const canFocusEditorSelector = state => state[ns].canFocusEditor;
export const visibleEditorsSelector = state => state[ns].visibleEditors;
@@ -47,7 +47,7 @@ interface Challenge {
superBlock: SuperBlocks;
}
interface SuperBlockTreeViewProps {
interface SuperBlockAccordionPropsViewProps {
challenges: Challenge[];
superBlock: SuperBlocks;
chosenBlock: string;
@@ -222,7 +222,7 @@ export const SuperBlockAccordion = ({
superBlock,
chosenBlock,
completedChallengeIds
}: SuperBlockTreeViewProps) => {
}: SuperBlockAccordionPropsViewProps) => {
const { t } = useTranslation();
const { allChapters } = useMemo(() => {
const populateBlocks = (blocks: { dashedName: string }[]) =>
@@ -86,6 +86,8 @@ exports.createChallengePages = function (
disableLoopProtectPreview,
certification,
superBlock,
chapter,
module,
block,
fields: { slug, blockHashSlug },
required = [],
@@ -108,6 +110,8 @@ exports.createChallengePages = function (
disableLoopProtectTests,
disableLoopProtectPreview,
superBlock,
chapter,
module,
block,
isFirstStep: getIsFirstStepInBlock(index, allChallengeEdges),
template,
+84
View File
@@ -313,6 +313,90 @@ test.describe('Donation modal appearance logic - Certified user claiming a new b
await completeFrontEndCert(page, 1);
await expect(donationModal).toBeHidden();
});
test("should not appear if the user has completed a new FSD block, but the block's module is not completed", async ({
page
}) => {
await page.goto(
'/learn/full-stack-developer/review-basic-html/basic-html-review'
);
await page.getByRole('checkbox', { name: /Review/ }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page.getByRole('button', { name: /Submit and go/ }).click();
const donationModal = page
.getByRole('dialog')
.filter({ hasText: 'Become a Supporter' });
await expect(donationModal).toBeHidden();
});
test('should not appear if FSD review module is completed', async ({
page
}) => {
await page.goto('/learn/full-stack-developer/review-html/review-html');
await page.getByRole('checkbox', { name: /Review/ }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await page.getByRole('button', { name: /Submit and go/ }).click();
await page.waitForTimeout(1000);
const donationModal = page
.getByRole('dialog')
.filter({ hasText: 'Become a Supporter' });
await expect(donationModal).toBeHidden();
});
});
test.describe('Donation modal appearance logic - Certified user claiming a new module', () => {
test.use({ storageState: 'playwright/.auth/certified-user.json' });
execSync('node ./tools/scripts/seed/seed-demo-user --almost-certified-user');
test('should appear if the user has just completed a new module', async ({
page
}) => {
test.setTimeout(40000);
// Go to the last lecture of the Welcome to freeCodeCamp block.
// This lecture is not added to the seed data, so it is not completed.
// By completing this lecture, we claim both the block and its module.
await page.goto(
'/learn/full-stack-developer/lecture-welcome-to-freecodecamp/how-can-you-build-effective-learning-habits-and-work-smarter'
);
// Wait for the page content to render
// TODO: Change the selector to `getByRole('radiogroup')` when we have migrated the MCQ component to fcc/ui
await expect(page.locator("div[class='video-quiz-options']")).toHaveCount(
3
);
const radioGroups = await page
.locator("div[class='video-quiz-options']")
.all();
await radioGroups[0].getByRole('radio').nth(2).click({ force: true });
await radioGroups[1].getByRole('radio').nth(2).click({ force: true });
await radioGroups[2].getByRole('radio').nth(3).click({ force: true });
await page.getByRole('button', { name: /Check your answer/ }).click();
await page.getByRole('button', { name: /Submit and go/ }).click();
const donationModal = page
.getByRole('dialog')
.filter({ hasText: 'Become a Supporter' });
await expect(donationModal).toBeVisible();
await expect(
donationModal.getByText(
'This is a 20 second animated advertisement to encourage campers to become supporters of freeCodeCamp. The animation starts with a teddy bear who becomes a supporter. As a result, distracting pop-ups disappear and the bear gets to complete all of its goals. Then, it graduates and becomes an education super hero helping people around the world.'
)
).toBeVisible();
// Second part of the modal.
// Use `slowExpect` as we need to wait 20s for this part to show up.
await slowExpect(
donationModal.getByText(
'Nicely done. You just completed Getting Started with freeCodeCamp.'
)
).toBeVisible();
});
});
test.describe('Donation modal appearance logic - Certified user', () => {
+12
View File
@@ -12251,6 +12251,18 @@ module.exports.fullyCertifiedUser = {
id: '671144cdcc01d73f7dd79dc9',
challengeType: 0,
files: []
},
{
completedDate: 1729240849345,
id: '6734ddabad59e593a49afafe'
},
{
completedDate: 1729240849345,
id: '6734e2c5780912abd874e79c'
},
{
completedDate: 1729240849345,
id: '6763500bd5a85d5898cc21a9'
}
],
completedExams: [