From d82247e39e342030bf7146b3daa6b142e64f3066 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 9 Sep 2026 17:20:04 +1000 Subject: [PATCH 1/7] fix(PM-6258 PM-6272 PM-6274 PM-6275 PM-6276): address Gig QA feedback --- src/apps/gigs/README.md | 22 ++++++----- .../components/GigApplicationForm.spec.tsx | 37 ++++++++++++++++++- src/apps/gigs/src/components/GigShared.tsx | 16 +++++++- src/apps/gigs/src/gigs.service.spec.ts | 20 ++++++++-- src/apps/gigs/src/gigs.service.ts | 19 ++++++++-- src/apps/gigs/src/gigs.utils.spec.ts | 14 +++++++ src/apps/gigs/src/gigs.utils.ts | 15 ++++++-- src/apps/gigs/src/pages/GigsPage.spec.tsx | 8 +++- src/apps/gigs/src/pages/GigsPage.tsx | 4 +- src/apps/gigs/src/styles/index.scss | 25 +++++++++++-- 10 files changed, 151 insertions(+), 29 deletions(-) diff --git a/src/apps/gigs/README.md b/src/apps/gigs/README.md index fb79893cb..1b3933b39 100644 --- a/src/apps/gigs/README.md +++ b/src/apps/gigs/README.md @@ -22,10 +22,10 @@ cards follow the platform design system. The reference was the August 2026 Figma file `C2cA6508RhpjWJDp7MLKbO`, Color page `1:54`, with design context retrieved from `674:8828`. Layout retains the legacy listing/detail/form hierarchy while adapting to the platform components. Styles apply only inside `.gigs-app`. -The listing search uses the same teal focused border and ring as the other 2026 -opportunity filters instead of inheriting the legacy blue outline. Keyboard -focus retains a real teal outline, with a system Highlight fallback in forced -color modes. +The listing search, location and sort controls use the same teal focused border +and ring as the other 2026 opportunity filters instead of inheriting the browser's +blue outline. Keyboard focus retains a real teal outline, with a system Highlight +fallback in forced color modes. The Gig Work resources callout opens its external community guide in a new tab with the opener relationship removed. @@ -37,9 +37,10 @@ lookup accepts both Recruit's current direct array and its legacy `{ data }` envelope. Applications use the refreshed platform token and preserve the existing multipart `form`/`resume` contract and Recruit custom field IDs 1, 2, 13 and 14. A saved resume may be reused; otherwise PDF/DOCX up to **8,000,000 bytes** is required to -match the server's multer limit. No success state appears without an explicit -`success: true` response. HTTP errors and Recruit error envelopes returned with -HTTP 200 both reject. Candidate searches return an existing profile from either +match the server's multer limit. Recruit's populated assignment response and its +idempotent `{ success: true }` response both confirm submission; empty, explicitly +unsuccessful, HTTP-error and HTTP-200 error-envelope responses reject. Candidate +searches return an existing profile from either response shape. A bare `[]` or `{ data: [] }` means no existing candidate and opens the application form with the member's Topcoder profile. Candidate lookup failures still block prefill/submission and expose a retry. @@ -48,6 +49,8 @@ Candidate Terms and the Equal Employment Opportunity Policy load on demand from the existing Payload compatibility endpoint using the original modal IDs. Descriptions and policy bodies are sanitized before display. Styling, scripts, unsafe URLs and embedded form controls cannot affect the surrounding application. +Policy dialogs size to their content, center their titles and provide both the +standard dismiss icon and a visible Close action. Search, country, sort and page are URL parameters. Updating filters preserves unrelated parameters such as `ref`, and resets the result page. The selected @@ -82,8 +85,9 @@ yarn test:no-watch --runInBand --watch=false --runTestsByPath \ src/apps/gigs/src/pages/GigApplyPage.spec.tsx ``` -The tests cover discovery rules, detail-route scroll restoration, salary fallbacks, required fields, consent and -availability, upload limits, legacy payload mapping, HTTP-200 error envelopes, +The tests cover discovery rules, detail-route scroll restoration, salary fallbacks, legacy validation copy, +required fields, consent and availability, upload limits, legacy payload mapping, Recruit assignment responses, +policy close actions, HTTP-200 error envelopes, expired authentication, empty candidate search responses, candidate lookup retry, prefill, submission retry and already-placed candidates. Also verify the listing, detail and anonymous apply route against real Recruit diff --git a/src/apps/gigs/src/components/GigApplicationForm.spec.tsx b/src/apps/gigs/src/components/GigApplicationForm.spec.tsx index 18644f779..b07bff720 100644 --- a/src/apps/gigs/src/components/GigApplicationForm.spec.tsx +++ b/src/apps/gigs/src/components/GigApplicationForm.spec.tsx @@ -42,7 +42,13 @@ jest.mock( {props.children} ), - BaseModal: (props: any) => (props.open ?
{props.children}
: undefined), + BaseModal: (props: any) => (props.open ? ( +
+
{props.title}
+
{props.children}
+ {props.buttons} +
+ ) : undefined), LoadingSpinner: () => Loading, }), { virtual: true }, @@ -170,4 +176,33 @@ describe('Gig application form', () => { .toBeNull() expect(applyToGig).not.toHaveBeenCalled() }) + it.each([ + ['Read Candidate Terms', 'Candidate Terms'], + ['View our Equal Employment Opportunity Policy', 'Equal Employment Opportunity Policy'], + ])('opens %s in a compact modal with a visible close action', (trigger, title) => { + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: trigger })) + const dialog = screen.getByRole('dialog') + expect(dialog.classList.contains('gigs-policy-modal')) + .toBe(true) + expect(within(dialog) + .getByText(title)) + .toBeTruthy() + expect(dialog.querySelector('.gigs-policy')) + .toBeTruthy() + fireEvent.click(within(dialog) + .getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog')) + .toBeNull() + }) }) diff --git a/src/apps/gigs/src/components/GigShared.tsx b/src/apps/gigs/src/components/GigShared.tsx index 56f4d436d..bfe6483b9 100644 --- a/src/apps/gigs/src/components/GigShared.tsx +++ b/src/apps/gigs/src/components/GigShared.tsx @@ -123,8 +123,20 @@ export const GigPolicy: FC<{ id?: string; title: string; close: () => void }> = { shouldRetryOnError: false }, ) return ( - -
+ + +
+ )} + classNames={{ modal: 'gigs-policy-modal' }} + open={!!props.id} + onClose={props.close} + title={props.title} + size='lg' + > +
{error ? ( { ) it('uses a refreshed token for multipart submission without a manual content-type boundary', async () => { const body = new FormData() - fetchMock.mockResolvedValue(response({ success: true })) + fetchMock.mockResolvedValue(response({ + candidate_slug: 'candidate-slug', + id: 123, + job_slug: 'gig-slug', + })) await applyToGig('gig-slug', body) expect(fetchMock) .toHaveBeenCalledWith( @@ -101,12 +105,22 @@ describe('Recruit API integration', () => { }), ) }) + it('also accepts Recruit\'s idempotent already-assigned success response', async () => { + fetchMock.mockResolvedValue(response({ success: true })) + await expect(applyToGig('gig-slug', new FormData())) + .resolves.toBeUndefined() + }) it('never treats an error, an empty result, or an expired session as a successful application', async () => { fetchMock.mockResolvedValue(response({ error: true, errorObj: { notAllowed: true } })) await expect(applyToGig('gig-slug', new FormData())).rejects.toThrow('already placed') fetchMock.mockResolvedValue(response({})) - await expect(applyToGig('gig-slug', new FormData())).rejects.toThrow('not confirmed'); - (tokenGetAsync as jest.Mock).mockResolvedValue({}) + await expect(applyToGig('gig-slug', new FormData())).rejects.toThrow('not confirmed') + fetchMock.mockResolvedValue(response({ success: false })) + await expect(applyToGig('gig-slug', new FormData())).rejects.toThrow('not confirmed') + fetchMock.mockResolvedValue(response(['unexpected'])) + await expect(applyToGig('gig-slug', new FormData())).rejects.toThrow('not confirmed') + const tokenGetMock = tokenGetAsync as jest.Mock + tokenGetMock.mockResolvedValue({}) fetchMock.mockClear() await expect(applyToGig('gig-slug', new FormData())).rejects.toMatchObject({ status: 401 }) expect(fetchMock).not.toHaveBeenCalled() diff --git a/src/apps/gigs/src/gigs.service.ts b/src/apps/gigs/src/gigs.service.ts index f6d8b5424..cbeb482e4 100644 --- a/src/apps/gigs/src/gigs.service.ts +++ b/src/apps/gigs/src/gigs.service.ts @@ -5,6 +5,14 @@ import { Candidate, Gig } from './models' const RECRUIT_URL = `${EnvironmentConfig.COMMUNITY_APP_URL}/api/recruit` +/** Returns whether Recruit supplied one of its documented successful application response shapes. */ +function isConfirmedApplication(result: unknown): boolean { + if (!result || typeof result !== 'object' || Array.isArray(result)) return false + const response = result as Record + return response.success === true + || (response.success === undefined && Object.keys(response).length > 0) +} + /** An API failure with an HTTP-equivalent status, including Recruit errors returned with HTTP 200. */ export class RecruitError extends Error { status: number @@ -74,14 +82,19 @@ export async function getCandidate(email: string): Promise { - const result = await recruitRequest<{ success?: boolean }>( + const result = await recruitRequest( `${RECRUIT_URL}/jobs/${encodeURIComponent(slug)}/apply`, true, body, ) - if (!result.success) throw new RecruitError('Your application was not confirmed. Please try again.', 502) + if (!isConfirmedApplication(result)) { + throw new RecruitError('Your application was not confirmed. Please try again.', 502) + } } /** Loads an authored candidate policy from the Payload compatibility endpoint; returns its Markdown body. */ diff --git a/src/apps/gigs/src/gigs.utils.spec.ts b/src/apps/gigs/src/gigs.utils.spec.ts index bf3495076..137e458c9 100644 --- a/src/apps/gigs/src/gigs.utils.spec.ts +++ b/src/apps/gigs/src/gigs.utils.spec.ts @@ -130,6 +130,20 @@ describe('Gigs discovery and application contracts', () => { 'The maximum file size is 8 MB.', ) }) + it('matches the legacy required, minimum and maximum copy for phone and city', () => { + expect(validateApplication({ ...valid, city: '', phone: '' })) + .toEqual(expect.objectContaining({ city: 'Required field', phone: 'Required field' })) + expect(validateApplication({ ...valid, city: 'H', phone: '1' })) + .toEqual(expect.objectContaining({ + city: 'Must be at least 2 characters', + phone: 'Must be at least 2 characters', + })) + expect(validateApplication({ ...valid, city: 'c'.repeat(51), phone: '1'.repeat(51) })) + .toEqual(expect.objectContaining({ + city: 'Must be max 50 characters', + phone: 'Must be max 50 characters', + })) + }) it('reuses a saved resume but requires one if the existing candidate has no resume', () => { expect( validateApplication( diff --git a/src/apps/gigs/src/gigs.utils.ts b/src/apps/gigs/src/gigs.utils.ts index fbc2b07d9..9f501c7b0 100644 --- a/src/apps/gigs/src/gigs.utils.ts +++ b/src/apps/gigs/src/gigs.utils.ts @@ -74,17 +74,24 @@ export function filterGigs(jobs: Gig[], search: string, location: string, sort: }) } -/** Validates application values against the legacy contract and the server's 8,000,000-byte upload limit. */ +/** + * Validates application values against the legacy contract, including its field-specific validation copy, + * and the server's 8,000,000-byte upload limit. + */ export function validateApplication(values: ApplicationValues, candidate?: Candidate): ApplicationErrors { const errors: ApplicationErrors = {} const fields = ['firstName', 'lastName', 'email', 'city', 'phone'] as const fields.forEach(field => { const value = values[field].trim() const max = ['city', 'phone'].includes(field) ? 50 : 40 - if (value.length < 2) errors[field] = 'Enter at least 2 characters.' - else if (value.length > max) errors[field] = `Enter no more than ${max} characters.` + if (!value) errors[field] = 'Required field' + else if (value.length < 2) errors[field] = 'Must be at least 2 characters' + else if (value.length > max) errors[field] = `Must be max ${max} characters` }) - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email.trim())) errors.email = 'Enter a valid email address.' + if (values.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email.trim())) { + errors.email = 'Enter a valid email address.' + } + if (!values.country) errors.country = 'Select your country.' if (!/^\d+$/.test(values.pay.trim())) { errors.pay = 'Enter your weekly pay expectation as a whole dollar amount.' diff --git a/src/apps/gigs/src/pages/GigsPage.spec.tsx b/src/apps/gigs/src/pages/GigsPage.spec.tsx index 101c438ed..3b31842b1 100644 --- a/src/apps/gigs/src/pages/GigsPage.spec.tsx +++ b/src/apps/gigs/src/pages/GigsPage.spec.tsx @@ -81,7 +81,7 @@ describe('GigsPage listing presentation', () => { mockUseSWR.mockReturnValue({ data: [], error: undefined, mutate: jest.fn() }) }) - it('uses the scoped 2026 focus treatment on the gig search field', () => { + it('uses the scoped 2026 focus treatment on every Gig listing filter control', () => { render( @@ -89,7 +89,11 @@ describe('GigsPage listing presentation', () => { ) expect(screen.getByRole('searchbox', { name: 'Search' })) - .toHaveClass('gigs-filter-input') + .toHaveClass('gigs-filter-control') + expect(screen.getByRole('combobox', { name: 'Location' })) + .toHaveClass('gigs-filter-control') + expect(screen.getByRole('combobox', { name: 'Sort by' })) + .toHaveClass('gigs-filter-control') }) it('opens the Gig Work resources in a separate tab without an opener', () => { diff --git a/src/apps/gigs/src/pages/GigsPage.tsx b/src/apps/gigs/src/pages/GigsPage.tsx index 3c08cd506..19583069b 100644 --- a/src/apps/gigs/src/pages/GigsPage.tsx +++ b/src/apps/gigs/src/pages/GigsPage.tsx @@ -75,7 +75,7 @@ const GigsPage: FC = () => {

Find a gig

{ /> updateFilter('sort', event.target.value)} diff --git a/src/apps/gigs/src/styles/index.scss b/src/apps/gigs/src/styles/index.scss index 565002e5c..6d891a4f4 100644 --- a/src/apps/gigs/src/styles/index.scss +++ b/src/apps/gigs/src/styles/index.scss @@ -24,8 +24,8 @@ .gigs-filters h2 { margin: 0; } .gigs-filters label { font-weight: 700; margin-top: 8px; } input:not([type='checkbox'], [type='radio']), select { width: 100%; min-height: 44px; padding: 10px 12px; background: $tc-2026-surface; color: $tc-2026-body; border: 1px solid $tc-2026-border-strong; font-size: 14px; } - .gigs-filter-input:focus { border-color: $tc-2026-teal; box-shadow: 0 0 0 1px $tc-2026-teal; } - .gigs-filter-input:focus-visible { outline: 2px solid $tc-2026-teal; outline-offset: 2px; } + .gigs-filter-control:focus { border-color: $tc-2026-teal; box-shadow: 0 0 0 1px $tc-2026-teal; } + .gigs-filter-control:focus-visible { outline: 2px solid $tc-2026-teal; outline-offset: 2px; } input[readonly] { background: $tc-2026-canvas; } .gigs-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; } .gigs-toolbar h2 { margin: 0; font-size: 18px; } @@ -102,6 +102,8 @@ .gigs-select__control--is-focused { border-color: $tc-2026-teal; box-shadow: 0 0 0 1px $tc-2026-teal; } .gigs-select__multi-value { background: $tc-2026-canvas; } .gigs-select__option--is-focused { background: $tc-2026-canvas; } + &.gigs-policy { min-height: 0; padding-bottom: 0; } + &.gigs-policy .gigs-content { margin-bottom: 0; } @media (max-width: 960px) { .gigs-container { width: calc(100% - 40px); } @@ -125,7 +127,22 @@ } @media (forced-colors: active) { - .gigs-filter-input:focus { border-color: Highlight; box-shadow: none; } - .gigs-filter-input:focus-visible { outline-color: Highlight; } + .gigs-filter-control:focus { border-color: Highlight; box-shadow: none; } + .gigs-filter-control:focus-visible { outline-color: Highlight; } } } + +.gigs-policy-modal h3 { + padding: 0 48px; + text-align: center; +} + +.gigs-policy-modal-body { + flex: 0 1 auto; +} + +.gigs-policy-actions { + display: flex; + justify-content: center; + width: 100%; +} From fcaf6deeff56f9a23aec5779f80a622a5b8e26bc Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 9 Sep 2026 17:24:08 +1000 Subject: [PATCH 2/7] fix(PM-6250 PM-6268 PM-6279): address QA follow-ups --- src/apps/opportunities/README.md | 14 ++++- .../ReviewOpportunityDetailsPage.module.scss | 10 +++- .../ReviewOpportunityDetailsPage.spec.tsx | 46 +++++++++++++++- .../pages/ReviewOpportunityDetailsPage.tsx | 17 +++--- .../services/opportunities.service.spec.ts | 55 ++++++++++++++++++- .../src/services/opportunities.service.ts | 19 +++++++ .../utils/review-opportunity.utils.spec.ts | 24 ++++++++ .../src/utils/review-opportunity.utils.ts | 19 +++++++ 8 files changed, 188 insertions(+), 16 deletions(-) diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index 65d5451cd..b71090491 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -53,6 +53,13 @@ against challenge names, authored tags, and standardized skills before its server-side pagination, so selecting a chip returns every matching review opportunity rather than filtering only the currently loaded page. +Copilot card skills retain that same shareable `search` route and visible +sidebar value. While the deployed Projects API rejects its JSON-backed +`search` and `skills` queries with an HTTP 500, the client uses the existing +bounded compatibility loader and filters the complete supported result window +locally. This avoids issuing the known-broken filtered request and keeps skill +selection functional until that owner query is repaired. + ## List and grid views Every domain toolbar exposes the same accessible List/Grid selector from the @@ -98,8 +105,11 @@ than presented as free work. Approved applications, rather than pending applications, consume reviewer capacity. When `remainingPositions` reaches zero, eligible reviewers can still -use the detail CTA to join the waitlist; the page explains that outcome before -submission and confirms it afterward. Review API persists these applications as +use the “Apply to be a reviewer (waitlist)” detail CTA; the page explains that +outcome before submission and confirms that Support may contact the applicant +if another reviewer cannot complete the review. The compatibility UI also +accepts the older capacity-only `NO_OPEN_POSITIONS` response while preserving +all other API rejection reasons. Review API persists these applications as `PENDING`. Browse and My Work cards render that caller state as `Waitlisted` while capacity remains full, then naturally return to `Applied` if a position reopens or to `Approved` when the reviewer is selected. diff --git a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.module.scss b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.module.scss index bc03a603d..761a90a9a 100644 --- a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.module.scss +++ b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.module.scss @@ -751,9 +751,14 @@ align-items: center; display: flex; gap: 8px; + min-width: 0; + overflow: hidden; + width: 100%; > a { + display: block; min-width: 0; + overflow: hidden; text-decoration: none; } @@ -775,6 +780,7 @@ strong { color: #007d79; + display: block; font-weight: 700; overflow: hidden; text-overflow: ellipsis; @@ -1007,9 +1013,11 @@ overflow: visible; } - .applicationTable { + .tableScroll .applicationTable { + box-sizing: border-box; display: block; min-width: 0; + width: 100%; thead { border: 0; diff --git a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.spec.tsx b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.spec.tsx index 185061f43..3d6791274 100644 --- a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.spec.tsx +++ b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.spec.tsx @@ -276,7 +276,11 @@ describe('ReviewOpportunityDetailsPage', () => { expect(reviewDetailStyles) .toContain('.applicationTable {') expect(reviewDetailStyles) - .toMatch(/\.applicationTable\s*\{[\s\S]*?thead\s*\{[\s\S]*?button\s*\{\s*display: none;/) + .toMatch(/\.tableScroll \.applicationTable\s*\{[\s\S]*?min-width: 0;[\s\S]*?width: 100%;/) + expect(reviewDetailStyles) + .toMatch(/\.tableScroll \.applicationTable\s*\{[\s\S]*?thead\s*\{[\s\S]*?button\s*\{\s*display: none;/) + expect(reviewDetailStyles) + .toMatch(/\.member\s*\{[\s\S]*?min-width: 0;[\s\S]*?overflow: hidden;[\s\S]*?width: 100%;/) }) it('uses member ratings to color application handles', () => { @@ -385,7 +389,7 @@ describe('ReviewOpportunityDetailsPage', () => { expect(screen.getByText(/All reviewer positions are currently filled/)) .toBeInTheDocument() - const waitlistButton = screen.getByRole('button', { name: 'Join reviewer waitlist' }) + const waitlistButton = screen.getByRole('button', { name: 'Apply to be a reviewer (waitlist)' }) expect(waitlistButton) .toBeEnabled() fireEvent.click(waitlistButton) @@ -396,7 +400,43 @@ describe('ReviewOpportunityDetailsPage', () => { expect(mutate) .toHaveBeenCalled() expect(mockedToastSuccess) - .toHaveBeenCalledWith("You've joined the reviewer waitlist.") + .toHaveBeenCalledWith( + 'You are waitlisted. Support may contact you if another reviewer cannot complete the review and ' + + 'you are needed.', + ) + }) + }) + + it('accepts a waitlist application from the legacy capacity-only eligibility response', async () => { + const mutate = jest.fn() + mockProfile = { roles: ['Reviewer'], userId: 12345 } + mockUseSWR.mockReturnValue({ + data: reviewFixture({ + approvedApplicationCount: 2, + canApply: false, + canApplyReason: 'NO_OPEN_POSITIONS', + openPositions: 2, + remainingPositions: 0, + }), + error: undefined, + isValidating: false, + mutate, + }) + + renderPage() + + const waitlistButton = screen.getByRole('button', { + name: 'Apply to be a reviewer (waitlist)', + }) + expect(waitlistButton) + .toBeEnabled() + fireEvent.click(waitlistButton) + + await waitFor(() => { + expect(mockedApplyToReviewOpportunity) + .toHaveBeenCalledWith('review-id', 'REVIEWER') + expect(mutate) + .toHaveBeenCalled() }) }) diff --git a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx index 2e1ea455f..375fa1f87 100644 --- a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx +++ b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx @@ -38,6 +38,7 @@ import { CHALLENGE_EXPLAINED_URL, memberProfileUrl, reviewFirstSubmissionPayment, + reviewOpportunityCanApply, reviewOpportunityIsFull, reviewOpportunityIsWaitlisted, reviewOpportunityLabels, @@ -256,7 +257,7 @@ export const ReviewOpportunityDetailsPage: FC = () => { return } - if (!opportunity?.canApply) return + if (!opportunity || !reviewOpportunityCanApply(opportunity)) return const role = applicationRole || opportunity.defaultApplicationRole || opportunity.applicationRoles?.[0] @@ -267,7 +268,8 @@ export const ReviewOpportunityDetailsPage: FC = () => { await applyToReviewOpportunity(opportunity.id, role) await response.mutate() toast.success(joinsWaitlist - ? "You've joined the reviewer waitlist." + ? 'You are waitlisted. Support may contact you if another reviewer cannot complete the review and ' + + 'you are needed.' : 'Your reviewer application was submitted.') } catch (error) { toast.error(error instanceof Error ? error.message : 'Application failed.') @@ -302,8 +304,9 @@ export const ReviewOpportunityDetailsPage: FC = () => { ) const applications = opportunity.applications?.filter(application => application.status !== 'CANCELLED') ?? [] const applicationTotal = applications.length + const canApply = reviewOpportunityCanApply(opportunity) const isWaitlisted = reviewOpportunityIsWaitlisted(opportunity) - const willJoinWaitlist = opportunity.canApply && reviewOpportunityIsFull(opportunity) + const willJoinWaitlist = canApply && reviewOpportunityIsFull(opportunity) const disabledLabel = !isReviewer ? 'Apply to be a reviewer' : isWaitlisted @@ -458,17 +461,17 @@ export const ReviewOpportunityDetailsPage: FC = () => {

)} diff --git a/src/apps/opportunities/src/services/opportunities.service.spec.ts b/src/apps/opportunities/src/services/opportunities.service.spec.ts index c0fadf6dd..7b115e6dc 100644 --- a/src/apps/opportunities/src/services/opportunities.service.spec.ts +++ b/src/apps/opportunities/src/services/opportunities.service.spec.ts @@ -607,7 +607,7 @@ describe('opportunities service normalization', () => { .toEqual([['1', '200'], ['2', '200']]) }) - it('falls back to locally filtered legacy Copilot results during API rollout', async () => { + it('falls back to locally filtered legacy Copilot facets during API rollout', async () => { const globalGet = xhrGlobalInstance.get as jest.MockedFunction globalGet .mockRejectedValueOnce({ @@ -638,7 +638,7 @@ describe('opportunities service normalization', () => { opportunityTitle: 'Backend migration', skills: [{ id: 'java', name: 'Java' }], status: 'active', - type: 'dev', + type: 'design', }, ], headers: { @@ -654,7 +654,6 @@ describe('opportunities service normalization', () => { await expect(getOpportunityPage('copilots', { page: 1, perPage: 10, - search: 'typescript', sort: 'newest', statuses: ['active'], tracks: ['dev'], @@ -680,6 +679,56 @@ describe('opportunities service normalization', () => { .toBe(false) }) + it('uses bounded local Copilot discovery before a broken server-side skill search', async () => { + const globalGet = xhrGlobalInstance.get as jest.MockedFunction + globalGet.mockResolvedValueOnce({ + data: [ + { + id: 'matching', + opportunityTitle: 'Matching copilot role', + skills: [{ id: 'cadence-skill', name: 'Cadence SKILL' }], + status: 'active', + }, + { + id: 'different', + opportunityTitle: 'Different copilot role', + skills: [{ id: 'react', name: 'React' }], + status: 'active', + }, + ], + headers: { + get: (name: string) => ({ + 'x-page': '1', + 'x-per-page': '200', + 'x-total': '2', + 'x-total-pages': '1', + } as Record)[name], + }, + }) + + await expect(getOpportunityPage('copilots', { + page: 1, + perPage: 10, + search: 'Cadence SKILL', + sort: 'newest', + statuses: ['active'], + })) + .resolves.toMatchObject({ + items: [expect.objectContaining({ id: 'matching' })], + total: 1, + }) + + expect(globalGet) + .toHaveBeenCalledTimes(1) + const requestUrl = new URL(String(globalGet.mock.calls[0][0])) + expect(requestUrl.searchParams.get('pageSize')) + .toBe('200') + expect(requestUrl.searchParams.has('search')) + .toBe(false) + expect(requestUrl.searchParams.has('skills')) + .toBe(false) + }) + it('sorts legacy Copilot results by start date without sending an unsupported sort', async () => { const globalGet = xhrGlobalInstance.get as jest.MockedFunction globalGet diff --git a/src/apps/opportunities/src/services/opportunities.service.ts b/src/apps/opportunities/src/services/opportunities.service.ts index 3db98b28d..c7999b763 100644 --- a/src/apps/opportunities/src/services/opportunities.service.ts +++ b/src/apps/opportunities/src/services/opportunities.service.ts @@ -777,6 +777,21 @@ function sortLegacyCopilotOpportunities( }) } +/** + * Determines whether Copilot discovery must use the bounded compatibility + * loader. The deployed Projects API currently returns HTTP 500 when either + * free-text or exact-skill discovery reaches its JSON skill query; retrieving + * its supported unfiltered pages first avoids a failed browser request while + * retaining the same shareable search behavior. + * + * @param filters active Copilot search and facet values. + * @returns true when text or skill matching must be applied locally. + * @throws Does not throw. + */ +function requiresLegacyCopilotDiscovery(filters: OpportunityFilters): boolean { + return !!filters.search?.trim() || !!filters.skills?.length +} + /** * Identifies semantic sorts that an owner API cannot apply to the complete result set. * @@ -1177,6 +1192,10 @@ export async function getOpportunityPage( ): Promise> { const page = Math.max(1, filters.page) const perPage = Math.max(1, filters.perPage) + if (kind === 'copilots' && requiresLegacyCopilotDiscovery(filters)) { + return getLegacyCopilotPage(filters) + } + if (kind === 'reviews' && filters.tracks?.some(track => opportunityFacetKey(track) === 'ai')) { const reviewPage = await getReviewPageWithAiTrack(filters) return hydrateReviewOpportunitySkills(reviewPage) diff --git a/src/apps/opportunities/src/utils/review-opportunity.utils.spec.ts b/src/apps/opportunities/src/utils/review-opportunity.utils.spec.ts index 3050d9da7..82aa19753 100644 --- a/src/apps/opportunities/src/utils/review-opportunity.utils.spec.ts +++ b/src/apps/opportunities/src/utils/review-opportunity.utils.spec.ts @@ -2,6 +2,7 @@ import { ReviewOpportunity } from '../models' import { reviewFirstSubmissionPayment, + reviewOpportunityCanApply, reviewOpportunityIsFull, reviewOpportunityIsWaitlisted, reviewOpportunityLabels, @@ -76,6 +77,29 @@ describe('review opportunity waitlist state', () => { .toBe(true) }) + it('allows only capacity-rejected legacy responses to join the waitlist', () => { + const fullOpportunity: ReviewOpportunity = { + canApply: false, + canApplyReason: 'NO_OPEN_POSITIONS', + challengeId: 'challenge-id', + id: 'review-id', + remainingPositions: 0, + } + + expect(reviewOpportunityCanApply(fullOpportunity)) + .toBe(true) + expect(reviewOpportunityCanApply({ + ...fullOpportunity, + canApplyReason: 'OPPORTUNITY_CLOSED', + })) + .toBe(false) + expect(reviewOpportunityCanApply({ + ...fullOpportunity, + canApplyReason: 'ALREADY_APPLIED', + })) + .toBe(false) + }) + it('labels only a pending caller application as waitlisted while capacity is full', () => { const opportunity: ReviewOpportunity = { challengeId: 'challenge-id', diff --git a/src/apps/opportunities/src/utils/review-opportunity.utils.ts b/src/apps/opportunities/src/utils/review-opportunity.utils.ts index 644714192..3af058279 100644 --- a/src/apps/opportunities/src/utils/review-opportunity.utils.ts +++ b/src/apps/opportunities/src/utils/review-opportunity.utils.ts @@ -86,6 +86,25 @@ export function reviewOpportunityIsFull(opportunity: ReviewOpportunity): boolean return opportunity.canApplyReason === 'NO_OPEN_POSITIONS' } +/** + * Resolves whether the current reviewer may submit an application, including + * the temporary pre-waitlist Review API response used by older deployments. + * A full opportunity is compatible only when the API's sole rejection reason + * is capacity; lifecycle, role, authentication, and duplicate rejections stay + * authoritative. + * + * @param opportunity review opportunity eligibility and capacity response. + * @returns true when a normal or waitlist application may be submitted. + * @throws Does not throw. + */ +export function reviewOpportunityCanApply(opportunity: ReviewOpportunity): boolean { + return opportunity.canApply === true + || ( + opportunity.canApplyReason === 'NO_OPEN_POSITIONS' + && reviewOpportunityIsFull(opportunity) + ) +} + /** * Resolves whether the caller's pending review application is currently on the * waitlist. A future explicit WAITLISTED API status is also accepted without From 7016979153c857f65140644a998366ee772e9324 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 9 Sep 2026 17:46:14 +1000 Subject: [PATCH 3/7] fix(PM-6250 PM-6275): align clarified QA copy --- .../components/GigApplicationForm.spec.tsx | 2 +- .../src/components/GigApplicationForm.tsx | 2 +- src/apps/opportunities/README.md | 6 ++-- .../ReviewOpportunityDetailsPage.spec.tsx | 35 +------------------ .../pages/ReviewOpportunityDetailsPage.tsx | 7 ++-- .../utils/review-opportunity.utils.spec.ts | 24 ------------- .../src/utils/review-opportunity.utils.ts | 19 ---------- 7 files changed, 8 insertions(+), 87 deletions(-) diff --git a/src/apps/gigs/src/components/GigApplicationForm.spec.tsx b/src/apps/gigs/src/components/GigApplicationForm.spec.tsx index b07bff720..96ea43571 100644 --- a/src/apps/gigs/src/components/GigApplicationForm.spec.tsx +++ b/src/apps/gigs/src/components/GigApplicationForm.spec.tsx @@ -177,7 +177,7 @@ describe('Gig application form', () => { expect(applyToGig).not.toHaveBeenCalled() }) it.each([ - ['Read Candidate Terms', 'Candidate Terms'], + ['Read Candidate Terms', 'CANDIDATE TERMS'], ['View our Equal Employment Opportunity Policy', 'Equal Employment Opportunity Policy'], ])('opens %s in a compact modal with a visible close action', (trigger, title) => { render( diff --git a/src/apps/gigs/src/components/GigApplicationForm.tsx b/src/apps/gigs/src/components/GigApplicationForm.tsx index 899153f7f..4500376de 100644 --- a/src/apps/gigs/src/components/GigApplicationForm.tsx +++ b/src/apps/gigs/src/components/GigApplicationForm.tsx @@ -430,7 +430,7 @@ const GigApplicationForm: FC<{ job: Gig; slug: string; profile: UserProfile; can : 'VAeo0vZ5tQFjPZlIcdt0m' : undefined } - title={policy === 'terms' ? 'Candidate Terms' : 'Equal Employment Opportunity Policy'} + title={policy === 'terms' ? 'CANDIDATE TERMS' : 'Equal Employment Opportunity Policy'} close={() => setPolicy(undefined)} /> diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index b71090491..c085537c2 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -106,10 +106,8 @@ than presented as free work. Approved applications, rather than pending applications, consume reviewer capacity. When `remainingPositions` reaches zero, eligible reviewers can still use the “Apply to be a reviewer (waitlist)” detail CTA; the page explains that -outcome before submission and confirms that Support may contact the applicant -if another reviewer cannot complete the review. The compatibility UI also -accepts the older capacity-only `NO_OPEN_POSITIONS` response while preserving -all other API rejection reasons. Review API persists these applications as +outcome before submission and confirms that Support will contact the applicant +if another reviewer cannot complete the review. Review API persists these applications as `PENDING`. Browse and My Work cards render that caller state as `Waitlisted` while capacity remains full, then naturally return to `Applied` if a position reopens or to `Approved` when the reviewer is selected. diff --git a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.spec.tsx b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.spec.tsx index 3d6791274..1b69a852c 100644 --- a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.spec.tsx +++ b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.spec.tsx @@ -401,45 +401,12 @@ describe('ReviewOpportunityDetailsPage', () => { .toHaveBeenCalled() expect(mockedToastSuccess) .toHaveBeenCalledWith( - 'You are waitlisted. Support may contact you if another reviewer cannot complete the review and ' + 'You are waitlisted. Support will contact you if another reviewer cannot complete the review and ' + 'you are needed.', ) }) }) - it('accepts a waitlist application from the legacy capacity-only eligibility response', async () => { - const mutate = jest.fn() - mockProfile = { roles: ['Reviewer'], userId: 12345 } - mockUseSWR.mockReturnValue({ - data: reviewFixture({ - approvedApplicationCount: 2, - canApply: false, - canApplyReason: 'NO_OPEN_POSITIONS', - openPositions: 2, - remainingPositions: 0, - }), - error: undefined, - isValidating: false, - mutate, - }) - - renderPage() - - const waitlistButton = screen.getByRole('button', { - name: 'Apply to be a reviewer (waitlist)', - }) - expect(waitlistButton) - .toBeEnabled() - fireEvent.click(waitlistButton) - - await waitFor(() => { - expect(mockedApplyToReviewOpportunity) - .toHaveBeenCalledWith('review-id', 'REVIEWER') - expect(mutate) - .toHaveBeenCalled() - }) - }) - it('shows the caller waitlisted state after a full-opportunity application', () => { mockProfile = { roles: ['Reviewer'], userId: 12345 } mockUseSWR.mockReturnValue({ diff --git a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx index 375fa1f87..e5b2d9c9d 100644 --- a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx +++ b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx @@ -38,7 +38,6 @@ import { CHALLENGE_EXPLAINED_URL, memberProfileUrl, reviewFirstSubmissionPayment, - reviewOpportunityCanApply, reviewOpportunityIsFull, reviewOpportunityIsWaitlisted, reviewOpportunityLabels, @@ -257,7 +256,7 @@ export const ReviewOpportunityDetailsPage: FC = () => { return } - if (!opportunity || !reviewOpportunityCanApply(opportunity)) return + if (!opportunity?.canApply) return const role = applicationRole || opportunity.defaultApplicationRole || opportunity.applicationRoles?.[0] @@ -268,7 +267,7 @@ export const ReviewOpportunityDetailsPage: FC = () => { await applyToReviewOpportunity(opportunity.id, role) await response.mutate() toast.success(joinsWaitlist - ? 'You are waitlisted. Support may contact you if another reviewer cannot complete the review and ' + ? 'You are waitlisted. Support will contact you if another reviewer cannot complete the review and ' + 'you are needed.' : 'Your reviewer application was submitted.') } catch (error) { @@ -304,7 +303,7 @@ export const ReviewOpportunityDetailsPage: FC = () => { ) const applications = opportunity.applications?.filter(application => application.status !== 'CANCELLED') ?? [] const applicationTotal = applications.length - const canApply = reviewOpportunityCanApply(opportunity) + const canApply = opportunity.canApply === true const isWaitlisted = reviewOpportunityIsWaitlisted(opportunity) const willJoinWaitlist = canApply && reviewOpportunityIsFull(opportunity) const disabledLabel = !isReviewer diff --git a/src/apps/opportunities/src/utils/review-opportunity.utils.spec.ts b/src/apps/opportunities/src/utils/review-opportunity.utils.spec.ts index 82aa19753..3050d9da7 100644 --- a/src/apps/opportunities/src/utils/review-opportunity.utils.spec.ts +++ b/src/apps/opportunities/src/utils/review-opportunity.utils.spec.ts @@ -2,7 +2,6 @@ import { ReviewOpportunity } from '../models' import { reviewFirstSubmissionPayment, - reviewOpportunityCanApply, reviewOpportunityIsFull, reviewOpportunityIsWaitlisted, reviewOpportunityLabels, @@ -77,29 +76,6 @@ describe('review opportunity waitlist state', () => { .toBe(true) }) - it('allows only capacity-rejected legacy responses to join the waitlist', () => { - const fullOpportunity: ReviewOpportunity = { - canApply: false, - canApplyReason: 'NO_OPEN_POSITIONS', - challengeId: 'challenge-id', - id: 'review-id', - remainingPositions: 0, - } - - expect(reviewOpportunityCanApply(fullOpportunity)) - .toBe(true) - expect(reviewOpportunityCanApply({ - ...fullOpportunity, - canApplyReason: 'OPPORTUNITY_CLOSED', - })) - .toBe(false) - expect(reviewOpportunityCanApply({ - ...fullOpportunity, - canApplyReason: 'ALREADY_APPLIED', - })) - .toBe(false) - }) - it('labels only a pending caller application as waitlisted while capacity is full', () => { const opportunity: ReviewOpportunity = { challengeId: 'challenge-id', diff --git a/src/apps/opportunities/src/utils/review-opportunity.utils.ts b/src/apps/opportunities/src/utils/review-opportunity.utils.ts index 3af058279..644714192 100644 --- a/src/apps/opportunities/src/utils/review-opportunity.utils.ts +++ b/src/apps/opportunities/src/utils/review-opportunity.utils.ts @@ -86,25 +86,6 @@ export function reviewOpportunityIsFull(opportunity: ReviewOpportunity): boolean return opportunity.canApplyReason === 'NO_OPEN_POSITIONS' } -/** - * Resolves whether the current reviewer may submit an application, including - * the temporary pre-waitlist Review API response used by older deployments. - * A full opportunity is compatible only when the API's sole rejection reason - * is capacity; lifecycle, role, authentication, and duplicate rejections stay - * authoritative. - * - * @param opportunity review opportunity eligibility and capacity response. - * @returns true when a normal or waitlist application may be submitted. - * @throws Does not throw. - */ -export function reviewOpportunityCanApply(opportunity: ReviewOpportunity): boolean { - return opportunity.canApply === true - || ( - opportunity.canApplyReason === 'NO_OPEN_POSITIONS' - && reviewOpportunityIsFull(opportunity) - ) -} - /** * Resolves whether the caller's pending review application is currently on the * waitlist. A future explicit WAITLISTED API status is also accepted without From 5aafd8666346f11ebd36e6df4fa60cbbcb94a416 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 9 Sep 2026 17:44:39 +1000 Subject: [PATCH 4/7] Fix PM-6160 PM-6190 PM-6271 PM-6273 PM-6277 PM-6278 PM-6280 --- src/apps/opportunities/README.md | 45 +++--- .../ChallengeDetailHeader.module.scss | 30 ++-- .../components/ChallengeDetailHeader.spec.tsx | 27 ++++ .../src/components/ChallengeDetailHeader.tsx | 135 +++++++++--------- .../OpportunityListCard.module.scss | 69 +++++++++ .../components/OpportunityListCard.spec.tsx | 50 +++++++ .../src/components/OpportunityListCard.tsx | 89 ++++++++++-- .../SubmissionHistoryModal.module.scss | 127 +++++++++++++++- .../SubmissionHistoryModal.spec.tsx | 63 ++++++++ .../src/components/SubmissionHistoryModal.tsx | 92 +++++++----- .../src/models/opportunity.models.ts | 1 + .../pages/ChallengeDetailsPage.flows.spec.tsx | 25 +++- .../services/opportunities.service.spec.ts | 86 ++++++++++- .../src/services/opportunities.service.ts | 84 +++++++++-- 14 files changed, 768 insertions(+), 155 deletions(-) diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index c085537c2..50bf2df7a 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -121,11 +121,12 @@ 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. +Competition list cards consume the Challenge API v6 list response directly. +Completed pages make one batched Members API projection request for the winner +IDs in that page; 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 @@ -142,6 +143,11 @@ subtype icons and member-facing labels. the authored yellow, light-blue, and peach placement assets at their native 14×18px size; the dark second- and third-place podium variants are reserved for the Winners presentation. +- Completed cards replace registration and stale phase-progress states with the + explicit Completed state. Up to three actual winner photos appear beside the + placement prizes with the existing podium medals; missing or failed photos + retain a handle-initial fallback. The complete avatar-and-medal affordance + opens that challenge's Winners tab. - `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. @@ -170,8 +176,9 @@ At phone widths, the timezone moves above a vertical timeline: phase nodes and progress connectors occupy the left rail while each phase name and its dates remain in an aligned, content-sized row to the right. Each mobile row owns its marker and connector, so wrapped dates and enlarged text grow the rail instead -of overlapping the following milestone. Wider layouts retain the horizontal -timeline and its overflow fallback for tablet-sized screens. +of overlapping the following milestone. The mobile prize/action card follows +the expanded timeline instead of interrupting it. Wider layouts retain the +horizontal timeline and its overflow fallback for tablet-sized screens. On phone viewports, Registrants preserves its semantic table while presenting each API row as the Figma key/value card. Registration Date remains a @@ -409,15 +416,21 @@ owns the Review App handoff. The Marathon Match My Submissions table reserves enough width for the complete submission timestamp and keeps its date heading and sort icon on one line, aligned with the dates beneath it. Score columns remain right aligned. -Submission history replaces the unreliable status field with Final Score and -uses a responsive table that scrolls only on narrow viewports. Design -submissions can be deleted only while Submission or Checkpoint Submission is -open. Successful deletion updates both the challenge and member submission -counts as well as the current list. Replacing a Design submission without -reloading therefore preserves accurate totals, and deleting the member's last -submission clears the submission-based Unregister restriction. Failed or -cancelled deletions leave the counts unchanged; Review API remains authoritative -for submission limits. +Submission history replaces the unreliable status field with Final Score. At +phone widths, each attempt becomes a compact stacked label/value card in the +legacy Submission, Final Score, Provisional Score, and Time order, avoiding +horizontal clipping. The dialog also exposes the latest-submission summary and +compact close action only at that breakpoint. History requests include the +selected member ID; +Review API returns every attempt to that member and authorized challenge staff, +while ordinary viewers receive only the selected entrant's latest attempt. +Design submissions can be deleted only while Submission or Checkpoint +Submission is open. Successful deletion updates both the challenge and member +submission counts as well as the current list. Replacing a Design submission +without reloading therefore preserves accurate totals, and deleting the +member's last submission clears the submission-based Unregister restriction. +Failed or cancelled deletions leave the counts unchanged; Review API remains +authoritative for submission limits. Challenge Discussion reads and writes use the authenticated `/v6/forums` API. Topic creation, comments and nested replies, owner edits, diff --git a/src/apps/opportunities/src/components/ChallengeDetailHeader.module.scss b/src/apps/opportunities/src/components/ChallengeDetailHeader.module.scss index 199891307..4fcf7d66d 100644 --- a/src/apps/opportunities/src/components/ChallengeDetailHeader.module.scss +++ b/src/apps/opportunities/src/components/ChallengeDetailHeader.module.scss @@ -292,14 +292,16 @@ display: flex; flex-direction: column; gap: 4px; +} - small { - align-self: flex-start; - color: rgba(255, 255, 255, .6); - font-size: 12px; - font-weight: 700; - line-height: 16px; - } +.prizeTitle { + align-self: center; + color: rgba(255, 255, 255, .6); + font-size: 12px; + font-weight: 700; + line-height: 16px; + text-align: center; + width: 100%; } .prizes { @@ -417,10 +419,12 @@ display: flex; flex-direction: column; gap: 16px; - margin: 24px auto 0; - max-width: 1200px; + grid-column: 1 / -1; + margin: 0; + max-width: none; padding-top: 24px; position: relative; + width: 100%; z-index: 1; } @@ -612,6 +616,14 @@ line-height: 40px; } + .expandedTimeline { + order: 2; + } + + .actionCard { + order: 3; + } + .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 index 8d2131497..f8b20fcaf 100644 --- a/src/apps/opportunities/src/components/ChallengeDetailHeader.spec.tsx +++ b/src/apps/opportunities/src/components/ChallengeDetailHeader.spec.tsx @@ -93,6 +93,27 @@ describe('ChallengeDetailHeader actions and presentation', () => { .not.toBeInTheDocument() }) + it.each(['Design', 'Development', 'Data Science', 'Quality Assurance', 'AI'])( + 'uses the centered Prizes title treatment for the %s track', + track => { + render( + + + , + ) + + expect(screen.getByText('Prizes')) + .toHaveClass('prizeTitle') + }, + ) + it('shows only Register for an unregistered open challenge', () => { render( @@ -453,6 +474,12 @@ describe('ChallengeDetailHeader actions and presentation', () => { fireEvent.click(screen.getByRole('button', { name: 'Show full timeline' })) const timeline = screen.getByRole('region', { name: 'Challenge timeline' }) + const prizeCard = screen.getByText('Prizes') + .closest('aside') + expect(prizeCard) + .toHaveClass('actionCard') + expect(timeline.parentElement) + .toBe(prizeCard?.parentElement) const items = within(timeline) .getAllByRole('listitem') expect(items) diff --git a/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx b/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx index a5862cba6..5b405b48b 100644 --- a/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx +++ b/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx @@ -570,7 +570,7 @@ export const ChallengeDetailHeader: FC = props => {
- - {timelineOpen && ( -
- - {`Time zone: ${timelineTimezone()}`} - -
- -
    - {expandedTimeline.map((item, index) => ( -
  1. -
  2. - ))} -
-
-
- )} + + + + + ))} + +
    + {expandedTimeline.map((item, index) => ( +
  1. +
  2. + ))} +
