feat(client): flash when can claim cert (#62594)

This commit is contained in:
Shaun Hamilton
2025-10-29 19:26:52 +02:00
committed by GitHub
parent d4e6f3ac8d
commit ce109e5dff
7 changed files with 96 additions and 0 deletions

View File

@@ -1007,6 +1007,7 @@
"not-eligible": "This user is not eligible for freeCodeCamp.org certifications at this time.",
"profile-private": "{{username}} has chosen to make their profile private. They will need to make their profile public in order for others to be able to view their certification.",
"certs-private": "{{username}} has chosen to make their certifications private. They will need to make their certifications public in order for others to be able to view them.",
"certs-claimable": "You can now claim the {{certName}} certification! Visit your settings page to claim your certification.",
"not-honest": "{{username}} has not yet agreed to our Academic Honesty Pledge.",
"user-not-certified": "It looks like user {{username}} is not {{cert}} certified",
"invalid-challenge": "That does not appear to be a valid challenge submission",

View File

@@ -5,6 +5,7 @@ export enum FlashMessages {
CertClaimSuccess = 'flash.cert-claim-success',
CertificateMissing = 'flash.certificate-missing',
CertsPrivate = 'flash.certs-private',
CertsClaimable = 'flash.certs-claimable',
ChallengeSaveTooBig = 'flash.challenge-save-too-big',
ChallengeSubmitTooBig = 'flash.challenge-submit-too-big',
CodeSaved = 'flash.code-saved',

View File

@@ -0,0 +1,36 @@
import { useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
claimableCertsSelector,
userFetchStateSelector
} from '../../redux/selectors';
import { createFlashMessage } from '../Flash/redux';
import { FlashMessages } from '../Flash/redux/flash-messages';
export function useClaimableCertsNotification() {
const dispatch = useDispatch();
const claimableCerts = useSelector<unknown, { certTitle: string }[]>(
claimableCertsSelector
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { complete } = useSelector(userFetchStateSelector);
const hasShownNotification = useRef(false);
useEffect(() => {
if (!complete || hasShownNotification.current) return;
if (claimableCerts.length === 0) return;
hasShownNotification.current = true;
// Only one cert is shown as claimable at a time
const certName = claimableCerts.at(0)!.certTitle;
dispatch(
createFlashMessage({
type: 'info',
message: FlashMessages.CertsClaimable,
variables: { certName }
})
);
}, [claimableCerts, complete, dispatch]);
}

View File

@@ -8,6 +8,8 @@ import Testimonials from '../components/landing/components/testimonials';
import Certifications from '../components/landing/components/certifications';
import Faq from '../components/landing/components/faq';
import Benefits from '../components/landing/components/benefits';
import { useClaimableCertsNotification } from '../components/helpers/use-claimable-certs-notification';
import '../components/landing/landing.css';
const Landing = () => (
@@ -27,6 +29,8 @@ const Landing = () => (
function IndexPage(): JSX.Element {
const { t } = useTranslation();
const growthbook = useGrowthBook();
useClaimableCertsNotification();
if (growthbook && growthbook.ready) {
growthbook.getFeatureValue('landing-aa-test', false);
return (

View File

@@ -16,6 +16,7 @@ import {
} from '../redux/selectors';
import callGA from '../analytics/call-ga';
import { useClaimableCertsNotification } from '../components/helpers/use-claimable-certs-notification';
interface FetchState {
pending: boolean;
@@ -70,6 +71,7 @@ function LearnPage({
const { name, completedChallengeCount, isDonating } = user ?? EMPTY_USER;
const { t } = useTranslation();
useClaimableCertsNotification();
const slug = challengeNode?.challenge?.fields?.slug || '';

View File

@@ -1,4 +1,9 @@
import { createSelector } from 'reselect';
import { liveCerts } from '../../config/cert-and-project-map';
import {
certTypeIdMap,
certTypeTitleMap
} from '../../../shared-dist/config/certification-settings.js';
import { randomBetween } from '../utils/random-between';
import { getSessionChallengeData } from '../utils/session-storage';
@@ -218,3 +223,49 @@ export const userSelector = state => state[MainApp].user.sessionUser;
export const otherUserSelector = state => state[MainApp].user.otherUser;
export const renderStartTimeSelector = state => state[MainApp].renderStartTime;
export const claimableCertsSelector = createSelector([userSelector], user => {
if (!user) return [];
const completedChallengeIds = (user.completedChallenges || []).map(
({ id }) => id
);
const isClaimedById = Object.entries(certTypeIdMap).reduce(
(acc, [userFlag, certId]) => {
acc[certId] = Boolean(user[userFlag]);
return acc;
},
{}
);
// Invert certTypeIdMap ({[userFlag]: certId} => {[certId]: userFlag}) to get certType from id
const invertedCertTypeIdMap = Object.entries(certTypeIdMap).reduce(
(acc, [userFlag, certId]) => {
acc[certId] = userFlag;
return acc;
},
{}
);
const claimable = [];
for (const { id, projects } of liveCerts) {
if (!projects) continue;
if (isClaimedById[id]) continue;
const projectIds = projects.map(p => p.id);
const allProjectsComplete = projectIds.every(id =>
completedChallengeIds.includes(id)
);
const certType = invertedCertTypeIdMap[id];
const certTitle = certTypeTitleMap[certType];
if (allProjectsComplete) {
claimable.push({
certTitle
});
}
}
return claimable;
});

View File

@@ -18,6 +18,7 @@ const toneUrls = {
[FlashMessages.CertClaimSuccess]:
'https://campfire-mode.freecodecamp.org/cert.mp3',
[FlashMessages.CertificateMissing]: TRY_AGAIN,
[FlashMessages.CertsClaimable]: CHAL_COMP,
[FlashMessages.CertsPrivate]: TRY_AGAIN,
[FlashMessages.ChallengeSaveTooBig]: TRY_AGAIN,
[FlashMessages.ChallengeSubmitTooBig]: TRY_AGAIN,