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/pages/copilot-opportunity-details/index.tsx b/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx index 809ad6ca4..2ee18a8ca 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx @@ -202,9 +202,6 @@ const CopilotOpportunityDetails: FC<{}> = () => { Copilot Opportunity - {isValidating && !showNotFound && ( - - ) }

{opportunity?.opportunityTitle ?? opportunity?.projectName} 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/pages/engagement-detail/EngagementDetailPage.tsx b/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx index 80380baa6..c3048cdee 100644 --- a/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx +++ b/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx @@ -385,7 +385,7 @@ const EngagementDetailPage: FC = () => { if (isCheckingTerms) { return (
- + Checking terms and NDA...
) @@ -394,7 +394,7 @@ const EngagementDetailPage: FC = () => { if (isFinalizingAgreement) { return (
- + Finalizing your agreement...
) @@ -433,7 +433,7 @@ const EngagementDetailPage: FC = () => { if (checkingApplication) { return (
- + Checking your application status...
) @@ -510,7 +510,7 @@ const EngagementDetailPage: FC = () => { const renderLoadingState = (): JSX.Element => (
- +
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..9c412c626 --- /dev/null +++ b/src/apps/opportunities/README.md @@ -0,0 +1,227 @@ +# 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 August 2026 masthead has two destinations. Browse Opportunities renders +the four dark category cards and the active owner-backed listing. My Work +renders the four light member-summary cards and combines the authenticated +member's competitions, engagements, copilot work, and review work into one +newest-first list. Anonymous visitors receive an in-page sign-in handoff; no +member-scoped request is issued until a profile ID is available. + +My Work requests at most the first 100 member records from each owning API in +parallel, then applies its shared opportunity-type and track facets, global +sorting, and pagination in the client. Owner-specific lifecycle values are +normalized to All, Active, and Past. Competition cards read Registered; +approved, accepted, or selected non-competition applications read Accepted; +the remaining member applications read Applied. Summary counts retain the +owner-reported totals even when an owner has more than 100 records. Once the +authenticated profile is available, four count-only owner requests load those +totals independently of the selected Browse/My Work destination. The masthead +therefore never uses a fabricated member-work fallback; it shows an em dash +until the complete count is available and keeps the same total while filters +or destinations change. + +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`. + +Long card titles expose their complete value in the authored dark tooltip. +When a card has more skills than fit in its visible skill row, its `+n` control +exposes the hidden skill names in the corresponding bullet-list tooltip. The +Engagement role filter uses the authored four-row keyboard-accessible listbox +while preserving the Engagement API's Designer, Software Developer, Data +Scientist, and Data Engineer enum values. + +## 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 detail timeline + +The expanded challenge timeline follows the Challenge API phase order between +the synthetic Launch and Winners boundaries. Launch uses the challenge start, +each authored phase displays its actual (then scheduled fallback) start and end +on separate rows, and Winners uses the top-level challenge end. Only responses +without a valid challenge end fall back to the latest valid phase end. + +Open phase flags, `currentPhase`, and `currentPhaseNames` can mark overlapping +phases current. Ended phases and boundaries render complete, future milestones +remain upcoming, and all timestamps use the browser's local time with its IANA +timezone displayed below the rail. Phase names select the corresponding Figma +glyph; unfamiliar phase names deliberately use the generic Review glyph. + +## 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 challenges with `submissionsViewable=true` use the private-submission +gallery from the Figma flow. Authenticated members receive the protected +submission metadata needed for locked cards, while the public-safe +`GET /v6/submissions/previews?challengeId=...` response overlays only previews +that Review API has released. Anonymous visitors receive only that public page. +Review API remains authoritative for group, whitelist, screening, and +review-phase release checks; absent previews render as locked placeholders. +Design challenges without the flag retain the authored submission-list state. + +Opportunity detail tabs keep their page header and tab navigation mounted while +the selected panel changes. Lazy challenge panels use a panel-scoped loading +state, so loading Registrants, Submissions, Dashboard, Forum, or a forum topic +never replaces or masks the surrounding detail page. Full-page loading states +are reserved for the initial detail-route request. Review and copilot detail +tabs likewise switch in place, and engagement detail background checks render +inside their owning section. + +The standard Submissions tab follows community-app's authenticated-member +gate; registration is required only for My Submissions and authored actions. +Review API submissions and Marathon Match review summations own provisional +and final scores. Final Marathon Match values remain hidden while a submission +phase is open, then appear after Review closes or Review API publishes a final +result. Non-Marathon final scores appear only for completed challenges. The +Figma keeps separate Provisional Score and Final Score columns and uses `-` +when a final value is not yet available. Winners use Review API's canonical +`GET /v6/projectResult` member-and-placement result instead of inferring a +score from Challenge API winners or a sibling submission; protected winner +scores are requested only for authenticated members. + +Registered members submit without leaving challenge details. The My +Submissions flow accepts one `.zip` archive up to 500MB, requires the authored +declaration, reports live multipart progress, and posts the file directly to +`POST /v6/submissions`. The active phase selects `CONTEST_SUBMISSION`, +`CHECKPOINT_SUBMISSION`, or `STUDIO_FINAL_FIX_SUBMISSION`; Review API remains +authoritative for registration, phase, winner, submission-limit, and file +validation. Design shows the four expected inner deliverables, while +Development, Marathon Match, and Quality Assurance direct members to their +Requirements content. Successful uploads expose the created submission ID and +refresh challenge counts without leaving the confirmation state. + +Challenge Discussion reads and writes use the authenticated +`/v6/forums` API. Topic creation, comments and nested replies, owner edits and +soft deletes, per-member thumbs-up/thumbs-down reactions, watch state, and read +state remain inside the challenge detail page. Each visible post shows shared +reaction counts and the current member's selected state; clicking the selected +thumb again removes it, while clicking the other thumb switches it. Topic +summaries expose bounded starter excerpts, participant snapshots, unique +authenticated view counts, and current-member watch state. The +environment-specific Vanilla URL is retained only as a recovery link when the +v6 API is unavailable or the member is signed out. Unregistered administrators +receive the registered read and monitoring tabs, including Submissions, the +metadata-enabled Marathon Dashboard, and Forum, while My Submissions and upload +actions remain registration-only. Administrators may create ordinary topics or +official announcements and can reply throughout every challenge forum. + +The Report an Issue dialog preserves the Figma subject, category, +1000-character description, and required attachment fields. Files upload +through the shared Filestack support-ticket pipeline with a 2MB-per-file UI +limit. Because support-api-v6 accepts only `challengeId` and Markdown +`description`, the client serializes the subject, category, body, and uploaded +links into that description without inventing unsupported request fields. + +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/timeline-ai-screening.svg b/src/apps/opportunities/src/assets/timeline-ai-screening.svg new file mode 100644 index 000000000..b6408cd3c --- /dev/null +++ b/src/apps/opportunities/src/assets/timeline-ai-screening.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/apps/opportunities/src/assets/timeline-appeals-response.svg b/src/apps/opportunities/src/assets/timeline-appeals-response.svg new file mode 100644 index 000000000..a5fd68f90 --- /dev/null +++ b/src/apps/opportunities/src/assets/timeline-appeals-response.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/timeline-appeals.svg b/src/apps/opportunities/src/assets/timeline-appeals.svg new file mode 100644 index 000000000..cc761a064 --- /dev/null +++ b/src/apps/opportunities/src/assets/timeline-appeals.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/timeline-launch.svg b/src/apps/opportunities/src/assets/timeline-launch.svg new file mode 100644 index 000000000..d90009114 --- /dev/null +++ b/src/apps/opportunities/src/assets/timeline-launch.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/timeline-registration.svg b/src/apps/opportunities/src/assets/timeline-registration.svg new file mode 100644 index 000000000..5e235ae14 --- /dev/null +++ b/src/apps/opportunities/src/assets/timeline-registration.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/timeline-review.svg b/src/apps/opportunities/src/assets/timeline-review.svg new file mode 100644 index 000000000..20943fd64 --- /dev/null +++ b/src/apps/opportunities/src/assets/timeline-review.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/timeline-screening.svg b/src/apps/opportunities/src/assets/timeline-screening.svg new file mode 100644 index 000000000..cd6d41652 --- /dev/null +++ b/src/apps/opportunities/src/assets/timeline-screening.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/timeline-submission.svg b/src/apps/opportunities/src/assets/timeline-submission.svg new file mode 100644 index 000000000..49f0f8c07 --- /dev/null +++ b/src/apps/opportunities/src/assets/timeline-submission.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/opportunities/src/assets/timeline-winners.svg b/src/apps/opportunities/src/assets/timeline-winners.svg new file mode 100644 index 000000000..ae63cc0cd --- /dev/null +++ b/src/apps/opportunities/src/assets/timeline-winners.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/assets/winner-thanks.svg b/src/apps/opportunities/src/assets/winner-thanks.svg new file mode 100644 index 000000000..996e87a28 --- /dev/null +++ b/src/apps/opportunities/src/assets/winner-thanks.svg @@ -0,0 +1,3 @@ + + + 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..ef80d9235 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeDetailHeader.module.scss @@ -0,0 +1,595 @@ +.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; + flex-direction: column; + gap: 16px; + margin: 24px auto 0; + max-width: 1200px; + padding-top: 24px; + position: relative; + z-index: 1; +} + +.timelineGraphic { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.timelineRail { + align-items: center; + display: flex; + gap: 4px; + width: 100%; +} + +.timelineNode { + align-items: center; + backdrop-filter: blur(2px); + background: rgba(255, 255, 255, .1); + border: 1px solid transparent; + border-radius: 50%; + box-sizing: border-box; + display: flex; + flex: 0 0 36px; + height: 36px; + justify-content: center; + padding: 8px; + width: 36px; + + img { + display: block; + height: 20px; + width: 20px; + } + + &.completed { + border-color: #3ddbd9; + border-style: solid; + } + + &.current { + border-color: #3ddbd9; + border-style: dashed; + } + + &.upcoming img { + filter: brightness(0) invert(1); + opacity: .2; + } +} + +.timelineConnector { + backdrop-filter: blur(2px); + background: rgba(255, 255, 255, .1); + border-radius: 8px; + flex: 1 1 0; + height: 8px; + min-width: 4px; + + &.completed { + background: #3ddbd9; + } + + &.current { + background: linear-gradient(90deg, #3ddbd9 49%, rgba(255, 255, 255, .1) 49%); + } +} + +.timelineItems { + display: grid; + gap: 4px; + list-style: none; + margin: 0; + padding: 0; + width: 100%; + + li { + color: #fff; + font-size: 14px; + line-height: 20px; + min-width: 0; + text-align: center; + word-break: break-word; + + &.upcoming { + color: rgba(255, 255, 255, .6); + } + + &:first-child { + padding-right: 8px; + text-align: left; + } + + &:last-child { + padding-left: 8px; + text-align: right; + } + + strong { + display: block; + font-weight: 400; + } + } +} + +.timelineDates { + color: rgba(255, 255, 255, .6); + display: flex; + flex-direction: column; + font-size: 12px; + line-height: 16px; + margin-top: 4px; +} + +.timelineTimezone { + color: rgba(255, 255, 255, .6); + font-size: 12px; + font-weight: 400; + line-height: 16px; +} + +@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 { + overflow-x: auto; + padding-bottom: 8px; + } + + .timelineGraphic { + min-width: 800px; + } +} + +@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..64a537471 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeDetailHeader.spec.tsx @@ -0,0 +1,232 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { fireEvent, render, RenderResult, screen, within } 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 onSubmit = jest.fn() + const { rerender }: RenderResult = render( + + + , + ) + + expect(screen.getByRole('button', { name: 'Unregister' })) + .toBeEnabled() + const submit = screen.getByRole('button', { name: 'Submit a solution' }) + expect(submit) + .toBeEnabled() + submit.click() + expect(onSubmit) + .toHaveBeenCalledTimes(1) + + rerender( + + + , + ) + expect(screen.getByRole('button', { name: 'Unregister' })) + .toBeDisabled() + expect(screen.getByRole('button', { name: 'Submit a solution' })) + .toBeDisabled() + }) + + 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() + }) + + it('renders the Figma phase rail with date rows and challenge-end Winners milestone', () => { + const challengeEnd = '2999-08-20T12:30:00.000Z' + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Show full timeline' })) + const timeline = screen.getByRole('region', { name: 'Challenge timeline' }) + const items = within(timeline) + .getAllByRole('listitem') + expect(items) + .toHaveLength(4) + expect(within(timeline) + .getByText('Launch')) + .toBeInTheDocument() + + const registration = within(timeline) + .getByText('Registration') + .closest('li') as HTMLLIElement + expect(registration) + .toHaveAttribute('data-state', 'current') + expect(registration.querySelectorAll('time')) + .toHaveLength(2) + + const winners = within(timeline) + .getByText('Winners') + .closest('li') as HTMLLIElement + expect(winners) + .toHaveAttribute('data-state', 'upcoming') + expect(winners.querySelector('time')) + .toHaveAttribute('datetime', challengeEnd) + expect(timeline.querySelectorAll('img')) + .toHaveLength(4) + expect(timeline.querySelectorAll('[data-state="current"]')) + .toHaveLength(6) + expect(within(timeline) + .getByText(/^Time zone:/)) + .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..b1d312451 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx @@ -0,0 +1,562 @@ +/* eslint-disable react/jsx-no-bind */ +import { CSSProperties, FC, Fragment, 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 timelineAiScreeningIcon from '../assets/timeline-ai-screening.svg' +import timelineAppealsIcon from '../assets/timeline-appeals.svg' +import timelineAppealsResponseIcon from '../assets/timeline-appeals-response.svg' +import timelineLaunchIcon from '../assets/timeline-launch.svg' +import timelineRegistrationIcon from '../assets/timeline-registration.svg' +import timelineReviewIcon from '../assets/timeline-review.svg' +import timelineScreeningIcon from '../assets/timeline-screening.svg' +import timelineSubmissionIcon from '../assets/timeline-submission.svg' +import timelineWinnersIcon from '../assets/timeline-winners.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 + onSubmit: () => void + onUnregister: () => void + registrationError?: boolean + registrationLoading?: boolean +} + +type ChallengeTimelineState = 'completed' | 'current' | 'upcoming' + +interface ChallengeTimelineItem { + endDate?: string + icon: string + key: string + name: string + range: boolean + startDate?: string + state: ChallengeTimelineState +} + +/** 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 +} + +/** + * Converts an API date into a comparable timestamp. + * + * @param value ISO timestamp returned by Challenge API. + * @returns finite timestamp, or undefined for absent and malformed values. + * @throws Does not throw. + */ +function timelineTimestamp(value?: string): number | undefined { + if (!value) return undefined + const timestamp = Date.parse(value) + return Number.isNaN(timestamp) ? undefined : timestamp +} + +/** + * Labels one expanded-timeline phase for visual completion state. + * + * @param phase scheduled challenge phase. + * @param selected API-authoritative current phase. + * @param currentPhaseNames every API-authoritative open phase name, including overlaps. + * @returns completed, current, or upcoming state. + * @throws Does not throw; malformed dates remain upcoming. + */ +function timelineState( + phase: ChallengePhase, + selected?: ChallengePhase, + currentPhaseNames: string[] = [], +): ChallengeTimelineState { + const phaseKey = challengeCatalogKey(phase.name) + const selectedMatches = (selected?.id && phase.id === selected.id) + || (!selected?.id && selected && challengeCatalogKey(selected.name) === phaseKey) + const namedCurrent = currentPhaseNames.some(name => challengeCatalogKey(name) === phaseKey) + if (phase.isOpen === true || selectedMatches || namedCurrent) return 'current' + const endValue = phase.actualEndDate ?? phase.scheduledEndDate + const end = timelineTimestamp(endValue) + return end !== undefined && end <= Date.now() ? 'completed' : 'upcoming' +} + +/** + * Maps Challenge API phase names to the exact phase glyph exported from Figma. + * + * @param name authored Challenge API phase name. + * @returns committed Figma icon asset for the phase family. + * @throws Does not throw; unrecognized phases use the Review glyph. + */ +function timelinePhaseIcon(name: string): string { + const key = challengeCatalogKey(name) + if (key.includes('registration')) return timelineRegistrationIcon + if (key.includes('submission') || key.includes('finalfix')) return timelineSubmissionIcon + if (key.includes('aiscreening')) return timelineAiScreeningIcon + if (key.includes('screening')) return timelineScreeningIcon + if (key.includes('appealsresponse')) return timelineAppealsResponseIcon + if (key.includes('appeals')) return timelineAppealsIcon + return timelineReviewIcon +} + +/** + * Resolves the end represented by the terminal Winners milestone. + * + * @param challenge Challenge API detail response. + * @returns the challenge end date, or the latest valid phase end when absent. + * @throws Does not throw; malformed dates are ignored. + */ +function challengeTimelineEnd(challenge: ChallengeOpportunity): string | undefined { + if (timelineTimestamp(challenge.endDate) !== undefined) return challenge.endDate + return (challenge.phases ?? []).reduce((latest, item) => { + const candidate = item.actualEndDate ?? item.scheduledEndDate + const candidateTimestamp = timelineTimestamp(candidate) + const latestTimestamp = timelineTimestamp(latest) + if (candidateTimestamp === undefined) return latest + return latestTimestamp === undefined || candidateTimestamp > latestTimestamp ? candidate : latest + }, undefined) +} + +/** + * Builds the Figma timeline sequence from Challenge API boundaries and phases. + * + * @param challenge Challenge API detail response. + * @param selected API-authoritative current phase. + * @returns Launch, authored phases, and terminal Winners timeline items in display order. + * @throws Does not throw; absent dates are retained as announced-later labels. + */ +function challengeTimelineItems( + challenge: ChallengeOpportunity, + selected?: ChallengePhase, +): ChallengeTimelineItem[] { + const now = Date.now() + const startTimestamp = timelineTimestamp(challenge.startDate) + const endDate = challengeTimelineEnd(challenge) + const endTimestamp = timelineTimestamp(endDate) + const phases = (challenge.phases ?? []).map((item, index): ChallengeTimelineItem => ({ + endDate: item.actualEndDate ?? item.scheduledEndDate, + icon: timelinePhaseIcon(item.name), + key: item.id ?? `phase-${challengeCatalogKey(item.name)}-${index}`, + name: item.name, + range: true, + startDate: item.actualStartDate ?? item.scheduledStartDate, + state: timelineState(item, selected, challenge.currentPhaseNames), + })) + + return [{ + icon: timelineLaunchIcon, + key: 'launch', + name: 'Launch', + range: false, + startDate: challenge.startDate, + state: startTimestamp !== undefined && startTimestamp <= now ? 'completed' : 'upcoming', + }, ...phases, { + icon: timelineWinnersIcon, + key: 'winners', + name: 'Winners', + range: false, + startDate: endDate, + state: ((endTimestamp !== undefined && endTimestamp <= now) + || challenge.status?.toUpperCase() === 'COMPLETED') ? 'completed' : 'upcoming', + }] +} + +/** + * Labels the thick connector between two Figma timeline milestones. + * + * @param previous state of the milestone on the connector's left. + * @param next state of the milestone on the connector's right. + * @returns completed, current-progress, or upcoming connector state. + * @throws Does not throw. + */ +function timelineConnectorState( + previous: ChallengeTimelineState, + next: ChallengeTimelineState, +): ChallengeTimelineState { + if (previous === 'completed' && (next === 'completed' || next === 'current')) return 'completed' + if (previous === 'current') return 'current' + return 'upcoming' +} + +/** + * Formats one timeline timestamp as the two-row Figma date content expects. + * + * @param value ISO timestamp returned by Challenge API. + * @returns local day, month, year, hour, and minute, or the schedule fallback. + * @throws Does not throw; malformed dates use the fallback label. + */ +function timelineDate(value?: string): string { + const timestamp = timelineTimestamp(value) + if (timestamp === undefined) return 'To be announced' + const parts = new Intl.DateTimeFormat('en-GB', { + day: 'numeric', + hour: '2-digit', + hour12: false, + minute: '2-digit', + month: 'long', + year: 'numeric', + }) + .formatToParts(new Date(timestamp)) + const day = parts.find(part => part.type === 'day')?.value + const month = parts.find(part => part.type === 'month')?.value + const year = parts.find(part => part.type === 'year')?.value + const hour = parts.find(part => part.type === 'hour')?.value + const minute = parts.find(part => part.type === 'minute')?.value + return `${day} ${month}, ${year}, ${hour}:${minute}` +} + +/** + * Formats the browser timezone in the human-readable Figma label style. + * + * @returns local IANA timezone with spaced path separators. + * @throws Does not throw; browsers without a timezone report UTC. + */ +function timelineTimezone(): string { + const timezone = Intl.DateTimeFormat() + .resolvedOptions() + .timeZone || 'UTC' + return timezone.replace(/_/g, ' ') + .replace(/\//g, ' / ') +} + +/** + * 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 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 ?? [] + const expandedTimeline = challengeTimelineItems(props.challenge, phase) + const timelineGridStyle: CSSProperties = { + gridTemplateColumns: [ + '88px', + ...(props.challenge.phases ?? []).map(() => 'minmax(0, 1fr)'), + '88px', + ].join(' '), + } + + 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..039f08011 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeForum.module.scss @@ -0,0 +1,1416 @@ +.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, +.overview > button, +.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; + + &:disabled { + cursor: wait; + opacity: 0.6; + } + + &: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 #a8a8a8; + 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: #6f6f6f; + 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 #a8a8a8; + 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 #a8a8a8; + 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: #e0e0e0; + 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: #6f6f6f; +} + +.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: #6f6f6f; + 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: #e9ecef; + 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: #6f6f6f; + 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); +} + +.topicInfoAuthor { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.authorBadge { + align-items: center; + background: #e9ecef; + border-radius: 2px; + color: #161616; + display: inline-flex; + font-size: 11px; + font-weight: 600; + gap: 2px; + line-height: 12px; + padding: 4px; + + svg { + height: 14px; + width: 14px; + } +} + +.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: #6f6f6f; + 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 #e0e0e0; +} + +.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; + } +} + +.overview > button { + cursor: pointer; + font-family: inherit; + width: 100%; +} + +.member > .ratingGray { + color: #555555; +} + +.member > .ratingGreen { + color: #2d7e2d; +} + +.member > .ratingBlue { + color: #616bd5; +} + +.member > .ratingYellow { + color: #f2c900; +} + +.member > .ratingRed { + color: #ef3a3a; +} + +.participantOverflow { + align-items: center; + background: #e9ecef; + border: 1px solid #fff; + border-radius: 50%; + color: #161616; + display: inline-flex; + font-size: 11px; + font-weight: 700; + height: 32px; + justify-content: center; + margin-left: -8px; + width: 32px; +} + +.newTopic { + background: #c1294f; + color: #fff; +} + +.announcementCard { + min-height: 320px; +} + +.topicExcerpt { + color: #161616; + display: -webkit-box; + font-size: 14px; + -webkit-line-clamp: 3; + line-height: 20px; + margin: 20px 0 0; + overflow: hidden; + -webkit-box-orient: vertical; +} + +.topicFooter { + align-items: center; + color: #6f6f6f; + display: flex; + flex-wrap: wrap; + font-size: 12px; + gap: 16px; + justify-content: space-between; + line-height: 16px; + margin-top: auto; + padding-top: 20px; +} + +.topicActions, +.postActions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 24px; + + button, + > span { + align-items: center; + appearance: none; + background: transparent; + border: 0; + color: #007d79; + display: inline-flex; + font: inherit; + font-size: 14px; + font-weight: 700; + gap: 5px; + line-height: 20px; + padding: 0; + } + + button { + cursor: pointer; + + &:hover, + &:focus-visible { + text-decoration: underline; + } + + &:disabled { + cursor: wait; + opacity: 0.6; + } + } + + svg { + height: 18px; + width: 18px; + } +} + +.topicMetrics p + p, +.topicInfoMetrics p + p { + margin-top: 16px; +} + +.postActions button.reactionActive { + background: #e5f6f6; + border-radius: 4px; + color: #006b68; + padding: 4px 6px; + text-decoration: none; +} + +.actionError, +.lockedNotice { + background: #fff; + border: 1px solid #c1294f; + border-radius: 4px; + color: #c1294f; + font-size: 14px; + line-height: 20px; + margin: 0; + padding: 12px 16px; +} + +.lockedNotice { + border-color: #a8a8a8; + color: #6f6f6f; +} + +.createView { + min-width: 0; +} + +.createBreadcrumb { + background: #fff; + color: #6f6f6f; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.5px; + line-height: 16px; + margin-bottom: 16px; + padding: 24px; + text-transform: uppercase; +} + +.createLayout { + align-items: start; + display: grid; + gap: 24px; + grid-template-columns: 281px minmax(0, 895px); +} + +.createRail { + display: flex; + flex-direction: column; + gap: 16px; + + section { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 24px; + } + + h2, + h3 { + color: #161616; + font-family: 'Figtree', sans-serif; + font-weight: 700; + margin: 0; + text-transform: none; + } + + h2 { + font-size: 24px; + line-height: 38px; + } + + h3 { + font-size: 18px; + line-height: 30px; + } + + p, + li { + color: #161616; + font-size: 14px; + line-height: 20px; + } + + p { + margin: 24px 0 0; + } + + ul { + display: flex; + flex-direction: column; + gap: 12px; + list-style: none; + margin: 16px 0 0; + padding: 0; + } + + li { + padding-left: 22px; + position: relative; + + &::before { + color: #007d79; + content: 'ϟ'; + font-size: 18px; + font-weight: 700; + left: 2px; + position: absolute; + top: 0; + } + } +} + +.createForm, +.commentForm, +.noPosts { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-sizing: border-box; + padding: 24px; +} + +.createForm { + display: flex; + flex-direction: column; + gap: 20px; +} + +.titleField, +.markdownField { + display: flex; + flex-direction: column; + gap: 8px; + + > span, + > label { + color: #161616; + font-size: 14px; + font-weight: 700; + line-height: 20px; + } +} + +.titleField input { + background: #fff; + border: 1px solid #a8a8a8; + border-radius: 4px; + box-sizing: border-box; + color: #161616; + font: inherit; + font-size: 14px; + height: 38px; + line-height: 20px; + padding: 8px 12px; + width: 100%; + + &:focus { + border-color: #007d79; + box-shadow: 0 0 0 1px #007d79; + outline: 0; + } +} + +.announcementOption { + align-items: flex-start; + background: #f4f4f4; + border: 1px solid #e0e0e0; + border-radius: 4px; + color: #161616; + cursor: pointer; + display: flex; + gap: 12px; + padding: 12px; + + input { + accent-color: #007d79; + flex: 0 0 auto; + height: 16px; + margin: 2px 0 0; + width: 16px; + } + + span { + display: flex; + flex-direction: column; + gap: 2px; + } + + strong { + font-size: 14px; + line-height: 20px; + } + + small { + color: #6f6f6f; + font-size: 12px; + line-height: 16px; + } + + &:focus-within { + border-color: #007d79; + box-shadow: 0 0 0 1px #007d79; + } +} + +.editorShell { + border: 1px solid #a8a8a8; + border-radius: 4px; + overflow: hidden; +} + +.editorToolbar { + align-items: stretch; + border-bottom: 1px solid #e0e0e0; + display: flex; + flex-wrap: wrap; + min-height: 48px; + + button { + align-items: center; + appearance: none; + background: #fff; + border: 0; + border-right: 1px solid #e0e0e0; + color: #6f6f6f; + cursor: pointer; + display: inline-flex; + font: inherit; + font-size: 12px; + justify-content: center; + min-width: 42px; + padding: 8px; + + &:hover, + &:focus-visible { + background: #e9ecef; + color: #007d79; + } + } + + svg { + height: 18px; + width: 18px; + } +} + +.editorShell textarea, +.editorPreview { + background: #fff; + border: 0; + box-sizing: border-box; + color: #161616; + font: inherit; + font-size: 14px; + line-height: 20px; + min-height: 176px; + outline: 0; + padding: 16px; + resize: vertical; + width: 100%; +} + +.editorPreview { + overflow: auto; +} + +.editorPreview > p { + color: #6f6f6f; + margin: 0; +} + +.editorHelp { + color: #6f6f6f; + display: flex; + font-size: 12px; + gap: 16px; + justify-content: space-between; + line-height: 16px; +} + +.formActions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 16px; + + button { + appearance: none; + background: #fff; + border: 1px solid #007d79; + border-radius: 4px; + color: #007d79; + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 700; + height: 38px; + line-height: 20px; + padding: 8px 20px; + + &:first-child { + background: #007d79; + color: #fff; + } + + &:hover, + &:focus-visible { + box-shadow: 0 0 0 2px #3ddbd9; + } + + &:disabled { + cursor: wait; + opacity: 0.6; + } + } + + > span { + color: #6f6f6f; + font-size: 12px; + line-height: 16px; + margin-left: auto; + } +} + +.post, +.replyPost { + min-height: 0; + + > header { + align-items: flex-start; + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 20px; + + > .newPost { + align-self: flex-start; + } + } +} + +.replyPost { + border-left: 1px solid #e0e0e0; + margin-left: 48px; +} + +.postIdentity { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + + > span:not(.member) { + background: #f4f4f4; + border-radius: 2px; + color: #6f6f6f; + font-size: 11px; + font-weight: 600; + line-height: 12px; + padding: 4px; + } +} + +.postMeta { + color: #6f6f6f; + display: flex; + flex-wrap: wrap; + font-size: 12px; + gap: 12px; + line-height: 16px; +} + +.postContent { + color: #161616; + font-size: 14px; + line-height: 20px; + min-height: 80px; +} + +.postActions { + border-top: 1px solid #e0e0e0; + margin-top: 20px; + padding-top: 16px; +} + +.commentForm { + display: flex; + flex-direction: column; + gap: 16px; + + > h2 { + color: #161616; + font-family: 'Figtree', sans-serif; + font-size: 18px; + line-height: 30px; + margin: 0; + text-transform: none; + } + + .markdownField > label { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; + } +} + +.replyingTo { + align-items: center; + background: #e9ecef; + border-radius: 4px; + color: #161616; + display: flex; + font-size: 12px; + gap: 4px; + line-height: 16px; + padding: 8px 12px; + + button { + appearance: none; + background: transparent; + border: 0; + color: #007d79; + cursor: pointer; + font: inherit; + font-weight: 700; + margin-left: auto; + padding: 0; + } +} + +.noPosts { + text-align: center; + + h2 { + color: #161616; + font-family: 'Figtree', sans-serif; + font-size: 18px; + line-height: 30px; + margin: 0; + text-transform: none; + } + + p { + color: #6f6f6f; + font-size: 14px; + line-height: 20px; + margin: 4px 0 0; + } +} + +@media (max-width: 1100px) { + .createLayout { + grid-template-columns: 240px minmax(0, 1fr); + } +} + +@media (max-width: 767px) { + .createLayout { + grid-template-columns: 1fr; + } + + .announcementCard { + min-height: 260px; + } + + .topicFooter, + .editorHelp { + align-items: flex-start; + flex-direction: column; + } + + .replyPost { + margin-left: 20px; + } + + .editorToolbar button { + min-width: 38px; + } + + .formActions > span { + margin-left: 0; + width: 100%; + } +} 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..bfa8d94c7 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeForum.spec.tsx @@ -0,0 +1,471 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, unicorn/no-null */ +import '@testing-library/jest-dom' +import { readFileSync } from 'fs' +import { act } from 'react' +import { + fireEvent, + render, + RenderResult, + screen, + waitFor, +} from '@testing-library/react' + +import { + ForumPost, + ForumTopicCollection, + ForumTopicDetail, + ForumTopicSummary, + MemberProfileSummary, +} from '../models' +import { + ChallengeForum, + flattenForumPosts, + forumRatingClass, + formatForumDate, + plainForumExcerpt, + wrapMarkdownSelection, +} from './ChallengeForum' + +const mockCreateForumPost = jest.fn() +const mockCreateForumTopic = jest.fn() +const mockDeleteForumPost = jest.fn() +const mockDeleteForumTopic = jest.fn() +const mockMarkForumTopicRead = jest.fn() +const mockSetForumPostReaction = jest.fn() +const mockSetForumTopicWatching = jest.fn() +const mockUseSWR = jest.fn() +const mockUpdateForumPost = jest.fn() +const mockUpdateForumTopic = 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', () => ({ + createForumPost: (...args: unknown[]) => mockCreateForumPost(...args), + createForumTopic: (...args: unknown[]) => mockCreateForumTopic(...args), + deleteForumPost: (...args: unknown[]) => mockDeleteForumPost(...args), + deleteForumTopic: (...args: unknown[]) => mockDeleteForumTopic(...args), + getChallengeForumTopics: jest.fn(), + getForumTopicDetail: jest.fn(), + getMemberProfilesByUserIds: jest.fn(), + markForumTopicRead: (...args: unknown[]) => mockMarkForumTopicRead(...args), + setForumPostReaction: (...args: unknown[]) => mockSetForumPostReaction(...args), + setForumTopicWatching: (...args: unknown[]) => mockSetForumTopicWatching(...args), + updateForumPost: (...args: unknown[]) => mockUpdateForumPost(...args), + updateForumTopic: (...args: unknown[]) => mockUpdateForumTopic(...args), +})) + +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, + participants: [ + { handle: 'DaraK', memberId: '1' }, + { handle: 'Yoki', memberId: '2' }, + ], + participantsCount: 2, + postsCount: 2, + roleName: null, + starterPostExcerpt: 'Welcome to the challenge.', + title: 'Welcome to React Component Library Development Challenge', + unread: true, + updatedAt: '2026-06-07T10:15:00.000Z', + viewsCount: 48, + watching: true, +} + +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', + authorPostsCount: 123, + 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', + authorPostsCount: 12, + content: 'Thanks for the clarification.', + createdAt: '2026-06-07T10:15:00.000Z', + deleted: false, + id: 'post-2', + parentId: 'post-1', + parentType: 'POST', + replies: [], + thumbsDownCount: 4, + thumbsUpCount: 3, + topicId: 'topic-1', + updatedAt: '2026-06-07T10:15:00.000Z', + viewerReaction: null, + }], + thumbsDownCount: 1, + thumbsUpCount: 2, + topicId: 'topic-1', + updatedAt: '2026-06-06T00:05:00.000Z', + viewerReaction: 'THUMBS_UP', +} + +const forumStyles = readFileSync(`${__dirname}/ChallengeForum.module.scss`, 'utf8') + +describe('ChallengeForum', () => { + beforeEach(() => { + jest.clearAllMocks() + mockCreateForumPost.mockResolvedValue({ id: 'post-3' }) + mockCreateForumTopic.mockResolvedValue({ + starterPost: { id: 'post-3' }, + topic: { id: 'topic-3' }, + }) + mockMarkForumTopicRead.mockResolvedValue(undefined) + mockSetForumPostReaction.mockResolvedValue({ + postId: 'post-1', + thumbsDownCount: 1, + thumbsUpCount: 1, + viewerReaction: null, + }) + mockSetForumTopicWatching.mockResolvedValue({ watching: false }) + 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('button', { name: /Create new topic/ })) + .toBeInTheDocument() + 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 an embedded post tree and keeps replies and comments in-page', async () => { + render() + await act(async () => 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.getAllByText('Author')) + .toHaveLength(2) + expect(screen.getAllByRole('button', { name: 'Reply' })) + .toHaveLength(2) + fireEvent.change(screen.getByPlaceholderText('Type here'), { + target: { value: 'A new in-page comment' }, + }) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Post comment' }))) + + await waitFor(() => expect(mockCreateForumPost) + .toHaveBeenCalledWith('topic-1', { content: 'A new in-page comment' })) + expect(mockMarkForumTopicRead) + .toHaveBeenCalledWith('topic-1') + }) + + it('creates a challenge topic without leaving Opportunities', async () => { + render() + fireEvent.click(screen.getByRole('button', { name: /Create new topic/ })) + expect(screen.queryByRole('checkbox', { name: /Post as announcement/ })) + .not.toBeInTheDocument() + fireEvent.change(screen.getByPlaceholderText(/clear, descriptive title/), { + target: { value: 'Clarify the API contract' }, + }) + fireEvent.change(screen.getByPlaceholderText(/Describe your question/), { + target: { value: 'Can the maintainers clarify the response type?' }, + }) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Create topic' }))) + + await waitFor(() => expect(mockCreateForumTopic) + .toHaveBeenCalledWith({ + challengeId: 'challenge-id', + content: 'Can the maintainers clarify the response type?', + title: 'Clarify the API contract', + })) + }) + + it('lets an administrator create a challenge announcement', async () => { + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /Create new topic/ })) + fireEvent.change(screen.getByPlaceholderText(/clear, descriptive title/), { + target: { value: 'Submission deadline extended' }, + }) + fireEvent.change(screen.getByPlaceholderText(/Describe your question/), { + target: { value: 'The submission deadline is now Friday at 18:00 UTC.' }, + }) + fireEvent.click(screen.getByRole('checkbox', { name: /Post as announcement/ })) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Create topic' }))) + + await waitFor(() => expect(mockCreateForumTopic) + .toHaveBeenCalledWith({ + challengeId: 'challenge-id', + content: 'The submission deadline is now Friday at 18:00 UTC.', + isAnnouncement: true, + title: 'Submission deadline extended', + })) + }) + + it('toggles topic watches through forums-api-v6', async () => { + render() + await act(async () => fireEvent.click(screen.getAllByRole('button', { name: 'Watched' })[0])) + + await waitFor(() => expect(mockSetForumTopicWatching) + .toHaveBeenCalledWith('topic-1', false)) + }) + + it('shows shared reaction counts and toggles or switches the current member reaction', async () => { + render() + await act(async () => fireEvent.click(screen.getByRole('button', { name: announcement.title }))) + + const selectedThumbsUp = screen.getByRole('button', { name: 'Remove thumbs up (2)' }) + expect(selectedThumbsUp) + .toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'Add thumbs down (1)' })) + .toHaveAttribute('aria-pressed', 'false') + expect(screen.getByRole('button', { name: 'Add thumbs up (3)' })) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add thumbs down (4)' })) + .toBeInTheDocument() + + await act(async () => fireEvent.click(selectedThumbsUp)) + await waitFor(() => expect(mockSetForumPostReaction) + .toHaveBeenCalledWith('post-1', undefined)) + + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Add thumbs down (1)' }))) + await waitFor(() => expect(mockSetForumPostReaction) + .toHaveBeenCalledWith('post-1', 'THUMBS_DOWN')) + }) + + it('keeps legacy topic summaries usable while the enriched API rolls out', () => { + topicCollection = { + data: [{ + ...announcement, + participants: undefined, + participantsCount: undefined, + viewsCount: undefined, + watching: undefined, + } as unknown as ForumTopicSummary], + sourceTotalCount: 1, + truncated: false, + } + + render() + + expect(screen.getByRole('button', { name: announcement.title })) + .toBeInTheDocument() + expect(screen.getByLabelText('Participants: DaraK, Yoki')) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Watch' })) + .toBeInTheDocument() + }) + + 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 legacy 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 and join this challenge discussion/)) + .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;') + expect(forumStyles) + .toContain('color: #f2c900;') + expect(forumStyles) + .not.toContain('#8d8d8d') + }) +}) + +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('—') + }) + + it('normalizes list excerpts and wraps editor selections', () => { + expect(plainForumExcerpt('Use **strong** [guidance](https://example.com).')) + .toBe('Use strong guidance.') + expect(wrapMarkdownSelection('hello world', 6, 11, '**', '**')) + .toEqual({ + selectionEnd: 13, + selectionStart: 8, + value: 'hello **world**', + }) + }) + + it('maps public ratings to the August 2026 handle palette', () => { + expect(forumRatingClass()) + .toBe('ratingGray') + expect(forumRatingClass(1000)) + .toBe('ratingGreen') + expect(forumRatingClass(1300)) + .toBe('ratingBlue') + expect(forumRatingClass(1800)) + .toBe('ratingYellow') + expect(forumRatingClass(2400)) + .toBe('ratingRed') + }) +}) diff --git a/src/apps/opportunities/src/components/ChallengeForum.tsx b/src/apps/opportunities/src/components/ChallengeForum.tsx new file mode 100644 index 000000000..9950487d5 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeForum.tsx @@ -0,0 +1,1566 @@ +/* eslint-disable no-alert, no-use-before-define, ordered-imports/ordered-imports, react/jsx-no-bind */ +import { + ChangeEvent, + FC, + FormEvent, + useMemo, + useRef, + useState, +} from 'react' +import useSWR, { SWRResponse } from 'swr' + +import { IconOutline } from '~/libs/ui' + +import { + ChallengeOpportunity, + ForumPost, + ForumPostReaction, + ForumTopicCollection, + ForumTopicDetail, + ForumTopicSummary, + MemberProfileSummary, +} from '../models' +import { + createForumPost, + createForumTopic, + deleteForumPost, + deleteForumTopic, + getChallengeForumTopics, + getForumTopicDetail, + getMemberProfilesByUserIds, + markForumTopicRead, + setForumPostReaction, + setForumTopicWatching, + updateForumPost, + updateForumTopic, +} from '../services' + +import { OpportunityTabLoading } from './OpportunityTabLoading' +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' +type ForumRatingClass = 'ratingBlue' | 'ratingGray' | 'ratingGreen' | 'ratingRed' | 'ratingYellow' + +interface ChallengeForumProps { + canCreateAnnouncements?: boolean + challenge: ChallengeOpportunity + memberId?: string +} + +interface FlatForumPost { + depth: number + post: ForumPost +} + +interface ForumParticipant { + handle: string + memberId: string +} + +interface MarkdownSelectionResult { + selectionEnd: number + selectionStart: number + value: string +} + +type MemberProfilesById = ReadonlyMap + +const COMMENT_CHARACTER_LIMIT = 500 +const TOPIC_CHARACTER_LIMIT = 16000 + +/** + * 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), + ]) +} + +/** + * Produces compact plain list-card copy from a bounded markdown excerpt. + * + * @param value optional starter-post excerpt. + * @returns whitespace-normalized copy with common markdown markers removed. + * @throws Does not throw. + */ +export function plainForumExcerpt(value?: string | null): string { + return (value ?? '') + .replace(/!?(\[([^\]]+)\])\([^)]+\)/g, '$2') + .replace(/[`*_>#~]/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * Wraps the selected editor range with a markdown prefix and suffix. + * + * @param value complete editor value. + * @param selectionStart inclusive selection start. + * @param selectionEnd exclusive selection end. + * @param prefix markdown inserted before the selection. + * @param suffix markdown inserted after the selection. + * @returns updated value and the range to reselect after rendering. + * @throws Does not throw; selection bounds are clamped to the input length. + */ +export function wrapMarkdownSelection( + value: string, + selectionStart: number, + selectionEnd: number, + prefix: string, + suffix: string, +): MarkdownSelectionResult { + const start = Math.max(0, Math.min(selectionStart, value.length)) + const end = Math.max(start, Math.min(selectionEnd, value.length)) + return { + selectionEnd: end + prefix.length, + selectionStart: start + prefix.length, + value: `${value.slice(0, start)}${prefix}${value.slice(start, end)}${suffix}${value.slice(end)}`, + } +} + +/** + * Resolves the August 2026 design-system handle color for a member rating. + * + * @param maxRating optional public maximum rating. + * @returns CSS module rating class name. + * @throws Does not throw. + */ +export function forumRatingClass(maxRating?: number): ForumRatingClass { + if (maxRating === undefined || maxRating < 900) return 'ratingGray' + if (maxRating < 1200) return 'ratingGreen' + if (maxRating < 1500) return 'ratingBlue' + if (maxRating < 2200) return 'ratingYellow' + return 'ratingRed' +} + +/** + * Resolves human-readable API error copy without exposing transport internals. + * + * @param error unknown rejection from an authenticated forum mutation. + * @returns server validation copy when available, otherwise a stable fallback. + * @throws Does not throw. + */ +export function forumErrorMessage(error: unknown): string { + const responseMessage = (error as { + response?: { data?: { message?: string | string[] } } + })?.response?.data?.message + if (Array.isArray(responseMessage)) return responseMessage.join(' ') + if (typeof responseMessage === 'string' && responseMessage.trim()) return responseMessage + if (error instanceof Error && error.message.trim()) return error.message + return 'The forum action could not be completed. Please try again.' +} + +/** + * 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 participant snapshots from the enriched summary with a legacy fallback. + * + * @param topic forum topic summary. + * @returns de-duplicated participant snapshots in API activity order. + * @throws Does not throw. + */ +function topicParticipants(topic: ForumTopicSummary): ForumParticipant[] { + const snapshots = topic.participants ?? [] + const participants = snapshots.length + ? snapshots + : [ + { handle: topic.authorHandle, memberId: topic.authorMemberId }, + topic.latestActivity + ? { + handle: topic.latestActivity.authorHandle, + memberId: topic.latestActivity.authorMemberId, + } + : undefined, + ].filter((participant): participant is ForumParticipant => !!participant) + + return participants.filter((participant, index) => ( + 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 rating-colored canonical handle. + * @throws Does not throw. + */ +const ForumMember: FC<{ + handle: string + profile?: MemberProfileSummary +}> = props => { + const handle = props.profile?.handle ?? props.handle + const ratingClass = forumRatingClass(props.profile?.maxRating) + + return ( + + + {handle} + + ) +} + +/** + * Renders topic participant identities and any bounded overflow count. + * + * @param props participant snapshots, complete count, and member projections. + * @returns accessible linked avatar group. + * @throws Does not throw. + */ +const ParticipantGroup: FC<{ + participants: ForumParticipant[] + profilesByMemberId: MemberProfilesById + total: number +}> = props => { + const labels = props.participants.map(participant => ( + props.profilesByMemberId.get(participant.memberId)?.handle ?? participant.handle + )) + const overflow = Math.max(0, props.total - props.participants.length) + + return ( + + {props.participants.map(participant => { + const profile = props.profilesByMemberId.get(participant.memberId) + const handle = profile?.handle ?? participant.handle + + return ( + + + + ) + })} + {overflow > 0 && ( + + + + {overflow} + + )} + + ) +} + +interface ForumFallbackProps { + externalUrl?: string + text: string + title: string +} + +/** + * Preserves a safe recovery path when embedded API access is unavailable. + * + * @param props fallback copy and optional legacy destination. + * @returns forum fallback state. + * @throws Does not throw. + */ +const ForumFallback: FC = props => ( +
+
+) + +/** + * Renders challenge forum counters and the in-page create-topic action. + * + * @param props visible topics, source total, and create callback. + * @returns authored forum overview rail. + * @throws Does not throw. + */ +const ForumOverview: FC<{ + onCreate: () => void + 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 + +
+ +
+ ) +} + +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 topic, comment + +
+ 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 with watch and owner mutation actions. + * + * @param props topic data, current member, projections, and mutation callbacks. + * @returns Figma-aligned topic card. + * @throws Does not throw; callbacks own API error handling. + */ +const ForumTopicCard: FC<{ + memberId: string + onDelete: (topic: ForumTopicSummary) => void + onEdit: (topic: ForumTopicSummary) => void + onSelect: (topicId: string) => void + onWatch: (topic: ForumTopicSummary) => void + pendingAction?: string + profilesByMemberId: MemberProfilesById + topic: ForumTopicSummary +}> = props => { + const participants = topicParticipants(props.topic) + const excerpt = plainForumExcerpt(props.topic.starterPostExcerpt) + const owner = props.topic.authorMemberId === props.memberId + const cardClass = props.topic.isAnnouncement + ? `${styles.topicCard} ${styles.announcementCard}` + : styles.topicCard + 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)} + +
+ {excerpt &&

{excerpt}

} +
+ + Last post at + {' '} + {formatForumDate(props.topic.latestActivity?.createdAt)} + +
+ {owner && !props.topic.locked && ( + <> + + + + )} + +
+
+
+ +
+ ) +} + +interface MarkdownEditorProps { + id: string + label: string + maxLength: number + onChange: (value: string) => void + placeholder: string + preview: boolean + value: string +} + +/** + * Renders the shared Markdown toolbar, textarea, preview, and character count. + * + * @param props controlled editor state and authored field metadata. + * @returns accessible Markdown authoring control. + * @throws Does not throw. + */ +const MarkdownEditor: FC = props => { + const textareaRef = useRef(null) + + /** Applies one toolbar token to the active textarea selection. */ + const format = (prefix: string, suffix: string = ''): void => { + const textarea = textareaRef.current + const result = wrapMarkdownSelection( + props.value, + textarea?.selectionStart ?? props.value.length, + textarea?.selectionEnd ?? props.value.length, + prefix, + suffix, + ) + props.onChange(result.value.slice(0, props.maxLength)) + window.setTimeout(() => { + textarea?.focus() + textarea?.setSelectionRange(result.selectionStart, result.selectionEnd) + }) + } + + const toolbarItems: Array<[string, string, string]> = [ + ['Bold', '**', '**'], + ['Italic', '_', '_'], + ['Underline', '', ''], + ['Heading 1', '# ', ''], + ['Heading 2', '## ', ''], + ['Heading 3', '### ', ''], + ['Bulleted list', '- ', ''], + ['Numbered list', '1. ', ''], + ['Link', '[', '](https://)'], + ['Inline code', '`', '`'], + ['Quote', '> ', ''], + ] + + return ( +
+ +
+
+ {toolbarItems.map(([label, prefix, suffix]) => ( + + ))} +
+ {props.preview + ? ( +
+ {props.value.trim() + ? + :

Nothing to preview yet.

} +
+ ) + : ( +