+ + + )} + ) diff --git a/src/apps/opportunities/src/components/OpportunityListCard.module.scss b/src/apps/opportunities/src/components/OpportunityListCard.module.scss index b0c60e31f..d02dff229 100644 --- a/src/apps/opportunities/src/components/OpportunityListCard.module.scss +++ b/src/apps/opportunities/src/components/OpportunityListCard.module.scss @@ -440,6 +440,75 @@ width: 28px; } +.winnersLink { + align-items: flex-start; + display: flex; + gap: 8px; + height: 45px; + position: relative; + text-decoration: none; + z-index: 2; + + &:focus-visible { + border-radius: 4px; + outline: 2px solid #0f62fe; + outline-offset: 3px; + } +} + +.winnerAvatar { + display: grid; + flex: 0 0 32px; + grid-template-columns: 32px; + grid-template-rows: 32px 13px; + height: 45px; + position: relative; + width: 32px; +} + +.winnerPhoto { + align-items: center; + background: #e9ecef; + border: 1px solid #a8a8a8; + border-radius: 50%; + box-sizing: border-box; + color: #161616; + display: flex; + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 700; + grid-column: 1; + grid-row: 1; + height: 32px; + justify-content: center; + overflow: hidden; + width: 32px; + + img { + height: 100%; + object-fit: cover; + width: 100%; + } +} + +.winnerMedal { + align-items: center; + display: flex; + grid-column: 1; + grid-row: 1 / span 2; + height: 20px; + justify-content: center; + margin-left: 6px; + margin-top: 22px; + width: 20px; + + svg { + display: block; + height: 20px; + width: 20px; + } +} + .prizeUnavailable { color: #6f6f6f; font-size: 12px; diff --git a/src/apps/opportunities/src/components/OpportunityListCard.spec.tsx b/src/apps/opportunities/src/components/OpportunityListCard.spec.tsx index 82ce0951a..4a91d3512 100644 --- a/src/apps/opportunities/src/components/OpportunityListCard.spec.tsx +++ b/src/apps/opportunities/src/components/OpportunityListCard.spec.tsx @@ -254,6 +254,56 @@ describe('OpportunityListCard competition presentation', () => { .toBeInTheDocument() }) + it('renders the Figma completed state with an API-backed winners affordance', () => { + render( + + + , + ) + + expect(screen.getByText('Completed')) + .toBeInTheDocument() + expect(screen.queryByText('Registration closed')) + .not.toBeInTheDocument() + expect(screen.queryByRole('progressbar')) + .not.toBeInTheDocument() + + const winners = screen.getByRole('link', { name: 'View winners' }) + expect(winners) + .toHaveAttribute('href', '/opportunities/challenge/challenge-id?tab=winners') + expect(winners.querySelectorAll('.winnerAvatar')) + .toHaveLength(3) + expect(winners.querySelector('img')) + .toHaveAttribute('src', 'https://images.example/first.png') + expect(winners.querySelectorAll('svg')) + .toHaveLength(3) + expect(within(winners) + .queryByText('$1000')) + .not.toBeInTheDocument() + expect(screen.getByLabelText('Placement prizes')) + .toHaveTextContent('$1000') + }) + it('shows Registered for the server-filtered My competitions result', () => { render( diff --git a/src/apps/opportunities/src/components/OpportunityListCard.tsx b/src/apps/opportunities/src/components/OpportunityListCard.tsx index f5ec6826c..91de04297 100644 --- a/src/apps/opportunities/src/components/OpportunityListCard.tsx +++ b/src/apps/opportunities/src/components/OpportunityListCard.tsx @@ -3,6 +3,7 @@ import { FC, ReactNode, SVGProps, + useState, } from 'react' import { Link } from 'react-router-dom' import classNames from 'classnames' @@ -109,6 +110,13 @@ interface CompetitionMetric { value: string } +type ChallengeWinner = NonNullable[number] + +interface CompetitionWinnerAvatarProps { + placement: number + winner: ChallengeWinner +} + const challengeTypePresentations: Record = { challenge: { icon: ChallengeTypeIcon, label: 'Challenge' }, first2finish: { icon: First2FinishTypeIcon, label: 'First 2 Finish' }, @@ -118,6 +126,37 @@ const challengeTypePresentations: Record = { const medalIcons: Array>> = [MedalFirstIcon, MedalSecondIcon, MedalThirdIcon] +/** + * Renders one API-backed winner photo with its existing placement medal. A + * failed or unavailable member photo falls back to the winner's real handle + * initial without inventing identity artwork. + * + * @param props Challenge API winner, enriched Members API photo, and placement. + * @returns compact winner avatar used by completed competition cards. + * @throws Does not throw; image failures switch to an initial fallback. + */ +const CompetitionWinnerAvatar: FC = props => { + const [failedPhotoURL, setFailedPhotoURL] = useState() + const handle = props.winner.handle?.trim() || String(props.winner.userId ?? 'Winner') + const photoURL = props.winner.photoURL + const showPhoto = !!photoURL && photoURL !== failedPhotoURL + const MedalIcon = medalIcons[props.placement - 1] ?? MedalThirdIcon + + return ( + + + + + ) +} + /** * Renders a card skill as a native filter control when the list supplies a * selection callback. @@ -607,6 +646,17 @@ const CompetitionListCard: FC = props => { const timeLeft = formatChallengeTimeLeft(phaseTiming) || 'TBD' const progress = Math.round(phaseTiming.progressPercent) const registrationOpen = challengeRegistrationIsOpen(item) + const completed = challengeCatalogKey(item.status) === 'completed' + const visibleWinners = completed + ? (item.winners ?? []) + .map((winner, index) => ({ + placement: winner.placement ?? index + 1, + winner, + })) + .filter(entry => entry.placement >= 1 && entry.placement <= 3) + .sort((first, second) => first.placement - second.placement) + .slice(0, 3) + : [] const metrics: CompetitionMetric[] = [ { icon: