From 1d567d83cef4df0e4dfb14f993eb8fd283328bf9 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 24 Aug 2026 13:35:49 +0300 Subject: [PATCH] PM-5939 - campus leaderboard UI --- src/apps/campus/src/CampusApp.tsx | 1 + .../campus/src/lib/assets/icons/icon-help.svg | 5 + .../campus/src/lib/assets/icons/icon-info.svg | 3 + .../src/lib/assets/icons/icon-medal-1st.svg | 9 + .../src/lib/assets/icons/icon-medal-2nd.svg | 9 + .../src/lib/assets/icons/icon-medal-3rd.svg | 9 + .../lib/assets/icons/icon-result-failed.svg | 5 + .../lib/assets/icons/icon-result-passed.svg | 5 + .../lib/assets/icons/icon-stat-members.svg | 8 + .../src/lib/assets/icons/icon-stat-passed.svg | 8 + .../lib/assets/icons/icon-stat-registered.svg | 8 + .../lib/assets/icons/icon-stat-submitted.svg | 8 + .../src/lib/assets/icons/icon-stat-wins.svg | 8 + src/apps/campus/src/lib/assets/icons/index.ts | 45 ++++ src/apps/campus/src/lib/components/index.ts | 1 + .../components/stat-card/StatCard.module.scss | 44 ++++ .../src/lib/components/stat-card/StatCard.tsx | 30 +++ .../src/lib/components/stat-card/index.ts | 1 + src/apps/campus/src/lib/styles/index.scss | 67 +++++ .../CampusLeaderboardPage.module.scss | 243 +++++++++++------- .../CampusLeaderboardPage.spec.tsx | 7 +- .../leaderboard/CampusLeaderboardPage.tsx | 165 ++++++------ .../ParticipationHistoryModal.module.scss | 114 ++++++-- .../leaderboard/ParticipationHistoryModal.tsx | 179 +++++++------ .../leaderboard/RankingRulesModal.module.scss | 76 +++++- .../pages/leaderboard/RankingRulesModal.tsx | 31 ++- 26 files changed, 796 insertions(+), 293 deletions(-) create mode 100644 src/apps/campus/src/lib/assets/icons/icon-help.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-info.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-medal-1st.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-medal-2nd.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-medal-3rd.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-result-failed.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-result-passed.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-stat-members.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-stat-passed.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-stat-registered.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-stat-submitted.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-stat-wins.svg create mode 100644 src/apps/campus/src/lib/assets/icons/index.ts create mode 100644 src/apps/campus/src/lib/components/index.ts create mode 100644 src/apps/campus/src/lib/components/stat-card/StatCard.module.scss create mode 100644 src/apps/campus/src/lib/components/stat-card/StatCard.tsx create mode 100644 src/apps/campus/src/lib/components/stat-card/index.ts create mode 100644 src/apps/campus/src/lib/styles/index.scss diff --git a/src/apps/campus/src/CampusApp.tsx b/src/apps/campus/src/CampusApp.tsx index 931de6a3f..a5c01921d 100644 --- a/src/apps/campus/src/CampusApp.tsx +++ b/src/apps/campus/src/CampusApp.tsx @@ -4,6 +4,7 @@ import { Outlet, Routes } from 'react-router-dom' import { routerContext, RouterContextData } from '~/libs/core' import { toolTitle } from './campus.routes' +import './lib/styles/index.scss' const CampusApp: FC = () => { const { getChildRoutes }: RouterContextData = useContext(routerContext) diff --git a/src/apps/campus/src/lib/assets/icons/icon-help.svg b/src/apps/campus/src/lib/assets/icons/icon-help.svg new file mode 100644 index 000000000..e2e8d906c --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-help.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-info.svg b/src/apps/campus/src/lib/assets/icons/icon-info.svg new file mode 100644 index 000000000..28595a183 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-info.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-medal-1st.svg b/src/apps/campus/src/lib/assets/icons/icon-medal-1st.svg new file mode 100644 index 000000000..997f82201 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-medal-1st.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-medal-2nd.svg b/src/apps/campus/src/lib/assets/icons/icon-medal-2nd.svg new file mode 100644 index 000000000..b92980117 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-medal-2nd.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-medal-3rd.svg b/src/apps/campus/src/lib/assets/icons/icon-medal-3rd.svg new file mode 100644 index 000000000..118a81b70 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-medal-3rd.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-result-failed.svg b/src/apps/campus/src/lib/assets/icons/icon-result-failed.svg new file mode 100644 index 000000000..a46b3769f --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-result-failed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-result-passed.svg b/src/apps/campus/src/lib/assets/icons/icon-result-passed.svg new file mode 100644 index 000000000..9112d0cb3 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-result-passed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-members.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-members.svg new file mode 100644 index 000000000..268aaf41c --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-members.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-passed.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-passed.svg new file mode 100644 index 000000000..406071c9c --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-passed.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-registered.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-registered.svg new file mode 100644 index 000000000..c140d46dd --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-registered.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-submitted.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-submitted.svg new file mode 100644 index 000000000..3e461a0e1 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-submitted.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-wins.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-wins.svg new file mode 100644 index 000000000..a427a31fa --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-wins.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/index.ts b/src/apps/campus/src/lib/assets/icons/index.ts new file mode 100644 index 000000000..9b710220b --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/index.ts @@ -0,0 +1,45 @@ +import { ReactComponent as IconHelp } from './icon-help.svg' +import { ReactComponent as IconInfo } from './icon-info.svg' +import { ReactComponent as IconMedal1st } from './icon-medal-1st.svg' +import { ReactComponent as IconMedal2nd } from './icon-medal-2nd.svg' +import { ReactComponent as IconMedal3rd } from './icon-medal-3rd.svg' +import { ReactComponent as IconResultFailed } from './icon-result-failed.svg' +import { ReactComponent as IconResultPassed } from './icon-result-passed.svg' +import { ReactComponent as IconStatMembers } from './icon-stat-members.svg' +import { ReactComponent as IconStatPassed } from './icon-stat-passed.svg' +import { ReactComponent as IconStatRegistered } from './icon-stat-registered.svg' +import { ReactComponent as IconStatSubmitted } from './icon-stat-submitted.svg' +import { ReactComponent as IconStatWins } from './icon-stat-wins.svg' + +export { + IconHelp, + IconInfo, + IconMedal1st, + IconMedal2nd, + IconMedal3rd, + IconResultFailed, + IconResultPassed, + IconStatMembers, + IconStatPassed, + IconStatRegistered, + IconStatSubmitted, + IconStatWins, +} + +/** + * Medal badges shown for the top three placements. + */ +export const placementIcons: { [placement: number]: typeof IconMedal1st } = { + 1: IconMedal1st, + 2: IconMedal2nd, + 3: IconMedal3rd, +} + +/** + * Ordinal labels for the top three placements. + */ +export const placementLabels: { [placement: number]: string } = { + 1: '1st place', + 2: '2nd place', + 3: '3rd place', +} diff --git a/src/apps/campus/src/lib/components/index.ts b/src/apps/campus/src/lib/components/index.ts new file mode 100644 index 000000000..3414cdcdf --- /dev/null +++ b/src/apps/campus/src/lib/components/index.ts @@ -0,0 +1 @@ +export * from './stat-card' diff --git a/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss b/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss new file mode 100644 index 000000000..f48eab043 --- /dev/null +++ b/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss @@ -0,0 +1,44 @@ +@import '@libs/ui/styles/includes'; + +.statCard { + align-items: center; + border: 1px solid var(--TableBorderColor); + border-radius: 8px; + display: flex; + flex: 1 0 0; + gap: $sp-2; + min-width: 0; + padding: $sp-4 $sp-6; +} + +.icon { + flex: 0 0 auto; + height: 50px; + width: 50px; +} + +.stat { + color: var(--FontColor); + display: flex; + flex-direction: column; + min-width: 0; +} + +.value { + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + line-height: 30px; +} + +.label { + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 20px; + + // single line at the design width; wraps on narrower viewports + @media (min-width: 1280px) { + white-space: nowrap; + } +} diff --git a/src/apps/campus/src/lib/components/stat-card/StatCard.tsx b/src/apps/campus/src/lib/components/stat-card/StatCard.tsx new file mode 100644 index 000000000..0ea689c3e --- /dev/null +++ b/src/apps/campus/src/lib/components/stat-card/StatCard.tsx @@ -0,0 +1,30 @@ +/** + * Bordered card showing one participation statistic next to its icon. + */ +import { FC, FunctionComponent, SVGProps } from 'react' + +import styles from './StatCard.module.scss' + +interface StatCardProps { + readonly icon: FunctionComponent> + readonly label: string + readonly value?: number +} + +export const StatCard: FC = (props: StatCardProps) => { + const Icon: FunctionComponent> = props.icon + + return ( +
+ +
+
+ {props.value?.toLocaleString() ?? '-'} +
+
{props.label}
+
+
+ ) +} + +export default StatCard diff --git a/src/apps/campus/src/lib/components/stat-card/index.ts b/src/apps/campus/src/lib/components/stat-card/index.ts new file mode 100644 index 000000000..4626bce2f --- /dev/null +++ b/src/apps/campus/src/lib/components/stat-card/index.ts @@ -0,0 +1 @@ +export { StatCard } from './StatCard' diff --git a/src/apps/campus/src/lib/styles/index.scss b/src/apps/campus/src/lib/styles/index.scss new file mode 100644 index 000000000..3d99e7f04 --- /dev/null +++ b/src/apps/campus/src/lib/styles/index.scss @@ -0,0 +1,67 @@ +@import '@libs/ui/styles/includes'; +@import url('https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Figtree:ital,wght@0,300..900;1,300..900&display=swap'); + +:root { + --Link: #0d61bf; + --FontColor: #0a0a0a; + --SubtitleColor: #202020; + --GrayFontColor: #767676; + --TableBorderColor: #a8a8a8; + --TableRowBorderColor: #e0e0e0; + --TableTextColor: #161616; + --TooltipColor: #0f172a; +} + +// Reskin of the shared ~/libs/ui Table to the campus design: +// Nunito Sans header and cells, 52px rows, square cells, gray rules. +.campus-table { + table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + + thead th { + border-bottom: 1px solid var(--TableBorderColor); + height: 52px; + padding: 0 $sp-4 !important; + vertical-align: middle; + + > div { + align-items: center; + color: var(--TableTextColor) !important; + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 700; + gap: $sp-1; + letter-spacing: normal; + line-height: 20px; + text-transform: none; + } + } + + td { + border-bottom: 1px solid var(--TableRowBorderColor); + color: var(--TableTextColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 400; + height: 52px; + letter-spacing: normal; + line-height: 20px; + max-width: none; + padding: 0 $sp-4; + vertical-align: middle; + + &:first-child, + &:last-child { + border-radius: 0; + } + + // the shared table centers the second to last column + &:nth-last-child(2) { + text-align: left; + } + } + } +} diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss index 3505c8520..5386c7035 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss @@ -1,98 +1,79 @@ @import '@libs/ui/styles/includes'; +$section-gap: 40px; +$card-gap: 35px; + .header { - margin-top: $sp-8; - margin-bottom: $sp-6; + margin-top: $section-gap; + margin-bottom: $section-gap; +} - h1 { - margin-bottom: $sp-2; - } +.title { + color: var(--FontColor); + font-family: 'Figtree', sans-serif; + font-size: 33px; + font-weight: 700; + letter-spacing: normal; + line-height: 38px; + margin-bottom: $sp-2; + text-transform: none; } .subtitle { - color: $black-60; + color: var(--SubtitleColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 18px; + font-weight: 400; + line-height: 25px; } .stats { - display: grid; - gap: $sp-4; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - margin-bottom: $sp-6; -} - -.statCard { - align-items: center; - background: $tc-white; - border: 1px solid $black-10; - border-radius: 8px; display: flex; - gap: $sp-4; - padding: $sp-4; -} - -.statIcon { align-items: center; - border-radius: 50%; - display: flex; - flex: 0 0 auto; - height: 48px; - justify-content: center; - width: 48px; + gap: $card-gap; + margin-bottom: $section-gap; - svg { - height: 24px; - width: 24px; + @include ltemd { + flex-direction: column; + align-items: stretch; + gap: $sp-4; } } -.statIconMembers { - background: #e6f7f0; - color: #0ab88a; -} - -.statIconRegistered { - background: #e9f2fe; - color: #2a8ded; -} - -.statIconSubmitted { - background: #f0eafc; - color: #7b61ff; -} - -.statLabel { - color: $black-80; - margin-bottom: $sp-1; -} - -.statValue { - @include font-barlow-condensed; - - font-size: 28px; - font-weight: 500; -} - .toolbar { align-items: center; + border-bottom: 1px solid var(--TableBorderColor); display: flex; - justify-content: space-between; gap: $sp-4; - margin-bottom: $sp-4; + justify-content: space-between; + padding-bottom: $sp-4; } .filter { - max-width: 320px; - min-width: 220px; - width: 100%; -} + max-width: 100%; + width: 234px; + + :global(.input-el) { + border-color: var(--TableBorderColor); + border-radius: 4px; + height: 40px; + justify-content: center; + margin-bottom: 0; + padding: $sp-2 $sp-4; + } -.tableWrapper { - position: relative; -} + :global(.input-el) span { + color: var(--FontColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + font-weight: 400; + line-height: 22px; + } -.lbTable { - tbody td { - vertical-align: middle; + :global(.input-el) svg { + color: var(--GrayFontColor); + height: 22px; + width: 22px; } } @@ -100,62 +81,124 @@ align-items: center; background: none; border: none; - color: $turq-160; + color: var(--Link); cursor: pointer; display: flex; - gap: $sp-2; + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + font-weight: 700; + gap: $sp-1; + line-height: 22px; padding: 0; + white-space: nowrap; svg { + flex: 0 0 auto; height: 20px; width: 20px; } } -.rank { +.tableWrapper { + position: relative; +} + +.lbTable { + table { + // doubled to win over the reskin's second-to-last column alignment + td.numberCell.numberCell { + text-align: right; + + :global(.TableCell_blockCell) { + justify-content: flex-end; + } + } + + th:global(.column-id-rank-) { + width: 52px; + } + + th:global(.column-id-member-) { + width: 210px; + } + + th:global(.column-id-wins-), + th:global(.column-id-passingSubmissions-), + th:global(.column-id-submissions-), + th:global(.column-id-registrations-) { + width: 220px; + + > div { + justify-content: flex-end; + } + } + } +} + +.infoIcon { align-items: center; - border-radius: 50%; + color: var(--GrayFontColor); display: inline-flex; - font-weight: 700; - height: 28px; + height: 24px; justify-content: center; - width: 28px; + width: 24px; + + svg { + height: 14px; + width: 14px; + } } -.gold { - background: #f5c344; - color: $tc-white; +.tooltip { + // doubled to win over the shared tooltip + react-tooltip variant styles + &.tooltip { + background-color: var(--TooltipColor); + border-radius: 8px; + color: $tc-white; + font-family: 'Nunito Sans', sans-serif; + font-size: 12px; + font-weight: 400; + line-height: 20px; + max-width: 244px; + padding: $sp-3; + text-align: left; + } } -.silver { - background: $black-20; - color: $black-100; +.medal { + display: block; + height: 20px; + width: 20px; } -.bronze { - background: #d9a48f; - color: $tc-white; +.rank { + display: inline-block; + text-align: center; + width: 20px; } .handleCell { align-items: center; display: flex; - gap: $sp-3; + gap: $sp-2; } .avatar { flex: 0 0 auto; - height: 40px; - width: 40px; -} + height: 32px; + width: 32px; -.handle { - font-weight: 500; + // doubled to win over the shared avatar's responsive min-width + &.avatar img { + min-width: 0; + } } -.wins { - color: #0ab88a; - font-weight: 500; +.handle { + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 700; + line-height: 20px; } .chevronButton { @@ -167,17 +210,19 @@ display: inline-flex; justify-content: center; padding: 0; + margin-left: auto; } .chevron { - color: $black-60; - height: 20px; - width: 20px; + color: var(--FontColor); + height: 24px; + width: 24px; } .empty, .error { - color: $black-60; + color: var(--GrayFontColor); + font-family: 'Nunito Sans', sans-serif; padding: $sp-6 0; text-align: center; } diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx index 427ec258b..9d9cc060b 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx @@ -91,6 +91,9 @@ jest.mock('~/libs/ui', () => { ), + Tooltip: (props: PropsWithChildren<{ content?: ReactNode }>): JSX.Element => ( + <>{props.children} + ), } }, { virtual: true }) @@ -211,11 +214,11 @@ describe('CampusLeaderboardPage', () => { fireEvent.click(screen.getByRole('button', { name: /View participation history for testaws1/i, })) - expect(screen.getByText(/testaws1 — Participation History/)) + expect(screen.getByText('testaws1 Participation History')) .toBeInTheDocument() expect(screen.getByText('Campus Sprint')) .toBeInTheDocument() - expect(screen.getByText('Won (place 1)')) + expect(screen.getByLabelText('1st place')) .toBeInTheDocument() }) diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx index 444ddc35c..c7ed8c989 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx @@ -14,6 +14,7 @@ import { PageTitle, Table, TableColumn, + Tooltip, } from '~/libs/ui' import { ProfilePicture } from '~/libs/shared' import { EnvironmentConfig } from '~/config' @@ -23,6 +24,15 @@ import { CampusLeaderboardMember, } from '../../lib/models' import { CampusLeaderboardResource, useCampusLeaderboard } from '../../lib/hooks' +import { + IconHelp, + IconInfo, + IconStatMembers, + IconStatRegistered, + IconStatSubmitted, + placementIcons, +} from '../../lib/assets/icons' +import { StatCard } from '../../lib/components' import { ParticipationHistoryModal } from './ParticipationHistoryModal' import { RankingRulesModal } from './RankingRulesModal' @@ -36,30 +46,45 @@ const CHALLENGE_FILTER_OPTIONS: ReadonlyArray = [ { label: 'Campus Challenges', value: 'campus' }, ] -const RANK_MEDAL_CLASSES: { [rank: number]: string } = { - 1: styles.gold, - 2: styles.silver, - 3: styles.bronze, -} - /** - * Marks rows that open the participation history modal. + * Renders a column header with an info tooltip, as designed. * - * @param member leaderboard row. - * @returns row class name, when the row is clickable. + * @param label column header text. + * @param tooltip tooltip copy. + * @returns header renderer. */ +function headerWithTooltip(label: string, tooltip: string): () => JSX.Element { + return function renderHeader(): JSX.Element { + return ( + <> + {label} + + + + + + + ) + } +} + /** - * Renders a rank badge, medal-styled for the top three ranks. + * Renders the placement: a medal for the top three ranks, the number otherwise. * * @param member leaderboard row. * @returns rank cell. */ function renderRank(member: CampusLeaderboardMember): JSX.Element { - return ( - - {member.rank} - - ) + const Medal = placementIcons[member.rank] + + return Medal + ? + : {member.rank} } /** @@ -150,40 +175,48 @@ export const CampusLeaderboardPage: FC = () => { type: 'element', }, { - columnId: 'handle', - label: 'Handle', + columnId: 'member', + label: 'Member', renderer: renderHandle, type: 'element', }, { - columnId: 'registrations', - label: 'Number of Registrations', - propertyName: 'registrations', - tooltip: 'Challenges the member registered for.', + className: styles.numberCell, + columnId: 'wins', + label: '# of Wins', + propertyName: 'wins', type: 'number', }, { - columnId: 'submissions', - label: 'Number of Submissions', - propertyName: 'submissions', - tooltip: 'Challenges the member submitted to. At most one submission is counted per challenge.', + className: styles.numberCell, + columnId: 'passingSubmissions', + label: headerWithTooltip( + '# of Passing Submissions', + 'Challenges where a submission passed review. ' + + 'At most one passing submission is counted per challenge.', + ), + propertyName: 'passingSubmissions', type: 'number', }, { - columnId: 'passingSubmissions', - label: 'Number of Passing Submissions', - propertyName: 'passingSubmissions', - tooltip: 'Challenges where a submission passed review. ' - + 'At most one passing submission is counted per challenge.', + className: styles.numberCell, + columnId: 'submissions', + label: headerWithTooltip( + '# of Submissions', + 'Challenges the member submitted to. At most one submission is counted per challenge.', + ), + propertyName: 'submissions', type: 'number', }, { - columnId: 'wins', - label: 'Number of Wins', - renderer: (member: CampusLeaderboardMember) => ( - {member.wins} + className: styles.numberCell, + columnId: 'registrations', + label: headerWithTooltip( + '# of Registrations', + 'Challenges the member registered for.', ), - type: 'numberElement', + propertyName: 'registrations', + type: 'number', }, { columnId: 'open', @@ -217,7 +250,7 @@ export const CampusLeaderboardPage: FC = () => { Campus Program Leaderboard
-

Campus Program Leaderboard

+

Campus Program Leaderboard

{`Track participation and performance of members in the ${displayGroupName} `} group across challenges. @@ -235,45 +268,21 @@ export const CampusLeaderboardPage: FC = () => { {(!!data || isLoading) && ( <>

-
- - - -
-
Total Members in Group
-
- {data?.summary.totalMembers.toLocaleString() ?? '-'} -
-
-
-
- - - -
-
- Members - {' Registered to Any Challenge'} -
-
- {data?.summary.membersRegistered.toLocaleString() ?? '-'} -
-
-
-
- - - -
-
- Members - {' Submitted to Any Challenge'} -
-
- {data?.summary.membersSubmitted.toLocaleString() ?? '-'} -
-
-
+ + +
@@ -290,15 +299,15 @@ export const CampusLeaderboardPage: FC = () => { onClick={function onRulesClick() { setRulesVisible(true) }} type='button' > - - How rankings are calculated + + How ratings are calculated
h3) { + padding-top: 0; + } + + h3 { + color: var(--FontColor); + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + letter-spacing: normal; + line-height: 30px; + text-transform: none; + } + + :global(.modal-body) { + margin: 0; + padding: 0; + } } -.challengeCell { +.body { display: flex; flex-direction: column; + gap: $box-padding; + margin-top: $box-padding; + + // nested to win over the shared modal body link styling + .workLink { + color: var(--Link); + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + font-weight: 700; + line-height: 22px; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + } + } } -.challengeName { +.stats { align-items: center; - display: inline-flex; - font-weight: 500; - gap: $sp-1; + display: flex; + gap: $sp-6; + + @include ltemd { + align-items: stretch; + flex-direction: column; + gap: $sp-4; + } } -.externalIcon { - width: 1rem; - height: 1rem; - flex: none; +.historyTable { + table { + th:global(.column-id-track-) { + width: 140px; + } + + th:global(.column-id-registrationDate-), + th:global(.column-id-submissionDate-) { + width: 150px; + } + + th:global(.column-id-result-) { + width: 160px; + } + } +} + +.result { + align-items: center; + display: inline-flex; + gap: $sp-2; } -.challengeMeta { - color: $black-60; - font-size: 12px; +.resultIcon, +.medal { + flex: 0 0 auto; + height: 20px; + width: 20px; } diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx index ea06998f4..01cdf5f4e 100644 --- a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx +++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx @@ -2,12 +2,24 @@ * Participation history for one leaderboard member. */ import { FC, useMemo } from 'react' +import classNames from 'classnames' -import { BaseModal, IconOutline, Table, TableColumn } from '~/libs/ui' +import { BaseModal, Table, TableColumn } from '~/libs/ui' import { textFormatDateLocaleShortString } from '~/libs/shared' import { EnvironmentConfig } from '~/config' import { CampusLeaderboardMember, CampusParticipation } from '../../lib/models' +import { + IconResultFailed, + IconResultPassed, + IconStatPassed, + IconStatRegistered, + IconStatSubmitted, + IconStatWins, + placementIcons, + placementLabels, +} from '../../lib/assets/icons' +import { StatCard } from '../../lib/components' import styles from './ParticipationHistoryModal.module.scss' @@ -27,48 +39,51 @@ function formatDate(value: string | null): string { } /** - * Describes the outcome of a member's participation in a challenge. + * Renders the outcome of a member's participation: a medal for a top three + * placement, a pass or fail tag once the submission was reviewed. * * @param entry participation entry. - * @returns human readable result. + * @returns result cell. */ -function formatPlacement(placement: number | null): string | undefined { - if (placement === 2) { - return '2nd place' - } - - if (placement === 3) { - return '3rd place' - } - - return placement && placement > 1 ? `Place ${placement}` : undefined -} - -function formatResult(entry: CampusParticipation): string { - if (entry.won) { - return entry.placement ? `Won (place ${entry.placement})` : 'Won' - } - - const placement = formatPlacement(entry.placement) - if (placement) { - return placement - } - - if (entry.challengeStatus !== 'COMPLETED') { - if (entry.challengeStatus === 'ACTIVE') { - return 'Challenge is in progress' - } +function renderResult(entry: CampusParticipation): JSX.Element { + const placement: number | null = entry.placement + const Medal = placement ? placementIcons[placement] : undefined + + if (Medal) { + return ( + + + + ) } if (entry.passedReview) { - return 'Passed review' + return ( + + + Passed Review + + ) } if (entry.submitted) { - return 'Did not pass review' + return ( + + + Failed Review + + ) } - return 'No submission' + return ( + + {entry.challengeStatus === 'ACTIVE' ? 'Challenge is in progress' : 'No submission'} + + ) } export const ParticipationHistoryModal: FC = props => { @@ -76,49 +91,48 @@ export const ParticipationHistoryModal: FC = pro const columns = useMemo>>(() => [ { - columnId: 'challenge', - label: 'Challenge', + columnId: 'work', + label: 'Work', renderer: (entry: CampusParticipation) => { const challengePath = `${EnvironmentConfig.REVIEW.CHALLENGE_PAGE_URL}/${encodeURIComponent(entry.challengeId)}` return ( -
- - {entry.challengeName ?? entry.challengeId} - - - - {[entry.challengeTrack, entry.challengeType].filter(Boolean) - .join(' • ')} - -
+ + {entry.challengeName ?? entry.challengeId} + ) }, type: 'element', }, { - columnId: 'registeredAt', - label: 'Registered', + columnId: 'track', + label: 'Track', + propertyName: 'challengeTrack', + type: 'text', + }, + { + columnId: 'registrationDate', + label: 'Registration Date', renderer: (entry: CampusParticipation) => {formatDate(entry.registeredAt)}, type: 'element', }, { - columnId: 'submittedDate', - label: 'Submitted', + columnId: 'submissionDate', + label: 'Submission Date', renderer: (entry: CampusParticipation) => {formatDate(entry.submittedDate)}, type: 'element', }, { columnId: 'result', label: 'Result', - renderer: (entry: CampusParticipation) => {formatResult(entry)}, + renderer: renderResult, type: 'element', }, ], []) @@ -129,32 +143,45 @@ export const ParticipationHistoryModal: FC = pro return ( -
- - {`${member.registrations} registrations`} - - - {`${member.submissions} submissions`} - - - {`${member.passingSubmissions} passing`} - - - {`${member.wins} wins`} - +
+
+ + + + +
+ +
- -
) } diff --git a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss index 3163ddb50..691e6b8f7 100644 --- a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss +++ b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss @@ -1,14 +1,76 @@ @import '@libs/ui/styles/includes'; -.rules { - list-style: decimal outside; - margin: $sp-3 0 $sp-4 $sp-5; +$box-padding: 40px; +$box-width: 476px; +$text-gap: 20px; + +.modal { + border-radius: 8px !important; + max-width: calc(100vw - #{$sp-8}) !important; + min-width: 0 !important; + padding: $box-padding !important; + width: $box-width !important; + + @include ltemd { + padding: $sp-4 !important; + width: calc(100vw - #{$sp-4}) !important; + } + + :global(.react-responsive-modal-closeButton) { + right: $sp-2; + top: $sp-2; + + svg { + height: 22px; + width: 22px; + } + } + + // the shared modal header pads its top by 5px, the design does not + div:has(> h3) { + padding-top: 0; + } - li { - margin-bottom: $sp-1; + h3 { + color: var(--FontColor); + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + letter-spacing: normal; + line-height: 30px; + text-transform: none; + } + + :global(.modal-body) { + margin: 0; + padding: 0; + } + + .body { + color: var(--FontColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 400; + letter-spacing: normal; + line-height: 20px; + margin-top: $sp-6; + + p { + margin: 0 0 $text-gap; + + &:last-child { + margin-bottom: 0; + } + } + + strong { + font-weight: 700; + } } } -.note { - color: $black-60; +.rules { + list-style: decimal outside; + margin: 0 0 $text-gap; + padding-left: $sp-6; } diff --git a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx index d7bebd976..903f0a346 100644 --- a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx +++ b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx @@ -19,23 +19,28 @@ export const RankingRulesModal: FC = props => { return ( -

Members are ranked by the following criteria, in order:

-
    -
  1. Number of wins, highest first
  2. -
  3. Number of passing submissions, highest first
  4. -
  5. Number of registrations, highest first
  6. -
  7. Signup time, earliest first
  8. -
-

- At most one submission and one passing submission are counted per member per - challenge. Every member of the group is listed, including members with no - challenge activity. -

+
+

Members are ranked by the following criteria, in order:

+
    +
  1. Number of wins, highest first
  2. +
  3. Number of passing submissions, highest first
  4. +
  5. Number of registrations, highest first
  6. +
  7. Signup time, earliest first
  8. +
+

+ Note: + At most one submission and one passing submission are counted per member per + challenge. Every member of the group is listed, including members with no + challenge activity. +

+
) }