From 8869a7e204ad97e2a16ea295eaf7d4543f71d490 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 6 Aug 2026 13:22:59 +0300 Subject: [PATCH 1/3] PM-4621 - general statistics --- package.json | 4 +- .../src/config/routes.config.ts | 1 + .../src/customer-portal.routes.tsx | 2 + .../components/NavTabs/config/tabs-config.ts | 4 + .../customer-portal/src/lib/services/index.ts | 1 + .../src/lib/services/statistics.service.ts | 173 +++++++++++ .../StatisticsPage/StatisticsPage.module.scss | 279 ++++++++++++++++++ .../StatisticsPage/StatisticsPage.tsx | 250 ++++++++++++++++ .../statistics/StatisticsPage/WorldMap.tsx | 118 ++++++++ .../StatisticsPage/assets/1st-place.svg | 5 + .../StatisticsPage/assets/2nd-place.svg | 5 + .../StatisticsPage/assets/3rd-place.svg | 5 + .../statistics/StatisticsPage/assets/index.ts | 9 + .../pages/statistics/StatisticsPage/index.ts | 1 + .../pages/statistics/statistics.routes.tsx | 26 ++ yarn.lock | 10 + 16 files changed, 892 insertions(+), 1 deletion(-) create mode 100644 src/apps/customer-portal/src/lib/services/statistics.service.ts create mode 100644 src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss create mode 100644 src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx create mode 100644 src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx create mode 100644 src/apps/customer-portal/src/pages/statistics/StatisticsPage/assets/1st-place.svg create mode 100644 src/apps/customer-portal/src/pages/statistics/StatisticsPage/assets/2nd-place.svg create mode 100644 src/apps/customer-portal/src/pages/statistics/StatisticsPage/assets/3rd-place.svg create mode 100644 src/apps/customer-portal/src/pages/statistics/StatisticsPage/assets/index.ts create mode 100644 src/apps/customer-portal/src/pages/statistics/StatisticsPage/index.ts create mode 100644 src/apps/customer-portal/src/pages/statistics/statistics.routes.tsx diff --git a/package.json b/package.json index bf291bac4..830f320f7 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@datadog/browser-logs": "^4.50.1", "@hello-pangea/dnd": "^18.0.1", "@heroicons/react": "^1.0.6", + "@highcharts/map-collection": "^2.3.3", "@hookform/resolvers": "^4.1.3", "@popperjs/core": "^2.11.8", "@sprig-technologies/sprig-browser": "^2.39.0", @@ -125,7 +126,8 @@ "typescript": "^4.9.5", "universal-navigation": "https://github.com/topcoder-platform/universal-navigation#master", "uuid": "^11.1.0", - "yup": "^1.7.1" + "yup": "^1.7.1", + "flag-icons": "^6.7.0" }, "devDependencies": { "@babel/core": "^7.29.6", diff --git a/src/apps/customer-portal/src/config/routes.config.ts b/src/apps/customer-portal/src/config/routes.config.ts index e27859747..c45b3e81e 100644 --- a/src/apps/customer-portal/src/config/routes.config.ts +++ b/src/apps/customer-portal/src/config/routes.config.ts @@ -11,3 +11,4 @@ export const rootRoute: string export const talentSearchRouteId = 'talent-search' export const showcaseSearchRouteId = 'showcase' export const flexiTalentRouteId = 'flexi-talent' +export const statisticsRouteId = 'statistics' diff --git a/src/apps/customer-portal/src/customer-portal.routes.tsx b/src/apps/customer-portal/src/customer-portal.routes.tsx index badc4f77b..3e9cb2249 100644 --- a/src/apps/customer-portal/src/customer-portal.routes.tsx +++ b/src/apps/customer-portal/src/customer-portal.routes.tsx @@ -17,6 +17,7 @@ import { import { customerPortalFlexiTalentRoutes } from './pages/flexi-talent/flexi-talent.routes' import { customerPortalTalentSearchRoutes } from './pages/talent-search/talent-search.routes' import { customerPortalProjectShowcaseRoutes } from './pages/project-showcase/project-showcase.routes' +import { customerPortalStatisticsRoutes } from './pages/statistics/statistics.routes' const CustomerPortalApp: LazyLoadedComponent = lazyLoad(() => import('./CustomerPortalApp')) @@ -32,6 +33,7 @@ export const customerPortalRoutes: ReadonlyArray = [ element: , route: '', }, + ...customerPortalStatisticsRoutes, ...customerPortalTalentSearchRoutes, ...customerPortalProjectShowcaseRoutes, ...customerPortalFlexiTalentRoutes, 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 6a831b4d8..c01e6ff0e 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 @@ -4,6 +4,7 @@ import { TabsNavItem } from '~/libs/ui' import { flexiTalentRouteId, showcaseSearchRouteId, + statisticsRouteId, talentSearchRouteId, } from '~/apps/customer-portal/src/config/routes.config' @@ -11,6 +12,9 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean, isUnpri const tabs: TabsNavItem[] = [ ...(!isUnprivilegedUser ? [{ + id: statisticsRouteId, + title: 'General Statistics', + }, { 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 932881090..76e7e10f9 100644 --- a/src/apps/customer-portal/src/lib/services/index.ts +++ b/src/apps/customer-portal/src/lib/services/index.ts @@ -1,3 +1,4 @@ export * from './talentSearch.service' export * from './flexiTalent.service' export * from './showcasePost.service' +export * from './statistics.service' diff --git a/src/apps/customer-portal/src/lib/services/statistics.service.ts b/src/apps/customer-portal/src/lib/services/statistics.service.ts new file mode 100644 index 000000000..c2bae70b9 --- /dev/null +++ b/src/apps/customer-portal/src/lib/services/statistics.service.ts @@ -0,0 +1,173 @@ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' +import worldMap from '@highcharts/map-collection/custom/world.topo.json' + +export const ISO3_TO_2 = new Map( + ((worldMap as any)?.objects?.default?.geometries ?? []) + .map((geometry: any) => { + const properties = geometry?.properties + const iso3 = String(properties?.['iso-a3'] ?? '') + .toUpperCase() + const iso2 = String(properties?.['iso-a2'] ?? '') + .toUpperCase() + + return [iso3, iso2] as [string, string] + }) + .filter(([iso3, iso2]: [string, string]) => iso3 && iso2), +) + +function toAlpha2CountryCode(code: string): string { + const normalized = String(code || '') + .trim() + .toUpperCase() + return normalized.length === 3 ? ISO3_TO_2.get(normalized) ?? normalized : normalized +} + +export type StatisticsCountry = { + code?: string + count: number + flagUrl?: string + name: string +} + +export type GeneralStatistics = { + completedChallenges: number + countries: StatisticsCountry[] + memberCount: number + totalPrizes: number +} + +type CountryReportRow = { + 'challenge_stats.count'?: number | string + 'country.country_name'?: string + 'user.count'?: number | string +} + +type CountryLookupRow = { + countryCode?: string + countryFlag?: string + name?: string +} + +type CountryLookupResponse = { + result?: CountryLookupRow[] +} + +const GENERAL_STATISTICS_URL = `${EnvironmentConfig.REPORTS_API}/statistics/general` +const COUNTRY_LOOKUP_URL = `${EnvironmentConfig.API.V6}/lookups/countries?page=1&perPage=9999` + +const COUNTRY_NAME_ALIASES: Record = { + 'bosnia and herzegovina': 'bosnia and herzegowina', + 'czech republic': 'czechia', + 'iran islamic republic of': 'iran', + 'korea democratic peoples republic of': 'north korea', + 'korea republic of': 'south korea', + 'lao peoples democratic republic': 'laos', + // 'macedonia the former yugoslav republic of': 'north macedonia', + 'macedonia the former yugoslav republic of': 'macedonia former yugoslav rep of', + 'moldova republic of': 'moldova', + 'russian federation': 'russia', + 'syrian arab republic': 'syria', + 'taiwan province of china': 'taiwan', + 'tanzania united republic of': 'tanzania', + 'united states': 'united states of america', + 'venezuela bolivarian republic of': 'venezuela', + 'viet nam': 'vietnam', +} + +function normalizeCountryName(name: string): string { + const normalized = name + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9]+/g, ' ') + .trim() + .toLowerCase() + + return COUNTRY_NAME_ALIASES[normalized] || normalized +} + +function unwrapCountryLookups(response: CountryLookupResponse | CountryLookupRow[]): CountryLookupRow[] { + if (Array.isArray(response)) { + return response + } + + return Array.isArray(response?.result) ? response.result : [] +} + +function normalizeCountryRows( + rows: CountryReportRow[], + countKey: 'challenge_stats.count' | 'user.count', + lookups: CountryLookupRow[], +): StatisticsCountry[] { + const lookupsByName = new Map() + lookups.forEach(lookup => { + if (lookup.name) { + lookupsByName.set(normalizeCountryName(lookup.name), lookup) + } + }) + + const countries = new Map() + rows.forEach(row => { + const name = String(row['country.country_name'] || '') + .trim() + const count = Number(row[countKey] || 0) + const normalizedName = normalizeCountryName(name) + const lookup = lookupsByName.get(normalizedName) + + // Ignore malformed report rows and values that cannot be mapped to a real country. + if (!name || !Number.isFinite(count) || count <= 0 || !lookup?.countryCode) { + return + } + + const code = toAlpha2CountryCode(lookup.countryCode) + const current = countries.get(code) + countries.set(code, { + code, + count: (current?.count || 0) + count, + // flagUrl: lookup.countryFlag?.replace(/^http:/, 'https:'), + name: lookup.name || name, + }) + }) + + return Array.from(countries.values()) + .sort((countryA, countryB) => countryB.count - countryA.count) +} + +export async function fetchCountriesRepresented(): Promise { + const [rows, lookupResponse] = await Promise.all([ + xhrGetAsync(`${GENERAL_STATISTICS_URL}/countries-represented`), + xhrGetAsync(COUNTRY_LOOKUP_URL), + ]) + + return normalizeCountryRows(rows, 'user.count', unwrapCountryLookups(lookupResponse)) +} + +export async function fetchWinnersByCountry(): Promise { + const [rows, lookupResponse] = await Promise.all([ + xhrGetAsync(`${GENERAL_STATISTICS_URL}/first-place-by-country`), + xhrGetAsync(COUNTRY_LOOKUP_URL), + ]) + + return normalizeCountryRows(rows, 'challenge_stats.count', unwrapCountryLookups(lookupResponse)) +} + +export async function fetchGeneralStatistics(): Promise { + const [ + memberCountResponse, + totalPrizesResponse, + completedChallengesResponse, + countries, + ] = await Promise.all([ + xhrGetAsync<{ 'user.count'?: number }>(`${GENERAL_STATISTICS_URL}/member-count`), + xhrGetAsync<{ total?: number | string }>(`${GENERAL_STATISTICS_URL}/total-prizes`), + xhrGetAsync<{ 'challenge.count'?: number }>(`${GENERAL_STATISTICS_URL}/completed-challenges`), + fetchCountriesRepresented(), + ]) + + return { + completedChallenges: Number(completedChallengesResponse['challenge.count'] || 0), + countries, + memberCount: Number(memberCountResponse['user.count'] || 0), + totalPrizes: Number(totalPrizesResponse.total || 0), + } +} diff --git a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss new file mode 100644 index 000000000..61c938c2e --- /dev/null +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss @@ -0,0 +1,279 @@ +@import '@libs/ui/styles/includes'; + +.page { + color: #0a0a0a; + font-family: 'Nunito Sans', sans-serif; + padding: 40px 0; +} + +.kpis { + display: grid; + gap: 24px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + + @include ltemd { + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @include ltesm { + grid-template-columns: 1fr; + } +} + +.kpiCard { + align-items: center; + background: #e1e5e9; + border-radius: 6px; + display: flex; + justify-content: space-between; + min-height: 84px; + padding: 24px 24px 16px; + + p { + font-size: 14px; + font-weight: 700; + line-height: 20px; + margin: 0 0 4px; + } + + strong { + color: #078477; + font-size: 24px; + line-height: 38px; + font-family: "Figtree", sans-serif; + font-weight: bold; + } +} + +.kpiIcon { + align-items: center; + background: #fff; + border-radius: 50%; + color: #078477; + display: flex; + height: 36px; + justify-content: center; + width: 36px; + + svg { + height: 20px; + width: 20px; + } +} + +.kpiError { + align-items: center; + color: #c1294f; + display: flex; + font-size: 13px; + gap: 10px; + margin-top: 10px; + + button { + color: #0d61bf; + text-decoration: underline; + } +} + +.distribution { + margin-top: 32px; + + h1 { + color: #151515; + font-size: 26px; + font-weight: 700; + line-height: 30px; + margin: 0; + } +} + +.subtitle { + color: #0a0a0a; + font-size: 18px; + line-height: 25px; + margin: 4px 0 0; +} + +.tabs { + border-bottom: 1px solid #a8a8a8; + display: flex; + margin-top: 22px; + + button { + color: #0a0a0a; + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + line-height: 22px; + padding: 8px 24px; + position: relative; + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: -2px; + } + } + + .activeTab { + color: #00797A; + font-weight: 700; + + &::after { + background: #00797A; + bottom: -1px; + content: ''; + height: 4px; + left: 0; + position: absolute; + right: 0; + } + } + + @include ltesm { + button { + flex: 1; + padding-left: 8px; + padding-right: 8px; + } + } +} + +.content { + display: grid; + column-gap: 48px; + grid-template-columns: minmax(285px, 32%) minmax(0, 68%); + min-height: 470px; + + @include ltemd { + column-gap: 24px; + grid-template-columns: 1fr; + } +} + +.tableWrapper { + max-height: 470px; + overflow: auto; + padding-right: 18px; + + table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + th, + td { + border-bottom: 1px solid #e2e2e2; + color: #1a1a1a; + font-size: 14px; + height: 52px; + padding: 16px; + text-align: left; + } + + th { + background: #fff; + border-bottom-color: #a8a8a8; + font-weight: 700; + position: sticky; + top: 0; + z-index: 1; + } + + th:first-child, + td:first-child { + text-align: center; + width: 52px; + } + + th:last-child, + td:last-child { + text-align: right; + width: 122px; + } + + td:nth-child(2) > div { + align-items: center; + display: flex; + gap: 8px; + overflow: hidden; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + @include ltemd { + max-height: 360px; + order: 2; + padding-right: 0; + } +} + +.flag { + display: inline-flex; + flex: 0 0 auto; + height: 14px; + width: 22px; +} + +.rank1, +.rank2, +.rank3 { + align-items: center; + border-radius: 50%; + display: inline-flex; + font-size: 10px; + font-weight: 800; + height: 19px; + justify-content: center; + width: 19px; +} + +.rank4 { + font-size: 14px; + display: inline-block; + min-width: 19px; + text-align: center; + font-family: serif; +} + +.map { + background: #f8f8f8; + min-height: 470px; + + > div, + :global(.highcharts-container), + :global(.highcharts-root) { + height: 470px !important; + width: 100% !important; + } + + @include ltemd { + min-height: 370px; + order: 1; + + > div, + :global(.highcharts-container), + :global(.highcharts-root) { + height: 370px !important; + } + } +} + +.status { + align-items: center; + color: #545f71; + display: flex; + gap: 8px; + grid-column: 1 / -1; + justify-content: center; + min-height: 300px; + + button { + color: #0d61bf; + text-decoration: underline; + } +} diff --git a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx new file mode 100644 index 000000000..3cbbba906 --- /dev/null +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx @@ -0,0 +1,250 @@ +import { FC, KeyboardEvent, useCallback, useMemo, useState } from 'react' +import useSWR, { SWRResponse } from 'swr' + +import { IconOutline } from '~/libs/ui' + +import { + fetchGeneralStatistics, + fetchWinnersByCountry, + GeneralStatistics, + StatisticsCountry, +} from '../../../lib' + +import WorldMap from './WorldMap' +import { + IconFirstPlace, + IconSecondPlace, + IconThirdPlace, +} from './assets' +import 'flag-icons/css/flag-icons.min.css' +import styles from './StatisticsPage.module.scss' + +type StatisticsTab = 'countries' | 'winners' + +const NUMBER_FORMATTER = new Intl.NumberFormat('en-US') + +function formatPrizeTotal(value?: number): string { + if (value === undefined) { + return '—' + } + + if (value >= 1000000) { + return `$${(value / 1000000).toFixed(1)}M` + } + + return `$${NUMBER_FORMATTER.format(value)}` +} + +function formatCount(value?: number): string { + return value === undefined ? '—' : NUMBER_FORMATTER.format(value) +} + +const StatisticsPage: FC = () => { + const [activeTab, setActiveTab] = useState('countries') + const { + data: generalStatistics, + error: generalStatisticsError, + mutate: reloadGeneralStatistics, + }: SWRResponse = useSWR( + 'customer-portal-general-statistics', + fetchGeneralStatistics, + ) + const { + data: winners, + error: winnersError, + mutate: reloadWinners, + }: SWRResponse = useSWR( + activeTab === 'winners' ? 'customer-portal-winners-by-country' : undefined, + fetchWinnersByCountry, + ) + + const countries: StatisticsCountry[] = useMemo( + () => ( + activeTab === 'countries' + ? generalStatistics?.countries || [] + : winners || [] + ), + [activeTab, generalStatistics?.countries, winners], + ) + const isLoading = activeTab === 'countries' + ? !generalStatistics && !generalStatisticsError + : !winners && !winnersError + const contentError = activeTab === 'countries' ? generalStatisticsError : winnersError + const valueLabel = activeTab === 'countries' ? 'Members' : 'First-place wins' + + const selectTab = useCallback((tab: StatisticsTab) => { + setActiveTab(tab) + }, []) + + const handleTabKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return + } + + event.preventDefault() + setActiveTab(current => (current === 'countries' ? 'winners' : 'countries')) + }, []) + + const reloadContent = useCallback(() => { + if (activeTab === 'countries') { + reloadGeneralStatistics() + + return + } + + reloadWinners() + }, [activeTab, reloadGeneralStatistics, reloadWinners]) + + const reloadAllStatistics = useCallback(() => { + reloadGeneralStatistics() + }, [reloadGeneralStatistics]) + const selectCountriesTab = useCallback(() => { + selectTab('countries') + }, [selectTab]) + const selectWinnersTab = useCallback(() => { + selectTab('winners') + }, [selectTab]) + + const kpis = [{ + icon: