From ce109e5dff86d71ff62c43ec69445f12abbf164b Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Wed, 29 Oct 2025 19:26:52 +0200 Subject: [PATCH] feat(client): flash when can claim cert (#62594) --- client/i18n/locales/english/translations.json | 1 + .../components/Flash/redux/flash-messages.ts | 1 + .../use-claimable-certs-notification.tsx | 36 +++++++++++++ client/src/pages/index.tsx | 4 ++ client/src/pages/learn.tsx | 2 + client/src/redux/selectors.js | 51 +++++++++++++++++++ client/src/utils/tone/index.ts | 1 + 7 files changed, 96 insertions(+) create mode 100644 client/src/components/helpers/use-claimable-certs-notification.tsx diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 35dabc90c3e..d0473bb08bc 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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", diff --git a/client/src/components/Flash/redux/flash-messages.ts b/client/src/components/Flash/redux/flash-messages.ts index 387dc296a99..71c350c099f 100644 --- a/client/src/components/Flash/redux/flash-messages.ts +++ b/client/src/components/Flash/redux/flash-messages.ts @@ -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', diff --git a/client/src/components/helpers/use-claimable-certs-notification.tsx b/client/src/components/helpers/use-claimable-certs-notification.tsx new file mode 100644 index 00000000000..cfce0787cc4 --- /dev/null +++ b/client/src/components/helpers/use-claimable-certs-notification.tsx @@ -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( + 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]); +} diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index aa9f545a0a1..c09be5c0142 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -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 ( diff --git a/client/src/pages/learn.tsx b/client/src/pages/learn.tsx index 539785bb2bb..6847c4465f6 100644 --- a/client/src/pages/learn.tsx +++ b/client/src/pages/learn.tsx @@ -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 || ''; diff --git a/client/src/redux/selectors.js b/client/src/redux/selectors.js index 4b64800b916..8e249a2c4e7 100644 --- a/client/src/redux/selectors.js +++ b/client/src/redux/selectors.js @@ -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; +}); diff --git a/client/src/utils/tone/index.ts b/client/src/utils/tone/index.ts index 0707d59ac96..2ebaa615fe6 100644 --- a/client/src/utils/tone/index.ts +++ b/client/src/utils/tone/index.ts @@ -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,