mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,9 +9,9 @@ export const actionTypes = createTypes(
|
||||
'toggleTheme',
|
||||
'appMount',
|
||||
'hardGoTo',
|
||||
'allowBlockDonationRequests',
|
||||
'allowSectionDonationRequests',
|
||||
'setRenderStartTime',
|
||||
'preventBlockDonationRequests',
|
||||
'preventSectionDonationRequests',
|
||||
'setIsRandomCompletionThreshold',
|
||||
'openDonationModal',
|
||||
'closeDonationModal',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -391,6 +391,7 @@ export type ChallengeMeta = {
|
||||
superBlock: SuperBlocks;
|
||||
title?: string;
|
||||
challengeType?: number;
|
||||
blockType?: BlockTypes;
|
||||
helpCategory: string;
|
||||
disableLoopProtectTests: boolean;
|
||||
disableLoopProtectPreview: boolean;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user