diff --git a/.circleci/config.yml b/.circleci/config.yml index 610988559..a1eda8638 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -229,6 +229,8 @@ workflows: only: - dev - copilot_reviewer + - support-app + - PM-5460 tags: only: /^dev-.*/ diff --git a/README.md b/README.md index 34fc8776e..b52243e1c 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,7 @@ The following summarizes the various [apps](#adding-a-new-platform-ui-applicatio - [Learn](#learn) - [Self Service](#self-service) - [Status](#status) +- [Support](#support) ## Platform App @@ -610,3 +611,11 @@ diagnostic section. [Status README](./src/apps/status/README.md) [Status Routes](./src/apps/status/src/status-app.routes.tsx) + +## Support + +The application where Topcoder members can open support tickets, track their +status, and communicate with the Topcoder Support Team. + +[Support README](./src/apps/support/README.md) +[Support Routes](./src/apps/support/src/support-app.routes.tsx) diff --git a/package.json b/package.json index bf291bac4..667c8db70 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,9 @@ "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", + "i18n-iso-countries": "^3.7.1" }, "devDependencies": { "@babel/core": "^7.29.6", diff --git a/src/apps/admin/src/lib/components/InputHandlesSelector/InputHandlesSelector.module.scss b/src/apps/admin/src/lib/components/InputHandlesSelector/InputHandlesSelector.module.scss index 5a3b5c669..d90f017ec 100644 --- a/src/apps/admin/src/lib/components/InputHandlesSelector/InputHandlesSelector.module.scss +++ b/src/apps/admin/src/lib/components/InputHandlesSelector/InputHandlesSelector.module.scss @@ -12,8 +12,12 @@ } .selectUserHandlesCustomMultiValue { + display: inline-flex; + align-items: center; + gap: 6px; font-size: 13px; - padding: 0 8px; + line-height: 1.4; + padding: 2px 8px; margin-right: 6px; color: $black-60; background-color: $black-10; @@ -28,6 +32,24 @@ } } +// Sized here so the control works outside admin-app (which provides svg.icon globals). +.removeIcon { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + line-height: 1; + + :global(svg) { + display: block; + width: 12px; + height: 12px; + max-width: 12px; + max-height: 12px; + stroke: currentColor; + } +} + .selectUserHandlesDropdownContainer { z-index: 9999 !important; } diff --git a/src/apps/admin/src/lib/services/user.service.ts b/src/apps/admin/src/lib/services/user.service.ts index 54d774ec8..4a61ad015 100644 --- a/src/apps/admin/src/lib/services/user.service.ts +++ b/src/apps/admin/src/lib/services/user.service.ts @@ -46,11 +46,23 @@ export const getMemberSuggestionsByHandle = async ( maxRating?: unknown } - const response = await xhrGetAsync( - `${EnvironmentConfig.API.V6}/members/autocomplete/${sanitizedHandle}`, + type MemberAutocompletePagedResponse = { + result?: MemberAutocompleteResponse[] + } + + // Prefer the query-param autocomplete endpoint: any authenticated member can + // call it. The path-param variant historically required copilot/admin only. + const response = await xhrGetAsync< + MemberAutocompleteResponse[] | MemberAutocompletePagedResponse + >( + `${EnvironmentConfig.API.V6}/members/autocomplete?term=${sanitizedHandle}`, ) - return response.map(member => ({ + const members = Array.isArray(response) + ? response + : (response?.result ?? []) + + return members.map(member => ({ firstName: member.firstName ?? undefined, handle: member.handle, lastName: member.lastName ?? undefined, 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..3dbabc006 --- /dev/null +++ b/src/apps/customer-portal/src/lib/services/statistics.service.ts @@ -0,0 +1,270 @@ +import * as countriesModule from 'i18n-iso-countries' +import enLocaleJson from 'i18n-iso-countries/langs/en.json' + +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +type LocaleData = { locale: string; countries: Record } + +const enLocale: LocaleData + = (enLocaleJson as { default?: LocaleData } | undefined)?.default + ?? (enLocaleJson as LocaleData) + +const countryUtil + = ((countriesModule as unknown as { default?: typeof countriesModule }) + .default ?? countriesModule) as typeof countriesModule + +countryUtil.registerLocale(enLocale) + +export function toAlpha2CountryCode(code: string): string { + const normalized = String(code || '') + .trim() + .toUpperCase() + + return normalized.length === 3 + ? countryUtil.alpha3ToAlpha2(normalized) ?? normalized + : normalized +} + +export type StatisticsWinner = { + handle: string + maxRating?: number + photoURL?: string + wins: number +} + +export type StatisticsSkill = { + count: number + name: string + percentage: number +} + +export type StatisticsCountry = { + code?: string + count: number + flagUrl?: string + name: string + skillsBreakdown?: StatisticsSkill[] + topMembers?: StatisticsWinner[] + topWinners?: StatisticsWinner[] + totalSkills?: number +} + +export type GeneralStatistics = { + completedChallenges: number + countries: StatisticsCountry[] + memberCount: number + totalPrizes: number +} + +type CountryReportRow = { + 'challenge_stats.count'?: number | string + 'country.country_name'?: string + topWinners?: Array<{ + handle?: string + maxRating?: number | string | null + photoURL?: string | null + wins?: number | string + }> + topMembers?: Array<{ + handle?: string + maxRating?: number | string | null + photoURL?: string | null + wins?: number | string + }> + skillsBreakdown?: Array<{ + count?: number | string + name?: string + percentage?: number | string + }> + totalSkills?: number | 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 + } + + if (row['country.country_name'] === 'Taiwan') { + console.log('here', row) + + } + + const code = toAlpha2CountryCode(lookup.countryCode) + const current = countries.get(code) + const topWinners = (row.topWinners || []) + .map(winner => ({ + handle: String(winner.handle || '') + .trim(), + maxRating: winner.maxRating === null || winner.maxRating === undefined + ? undefined + : Number(winner.maxRating), + photoURL: String(winner.photoURL || '') + .trim() || undefined, + wins: Number(winner.wins || 0), + })) + .filter(winner => ( + winner.handle + && Number.isFinite(winner.wins) + && winner.wins > 0 + )) + .slice(0, 3) + const topMembers = (row.topMembers || []) + .map(member => ({ + handle: String(member.handle || '') + .trim(), + maxRating: member.maxRating === null || member.maxRating === undefined + ? undefined + : Number(member.maxRating), + photoURL: String(member.photoURL || '') + .trim() || undefined, + wins: Number(member.wins || 0), + })) + .filter(member => ( + member.handle + && Number.isFinite(member.wins) + && member.wins > 0 + )) + .slice(0, 3) + const skillsBreakdown = (row.skillsBreakdown || []) + .map(skill => ({ + count: Number(skill.count || 0), + name: String(skill.name || '') + .trim(), + percentage: Number(skill.percentage || 0), + })) + .filter(skill => ( + skill.name + && Number.isFinite(skill.count) + && skill.count > 0 + && Number.isFinite(skill.percentage) + )) + .slice(0, 3) + countries.set(code, { + code, + count: (current?.count || 0) + count, + // flagUrl: lookup.countryFlag?.replace(/^http:/, 'https:'), + name: lookup.name || name, + skillsBreakdown: skillsBreakdown.length > 0 + ? skillsBreakdown + : current?.skillsBreakdown, + topMembers: topMembers.length > 0 ? topMembers : current?.topMembers, + topWinners: topWinners.length > 0 ? topWinners : current?.topWinners, + totalSkills: Number(row.totalSkills || 0), + }) + }) + + 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}/country-member-details`), + 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}/top-winners-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..9c9b47fea --- /dev/null +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss @@ -0,0 +1,637 @@ +@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; + text-transform: none; + font-family: 'Figtree', sans-serif; + } +} + +.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; + } + } + + + * { + margin-top: 24px; + } + + @include ltesm { + button { + flex: 1; + padding-left: 8px; + padding-right: 8px; + } + + + * { + margin-top: 32px; + } + } +} + +.content { + display: grid; + column-gap: 48px; + grid-template-columns: minmax(285px, 32%) minmax(0, 68%); + min-height: 572px; + + @include ltemd { + column-gap: 24px; + grid-template-columns: 1fr; + } +} + +.tableWrapper { + max-height: 572px; + 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; + line-height: 20px; + } + + 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; + vertical-align: middle; + } + + th:last-child, + td:last-child { + text-align: right; + width: 122px; + } + + tbody tr { + cursor: pointer; + transition: background-color 150ms ease; + } + + tbody tr:hover, + tbody tr:focus-within { + background-color: #f5f8fa; + } + + td:nth-child(2) > div { + align-items: center; + display: flex; + gap: 8px; + overflow: hidden; + line-height: 20px; + + 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: 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; + line-height: 20px; +} + +.map { + background: #fff; + min-height: 572px; + position: relative; + + > div, + :global(.highcharts-container), + :global(.highcharts-root) { + height: 572px !important; + width: 100% !important; + } + + @include ltemd { + min-height: 370px; + order: 1; + + > div, + :global(.highcharts-container), + :global(.highcharts-root) { + height: 370px !important; + } + } + + @include ltesm { + min-height: 319px; + + > div, + :global(.highcharts-container), + :global(.highcharts-root) { + height: 319px !important; + } + } + + &:fullscreen { + height: 100vh; + min-height: 100vh; + + > div, + :global(.highcharts-container), + :global(.highcharts-root) { + height: 100vh !important; + } + } +} + +.mapFullscreenButton { + align-items: center; + background: #fff; + border: 1px solid #a8a8a8; + border-radius: 4px; + display: flex; + height: 40px; + justify-content: center; + padding: 0; + position: absolute; + right: 9px; + top: 12px; + width: 40px; + z-index: 2; + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: 2px; + } + + img { + height: 24px; + width: 24px; + } + + @include gtelg { + display: none; + } +} + +.mapTooltip, +.mapTooltipCompact, +.countryMapTooltip { + background: #0f172a; + box-sizing: border-box; + color: #fff; + font-family: 'Figtree', sans-serif; + pointer-events: none; +} + +.mapTooltip, +.countryMapTooltip { + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; + position: relative; + &::after { + border-left: 9px solid transparent; + border-right: 9px solid transparent; + border-top: 8px solid #0f172a; + content: ''; + height: 0; + left: 50%; + position: absolute; + top: 100%; + transform: translateX(-50%); + width: 0; + } +} + +.mapTooltip { + width: 218px; +} + +.countryMapTooltip { + width: 360px; +} + +.mapTooltipCompact { + border-radius: 6px; + font-size: 12px; + line-height: 16px; + padding: 10px 12px; +} + +.tooltipHeader { + align-items: flex-start; + display: flex; + flex-direction: column; + font-size: 12px; + gap: 4px; + line-height: normal; +} + +.tooltipCountry { + align-items: center; + display: flex; + gap: 4px; + min-width: 0; + width: 100%; + + strong { + font-size: 20px; + line-height: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.tooltipFlag { + flex: 0 0 16px; + height: 16px; + width: 16px; +} + +.tooltipSectionTitle { + font-size: 12px; + line-height: normal; +} + +.tooltipWinners { + display: flex; + flex-direction: column; + gap: 16px; +} + +.tooltipWinner { + align-items: center; + display: flex; + gap: 14px; + min-width: 0; +} + +.tooltipAvatar { + background-color: #d9d9d9; + background-position: center; + background-size: cover; + border-radius: 50%; + display: block; + flex: 0 0 40px; + height: 40px; + overflow: hidden; + position: relative; + width: 40px; + + &[style] { + .tooltipAvatarHead, + .tooltipAvatarBody { + display: none; + } + } +} + +.tooltipAvatarHead { + background: #aab6c2; + border-radius: 50%; + height: 14px; + left: 13px; + position: absolute; + top: 7px; + width: 14px; +} + +.tooltipAvatarBody { + background: #aab6c2; + border-radius: 16px 16px 8px 8px; + bottom: -2px; + height: 18px; + left: 7px; + position: absolute; + width: 26px; +} + +.tooltipWinnerText { + display: flex; + flex-direction: column; + min-width: 0; +} + +.tooltipHandle { + font-size: 16px; + font-weight: 600; + line-height: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tooltipWins { + color: #fff; + font-size: 12px; + line-height: normal; +} + +.tooltipCountryTitle { + font-size: 20px; + line-height: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tooltipMetrics { + display: grid; + gap: 40px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.tooltipMetric { + display: flex; + flex-direction: column; + font-size: 12px; + gap: 3px; + line-height: normal; +} + +.tooltipMetricValue { + align-items: center; + display: flex; + gap: 5px; + + img { + flex: 0 0 24px; + height: 24px; + width: 24px; + } + + strong { + font-size: 24px; + font-weight: 600; + line-height: normal; + white-space: nowrap; + } +} + +.tooltipSkillBreakdown { + display: flex; + flex-direction: column; + font-size: 12px; + gap: 10px; + line-height: normal; +} + +.tooltipSkillBar { + border-radius: 4px; + display: flex; + height: 24px; + overflow: hidden; + width: 100%; +} + +.tooltipSkillSegment { + align-items: center; + display: flex; + flex: 0 0 auto; + justify-content: center; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.tooltipSkillLegend { + align-items: center; + display: flex; + justify-content: space-between; +} + +.tooltipSkillLegendItem { + align-items: center; + display: flex; + gap: 8px; + min-width: 0; + + > span:last-child { + max-width: 70px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.tooltipSkillDot { + border-radius: 50%; + flex: 0 0 9px; + height: 9px; + width: 9px; +} + +.tooltipTopMember { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tooltipTopMemberContent { + align-items: center; + display: flex; + gap: 14px; + min-width: 0; +} + +.tooltipMemberStats { + align-items: center; + display: flex; + font-size: 12px; + gap: 5px; + line-height: normal; + white-space: nowrap; +} + +.tooltipMemberFlag { + flex: 0 0 16px; + height: 16px; + width: 16px; +} + +.tooltipMemberDivider { + margin: 0 5px; +} + +.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..8e9b901e8 --- /dev/null +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx @@ -0,0 +1,274 @@ +import { FC, KeyboardEvent, useCallback, useMemo, useState } from 'react' +import useSWR, { SWRResponse } from 'swr' +import 'flag-icons/css/flag-icons.min.css' + +import { IconOutline } from '~/libs/ui' + +import { + fetchGeneralStatistics, + fetchWinnersByCountry, + GeneralStatistics, + StatisticsCountry, +} from '../../../lib' + +import { + IconFirstPlace, + IconGlobe, + IconSecondPlace, + IconThirdPlace, + IconTrophy, +} from './assets' +import WorldMap from './WorldMap' +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 [hoveredCountryCode, setHoveredCountryCode] = useState() + 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' : 'Winners' + + 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 handleRowMouseEnter = useCallback((code?: string) => { + setHoveredCountryCode(code) + }, []) + const handleRowMouseLeave = useCallback(() => { + setHoveredCountryCode(undefined) + }, []) + + const kpis = [{ + icon: