diff --git a/.circleci/config.yml b/.circleci/config.yml index a1eda8638..0cc0d57f5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -229,8 +229,8 @@ workflows: only: - dev - copilot_reviewer - - support-app - PM-5460 + - opportunities-v6 tags: only: /^dev-.*/ diff --git a/package.json b/package.json index 667c8db70..113aa7272 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "browser-cookies": "^1.2.0", "city-timezones": "^1.3.2", "classnames": "^2.5.1", - "contentful": "^9.3.7", "country-calling-code": "0.0.3", "crypto-js": "^4.2.0", "customize-cra": "^1.0.0", @@ -64,10 +63,12 @@ "express-interceptor": "^1.2.0", "fflate": "^0.8.2", "filestack-js": "^3.44.2", + "flag-icons": "^6.7.0", "highcharts": "^10.3.3", "highcharts-react-official": "^3.2.3", "highlight.js": "^11.11.1", "html2canvas": "^1.4.1", + "i18n-iso-countries": "^3.7.1", "lodash": "^4.18.1", "markdown-it": "^14.3.0", "marked": "4.3.0", @@ -111,6 +112,7 @@ "redux-promise-middleware": "^6.2.0", "redux-thunk": "^2.4.2", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^5.0.1", "rehype-stringify": "^10.0.1", "remark-breaks": "^3.0.3", "remark-frontmatter": "^4.0.1", @@ -126,9 +128,7 @@ "typescript": "^4.9.5", "universal-navigation": "https://github.com/topcoder-platform/universal-navigation#master", "uuid": "^11.1.0", - "yup": "^1.7.1", - "flag-icons": "^6.7.0", - "i18n-iso-countries": "^3.7.1" + "yup": "^1.7.1" }, "devDependencies": { "@babel/core": "^7.29.6", diff --git a/src/apps/admin/src/lib/styles/index.scss b/src/apps/admin/src/lib/styles/index.scss index 1b4138a17..46be06db6 100644 --- a/src/apps/admin/src/lib/styles/index.scss +++ b/src/apps/admin/src/lib/styles/index.scss @@ -1,4 +1,5 @@ @import './includes'; +@import '@libs/ui/styles/2026/index'; body.admin-app { color: $body-color; diff --git a/src/apps/calendar/src/lib/styles/index.scss b/src/apps/calendar/src/lib/styles/index.scss index 6c1ffcaa9..0cb9f9436 100644 --- a/src/apps/calendar/src/lib/styles/index.scss +++ b/src/apps/calendar/src/lib/styles/index.scss @@ -1,4 +1,5 @@ @import '@libs/ui/styles/includes'; +@import '@libs/ui/styles/2026/index'; :root { --LeaveColor: #4caf50; diff --git a/src/apps/copilots/src/CopilotsApp.tsx b/src/apps/copilots/src/CopilotsApp.tsx index 5c7198c2a..80e20473e 100644 --- a/src/apps/copilots/src/CopilotsApp.tsx +++ b/src/apps/copilots/src/CopilotsApp.tsx @@ -1,14 +1,20 @@ -import { FC, useContext } from 'react' +import { FC, useContext, useEffect } from 'react' import { Outlet, Routes } from 'react-router-dom' import { routerContext, RouterContextData } from '~/libs/core' import { SharedSwrConfig } from '~/libs/shared' import { toolTitle } from './copilots.routes' +import './styles/index.scss' const CopilotsApp: FC<{}> = () => { const { getChildRoutes }: RouterContextData = useContext(routerContext) + useEffect(() => { + document.body.classList.add('copilots-app') + return () => document.body.classList.remove('copilots-app') + }, []) + return ( diff --git a/src/apps/copilots/src/styles/index.scss b/src/apps/copilots/src/styles/index.scss new file mode 100644 index 000000000..3dafeb7c5 --- /dev/null +++ b/src/apps/copilots/src/styles/index.scss @@ -0,0 +1 @@ +@import '@libs/ui/styles/2026/index'; diff --git a/src/apps/engagements/src/EngagementsApp.tsx b/src/apps/engagements/src/EngagementsApp.tsx index 59e907ff9..0fff5e799 100644 --- a/src/apps/engagements/src/EngagementsApp.tsx +++ b/src/apps/engagements/src/EngagementsApp.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react' -import { useContext } from 'react' +import { useContext, useEffect } from 'react' import { Outlet, Routes } from 'react-router-dom' import type { RouterContextData } from '~/libs/core' @@ -7,10 +7,16 @@ import { routerContext } from '~/libs/core' import { toolTitle } from './engagements.routes' import { EngagementsSwr } from './lib' +import './styles/index.scss' const EngagementsApp: FC<{}> = () => { const { getChildRoutes }: RouterContextData = useContext(routerContext) + useEffect(() => { + document.body.classList.add('engagements-app') + return () => document.body.classList.remove('engagements-app') + }, []) + return ( diff --git a/src/apps/engagements/src/styles/index.scss b/src/apps/engagements/src/styles/index.scss new file mode 100644 index 000000000..3dafeb7c5 --- /dev/null +++ b/src/apps/engagements/src/styles/index.scss @@ -0,0 +1 @@ +@import '@libs/ui/styles/2026/index'; diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md new file mode 100644 index 000000000..1709b63d9 --- /dev/null +++ b/src/apps/opportunities/README.md @@ -0,0 +1,134 @@ +# Opportunities + +The Opportunities app replaces the legacy community-app challenge discovery, +challenge detail, and reviewer-opportunity detail experiences. The main route +is `/opportunities`; domain tabs use `/opportunities/:kind`, challenge details +use `/opportunities/challenge/:challengeId`, and review details use +`/opportunities/review/:reviewOpportunityId`. + +The four headline metrics come from one `GET /v6/opportunities/summary` +request. List content is requested lazily from its owning API as members switch +tabs, filter, sort, or paginate. Do not prefetch bucket-sized list payloads. + +The Competitions sidebar follows the authored Figma filter with one Search +control and the helper text “Search skills, technologies, projects.” Its value +is sent through the Challenge API `search` parameter; Competitions does not +render a second skills/technologies field. Other opportunity domains retain +their owner-specific skill facet where supported. + +## List and grid views + +Every domain toolbar exposes the same accessible List/Grid selector from the +Figma. List remains the default. The selected presentation is held above the +keyed domain listing, so it remains stable while a member moves among +Competitions, Engagements, Copilot Opportunities, and Review Opportunities. +Changing the presentation is client-only: it reuses the current owner API page +and does not issue another list request or create per-card requests. + +At the authored 1200px desktop content width, Grid uses two 439px cards with a +16px gutter inside the 894px results column. Cards retain each owner's content: +competition prizes and phase progress stack above a horizontal metric footer; +engagement, copilot, and review metrics move to a vertical footer below the +card content. The responsive grid uses the same cards and automatically drops +to one column when two authored-width cards no longer fit. Both selector +buttons remain keyboard accessible and expose their active state with +`aria-pressed`. + +## Competition card contract + +Competition list cards consume the Challenge API v6 list response directly; +they do not make per-card follow-up requests. Track catalog values drive the +Figma Design, Development, Data Science, AI, and QA pill palettes. Challenge, +First2Finish, Marathon Match, and Task catalog values map to their authored +subtype icons and member-facing labels. + +- “Open for registration” requires an `ACTIVE` challenge and an open + `Registration` phase (or legacy combined `Open` phase). `ACTIVE` by itself + is not treated as an open registration window. The server-filtered “My + competitions” result marks those cards Registered without per-card calls. +- The prize footer uses only the `PLACEMENT` prize set and preserves its API + order as first, second, and third place. Checkpoint, copilot, and reviewer + payments are not mixed into competitor prizes. +- `currentPhase` is preferred for the phase chip. Older responses fall back to + the latest-started open phase. Progress uses actual then scheduled dates, + clamps to 0–100%, and may derive the end from the phase duration in seconds. + Competition pages revalidate once a minute and when focus returns; cards + with no open phase omit the phase display instead of inventing one. +- The right rail shows submissions and registrants from Challenge API. It also + reserves the Figma Posts row; until Challenge API publishes `numOfPosts`, the + value is an em dash rather than a fabricated discussion or forum count. + +## Challenge Markdown table of contents + +Challenge descriptions are safe Markdown. Authors create the generated table +of contents with level-two and level-three ATX headings: + +```markdown +## Challenge Summary + +Summary content. + +### Required Deliverables + +Deliverable content. +``` + +- `##` creates a top-level table-of-contents entry. +- `###` creates a nested entry. +- `#` is reserved for the page title and is not included. +- Duplicate headings are supported; stable source-line suffixes keep their + fragment links unique. +- GFM tables and hard line breaks are supported. Raw HTML is not rendered. + +Challenges declaring `descriptionFormat: "html"` use the legacy HTML path; +the HTML is DOMPurify-sanitized before rendering and does not produce a +Markdown table of contents. When Challenge API returns `privateDescription`, +the page renders it under “Registered User Additional Information” using the +same declared format. Challenge API remains authoritative for whether that +field is present for the caller. + +## Owning API contracts + +- Competitions: Challenge API, including `currentPhase`, + `currentPhaseNames`, phase schedules, `PLACEMENT` prize sets, plural + `tracks` and `types`, and canonical Challenge catalog values. +- Engagements: Engagements API, including top-level `durationWeeks` or + `durationMonths` and `IMMEDIATE`, `FEW_DAYS`, or `FEW_WEEKS` anticipated + start values. Cards display hydrated `skills[].name` values and retain + `requiredSkills` IDs only as a fallback for older API deployments. +- Copilot opportunities: Projects API, where the Figma track facet maps to the + opportunity `type` enum (`dev`, `qa`, `design`, `ai`, `datascience`). +- Review opportunities: Review API metadata search. The application action + uses `defaultApplicationRole` or a role selected from `applicationRoles`. + +Challenge registration and registrant displays resolve the canonical +Submitter resource role and exclude copilot, reviewer, observer, and manager +resources. The Registrants tab requests bounded Resource API pages and uses its +pagination headers instead of truncating a fixed bucket. “My competitions” +resolves that same role and sends `memberId` plus +`resourceRoleId` to Challenge API so role narrowing happens before the other +filters, global sorting, counts, and pagination. The terms modal similarly +filters Challenge API references to the +Submitter role and loads complete v5 Terms API records before an electronic +agreement. Passive “Review challenge terms” mode never registers or agrees on +a member's behalf. +DocuSign-template terms expose the Terms API recipient flow and return to the +challenge route after signing; registration remains blocked until the service +reports that every external agreement is complete. + +Design preview galleries use the public-safe +`GET /v6/submissions/previews?challengeId=...` endpoint rather than the +protected general submissions list. Review API remains authoritative for group, +whitelist, and review-phase release checks and returns only released Payload +asset URLs; an absent preview is rendered as a pending placeholder. + +Forum links are composed from the environment-specific +`VANILLA_FORUM.V2_URL` origin, so development routes stay on the development +Vanilla instance and production routes stay on production. + +The challenge rail parses case-insensitive `fileTypes`, `submissionLimit`, +`environment`, and `codeRepo` metadata, shows safe Challenge API discussions +and attachments, and fails closed for unsafe or retired-host URLs. Positive +legacy screening and review scorecard IDs link through the environment-specific +`ADMIN.ONLINE_REVIEW_URL`; Review App remains the primary authenticated review +handoff. diff --git a/src/apps/opportunities/index.tsx b/src/apps/opportunities/index.tsx new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/opportunities/index.tsx @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/opportunities/src/OpportunitiesApp.spec.tsx b/src/apps/opportunities/src/OpportunitiesApp.spec.tsx new file mode 100644 index 000000000..88b17e6f0 --- /dev/null +++ b/src/apps/opportunities/src/OpportunitiesApp.spec.tsx @@ -0,0 +1,47 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { render } from '@testing-library/react' +import { MemoryRouter, Route } from 'react-router-dom' + +import { routerContext } from '~/libs/core' + +import OpportunitiesApp from './OpportunitiesApp' + +jest.mock('~/libs/core', () => { + const React = jest.requireActual('react') + return { routerContext: React.createContext({ getChildRoutes: () => [] }) } +}, { virtual: true }) +jest.mock('./opportunities.routes', () => ({ toolTitle: 'Opportunities' })) + +describe('OpportunitiesApp', () => { + afterEach(() => { + document.body.className = '' + }) + + it('scopes the 2026 design system to app content instead of universal navigation', () => { + const getChildRoutes = jest.fn(() => [ + Opportunities} key='root' path='/' />, + ]) + const result = render( + + + + + , + ) + + expect(result.container.querySelector('.opportunities-app.tc-2026')) + .toBeInTheDocument() + expect(document.body) + .toHaveClass('opportunities-page') + expect(document.body) + .not.toHaveClass('opportunities-app', 'tc-2026') + + result.unmount() + expect(document.body) + .not.toHaveClass('opportunities-page') + }) +}) diff --git a/src/apps/opportunities/src/OpportunitiesApp.tsx b/src/apps/opportunities/src/OpportunitiesApp.tsx new file mode 100644 index 000000000..06a44dc5b --- /dev/null +++ b/src/apps/opportunities/src/OpportunitiesApp.tsx @@ -0,0 +1,50 @@ +import { + FC, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { OpportunityView } from './models' +import { + opportunityViewContext, + OpportunityViewContextData, +} from './opportunities.context' +import { toolTitle } from './opportunities.routes' +import './styles/index.scss' + +/** + * Hosts nested Opportunities routes and scopes the 2026 design system to this app. + * + * @returns the active child page and its nested Router elements. + * @throws Does not throw. + */ +const OpportunitiesApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + const [view, setView] = useState('list') + const viewContext = useMemo(() => ({ + onViewChange: setView, + view, + }), [view]) + + useEffect(() => { + document.body.classList.add('opportunities-page') + return () => document.body.classList.remove('opportunities-page') + }, []) + + return ( + +
+ + {childRoutes} +
+
+ ) +} + +export default OpportunitiesApp diff --git a/src/apps/opportunities/src/assets/ai-exponential-program.png b/src/apps/opportunities/src/assets/ai-exponential-program.png new file mode 100644 index 000000000..90a57800f Binary files /dev/null and b/src/apps/opportunities/src/assets/ai-exponential-program.png differ diff --git a/src/apps/opportunities/src/assets/challenge-type.svg b/src/apps/opportunities/src/assets/challenge-type.svg new file mode 100644 index 000000000..ac5363565 --- /dev/null +++ b/src/apps/opportunities/src/assets/challenge-type.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/chevron-down.svg b/src/apps/opportunities/src/assets/chevron-down.svg new file mode 100644 index 000000000..f7a27007b --- /dev/null +++ b/src/apps/opportunities/src/assets/chevron-down.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/competition.svg b/src/apps/opportunities/src/assets/competition.svg new file mode 100644 index 000000000..42bae7c2e --- /dev/null +++ b/src/apps/opportunities/src/assets/competition.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/opportunities/src/assets/copilot.svg b/src/apps/opportunities/src/assets/copilot.svg new file mode 100644 index 000000000..d4bb52c4d --- /dev/null +++ b/src/apps/opportunities/src/assets/copilot.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/opportunities/src/assets/empty-info.svg b/src/apps/opportunities/src/assets/empty-info.svg new file mode 100644 index 000000000..10033142f --- /dev/null +++ b/src/apps/opportunities/src/assets/empty-info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/engagement.svg b/src/apps/opportunities/src/assets/engagement.svg new file mode 100644 index 000000000..2e8dfc8bf --- /dev/null +++ b/src/apps/opportunities/src/assets/engagement.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/opportunities/src/assets/filter-checkbox-checked.svg b/src/apps/opportunities/src/assets/filter-checkbox-checked.svg new file mode 100644 index 000000000..067462ec5 --- /dev/null +++ b/src/apps/opportunities/src/assets/filter-checkbox-checked.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/opportunities/src/assets/filter-radio-selected.svg b/src/apps/opportunities/src/assets/filter-radio-selected.svg new file mode 100644 index 000000000..0ad2e23cf --- /dev/null +++ b/src/apps/opportunities/src/assets/filter-radio-selected.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/opportunities/src/assets/filter-search.svg b/src/apps/opportunities/src/assets/filter-search.svg new file mode 100644 index 000000000..8ca6452e4 --- /dev/null +++ b/src/apps/opportunities/src/assets/filter-search.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/first2finish-type.svg b/src/apps/opportunities/src/assets/first2finish-type.svg new file mode 100644 index 000000000..30c233840 --- /dev/null +++ b/src/apps/opportunities/src/assets/first2finish-type.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/marathon-type.svg b/src/apps/opportunities/src/assets/marathon-type.svg new file mode 100644 index 000000000..12096a7d1 --- /dev/null +++ b/src/apps/opportunities/src/assets/marathon-type.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/medal-1.svg b/src/apps/opportunities/src/assets/medal-1.svg new file mode 100644 index 000000000..b0b89fa17 --- /dev/null +++ b/src/apps/opportunities/src/assets/medal-1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/apps/opportunities/src/assets/medal-10.svg b/src/apps/opportunities/src/assets/medal-10.svg new file mode 100644 index 000000000..c2ccb0f4b --- /dev/null +++ b/src/apps/opportunities/src/assets/medal-10.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/apps/opportunities/src/assets/medal-2.svg b/src/apps/opportunities/src/assets/medal-2.svg new file mode 100644 index 000000000..3d7a2f58c --- /dev/null +++ b/src/apps/opportunities/src/assets/medal-2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/apps/opportunities/src/assets/medal-3.svg b/src/apps/opportunities/src/assets/medal-3.svg new file mode 100644 index 000000000..ee66c2635 --- /dev/null +++ b/src/apps/opportunities/src/assets/medal-3.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/apps/opportunities/src/assets/medal-4.svg b/src/apps/opportunities/src/assets/medal-4.svg new file mode 100644 index 000000000..45ea44298 --- /dev/null +++ b/src/apps/opportunities/src/assets/medal-4.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/apps/opportunities/src/assets/medal-5.svg b/src/apps/opportunities/src/assets/medal-5.svg new file mode 100644 index 000000000..54fea3c6d --- /dev/null +++ b/src/apps/opportunities/src/assets/medal-5.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/apps/opportunities/src/assets/medal-6.svg b/src/apps/opportunities/src/assets/medal-6.svg new file mode 100644 index 000000000..4b932a527 --- /dev/null +++ b/src/apps/opportunities/src/assets/medal-6.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/apps/opportunities/src/assets/medal-7.svg b/src/apps/opportunities/src/assets/medal-7.svg new file mode 100644 index 000000000..2d2a610f8 --- /dev/null +++ b/src/apps/opportunities/src/assets/medal-7.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/apps/opportunities/src/assets/medal-8.svg b/src/apps/opportunities/src/assets/medal-8.svg new file mode 100644 index 000000000..ae1aeed59 --- /dev/null +++ b/src/apps/opportunities/src/assets/medal-8.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/apps/opportunities/src/assets/medal-9.svg b/src/apps/opportunities/src/assets/medal-9.svg new file mode 100644 index 000000000..56d558562 --- /dev/null +++ b/src/apps/opportunities/src/assets/medal-9.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/apps/opportunities/src/assets/metric-calendar.svg b/src/apps/opportunities/src/assets/metric-calendar.svg new file mode 100644 index 000000000..8a230f3f8 --- /dev/null +++ b/src/apps/opportunities/src/assets/metric-calendar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/metric-hours.svg b/src/apps/opportunities/src/assets/metric-hours.svg new file mode 100644 index 000000000..0703b4c29 --- /dev/null +++ b/src/apps/opportunities/src/assets/metric-hours.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/metric-payment.svg b/src/apps/opportunities/src/assets/metric-payment.svg new file mode 100644 index 000000000..515878430 --- /dev/null +++ b/src/apps/opportunities/src/assets/metric-payment.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/metric-posts.svg b/src/apps/opportunities/src/assets/metric-posts.svg new file mode 100644 index 000000000..0da9efb30 --- /dev/null +++ b/src/apps/opportunities/src/assets/metric-posts.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/metric-registrants.svg b/src/apps/opportunities/src/assets/metric-registrants.svg new file mode 100644 index 000000000..9501bc48b --- /dev/null +++ b/src/apps/opportunities/src/assets/metric-registrants.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/metric-role.svg b/src/apps/opportunities/src/assets/metric-role.svg new file mode 100644 index 000000000..1b8441978 --- /dev/null +++ b/src/apps/opportunities/src/assets/metric-role.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/metric-start.svg b/src/apps/opportunities/src/assets/metric-start.svg new file mode 100644 index 000000000..a9c57b9ae --- /dev/null +++ b/src/apps/opportunities/src/assets/metric-start.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/metric-submissions.svg b/src/apps/opportunities/src/assets/metric-submissions.svg new file mode 100644 index 000000000..2059aae60 --- /dev/null +++ b/src/apps/opportunities/src/assets/metric-submissions.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/opportunity-map.svg b/src/apps/opportunities/src/assets/opportunity-map.svg new file mode 100644 index 000000000..adb49c1a3 --- /dev/null +++ b/src/apps/opportunities/src/assets/opportunity-map.svg @@ -0,0 +1,916 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/apps/opportunities/src/assets/pagination-chevron-right.svg b/src/apps/opportunities/src/assets/pagination-chevron-right.svg new file mode 100644 index 000000000..1b0918cbc --- /dev/null +++ b/src/apps/opportunities/src/assets/pagination-chevron-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/phase-registration.svg b/src/apps/opportunities/src/assets/phase-registration.svg new file mode 100644 index 000000000..6c2422d3c --- /dev/null +++ b/src/apps/opportunities/src/assets/phase-registration.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/phase-submission.svg b/src/apps/opportunities/src/assets/phase-submission.svg new file mode 100644 index 000000000..2059aae60 --- /dev/null +++ b/src/apps/opportunities/src/assets/phase-submission.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/registration-closed.svg b/src/apps/opportunities/src/assets/registration-closed.svg new file mode 100644 index 000000000..a61a29f10 --- /dev/null +++ b/src/apps/opportunities/src/assets/registration-closed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/registration-open.svg b/src/apps/opportunities/src/assets/registration-open.svg new file mode 100644 index 000000000..9513dae9c --- /dev/null +++ b/src/apps/opportunities/src/assets/registration-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/reset.svg b/src/apps/opportunities/src/assets/reset.svg new file mode 100644 index 000000000..2668da2ee --- /dev/null +++ b/src/apps/opportunities/src/assets/reset.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/review.svg b/src/apps/opportunities/src/assets/review.svg new file mode 100644 index 000000000..a00dba66a --- /dev/null +++ b/src/apps/opportunities/src/assets/review.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/opportunities/src/assets/sort.svg b/src/apps/opportunities/src/assets/sort.svg new file mode 100644 index 000000000..e4302085a --- /dev/null +++ b/src/apps/opportunities/src/assets/sort.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/task-type.svg b/src/apps/opportunities/src/assets/task-type.svg new file mode 100644 index 000000000..d7e7c3a29 --- /dev/null +++ b/src/apps/opportunities/src/assets/task-type.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/view-grid-active.svg b/src/apps/opportunities/src/assets/view-grid-active.svg new file mode 100644 index 000000000..e9375941d --- /dev/null +++ b/src/apps/opportunities/src/assets/view-grid-active.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/view-grid-inactive.svg b/src/apps/opportunities/src/assets/view-grid-inactive.svg new file mode 100644 index 000000000..bb8d3679a --- /dev/null +++ b/src/apps/opportunities/src/assets/view-grid-inactive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/view-list-active.svg b/src/apps/opportunities/src/assets/view-list-active.svg new file mode 100644 index 000000000..dc15ceb5c --- /dev/null +++ b/src/apps/opportunities/src/assets/view-list-active.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/view-list-inactive.svg b/src/apps/opportunities/src/assets/view-list-inactive.svg new file mode 100644 index 000000000..c4cba669d --- /dev/null +++ b/src/apps/opportunities/src/assets/view-list-inactive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/components/ChallengeDetailHeader.module.scss b/src/apps/opportunities/src/components/ChallengeDetailHeader.module.scss new file mode 100644 index 000000000..b4894d27b --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeDetailHeader.module.scss @@ -0,0 +1,555 @@ +.header { + color: #fff; + font-family: 'Nunito Sans', sans-serif; +} + +.breadcrumbRow { + align-items: center; + background: #001e2e; + display: flex; + min-height: 66px; + padding: 0 40px; +} + +.breadcrumbs { + color: rgba(255, 255, 255, .6); + display: flex; + font-size: 14px; + gap: 8px; + line-height: 20px; + margin: 0 auto; + max-width: 1200px; + overflow: hidden; + white-space: nowrap; + width: 100%; + + a { + color: inherit; + text-decoration: none; + } + + span:last-child { + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.masthead { + background: #001e2e; + min-height: 286px; + overflow: hidden; + padding: 0 40px 32px; + position: relative; +} + +.rings { + border: 2px dashed rgba(61, 219, 217, .42); + border-radius: 50%; + height: 498px; + position: absolute; + right: 61px; + top: -122px; + width: 498px; + + &::before, + &::after { + border: 2px dashed rgba(61, 219, 217, .42); + border-radius: 50%; + content: ''; + inset: 61px; + position: absolute; + } + + &::after { + inset: 121px; + } +} + +.layout { + align-items: start; + display: grid; + gap: 24px; + grid-template-columns: minmax(0, 792px) 384px; + margin: 0 auto; + max-width: 1200px; + min-height: 252px; + position: relative; + z-index: 1; +} + +.copy { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 252px; +} + +.catalog, +.skills, +.timeline, +.prizes { + align-items: center; + display: flex; + flex-wrap: wrap; +} + +.catalog { + font-size: 11px; + font-weight: 600; + gap: 4px; + line-height: 12px; + + > span { + align-items: center; + border-radius: 2px; + display: flex; + height: 20px; + padding: 4px; + } + + > span:last-child { + gap: 2px; + } + + img { + filter: brightness(0) invert(1); + height: 14px; + width: 14px; + } +} + +.defaultTrack { + border: 1px solid rgba(255, 255, 255, .6); + color: rgba(255, 255, 255, .8); +} + +.designTrack { + border: 1px solid #a6c8ff; + color: #a6c8ff; +} + +.developmentTrack { + border: 1px solid #6fdc8c; + color: #6fdc8c; +} + +.dataScienceTrack { + border: 1px solid #ffb784; + color: #ffb784; +} + +.qaTrack { + border: 1px solid #ffafd2; + color: #ffafd2; +} + +.aiTrack { + border: 1px solid #d4bbff; + color: #d4bbff; +} + +.copy h1 { + font-family: 'Figtree', sans-serif; + font-size: 48px; + font-weight: 700; + line-height: 56px; + margin: 16px 0 8px; + max-width: 792px; + text-transform: none; +} + +.skills { + gap: 8px; + + span { + background: rgba(255, 255, 255, .2); + border-radius: 2px; + font-size: 11px; + font-weight: 600; + height: 20px; + line-height: 12px; + padding: 4px; + } +} + +.designSkills span:first-child { + background: transparent; + border: 1px solid rgba(255, 255, 255, .6); + padding: 3px; +} + +.timeline { + font-size: 14px; + font-weight: 700; + gap: 16px; + line-height: 20px; + padding-top: 24px; + + > span, + > button { + align-items: center; + display: flex; + gap: 8px; + } + + > span > svg { + background: rgba(0, 125, 121, .12); + border-radius: 50%; + box-sizing: content-box; + color: #3ddbd9; + height: 20px; + padding: 8px; + width: 20px; + } + + > button { + background: transparent; + border: 1px solid #3ddbd9; + border-radius: 4px; + color: #3ddbd9; + cursor: pointer; + font: inherit; + font-size: 16px; + height: 38px; + padding: 8px 8px 8px 16px; + + svg { + height: 20px; + transition: transform .2s ease; + width: 20px; + } + + &[aria-expanded='true'] svg { + transform: rotate(180deg); + } + } +} + +.actionCard { + backdrop-filter: blur(8px); + background: rgba(255, 255, 255, .05); + border-radius: 8px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 20px; + height: 252px; + justify-content: space-between; + padding: 24px; + position: relative; + + button, + a { + align-items: center; + border-radius: 4px; + box-sizing: border-box; + display: flex; + font-family: inherit; + font-size: 16px; + font-weight: 700; + height: 38px; + justify-content: center; + line-height: 22px; + text-decoration: none; + width: 100%; + } + + button:disabled { + cursor: not-allowed; + opacity: .55; + } +} + +.prizeFrame { + align-items: center; + display: flex; + flex-direction: column; + gap: 4px; + + small { + color: rgba(255, 255, 255, .6); + font-size: 12px; + font-weight: 700; + line-height: 16px; + } +} + +.prizes { + color: #3ddbd9; + display: flex; + gap: 24px; + justify-content: center; + width: 100%; + + strong { + align-items: center; + display: flex; + font-family: 'Figtree', sans-serif; + font-size: 22px; + gap: 4px; + line-height: normal; + + img { + height: 20px; + width: 20px; + } + } +} + +.extendedPrizeFrame { + height: 92px; + width: 100%; + + .prizes { + display: grid; + gap: 4px 0; + grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-template-rows: 26px 19px 19px; + height: 72px; + } + + strong { + justify-self: center; + } + + strong:nth-child(1) { + grid-column: 1 / 5; + justify-self: start; + } + + strong:nth-child(2) { + grid-column: 5 / 9; + } + + strong:nth-child(3) { + grid-column: 9 / 13; + justify-self: end; + } + + strong:nth-child(4) { grid-column: 1 / 4; } + strong:nth-child(5) { grid-column: 4 / 7; } + strong:nth-child(6) { grid-column: 7 / 10; } + strong:nth-child(7) { grid-column: 10 / 13; } + strong:nth-child(8) { grid-column: 3 / 6; } + strong:nth-child(9) { grid-column: 6 / 9; } + strong:nth-child(10) { grid-column: 9 / 12; } +} + +.primaryPrize { + grid-row: 1; +} + +.secondaryPrize { + grid-row: 2; + + &:nth-child(n + 8) { + grid-row: 3; + } + + &, + img { + height: 16px !important; + } + + & { + font-size: 16px !important; + } +} + +.actions { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +.secondary { + background: transparent; + border: 1px solid #fff; + color: #fff; + cursor: pointer; +} + +.primary { + background: #007d79; + border: 1px solid #007d79; + color: #fff; + gap: 4px; + + &[aria-disabled='true'] { + cursor: not-allowed; + opacity: .45; + pointer-events: none; + } + + &:disabled { + background: rgba(0, 125, 121, .45); + border-color: transparent; + color: rgba(255, 255, 255, .6); + } + + svg { + height: 20px; + width: 20px; + } +} + +.expandedTimeline { + display: flex; + gap: 0; + justify-content: space-between; + list-style: none; + margin: 32px auto 0; + max-width: 1200px; + padding: 20px 0 0; + position: relative; + z-index: 1; + + &::before { + background: rgba(255, 255, 255, .4); + content: ''; + height: 1px; + left: 0; + position: absolute; + right: 0; + top: 7px; + } + + li { + align-items: center; + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + min-width: 110px; + position: relative; + text-align: center; + + > span { + background: #001e2e; + border: 2px solid rgba(255, 255, 255, .7); + border-radius: 50%; + height: 14px; + position: absolute; + top: -20px; + width: 14px; + } + + strong { + font-size: 12px; + line-height: 16px; + } + + small { + color: rgba(255, 255, 255, .6); + font-size: 11px; + line-height: 16px; + } + } +} + +.completed { + > span { + background: #fff !important; + border-color: #fff !important; + } +} + +.current { + color: #3ddbd9; + + > span { + background: #3ddbd9 !important; + border-color: #3ddbd9 !important; + } +} + +.upcoming { + color: rgba(255, 255, 255, .6); +} + +@media (max-width: 1100px) { + .layout { + grid-template-columns: minmax(0, 1fr) 340px; + } + + .copy h1 { + font-size: 40px; + line-height: 48px; + } + + .actionCard { + width: 340px; + } + + .prizes { + gap: 12px; + + strong { + font-size: 24px; + } + } +} + +@media (max-width: 860px) { + .masthead { + padding-block: 8px 32px; + } + + .layout { + grid-template-columns: 1fr; + } + + .copy { + min-height: auto; + } + + .actionCard { + max-width: 420px; + width: 100%; + } + + .expandedTimeline { + align-items: stretch; + flex-direction: column; + gap: 16px; + padding-left: 24px; + + &::before { + bottom: 0; + height: auto; + left: 6px; + right: auto; + top: 0; + width: 1px; + } + + li { + align-items: flex-start; + text-align: left; + + > span { + left: -24px; + top: 2px; + } + } + } +} + +@media (max-width: 620px) { + .breadcrumbRow, + .masthead { + padding-inline: 16px; + } + + .copy h1 { + font-size: 34px; + line-height: 40px; + } + + .timeline { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/apps/opportunities/src/components/ChallengeDetailHeader.spec.tsx b/src/apps/opportunities/src/components/ChallengeDetailHeader.spec.tsx new file mode 100644 index 000000000..1c229590b --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeDetailHeader.spec.tsx @@ -0,0 +1,158 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { render, RenderResult, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +import { ChallengeOpportunity } from '../models' + +import { ChallengeDetailHeader } from './ChallengeDetailHeader' + +jest.mock('~/libs/ui', () => { + const Icon = (): JSX.Element => + return { + IconOutline: new Proxy({}, { + get: () => Icon, + }), + } +}, { virtual: true }) + +/** Creates an active, overlapping registration/submission challenge fixture. */ +function challengeFixture(overrides: Partial = {}): ChallengeOpportunity { + return { + currentPhase: { + isOpen: true, + name: 'Submission', + scheduledEndDate: '2026-08-15T00:00:00.000Z', + }, + currentPhaseNames: ['Registration', 'Submission'], + id: 'challenge-id', + name: 'Figma challenge', + phases: [ + { isOpen: true, name: 'Registration' }, + { isOpen: true, name: 'Submission', scheduledEndDate: '2026-08-15T00:00:00.000Z' }, + ], + prizeSets: [{ + prizes: Array.from({ length: 10 }, (_, index) => ({ type: 'USD', value: 1000 - (index * 50) })), + type: 'PLACEMENT', + }], + skills: [{ name: 'Algorithms' }, { name: 'Probability' }], + status: 'ACTIVE', + track: { name: 'Data Science', track: 'DATA_SCIENCE' }, + type: { name: 'Marathon Match' }, + ...overrides, + } +} + +describe('ChallengeDetailHeader actions and presentation', () => { + beforeEach(() => { + jest.spyOn(Date, 'now') + .mockReturnValue(Date.parse('2026-08-14T00:00:00.000Z')) + }) + + afterEach(() => jest.restoreAllMocks()) + + it('shows only Register for an unregistered open challenge', () => { + render( + + + , + ) + + expect(screen.getByRole('button', { name: 'Register' })) + .toBeEnabled() + expect(screen.queryByText('Unregister')) + .not.toBeInTheDocument() + expect(screen.queryByText('Submit a solution')) + .not.toBeInTheDocument() + }) + + it('shows enabled member actions only while their phases are open', () => { + const { rerender }: RenderResult = render( + + + , + ) + + expect(screen.getByRole('button', { name: 'Unregister' })) + .toBeEnabled() + expect(screen.getByRole('link', { name: 'Submit a solution' })) + .toHaveAttribute('href', '/challenges/challenge-id/submit') + + rerender( + + + , + ) + expect(screen.getByRole('button', { name: 'Unregister' })) + .toBeDisabled() + expect(screen.getByText('Submit a solution') + .closest('a')) + .toHaveAttribute('aria-disabled', 'true') + expect(screen.getByText('Submit a solution') + .closest('a')) + .not.toHaveAttribute('href') + }) + + it('avoids flashing Register while member registration is unresolved', () => { + render( + + + , + ) + + expect(screen.getByRole('button', { name: 'Checking registration…' })) + .toBeDisabled() + expect(screen.queryByRole('button', { name: 'Register' })) + .not.toBeInTheDocument() + }) + + it('uses the exact Data Science header color and all ten placement prizes', () => { + render( + + + , + ) + + expect(screen.getByText('Data Science').className) + .toContain('dataScienceTrack') + expect(screen.getAllByAltText(/place$/)) + .toHaveLength(10) + expect(screen.getByAltText('10 place')) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx b/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx new file mode 100644 index 000000000..d2d2abc8d --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx @@ -0,0 +1,336 @@ +/* eslint-disable react/jsx-no-bind */ +import { FC, useState } from 'react' +import { Link } from 'react-router-dom' +import classNames from 'classnames' + +import { IconOutline } from '~/libs/ui' + +import { ChallengeOpportunity, ChallengePhase } from '../models' +import challengeTypeIcon from '../assets/challenge-type.svg' +import first2FinishTypeIcon from '../assets/first2finish-type.svg' +import marathonTypeIcon from '../assets/marathon-type.svg' +import medal1 from '../assets/medal-1.svg' +import medal10 from '../assets/medal-10.svg' +import medal2 from '../assets/medal-2.svg' +import medal3 from '../assets/medal-3.svg' +import medal4 from '../assets/medal-4.svg' +import medal5 from '../assets/medal-5.svg' +import medal6 from '../assets/medal-6.svg' +import medal7 from '../assets/medal-7.svg' +import medal8 from '../assets/medal-8.svg' +import medal9 from '../assets/medal-9.svg' +import taskTypeIcon from '../assets/task-type.svg' + +import { + challengeCatalogKey, + challengeCurrentPhase, + ChallengePlacementPrize, + challengePlacementPrizes, + challengeRegistrationIsOpen, + challengeSubmissionIsOpen, +} from './challenge-card.utils' +import styles from './ChallengeDetailHeader.module.scss' + +interface ChallengeDetailHeaderProps { + busy: boolean + challenge: ChallengeOpportunity + isRegistered: boolean + onRegister: () => void + onUnregister: () => void + registrationError?: boolean + registrationLoading?: boolean +} + +/** Returns a catalog name from either v5-compatible or v6 challenge data. */ +function catalogName(value: string | { name?: string } | undefined, fallback: string): string { + return typeof value === 'string' ? value : value?.name || fallback +} + +/** + * Formats the compact same-year date range used by the challenge masthead. + * + * @param startValue phase or challenge start timestamp. + * @param endValue phase or challenge end timestamp. + * @returns member-facing date range or a schedule fallback. + * @throws Does not throw; malformed dates use the fallback label. + */ +function dateRange(startValue?: string, endValue?: string): string { + const start = startValue ? new Date(startValue) : undefined + const end = endValue ? new Date(endValue) : undefined + if (!start || Number.isNaN(start.getTime())) return 'Schedule to be announced' + const month = new Intl.DateTimeFormat('en-US', { month: 'long' }) + const startLabel = `${start.getDate()} ${month.format(start)}` + if (!end || Number.isNaN(end.getTime())) return `${startLabel}, ${start.getFullYear()}` + const endLabel = `${end.getDate()} ${month.format(end)}, ${end.getFullYear()}` + return `${startLabel} - ${endLabel}` +} + +/** + * Maps canonical challenge subtypes to the exported Topcoder glyphs. + * + * @param type Challenge API type label. + * @returns local icon asset for the subtype tag. + * @throws Does not throw; unknown types use the generic Challenge glyph. + */ +function typeIcon(type: string): string { + const normalized = type.toLowerCase() + if (normalized.includes('marathon')) return marathonTypeIcon + if (normalized.includes('first2finish') || normalized.includes('first 2 finish') || normalized === 'f2f') { + return first2FinishTypeIcon + } + + if (normalized.includes('task')) return taskTypeIcon + return challengeTypeIcon +} + +/** + * Formats the active phase and remaining time for the masthead metric. + * + * @param phase current or next challenge phase. + * @returns phase deadline summary suitable for a compact metric. + * @throws Does not throw; absent and malformed dates use stable fallbacks. + */ +function phaseSummary(phase: ChallengePhase | undefined): string { + if (!phase) return 'Timeline complete' + const endValue = phase.actualEndDate ?? phase.scheduledEndDate + const end = endValue ? new Date(endValue) : undefined + if (!end || Number.isNaN(end.getTime())) return `${phase.name} phase is active` + const remainingMinutes = Math.max(0, Math.ceil((end.getTime() - Date.now()) / 60000)) + if (remainingMinutes === 0) return `${phase.name} phase is active` + const days = Math.floor(remainingMinutes / 1440) + const hours = Math.floor((remainingMinutes % 1440) / 60) + const minutes = remainingMinutes % 60 + const parts = [ + days > 0 ? `${days}d` : '', + hours > 0 ? `${hours}h` : '', + days === 0 && minutes > 0 ? `${minutes}m` : '', + ].filter(Boolean) + return `${phase.name} phase closes in ${parts.join(' ')}` +} + +/** + * Formats one typed placement prize without assuming every reward is USD. + * + * @param prize Challenge API placement prize. + * @returns localized currency, point, or typed-value label. + * @throws Does not throw; unsupported currency codes fall back to typed text. + */ +function formatPrize(prize: ChallengePlacementPrize): string { + const type = prize.type?.trim() + .toUpperCase() + const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }) + .format(prize.value) + if (type === 'POINT' || type === 'POINTS') return `${number} pts` + const currency = type || 'USD' + if (/^[A-Z]{3}$/.test(currency)) { + try { + return new Intl.NumberFormat('en-US', { + currency, + maximumFractionDigits: 2, + minimumFractionDigits: 0, + style: 'currency', + }) + .format(prize.value) + } catch { + // Render the API's explicit reward type below. + } + } + + return type ? `${number} ${type}` : number +} + +/** + * Returns the exact Figma header-track color class for a catalog value. + * + * @param trackKey normalized Challenge API track key. + * @returns scoped CSS class for Design, Development, Data Science, QA, or AI. + * @throws Does not throw. + */ +function trackClass(trackKey: string): string { + const classes: Record = { + ai: styles.aiTrack, + artificialintelligence: styles.aiTrack, + datascience: styles.dataScienceTrack, + design: styles.designTrack, + development: styles.developmentTrack, + qualityassurance: styles.qaTrack, + } + return classes[trackKey] ?? styles.defaultTrack +} + +/** + * Labels one expanded-timeline phase for visual completion state. + * + * @param phase scheduled challenge phase. + * @param selected API-authoritative current phase. + * @returns completed, current, or upcoming state. + * @throws Does not throw; malformed dates remain upcoming. + */ +function timelineState(phase: ChallengePhase, selected?: ChallengePhase): 'completed' | 'current' | 'upcoming' { + if ((selected?.id && phase.id === selected.id) || (!selected?.id && selected === phase)) return 'current' + const endValue = phase.actualEndDate ?? phase.scheduledEndDate + const end = endValue ? new Date(endValue) : undefined + return end && !Number.isNaN(end.getTime()) && end.getTime() <= Date.now() ? 'completed' : 'upcoming' +} + +/** + * Renders the Figma challenge title, phase context, prizes, and member actions. + * + * @param props challenge and registration state. + * @returns dark challenge detail masthead. + * @throws Does not throw. + */ +export const ChallengeDetailHeader: FC = props => { + const [timelineOpen, setTimelineOpen] = useState(false) + const phase = challengeCurrentPhase(props.challenge) + const challengePrizes = challengePlacementPrizes(props.challenge) + const type = catalogName(props.challenge.type, 'Challenge') + const track = catalogName(props.challenge.track, 'Competition') + const trackKey = challengeCatalogKey(props.challenge.track) + const submitUrl = `/challenges/${props.challenge.id}/submit` + const registrationOpen = challengeRegistrationIsOpen(props.challenge) + const submissionOpen = challengeSubmissionIsOpen(props.challenge) + const registrationUnavailable = props.registrationLoading || props.registrationError + const canUnregister = props.isRegistered && registrationOpen && !registrationUnavailable && !props.busy + const canSubmit = props.isRegistered && submissionOpen && !registrationUnavailable && !props.busy + const medalAssets = [medal1, medal2, medal3, medal4, medal5, medal6, medal7, medal8, medal9, medal10] + const skills = props.challenge.skills ?? [] + + return ( +
+
+
+ Opportunities + / + Competitions + / + {props.challenge.name} +
+
+
+ +
+ ) +} diff --git a/src/apps/opportunities/src/components/ChallengeForum.module.scss b/src/apps/opportunities/src/components/ChallengeForum.module.scss new file mode 100644 index 000000000..ae517640d --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeForum.module.scss @@ -0,0 +1,771 @@ +.visuallyHidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.forumLayout { + align-items: start; + display: grid; + gap: 24px; + grid-template-columns: 281px minmax(0, 895px); +} + +.leftPanel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.overview, +.filters, +.discussionInfo, +.topicCard, +.topicInfo, +.post, +.replyPost, +.fallback, +.noResults { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-sizing: border-box; +} + +.overview, +.filters, +.discussionInfo, +.topicInfo { + padding: 24px; +} + +.overview h2, +.filters h2, +.discussionInfo h2, +.topicInfo h2 { + color: #161616; + font-family: 'Figtree', sans-serif; + font-weight: 700; + margin: 0; + text-transform: none; +} + +.overview h2 { + font-size: 24px; + line-height: 38px; +} + +.overviewStats { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 2px; + + span { + background: #f4f4f4; + border-radius: 2px; + color: #161616; + font-size: 11px; + font-weight: 600; + line-height: 12px; + padding: 4px; + } + + .newCount { + background: #c1294f; + color: #fff; + } +} + +.overview > a, +.fallback > a { + align-items: center; + background: #007d79; + border: 1px solid #007d79; + border-radius: 4px; + box-sizing: border-box; + color: #fff; + display: flex; + font-size: 16px; + font-weight: 700; + gap: 6px; + height: 38px; + justify-content: center; + margin-top: 24px; + padding: 7px 16px; + text-decoration: none; + + &:hover, + &:focus-visible { + background: #006b68; + color: #fff; + } + + svg { + height: 18px; + width: 18px; + } +} + +.filters header { + align-items: center; + display: flex; + justify-content: space-between; + + h2 { + font-size: 18px; + line-height: 30px; + } + + button { + appearance: none; + background: transparent; + border: 0; + color: #007d79; + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 700; + line-height: 20px; + padding: 0; + } +} + +.searchField { + align-items: center; + border: 1px solid #8d8d8d; + border-radius: 4px; + box-sizing: border-box; + display: flex; + height: 38px; + margin-top: 16px; + padding: 0 12px; + + &:focus-within { + border-color: #007d79; + box-shadow: 0 0 0 1px #007d79; + } + + svg { + color: #525252; + flex: 0 0 18px; + height: 18px; + width: 18px; + } + + input { + appearance: none; + background: transparent; + border: 0; + color: #161616; + font: inherit; + font-size: 14px; + line-height: 20px; + min-width: 0; + outline: 0; + padding: 8px; + width: 100%; + + &::placeholder { + color: #6f6f6f; + } + } +} + +.filters > small { + color: #6f6f6f; + display: block; + font-size: 12px; + line-height: 16px; + margin-top: 4px; +} + +.sortField { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 16px; + + > span { + color: #161616; + font-size: 14px; + font-weight: 700; + line-height: 20px; + } + + select { + appearance: auto; + background: #fff; + border: 1px solid #8d8d8d; + border-radius: 4px; + color: #161616; + font: inherit; + font-size: 14px; + height: 38px; + line-height: 20px; + padding: 0 10px; + width: 100%; + } +} + +.filters fieldset { + border: 0; + display: flex; + flex-direction: column; + gap: 2px; + margin: 12px 0 0; + padding: 0; + + label { + align-items: center; + color: #161616; + cursor: pointer; + display: flex; + font-size: 14px; + gap: 8px; + line-height: 32px; + min-height: 32px; + } + + input { + clip: rect(0 0 0 0); + height: 1px; + overflow: hidden; + position: absolute; + width: 1px; + } + + input:focus-visible + .radio { + box-shadow: 0 0 0 2px #fff, 0 0 0 4px #007d79; + } + + input:checked + .radio { + border-color: #007d79; + + &::after { + background: #007d79; + border-radius: 50%; + content: ''; + height: 6px; + width: 6px; + } + } + + em { + align-items: center; + background: #c1294f; + border-radius: 8px; + color: #fff; + display: inline-flex; + font-size: 11px; + font-style: normal; + font-weight: 700; + height: 16px; + justify-content: center; + min-width: 16px; + padding: 0 4px; + } +} + +.radio { + align-items: center; + border: 1px solid #8d8d8d; + border-radius: 50%; + display: inline-flex; + height: 12px; + justify-content: center; + width: 12px; +} + +.discussionInfo h2, +.topicInfo h2 { + align-items: center; + display: flex; + font-size: 18px; + gap: 8px; + line-height: 30px; + + svg { + height: 20px; + width: 20px; + } +} + +.discussionInfo > .member, +.topicInfo > .member { + margin-top: 16px; +} + +.discussionInfo dl, +.topicInfo dl { + border-top: 1px solid #e0e0e0; + display: flex; + flex-direction: column; + gap: 8px; + margin: 16px 0 0; + padding-top: 16px; + + > div { + display: grid; + font-size: 12px; + gap: 8px; + grid-template-columns: 64px 1fr; + line-height: 16px; + } + + dt { + font-weight: 700; + } + + dd { + margin: 0; + } +} + +.member { + align-items: center; + display: inline-flex; + gap: 8px; + min-width: 0; + + > a { + color: #007d79; + font-size: 14px; + font-weight: 700; + line-height: 20px; + overflow: hidden; + text-decoration: none; + text-overflow: ellipsis; + } +} + +.avatar { + align-items: center; + background: #d9e1e5; + border: 1px solid #fff; + border-radius: 50%; + color: #161616; + display: inline-flex; + flex: 0 0 32px; + font-size: 12px; + font-weight: 700; + height: 32px; + justify-content: center; + overflow: hidden; + text-decoration: none; + width: 32px; + + img { + height: 100%; + object-fit: cover; + width: 100%; + } +} + +.topicList { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.limitNotice { + background: #fff4ce; + border: 1px solid #f1c21b; + border-radius: 4px; + color: #161616; + font-size: 14px; + line-height: 20px; + margin: 0; + padding: 12px 16px; +} + +.topicCard { + display: grid; + grid-template-columns: minmax(0, 647px) 200px; + min-height: 260px; + padding: 24px; +} + +.topicMain { + display: flex; + flex-direction: column; + min-width: 0; + padding-right: 24px; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 20px; + + span { + border-radius: 2px; + font-size: 11px; + font-weight: 600; + line-height: 12px; + padding: 4px; + } +} + +.announcement { + background: #007d79; + color: #fff; +} + +.newPost { + background: #fff; + border: 1px solid #c1294f; + color: #c1294f; +} + +.locked { + background: #f4f4f4; + color: #525252; +} + +.topicTitle { + appearance: none; + background: transparent; + border: 0; + color: #161616; + cursor: pointer; + font-family: 'Figtree', sans-serif; + font-size: 24px; + font-weight: 700; + line-height: 38px; + margin: 4px 0 0; + padding: 0; + text-align: left; + + &:hover, + &:focus-visible { + color: #007d79; + text-decoration: underline; + } +} + +.createdBy { + align-items: center; + color: #525252; + display: flex; + flex-wrap: wrap; + font-size: 12px; + gap: 8px; + line-height: 16px; + margin-top: 4px; + + .member { + gap: 6px; + } + + .avatar { + height: 32px; + width: 32px; + } +} + +.activity { + color: #6f6f6f; + font-size: 12px; + line-height: 16px; + margin: auto 0 0; +} + +.viewTopic, +.back, +.detailError > button { + appearance: none; + background: transparent; + border: 0; + color: #007d79; + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 700; + line-height: 20px; + padding: 0; +} + +.viewTopic { + align-self: flex-end; + margin-top: 12px; +} + +.topicMetrics { + border-left: 1px solid #e0e0e0; + display: flex; + flex-direction: column; + padding: 42px 0 0 24px; + + p { + align-items: center; + display: flex; + font-size: 14px; + gap: 4px; + line-height: 20px; + margin: 0; + + svg { + color: #007d79; + height: 20px; + width: 20px; + } + } + + > strong { + font-size: 14px; + line-height: 20px; + margin-top: 16px; + } +} + +.participants { + display: flex; + margin-top: 4px; + min-height: 32px; + + a { + display: inline-flex; + height: 32px; + text-decoration: none; + width: 32px; + } + + a + a { + margin-left: -8px; + } + + a:nth-child(2n) .avatar { + background: #d0e2ff; + } +} + +.fallback, +.noResults { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 320px; + padding: 40px; + text-align: center; + + > svg { + background: #e8e8e8; + border-radius: 50%; + color: #161616; + height: 20px; + padding: 6px; + width: 20px; + } + + h2 { + color: #161616; + font-family: 'Figtree', sans-serif; + font-size: 18px; + line-height: 30px; + margin: 12px 0 0; + text-transform: none; + } + + p { + color: #525252; + font-size: 14px; + line-height: 20px; + margin: 4px 0 0; + max-width: 540px; + } + + > a { + margin-top: 16px; + width: auto; + } +} + +.detailView { + min-width: 0; +} + +.back { + align-items: center; + color: #161616; + display: inline-flex; + font-size: 16px; + gap: 4px; + line-height: 22px; + margin-bottom: 24px; + max-width: 100%; + text-align: left; + + &:hover, + &:focus-visible { + color: #007d79; + } + + svg { + flex: 0 0 20px; + height: 20px; + width: 20px; + } +} + +.detailLayout { + align-items: start; + display: grid; + gap: 24px; + grid-template-columns: 281px minmax(0, 895px); +} + +.topicInfoMetrics { + border-top: 1px solid #e0e0e0; + display: flex; + flex-direction: column; + margin-top: 16px; + padding-top: 16px; + + p { + align-items: center; + display: flex; + font-size: 14px; + gap: 6px; + line-height: 20px; + margin: 0; + + svg { + color: #007d79; + height: 20px; + width: 20px; + } + } + + > strong { + font-size: 14px; + line-height: 20px; + margin-top: 12px; + } +} + +.posts { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.post, +.replyPost { + padding: 24px; + + > header { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; + + > span:not(.member) { + background: #f4f4f4; + border-radius: 2px; + color: #525252; + font-size: 11px; + font-weight: 600; + line-height: 12px; + padding: 4px; + } + + > time { + color: #6f6f6f; + flex-basis: 100%; + font-size: 12px; + line-height: 16px; + } + } + + > footer { + border-top: 1px solid #e0e0e0; + margin-top: 16px; + padding-top: 12px; + + a { + align-items: center; + color: #007d79; + display: inline-flex; + font-size: 14px; + font-weight: 700; + gap: 6px; + line-height: 20px; + text-decoration: none; + + svg { + height: 18px; + width: 18px; + } + } + } +} + +.replyPost { + border-left: 4px solid #d9e1e5; +} + +.deleted { + color: #6f6f6f; + font-size: 14px; + font-style: italic; + line-height: 20px; + margin: 0; +} + +.detailError { + display: flex; + flex-direction: column; + gap: 16px; + + > button { + align-self: flex-start; + } +} + +@media (max-width: 1100px) { + .forumLayout, + .detailLayout { + grid-template-columns: 240px minmax(0, 1fr); + } + + .topicCard { + grid-template-columns: minmax(0, 1fr) 170px; + } +} + +@media (max-width: 767px) { + .forumLayout, + .detailLayout { + grid-template-columns: 1fr; + } + + .topicCard { + grid-template-columns: 1fr; + } + + .topicMain { + padding-right: 0; + } + + .topicMetrics { + border-left: 0; + border-top: 1px solid #e0e0e0; + margin-top: 20px; + padding: 20px 0 0; + } + + .fallback, + .noResults { + padding: 32px 20px; + } +} diff --git a/src/apps/opportunities/src/components/ChallengeForum.spec.tsx b/src/apps/opportunities/src/components/ChallengeForum.spec.tsx new file mode 100644 index 000000000..c18fd171d --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeForum.spec.tsx @@ -0,0 +1,278 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, unicorn/no-null */ +import '@testing-library/jest-dom' +import { readFileSync } from 'fs' +import { + fireEvent, + render, + RenderResult, + screen, +} from '@testing-library/react' + +import { + ForumPost, + ForumTopicCollection, + ForumTopicDetail, + ForumTopicSummary, + MemberProfileSummary, +} from '../models' +import { + ChallengeForum, + flattenForumPosts, + formatForumDate, +} from './ChallengeForum' + +const mockUseSWR = jest.fn() +let listError: Error | undefined +let memberProfiles: MemberProfileSummary[] | undefined +let topicCollection: ForumTopicCollection | undefined +let topicDetail: ForumTopicDetail | undefined + +jest.mock('swr', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseSWR(...args), +})) + +jest.mock('~/libs/ui', () => { + const Icon = (): JSX.Element => + return { + IconOutline: new Proxy({}, { get: () => Icon }), + LoadingSpinner: (): JSX.Element => Loading forum, + } +}, { virtual: true }) + +jest.mock('../services', () => ({ + getChallengeForumTopics: jest.fn(), + getForumTopicDetail: jest.fn(), + getMemberProfilesByUserIds: jest.fn(), +})) + +jest.mock('../utils', () => ({ + challengeForumUrl: (): string => 'https://forum.example/challenge', + memberProfileUrl: (handle: string): string => `https://profiles.example/${handle}`, +})) + +jest.mock('./ChallengeMarkdown', () => ({ + ChallengeMarkdown: (props: { markdown: string }): JSX.Element =>
{props.markdown}
, +})) + +const announcement: ForumTopicSummary = { + authorHandle: 'DaraK', + authorMemberId: '1', + challengeId: 'challenge-id', + createdAt: '2026-06-06T00:05:00.000Z', + id: 'topic-1', + isAnnouncement: true, + latestActivity: { + authorHandle: 'Yoki', + authorMemberId: '2', + createdAt: '2026-06-07T10:15:00.000Z', + postId: 'post-2', + }, + locked: false, + lockedAt: null, + lockedBy: null, + parentTopicId: null, + postsCount: 2, + roleName: null, + title: 'Welcome to React Component Library Development Challenge', + unread: true, + updatedAt: '2026-06-07T10:15:00.000Z', +} + +const discussion: ForumTopicSummary = { + ...announcement, + authorHandle: 'PereViki', + authorMemberId: '3', + id: 'topic-2', + isAnnouncement: false, + latestActivity: null, + postsCount: 1, + title: 'TypeScript Interface Definitions - Need Clarification', + unread: false, +} + +const starterPost: ForumPost = { + authorHandle: 'DaraK', + authorMemberId: '1', + content: 'Welcome **competitors**.', + createdAt: '2026-06-06T00:05:00.000Z', + deleted: false, + id: 'post-1', + parentId: 'topic-1', + parentType: 'TOPIC', + replies: [{ + authorHandle: 'Yoki', + authorMemberId: '2', + content: 'Thanks for the clarification.', + createdAt: '2026-06-07T10:15:00.000Z', + deleted: false, + id: 'post-2', + parentId: 'post-1', + parentType: 'POST', + replies: [], + topicId: 'topic-1', + updatedAt: '2026-06-07T10:15:00.000Z', + }], + topicId: 'topic-1', + updatedAt: '2026-06-06T00:05:00.000Z', +} + +const forumStyles = readFileSync(`${__dirname}/ChallengeForum.module.scss`, 'utf8') + +describe('ChallengeForum', () => { + beforeEach(() => { + jest.clearAllMocks() + listError = undefined + memberProfiles = [{ + handle: 'DaraK', + photoURL: 'https://cdn.example/darak.png', + userId: '1', + }] + topicCollection = { + data: [announcement, discussion], + sourceTotalCount: 2, + truncated: false, + } + topicDetail = { posts: [starterPost], topic: announcement } + mockUseSWR.mockImplementation((key: unknown) => { + if (Array.isArray(key) && key[0] === 'opportunities:forum-topics') { + return { + data: topicCollection, + error: listError, + isValidating: false, + mutate: jest.fn(), + } + } + + if (Array.isArray(key) && key[0] === 'opportunities:forum-topic') { + return { + data: topicDetail, + error: undefined, + isValidating: false, + mutate: jest.fn(), + } + } + + if (Array.isArray(key) && key[0] === 'opportunities:forum-members') { + return { + data: memberProfiles, + error: undefined, + isValidating: false, + mutate: jest.fn(), + } + } + + return { + data: undefined, + error: undefined, + isValidating: false, + mutate: jest.fn(), + } + }) + }) + + it('renders Figma topic controls, truthful metrics, local search, and filters', () => { + render() + + expect(screen.getByRole('heading', { name: 'Challenge Forum' })) + .toBeInTheDocument() + expect(screen.getByRole('link', { name: /Create new topic/ })) + .toHaveAttribute('href', 'https://forum.example/challenge') + expect(screen.getByRole('button', { name: announcement.title })) + .toBeInTheDocument() + expect(screen.getByText('1 new topic')) + .toBeInTheDocument() + + fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'typescript' } }) + expect(screen.queryByRole('button', { name: announcement.title })) + .not.toBeInTheDocument() + expect(screen.getByRole('button', { name: discussion.title })) + .toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Reset all' })) + fireEvent.click(screen.getByRole('radio', { name: 'Announcements' })) + expect(screen.getByRole('button', { name: announcement.title })) + .toBeInTheDocument() + expect(screen.queryByRole('button', { name: discussion.title })) + .not.toBeInTheDocument() + }) + + it('opens a read-only embedded post tree and keeps replies external', () => { + render() + fireEvent.click(screen.getByRole('button', { name: announcement.title })) + + expect(screen.getByRole('button', { name: announcement.title })) + .toBeInTheDocument() + expect(screen.getByText('Welcome **competitors**.')) + .toBeInTheDocument() + expect(screen.getByText('Thanks for the clarification.')) + .toBeInTheDocument() + expect(screen.getAllByRole('link', { name: /Reply in forum/ })) + .toHaveLength(2) + }) + + it('uses public member photos with an initials fallback when an image fails', () => { + const { container }: RenderResult = render( + , + ) + const profileImage = container.querySelector( + 'img[src="https://cdn.example/darak.png"]', + ) + + expect(profileImage) + .toBeInTheDocument() + const avatar = profileImage?.parentElement + fireEvent.error(profileImage as HTMLImageElement) + expect(avatar) + .toHaveTextContent('D') + expect(mockUseSWR.mock.calls.some(call => ( + Array.isArray(call[0]) && call[0][0] === 'opportunities:forum-members' + ))) + .toBe(true) + }) + + it('preserves the external forum when authenticated reads fail or are unavailable', () => { + listError = new Error('unavailable') + render() + + expect(screen.getByRole('heading', { name: 'Forum temporarily unavailable' })) + .toBeInTheDocument() + expect(screen.getByRole('link', { name: /Open forum/ })) + .toHaveAttribute('href', 'https://forum.example/challenge') + }) + + it('does not attempt an authenticated embed for signed-out members', () => { + render() + + expect(screen.getByText(/Sign in to read challenge discussions here/)) + .toBeInTheDocument() + expect(mockUseSWR.mock.calls[0][0]) + .toBeUndefined() + }) + + it('matches the Figma desktop forum geometry and core tokens', () => { + expect(forumStyles) + .toContain('grid-template-columns: 281px minmax(0, 895px);') + expect(forumStyles) + .toContain('gap: 24px;') + expect(forumStyles) + .toContain('color: #161616;') + expect(forumStyles) + .toContain('background: #007d79;') + }) +}) + +describe('forum presentation helpers', () => { + it('flattens nested replies without losing their depth', () => { + expect(flattenForumPosts([starterPost]) + .map(item => [item.post.id, item.depth])) + .toEqual([['post-1', 0], ['post-2', 1]]) + }) + + it('handles absent and invalid forum dates safely', () => { + expect(formatForumDate()) + .toBe('—') + expect(formatForumDate('not-a-date')) + .toBe('—') + }) +}) diff --git a/src/apps/opportunities/src/components/ChallengeForum.tsx b/src/apps/opportunities/src/components/ChallengeForum.tsx new file mode 100644 index 000000000..9d982f056 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeForum.tsx @@ -0,0 +1,796 @@ +/* eslint-disable no-use-before-define, ordered-imports/ordered-imports, react/jsx-no-bind */ +import { + ChangeEvent, + FC, + useMemo, + useState, +} from 'react' +import useSWR, { SWRResponse } from 'swr' + +import { IconOutline, LoadingSpinner } from '~/libs/ui' + +import { + ChallengeOpportunity, + ForumPost, + ForumTopicCollection, + ForumTopicDetail, + ForumTopicSummary, + MemberProfileSummary, +} from '../models' +import { + getChallengeForumTopics, + getForumTopicDetail, + getMemberProfilesByUserIds, +} from '../services' +import { + challengeForumUrl, + memberProfileUrl, +} from '../utils' +import { ChallengeMarkdown } from './ChallengeMarkdown' +import { OpportunityPagination } from './OpportunityPagination' +import styles from './ChallengeForum.module.scss' + +type ForumScope = 'all' | 'announcements' | 'discussions' | 'unread' +type ForumSort = 'active' | 'oldest' | 'recent' + +interface ChallengeForumProps { + challenge: ChallengeOpportunity + memberId?: string +} + +interface FlatForumPost { + depth: number + post: ForumPost +} + +interface ForumParticipant { + handle: string + memberId: string +} + +type MemberProfilesById = ReadonlyMap + +/** + * Formats a Forums API timestamp in the authored day-month-year presentation. + * + * @param value optional ISO timestamp. + * @returns formatted local date and time, or an em dash. + * @throws Does not throw. + */ +export function formatForumDate(value?: string): string { + if (!value) return '—' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '—' + const month = new Intl.DateTimeFormat('en-US', { month: 'long' }) + .format(date) + const hour = String(date.getHours()) + .padStart(2, '0') + const minute = String(date.getMinutes()) + .padStart(2, '0') + return `${date.getDate()} ${month}, ${date.getFullYear()}, ${hour}:${minute}` +} + +/** + * Flattens a Forums API reply tree while retaining visual thread depth. + * + * @param posts nested visible posts. + * @param depth current recursive nesting depth. + * @returns depth-annotated posts in display order. + * @throws Does not throw. + */ +export function flattenForumPosts(posts: ForumPost[], depth: number = 0): FlatForumPost[] { + return posts.flatMap(post => [ + { depth, post }, + ...flattenForumPosts(post.replies ?? [], depth + 1), + ]) +} + +/** + * Resolves the last visible activity timestamp used by local sort choices. + * + * @param topic forum topic summary. + * @returns epoch milliseconds, or zero for invalid dates. + * @throws Does not throw. + */ +function activityTimestamp(topic: ForumTopicSummary): number { + const value = topic.latestActivity?.createdAt ?? topic.updatedAt ?? topic.createdAt + const timestamp = new Date(value) + .getTime() + return Number.isFinite(timestamp) ? timestamp : 0 +} + +/** + * Extracts the de-duplicated participant snapshots present in a topic summary. + * + * @param topic forum topic summary. + * @returns author and latest-activity participant snapshots. + * @throws Does not throw. + */ +function topicParticipants(topic: ForumTopicSummary): ForumParticipant[] { + return [ + { handle: topic.authorHandle, memberId: topic.authorMemberId }, + topic.latestActivity + ? { + handle: topic.latestActivity.authorHandle, + memberId: topic.latestActivity.authorMemberId, + } + : undefined, + ] + .filter((participant): participant is ForumParticipant => !!participant) + .filter((participant, index, participants) => ( + participants.findIndex(candidate => candidate.memberId === participant.memberId) === index + )) +} + +/** + * Renders a member photo with a resilient initials fallback. + * + * @param props member handle and optional public profile projection. + * @returns avatar image or initial. + * @throws Does not throw; image failures switch to the fallback. + */ +const MemberAvatar: FC<{ + handle: string + profile?: MemberProfileSummary +}> = props => { + const [failedPhotoURL, setFailedPhotoURL] = useState() + const photoURL = props.profile?.photoURL + const showPhoto = !!photoURL && failedPhotoURL !== photoURL + + return ( + + ) +} + +/** + * Renders a compact linked member snapshot enriched by the public Members API. + * + * @param props fallback handle and optional profile projection. + * @returns profile link with avatar and canonical handle. + * @throws Does not throw. + */ +const ForumMember: FC<{ + handle: string + profile?: MemberProfileSummary +}> = props => { + const handle = props.profile?.handle ?? props.handle + + return ( + + + {handle} + + ) +} + +/** + * Renders the limited participant identities provided by topic summaries. + * + * @param props participant snapshots and member projections keyed by ID. + * @returns accessible linked avatar group. + * @throws Does not throw. + */ +const ParticipantGroup: FC<{ + participants: ForumParticipant[] + profilesByMemberId: MemberProfilesById +}> = props => { + const labels = props.participants.map(participant => ( + props.profilesByMemberId.get(participant.memberId)?.handle ?? participant.handle + )) + + return ( + + {props.participants.map(participant => { + const profile = props.profilesByMemberId.get(participant.memberId) + const handle = profile?.handle ?? participant.handle + + return ( + + + + ) + })} + + ) +} + +interface ForumFallbackProps { + externalUrl?: string + text: string + title: string +} + +/** + * Preserves the external forum path when embedded reads are unavailable. + * + * @param props fallback copy and optional forum destination. + * @returns forum fallback state. + * @throws Does not throw. + */ +const ForumFallback: FC = props => ( +
+
+) + +/** + * Renders challenge forum counters and the external create action. + * + * @param props visible topics, source total, and optional forum destination. + * @returns authored forum overview rail. + * @throws Does not throw. + */ +const ForumOverview: FC<{ + externalUrl?: string + topics: ForumTopicSummary[] + total: number +}> = props => { + const unread = props.topics.filter(topic => topic.unread).length + const posts = props.topics.reduce((sum, topic) => sum + topic.postsCount, 0) + return ( +
+

Challenge Forum

+
+ + {unread} + {' '} + new + {' '} + {unread === 1 ? 'topic' : 'topics'} + + + {props.total} + {' '} + {props.total === 1 ? 'topic' : 'topics'} + + + {posts} + {' '} + posts + +
+ {props.externalUrl && ( + + + )} +
+ ) +} + +interface ForumFiltersProps { + onReset: () => void + onScope: (scope: ForumScope) => void + onSearch: (search: string) => void + onSort: (sort: ForumSort) => void + scope: ForumScope + search: string + sort: ForumSort + unread: number +} + +/** + * Renders local search, sort, and scope controls for loaded topic summaries. + * + * @param props controlled filter values and update callbacks. + * @returns accessible forum filter panel. + * @throws Does not throw. + */ +const ForumFilters: FC = props => { + const onSearch = (event: ChangeEvent): void => props.onSearch(event.target.value) + const onSort = (event: ChangeEvent): void => props.onSort(event.target.value as ForumSort) + return ( +
+
+

Filters

+ +
+ + Search topics or member handles + +
+ Topic type + {([ + ['all', 'All topics'], + ['unread', 'Unread'], + ['announcements', 'Announcements'], + ['discussions', 'Discussions'], + ] as Array<[ForumScope, string]>).map(([value, label]) => ( + + ))} +
+
+ ) +} + +/** + * Renders forum creator and activity context beneath the filters. + * + * @param props visible topics and member profiles keyed by ID. + * @returns discussion information panel, or an empty fragment. + * @throws Does not throw. + */ +const DiscussionInfo: FC<{ + profilesByMemberId: MemberProfilesById + topics: ForumTopicSummary[] +}> = props => { + if (!props.topics.length) return <> + const creator = props.topics.find(topic => topic.isAnnouncement) ?? props.topics[props.topics.length - 1] + const lastActivity = [...props.topics].sort((a, b) => activityTimestamp(b) - activityTimestamp(a))[0] + return ( +
+

+

+ +
+
+
Last post
+
{formatForumDate(lastActivity.latestActivity?.createdAt)}
+
+
+
Created
+
{formatForumDate(creator.createdAt)}
+
+
+
+ ) +} + +/** + * Renders one topic summary card that can open embedded detail. + * + * @param props topic, member projections, and selection callback. + * @returns authored topic card. + * @throws Does not throw. + */ +const ForumTopicCard: FC<{ + onSelect: (topicId: string) => void + profilesByMemberId: MemberProfilesById + topic: ForumTopicSummary +}> = props => { + const participants = topicParticipants(props.topic) + const latestActivityLabel = props.topic.latestActivity + ? `Last post by ${props.topic.latestActivity.authorHandle}` + + ` at ${formatForumDate(props.topic.latestActivity.createdAt)}` + : 'No visible posts' + return ( +
+
+
+ {props.topic.isAnnouncement && Announcement} + {props.topic.unread && ( + + {props.topic.postsCount <= 1 ? 'New topic' : 'New post'} + + )} + {props.topic.locked && Locked} +
+ +
+ Created by + + + at + {' '} + {formatForumDate(props.topic.createdAt)} + +
+

+ {latestActivityLabel} +

+ +
+ +
+ ) +} + +/** + * Renders read-only topic detail with nested post threading. + * + * @param props topic detail, member projections, navigation callback, and external URL. + * @returns topic information and visible post cards. + * @throws Does not throw. + */ +const ForumTopicView: FC<{ + detail: ForumTopicDetail + externalUrl?: string + onBack: () => void + profilesByMemberId: MemberProfilesById +}> = props => { + const flatPosts = flattenForumPosts(props.detail.posts) + const participants = [ + { + handle: props.detail.topic.authorHandle, + memberId: props.detail.topic.authorMemberId, + }, + ...flatPosts.map(item => ({ + handle: item.post.authorHandle, + memberId: item.post.authorMemberId, + })), + ].filter((participant, index, allParticipants) => ( + allParticipants.findIndex(candidate => candidate.memberId === participant.memberId) === index + )) + return ( +
+ +
+ +
+ {flatPosts.map((item, index) => ( +
0 ? styles.replyPost : styles.post} + key={item.post.id} + > +
+ + {item.post.authorMemberId === props.detail.topic.authorMemberId && Author} + {index === 0 && props.detail.topic.isAnnouncement && Announcement} + {item.post.id === props.detail.topic.latestActivity?.postId + && props.detail.topic.unread && New post} + +
+ {item.post.deleted || !item.post.content + ?

This post has been deleted.

+ : } + {props.externalUrl && !props.detail.topic.locked && ( + + )} +
+ ))} + {!flatPosts.length && ( + + )} +
+
+
+ ) +} + +/** + * Renders authenticated Forums API reads, with legacy forum links retained for + * unsupported writes and for deployments without migrated forum data. + * + * @param props challenge context and optional authenticated member ID. + * @returns embedded topic list/detail or an authenticated fallback state. + * @throws Does not throw; API failures render an external-forum fallback. + */ +export const ChallengeForum: FC = props => { + const externalUrl = challengeForumUrl(props.challenge) + const [search, setSearch] = useState('') + const [scope, setScope] = useState('all') + const [sort, setSort] = useState('recent') + const [page, setPage] = useState(1) + const [perPage, setPerPage] = useState(10) + const [selectedTopicId, setSelectedTopicId] = useState() + const response: SWRResponse = useSWR( + props.memberId ? ['opportunities:forum-topics', props.challenge.id] : undefined, + () => getChallengeForumTopics(props.challenge.id), + { revalidateOnFocus: false }, + ) + const detailResponse: SWRResponse = useSWR( + selectedTopicId ? ['opportunities:forum-topic', selectedTopicId] : undefined, + () => getForumTopicDetail(selectedTopicId as string), + { revalidateOnFocus: false }, + ) + const topics = useMemo(() => response.data?.data ?? [], [response.data?.data]) + const unread = topics.filter(topic => topic.unread).length + const filteredTopics = useMemo(() => { + const query = search.trim() + .toLowerCase() + const result = topics.filter(topic => { + if (scope === 'unread' && !topic.unread) return false + if (scope === 'announcements' && !topic.isAnnouncement) return false + if (scope === 'discussions' && topic.isAnnouncement) return false + if (!query) return true + return [ + topic.title, + topic.authorHandle, + topic.latestActivity?.authorHandle, + topic.roleName, + ].some(value => value?.toLowerCase() + .includes(query)) + }) + return [...result].sort((a, b) => { + if (sort === 'active') return b.postsCount - a.postsCount || activityTimestamp(b) - activityTimestamp(a) + if (sort === 'oldest') { + return new Date(a.createdAt) + .getTime() - new Date(b.createdAt) + .getTime() + } + + return activityTimestamp(b) - activityTimestamp(a) + }) + }, [scope, search, sort, topics]) + const totalPages = Math.ceil(filteredTopics.length / perPage) + const visibleTopics = filteredTopics.slice((page - 1) * perPage, page * perPage) + const detailPosts = useMemo( + () => flattenForumPosts(detailResponse.data?.posts ?? []), + [detailResponse.data?.posts], + ) + const discussionCreator = topics.find(topic => topic.isAnnouncement) ?? topics[topics.length - 1] + const memberIds = useMemo(() => Array.from(new Set([ + ...visibleTopics.flatMap(topic => [ + topic.authorMemberId, + topic.latestActivity?.authorMemberId, + ]), + discussionCreator?.authorMemberId, + detailResponse.data?.topic.authorMemberId, + ...detailPosts.map(item => item.post.authorMemberId), + ].filter((memberId): memberId is string => !!memberId))), [ + detailPosts, + detailResponse.data?.topic.authorMemberId, + discussionCreator?.authorMemberId, + visibleTopics, + ]) + const profileResponse: SWRResponse = useSWR( + props.memberId && memberIds.length + ? ['opportunities:forum-members', memberIds] + : undefined, + () => getMemberProfilesByUserIds(memberIds), + { revalidateOnFocus: false }, + ) + const profilesByMemberId = useMemo( + () => new Map((profileResponse.data ?? []).map(profile => [profile.userId, profile])), + [profileResponse.data], + ) + + if (!props.memberId) { + return ( + + ) + } + + if (response.isValidating && !response.data) return + if (response.error) { + return ( + + ) + } + + if (!topics.length) { + return ( + + ) + } + + if (selectedTopicId) { + if (detailResponse.isValidating && !detailResponse.data) return + if (detailResponse.error || !detailResponse.data) { + return ( +
+ + +
+ ) + } + + return ( + setSelectedTopicId(undefined)} + profilesByMemberId={profilesByMemberId} + /> + ) + } + + /** Resets every authored filter and returns to the first client-side page. */ + const resetFilters = (): void => { + setSearch('') + setScope('all') + setSort('recent') + setPage(1) + } + + return ( +
+ +
+ {response.data?.truncated && ( +

+ Showing the first + {' '} + {topics.length} + {' '} + of + {' '} + {response.data.sourceTotalCount} + {' '} + topics. Open the external forum to find older discussions. +

+ )} + {visibleTopics.map(topic => ( + + ))} + {!visibleTopics.length && ( +
+
+ )} + {filteredTopics.length > perPage && ( + { + setPerPage(value) + setPage(1) + }} + page={page} + perPage={perPage} + total={filteredTopics.length} + totalPages={totalPages} + /> + )} +
+
+ ) +} diff --git a/src/apps/opportunities/src/components/ChallengeMarkdown.module.scss b/src/apps/opportunities/src/components/ChallengeMarkdown.module.scss new file mode 100644 index 000000000..95e338bca --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeMarkdown.module.scss @@ -0,0 +1,100 @@ +.markdown { + color: #161616; + font-size: 16px; + line-height: 22px; + + h1, + h2, + h3, + h4 { + color: #161616; + font-family: 'Figtree', sans-serif; + font-weight: 700; + margin: 16px 0 8px; + scroll-margin-top: 96px; + text-transform: none; + } + + h1 { font-size: 32px; line-height: 40px; } + h2 { font-size: 20px; line-height: 32px; } + h3 { font-size: 18px; line-height: 30px; } + h4 { font-size: 16px; line-height: 22px; } + + p, + ul, + ol, + blockquote, + pre, + table { + margin: 0 0 24px; + } + + ul, + ol { + list-style: revert; + padding-left: 26px; + } + + a { + color: #007d79; + font-weight: 700; + } + + img { + height: auto; + max-width: 100%; + } + + table { + border-collapse: collapse; + display: block; + max-width: 100%; + overflow-x: auto; + } + + th, + td { + border: 1px solid #e0e0e0; + padding: 8px 10px; + text-align: left; + } + + blockquote { + border-left: 4px solid #007d79; + color: #525252; + padding-left: 15px; + } + + code { + background: #f5f7fa; + border-radius: 3px; + padding: 2px 4px; + } + + pre { + background: #001e2e; + border-radius: 4px; + color: #fff; + overflow-x: auto; + padding: 14px; + + code { + background: transparent; + padding: 0; + } + } +} + +.privateDetails { + border-top: 1px solid #e0e0e0; + margin-top: 28px; + padding-top: 4px; + + > h2 { + color: #161616; + font-family: 'Figtree', sans-serif; + font-size: 20px; + margin: 24px 0 12px; + text-transform: none; + } +} diff --git a/src/apps/opportunities/src/components/ChallengeMarkdown.spec.tsx b/src/apps/opportunities/src/components/ChallengeMarkdown.spec.tsx new file mode 100644 index 000000000..c96026e6d --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeMarkdown.spec.tsx @@ -0,0 +1,59 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { render, RenderResult, screen } from '@testing-library/react' + +import { + ChallengeDescription, + isHtmlDescriptionFormat, +} from './ChallengeMarkdown' + +jest.mock('react-markdown', () => function MarkdownMock(props: { children?: string }) { + return
{props.children}
+}) +jest.mock('remark-breaks', () => jest.fn()) +jest.mock('remark-gfm', () => jest.fn()) + +describe('ChallengeDescription', () => { + it('sanitizes case-insensitive HTML descriptions and omits a Markdown interpretation', () => { + const container = render( + , + ).container + + expect(isHtmlDescriptionFormat('HtMl')) + .toBe(true) + expect(screen.getByRole('heading', { name: 'Legacy requirements' })) + .toBeTruthy() + expect(container.querySelector('script')) + .toBeNull() + expect(container.querySelector('img') + ?.getAttribute('onerror')) + .toBeNull() + }) + + it('renders non-HTML formats as Markdown', () => { + render() + + expect(screen.getByTestId('markdown-renderer').textContent) + .toBe('## Markdown requirements') + }) + + it('shows API-authorized private details and omits an empty private section', () => { + const view: RenderResult = render( + , + ) + + expect(screen.getByRole('heading', { name: 'Registered User Additional Information' })) + .toBeTruthy() + expect(screen.getAllByTestId('markdown-renderer')[1].textContent) + .toBe('**Private workflow**') + + view.rerender() + expect(screen.queryByRole('heading', { name: 'Registered User Additional Information' })) + .toBeNull() + }) +}) diff --git a/src/apps/opportunities/src/components/ChallengeMarkdown.tsx b/src/apps/opportunities/src/components/ChallengeMarkdown.tsx new file mode 100644 index 000000000..ca309d4a0 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeMarkdown.tsx @@ -0,0 +1,202 @@ +/* eslint-disable ordered-imports/ordered-imports */ +import { + FC, + isValidElement, + ReactNode, + useEffect, + useMemo, +} from 'react' +import DOMPurify from 'dompurify' +import ReactMarkdown, { Components, Options as ReactMarkdownOptions } from 'react-markdown' +import type { HeadingProps } from 'react-markdown/lib/ast-to-react' +import remarkBreaks from 'remark-breaks' +import remarkGfm from 'remark-gfm' + +import styles from './ChallengeMarkdown.module.scss' + +const Markdown = ReactMarkdown as unknown as FC + +export interface ChallengeTocItem { + id: string + label: string + level: 2 | 3 +} + +interface ChallengeMarkdownProps { + markdown: string + onTableOfContents?: (items: ChallengeTocItem[]) => void +} + +interface ChallengeDescriptionProps { + content: string + format?: string + privateDescription?: string +} + +/** + * Detects the legacy Challenge API HTML description format. + * + * @param format Challenge API description format. + * @returns true only for a case-insensitive `html` value. + * @throws Does not throw. + */ +export function isHtmlDescriptionFormat(format?: string): boolean { + return format?.trim() + .toLowerCase() === 'html' +} + +/** + * Creates a stable fragment identifier from a Markdown heading. + * + * @param value heading text. + * @returns URL-safe lowercase fragment. + * @throws Does not throw. + */ +export function headingSlug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + || 'section' +} + +/** + * Extracts second- and third-level Markdown headings for the challenge table of contents. + * Source line numbers keep duplicate heading fragments stable and unique. + * + * @param markdown challenge specification Markdown. + * @returns ordered table-of-contents entries. + * @throws Does not throw. + */ +export function extractTableOfContents(markdown: string): ChallengeTocItem[] { + return markdown.split('\n') + .map((line, index) => ({ + line: index + 1, + match: /^(#{2,3})\s+(.+?)\s*#*$/.exec(line.trim()), + })) + .filter((entry): entry is { line: number, match: RegExpExecArray } => !!entry.match) + .map(entry => { + const label = entry.match[2] + .replace(/\[([^\]]+)]\([^)]*\)/g, '$1') + .replace(/[*_`~]/g, '') + .trim() + return { + id: `${headingSlug(label)}-${entry.line}`, + label, + level: entry.match[1].length as 2 | 3, + } + }) +} + +/** + * Converts nested Markdown children to plain text for heading fragment generation. + * + * @param children rendered heading children. + * @returns concatenated plain text. + * @throws Does not throw. + */ +export function markdownHeadingText(children: ReactNode): string { + if (typeof children === 'string' || typeof children === 'number') return String(children) + if (Array.isArray(children)) { + return children.map(markdownHeadingText) + .join('') + } + + if (isValidElement<{ children?: ReactNode }>(children)) { + return markdownHeadingText(children.props.children) + } + + return '' +} + +/** + * Renders a Markdown heading whose stable fragment matches the generated TOC. + * + * @param props heading level, source location, and rendered children from React Markdown. + * @returns an H2 or H3 with a stable source-line fragment identifier. + * @throws Does not throw. + */ +const MarkdownHeading: FC = props => { + const label = markdownHeadingText(props.children) + const line = props.node?.position?.start.line ?? 0 + const id = `${headingSlug(label)}-${line}` + return props.level === 3 + ?

{props.children}

+ :

{props.children}

+} + +const MARKDOWN_COMPONENTS: Components = { + h2: MarkdownHeading, + h3: MarkdownHeading, +} + +/** + * Renders a challenge Markdown specification with fragment-addressable headings. + * + * @param props specification Markdown and optional TOC observer. + * @returns safe Markdown presentation supporting GFM tables and hard breaks. + * @throws Does not throw. + */ +export const ChallengeMarkdown: FC = props => { + const tableOfContents = useMemo(() => extractTableOfContents(props.markdown), [props.markdown]) + const onTableOfContents = props.onTableOfContents + + useEffect(() => { + onTableOfContents?.(tableOfContents) + }, [onTableOfContents, tableOfContents]) + + return ( +
+ + {props.markdown} + +
+ ) +} + +/** + * Renders Challenge API description fields using their declared format. + * HTML is sanitized after receipt; every other format uses safe Markdown. + * When the API returns private details, they are appended without applying a + * second UI authorization check because Challenge API is authoritative for + * field visibility. + * + * @param props public content, optional private content, and description format. + * @returns safe formatted challenge requirements. + * @throws Does not throw. + */ +export const ChallengeDescription: FC = props => { + const htmlFormat = isHtmlDescriptionFormat(props.format) + const privateDescription = props.privateDescription?.trim() + /** + * Selects the safe renderer matching the challenge's declared format. + * + * @param content public or API-authorized private description content. + * @returns sanitized HTML or safe Markdown React content. + * @throws Does not throw. + */ + const renderContent = (content: string): ReactNode => (htmlFormat ? ( +
+ ) : ) + + return ( + <> + {renderContent(props.content)} + {privateDescription && ( +
+

Registered User Additional Information

+ {renderContent(privateDescription)} +
+ )} + + ) +} diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.module.scss b/src/apps/opportunities/src/components/ChallengeSidebar.module.scss new file mode 100644 index 000000000..15ed27a40 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeSidebar.module.scss @@ -0,0 +1,157 @@ +.sidebar { + display: flex; + flex-direction: column; + gap: 16px; + width: 384px; +} + +.card, +.promo { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-sizing: border-box; + color: #161616; + padding: 24px; + width: 384px; + + h3 { + align-items: center; + color: #161616; + display: flex; + font-family: 'Figtree', sans-serif; + font-size: 18px; + font-weight: 700; + gap: 8px; + line-height: 30px; + margin: 0; + text-transform: none; + + svg { + color: #161616; + flex: 0 0 20px; + height: 20px; + width: 20px; + } + } + + p, + li { + font-size: 14px; + line-height: 20px; + } + + p { + margin: 8px 0 0; + } + + ol, + ul { + margin: 8px 0 0; + padding-left: 21px; + } + + ol { + list-style: decimal; + } + + ul { + list-style: disc; + } + + a, + button { + align-items: center; + background: none; + border: 0; + color: #007d79; + cursor: pointer; + display: flex; + font: inherit; + font-size: 16px; + font-weight: 700; + gap: 4px; + line-height: 22px; + margin-top: 8px; + padding: 0; + text-align: left; + text-decoration: none; + + svg { + height: 20px; + width: 20px; + } + } +} + +.promo { + border: 0; + min-height: 181px; + overflow: hidden; + position: relative; + + > div { + position: relative; + z-index: 1; + } + + h3, + p, + a { + color: #fff; + } + + h3 { + font-size: 20px; + line-height: 32px; + } + + p { + max-width: 280px; + } +} + +.promoArt { + height: 100%; + inset: 0; + object-fit: cover; + position: absolute; + width: 100%; +} + +.challengeInfo { + display: flex; + flex-direction: column; + gap: 24px; +} + +.infoSection { + display: flex; + flex-direction: column; + gap: 8px; + + p, + ol, + ul, + a, + button { + margin-top: 0; + } +} + +.resourceLinks { + border-top: 1px solid #e0e0e0; + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; + padding-top: 8px; +} + +@media (max-width: 1120px) { + .sidebar, + .card, + .promo { + width: 100%; + } +} diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.tsx b/src/apps/opportunities/src/components/ChallengeSidebar.tsx new file mode 100644 index 000000000..2871772ff --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeSidebar.tsx @@ -0,0 +1,280 @@ +/* eslint-disable react/jsx-no-bind */ +import { FC, ReactNode } from 'react' +import { Link } from 'react-router-dom' + +import { IconOutline } from '~/libs/ui' + +import { ChallengeOpportunity, ChallengeTerm } from '../models' +import { + challengeFileTypes, + challengeForumUrl, + ChallengeSidebarLink, + challengeSidebarLinks, + challengeSubmissionLimit, +} from '../utils' +import programBanner from '../assets/ai-exponential-program.png' + +import styles from './ChallengeSidebar.module.scss' + +interface ChallengeSidebarProps { + challenge: ChallengeOpportunity + onContactTeam: () => void + onShowTerms: (term?: ChallengeTerm) => void +} + +interface SidebarCardProps { + children: ReactNode + icon: ReactNode + title: string +} + +/** + * Returns a challenge catalog label from current or legacy API shapes. + * + * @param value string or expanded catalog record. + * @returns normalized catalog label. + * @throws Does not throw. + */ +function catalogName(value: string | { name?: string } | undefined): string { + return typeof value === 'string' ? value : value?.name ?? '' +} + +/** + * Renders one reusable challenge right-rail card. + * + * @param props card icon, title, and body content. + * @returns a styled sidebar section. + * @throws Does not throw. + */ +const SidebarCard: FC = props => ( +
+

+ {props.icon} + {props.title} +

+ {props.children} +
+) + +interface ChallengeTermButtonProps { + index: number + onShowTerms: (term: ChallengeTerm) => void + term: ChallengeTerm +} + +/** + * Opens the exact authored term selected in the challenge information rail. + * + * @param props term, source position, and modal callback. + * @returns one term link-style button. + * @throws Does not throw. + */ +const ChallengeTermButton: FC = props => { + /** + * Opens the term represented by this sidebar row. + * + * @returns void after forwarding the authored term to the parent modal handler. + * @throws Does not throw. + */ + const showTerm = (): void => props.onShowTerms(props.term) + return ( + + ) +} + +/** + * Renders the challenge-details support column, including Review App, learning, + * submission guidance, challenge terms, source files, and team contact. + * + * @param props challenge context and modal actions. + * @returns Figma right rail cards. + * @throws Does not throw. + */ +export const ChallengeSidebar: FC = props => { + const fileTypes = challengeFileTypes(props.challenge) + const submissionLimit = challengeSubmissionLimit(props.challenge) + const links = challengeSidebarLinks(props.challenge) + const forumUrl = challengeForumUrl(props.challenge) + const designChallenge = catalogName(props.challenge.track) + .toLowerCase() === 'design' + const developmentChallenge = catalogName(props.challenge.track) + .toLowerCase() === 'development' + const submissionGuidance = designChallenge || developmentChallenge + /** + * Opens the all-terms fallback used when the challenge has no individual term rows. + * + * @returns void after requesting the parent modal without a selected term. + * @throws Does not throw. + */ + const showAllTerms = (): void => props.onShowTerms() + const challengeLinks = [...links.challengeLinks] + if (forumUrl && !challengeLinks.some(link => link.url === forumUrl)) { + challengeLinks.push({ label: 'Challenge Forum', url: forumUrl }) + } + + /** + * Renders one safe external right-rail link. + * + * @param link approved label and HTTP(S) URL. + * @returns external anchor with a visual indicator. + * @throws Does not throw. + */ + const externalLink = (link: ChallengeSidebarLink): ReactNode => ( + + {link.label} + + + ) + + return ( + + ) +} diff --git a/src/apps/opportunities/src/components/ChallengeTermsModal.module.scss b/src/apps/opportunities/src/components/ChallengeTermsModal.module.scss new file mode 100644 index 000000000..07b7e8b0f --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeTermsModal.module.scss @@ -0,0 +1,207 @@ +.compactModal, +.termsModal { + border-radius: 8px !important; + box-sizing: border-box; + box-shadow: 0 4px 12px rgba(0, 0, 0, .1) !important; + padding: 32px !important; + + > hr, + > div:last-child > hr { + display: none; + } + + > div:last-child { + margin-top: 24px; + } + + h3 { + color: #161616; + font-family: 'Figtree', sans-serif; + font-size: 24px; + line-height: 38px; + margin: 0; + text-transform: none; + } + + :global(.modal-body) { + margin: 24px 0 0; + overflow: visible !important; + padding: 0; + } +} + +.compactModal { + max-width: calc(100vw - 32px) !important; + min-width: 0 !important; + width: 600px !important; +} + +.termsModal { + max-width: calc(100vw - 32px) !important; + min-width: 0 !important; + width: 960px !important; +} + +.body { + color: #161616; + display: flex; + flex-direction: column; + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + gap: 16px; + line-height: 22px; + + p, + ul, + h3 { + margin: 0; + } + + ul { + list-style: disc; + padding-left: 21px; + + ul { + padding-left: 28px; + } + } + + a { + color: #007d79; + font-weight: 400; + } + + label { + align-items: center; + cursor: pointer; + display: flex; + gap: 8px; + + input { + accent-color: #007d79; + height: 16px; + margin: 0; + width: 16px; + } + } +} + +.compactModal .body { + font-size: 14px; + gap: 8px; + line-height: 20px; + + label { + font-size: 16px; + line-height: 22px; + min-height: 32px; + } +} + +.modalAction { + border-radius: 4px !important; + font-family: 'Nunito Sans', sans-serif !important; + font-size: 16px !important; + height: 38px; + line-height: 22px !important; + padding: 8px 24px !important; + text-transform: none !important; +} + +.loading, +.error { + align-items: center; + border-radius: 4px; + display: flex; + gap: 8px; + padding: 12px; +} + +.loading { + background: #f5f7fa; +} + +.error { + background: #fcf4d6; + color: #8e6a00; + justify-content: space-between; + + button { + background: none; + border: 0; + color: #007d79; + cursor: pointer; + font: inherit; + font-weight: 700; + } +} + +.terms { + border: 1px solid #e0e0e0; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 32px; + max-height: 532px; + overflow: auto; + padding: 24px; +} + +.term { + display: flex; + flex-direction: column; + gap: 16px; + + h3, + h1, + h2, + h4 { + color: #161616; + font-family: 'Figtree', sans-serif; + font-size: 20px; + line-height: 32px; + margin: 0 0 8px; + text-transform: none; + } + + p { + margin: 0 0 24px; + } + + small { + color: #137d60; + font-weight: 700; + } +} + +.externalButton { + align-self: flex-start; + background: #007d79; + border: 0; + border-radius: 4px; + color: #fff; + cursor: pointer; + font: inherit; + font-weight: 700; + padding: 8px 24px; + + &:disabled { + cursor: wait; + opacity: .6; + } +} + +@media (max-width: 700px) { + .compactModal, + .termsModal { + padding: 24px 16px !important; + + :global(.modal-body) { + margin-top: 24px; + } + } + + .terms { + padding: 16px; + } +} diff --git a/src/apps/opportunities/src/components/ChallengeTermsModal.tsx b/src/apps/opportunities/src/components/ChallengeTermsModal.tsx new file mode 100644 index 000000000..52e0987b1 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeTermsModal.tsx @@ -0,0 +1,262 @@ +/* eslint-disable react/jsx-no-bind */ +import { + FC, + useEffect, + useMemo, + useState, +} from 'react' +import DOMPurify from 'dompurify' +import useSWR, { SWRResponse } from 'swr' + +import { getSafeCmsLink } from '~/libs/cms' +import { BaseModal, Button, LoadingSpinner } from '~/libs/ui' + +import { ChallengeTerm } from '../models' +import { + getChallengeSubmitterTermsDetails, + getChallengeTermDocuSignUrl, + getChallengeTermsDetails, +} from '../services' + +import styles from './ChallengeTermsModal.module.scss' + +export type ChallengeTermsMode = 'register' | 'view' + +interface ChallengeTermsModalProps { + busy?: boolean + mode: ChallengeTermsMode + onAccept: (terms: ChallengeTerm[]) => void + onClose: () => void + open: boolean + terms: ChallengeTerm[] +} + +/** + * Checks whether an outstanding term explicitly requires a flow other than + * the Terms API's electronic `/agree` endpoint. + * + * @param term complete Terms API record. + * @returns true for an unaccepted non-electronic agreement. + * @throws Does not throw. + */ +export function requiresExternalAgreement(term: ChallengeTerm): boolean { + return !term.agreed + && (!!term.docusignTemplateId + || (!!term.agreeabilityType + && term.agreeabilityType.toLowerCase() !== 'electronically-agreeable')) +} + +/** + * Renders full Terms API content for either passive review or challenge + * registration. View mode never exposes an agreement or registration action; + * registration mode requires acknowledgement and blocks external agreements. + * + * @param props term references, modal mode, submit state, and callbacks. + * @returns Figma-aligned terms dialog with retryable detail loading. + * @throws Does not throw; Terms API failures render a retry action. + */ +export const ChallengeTermsModal: FC = props => { + const [accepted, setAccepted] = useState(false) + const [externalBusy, setExternalBusy] = useState(false) + const [externalError, setExternalError] = useState('') + const termKey = useMemo( + () => props.terms.map(term => term.id ?? term.url ?? term.title ?? 'term') + .join('|'), + [props.terms], + ) + const shouldLoad = props.open && props.terms.some(term => !!term.id) + /** + * Loads the terms appropriate to the active modal mode. + * + * Registration resolves only Submitter terms; passive viewing resolves the exact selected terms. + * + * @returns a promise containing hydrated Challenge API term details. + * @throws propagates Challenge API failures to SWR for the modal error state. + */ + const loadTerms = (): Promise => (props.mode === 'register' + ? getChallengeSubmitterTermsDetails(props.terms) + : getChallengeTermsDetails(props.terms)) + const response: SWRResponse = useSWR( + shouldLoad ? ['opportunities:challenge-terms', props.mode, termKey] : undefined, + loadTerms, + { revalidateOnFocus: false }, + ) + const terms = response.data ?? (shouldLoad ? [] : props.terms) + const externalAgreement = terms.some(requiresExternalAgreement) + const registrationMode = props.mode === 'register' + const compactRegistration = registrationMode && props.terms.length === 0 + const fullTitle = terms[0]?.title || props.terms[0]?.title || 'Challenge Terms' + + useEffect(() => { + if (props.open) { + setAccepted(false) + setExternalError('') + } + }, [props.open, props.mode]) + + /** Sanitizes trusted-format Terms API HTML before inserting it. */ + const sanitizedText = (text: string): string => String(DOMPurify.sanitize(text)) + + /** Submits the exact resolved term records displayed to the member. */ + const accept = (): void => props.onAccept(terms) + + /** Opens the Terms API's authenticated DocuSign recipient flow. */ + const startDocuSign = async (term: ChallengeTerm): Promise => { + if (!term.docusignTemplateId) return + setExternalBusy(true) + setExternalError('') + try { + const url = await getChallengeTermDocuSignUrl(term.docusignTemplateId, window.location.href) + window.location.assign(url) + } catch (error) { + setExternalError(error instanceof Error + ? error.message + : 'The external agreement could not be opened.') + setExternalBusy(false) + } + } + + const buttons = registrationMode ? ( + <> + + + )} + {!compactRegistration && !response.error && !response.isValidating && terms.length === 0 && ( +

No additional challenge-specific terms are listed.

+ )} + {!compactRegistration && terms.length > 0 && ( +
+ {terms.map((term: ChallengeTerm, index: number) => ( +
+ {(terms.length > 1 || index > 0) && ( +

{term.title || `Challenge term ${index + 1}`}

+ )} + {term.text && ( +
+ )} + {getSafeCmsLink(term.url) && ( + + Open this term in a new window + + )} + {registrationMode && requiresExternalAgreement(term) && term.docusignTemplateId && ( + + )} + {term.agreed && You have already accepted this term.} +
+ ))} +
+ )} + {!compactRegistration && registrationMode && externalAgreement && ( +
+ Complete each external agreement before registering. +
+ )} + {!compactRegistration && externalError && ( +
{externalError}
+ )} + {compactRegistration && ( + + )} + + + ) +} diff --git a/src/apps/opportunities/src/components/MarathonDashboard.module.scss b/src/apps/opportunities/src/components/MarathonDashboard.module.scss new file mode 100644 index 000000000..06bc171b2 --- /dev/null +++ b/src/apps/opportunities/src/components/MarathonDashboard.module.scss @@ -0,0 +1,76 @@ +.chart { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-sizing: border-box; + overflow: hidden; + padding: 24px; + + h3 { + font-family: 'Figtree', sans-serif; + font-size: 18px; + line-height: 30px; + margin: 0 0 16px; + text-transform: none; + } +} + +.loading, +.message { + align-items: center; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 360px; + text-align: center; +} + +.message { + h2 { + font-family: 'Figtree', sans-serif; + font-size: 18px; + line-height: 26px; + margin: 0; + text-transform: none; + } + + p { + font-size: 14px; + line-height: 20px; + margin: 4px 0 0; + } + + button { + background: #007d79; + border: 1px solid #007d79; + border-radius: 4px; + color: #fff; + cursor: pointer; + font: inherit; + font-weight: 700; + margin-top: 16px; + padding: 8px 24px; + } +} + +.screenReaderOnly { + border: 0; + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +@media (max-width: 620px) { + .chart { + padding: 8px; + } +} diff --git a/src/apps/opportunities/src/components/MarathonDashboard.spec.tsx b/src/apps/opportunities/src/components/MarathonDashboard.spec.tsx new file mode 100644 index 000000000..9f0c411bf --- /dev/null +++ b/src/apps/opportunities/src/components/MarathonDashboard.spec.tsx @@ -0,0 +1,67 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { + render, + screen, +} from '@testing-library/react' +import { SWRConfig } from 'swr' + +import { getChallengeReviewSummations } from '../services' + +import { MarathonDashboard } from './MarathonDashboard' + +jest.mock('../services', () => ({ + getChallengeReviewSummations: jest.fn(), +})) + +jest.mock('highcharts', () => ({})) +jest.mock('highcharts-react-official', () => ({ + __esModule: true, + default: (props: { options: unknown }): JSX.Element => ( +
+ ), +})) +jest.mock('~/libs/ui', () => ({ + LoadingSpinner: (): JSX.Element => Loading, +}), { virtual: true }) + +const mockedGetReviewSummations = getChallengeReviewSummations as jest.Mock + +describe('MarathonDashboard', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedGetReviewSummations.mockResolvedValue([{ + aggregateScore: 35.12, + createdAt: '2026-06-03T10:00:00.000Z', + id: 'summation', + isPassing: true, + isProvisional: true, + submissionId: 'submission', + submitterHandle: 'coder', + submitterId: 123, + submitterMaxRating: 2100, + }]) + }) + + it('renders the Figma Challenge Activity chart from Review API summations', async () => { + render( + new Map() }}> + + , + ) + + expect(await screen.findByRole('heading', { name: 'Challenge Activity' })) + .toBeInTheDocument() + const chart = screen.getByTestId('marathon-chart') + expect(chart) + .toHaveAttribute('data-options', expect.stringContaining('35.12')) + expect(chart) + .toHaveAttribute('data-options', expect.stringContaining('coder')) + expect(mockedGetReviewSummations) + .toHaveBeenCalledWith('challenge') + expect(screen.getByRole('table', { name: 'Marathon Match submission scores over time' })) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/opportunities/src/components/MarathonDashboard.tsx b/src/apps/opportunities/src/components/MarathonDashboard.tsx new file mode 100644 index 000000000..7c9ea3916 --- /dev/null +++ b/src/apps/opportunities/src/components/MarathonDashboard.tsx @@ -0,0 +1,204 @@ +/* eslint-disable react/no-this-in-sfc, react/jsx-no-bind */ +import { FC, useMemo } from 'react' +import Highcharts from 'highcharts' +import HighchartsReact from 'highcharts-react-official' +import useSWR, { SWRResponse } from 'swr' + +import { LoadingSpinner } from '~/libs/ui' + +import { + ChallengeOpportunity, + ChallengeReviewSummation, +} from '../models' +import { getChallengeReviewSummations } from '../services' +import { + buildMarathonDashboardData, + formatMarathonScore, + marathonRatingColor, +} from '../utils/marathon-match.utils' + +import styles from './MarathonDashboard.module.scss' + +interface MarathonDashboardProps { + challenge: ChallengeOpportunity +} + +/** + * Reads one finite numeric value from Challenge API metadata. + * + * @param challenge Marathon Match detail record. + * @param name case-insensitive metadata key. + * @returns finite metadata number, or undefined. + * @throws Does not throw. + */ +function numericMetadata(challenge: ChallengeOpportunity, name: string): number | undefined { + const normalizedName = name.replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase() + const value = challenge.metadata?.find(item => item.name.replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase() === normalizedName) + ?.value + if (value === null || value === undefined || value === '') return undefined + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : undefined +} + +/** + * Restores the Marathon Match score-over-time dashboard from community-app on + * the Opportunities challenge route. + * + * @param props Marathon Match challenge used to load summations and chart thresholds. + * @returns accessible chart, loading/error state, or empty-score state. + * @throws Does not throw; request failures render a retryable in-page state. + */ +export const MarathonDashboard: FC = props => { + const response: SWRResponse = useSWR( + ['opportunities:mm-review-summations', props.challenge.id], + () => getChallengeReviewSummations(props.challenge.id), + { revalidateOnFocus: false }, + ) + const dashboard = useMemo( + () => buildMarathonDashboardData(response.data ?? []), + [response.data], + ) + const points = dashboard.flatMap(member => member.submissions.map(submission => ({ + color: marathonRatingColor(member.rating), + custom: { + submissionCount: member.submissions.length, + submissionId: submission.submissionId, + submissionLabel: `${member.submissions.length} submission${member.submissions.length === 1 ? '' : 's'}`, + }, + name: member.handle, + x: Date.parse(submission.createdAt), + y: submission.score, + }))) + .filter(point => Number.isFinite(point.x) && Number.isFinite(point.y)) + const baseline = numericMetadata(props.challenge, 'baseline') + const awardLine = numericMetadata(props.challenge, 'awardLine') + const options = useMemo(() => ({ + accessibility: { enabled: false }, + chart: { + animation: false, + backgroundColor: '#ffffff', + height: 340, + type: 'scatter', + }, + credits: { enabled: false }, + exporting: { enabled: false }, + legend: { enabled: false }, + plotOptions: { + scatter: { + marker: { + radius: 5, + symbol: 'circle', + }, + }, + series: { + animation: false, + }, + }, + series: [{ + data: points, + name: 'Submissions', + type: 'scatter', + }], + title: { text: undefined }, + tooltip: { + backgroundColor: '#001e2e', + borderColor: '#001e2e', + borderRadius: 8, + headerFormat: '', + pointFormat: '{point.name}
' + + '{point.custom.submissionLabel}
' + + 'Score: {point.y:,.2f}
' + + 'Submitted {point.x:%e %b, %Y}
', + style: { + color: '#fff', + fontSize: '12px', + lineHeight: '16px', + }, + }, + xAxis: { + gridLineColor: '#e8e8e8', + gridLineWidth: 1, + labels: { + format: '{value:%m/%d}', + style: { color: '#525252', fontSize: '12px' }, + }, + lineColor: '#a8a8a8', + title: { text: undefined }, + type: 'datetime', + }, + yAxis: { + gridLineColor: '#e8e8e8', + labels: { style: { color: '#525252', fontSize: '12px' } }, + max: 100, + min: 0, + plotLines: [ + ...(awardLine !== undefined ? [{ + color: 'rgba(19, 125, 96, 0.7)', + label: { text: 'Award' }, + value: awardLine, + width: 2, + }] : []), + ...(baseline !== undefined ? [{ + color: 'rgba(82, 82, 82, 0.7)', + label: { text: 'Baseline' }, + value: baseline, + width: 2, + }] : []), + ], + title: { text: 'Score' }, + }, + }), [awardLine, baseline, points]) + + if (response.isValidating && !response.data) { + return
+ } + + if (response.error) { + return ( +
+

Dashboard unavailable

+

The Marathon Match scores could not be loaded.

+ +
+ ) + } + + if (!points.length) { + return ( +
+

No dashboard scores yet

+

Successful provisional or final scoring results will appear here.

+
+ ) + } + + return ( +
+
+

Challenge Activity

+ +
+ + + + + + + + + + + {dashboard.flatMap(member => member.submissions.map(submission => ( + + + + + + )))} + +
Marathon Match submission scores over time
HandleSubmission DateScore
{member.handle}{submission.createdAt}{formatMarathonScore(submission.score, '-')}
+
+ ) +} diff --git a/src/apps/opportunities/src/components/OpportunityFiltersPanel.module.scss b/src/apps/opportunities/src/components/OpportunityFiltersPanel.module.scss new file mode 100644 index 000000000..7c6b2f15e --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityFiltersPanel.module.scss @@ -0,0 +1,232 @@ +.panel { + align-self: start; + background: #fff; + border-radius: 8px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; + width: 100%; + + h3, + legend { + color: #161616; + font-family: 'Figtree', sans-serif; + font-weight: 700; + text-transform: none; + } + + h3 { + font-size: 18px; + line-height: 30px; + margin: 0; + } + + legend { + font-size: 14px; + line-height: 22px; + margin-bottom: 8px; + } + + small { + color: #6f6f6f; + font-size: 12px; + line-height: 16px; + } + + fieldset { + border: 0; + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0; + } +} + +.competitions { + min-height: 740px; +} + +.heading { + align-items: center; + display: flex; + justify-content: space-between; + + button { + background: none; + border: 0; + color: #007d79; + cursor: pointer; + font-family: inherit; + font-size: 16px; + font-weight: 700; + height: 24px; + line-height: 22px; + padding: 0; + } +} + +.searchGroup { + display: flex; + flex-direction: column; + gap: 4px; + + small { + color: #6f6f6f; + font-size: 12px; + line-height: 16px; + margin: 0; + } +} + +.search { + align-items: center; + border: 1px solid #e0e0e0; + border-radius: 4px; + display: flex; + gap: 4px; + height: 38px; + padding: 0 16px; + + svg { + flex: 0 0 20px; + height: 20px; + width: 20px; + } + + input { + border: 0; + color: #161616; + font: inherit; + font-size: 16px; + line-height: 22px; + min-width: 0; + outline: none; + padding: 7px 0; + width: 100%; + + &::placeholder { + color: #6f6f6f; + opacity: 1; + } + } + + &:focus-within { + border-color: #007d79; + box-shadow: 0 0 0 1px #007d79; + } +} + +.selectGroup { + display: flex; + flex-direction: column; + gap: 4px; + + > strong { + color: #161616; + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + line-height: 22px; + } +} + +.select { + align-items: center; + border: 1px solid #e0e0e0; + border-radius: 4px; + display: flex; + height: 38px; + position: relative; + + select { + appearance: none; + background: transparent; + border: 0; + color: #161616; + font: inherit; + font-size: 16px; + height: 100%; + line-height: 22px; + outline: 0; + padding: 0 44px 0 16px; + width: 100%; + } + + .placeholder { + color: #6f6f6f; + } + + svg { + height: 20px; + pointer-events: none; + position: absolute; + right: 16px; + width: 20px; + } + + &:focus-within { + border-color: #007d79; + box-shadow: 0 0 0 1px #007d79; + } +} + +.checkRow, +.radioRow { + align-items: center; + color: #161616; + cursor: pointer; + display: flex; + font-size: 16px; + gap: 8px; + height: 32px; + line-height: 22px; + + input { + appearance: none; + background: #fff; + border: 1px solid #a8a8a8; + border-radius: 4px; + flex: 0 0 16px; + height: 16px; + margin: 0; + width: 16px; + + &:checked[type='checkbox'] { + background: url('../assets/filter-checkbox-checked.svg') center / 16px 16px no-repeat; + border: 0; + } + + &[type='radio'] { + border-radius: 50%; + } + + &:checked[type='radio'] { + background: url('../assets/filter-radio-selected.svg') center / 16px 16px no-repeat; + border: 0; + } + + &:focus-visible { + outline: 2px solid #161616; + outline-offset: 2px; + } + } +} + +.visuallyHidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +@media (max-width: 760px) { + .panel { + min-height: 0; + position: static; + } +} diff --git a/src/apps/opportunities/src/components/OpportunityFiltersPanel.spec.tsx b/src/apps/opportunities/src/components/OpportunityFiltersPanel.spec.tsx new file mode 100644 index 000000000..ff9b7a904 --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityFiltersPanel.spec.tsx @@ -0,0 +1,136 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { fireEvent, render, screen } from '@testing-library/react' + +import { OpportunityFiltersPanel } from './OpportunityFiltersPanel' + +jest.mock('~/libs/ui', () => { + const Icon = (): JSX.Element => + return { + IconOutline: new Proxy({}, { + get: () => Icon, + }), + } +}, { virtual: true }) + +describe('OpportunityFiltersPanel', () => { + it('uses the single authored competition search control and helper', () => { + const onSearchChange = jest.fn() + + render( + , + ) + + const search = screen.getByRole('searchbox', { name: 'Search opportunities' }) + expect(screen.getAllByRole('searchbox')) + .toHaveLength(1) + expect(search) + .toHaveAttribute('aria-describedby', 'competitions-search-description') + expect(screen.getByText('Search skills, technologies, projects')) + .toHaveAttribute('id', 'competitions-search-description') + expect(screen.queryByText('Skills / technologies')) + .not.toBeInTheDocument() + expect(screen.queryByPlaceholderText('React, Figma, Python')) + .not.toBeInTheDocument() + expect(screen.getByText('AI')) + .toBeInTheDocument() + + fireEvent.change(search, { target: { value: 'React project' } }) + expect(onSearchChange) + .toHaveBeenCalledWith('React project') + }) + + it('uses the authored unified search and supported My engagements filter', () => { + const onAppliedChange = jest.fn() + const onRoleChange = jest.fn() + + render( + , + ) + + expect(screen.queryByText('Skills / technologies')) + .not.toBeInTheDocument() + expect(screen.getByText('Search skills, technologies, projects')) + .toBeInTheDocument() + expect(screen.getByText('Completed')) + .toBeInTheDocument() + const myEngagements = screen.getByRole('checkbox', { name: 'My engagements' }) + fireEvent.click(myEngagements) + expect(onAppliedChange) + .toHaveBeenCalledWith(true) + fireEvent.change(screen.getByRole('combobox', { name: 'Role' }), { + target: { value: 'SOFTWARE_DEVELOPER' }, + }) + expect(onRoleChange) + .toHaveBeenCalledWith('SOFTWARE_DEVELOPER') + expect(screen.getByText('Select e.g. “Software Engineer”')) + .toHaveAttribute('id', 'engagements-role-description') + }) + + it('matches the authored review track facets without an extra Type section', () => { + render( + , + ) + + expect(screen.getByText('My review opportunities')) + .toBeInTheDocument() + expect(screen.getByText('Status')) + .toBeInTheDocument() + expect(screen.queryByText('Type')) + .not.toBeInTheDocument() + expect(screen.queryByText('Challenge')) + .not.toBeInTheDocument() + expect(screen.queryByText('Task')) + .not.toBeInTheDocument() + expect(screen.getByText('AI')) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/opportunities/src/components/OpportunityFiltersPanel.tsx b/src/apps/opportunities/src/components/OpportunityFiltersPanel.tsx new file mode 100644 index 000000000..a6d807155 --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityFiltersPanel.tsx @@ -0,0 +1,232 @@ +/* eslint-disable ordered-imports/ordered-imports, react/jsx-no-bind */ +import { ChangeEvent, FC } from 'react' +import classNames from 'classnames' + +import { OpportunityKind } from '../models' + +import { ReactComponent as ChevronDownIcon } from '../assets/chevron-down.svg' +import { ReactComponent as SearchIcon } from '../assets/filter-search.svg' +import styles from './OpportunityFiltersPanel.module.scss' + +interface OpportunityFiltersPanelProps { + applied: boolean + isAuthenticated: boolean + kind: OpportunityKind + onAppliedChange: (checked: boolean) => void + onReset: () => void + onRoleChange: (role: string) => void + onSearchChange: (value: string) => void + onStatusChange: (status: string) => void + onTrackChange: (track: string, checked: boolean) => void + onTypeChange: (type: string, checked: boolean) => void + search: string + selectedRole: string + status: string + tracks: string[] + types: string[] +} + +interface FacetOption { + label: string + value: string +} + +const TRACKS: Record = { + competitions: [ + { label: 'Design', value: 'Des' }, + { label: 'Development', value: 'Dev' }, + { label: 'Data Science', value: 'DS' }, + { label: 'QA', value: 'QA' }, + { label: 'AI', value: 'AI' }, + ], + copilots: [ + { label: 'Design', value: 'design' }, + { label: 'Development', value: 'dev' }, + { label: 'Data Science', value: 'datascience' }, + { label: 'QA', value: 'qa' }, + { label: 'AI', value: 'ai' }, + ], + engagements: [], + reviews: [ + { label: 'Design', value: 'Design' }, + { label: 'Development', value: 'Development' }, + { label: 'Data Science', value: 'Data Science' }, + { label: 'QA', value: 'Quality Assurance' }, + { label: 'AI', value: 'AI' }, + ], +} + +const COMPETITION_TYPES: FacetOption[] = [ + { label: 'Challenge', value: 'CH' }, + { label: 'First2Finish', value: 'F2F' }, + { label: 'Marathon Match', value: 'MM' }, + { label: 'Task', value: 'TSK' }, +] + +const ENGAGEMENT_ROLES: FacetOption[] = [ + { label: 'Designer', value: 'DESIGNER' }, + { label: 'Software Developer', value: 'SOFTWARE_DEVELOPER' }, + { label: 'Data Scientist', value: 'DATA_SCIENTIST' }, + { label: 'Data Engineer', value: 'DATA_ENGINEER' }, +] + +const MY_LABELS: Record = { + competitions: 'My competitions', + copilots: 'My copilot opportunities', + engagements: 'My engagements', + reviews: 'My review opportunities', +} + +interface StatusOption { + label: string + value: string +} + +/** + * Returns the status values understood by the active owning API. + * + * @param kind opportunity domain. + * @returns labeled status filter options. + * @throws Does not throw. + */ +function statusOptions(kind: OpportunityKind): StatusOption[] { + if (kind === 'competitions') { + return [ + { label: 'Active competitions', value: 'ACTIVE' }, + { label: 'Open for registration', value: 'REGISTRATION' }, + { label: 'Past competitions', value: 'COMPLETED' }, + ] + } + + if (kind === 'copilots') { + return [ + { label: 'Open for application', value: 'active' }, + { label: 'Completed', value: 'completed' }, + ] + } + + return [ + { label: 'Open for application', value: 'OPEN' }, + { label: 'Completed', value: kind === 'reviews' || kind === 'engagements' ? 'CLOSED' : 'COMPLETED' }, + ] +} + +/** + * Renders the server-backed filter controls shown beside each Opportunities list. + * + * @param props current filters and callbacks that reset pagination before refetching. + * @returns accessible domain-specific search, ownership, status, track, type, and role controls. + * @throws Does not throw. + */ +export const OpportunityFiltersPanel: FC = props => { + const tracks = TRACKS[props.kind] + const statuses = statusOptions(props.kind) + const searchDescriptionId = `${props.kind}-search-description` + const roleDescriptionId = `${props.kind}-role-description` + + /** Updates the controlled search string. */ + const handleSearch = (event: ChangeEvent): void => props.onSearchChange(event.target.value) + + return ( + + ) +} diff --git a/src/apps/opportunities/src/components/OpportunityHero.module.scss b/src/apps/opportunities/src/components/OpportunityHero.module.scss new file mode 100644 index 000000000..981960189 --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityHero.module.scss @@ -0,0 +1,221 @@ +.hero { + background: #001e2e; + color: #fff; + height: 246px; + min-height: 246px; + overflow: visible; + position: relative; +} + +.inner { + margin: 0 auto; + max-width: 1248px; + padding: 0 24px; + position: relative; + z-index: 1; + + h1 { + color: #fff; + font-family: 'Figtree', sans-serif; + font-size: 32px; + font-weight: 700; + line-height: 40px; + margin: 0; + text-transform: none; + + span { + color: #3ddbd9; + } + } +} + +.map { + background: url('../assets/opportunity-map.svg') center / contain no-repeat; + height: 420px; + left: calc(50% + 360.5px); + pointer-events: none; + position: absolute; + top: -23px; + transform: translateX(-50%); + width: 753px; +} + +.breadcrumbs { + align-items: center; + color: rgba(255, 255, 255, .6); + display: flex; + font-size: 14px; + gap: 8px; + height: 66px; + line-height: 20px; + + > a { + color: rgba(255, 255, 255, .6); + text-decoration: none; + } + + > span:last-child { + color: #fff; + } +} + +.subtitle { + color: #fff; + font-size: 20px; + line-height: 28px; + margin: 8px 0 40px; +} + +.summaryError { + align-items: center; + background: rgba(255, 255, 255, .12); + border: 1px solid rgba(255, 255, 255, .35); + border-radius: 4px; + display: flex; + font-size: 12px; + gap: 12px; + left: 24px; + margin: 0; + max-width: max-content; + padding: 7px 10px; + position: absolute; + top: 146px; + + button { + background: none; + border: 0; + color: #72f2ec; + cursor: pointer; + font: inherit; + font-weight: 700; + padding: 0; + } +} + +.cells { + display: grid; + gap: 24px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.cell { + align-items: flex-start; + backdrop-filter: blur(2px); + background: + linear-gradient(rgba(255, 255, 255, .05), rgba(255, 255, 255, .05)), + #001e2e; + border: 1px dashed rgba(255, 255, 255, .4); + border-radius: 8px; + color: #fff; + display: flex; + gap: 8px; + height: 106px; + justify-content: center; + padding: 16px; + text-decoration: none; + transition: background .15s ease; + + > svg { + flex: 0 0 32px; + height: 32px; + width: 32px; + } + + &:hover { + background: + linear-gradient(rgba(255, 255, 255, .1), rgba(255, 255, 255, .1)), + #001e2e; + } +} + +.active { + background: #007d79; + border: 0; + + &:hover { + background: #007d79; + } +} + +.cellCopy { + color: #fff; + display: flex; + flex-direction: column; + flex: 1 0 0; + height: 74px; + min-width: 0; + + strong { + color: #fff; + font-family: 'Figtree', sans-serif; + font-size: 18px; + font-weight: 700; + line-height: 30px; + white-space: nowrap; + } + + > span:not(.metrics) { + color: #fff; + font-family: 'Nunito Sans', sans-serif; + font-size: 12px; + font-weight: 400; + line-height: 16px; + white-space: nowrap; + } +} + +.metrics { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + + small { + align-items: center; + background: rgba(255, 255, 255, .2); + border-radius: 2px; + color: #fff; + display: inline-flex; + font-family: 'Nunito Sans', sans-serif; + font-size: 11px; + font-weight: 600; + height: 20px; + line-height: 12px; + padding: 4px; + white-space: nowrap; + } +} + +@media (max-width: 1199px) { + .hero { + height: auto; + padding-bottom: 18px; + } + + .cells { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .map { + display: none; + } +} + +@media (max-width: 520px) { + .inner { + padding-inline: 16px; + } + + .cells { + grid-template-columns: 1fr; + } + + .cellCopy strong { + white-space: normal; + } + + .cellCopy > span:not(.metrics) { + white-space: normal; + } +} diff --git a/src/apps/opportunities/src/components/OpportunityHero.tsx b/src/apps/opportunities/src/components/OpportunityHero.tsx new file mode 100644 index 000000000..9b84f517a --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityHero.tsx @@ -0,0 +1,143 @@ +/* eslint-disable ordered-imports/ordered-imports */ +import { FC } from 'react' +import { NavLink } from 'react-router-dom' +import classNames from 'classnames' + +import { OpportunityKind, OpportunitySummary } from '../models' + +import { ReactComponent as CompetitionIcon } from '../assets/competition.svg' +import { ReactComponent as CopilotIcon } from '../assets/copilot.svg' +import { ReactComponent as EngagementIcon } from '../assets/engagement.svg' +import { ReactComponent as ReviewIcon } from '../assets/review.svg' +import styles from './OpportunityHero.module.scss' + +interface OpportunityHeroProps { + active: OpportunityKind + error?: boolean + loading?: boolean + onRetry?: () => void + summary?: OpportunitySummary +} + +interface CellConfig { + description: string + icon: FC + kind: OpportunityKind + label: string +} + +const cells: CellConfig[] = [ + { + description: 'Compete & win prizes', + icon: CompetitionIcon, + kind: 'competitions', + label: 'Competitions', + }, + { + description: 'Contract work with clients', + icon: EngagementIcon, + kind: 'engagements', + label: 'Engagements', + }, + { + description: 'Lead & coordinate competitions', + icon: CopilotIcon, + kind: 'copilots', + label: 'Copilot Opportunities', + }, + { + description: 'Evaluate submissions to earn', + icon: ReviewIcon, + kind: 'reviews', + label: 'Review Opportunities', + }, +] + +/** + * Formats compact currency used in the opportunity summary cells. + * + * @param value amount in US dollars. + * @returns compact currency text such as `$38.5k`. + * @throws Does not throw. + */ +function formatCompactCurrency(value?: number): string | undefined { + if (value === undefined) return undefined + return new Intl.NumberFormat('en-US', { + currency: 'USD', + maximumFractionDigits: 1, + notation: 'compact', + style: 'currency', + }) + .format(value) + .replace('K', 'k') +} + +/** + * Renders the dark Opportunities masthead and the four Figma navigation cells. + * + * @param props active cell and the single-call aggregation summary. + * @returns hero navigation for all opportunity types. + * @throws Does not throw. + */ +export const OpportunityHero: FC = props => ( +
+
+) diff --git a/src/apps/opportunities/src/components/OpportunityListCard.module.scss b/src/apps/opportunities/src/components/OpportunityListCard.module.scss new file mode 100644 index 000000000..1648fbde1 --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityListCard.module.scss @@ -0,0 +1,664 @@ +.card { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-sizing: border-box; + color: #161616; + display: grid; + grid-template-columns: minmax(0, 1fr) 223px; + min-height: 136px; + overflow: hidden; + text-decoration: none; + transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease; + + &:link, + &:visited { + color: #161616; + } + + &:hover { + border-color: #007d79; + box-shadow: 0 5px 18px rgba(0, 30, 46, .1); + transform: translateY(-1px); + } + + + &:focus-visible { + border-color: #007d79; + box-shadow: 0 0 0 3px rgba(0, 125, 121, .28); + outline: 0; + } +} + +.main { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + padding: 24px; + + h3 { + color: #161616; + font-family: 'Figtree', sans-serif; + font-size: 24px; + font-weight: 700; + line-height: 28px; + margin: 0; + text-transform: none; + } + + p { + color: #000; + display: -webkit-box; + font-size: 14px; + height: 40px; + line-height: 20px; + margin: 0; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } +} + +.eyebrow { + align-items: center; + color: #161616; + display: flex; + font-family: 'Nunito Sans', sans-serif; + font-size: 11px; + font-weight: 600; + gap: 4px; + height: 24px; + line-height: 12px; +} + +.badge { + background: #a7f0ba; + border-radius: 2px; + color: #044317; + line-height: 12px; + padding: 4px; +} + +.state { + align-items: center; + border: 1px solid #a8a8a8; + border-radius: 2px; + box-sizing: border-box; + color: #161616; + display: inline-flex; + gap: 2px; + height: 20px; + margin-left: auto; + padding: 3px 4px; + white-space: nowrap; + + svg { + height: 16px; + width: 16px; + } +} + +.stateApplied { + border-color: #198038; + color: #198038; +} + +.stateClosed { + border-color: #c1294f; + color: #c1294f; +} + +.skills { + display: flex; + flex-wrap: wrap; + gap: 8px; + + span { + background: #e9ecef; + border-radius: 2px; + color: #161616; + font-family: 'Nunito Sans', sans-serif; + font-size: 11px; + font-weight: 600; + line-height: 12px; + padding: 4px; + } +} + +.meta { + align-content: center; + border-left: 1px solid #e0e0e0; + color: #161616; + display: grid; + gap: 16px; + margin: 0; + padding: 24px 32px; + + div { + align-items: center; + display: grid; + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + gap: 4px; + grid-template-columns: 20px auto minmax(0, 1fr); + line-height: 20px; + } + + svg { + color: #007d79; + height: 20px; + width: 20px; + } + + dt { + color: #161616; + font-weight: 700; + } + + dd { + color: #161616; + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.engagementCard:not(.gridCard), +.copilotCard:not(.gridCard) { + min-height: 197px; + + .main { + padding-bottom: 29px; + } + + .skills { + padding-bottom: 8px; + } +} + +.engagementCard:not(.gridCard) .meta { + padding-inline: 16px; +} + +.competitionCard { + grid-template-columns: minmax(0, 1fr) 195px; + min-height: 212px; +} + +.competitionMain { + display: flex; + flex-direction: column; + gap: 15px; + min-width: 0; + padding: 24px; + + h3 { + color: #161616; + font-family: 'Figtree', sans-serif; + font-size: 24px; + font-weight: 700; + line-height: 28px; + margin: 0; + text-transform: none; + } +} + +.competitionCopy { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.competitionCard .eyebrow { + align-items: flex-start; + height: 24px; +} + +.competitionCard .skills { + gap: 8px; + + .primarySkill { + background: #fff; + border: 1px solid #a8a8a8; + padding: 3px; + } +} + +.trackBadge { + box-sizing: border-box; + height: 20px; +} + +.designBadge { + background: #d0e2ff; + color: #002d9c; +} + +.developmentBadge { + background: #a7f0ba; + color: #044317; +} + +.dataScienceBadge { + background: #ffd9be; + color: #5e2900; +} + +.artificialIntelligenceBadge { + background: #e8daff; + color: #491d8b; +} + +.qualityAssuranceBadge { + background: #ffd6e8; + color: #740937; +} + +.competitionBadge { + background: #e9ecef; + color: #161616; +} + +.challengeType, +.registrationState { + align-items: center; + border-radius: 2px; + box-sizing: border-box; + color: #161616; + display: inline-flex; + gap: 2px; + height: 20px; + padding: 4px; + white-space: nowrap; + + svg { + flex: 0 0 14px; + height: 14px; + width: 14px; + } +} + +.registrationState { + border: 1px solid #a8a8a8; + margin-left: auto; + + svg { + flex-basis: 16px; + height: 16px; + width: 16px; + } +} + +.registrationClosed { + border-color: #c1294f; + color: #c1294f; +} + +.registrationRegistered { + border-color: #198038; + color: #198038; +} + +.competitionFooter { + align-items: center; + border-top: 1px solid #e0e0e0; + display: flex; + gap: 24px; + justify-content: space-between; + margin-top: auto; + padding-top: 15px; +} + +.prizes { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 16px; + min-height: 28px; + min-width: 0; +} + +.prize { + align-items: center; + color: #161616; + display: inline-flex; + gap: 4px; + white-space: nowrap; + + strong { + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + font-weight: 700; + line-height: 22px; + } +} + +.medalIcon { + align-items: center; + display: inline-flex; + flex: 0 0 16px; + height: 16px; + justify-content: center; + overflow: hidden; + width: 16px; + + svg { + display: block; + flex: none; + height: 90%; + width: 70%; + } +} + +.morePrizes { + align-items: center; + background: #f5f7fa; + border-radius: 50%; + color: #161616; + display: inline-flex; + font-size: 11px; + font-weight: 600; + height: 28px; + justify-content: center; + width: 28px; +} + +.prizeUnavailable { + color: #6f6f6f; + font-size: 12px; + line-height: 16px; +} + +.phase { + display: flex; + flex: 0 1 255px; + flex-direction: column; + gap: 15px; + max-width: 255px; + min-width: 220px; +} + +.phaseHeading { + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; +} + +.phaseLabel { + align-items: center; + background: #f5f7fa; + border-radius: 4px; + color: #161616; + display: inline-flex; + font-size: 14px; + gap: 4px; + line-height: 20px; + min-width: 0; + padding: 2px 4px; + white-space: nowrap; + + svg { + flex: 0 0 20px; + height: 20px; + width: 20px; + } +} + +.timeLeft { + color: #161616; + font-size: 11px; + font-weight: 600; + line-height: 12px; + white-space: nowrap; +} + +.progress { + background: #e0e0e0; + border-radius: 999px; + height: 6px; + overflow: hidden; + width: 100%; + + span { + background: #007d79; + border-radius: inherit; + display: block; + height: 100%; + transition: width .2s ease; + } +} + +.competitionMeta { + align-content: center; + gap: 16px; + padding: 32px; + + div { + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + gap: 4px; + grid-template-columns: 20px auto 1fr; + line-height: 20px; + } + + svg { + height: 20px; + width: 20px; + } +} + +.gridCard { + grid-template-columns: minmax(0, 1fr); + height: 100%; + + .main { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 8px; + padding: 24px; + + h3 { + color: #161616; + display: -webkit-box; + font-family: 'Figtree', sans-serif; + font-size: 24px; + font-weight: 700; + line-height: 28px; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + p { + color: #000; + display: -webkit-box; + font-size: 14px; + height: auto; + line-height: 20px; + margin: 0; + max-height: 60px; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + } + } + + .eyebrow { + align-items: flex-start; + height: 24px; + } + + .skills { + gap: 8px; + } + + .meta { + border-left: 0; + border-top: 1px solid #e0e0e0; + box-sizing: border-box; + gap: 16px; + grid-template-columns: 1fr; + padding: 24px; + + div { + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + gap: 4px; + grid-template-columns: 20px auto minmax(0, 1fr); + line-height: 20px; + } + + svg { + height: 20px; + width: 20px; + } + } +} + +.gridCard:not(.competitionCard) { + align-content: start; + border: 0; + box-shadow: inset 0 0 0 1px #e0e0e0; + + &:hover { + box-shadow: + inset 0 0 0 1px #007d79, + 0 5px 18px rgba(0, 30, 46, .1); + } + + .meta { + border-top: 0; + box-shadow: inset 0 1px #e0e0e0; + } +} + +.competitionCard.gridCard { + .competitionMain { + gap: 0; + min-height: 0; + } + + h3 { + display: -webkit-box; + overflow: hidden; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .competitionFooter { + align-items: stretch; + flex-direction: column; + } + + .phase { + flex-basis: auto; + max-width: none; + min-width: 0; + width: 100%; + } + + .competitionMeta { + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding: 24px; + } +} + +.engagementCard.gridCard .meta { + min-height: 176px; +} + +.copilotCard.gridCard .meta, +.reviewCard.gridCard .meta { + min-height: 140px; +} + +.engagementCard.gridCard .skills, +.copilotCard.gridCard .skills { + padding-bottom: 8px; +} + +.engagementCard.gridCard .opportunityType { + display: none; +} + +@media (max-width: 900px) { + .competitionFooter { + align-items: stretch; + flex-direction: column; + } + + .phase { + flex-basis: auto; + max-width: none; + min-width: 0; + } +} + +@media (max-width: 800px) { + .card, + .competitionCard { + grid-template-columns: 1fr; + } + + .meta, + .competitionMeta { + border-left: 0; + border-top: 1px solid #e0e0e0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .competitionMeta { + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding: 24px; + } +} + +@media (max-width: 520px) { + .competitionMain, + .main { + padding: 16px; + } + + .gridCard .main, + .gridCard .meta { + padding: 16px; + } + + .eyebrow { + align-items: flex-start; + flex-wrap: wrap; + } + + .competitionCard .eyebrow { + height: auto; + } + + .gridCard .eyebrow { + height: auto; + } + + .registrationState { + margin-left: 0; + width: 100%; + } + + .competitionMeta, + .meta { + grid-template-columns: 1fr; + padding: 16px; + } + + .phaseHeading { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/apps/opportunities/src/components/OpportunityListCard.spec.tsx b/src/apps/opportunities/src/components/OpportunityListCard.spec.tsx new file mode 100644 index 000000000..eb510e94f --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityListCard.spec.tsx @@ -0,0 +1,408 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { + render, + RenderResult, + screen, + within, +} from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +import { + ChallengeOpportunity, + CopilotOpportunity, + EngagementOpportunity, + ReviewOpportunity, +} from '../models' + +import { OpportunityListCard } from './OpportunityListCard' + +jest.mock('~/libs/ui', () => { + const Icon = (): JSX.Element => + return { + IconOutline: new Proxy({}, { + get: () => Icon, + }), + } +}, { virtual: true }) + +/** + * Creates a complete competition-card fixture with an open registration and submission phase. + * + * @param overrides Challenge API fields replaced for the current test. + * @returns Challenge API item suitable for rendering a competition card. + * @throws Does not throw. + */ +function competitionFixture(overrides: Partial = {}): ChallengeOpportunity { + const currentPhase = { + actualStartDate: '2026-08-14T00:00:00.000Z', + isOpen: true, + name: 'Submission', + scheduledEndDate: '2026-08-14T01:00:00.000Z', + } + return { + currentPhase, + currentPhaseNames: ['Registration', 'Submission'], + description: 'This description is intentionally absent from the Figma competition card.', + id: 'challenge-id', + name: 'Topcoder Opportunities Challenge', + numOfRegistrants: 53, + numOfSubmissions: 15, + phases: [ + { + isOpen: true, + name: 'Registration', + scheduledEndDate: '2026-08-14T01:00:00.000Z', + scheduledStartDate: '2026-08-13T00:00:00.000Z', + }, + currentPhase, + ], + prizeSets: [{ + prizes: [{ value: 1000 }, { value: 500 }, { value: 150 }, { value: 50 }], + type: 'PLACEMENT', + }], + skills: [{ name: 'Figma' }, { name: 'User Experience Design' }], + status: 'ACTIVE', + track: { name: 'Design', track: 'DESIGN' }, + type: { name: 'First2Finish' }, + ...overrides, + } +} + +describe('OpportunityListCard competition presentation', () => { + beforeEach(() => { + jest.spyOn(Date, 'now') + .mockReturnValue(Date.parse('2026-08-14T00:30:00.000Z')) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('renders placement prizes, accurate registration state, and current phase progress', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: /Topcoder Opportunities Challenge/ })) + .toHaveAttribute('href', '/opportunities/challenge/challenge-id') + expect(screen.getByText('First 2 Finish') + .querySelector('svg')) + .toBeInTheDocument() + expect(screen.getByText('Open for registration') + .querySelector('svg')) + .toBeInTheDocument() + + const prizes = screen.getByLabelText('Placement prizes') + expect(within(prizes) + .getByText('$1000')) + .toBeInTheDocument() + expect(within(prizes) + .getByText('$500')) + .toBeInTheDocument() + expect(within(prizes) + .getByText('$150')) + .toBeInTheDocument() + expect(within(prizes) + .getByText('+1')) + .toBeInTheDocument() + const firstMedal = within(prizes) + .getByText('$1000') + .previousElementSibling + expect(firstMedal) + .toHaveClass('medalIcon') + expect(firstMedal?.querySelector('svg')) + .toBeInTheDocument() + expect(screen.queryByText('Prize:')) + .not.toBeInTheDocument() + + expect(screen.getByText('Submission')) + .toBeInTheDocument() + expect(screen.getByText('30m left')) + .toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'Submission phase progress' })) + .toHaveAttribute('aria-valuenow', '50') + expect(screen.getByText('Submissions:')) + .toBeInTheDocument() + expect(screen.getByText('Registrants:')) + .toBeInTheDocument() + expect(screen.getByText('Posts:')) + .toBeInTheDocument() + expect(screen.getByText('—')) + .toBeInTheDocument() + expect(screen.getByText('Figma').className) + .toContain('primarySkill') + expect(screen.queryByText(/intentionally absent/)) + .not.toBeInTheDocument() + }) + + it('uses the compact Figma card structure in grid view', () => { + render( + + + , + ) + + const card = screen.getByRole('link', { name: /Topcoder Opportunities Challenge/ }) + expect(card.className) + .toContain('gridCard') + expect(screen.getAllByText('+1')) + .toHaveLength(2) + expect(screen.getByText('Submissions:')) + .toBeInTheDocument() + }) + + it('shows Registered for the server-filtered My competitions result', () => { + render( + + + , + ) + + const state = screen.getByText('Registered') + expect(state.querySelector('svg')) + .toBeInTheDocument() + expect(state.className) + .toContain('registrationRegistered') + expect(screen.queryByText('Open for registration')) + .not.toBeInTheDocument() + }) + + it('omits invented schedule progress when no phase is open', () => { + render( + + + , + ) + + expect(screen.queryByRole('progressbar')) + .not.toBeInTheDocument() + expect(screen.queryByText('Schedule')) + .not.toBeInTheDocument() + }) + + it('preserves decimal currency and does not label point prizes as dollars', () => { + render( + + + , + ) + + const prizes = screen.getByLabelText('Placement prizes') + expect(within(prizes) + .getByText('$15.5')) + .toBeInTheDocument() + expect(within(prizes) + .getByText('10 pts')) + .toBeInTheDocument() + }) + + it('shows the Figma closed-registration treatment when registration is no longer open', () => { + render( + + + , + ) + + const state = screen.getByText('Registration closed') + expect(state.querySelector('svg')) + .toBeInTheDocument() + expect(state.className) + .toContain('registrationClosed') + }) + + it.each([ + ['Design', 'DESIGN', 'Challenge', 'Design', 'designBadge'], + ['Development', 'DEVELOPMENT', 'First2Finish', 'Development', 'developmentBadge'], + ['Data Science', 'DATA_SCIENCE', 'Marathon Match', 'Data Science', 'dataScienceBadge'], + ['Quality Assurance', 'QUALITY_ASSURANCE', 'Task', 'QA', 'qualityAssuranceBadge'], + ['AI', 'AI', 'Challenge', 'AI', 'artificialIntelligenceBadge'], + ])( + 'uses the Figma track palette and subtype icon for %s', + (trackName, trackKey, typeName, expectedLabel, expectedClass) => { + render( + + + , + ) + + expect(screen.getByText(expectedLabel).className) + .toContain(expectedClass) + expect(screen.getByText(typeName === 'First2Finish' ? 'First 2 Finish' : typeName) + .querySelector('svg')) + .toBeInTheDocument() + }, + ) +}) + +describe('OpportunityListCard owner-specific grid presentation', () => { + it('moves Engagement metrics below a three-line content card', () => { + const item: EngagementOpportunity = { + anticipatedStart: 'IMMEDIATE', + compensationRange: 'Negotiable', + description: 'Build a member-facing experience with the client team.', + durationWeeks: 44, + id: 'engagement-id', + role: 'SOFTWARE_DEVELOPER', + skills: [{ name: 'React' }, { name: 'Node.js' }], + status: 'OPEN', + title: 'Senior Front-end Developer', + } + render( + + + , + ) + + expect(screen.getByRole('link').className) + .toEqual(expect.stringContaining('gridCard')) + expect(screen.getByText('Role:')) + .toBeInTheDocument() + expect(screen.getByText('Development')) + .toBeInTheDocument() + expect(screen.getByText('Payment:')) + .toBeInTheDocument() + expect(screen.getByText('Open for application') + .querySelector('svg')) + .toBeInTheDocument() + }) + + it('renders Copilot hours, duration, and start metrics in grid view', () => { + const item: CopilotOpportunity = { + id: 'copilot-id', + numHoursPerWeek: 5, + numWeeks: 2, + opportunityTitle: 'Design Copilot', + overview: 'Lead and coordinate the challenge delivery.', + projectType: 'Design', + skills: [{ name: 'Figma' }], + status: 'active', + type: 'Challenge', + } + render( + + + , + ) + + expect(screen.getByRole('link').className) + .toEqual(expect.stringContaining('copilotCard')) + expect(screen.getByText('Hours / week:')) + .toBeInTheDocument() + expect(screen.getByText('Challenge') + .querySelector('svg')) + .toBeInTheDocument() + expect(screen.getByText('Duration:')) + .toBeInTheDocument() + expect(screen.getByText('Start:')) + .toBeInTheDocument() + }) + + it('normalizes closed and applied owner statuses to the authored pills', () => { + const closed: EngagementOpportunity = { + id: 'closed-engagement', + status: 'CLOSED', + title: 'Closed engagement', + } + const applied: CopilotOpportunity = { + hasApplied: true, + id: 'applied-copilot', + opportunityTitle: 'Applied copilot opportunity', + status: 'active', + } + const { rerender }: RenderResult = render( + + + , + ) + + expect(screen.getByText('Application closed').className) + .toContain('stateClosed') + rerender( + + + , + ) + expect(screen.getByText('Applied').className) + .toContain('stateApplied') + }) + + it('renders Review role, start, and application metrics without a description', () => { + const item: ReviewOpportunity = { + applicationCount: 3, + canApply: true, + challengeId: 'challenge-id', + challengeName: 'Review this challenge', + id: 'review-id', + payments: [{ payment: 100, role: 'Reviewer', roleId: 1 }], + startDate: '2026-06-22T00:00:00.000Z', + status: 'OPEN', + } + render( + + + , + ) + + expect(screen.getByRole('link').className) + .toEqual(expect.stringContaining('reviewCard')) + expect(screen.getByText('Role:')) + .toBeInTheDocument() + expect(screen.getByText('Applications:')) + .toBeInTheDocument() + expect(screen.getByText('3')) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/opportunities/src/components/OpportunityListCard.tsx b/src/apps/opportunities/src/components/OpportunityListCard.tsx new file mode 100644 index 000000000..fbaf784aa --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityListCard.tsx @@ -0,0 +1,690 @@ +/* eslint-disable ordered-imports/ordered-imports */ +import { FC, ReactNode, SVGProps } from 'react' +import { Link } from 'react-router-dom' +import classNames from 'classnames' +import { IconOutline } from '~/libs/ui' + +import { + ChallengeOpportunity, + CopilotOpportunity, + EngagementOpportunity, + OpportunityItem, + OpportunityKind, + OpportunitySkill, + OpportunityView, + ReviewOpportunity, +} from '../models' + +import { ReactComponent as ChallengeTypeIcon } from '../assets/challenge-type.svg' +import { ReactComponent as First2FinishTypeIcon } from '../assets/first2finish-type.svg' +import { ReactComponent as MarathonTypeIcon } from '../assets/marathon-type.svg' +import { ReactComponent as MedalFirstIcon } from '../assets/medal-1.svg' +import { ReactComponent as MedalSecondIcon } from '../assets/medal-2.svg' +import { ReactComponent as MedalThirdIcon } from '../assets/medal-3.svg' +import { ReactComponent as CalendarMetricIcon } from '../assets/metric-calendar.svg' +import { ReactComponent as HoursMetricIcon } from '../assets/metric-hours.svg' +import { ReactComponent as PaymentMetricIcon } from '../assets/metric-payment.svg' +import { ReactComponent as PostsMetricIcon } from '../assets/metric-posts.svg' +import { ReactComponent as RegistrantsMetricIcon } from '../assets/metric-registrants.svg' +import { ReactComponent as RoleMetricIcon } from '../assets/metric-role.svg' +import { ReactComponent as StartMetricIcon } from '../assets/metric-start.svg' +import { ReactComponent as SubmissionsMetricIcon } from '../assets/metric-submissions.svg' +import { ReactComponent as PhaseRegistrationIcon } from '../assets/phase-registration.svg' +import { ReactComponent as PhaseSubmissionIcon } from '../assets/phase-submission.svg' +import { ReactComponent as RegistrationClosedIcon } from '../assets/registration-closed.svg' +import { ReactComponent as RegistrationOpenIcon } from '../assets/registration-open.svg' +import { ReactComponent as TaskTypeIcon } from '../assets/task-type.svg' +import { + ChallengePlacementPrize, + challengeCatalogKey, + challengeCatalogName, + challengeCurrentPhase, + challengePhaseTiming, + challengePlacementPrizes, + challengeRegistrationIsOpen, + formatChallengeTimeLeft, +} from './challenge-card.utils' +import styles from './OpportunityListCard.module.scss' + +interface OpportunityListCardProps { + item: OpportunityItem + kind: OpportunityKind + registered?: boolean + view?: OpportunityView +} + +interface CompetitionListCardProps { + item: ChallengeOpportunity + registered?: boolean + view?: OpportunityView +} + +interface CardViewModel { + badge: string + description?: string + href: string + meta: Array<{ icon: ReactNode; label: string; value: string }> + skills: string[] + state?: string + title: string + type?: string +} + +interface ChallengeTypePresentation { + icon: FC> + label: string +} + +const challengeTypePresentations: Record = { + challenge: { icon: ChallengeTypeIcon, label: 'Challenge' }, + first2finish: { icon: First2FinishTypeIcon, label: 'First 2 Finish' }, + marathonmatch: { icon: MarathonTypeIcon, label: 'Marathon Match' }, + task: { icon: TaskTypeIcon, label: 'Task' }, +} + +const medalIcons: Array>> = [MedalFirstIcon, MedalSecondIcon, MedalThirdIcon] + +/** + * Formats a date for compact card metadata. + * + * @param value ISO date from an owning API. + * @returns localized date, or `TBD` when absent/invalid. + * @throws Does not throw. + */ +function formatDate(value?: string): string { + if (!value) return 'TBD' + const date = new Date(value) + return Number.isNaN(date.getTime()) + ? 'TBD' + : new Intl.DateTimeFormat('en-US', { day: 'numeric', month: 'short', year: 'numeric' }) + .format(date) +} + +/** + * Formats the Engagement API's anticipated-start enum, while retaining an ISO + * date fallback for older responses. + * + * @param value anticipated-start enum or legacy ISO timestamp. + * @returns member-friendly start timeframe. + * @throws Does not throw. + */ +export function formatAnticipatedStart(value?: string): string { + const labels: Record = { + FEW_DAYS: 'In a few days', + FEW_WEEKS: 'In a few weeks', + IMMEDIATE: 'Immediate', + } + return value && labels[value] ? labels[value] : formatDate(value) +} + +/** + * Formats the canonical top-level engagement duration fields, with legacy + * nested-duration and explicit date-range fallbacks. + * + * @param item Engagement API response. + * @returns duration label such as `8 weeks`, `2 months`, or `TBD`. + * @throws Does not throw. + */ +export function formatEngagementDuration(item: EngagementOpportunity): string { + const weeks = item.durationWeeks ?? item.duration?.lengthInWeeks + if (weeks) return `${weeks} ${weeks === 1 ? 'week' : 'weeks'}` + const months = item.durationMonths ?? item.duration?.lengthInMonths + if (months) return `${months} ${months === 1 ? 'month' : 'months'}` + + const startValue = item.durationStartDate ?? item.duration?.startDate + const endValue = item.durationEndDate ?? item.duration?.endDate + const start = startValue ? new Date(startValue) : undefined + const end = endValue ? new Date(endValue) : undefined + if (start && end && !Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())) { + const days = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000)) + return `${days} ${days === 1 ? 'day' : 'days'}` + } + + return 'TBD' +} + +/** + * Resolves member-facing Engagement skill labels from the hydrated API field. + * + * @param item Engagement API response. + * @returns hydrated names, or legacy required-skill IDs when names are absent. + * @throws Does not throw. + */ +export function engagementSkillNames(item: EngagementOpportunity): string[] { + /** + * Trims, removes empty labels, and deduplicates names for stable card keys. + * + * @param values raw API skill labels or IDs. + * @returns normalized unique labels in source order. + * @throws Does not throw. + */ + const normalizedNames = (values: string[]): string[] => Array.from(new Set(values + .map(value => value.trim()) + .filter(Boolean))) + const names = normalizedNames((item.skills ?? []).map(skill => skill.name)) + return names.length ? names : normalizedNames(item.requiredSkills ?? []) +} + +/** + * Converts an API enum token into title-cased words for card metadata. + * + * @param value underscore-delimited enum value. + * @returns title-cased label, or undefined when the value is absent. + * @throws Does not throw. + */ +function enumLabel(value?: string): string | undefined { + return value?.toLowerCase() + .split('_') + .map(part => `${part.charAt(0) + .toUpperCase()}${part.slice(1)}`) + .join(' ') +} + +/** + * Normalizes owner-specific API statuses to the three authored card states. + * + * @param applied whether the current member already applied. + * @param open whether the opportunity is currently accepting applications. + * @returns Applied, Open for application, or Application closed. + * @throws Does not throw. + */ +function applicationState(applied: boolean, open: boolean): string { + if (applied) return 'Applied' + return open ? 'Open for application' : 'Application closed' +} + +/** + * Maps owning-API discipline enums to the authored Opportunities track labels. + * + * @param value API role or track token. + * @returns Figma-facing track label, retaining unknown values in title case. + * @throws Does not throw. + */ +function opportunityTrackLabel(value?: string): string { + const key = challengeCatalogKey(value) + const labels: Record = { + ai: 'AI', + dataengineer: 'Data Science', + datascience: 'Data Science', + datascientist: 'Data Science', + design: 'Design', + designer: 'Design', + dev: 'Development', + development: 'Development', + qa: 'QA', + softwaredeveloper: 'Development', + } + return labels[key] ?? enumLabel(value) ?? 'Opportunity' +} + +/** + * Formats a Challenge API prize without mislabeling point or non-USD values. + * + * @param prize typed placement prize. + * @returns compact currency, points, or typed-value text. + * @throws Does not throw. + */ +function formatPrize(prize: ChallengePlacementPrize): string { + const type = prize.type?.trim() + .toUpperCase() + const number = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, + useGrouping: false, + }) + .format(prize.value) + if (type === 'POINT' || type === 'POINTS') return `${number} pts` + + const currency = type || 'USD' + if (/^[A-Z]{3}$/.test(currency)) { + try { + return new Intl.NumberFormat('en-US', { + currency, + maximumFractionDigits: 2, + minimumFractionDigits: 0, + style: 'currency', + useGrouping: false, + }) + .format(prize.value) + } catch { + // Fall through to the explicit typed-value treatment below. + } + } + + return type ? `${number} ${type}` : number +} + +/** + * Produces a plain excerpt from Markdown or HTML description content. + * + * @param value rich text from the API. + * @returns short plain-text excerpt. + * @throws Does not throw. + */ +function descriptionExcerpt(value?: string): string | undefined { + if (!value) return undefined + const plain = value + .replace(/<[^>]*>/g, ' ') + .replace(/[#*_>`~()]/g, '') + .replace(/\[/g, '') + .replace(/]/g, '') + .replace(/\s+/g, ' ') + .trim() + return plain.length > 180 ? `${plain.slice(0, 177)}…` : plain +} + +/** + * Resolves the Figma track-pill label for current and legacy Challenge API values. + * + * @param item Challenge API list item. + * @returns abbreviated QA label or the owning catalog display name. + * @throws Does not throw. + */ +function challengeTrackLabel(item: ChallengeOpportunity): string { + const trackKey = challengeCatalogKey(item.track) + if (trackKey === 'qualityassurance') return 'QA' + if (trackKey === 'ai' || trackKey === 'artificialintelligence') return 'AI' + return challengeCatalogName(item.track, 'Competition') +} + +/** + * Selects the Figma subtype label and icon for a Challenge API type. + * + * @param item Challenge API list item. + * @returns mapped Challenge, First 2 Finish, Marathon Match, or Task presentation. + * @throws Does not throw; unknown types use the Challenge icon and API label. + */ +function challengeTypePresentation(item: ChallengeOpportunity): ChallengeTypePresentation { + const typeKey = challengeCatalogKey(item.type) + return challengeTypePresentations[typeKey] ?? { + icon: ChallengeTypeIcon, + label: challengeCatalogName(item.type, 'Challenge'), + } +} + +/** + * Deduplicates Challenge API skill and tag labels while retaining source order. + * + * @param item Challenge API list item. + * @returns non-empty card labels in stable source order. + * @throws Does not throw. + */ +function challengeSkillLabels(item: ChallengeOpportunity): string[] { + return Array.from(new Set([ + ...(item.skills ?? []).map(skill => skill.name), + ...(item.tags ?? []), + ].map(label => label.trim()) + .filter(Boolean))) +} + +/** + * Returns the scoped CSS class for a Challenge API track pill. + * + * @param trackKey normalized catalog track key. + * @returns matching Figma track color class or the neutral fallback class. + * @throws Does not throw. + */ +function challengeTrackClass(trackKey: string): string { + const trackClasses: Record = { + ai: styles.artificialIntelligenceBadge, + artificialintelligence: styles.artificialIntelligenceBadge, + datascience: styles.dataScienceBadge, + design: styles.designBadge, + development: styles.developmentBadge, + qualityassurance: styles.qualityAssuranceBadge, + } + return trackClasses[trackKey] ?? styles.competitionBadge +} + +/** + * Renders the visible placement prizes from the Challenge API PLACEMENT set. + * + * @param prizes placement prizes with stable source-order positions. + * @returns Figma medal/value row with an overflow count when required. + * @throws Does not throw. + */ +function renderChallengePrizes(prizes: ChallengePlacementPrize[]): ReactNode { + if (!prizes.length) return Prize details coming soon + + const visiblePrizes = prizes.slice(0, medalIcons.length) + const remaining = prizes.length - visiblePrizes.length + return ( + <> + {visiblePrizes.map(prize => { + const MedalIcon = medalIcons[prize.placement - 1] ?? MedalThirdIcon + return ( + + + {formatPrize(prize)} + + ) + })} + {remaining > 0 && {`+${remaining}`}} + + ) +} + +/** Converts engagement data to the shared card presentation model. */ +function engagementView(item: EngagementOpportunity): CardViewModel { + const role = enumLabel(item.role) || 'Contributor' + return { + badge: opportunityTrackLabel(item.role), + description: descriptionExcerpt(item.description), + href: `/engagements/${item.nanoId ?? item.id}`, + meta: [ + { icon: , label: 'Role', value: role }, + { icon: , label: 'Duration', value: formatEngagementDuration(item) }, + { + icon: , + label: 'Start', + value: formatAnticipatedStart(item.anticipatedStart), + }, + { + icon: , + label: 'Payment', + value: item.compensationRange || 'Negotiable', + }, + ], + skills: engagementSkillNames(item), + state: applicationState(false, challengeCatalogKey(item.status) === 'open'), + title: item.title, + } +} + +/** Converts copilot data to the shared card presentation model. */ +function copilotView(item: CopilotOpportunity): CardViewModel { + return { + badge: opportunityTrackLabel(item.projectType || item.type || 'Copilot'), + description: descriptionExcerpt(item.overview), + href: `/copilots/opportunity/${item.id}`, + meta: [ + { + icon: , + label: 'Hours / week', + value: String(item.numHoursPerWeek ?? 'TBD'), + }, + { + icon: , + label: 'Duration', + value: item.numWeeks ? `${item.numWeeks} weeks` : 'TBD', + }, + { icon: , label: 'Start', value: formatDate(item.startDate) }, + ], + skills: (item.skills ?? []).map((skill: OpportunitySkill) => skill.name), + state: applicationState(!!item.hasApplied, challengeCatalogKey(item.status) === 'active'), + title: item.opportunityTitle || item.projectName || item.project?.name || 'Copilot Opportunity', + type: enumLabel(item.type) || 'Copilot', + } +} + +/** + * Resolves the public application total for a review opportunity. + * + * @param item Review API opportunity response. + * @returns public total, falling back to the visible rows for older API deployments. + * @throws Does not throw. + */ +export function reviewApplicationTotal(item: ReviewOpportunity): number { + return item.applicationCount ?? item.applications?.length ?? 0 +} + +/** Converts review data to the shared card presentation model. */ +function reviewView(item: ReviewOpportunity): CardViewModel { + const track = String(item.challengeData?.track ?? item.challengeData?.trackName ?? 'Review') + const technologies = item.challengeData?.technologies + return { + badge: track, + href: `/opportunities/review/${item.id}`, + meta: [ + { icon: , label: 'Role', value: item.payments?.[0]?.role || 'Reviewer' }, + { icon: , label: 'Start', value: formatDate(item.startDate) }, + { + icon: , + label: 'Applications', + value: String(reviewApplicationTotal(item)), + }, + ], + skills: Array.isArray(technologies) ? technologies.map(String) : [], + state: applicationState( + !!item.myApplications?.length, + item.canApply === true || challengeCatalogKey(item.status) === 'open', + ), + title: item.challengeName || String(item.challengeData?.name ?? 'Review Opportunity'), + type: String(item.challengeData?.type ?? item.type ?? ''), + } +} + +/** + * Selects the owning API adapter for a list item. + * + * @param kind opportunity domain selected in the hero. + * @param item raw owning API response. + * @returns shared Figma card presentation data. + * @throws Does not throw when called with matching kind/item data. + */ +function toViewModel(kind: OpportunityKind, item: OpportunityItem): CardViewModel { + if (kind === 'engagements') return engagementView(item as EngagementOpportunity) + if (kind === 'copilots') return copilotView(item as CopilotOpportunity) + return reviewView(item as ReviewOpportunity) +} + +/** + * Renders the Figma competition card using Challenge API placement and phase data. + * + * @param item Challenge API list item. + * @returns linked competition card with catalog tags, placement prizes, phase progress, and metrics. + * @throws Does not throw; absent API fields use explicit pending placeholders. + */ +const CompetitionListCard: FC = props => { + const item = props.item + const type = challengeTypePresentation(item) + const TypeIcon = type.icon + const trackKey = challengeCatalogKey(item.track) + const skillLabels = challengeSkillLabels(item) + const visibleSkills = skillLabels.slice(0, props.view === 'grid' ? 3 : 5) + const remainingSkills = skillLabels.length - visibleSkills.length + const placementPrizes = challengePlacementPrizes(item) + const phase = challengeCurrentPhase(item) + const phaseKey = challengeCatalogKey(phase?.name) + const PhaseIcon = phaseKey === 'registration' || phaseKey === 'open' + ? PhaseRegistrationIcon + : PhaseSubmissionIcon + const phaseLabel = phaseKey === 'open' ? 'Registration & Submission' : phase?.name || 'Schedule' + const phaseTiming = challengePhaseTiming(phase) + const timeLeft = formatChallengeTimeLeft(phaseTiming) || 'TBD' + const progress = Math.round(phaseTiming.progressPercent) + const registrationOpen = challengeRegistrationIsOpen(item) + const metrics = [ + { + icon: