diff --git a/src/apps/customer-portal/src/config/routes.config.ts b/src/apps/customer-portal/src/config/routes.config.ts index e83343378..316357915 100644 --- a/src/apps/customer-portal/src/config/routes.config.ts +++ b/src/apps/customer-portal/src/config/routes.config.ts @@ -9,4 +9,3 @@ export const rootRoute: string : `/${AppSubdomain.customer}` export const talentSearchRouteId = 'talent-search' -export const profileCompletionRouteId = 'profile-completion' diff --git a/src/apps/customer-portal/src/customer-portal.routes.tsx b/src/apps/customer-portal/src/customer-portal.routes.tsx index fd5b6e0e1..2ee282745 100644 --- a/src/apps/customer-portal/src/customer-portal.routes.tsx +++ b/src/apps/customer-portal/src/customer-portal.routes.tsx @@ -11,11 +11,10 @@ import { } from '~/libs/core' import { - profileCompletionRouteId, rootRoute, + talentSearchRouteId, } from './config/routes.config' import { customerPortalTalentSearchRoutes } from './pages/talent-search/talent-search.routes' -import { customerPortalProfileCompletionRoutes } from './pages/profile-completion/profile-completion.routes' const CustomerPortalApp: LazyLoadedComponent = lazyLoad(() => import('./CustomerPortalApp')) @@ -28,10 +27,9 @@ export const customerPortalRoutes: ReadonlyArray = [ children: [ { authRequired: true, - element: , + element: , route: '', }, - ...customerPortalProfileCompletionRoutes, ...customerPortalTalentSearchRoutes, ], domain: AppSubdomain.customer, diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts index d1292ef52..c76f23ed4 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts +++ b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts @@ -2,7 +2,6 @@ import _ from 'lodash' import { TabsNavItem } from '~/libs/ui' import { - profileCompletionRouteId, talentSearchRouteId, } from '~/apps/customer-portal/src/config/routes.config' @@ -10,9 +9,6 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean, isUnpri const tabs: TabsNavItem[] = [ ...(!isUnprivilegedUser ? [{ - id: profileCompletionRouteId, - title: 'Profile Completion', - }, { id: talentSearchRouteId, title: 'Talent Search', }] : []), diff --git a/src/apps/customer-portal/src/lib/services/index.ts b/src/apps/customer-portal/src/lib/services/index.ts index d67943da3..9e33c22a5 100644 --- a/src/apps/customer-portal/src/lib/services/index.ts +++ b/src/apps/customer-portal/src/lib/services/index.ts @@ -1,2 +1 @@ -export * from './profileCompletion.service' export * from './talentSearch.service' diff --git a/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts b/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts deleted file mode 100644 index f0674b639..000000000 --- a/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { EnvironmentConfig } from '~/config' -import { UserSkill, xhrGetAsync } from '~/libs/core' - -export type CompletedProfile = { - countryCode?: string - countryName?: string - city?: string - firstName?: string - handle: string - lastName?: string - photoURL?: string - skillCount?: number - userId?: number | string - isOpenToWork?: boolean | null - openToWork?: { - availability?: string - preferredRoles?: string[] - } | null -} - -export type CompletedProfilesResponse = { - data: CompletedProfile[] - page: number - perPage: number - total: number - totalPages: number -} - -export const DEFAULT_PAGE_SIZE = 50 - -function normalizeToList(raw: any): any[] { - if (Array.isArray(raw)) { - return raw - } - - if (Array.isArray(raw?.data)) { - return raw.data - } - - if (Array.isArray(raw?.result?.content)) { - return raw.result.content - } - - if (Array.isArray(raw?.result)) { - return raw.result - } - - return [] -} - -function normalizeCompletedProfilesResponse( - raw: any, - fallbackPage: number, - fallbackPerPage: number, -): CompletedProfilesResponse { - if (raw && Array.isArray(raw.data)) { - const total: number = Number(raw.total ?? raw.data.length) - const perPage: number = Number(raw.perPage ?? fallbackPerPage) - const page: number = Number(raw.page ?? fallbackPage) - const safePerPage = Number.isFinite(perPage) ? Math.max(perPage, 1) : fallbackPerPage - const safeTotal = Number.isFinite(total) ? Math.max(total, 0) : raw.data.length - - return { - data: raw.data, - page: Number.isFinite(page) ? Math.max(page, 1) : fallbackPage, - perPage: safePerPage, - total: safeTotal, - totalPages: Number.isFinite(raw.totalPages) - ? Math.max(Number(raw.totalPages), 1) - : Math.max(Math.ceil(safeTotal / safePerPage), 1), - } - } - - const rows = normalizeToList(raw) - const total = Number(raw?.total ?? rows.length) - const safeTotal = Number.isFinite(total) ? Math.max(total, 0) : rows.length - - return { - data: rows, - page: fallbackPage, - perPage: fallbackPerPage, - total: safeTotal, - totalPages: Math.max(Math.ceil(safeTotal / fallbackPerPage), 1), - } -} - -export type OpenToWorkFilter = 'all' | 'yes' | 'no' - -export async function fetchCompletedProfiles( - countryCode: string | undefined, - page: number, - perPage: number, - openToWorkFilter?: OpenToWorkFilter, - skillIds?: string[], -): Promise { - const queryParams = new URLSearchParams({ - page: String(page), - perPage: String(perPage), - }) - - if (countryCode) { - queryParams.set('countryCode', countryCode) - } - - if (openToWorkFilter === 'yes') { - queryParams.set('openToWork', 'true') - } - - if (openToWorkFilter === 'no') { - queryParams.set('openToWork', 'false') - } - - if (Array.isArray(skillIds) && skillIds.length > 0) { - skillIds.forEach(id => { - if (id) { - queryParams.append('skillId', String(id)) - } - }) - } - - const response = await xhrGetAsync( - `${EnvironmentConfig.REPORTS_API}/topcoder/completed-profiles?${queryParams.toString()}`, - ) - - return normalizeCompletedProfilesResponse(response, page, perPage) -} - -export async function fetchMemberSkillsData(userId: string | number | undefined): Promise { - if (!userId) { - return [] - } - - const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills` - const url = `${baseUrl}/user-skills/${userId}?disablePagination=true` - - try { - return await xhrGetAsync(url) - } catch { - // If skills API fails, return empty array to not block the page - return [] - } -} diff --git a/src/apps/customer-portal/src/lib/services/talentSearch.service.ts b/src/apps/customer-portal/src/lib/services/talentSearch.service.ts index a8fc905ed..14d34dd87 100644 --- a/src/apps/customer-portal/src/lib/services/talentSearch.service.ts +++ b/src/apps/customer-portal/src/lib/services/talentSearch.service.ts @@ -21,16 +21,17 @@ export type SearchTalent = { } export type MemberSearchPayload = { + countries?: string[] limit: number - openToWork: boolean + openToWork?: boolean page: number - recentlyActive: boolean + recentlyActive?: boolean skillSearchType: 'OR' skills: Array<{ id: string wins: number }> - verifiedProfile: boolean + verifiedProfile?: boolean } export type MemberSearchResponse = { diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss deleted file mode 100644 index ec7051428..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss +++ /dev/null @@ -1,218 +0,0 @@ -@import '@libs/ui/styles/includes'; - -.container { - display: flex; - flex-direction: column; - gap: $sp-4; -} - -.headerRow { - display: flex; - align-items: flex-end; - gap: $sp-4; - justify-content: space-between; - - @include ltemd { - flex-direction: column; - align-items: stretch; - } -} - -.filterWrapper { - display: flex; - gap: $sp-4; - - :global([class*='__value-container']) { - min-height: 18px; - } - - @include ltemd { - flex-direction: column; - align-items: stretch; - } - - .filterWrap { - min-width: 280px; - max-width: 360px; - - @include ltemd { - max-width: unset; - min-width: unset; - width: 100%; - } - } -} - -.counterCard { - border: 1px solid $black-20; - border-radius: $sp-2; - background: $tc-white; - padding: $sp-4; - min-width: 260px; - display: flex; - flex-direction: column; - gap: $sp-1; -} - -.counterLabel { - color: $black-60; - font-size: 12px; - line-height: 16px; - font-weight: 600; - text-transform: uppercase; -} - -.counterValue { - color: $black-100; - font-size: 32px; - line-height: 36px; - font-weight: 700; - font-family: 'Nunito Sans', sans-serif; -} - -.loadingWrap { - position: relative; - height: 90px; - - .spinner { - background: none; - } -} - -.errorMessage { - color: $red-100; - font-size: 14px; - line-height: 20px; - font-weight: 700; -} - -.emptyMessage { - color: $black-60; - font-size: 14px; - line-height: 20px; -} - -.tableWrap { - overflow: auto; - border: 1px solid $black-20; - border-radius: $sp-2; - - table { - width: 100%; - border-collapse: collapse; - min-width: 1120px; - } - - th, - td { - text-align: left; - padding: $sp-3 $sp-4; - border-bottom: 1px solid $black-20; - font-size: 14px; - line-height: 20px; - } - - th { - color: $black-100; - font-weight: 700; - background: $black-5; - } - - td { - color: $black-100; - vertical-align: middle; - } - - tr:last-child td { - border-bottom: 0; - } -} - -.memberCell { - display: flex; - align-items: center; - gap: $sp-2; -} - -.avatar { - width: 28px; - height: 28px; - border-radius: 50%; - object-fit: cover; - border: 1px solid $black-20; -} - -.paginationRow { - display: flex; - align-items: center; - justify-content: space-between; - gap: $sp-3; - - @include ltemd { - flex-direction: column; - align-items: flex-start; - } -} - -.paginationInfo { - color: $black-60; - font-size: 14px; - line-height: 20px; -} - -.paginationButtons { - display: flex; - align-items: center; - gap: $sp-2; -} - -.skillsList { - display: flex; - flex-wrap: wrap; - gap: $sp-2; -} - -.skillTag { - display: inline-block; - background: $black-5; - border: 1px solid $black-20; - border-radius: $sp-1; - padding: $sp-1 $sp-2; - font-size: 12px; - line-height: 16px; - color: $black-80; - white-space: nowrap; -} - -.moreIndicator { - display: inline-block; - background: $black-5; - border: 1px solid $black-20; - border-radius: $sp-1; - padding: $sp-1 $sp-2; - font-size: 12px; - line-height: 16px; - color: $black-80; - font-weight: 700; - min-width: 24px; - text-align: center; - cursor: help; -} - -.link { - display: flex; - gap: $sp-1; - text-decoration: underline; - color: $link-blue; - cursor: pointer; -} - -.openToWorkYes { - color: $green-100; - font-weight: 600; -} - -.openToWorkNo { - color: $red-100; - font-weight: 600; -} diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx deleted file mode 100644 index 0434e1826..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx +++ /dev/null @@ -1,409 +0,0 @@ -/* eslint-disable react/jsx-no-bind */ -/* eslint-disable no-await-in-loop */ -/* eslint-disable complexity */ -import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' -import useSWR, { SWRResponse } from 'swr' - -import { EnvironmentConfig } from '~/config' -import { CountryLookup, useCountryLookup, UserSkill, UserSkillDisplayModes } from '~/libs/core' -import { - Button, - InputMultiselect, - InputMultiselectOption, - InputSelect, - InputSelectOption, - LoadingSpinner, - Tooltip, -} from '~/libs/ui' -import { fetchSkillAutocompleteOptions } from '~/libs/shared' -import { getPreferredRoleLabelByValue } from '~/libs/shared/lib/utils/roles' - -import { PageWrapper } from '../../../lib' -import { - CompletedProfilesResponse, - DEFAULT_PAGE_SIZE, - fetchCompletedProfiles, - fetchMemberSkillsData, - type OpenToWorkFilter, -} from '../../../lib/services/profileCompletion.service' - -import styles from './ProfileCompletionPage.module.scss' - -const DISPLAY_SKILLS_COUNT = 5 - -export const ProfileCompletionPage: FC = () => { - const [selectedCountry, setSelectedCountry] = useState('all') - const [currentPage, setCurrentPage] = useState(1) - const [selectedOpenToWork, setSelectedOpenToWork] = useState('all') - const [selectedSkills, setSelectedSkills] = useState([]) - const [memberSkills, setMemberSkills] = useState>(new Map()) - const [skillOptionsLoading, setSkillOptionsLoading] = useState(false) - const countryLookup: CountryLookup[] | undefined = useCountryLookup() - - const countryCodeFilter = selectedCountry === 'all' ? undefined : selectedCountry - - const loadSkillOptions = useCallback(async (query: string): Promise => { - setSkillOptionsLoading(true) - try { - return await fetchSkillAutocompleteOptions(query) - } catch { - return [] - } finally { - setSkillOptionsLoading(false) - } - }, []) - - const { data, error, isValidating }: SWRResponse = useSWR( - // eslint-disable-next-line max-len - `customer-portal-completed-profiles:${countryCodeFilter || 'all'}:${selectedOpenToWork}:${currentPage}:${DEFAULT_PAGE_SIZE}:${selectedSkills.map(skill => skill.value) - .sort() - .join(',')}`, - () => fetchCompletedProfiles( - countryCodeFilter, - currentPage, - DEFAULT_PAGE_SIZE, - selectedOpenToWork, - selectedSkills.map(skill => skill.value), - ), - { - revalidateOnFocus: false, - }, - ) - - // Fetch member skills for all profiles on the current page - useEffect(() => { - if (!data?.data || data.data.length === 0) return - - const fetchAllMemberSkills = async (): Promise => { - const skillsMap = new Map() - - for (const profile of data.data) { - if (profile.userId && !memberSkills.has(profile.userId)) { - const skills = await fetchMemberSkillsData(profile.userId) - skillsMap.set(profile.userId, skills) - } - } - - if (skillsMap.size > 0) { - setMemberSkills(prevSkills => { - const newMap = new Map(prevSkills) - skillsMap.forEach((skills, userId) => { - newMap.set(userId, skills) - }) - return newMap - }) - } - } - - fetchAllMemberSkills() - }, [data?.data]) - - const countryMap = useMemo(() => { - const map = new Map() - const countries = countryLookup || [] - - countries.forEach((country: CountryLookup) => { - if (country.countryCode) { - map.set(country.countryCode, country.country) - } - }) - - return map - }, [countryLookup]) - - const countryOptions = useMemo(() => { - const staticOptions = (countryLookup || []) - .filter(country => !!country.countryCode) - .map(country => ({ - label: country.country, - value: country.countryCode, - })) - .sort((a, b) => String(a.label) - .localeCompare(String(b.label))) - - const seen = new Set(staticOptions.map(option => option.value)) - const dynamicOptions = (data?.data || []) - .filter(profile => !!profile.countryCode && !seen.has(String(profile.countryCode))) - .map(profile => ({ - label: ( - countryMap.get(String(profile.countryCode)) - || profile.countryName - || String(profile.countryCode) - ), - value: String(profile.countryCode), - })) - .sort((a, b) => String(a.label) - .localeCompare(String(b.label))) - - return [ - { - label: 'All Countries', - value: 'all', - }, - ...staticOptions, - ...dynamicOptions, - ] - }, [countryLookup, countryMap, data?.data]) - - const profiles = data?.data || [] - const totalProfiles = data?.total || 0 - const totalPages = data?.totalPages || 1 - - const displayedRows = useMemo(() => profiles - .map(profile => { - const userSkills = profile.userId ? (memberSkills.get(profile.userId) || []) : [] - - // Prioritize principal skills, then add additional skills - const principalSkills = [ - ...userSkills.filter(skill => skill.displayMode?.name === UserSkillDisplayModes.principal), - ] - - const displayedSkills = principalSkills.slice(0, DISPLAY_SKILLS_COUNT) - const remainingSkillsText = principalSkills.slice(DISPLAY_SKILLS_COUNT) - .map(skill => skill.name) - .filter(Boolean) - .join(', ') - const additionalSkillsCount = Math.max(0, principalSkills.length - DISPLAY_SKILLS_COUNT) - - const isOpenToWork = profile.isOpenToWork === true - const openToWorkLabel = isOpenToWork ? 'Yes' : 'No' - const openToWorkRolesText = profile.openToWork?.preferredRoles && profile.openToWork.preferredRoles.length - ? profile.openToWork.preferredRoles.map(getPreferredRoleLabelByValue) - .filter(Boolean) - .join(', ') - : 'No role preferences set' - - return { - ...profile, - additionalSkillsCount, - countryLabel: profile.countryCode - ? countryMap.get(profile.countryCode) || profile.countryName || profile.countryCode - : profile.countryName || '-', - displayedSkills, - fullName: [profile.firstName, profile.lastName].filter(Boolean) - .join(' ') - .trim(), - isOpenToWork, - locationLabel: [profile.city, profile.countryCode - ? countryMap.get(profile.countryCode) || profile.countryName || profile.countryCode - : profile.countryName] - .filter(Boolean) - .join(', '), - openToWorkLabel, - openToWorkRolesText, - remainingSkillsText, - } - }) - .sort((a, b) => a.handle.localeCompare(b.handle)), [profiles, countryMap, memberSkills]) - - const isPreviousDisabled = currentPage <= 1 || isValidating - const isNextDisabled = isValidating || currentPage >= totalPages - - return ( - -
-
-
- ) => { - setSelectedCountry(event.target.value || 'all') - setCurrentPage(1) - }} - placeholder='Select country' - /> -
-
- ) => { - setSelectedOpenToWork((event.target.value || 'all') as OpenToWorkFilter) - setCurrentPage(1) - }} - placeholder='Select' - /> -
-
- ) => { - const value = (event.target.value || []) as InputMultiselectOption[] - setSelectedSkills(value) - setCurrentPage(1) - }} - /> -
-
-
- Fully Completed Profiles - {totalProfiles} -
-
- - {isValidating && !data && ( -
- -
- )} - - {!isValidating && !!error && ( -
- Failed to load profile completion data. -
- )} - - {!error && !isValidating && displayedRows.length === 0 && ( -
- No fully completed profiles found for the selected country. -
- )} - - {!error && displayedRows.length > 0 && ( - <> -
- - - - - - - - - - - - - {displayedRows.map(profile => ( - - - - - - - - - ))} - -
MemberHandleLocationOpen to WorkPrincipal Skills{' '}
-
- {profile.photoURL && ( - {profile.handle} - )} - {profile.fullName || '-'} -
-
- - {profile.handle} - - {profile.locationLabel || profile.countryLabel} - { - profile.openToWorkLabel === 'Yes' ? ( - - - {profile.openToWorkLabel} - - - ) : ( - - {profile.openToWorkLabel} - - ) - } - - {profile.displayedSkills && profile.displayedSkills.length > 0 ? ( -
- {profile.displayedSkills.map(skill => ( - - {skill.name} - - ))} - {profile.additionalSkillsCount > 0 && ( - - - + - {profile.additionalSkillsCount} - {' '} - skills - - - )} -
- ) : ( - '-' - )} -
- - Go to profile - -
-
-
- - Page - {' '} - {currentPage} - {' '} - of - {' '} - {totalPages} - -
- - -
-
- - )} -
- ) -} - -export default ProfileCompletionPage diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts deleted file mode 100644 index 4d99c8c31..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as ProfileCompletionPage } from './ProfileCompletionPage' diff --git a/src/apps/customer-portal/src/pages/profile-completion/index.ts b/src/apps/customer-portal/src/pages/profile-completion/index.ts deleted file mode 100644 index 73dcadd92..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ProfileCompletionPage' diff --git a/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx b/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx deleted file mode 100644 index 42042bc0f..000000000 --- a/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' - -import { profileCompletionRouteId } from '../../config/routes.config' - -const ProfileCompletionPage: LazyLoadedComponent = lazyLoad( - () => import('./ProfileCompletionPage'), - 'ProfileCompletionPage', -) - -export const profileCompletionChildRoutes = [ - { - authRequired: true, - element: , - id: 'profile-completion-page', - route: '', - }, -] - -export const customerPortalProfileCompletionRoutes = [ - { - children: [...profileCompletionChildRoutes], - element: getRoutesContainer(profileCompletionChildRoutes), - id: profileCompletionRouteId, - route: profileCompletionRouteId, - }, -] diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index c54a96a34..cbc953423 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -28,6 +28,8 @@ import personSearchImage from '../../../lib/assets/person-search.png' import styles from './TalentSearchPage.module.scss' +type TalentSearchSortOption = 'alphabetical' | 'matching-index' + export const TalentSearchPage: FC = () => { const skipNextAutoSearchRef = useRef(false) const searchGenerationRef = useRef(0) // ← add this @@ -37,53 +39,95 @@ export const TalentSearchPage: FC = () => { const [jobDescription, setJobDescription] = useState('') const [isExtractingSkills, setIsExtractingSkills] = useState(false) const [errorMessage, setErrorMessage] = useState('') - const [hasSearched, setHasSearched] = useState(false) + const [hasSearched, setHasSearched] = useState(true) const [skillOptionsLoading, setSkillOptionsLoading] = useState(false) const [selectedSkills, setSelectedSkills] = useState([]) - const [selectedCountry, setSelectedCountry] = useState('all') - const [onlyOpenToWork, setOnlyOpenToWork] = useState(true) - const [onlyActive, setOnlyActive] = useState(true) - const [isSearchingMembers, setIsSearchingMembers] = useState(false) + const [sortBy, setSortBy] = useState('alphabetical') + const [selectedCountries, setSelectedCountries] = useState([]) + const [onlyOpenToWork, setOnlyOpenToWork] = useState(false) + const [onlyActive, setOnlyActive] = useState(false) + const [isSearchingMembers, setIsSearchingMembers] = useState(true) const [isLoadingMore, setIsLoadingMore] = useState(false) const [results, setResults] = useState([]) const [totalResults, setTotalResults] = useState(0) const [currentPage, setCurrentPage] = useState(1) - const [isLoading, setIsLoading] = useState(false) - const countryOptions = useMemo( - (): InputSelectOption[] => [ - { label: 'All Countries', value: 'all' }, - ...((countryLookup || []) - .map(country => ({ - label: country.country, - value: country.countryCode, - })) - .filter(option => option.label && option.value) - .sort((a, b) => String(a.label) - .localeCompare(String(b.label)))), - ], + const countryNameByCode = useMemo((): Map => new Map( + (countryLookup || []) + .filter(country => country.countryCode && country.country) + .map(country => [country.countryCode.toUpperCase(), country.country]), + ), [countryLookup]) + const countryFilterOptions = useMemo( + (): InputMultiselectOption[] => (countryLookup || []) + .map(country => ({ + label: country.country, + value: country.countryCode, + })) + .filter(option => option.label && option.value) + .sort((a, b) => String(a.label) + .localeCompare(String(b.label))), [countryLookup], ) + const selectedCountryCodesList = useMemo( + (): string[] => selectedCountries + .map(country => String(country.value || '') + .trim() + .toUpperCase()) + .filter(Boolean), + [selectedCountries], + ) + + const hasSkillSearch = selectedSkills.length > 0 + const activeSort: TalentSearchSortOption = hasSkillSearch ? 'matching-index' : sortBy + const sortOptions = useMemo( + (): InputSelectOption[] => (hasSkillSearch + ? [{ label: 'Matching Index', value: 'matching-index' }] + : [{ label: 'Alphabetical', value: 'alphabetical' }]), + [hasSkillSearch], + ) const filteredResults = useMemo(() => results.filter(talent => { - if (selectedCountry !== 'all') { - const selectedCountryOption = countryOptions.find(option => option.value === selectedCountry) - const selectedCountryName = typeof selectedCountryOption?.label === 'string' - ? selectedCountryOption.label - : '' - const normalizedLocation = talent.location.toLowerCase() - - if (!selectedCountryName || !normalizedLocation.includes(selectedCountryName.toLowerCase())) { - return false - } + if (onlyActive && !talent.isRecentlyActive) { + return false } - if (onlyActive && !talent.isRecentlyActive) { + if (onlyOpenToWork && !talent.openToWork) { return false } return true - }), [countryOptions, onlyActive, results, selectedCountry]) + }), [onlyActive, onlyOpenToWork, results]) + const displayedResults = useMemo(() => { + const sorted = [...filteredResults] + if (activeSort === 'matching-index') { + sorted.sort((a, b) => b.matchIndex - a.matchIndex) + return sorted + } + + sorted.sort((a, b) => String(a.handle || '') + .localeCompare(String(b.handle || ''), undefined, { sensitivity: 'base' })) + return sorted + }, [activeSort, filteredResults]) + + const foundMembersCount = totalResults || displayedResults.length + const displayedResultsWithCountryName = useMemo( + () => displayedResults.map(talent => { + const code = String(talent.location || '') + .trim() + .toUpperCase() + const countryName = countryNameByCode.get(code) + + if (!countryName) { + return talent + } + + return { + ...talent, + location: countryName, + } + }), + [countryNameByCode, displayedResults], + ) const hasMoreResults = results.length < totalResults const loadSkillOptions = useCallback(async (query: string): Promise => { @@ -96,11 +140,23 @@ export const TalentSearchPage: FC = () => { setSkillOptionsLoading(false) } }, []) + const loadCountryOptions = useCallback(async (query: string): Promise => { + const normalizedQuery = query.trim() + .toLowerCase() + if (!normalizedQuery) { + return countryFilterOptions + } + + return countryFilterOptions.filter(option => String(option.label || '') + .toLowerCase() + .includes(normalizedQuery)) + }, [countryFilterOptions]) const runMemberSearch = useCallback(async ( skillsToSearch: InputMultiselectOption[], overrides?: { append?: boolean + countries?: string[] generation?: number openToWork?: boolean page?: number @@ -108,15 +164,15 @@ export const TalentSearchPage: FC = () => { }, ): Promise => { const append = overrides?.append === true + const countries = (overrides?.countries ?? selectedCountryCodesList) + .filter(Boolean) const generation = overrides?.generation const openToWork = overrides?.openToWork ?? onlyOpenToWork const page = overrides?.page ?? 1 const recentlyActive = overrides?.recentlyActive ?? onlyActive const payload: MemberSearchPayload = { limit: MEMBER_SEARCH_LIMIT, - openToWork, page, - recentlyActive, skills: skillsToSearch .map(skill => String(skill.value || '') .trim()) @@ -126,13 +182,24 @@ export const TalentSearchPage: FC = () => { wins: 1, })), skillSearchType: 'OR', - verifiedProfile: true, } + + if (countries.length > 0) { + payload.countries = countries + } + + if (openToWork) { + payload.openToWork = true + } + + if (recentlyActive) { + payload.recentlyActive = true + } + if (append) { setIsLoadingMore(true) } else { setIsSearchingMembers(true) - setIsLoading(true) } setErrorMessage('') @@ -177,24 +244,29 @@ export const TalentSearchPage: FC = () => { setIsLoadingMore(false) } else { setIsSearchingMembers(false) - setIsLoading(false) } } - }, [onlyActive, onlyOpenToWork]) + }, [onlyActive, onlyOpenToWork, selectedCountryCodesList]) const clearAllFilters = useCallback((): void => { searchGenerationRef.current += 1 - setSelectedCountry('all') - setOnlyOpenToWork(true) - setOnlyActive(true) + setSelectedCountries([]) + setOnlyOpenToWork(false) + setOnlyActive(false) + setSortBy('alphabetical') setSelectedSkills([]) - setHasSearched(false) - setResults([]) - setTotalResults(0) - setCurrentPage(1) + setHasSearched(true) setErrorMessage('') + skipNextAutoSearchRef.current = true setLastSearchedDescription('') - }, []) + runMemberSearch([], { + countries: [], + generation: searchGenerationRef.current, + openToWork: false, + page: 1, + recentlyActive: false, + }) + }, [runMemberSearch]) const handleAiSearch = useCallback(async (): Promise => { const normalizedDescription = jobDescription.trim() @@ -240,6 +312,7 @@ export const TalentSearchPage: FC = () => { setTotalResults(0) setHasSearched(true) setErrorMessage('No skills were extracted from the job description.') + skipNextAutoSearchRef.current = true return } @@ -263,7 +336,7 @@ export const TalentSearchPage: FC = () => { }, [isExtractingSkills, jobDescription, runMemberSearch]) useEffect(() => { - if (!hasSearched || isExtractingSkills || selectedSkills.length === 0) { + if (!hasSearched || isExtractingSkills) { return } @@ -272,18 +345,19 @@ export const TalentSearchPage: FC = () => { return } - runMemberSearch(selectedSkills, { generation: searchGenerationRef.current }) + runMemberSearch(selectedSkills, { generation: searchGenerationRef.current, page: 1 }) }, [ hasSearched, isExtractingSkills, onlyActive, onlyOpenToWork, runMemberSearch, + selectedCountries, selectedSkills, ]) const handleLoadMore = useCallback((): void => { - if (isLoadingMore || isSearchingMembers || !hasMoreResults || selectedSkills.length === 0) { + if (isLoadingMore || isSearchingMembers || !hasMoreResults) { return } @@ -375,13 +449,15 @@ export const TalentSearchPage: FC = () => { />
- ) => { - setSelectedCountry(event.target.value || 'all') + const value = (event.target.value || []) as InputMultiselectOption[] + setSelectedCountries(value) }} placeholder='Select country' /> @@ -460,40 +536,64 @@ export const TalentSearchPage: FC = () => { {hasSearched && (
- {isLoading ? ( + {!isSearchingMembers && ( +
+

+ We have found  + + {`${foundMembersCount} members`} + +  that match your search. +

+
+ Sort by + ) => { + const nextSort = event.target.value || 'alphabetical' + setSortBy( + nextSort as TalentSearchSortOption, + ) + }} + /> +
+
+ )} + {isSearchingMembers && (

Searching talent...

- ) : ( + )} + {!isSearchingMembers && displayedResults.length === 0 && ( +
+

No matching talent found

+

Try changing filters or using a different job description.

+
+ )} + {!isSearchingMembers && displayedResults.length > 0 && ( <> - - {filteredResults.length === 0 ? ( -
-

No matching talent found

-

Try changing filters or using a different job description.

+
+ {displayedResultsWithCountryName.map(talent => ( + + ))} +
+ {hasMoreResults && ( +
+
- ) : ( - <> -
- {filteredResults.map(talent => ( - - ))} -
- {hasMoreResults && ( -
- -
- )} - )} )} diff --git a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss index 83b5982ba..2cea74382 100644 --- a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss +++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss @@ -194,6 +194,10 @@ font-weight: 400; } +.cardFooterWithoutMatch { + justify-content: flex-end; +} + .footerMatched { display: flex; align-items: center; diff --git a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx index 7d636e228..b2f47e90f 100644 --- a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx @@ -30,6 +30,7 @@ interface TalentResultCardTalent { interface TalentResultCardProps { talent: TalentResultCardTalent + showSkillMatch: boolean } function getUniqueMatchedSkills(talent: TalentResultCardTalent): TalentResultCardTalent['matchedSkills'] { @@ -68,6 +69,7 @@ function buildMatchedSkillsTooltipContent( export const TalentResultCard: FC = (props: TalentResultCardProps) => { const talent: TalentResultCardTalent = props.talent + const showSkillMatch = props.showSkillMatch const uniqueSkills = useMemo(() => getUniqueMatchedSkills(talent), [talent]) const isVerifiedProfile = talent.isVerified === true const displayName = String(talent.name || '') @@ -105,9 +107,11 @@ export const TalentResultCard: FC = (props: TalentResultC
{displayHandle} - - {`${talent.matchIndex}% Match`} - + {showSkillMatch && ( + + {`${talent.matchIndex}% Match`} + + )}

{talent.name}

@@ -148,27 +152,29 @@ export const TalentResultCard: FC = (props: TalentResultC
-
-
- - {`${uniqueSkills.length} ${matchedSkillLabel}`} - - {uniqueSkills.length > 0 && ( - - - - )} -
+ + + )} +
+ )}