From 07e708890af7c003df61641d5fb02799f8b1f1fd Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Thu, 10 Apr 2025 01:32:02 +0700 Subject: [PATCH] fix(client): show donation modal on module completion (#57583) Co-authored-by: ahmad abdolsaheb Co-authored-by: Oliver Eyton-Williams Co-authored-by: Tom <20648924+moT01@users.noreply.github.com> --- client/gatsby-node.js | 2 + .../Donation/donation-modal-body.tsx | 39 ++++---- .../components/Donation/donation-modal.tsx | 25 ++--- client/src/components/Donation/types.ts | 7 ++ client/src/components/Map/index.tsx | 4 +- client/src/redux/action-types.js | 4 +- client/src/redux/actions.ts | 8 +- client/src/redux/donation-saga.js | 33 ++++--- client/src/redux/index.js | 14 ++- client/src/redux/prop-types.ts | 1 + client/src/redux/selectors.js | 95 ++++++++++++++++++- .../components/completion-modal.tsx | 6 +- .../src/templates/Challenges/generic/show.tsx | 1 + .../Challenges/redux/completion-epic.js | 35 +++++-- .../src/templates/Challenges/redux/index.js | 1 + .../templates/Challenges/redux/selectors.js | 29 +++++- .../components/super-block-accordion.tsx | 4 +- client/utils/gatsby/challenge-page-creator.js | 4 + e2e/donation-modal.spec.ts | 84 ++++++++++++++++ tools/scripts/seed/user-data.js | 12 +++ 20 files changed, 328 insertions(+), 80 deletions(-) diff --git a/client/gatsby-node.js b/client/gatsby-node.js index d111a86cc4e..8c6e587d802 100644 --- a/client/gatsby-node.js +++ b/client/gatsby-node.js @@ -112,6 +112,8 @@ exports.createPages = async function createPages({ superOrder template usesMultifileEditor + chapter + module } } } diff --git a/client/src/components/Donation/donation-modal-body.tsx b/client/src/components/Donation/donation-modal-body.tsx index 3d6f98088cc..02d4b86731e 100644 --- a/client/src/components/Donation/donation-modal-body.tsx +++ b/client/src/components/Donation/donation-modal-body.tsx @@ -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 ( - {recentlyClaimedBlock !== null && ( + {donatableSectionRecentlyCompleted && ( <> {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`) })} )} - {t('donate.modal-benefits-title')} @@ -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 = ({ ) : ( ({ + 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({ diff --git a/client/src/components/Donation/types.ts b/client/src/components/Donation/types.ts index 454780e4f92..105032ef751 100644 --- a/client/src/components/Donation/types.ts +++ b/client/src/components/Donation/types.ts @@ -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; +}; diff --git a/client/src/components/Map/index.tsx b/client/src/components/Map/index.tsx index 4e79b0a4ca6..e41ec99db66 100644 --- a/client/src/components/Map/index.tsx +++ b/client/src/components/Map/index.tsx @@ -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; diff --git a/client/src/redux/action-types.js b/client/src/redux/action-types.js index f7b36b41d84..0eb33759224 100644 --- a/client/src/redux/action-types.js +++ b/client/src/redux/action-types.js @@ -9,9 +9,9 @@ export const actionTypes = createTypes( 'toggleTheme', 'appMount', 'hardGoTo', - 'allowBlockDonationRequests', + 'allowSectionDonationRequests', 'setRenderStartTime', - 'preventBlockDonationRequests', + 'preventSectionDonationRequests', 'setIsRandomCompletionThreshold', 'openDonationModal', 'closeDonationModal', diff --git a/client/src/redux/actions.ts b/client/src/redux/actions.ts index 9c70e4a5a42..ebc23b054e9 100644 --- a/client/src/redux/actions.ts +++ b/client/src/redux/actions.ts @@ -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 diff --git a/client/src/redux/donation-saga.js b/client/src/redux/donation-saga.js index c9ed377528a..4646e0b8cfd 100644 --- a/client/src/redux/donation-saga.js +++ b/client/src/redux/donation-saga.js @@ -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({ diff --git a/client/src/redux/index.js b/client/src/redux/index.js index 2d3b7901db3..532d05435a0 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -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, diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index 96550a2b556..1dc7e68f0f7 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -391,6 +391,7 @@ export type ChallengeMeta = { superBlock: SuperBlocks; title?: string; challengeType?: number; + blockType?: BlockTypes; helpCategory: string; disableLoopProtectTests: boolean; disableLoopProtectPreview: boolean; diff --git a/client/src/redux/selectors.js b/client/src/redux/selectors.js index 07e58d32aef..891df536410 100644 --- a/client/src/redux/selectors.js +++ b/client/src/redux/selectors.js @@ -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; diff --git a/client/src/templates/Challenges/components/completion-modal.tsx b/client/src/templates/Challenges/components/completion-modal.tsx index bf51188edc7..765190a613e 100644 --- a/client/src/templates/Challenges/components/completion-modal.tsx +++ b/client/src/templates/Challenges/components/completion-modal.tsx @@ -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, diff --git a/client/src/templates/Challenges/generic/show.tsx b/client/src/templates/Challenges/generic/show.tsx index bfb9d910e39..f8058e9db0f 100644 --- a/client/src/templates/Challenges/generic/show.tsx +++ b/client/src/templates/Challenges/generic/show.tsx @@ -118,6 +118,7 @@ const ShowGeneric = ({ title, challengeType, helpCategory, + blockType, ...challengePaths }); challengeMounted(challengeMeta.id); diff --git a/client/src/templates/Challenges/redux/completion-epic.js b/client/src/templates/Challenges/redux/completion-epic.js index 33ad5371ab6..0721d1e8c53 100644 --- a/client/src/templates/Challenges/redux/completion-epic.js +++ b/client/src/templates/Challenges/redux/completion-epic.js @@ -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) { diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js index 7a468a7259d..7a716ac10f8 100644 --- a/client/src/templates/Challenges/redux/index.js +++ b/client/src/templates/Challenges/redux/index.js @@ -22,6 +22,7 @@ const initialState = { superBlock: '', block: '', blockHashSlug: '/', + blockType: '', id: '', isLastChallengeInBlock: false, nextChallengePath: '/', diff --git a/client/src/templates/Challenges/redux/selectors.js b/client/src/templates/Challenges/redux/selectors.js index 0c3d82972ca..517dd5a4d17 100644 --- a/client/src/templates/Challenges/redux/selectors.js +++ b/client/src/templates/Challenges/redux/selectors.js @@ -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; diff --git a/client/src/templates/Introduction/components/super-block-accordion.tsx b/client/src/templates/Introduction/components/super-block-accordion.tsx index f2c4a8b0690..2f01d8ce749 100644 --- a/client/src/templates/Introduction/components/super-block-accordion.tsx +++ b/client/src/templates/Introduction/components/super-block-accordion.tsx @@ -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 }[]) => diff --git a/client/utils/gatsby/challenge-page-creator.js b/client/utils/gatsby/challenge-page-creator.js index 1d0c01aa8ec..8c795da96c7 100644 --- a/client/utils/gatsby/challenge-page-creator.js +++ b/client/utils/gatsby/challenge-page-creator.js @@ -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, diff --git a/e2e/donation-modal.spec.ts b/e2e/donation-modal.spec.ts index a5c42d87516..0374abb3002 100644 --- a/e2e/donation-modal.spec.ts +++ b/e2e/donation-modal.spec.ts @@ -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', () => { diff --git a/tools/scripts/seed/user-data.js b/tools/scripts/seed/user-data.js index 46e9187099a..909ba7fe546 100644 --- a/tools/scripts/seed/user-data.js +++ b/tools/scripts/seed/user-data.js @@ -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: [