Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions src/apps/gigs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 36 additions & 1 deletion src/apps/gigs/src/components/GigApplicationForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ jest.mock(
{props.children}
</button>
),
BaseModal: (props: any) => (props.open ? <div role='dialog'>{props.children}</div> : undefined),
BaseModal: (props: any) => (props.open ? (
<div className={props.classNames?.modal} role='dialog'>
<header>{props.title}</header>
<div className={props.bodyClassName}>{props.children}</div>
{props.buttons}
</div>
) : undefined),
LoadingSpinner: () => <span>Loading</span>,
}),
{ virtual: true },
Expand Down Expand Up @@ -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(
<MemoryRouter>
<GigApplicationForm
job={job}
slug='example-gig'
profile={profile}
candidate={candidate}
/>
</MemoryRouter>,
)

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()
})
})
2 changes: 1 addition & 1 deletion src/apps/gigs/src/components/GigApplicationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
/>
</form>
Expand Down
16 changes: 14 additions & 2 deletions src/apps/gigs/src/components/GigShared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,20 @@ export const GigPolicy: FC<{ id?: string; title: string; close: () => void }> =
{ shouldRetryOnError: false },
)
return (
<BaseModal open={!!props.id} onClose={props.close} title={props.title} size='lg'>
<div className='gigs-app'>
<BaseModal
bodyClassName='gigs-policy-modal-body'
buttons={(
<div className='gigs-policy-actions'>
<Button primary onClick={props.close}>Close</Button>
</div>
)}
classNames={{ modal: 'gigs-policy-modal' }}
open={!!props.id}
onClose={props.close}
title={props.title}
size='lg'
>
<div className='gigs-app gigs-policy'>
{error ? (
<GigState
title='Unable to load this policy'
Expand Down
28 changes: 25 additions & 3 deletions src/apps/gigs/src/gigs.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,11 @@ describe('Recruit API integration', () => {
)
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(
Expand All @@ -101,12 +105,30 @@ 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.each([
{ message: 'Assignment failed' },
{ candidate_slug: 'candidate-slug' },
{ candidate_slug: 'candidate-slug', job_slug: 'another-gig' },
])('rejects a nonempty response that does not confirm the requested assignment: %j', async data => {
fetchMock.mockResolvedValue(response(data))
await expect(applyToGig('gig-slug', new FormData())).rejects.toThrow('not confirmed')
})
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()
Expand Down
29 changes: 26 additions & 3 deletions src/apps/gigs/src/gigs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ import { Candidate, Gig } from './models'

const RECRUIT_URL = `${EnvironmentConfig.COMMUNITY_APP_URL}/api/recruit`

/**
* Returns whether Recruit supplied an explicit success or an assignment for the requested Gig.
*
* @param result Recruit's parsed application response.
* @param slug The requested Gig slug, which must match a returned assignment resource.
* @returns Whether the response confirms the application.
*/
function isConfirmedApplication(result: unknown, slug: string): boolean {
if (!result || typeof result !== 'object' || Array.isArray(result)) return false
const response = result as Record<string, unknown>
return response.success === true
|| (
typeof response.candidate_slug === 'string'
&& response.candidate_slug.trim().length > 0
&& response.job_slug === slug
)
}

/** An API failure with an HTTP-equivalent status, including Recruit errors returned with HTTP 200. */
export class RecruitError extends Error {
status: number
Expand Down Expand Up @@ -74,14 +92,19 @@ export async function getCandidate(email: string): Promise<Candidate | undefined
return candidates[0]
}

/** Posts a multipart application with the refreshed member token; resolves only on confirmed success. */
/**
* Posts a multipart application with the refreshed member token. Recruit confirms a new assignment with its
* populated assignment resource, while an already-existing assignment uses the older `{ success: true }` shape.
*/
export async function applyToGig(slug: string, body: FormData): Promise<void> {
const result = await recruitRequest<{ success?: boolean }>(
const result = await recruitRequest<unknown>(
`${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, slug)) {
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. */
Expand Down
14 changes: 14 additions & 0 deletions src/apps/gigs/src/gigs.utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 11 additions & 4 deletions src/apps/gigs/src/gigs.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down
8 changes: 6 additions & 2 deletions src/apps/gigs/src/pages/GigsPage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,19 @@ 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(
<MemoryRouter>
<GigsPage />
</MemoryRouter>,
)

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', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/apps/gigs/src/pages/GigsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const GigsPage: FC = () => {
<h2>Find a gig</h2>
<label htmlFor='gig-search'>Search</label>
<input
className='gigs-filter-input'
className='gigs-filter-control'
id='gig-search'
type='search'
placeholder='Name, skills, location or duration'
Expand All @@ -84,6 +84,7 @@ const GigsPage: FC = () => {
/>
<label htmlFor='gig-location'>Location</label>
<select
className='gigs-filter-control'
id='gig-location'
value={location}
onChange={event => updateFilter('location', event.target.value)}
Expand Down Expand Up @@ -119,6 +120,7 @@ const GigsPage: FC = () => {
<label htmlFor='gig-sort'>
Sort by
<select
className='gigs-filter-control'
id='gig-sort'
value={sort}
onChange={event => updateFilter('sort', event.target.value)}
Expand Down
29 changes: 25 additions & 4 deletions src/apps/gigs/src/styles/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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); }
Expand All @@ -125,7 +127,26 @@
}

@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%;
}

@media (max-width: 464px) {
.react-responsive-modal-modal.gigs-policy-modal { height: auto; }
}
Loading
Loading