From 5d85ba125eb6933ae49ed2b7cb4502aeff2de149 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 26 Jan 2026 11:56:26 +0200 Subject: [PATCH 1/3] PM-3329 - show skills activity in member profile --- .../skills/MemberSkillsInfo.tsx | 16 +++- .../profile-functions/profile-store/index.ts | 1 + .../profile-store/profile-xhr.store.ts | 5 + src/libs/core/lib/profile/user-skill.model.ts | 21 +++++ .../grouped-skills-ui/GroupedSkillsUI.tsx | 5 +- .../skill-pill/SkillPill.module.scss | 16 ++++ .../lib/components/skill-pill/SkillPill.tsx | 94 ++++++++++++++++++- 7 files changed, 150 insertions(+), 8 deletions(-) diff --git a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx index 738e3918e..cad610959 100644 --- a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx +++ b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx @@ -1,8 +1,8 @@ -import { Dispatch, FC, SetStateAction, useEffect, useMemo, useState } from 'react' +import { Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { filter, orderBy } from 'lodash' -import { UserProfile, UserSkill, UserSkillDisplayModes } from '~/libs/core' +import { getMemberSkillDetails, UserProfile, UserSkill, UserSkillDisplayModes } from '~/libs/core' import { GroupedSkillsUI, HowSkillsWorkModal, isSkillVerified, SkillPill, useLocalStorage } from '~/libs/shared' import { Button } from '~/libs/ui' @@ -23,6 +23,7 @@ interface MemberSkillsInfoProps { const MemberSkillsInfo: FC = (props: MemberSkillsInfoProps) => { const [queryParams]: [URLSearchParams, any] = useSearchParams() const editMode: string | null = queryParams.get(EDIT_MODE_QUERY_PARAM) + const [canFetchSkillDetails, setCanFetchSkillDetails] = useState(true) const canEdit: boolean = props.authProfile?.handle === props.profile.handle const [hasSeenPrincipalIntro, setHasSeenPrincipalIntro] = useLocalStorage('seen-principal-intro', {} as any) @@ -131,6 +132,15 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp setPrincipalIntroModalVisible(false) } + const fetchSkillDetails = useCallback((skillId: string) => { + return getMemberSkillDetails(props.profile.handle, skillId).catch(e => { + if (e.response.status === 403) { + setCanFetchSkillDetails(false) + } + throw e; + }) + }, [props.profile.handle]); + return (
@@ -174,6 +184,7 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp skill={skill} key={skill.id} theme={isSkillVerified(skill) ? 'verified' : 'dark'} + fetchSkillDetails={canFetchSkillDetails ? fetchSkillDetails : undefined} /> ))}
@@ -188,6 +199,7 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp )}
)} diff --git a/src/libs/core/lib/profile/profile-functions/profile-store/index.ts b/src/libs/core/lib/profile/profile-functions/profile-store/index.ts index 6210fc38f..258c2c1b9 100644 --- a/src/libs/core/lib/profile/profile-functions/profile-store/index.ts +++ b/src/libs/core/lib/profile/profile-functions/profile-store/index.ts @@ -2,6 +2,7 @@ export { get as profileStoreGet, patchName as profileStorePatchName, getMemberStats, + getMemberSkillDetails, } from './profile-xhr.store' export { diff --git a/src/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store.ts b/src/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store.ts index 6201148af..327babf7d 100644 --- a/src/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store.ts +++ b/src/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store.ts @@ -7,6 +7,7 @@ import { UpdateProfileRequest, UserPhotoUpdateResponse } from '../../modify-user import { ModifyUserPropertyRequest, ModifyUserPropertyResponse } from '../../modify-user-role.model' import { UserEmailPreferences } from '../../user-email-preference.model' import { UserProfile } from '../../user-profile.model' +import { UserSkillWithActivity } from '../../user-skill.model' import { UserStats } from '../../user-stats.model' import { UserTraits } from '../../user-traits.model' @@ -123,3 +124,7 @@ export async function updateMemberPhoto(handle: string, payload: FormData): Prom export async function downloadProfile(handle: string): Promise { return xhrGetBlobAsync(`${profileUrl(handle)}/profileDownload`) } + +export function getMemberSkillDetails(handle: string, skillId: string): Promise { + return xhrGetAsync(`${profileUrl(handle)}/skills/${skillId}`) +} diff --git a/src/libs/core/lib/profile/user-skill.model.ts b/src/libs/core/lib/profile/user-skill.model.ts index 3670351ad..9e32f1435 100644 --- a/src/libs/core/lib/profile/user-skill.model.ts +++ b/src/libs/core/lib/profile/user-skill.model.ts @@ -38,4 +38,25 @@ export type UserSkill = { description?: string | null | undefined } +export type UserSkillActivity = { + count: number + lastSources: Array<{ + id?: string + completionEventId?: string + title?: string + name?: string + certification?: string + dashedName?: string + }> +} + +export type UserSkillWithActivity = { + lastUsedDate: string + activity: { + certification?: UserSkillActivity + course?: UserSkillActivity + challenge?: UserSkillActivity + } +} & UserSkill + export type SearchUserSkill = Pick & Partial> diff --git a/src/libs/shared/lib/components/grouped-skills-ui/GroupedSkillsUI.tsx b/src/libs/shared/lib/components/grouped-skills-ui/GroupedSkillsUI.tsx index 39b2a07bb..8b9877a40 100644 --- a/src/libs/shared/lib/components/grouped-skills-ui/GroupedSkillsUI.tsx +++ b/src/libs/shared/lib/components/grouped-skills-ui/GroupedSkillsUI.tsx @@ -1,14 +1,16 @@ import { FC } from 'react' -import { UserSkill } from '~/libs/core' +import { UserSkill, UserSkillWithActivity } from '~/libs/core' import { SkillPill } from '~/libs/shared' import { SkillsList } from '../skills-list' import styles from './GroupedSkillsUI.module.scss' +import { SkillPillProps } from '../skill-pill/SkillPill' interface GroupedSkillsUIProps { groupedSkillsByCategory: { [key: string]: UserSkill[] } + fetchSkillDetails: SkillPillProps['fetchSkillDetails'] } const GroupedSkillsUI: FC = (props: GroupedSkillsUIProps) => (
= (props: GroupedSkillsUIProps) skill={skill} key={skill.id} theme='catList' + fetchSkillDetails={props.fetchSkillDetails} /> )) } diff --git a/src/libs/shared/lib/components/skill-pill/SkillPill.module.scss b/src/libs/shared/lib/components/skill-pill/SkillPill.module.scss index 9740c7b27..9b6231dc0 100644 --- a/src/libs/shared/lib/components/skill-pill/SkillPill.module.scss +++ b/src/libs/shared/lib/components/skill-pill/SkillPill.module.scss @@ -69,3 +69,19 @@ align-items: flex-start; } } + +.tootltipRow { + display: flex; + align-items: center; + gap: $sp-1; + text-align: left; + &.padLeft{ + padding-left: $sp-2; + } +} + +.tooltipDetails { + li { + margin-top: $sp-2; + } +} diff --git a/src/libs/shared/lib/components/skill-pill/SkillPill.tsx b/src/libs/shared/lib/components/skill-pill/SkillPill.tsx index ce0031c3b..ef539c4f2 100644 --- a/src/libs/shared/lib/components/skill-pill/SkillPill.tsx +++ b/src/libs/shared/lib/components/skill-pill/SkillPill.tsx @@ -1,8 +1,11 @@ -import { FC, ReactNode, useCallback, useMemo } from 'react' +import { FC, ReactNode, useCallback, useMemo, useState } from 'react' import classNames from 'classnames' +import useSWR, { SWRResponse } from 'swr' +import { format } from 'date-fns' -import { IconOutline } from '~/libs/ui' -import { UserSkill } from '~/libs/core' + +import { IconOutline, Tooltip } from '~/libs/ui' +import { UserSkill, UserSkillWithActivity } from '~/libs/core' import { isSkillVerified } from '../../services/standard-skills' @@ -12,11 +15,15 @@ export interface SkillPillProps { children?: ReactNode onClick?: (skill: UserSkill) => void selected?: boolean - skill: Partial> + skill: Partial> theme?: 'dark' | 'verified' | 'presentation' | 'etc' | 'catList' + fetchSkillDetails?: (skillId: string) => Promise } const SkillPill: FC = props => { + const [hideDetails, setHideDetails] = useState(false) + const [loadDetails, setLoadDetails] = useState(false) + const isVerified = useMemo(() => ( isSkillVerified({ levels: props.skill.levels ?? [] }) ), [props.skill]) @@ -29,10 +36,77 @@ const SkillPill: FC = props => { styles[`theme-${props.theme ?? ''}`], ) + const handleMouseEnter = useCallback(() => { + setLoadDetails(true); + }, []); + const handleClick = useCallback(() => props.onClick?.call(undefined, props.skill as UserSkill), [ props.onClick, props.skill, ]) + const { data: skillDetails, isValidating: isLoadingDetails } = useSWR( + loadDetails && + props.fetchSkillDetails + && props.skill?.id ? `user-skill-activity/${props.skill.id}` : null, + () => props.fetchSkillDetails!(props.skill.id!).catch((e) => { + setHideDetails(true) + return {} as any + }) + ) + + const skillDetailsTooltipContent = useMemo(() => { + if (!skillDetails || isLoadingDetails || hideDetails) { + return 'Loading...' + } + + return ( + <> +
+ Last used: + {format(new Date(skillDetails.lastUsedDate), 'MMM dd, yyyy HH:mm')} +
+
    + {skillDetails.activity.challenge && ( +
  • +
    + Challenges ({skillDetails.activity.challenge.count}): +
    + {skillDetails.activity.challenge.lastSources.map(s => ( + + {s.name} + + ))} +
  • + )} + {skillDetails.activity.course && ( +
  • +
    + Courses ({skillDetails.activity.course.count}): +
    + {skillDetails.activity.course.lastSources.map(s => ( + + {s.title} + + ))} +
  • + )} + {skillDetails.activity.certification && ( +
  • +
    + TCA Certifications ({skillDetails.activity.certification.count}): +
    + {skillDetails.activity.certification.lastSources.map(s => ( + + {s.title} + + ))} +
  • + )} +
+ + ) + }, [skillDetails, isLoadingDetails, hideDetails]) + return (
= props => { {props.skill.name} {props.children} - {isVerified && } + {isVerified && props.fetchSkillDetails && !hideDetails && ( + + + + )} + {isVerified && (!props.fetchSkillDetails || hideDetails) && ( + + )}
) } From 99dc65d08d47efcadc50fe07548e9304dee3cff3 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 26 Jan 2026 12:35:42 +0200 Subject: [PATCH 2/3] PM-3329 - cleanup & lint --- .../skills/MemberSkillsInfo.tsx | 11 ++-- src/config/environments/default.env.ts | 2 + .../environments/global-config.model.ts | 2 + .../grouped-skills-ui/GroupedSkillsUI.tsx | 4 +- .../lib/components/skill-pill/SkillPill.tsx | 62 +++++++++++++------ 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx index cad610959..334f5f9b0 100644 --- a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx +++ b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import { Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { filter, orderBy } from 'lodash' @@ -132,14 +133,14 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp setPrincipalIntroModalVisible(false) } - const fetchSkillDetails = useCallback((skillId: string) => { - return getMemberSkillDetails(props.profile.handle, skillId).catch(e => { + const fetchSkillDetails = useCallback((skillId: string) => getMemberSkillDetails(props.profile.handle, skillId) + .catch(e => { if (e.response.status === 403) { setCanFetchSkillDetails(false) } - throw e; - }) - }, [props.profile.handle]); + + throw e + }), [props.profile.handle]) return (
diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index c3cf12ba6..77be67235 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -96,6 +96,8 @@ export const STRIPE = { } export const URLS = { + ACADEMY_CERTIFICATION: `https://academy.${TC_DOMAIN}/tca-certifications`, + ACADEMY_COURSE: `https://academy.${TC_DOMAIN}/freeCodeCamp`, ACCOUNT_SETTINGS: `https://account-settings.${TC_DOMAIN}/#account`, CHALLENGES_PAGE: `${TOPCODER_URL}/challenges`, UNIVERSAL_NAV: `https://uni-nav.${TC_DOMAIN}/v1/tc-universal-nav.js`, diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts index 019625f96..d20398eab 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -39,6 +39,8 @@ export interface GlobalConfig { API_VERSION: string | undefined } URLS: { + ACADEMY_CERTIFICATION: string + ACADEMY_COURSE: string USER_PROFILE: string ACCOUNT_SETTINGS: string UNIVERSAL_NAV: string diff --git a/src/libs/shared/lib/components/grouped-skills-ui/GroupedSkillsUI.tsx b/src/libs/shared/lib/components/grouped-skills-ui/GroupedSkillsUI.tsx index 8b9877a40..88d55922f 100644 --- a/src/libs/shared/lib/components/grouped-skills-ui/GroupedSkillsUI.tsx +++ b/src/libs/shared/lib/components/grouped-skills-ui/GroupedSkillsUI.tsx @@ -1,12 +1,12 @@ import { FC } from 'react' -import { UserSkill, UserSkillWithActivity } from '~/libs/core' +import { UserSkill } from '~/libs/core' import { SkillPill } from '~/libs/shared' import { SkillsList } from '../skills-list' +import { SkillPillProps } from '../skill-pill/SkillPill' import styles from './GroupedSkillsUI.module.scss' -import { SkillPillProps } from '../skill-pill/SkillPill' interface GroupedSkillsUIProps { groupedSkillsByCategory: { [key: string]: UserSkill[] } diff --git a/src/libs/shared/lib/components/skill-pill/SkillPill.tsx b/src/libs/shared/lib/components/skill-pill/SkillPill.tsx index ef539c4f2..5cecc1807 100644 --- a/src/libs/shared/lib/components/skill-pill/SkillPill.tsx +++ b/src/libs/shared/lib/components/skill-pill/SkillPill.tsx @@ -1,9 +1,10 @@ +/* eslint-disable complexity */ import { FC, ReactNode, useCallback, useMemo, useState } from 'react' +import { format } from 'date-fns' import classNames from 'classnames' import useSWR, { SWRResponse } from 'swr' -import { format } from 'date-fns' - +import { EnvironmentConfig } from '~/config' import { IconOutline, Tooltip } from '~/libs/ui' import { UserSkill, UserSkillWithActivity } from '~/libs/core' @@ -37,22 +38,24 @@ const SkillPill: FC = props => { ) const handleMouseEnter = useCallback(() => { - setLoadDetails(true); - }, []); + setLoadDetails(true) + }, []) const handleClick = useCallback(() => props.onClick?.call(undefined, props.skill as UserSkill), [ props.onClick, props.skill, ]) - const { data: skillDetails, isValidating: isLoadingDetails } = useSWR( - loadDetails && - props.fetchSkillDetails - && props.skill?.id ? `user-skill-activity/${props.skill.id}` : null, - () => props.fetchSkillDetails!(props.skill.id!).catch((e) => { - setHideDetails(true) - return {} as any - }) - ) + const { data: skillDetails, isValidating: isLoadingDetails }: SWRResponse + = useSWR( + loadDetails + && props.fetchSkillDetails + && props.skill?.id ? `user-skill-activity/${props.skill.id}` : undefined, + () => props.fetchSkillDetails!(props.skill.id!) + .catch(() => { + setHideDetails(true) + return {} as any + }), + ) const skillDetailsTooltipContent = useMemo(() => { if (!skillDetails || isLoadingDetails || hideDetails) { @@ -69,10 +72,17 @@ const SkillPill: FC = props => { {skillDetails.activity.challenge && (
  • - Challenges ({skillDetails.activity.challenge.count}): + Challenges ( + {skillDetails.activity.challenge.count} + ):
    {skillDetails.activity.challenge.lastSources.map(s => ( - + {s.name} ))} @@ -81,10 +91,17 @@ const SkillPill: FC = props => { {skillDetails.activity.course && (
  • - Courses ({skillDetails.activity.course.count}): + Courses ( + {skillDetails.activity.course.count} + ):
    {skillDetails.activity.course.lastSources.map(s => ( - + {s.title} ))} @@ -93,10 +110,17 @@ const SkillPill: FC = props => { {skillDetails.activity.certification && (
  • - TCA Certifications ({skillDetails.activity.certification.count}): + TCA Certifications ( + {skillDetails.activity.certification.count} + ):
    {skillDetails.activity.certification.lastSources.map(s => ( - + {s.title} ))} From bc24cfdad81a899d40c554b87377979f6f57a152 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 27 Jan 2026 09:35:59 +0200 Subject: [PATCH 3/3] typo & fix rel for links --- .../skill-pill/SkillPill.module.scss | 2 +- .../lib/components/skill-pill/SkillPill.tsx | 23 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/libs/shared/lib/components/skill-pill/SkillPill.module.scss b/src/libs/shared/lib/components/skill-pill/SkillPill.module.scss index 9b6231dc0..f88734fe9 100644 --- a/src/libs/shared/lib/components/skill-pill/SkillPill.module.scss +++ b/src/libs/shared/lib/components/skill-pill/SkillPill.module.scss @@ -70,7 +70,7 @@ } } -.tootltipRow { +.tooltipRow { display: flex; align-items: center; gap: $sp-1; diff --git a/src/libs/shared/lib/components/skill-pill/SkillPill.tsx b/src/libs/shared/lib/components/skill-pill/SkillPill.tsx index 5cecc1807..53307b8a3 100644 --- a/src/libs/shared/lib/components/skill-pill/SkillPill.tsx +++ b/src/libs/shared/lib/components/skill-pill/SkillPill.tsx @@ -64,14 +64,14 @@ const SkillPill: FC = props => { return ( <> -
    +
    Last used: {format(new Date(skillDetails.lastUsedDate), 'MMM dd, yyyy HH:mm')}
      {skillDetails.activity.challenge && (
    • -
      +
      Challenges ( {skillDetails.activity.challenge.count} ): @@ -79,9 +79,10 @@ const SkillPill: FC = props => { {skillDetails.activity.challenge.lastSources.map(s => ( {s.name} @@ -90,7 +91,7 @@ const SkillPill: FC = props => { )} {skillDetails.activity.course && (
    • -
      +
      Courses ( {skillDetails.activity.course.count} ): @@ -98,9 +99,10 @@ const SkillPill: FC = props => { {skillDetails.activity.course.lastSources.map(s => ( {s.title} @@ -109,7 +111,7 @@ const SkillPill: FC = props => { )} {skillDetails.activity.certification && (
    • -
      +
      TCA Certifications ( {skillDetails.activity.certification.count} ): @@ -117,9 +119,10 @@ const SkillPill: FC = props => { {skillDetails.activity.certification.lastSources.map(s => ( {s.title}