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 superOrder
template template
usesMultifileEditor usesMultifileEditor
chapter
module
} }
} }
} }
@@ -3,20 +3,19 @@ import { useTranslation } from 'react-i18next';
import { useFeature } from '@growthbook/growthbook-react'; import { useFeature } from '@growthbook/growthbook-react';
import { Col, Row, Modal, Spacer } from '@freecodecamp/ui'; import { Col, Row, Modal, Spacer } from '@freecodecamp/ui';
import { closeDonationModal } from '../../redux/actions'; 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 donationAnimation from '../../assets/images/donation-bear-animation.svg';
import donationAnimationB from '../../assets/images/new-bear-animation.svg'; import donationAnimationB from '../../assets/images/new-bear-animation.svg';
import supporterBear from '../../assets/images/supporter-bear.svg'; import supporterBear from '../../assets/images/supporter-bear.svg';
import callGA from '../../analytics/call-ga'; import callGA from '../../analytics/call-ga';
import MultiTierDonationForm from './multi-tier-donation-form'; import MultiTierDonationForm from './multi-tier-donation-form';
import { ModalBenefitList } from './donation-text-components'; import { ModalBenefitList } from './donation-text-components';
import { DonatableSectionRecentlyCompleted } from './types';
type RecentlyClaimedBlock = null | { block: string; superBlock: string };
type DonationModalBodyProps = { type DonationModalBodyProps = {
activeDonors?: number; activeDonors?: number;
closeDonationModal: typeof closeDonationModal; closeDonationModal: typeof closeDonationModal;
recentlyClaimedBlock: RecentlyClaimedBlock; donatableSectionRecentlyCompleted: DonatableSectionRecentlyCompleted;
setCanClose: (canClose: boolean) => void; setCanClose: (canClose: boolean) => void;
}; };
@@ -32,17 +31,19 @@ const Illustration = () => {
}; };
function ModalHeader({ function ModalHeader({
recentlyClaimedBlock,
showHeaderAndFooter, showHeaderAndFooter,
donationAttempted, donationAttempted,
showForm showForm,
donatableSectionRecentlyCompleted
}: { }: {
recentlyClaimedBlock: RecentlyClaimedBlock; donatableSectionRecentlyCompleted: DonatableSectionRecentlyCompleted;
showHeaderAndFooter: boolean; showHeaderAndFooter: boolean;
donationAttempted: boolean; donationAttempted: boolean;
showForm: boolean; showForm: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { section, superBlock, title } =
donatableSectionRecentlyCompleted || {};
if (!showHeaderAndFooter || donationAttempted) { if (!showHeaderAndFooter || donationAttempted) {
return null; return null;
@@ -50,19 +51,19 @@ function ModalHeader({
return ( return (
<Row className='text-center'> <Row className='text-center'>
<Col sm={10} smOffset={1} xs={12}> <Col sm={10} smOffset={1} xs={12}>
{recentlyClaimedBlock !== null && ( {donatableSectionRecentlyCompleted && (
<> <>
<b> <b>
{t('donate.nicely-done', { {t('donate.nicely-done', {
block: t( block:
`intro:${recentlyClaimedBlock.superBlock}.blocks.${recentlyClaimedBlock.block}.title` section === 'module'
) ? t(`intro:${superBlock}.${section}s.${title}`)
: t(`intro:${superBlock}.${section}s.${title}.title`)
})} })}
</b> </b>
<Spacer size='xs' /> <Spacer size='xs' />
</> </>
)} )}
<Modal.Header showCloseButton={false} borderless> <Modal.Header showCloseButton={false} borderless>
{t('donate.modal-benefits-title')} {t('donate.modal-benefits-title')}
</Modal.Header> </Modal.Header>
@@ -188,16 +189,16 @@ const AnimationContainer = ({
}; };
const BecomeASupporterConfirmation = ({ const BecomeASupporterConfirmation = ({
recentlyClaimedBlock,
showHeaderAndFooter, showHeaderAndFooter,
closeDonationModal, closeDonationModal,
donationAttempted, donationAttempted,
showForm, showForm,
setShowHeaderAndFooter, setShowHeaderAndFooter,
handleProcessing, handleProcessing,
setShowForm setShowForm,
donatableSectionRecentlyCompleted
}: { }: {
recentlyClaimedBlock: RecentlyClaimedBlock; donatableSectionRecentlyCompleted: DonatableSectionRecentlyCompleted;
showHeaderAndFooter: boolean; showHeaderAndFooter: boolean;
closeDonationModal: () => void; closeDonationModal: () => void;
donationAttempted: boolean; donationAttempted: boolean;
@@ -212,7 +213,7 @@ const BecomeASupporterConfirmation = ({
<Illustration /> <Illustration />
</div> </div>
<ModalHeader <ModalHeader
recentlyClaimedBlock={recentlyClaimedBlock} donatableSectionRecentlyCompleted={donatableSectionRecentlyCompleted}
showHeaderAndFooter={showHeaderAndFooter} showHeaderAndFooter={showHeaderAndFooter}
donationAttempted={donationAttempted} donationAttempted={donationAttempted}
showForm={showForm} showForm={showForm}
@@ -240,7 +241,7 @@ const BecomeASupporterConfirmation = ({
function DonationModalBody({ function DonationModalBody({
closeDonationModal, closeDonationModal,
recentlyClaimedBlock, donatableSectionRecentlyCompleted,
setCanClose setCanClose
}: DonationModalBodyProps): JSX.Element { }: DonationModalBodyProps): JSX.Element {
const [donationAttempted, setDonationAttempted] = useState(false); const [donationAttempted, setDonationAttempted] = useState(false);
@@ -273,7 +274,9 @@ function DonationModalBody({
<AnimationContainer secondsRemaining={secondsRemaining} /> <AnimationContainer secondsRemaining={secondsRemaining} />
) : ( ) : (
<BecomeASupporterConfirmation <BecomeASupporterConfirmation
recentlyClaimedBlock={recentlyClaimedBlock} donatableSectionRecentlyCompleted={
donatableSectionRecentlyCompleted
}
showHeaderAndFooter={showHeaderAndFooter} showHeaderAndFooter={showHeaderAndFooter}
closeDonationModal={closeDonationModal} closeDonationModal={closeDonationModal}
donationAttempted={donationAttempted} donationAttempted={donationAttempted}
@@ -10,21 +10,24 @@ import { useFeature } from '@growthbook/growthbook-react';
import { closeDonationModal } from '../../redux/actions'; import { closeDonationModal } from '../../redux/actions';
import { import {
isDonationModalOpenSelector, isDonationModalOpenSelector,
recentlyClaimedBlockSelector donatableSectionRecentlyCompletedSelector
} from '../../redux/selectors'; } from '../../redux/selectors';
import { isLocationSuperBlock } from '../../utils/path-parsers'; import { isLocationSuperBlock } from '../../utils/path-parsers';
import { playTone } from '../../utils/tone'; import { playTone } from '../../utils/tone';
import callGA from '../../analytics/call-ga'; import callGA from '../../analytics/call-ga';
import { DonatableSectionRecentlyCompleted } from './types';
import DonationModalBody from './donation-modal-body'; import DonationModalBody from './donation-modal-body';
type RecentlyClaimedBlock = null | { block: string; superBlock: string };
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
isDonationModalOpenSelector, isDonationModalOpenSelector,
recentlyClaimedBlockSelector, donatableSectionRecentlyCompletedSelector,
(show: boolean, recentlyClaimedBlock: RecentlyClaimedBlock) => ({ (
show: boolean,
donatableSectionRecentlyCompleted: DonatableSectionRecentlyCompleted
) => ({
show, show,
recentlyClaimedBlock donatableSectionRecentlyCompleted
}) })
); );
@@ -35,7 +38,7 @@ type DonateModalProps = {
activeDonors?: number; activeDonors?: number;
closeDonationModal: typeof closeDonationModal; closeDonationModal: typeof closeDonationModal;
location?: WindowLocation; location?: WindowLocation;
recentlyClaimedBlock: RecentlyClaimedBlock; donatableSectionRecentlyCompleted: DonatableSectionRecentlyCompleted;
show: boolean; show: boolean;
}; };
@@ -43,7 +46,7 @@ function DonateModal({
show, show,
closeDonationModal, closeDonationModal,
location, location,
recentlyClaimedBlock donatableSectionRecentlyCompleted
}: DonateModalProps): JSX.Element { }: DonateModalProps): JSX.Element {
const [canClose, setCanClose] = useState(false); const [canClose, setCanClose] = useState(false);
const isA11yFeatureEnabled = useFeature('a11y-donation-modal').on; const isA11yFeatureEnabled = useFeature('a11y-donation-modal').on;
@@ -55,11 +58,11 @@ function DonateModal({
callGA({ callGA({
event: 'donation_view', event: 'donation_view',
action: `Displayed ${ action: `Displayed ${
recentlyClaimedBlock !== null ? 'Block' : 'Progress' donatableSectionRecentlyCompleted !== null ? 'Block' : 'Progress'
} Donation Modal` } Donation Modal`
}); });
} }
}, [show, recentlyClaimedBlock]); }, [show, donatableSectionRecentlyCompleted]);
const handleModalHide = () => { const handleModalHide = () => {
// If modal is open on a SuperBlock page // If modal is open on a SuperBlock page
@@ -76,7 +79,7 @@ function DonateModal({
<Modal size='large' onClose={handleModalHide} open={show}> <Modal size='large' onClose={handleModalHide} open={show}>
<DonationModalBody <DonationModalBody
closeDonationModal={closeDonationModal} closeDonationModal={closeDonationModal}
recentlyClaimedBlock={recentlyClaimedBlock} donatableSectionRecentlyCompleted={donatableSectionRecentlyCompleted}
setCanClose={setCanClose} setCanClose={setCanClose}
/> />
</Modal> </Modal>
+7
View File
@@ -1,4 +1,5 @@
import type { PaymentIntentResult } from '@stripe/stripe-js'; import type { PaymentIntentResult } from '@stripe/stripe-js';
import { SuperBlocks } from '../../../../shared/config/curriculum';
export type PaymentContext = 'modal' | 'donate page' | 'certificate'; export type PaymentContext = 'modal' | 'donate page' | 'certificate';
export type PaymentProvider = 'patreon' | 'paypal' | 'stripe' | 'stripe card'; export type PaymentProvider = 'patreon' | 'paypal' | 'stripe' | 'stripe card';
@@ -28,3 +29,9 @@ export interface DonationApprovalData {
paypal: boolean; paypal: boolean;
}; };
} }
export type DonatableSectionRecentlyCompleted = null | {
section: string;
title: string;
superBlock: SuperBlocks;
};
+2 -2
View File
@@ -21,7 +21,8 @@ import './map.css';
import { import {
isSignedInSelector, isSignedInSelector,
currentCertsSelector currentCertsSelector,
completedChallengesIdsSelector
} from '../../redux/selectors'; } from '../../redux/selectors';
import { RibbonIcon } from '../../assets/icons/completion-ribbon'; import { RibbonIcon } from '../../assets/icons/completion-ribbon';
@@ -31,7 +32,6 @@ import {
certSlugTypeMap, certSlugTypeMap,
superBlockCertTypeMap superBlockCertTypeMap
} from '../../../../shared/config/certification-settings'; } from '../../../../shared/config/certification-settings';
import { completedChallengesIdsSelector } from '../../templates/Challenges/redux/selectors';
interface MapProps { interface MapProps {
forLanding?: boolean; forLanding?: boolean;
+2 -2
View File
@@ -9,9 +9,9 @@ export const actionTypes = createTypes(
'toggleTheme', 'toggleTheme',
'appMount', 'appMount',
'hardGoTo', 'hardGoTo',
'allowBlockDonationRequests', 'allowSectionDonationRequests',
'setRenderStartTime', 'setRenderStartTime',
'preventBlockDonationRequests', 'preventSectionDonationRequests',
'setIsRandomCompletionThreshold', 'setIsRandomCompletionThreshold',
'openDonationModal', 'openDonationModal',
'closeDonationModal', 'closeDonationModal',
+4 -4
View File
@@ -8,14 +8,14 @@ export const tryToShowDonationModal = createAction(
actionTypes.tryToShowDonationModal actionTypes.tryToShowDonationModal
); );
export const allowBlockDonationRequests = createAction( export const allowSectionDonationRequests = createAction(
actionTypes.allowBlockDonationRequests actionTypes.allowSectionDonationRequests
); );
export const setRenderStartTime = createAction(actionTypes.setRenderStartTime); export const setRenderStartTime = createAction(actionTypes.setRenderStartTime);
export const closeDonationModal = createAction(actionTypes.closeDonationModal); export const closeDonationModal = createAction(actionTypes.closeDonationModal);
export const openDonationModal = createAction(actionTypes.openDonationModal); export const openDonationModal = createAction(actionTypes.openDonationModal);
export const preventBlockDonationRequests = createAction( export const preventSectionDonationRequests = createAction(
actionTypes.preventBlockDonationRequests actionTypes.preventSectionDonationRequests
); );
export const setIsRandomCompletionThreshold = createAction( export const setIsRandomCompletionThreshold = createAction(
actionTypes.setIsRandomCompletionThreshold actionTypes.setIsRandomCompletionThreshold
+19 -14
View File
@@ -23,19 +23,20 @@ import {
getSessionChallengeData, getSessionChallengeData,
saveCurrentCount saveCurrentCount
} from '../utils/session-storage'; } from '../utils/session-storage';
import { actionTypes as appTypes } from './action-types'; import { actionTypes as appTypes } from './action-types';
import { import {
openDonationModal, openDonationModal,
postChargeComplete, postChargeComplete,
postChargeProcessing, postChargeProcessing,
postChargeError, postChargeError,
preventBlockDonationRequests, preventSectionDonationRequests,
updateCardError, updateCardError,
updateCardRedirecting updateCardRedirecting
} from './actions'; } from './actions';
import { import {
isDonatingSelector, isDonatingSelector,
recentlyClaimedBlockSelector, donatableSectionRecentlyCompletedSelector,
shouldRequestDonationSelector, shouldRequestDonationSelector,
isSignedInSelector, isSignedInSelector,
completedChallengesSelector completedChallengesSelector
@@ -46,27 +47,31 @@ const updateCardErrorMessage = i18next.t('donate.error-3');
function* showDonateModalSaga() { function* showDonateModalSaga() {
let shouldRequestDonation = yield select(shouldRequestDonationSelector); let shouldRequestDonation = yield select(shouldRequestDonationSelector);
const recentlyClaimedBlock = yield select(recentlyClaimedBlockSelector);
const MODAL_SHOWN_KEY = 'modalShownTimestamp'; const MODAL_SHOWN_KEY = 'modalShownTimestamp';
const modalShownTimestamp = sessionStorage.getItem(MODAL_SHOWN_KEY); const modalShownTimestamp = sessionStorage.getItem(MODAL_SHOWN_KEY);
const isModalRecentlyShown = Date.now() - modalShownTimestamp < 20000; // If the modal has been shown in the last 20 seconds, the animation should
if ( // still be running:
shouldRequestDonation && const isAnimationRunning = Date.now() - modalShownTimestamp < 20000;
recentlyClaimedBlock && const shouldShowModal = shouldRequestDonation || isAnimationRunning;
chapterBasedSuperBlocks.includes(recentlyClaimedBlock.superBlock) const donatableSectionRecentlyCompleted = yield select(
) { donatableSectionRecentlyCompletedSelector
yield put(preventBlockDonationRequests()); );
} else if (shouldRequestDonation || isModalRecentlyShown) {
if (shouldShowModal) {
yield delay(200); yield delay(200);
yield put(openDonationModal()); yield put(openDonationModal());
sessionStorage.setItem(MODAL_SHOWN_KEY, Date.now()); sessionStorage.setItem(MODAL_SHOWN_KEY, Date.now());
yield take(appTypes.closeDonationModal); yield take(appTypes.closeDonationModal);
if (recentlyClaimedBlock) { if (!donatableSectionRecentlyCompleted) {
yield put(preventBlockDonationRequests());
} else {
yield call(saveCurrentCount); 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({ export function* postChargeSaga({
+9 -5
View File
@@ -52,7 +52,7 @@ export const defaultDonationFormState = {
const initialState = { const initialState = {
appUsername: '', appUsername: '',
isRandomCompletionThreshold: false, isRandomCompletionThreshold: false,
recentlyClaimedBlock: null, donatableSectionRecentlyCompleted: null,
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY), currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
examInProgress: false, examInProgress: false,
isProcessing: false, isProcessing: false,
@@ -138,10 +138,14 @@ export const reducer = handleActions(
} }
}; };
}, },
[actionTypes.allowBlockDonationRequests]: (state, { payload }) => { [actionTypes.allowSectionDonationRequests]: (state, { payload }) => {
return { return {
...state, ...state,
recentlyClaimedBlock: payload donatableSectionRecentlyCompleted: {
block: payload.block,
module: payload.module,
superBlock: payload.superBlock
}
}; };
}, },
[actionTypes.setRenderStartTime]: (state, { payload }) => { [actionTypes.setRenderStartTime]: (state, { payload }) => {
@@ -277,9 +281,9 @@ export const reducer = handleActions(
...state, ...state,
showDonationModal: true showDonationModal: true
}), }),
[actionTypes.preventBlockDonationRequests]: state => ({ [actionTypes.preventSectionDonationRequests]: state => ({
...state, ...state,
recentlyClaimedBlock: null donatableSectionRecentlyCompleted: null
}), }),
[actionTypes.setIsRandomCompletionThreshold]: (state, { payload }) => ({ [actionTypes.setIsRandomCompletionThreshold]: (state, { payload }) => ({
...state, ...state,
+1
View File
@@ -391,6 +391,7 @@ export type ChallengeMeta = {
superBlock: SuperBlocks; superBlock: SuperBlocks;
title?: string; title?: string;
challengeType?: number; challengeType?: number;
blockType?: BlockTypes;
helpCategory: string; helpCategory: string;
disableLoopProtectTests: boolean; disableLoopProtectTests: boolean;
disableLoopProtectPreview: boolean; disableLoopProtectPreview: boolean;
+90 -5
View File
@@ -1,4 +1,7 @@
import { createSelector } from 'reselect';
import { Certification } from '../../../shared/config/certification-settings'; import { Certification } from '../../../shared/config/certification-settings';
import superBlockStructure from '../../../curriculum/superblock-structure/full-stack.json';
import { randomBetween } from '../utils/random-between'; import { randomBetween } from '../utils/random-between';
import { getSessionChallengeData } from '../utils/session-storage'; import { getSessionChallengeData } from '../utils/session-storage';
import { ns as MainApp } from './action-types'; import { ns as MainApp } from './action-types';
@@ -22,8 +25,20 @@ export const isDonationModalOpenSelector = state =>
state[MainApp].showDonationModal; state[MainApp].showDonationModal;
export const isSignoutModalOpenSelector = state => export const isSignoutModalOpenSelector = state =>
state[MainApp].showSignoutModal; state[MainApp].showSignoutModal;
export const recentlyClaimedBlockSelector = state => export const donatableSectionRecentlyCompletedSelector = state => {
state[MainApp].recentlyClaimedBlock; 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 => export const donationFormStateSelector = state =>
state[MainApp].donationFormState; state[MainApp].donationFormState;
export const updateCardStateSelector = state => state[MainApp].updateCardState; export const updateCardStateSelector = state => state[MainApp].updateCardState;
@@ -35,7 +50,8 @@ export const showCertFetchStateSelector = state =>
export const shouldRequestDonationSelector = state => { export const shouldRequestDonationSelector = state => {
const completedChallengeCount = completedChallengesSelector(state).length; const completedChallengeCount = completedChallengesSelector(state).length;
const isDonating = isDonatingSelector(state); const isDonating = isDonatingSelector(state);
const recentlyClaimedBlock = recentlyClaimedBlockSelector(state); const donatableSectionRecentlyCompleted =
donatableSectionRecentlyCompletedSelector(state);
const isRandomCompletionThreshold = const isRandomCompletionThreshold =
isRandomCompletionThresholdSelector(state); isRandomCompletionThresholdSelector(state);
@@ -46,8 +62,8 @@ export const shouldRequestDonationSelector = state => {
// not before the 11th challenge has mounted) // not before the 11th challenge has mounted)
if (completedChallengeCount < 10) return false; if (completedChallengeCount < 10) return false;
// a block has been completed // a block or module has been completed
if (recentlyClaimedBlock) return true; if (donatableSectionRecentlyCompleted) return true;
const sessionChallengeData = getSessionChallengeData(); const sessionChallengeData = getSessionChallengeData();
/* /*
@@ -256,6 +272,75 @@ export const certificatesByNameSelector = username => state => {
export const userFetchStateSelector = state => state[MainApp].userFetchState; export const userFetchStateSelector = state => state[MainApp].userFetchState;
export const allChallengesInfoSelector = state => export const allChallengesInfoSelector = state =>
state[MainApp].allChallengesInfo; 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 => export const userProfileFetchStateSelector = state =>
state[MainApp].userProfileFetchState; state[MainApp].userProfileFetchState;
export const usernameSelector = state => state[MainApp].appUsername; export const usernameSelector = state => state[MainApp].appUsername;
@@ -7,11 +7,13 @@ import { createSelector } from 'reselect';
import { Button, Modal, Spacer } from '@freecodecamp/ui'; import { Button, Modal, Spacer } from '@freecodecamp/ui';
import Login from '../../../components/Header/components/login'; 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 { ChallengeFiles } from '../../../redux/prop-types';
import { closeModal, submitChallenge } from '../redux/actions'; import { closeModal, submitChallenge } from '../redux/actions';
import { import {
completedChallengesIdsSelector,
isCompletionModalOpenSelector, isCompletionModalOpenSelector,
successMessageSelector, successMessageSelector,
challengeFilesSelector, challengeFilesSelector,
@@ -118,6 +118,7 @@ const ShowGeneric = ({
title, title,
challengeType, challengeType,
helpCategory, helpCategory,
blockType,
...challengePaths ...challengePaths
}); });
challengeMounted(challengeMeta.id); challengeMounted(challengeMeta.id);
@@ -21,7 +21,7 @@ import {
} from '../../../../../shared/config/challenge-types'; } from '../../../../../shared/config/challenge-types';
import { actionTypes as submitActionTypes } from '../../../redux/action-types'; import { actionTypes as submitActionTypes } from '../../../redux/action-types';
import { import {
allowBlockDonationRequests, allowSectionDonationRequests,
setIsProcessing, setIsProcessing,
setRenderStartTime, setRenderStartTime,
submitComplete, submitComplete,
@@ -32,6 +32,7 @@ import { isSignedInSelector, userSelector } from '../../../redux/selectors';
import { mapFilesToChallengeFiles } from '../../../utils/ajax'; import { mapFilesToChallengeFiles } from '../../../utils/ajax';
import { standardizeRequestBody } from '../../../utils/challenge-request-helpers'; import { standardizeRequestBody } from '../../../utils/challenge-request-helpers';
import postUpdate$ from '../utils/post-update'; import postUpdate$ from '../utils/post-update';
import { SuperBlocks } from '../../../../../shared/config/curriculum';
import { actionTypes } from './action-types'; import { actionTypes } from './action-types';
import { import {
closeModal, closeModal,
@@ -46,7 +47,8 @@ import {
challengeTestsSelector, challengeTestsSelector,
userCompletedExamSelector, userCompletedExamSelector,
projectFormValuesSelector, projectFormValuesSelector,
isBlockNewlyCompletedSelector isBlockNewlyCompletedSelector,
isModuleNewlyCompletedSelector
} from './selectors'; } from './selectors';
function postChallenge(update) { function postChallenge(update) {
@@ -230,7 +232,9 @@ export default function completionEpic(action$, state$) {
nextChallengePath, nextChallengePath,
challengeType, challengeType,
superBlock, superBlock,
blockType,
block, block,
module,
blockHashSlug blockHashSlug
} = challengeMetaSelector(state); } = challengeMetaSelector(state);
// Default to submitChallengeComplete since we do not want the user to // Default to submitChallengeComplete since we do not want the user to
@@ -254,19 +258,30 @@ export default function completionEpic(action$, state$) {
? blockHashSlug ? blockHashSlug
: nextChallengePath; : nextChallengePath;
const canAllowDonationRequest = (state, action) => const canAllowDonationRequest = (state, action) => {
isBlockNewlyCompletedSelector(state) && if (action.type !== submitActionTypes.submitComplete) return null;
action.type === submitActionTypes.submitComplete;
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( return submitter(type, state).pipe(
concat( concat(
of(setIsAdvancing(!isLastChallengeInBlock), setIsProcessing(false)) of(setIsAdvancing(!isLastChallengeInBlock), setIsProcessing(false))
), ),
mergeMap(x => mergeMap(x => {
canAllowDonationRequest(state, x) const donationAction = canAllowDonationRequest(state, x);
? of(x, allowBlockDonationRequests({ superBlock, block })) return donationAction ? of(x, donationAction) : of(x);
: of(x) }),
),
mergeMap(x => of(x, setRenderStartTime(Date.now()))), mergeMap(x => of(x, setRenderStartTime(Date.now()))),
tap(res => { tap(res => {
if (res.type !== submitActionTypes.updateFailed) { if (res.type !== submitActionTypes.updateFailed) {
@@ -22,6 +22,7 @@ const initialState = {
superBlock: '', superBlock: '',
block: '', block: '',
blockHashSlug: '/', blockHashSlug: '/',
blockType: '',
id: '', id: '',
isLastChallengeInBlock: false, isLastChallengeInBlock: false,
nextChallengePath: '/', nextChallengePath: '/',
@@ -3,7 +3,9 @@ import { challengeTypes } from '../../../../../shared/config/challenge-types';
import { import {
completedChallengesSelector, completedChallengesSelector,
allChallengesInfoSelector, allChallengesInfoSelector,
isSignedInSelector isSignedInSelector,
completionStateSelector,
completedChallengesIdsSelector
} from '../../../redux/selectors'; } from '../../../redux/selectors';
import { import {
getCurrentBlockIds, getCurrentBlockIds,
@@ -17,10 +19,6 @@ export const challengeMetaSelector = state => state[ns].challengeMeta;
export const challengeHooksSelector = state => state[ns].challengeHooks; export const challengeHooksSelector = state => state[ns].challengeHooks;
export const challengeTestsSelector = state => state[ns].challengeTests; export const challengeTestsSelector = state => state[ns].challengeTests;
export const consoleOutputSelector = state => state[ns].consoleOut; export const consoleOutputSelector = state => state[ns].consoleOut;
export const completedChallengesIdsSelector = createSelector(
completedChallengesSelector,
completedChallenges => completedChallenges.map(node => node.id)
);
export const isChallengeCompletedSelector = createSelector( export const isChallengeCompletedSelector = createSelector(
[completedChallengesIdsSelector, challengeMetaSelector], [completedChallengesIdsSelector, challengeMetaSelector],
(ids, meta) => ids.includes(meta.id) (ids, meta) => ids.includes(meta.id)
@@ -152,6 +150,27 @@ export const isBlockNewlyCompletedSelector = state => {
return completedPercentage === 100 && !completedChallengesIds.includes(id); 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 attemptsSelector = state => state[ns].attempts;
export const canFocusEditorSelector = state => state[ns].canFocusEditor; export const canFocusEditorSelector = state => state[ns].canFocusEditor;
export const visibleEditorsSelector = state => state[ns].visibleEditors; export const visibleEditorsSelector = state => state[ns].visibleEditors;
@@ -47,7 +47,7 @@ interface Challenge {
superBlock: SuperBlocks; superBlock: SuperBlocks;
} }
interface SuperBlockTreeViewProps { interface SuperBlockAccordionPropsViewProps {
challenges: Challenge[]; challenges: Challenge[];
superBlock: SuperBlocks; superBlock: SuperBlocks;
chosenBlock: string; chosenBlock: string;
@@ -222,7 +222,7 @@ export const SuperBlockAccordion = ({
superBlock, superBlock,
chosenBlock, chosenBlock,
completedChallengeIds completedChallengeIds
}: SuperBlockTreeViewProps) => { }: SuperBlockAccordionPropsViewProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { allChapters } = useMemo(() => { const { allChapters } = useMemo(() => {
const populateBlocks = (blocks: { dashedName: string }[]) => const populateBlocks = (blocks: { dashedName: string }[]) =>
@@ -86,6 +86,8 @@ exports.createChallengePages = function (
disableLoopProtectPreview, disableLoopProtectPreview,
certification, certification,
superBlock, superBlock,
chapter,
module,
block, block,
fields: { slug, blockHashSlug }, fields: { slug, blockHashSlug },
required = [], required = [],
@@ -108,6 +110,8 @@ exports.createChallengePages = function (
disableLoopProtectTests, disableLoopProtectTests,
disableLoopProtectPreview, disableLoopProtectPreview,
superBlock, superBlock,
chapter,
module,
block, block,
isFirstStep: getIsFirstStepInBlock(index, allChallengeEdges), isFirstStep: getIsFirstStepInBlock(index, allChallengeEdges),
template, 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 completeFrontEndCert(page, 1);
await expect(donationModal).toBeHidden(); 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', () => { test.describe('Donation modal appearance logic - Certified user', () => {
+12
View File
@@ -12251,6 +12251,18 @@ module.exports.fullyCertifiedUser = {
id: '671144cdcc01d73f7dd79dc9', id: '671144cdcc01d73f7dd79dc9',
challengeType: 0, challengeType: 0,
files: [] files: []
},
{
completedDate: 1729240849345,
id: '6734ddabad59e593a49afafe'
},
{
completedDate: 1729240849345,
id: '6734e2c5780912abd874e79c'
},
{
completedDate: 1729240849345,
id: '6763500bd5a85d5898cc21a9'
} }
], ],
completedExams: [ completedExams: [