Skip to content
34 changes: 32 additions & 2 deletions src/apps/copilots/src/copilots.routes.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { copilotsRoutes } from './copilots.routes'
import { copilotsRoutes, getCopilotsAbsoluteRootRoute } from './copilots.routes'

jest.mock('~/config', () => ({
AppSubdomain: { copilots: 'copilots' },
EnvironmentConfig: { SUBDOMAIN: 'topcoder-dev' },
EnvironmentConfig: { SUBDOMAIN: 'topcoder-dev', TC_DOMAIN: 'topcoder-dev.com' },
ToolTitle: { copilots: 'Copilots' },
}), { virtual: true })

Expand Down Expand Up @@ -32,4 +32,34 @@ describe('copilotsRoutes', () => {
expect(detailRoute?.route)
.toBe('/opportunity/:opportunityId')
})

it('targets the copilots subdomain for direct opens from Topcoder hosts', () => {
expect(getCopilotsAbsoluteRootRoute(
'https://topcoder-dev.com',
'topcoder-dev',
'topcoder-dev.com',
))
.toBe('https://copilots.topcoder-dev.com')
expect(getCopilotsAbsoluteRootRoute(
'https://www.topcoder-dev.com',
'www',
'topcoder-dev.com',
))
.toBe('https://copilots.topcoder-dev.com')
})

it('keeps current-origin routing on the copilots subdomain and localhost', () => {
expect(getCopilotsAbsoluteRootRoute(
'https://copilots.topcoder-dev.com',
'copilots',
'topcoder-dev.com',
))
.toBe('https://copilots.topcoder-dev.com')
expect(getCopilotsAbsoluteRootRoute(
'http://localhost:3000',
'localhost',
'topcoder-dev.com',
))
.toBe('http://localhost:3000/copilots')
})
})
39 changes: 38 additions & 1 deletion src/apps/copilots/src/copilots.routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,44 @@ export const rootRoute: string = (
)

export const toolTitle: string = ToolTitle.copilots
export const absoluteRootRoute: string = `${window.location.origin}${rootRoute}`

/**
* Resolves the canonical absolute Copilots app root used for full-page and new-tab links.
*
* On Topcoder hosts, opening `/copilots/...` on the main site redirects to `www` and can
* render the site-level 404 instead of the Copilots SPA. To keep direct opens working,
* cross-app links target the dedicated Copilots subdomain. Non-Topcoder hosts such as
* localhost keep the current-origin path fallback so local development still works.
*
* @param origin current browser origin.
* @param subdomain active environment subdomain derived from the current host.
* @param tcDomain configured Topcoder base domain such as `topcoder-dev.com`.
* @returns canonical absolute Copilots root for the current runtime host.
*/
export function getCopilotsAbsoluteRootRoute(
origin: string,
subdomain: string,
tcDomain: string,
): string {
const currentOrigin = new URL(origin)
if (subdomain === AppSubdomain.copilots) return currentOrigin.origin

const normalizedDomain = tcDomain.toLowerCase()
const hostname = currentOrigin.hostname.toLowerCase()
const onTopcoderHost = hostname === normalizedDomain
|| hostname === `www.${normalizedDomain}`
|| hostname.endsWith(`.${normalizedDomain}`)

return onTopcoderHost
? `${currentOrigin.protocol}//${AppSubdomain.copilots}.${normalizedDomain}`
: `${currentOrigin.origin}${rootRoute}`
}

export const absoluteRootRoute: string = getCopilotsAbsoluteRootRoute(
window.location.origin,
EnvironmentConfig.SUBDOMAIN,
EnvironmentConfig.TC_DOMAIN,
)

export const childRoutes = [
{
Expand Down
21 changes: 14 additions & 7 deletions src/apps/opportunities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,13 @@ 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.
is not treated as an open registration window. “Active competitions” uses
Challenge API's `hasCurrentPhase` filter so scheduled challenges remain
hidden while Submission, Review, and every other open phase remain visible.
“My competitions” uses the member's complete Challenge resource membership
so active work remains visible to Submitters, Copilots, and challenge
Managers. The separate member-registration request keeps the Registered card
state limited to actual Submitter resources.
- 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.
Expand Down Expand Up @@ -240,12 +245,14 @@ 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
The Report an Issue dialog preserves the Figma subject, category, and
1000-character description while keeping attachments optional. 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.
limit, grouped under the active challenge ID when one exists or a draft upload
context before ticket creation otherwise. Because support-api-v6 accepts only
`challengeId` and Markdown `description`, the client serializes the subject,
category, body, and any 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,24 @@
}
}

.fileNameLabel {
font-weight: 700;
}

.card a.inlineAnchor {
display: inline;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
margin-top: 0;
text-decoration: underline;
white-space: nowrap;

&:hover {
text-decoration: none;
}
}

.reviewStyleList {
margin: 0;
padding-left: 21px;
Expand Down
76 changes: 62 additions & 14 deletions src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { ChallengeAiReviewConfig, ChallengeOpportunity } from '../models'

import { ChallengeSidebar } from './ChallengeSidebar'

const challengeExplainedUrl
= 'https://www.topcoder.example/thrive/articles/all-about-topcoder-challenges-tasks-and-gig-work-opportunities'
const designChallengeLearningUrl
= 'https://www.topcoder.example/thrive/articles/How%20To%20Compete%20in%20Design'
const checkpointFeedbackLearningUrl
= 'https://www.topcoder.example/thrive/articles/how-to-approach-the-checkpoint-feedback-to-decipher-hidden-codes'

const mockUseSWR = jest.fn()

jest.mock('swr', () => ({
Expand Down Expand Up @@ -40,11 +47,9 @@ jest.mock('../services', () => ({
getChallengeTermsDetails: jest.fn(),
}))
jest.mock('../utils/opportunity-learning.utils', () => ({
CHALLENGE_EXPLAINED_URL: 'https://www.topcoder.example/thrive/search?title=Topcoder%20Challenge%20Explained',
CHECKPOINT_FEEDBACK_LEARNING_URL:
'https://www.topcoder.example/thrive/search?title=How%20to%20Approach%20the%20Checkpoint%20Feed',
DESIGN_CHALLENGE_LEARNING_URL:
'https://www.topcoder.example/thrive/search?title=How%20to%20Compete%20in%20Design%20Challenges',
CHALLENGE_EXPLAINED_URL: challengeExplainedUrl,
CHECKPOINT_FEEDBACK_LEARNING_URL: checkpointFeedbackLearningUrl,
DESIGN_CHALLENGE_LEARNING_URL: designChallengeLearningUrl,
SCREENING_LEARNING_URL: 'https://www.topcoder.example/thrive/search?title=How%20to%20Pass%20Screening',
}))

Expand Down Expand Up @@ -140,21 +145,21 @@ describe('ChallengeSidebar Review Style', () => {
it('uses published challenge-learning article links for the educational materials rail', () => {
renderSidebar()

expect(screen.getByRole('link', { name: 'Topcoder Challenge Explained' }))
expect(screen.getByRole('link', { name: 'Topcoder Challenges Explained' }))
.toHaveAttribute(
'href',
'https://www.topcoder.example/thrive/search?title=Topcoder%20Challenge%20Explained',
challengeExplainedUrl,
)
})

it('keeps design-only educational links and copy out of development challenges', () => {
renderSidebar(undefined, developmentChallenge)

expect(screen.getByRole('link', { name: 'Topcoder Challenge Explained' }))
expect(screen.getByRole('link', { name: 'Topcoder Challenges Explained' }))
.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'How to Compete in Design Challenges' }))
expect(screen.queryByRole('link', { name: 'How to compete in design challenges' }))
.not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'How to Approach the Checkpoint Feed' }))
expect(screen.queryByRole('link', { name: 'How to approach the checkpoint feedback' }))
.not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Submission Format' }))
.not.toBeInTheDocument()
Expand Down Expand Up @@ -198,9 +203,52 @@ describe('ChallengeSidebar Review Style', () => {
it('shows design educational links for design challenges', () => {
renderSidebar(undefined, designChallenge)

expect(screen.getByRole('link', { name: 'How to Compete in Design Challenges' }))
.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'How to Approach the Checkpoint Feed' }))
.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Topcoder Challenges Explained' }))
.toHaveAttribute(
'href',
challengeExplainedUrl,
)
expect(screen.getByRole('link', { name: 'How to compete in design challenges' }))
.toHaveAttribute(
'href',
designChallengeLearningUrl,
)
expect(screen.getByRole('link', { name: 'How to approach the checkpoint feedback' }))
.toHaveAttribute(
'href',
checkpointFeedbackLearningUrl,
)
})

it('emphasizes required submission filenames in the design submission format list', () => {
renderSidebar(undefined, designChallenge)

for (const fileName of ['Submission.zip:', 'Source.zip:', 'Declaration.txt:', 'Preview.jpg:']) {
const label = screen.getByText(fileName)

expect(label.tagName)
.toBe('STRONG')
}
})

it('keeps the policy and screening links inline with their punctuation', () => {
renderSidebar(undefined, designChallenge)

const policyLink = screen.getByRole('link', { name: 'Policy' })
const screeningLink = screen.getByRole('link', { name: 'how to pass screening' })
const faqLink = screen.getByRole('link', { name: 'Read the FAQ.' })

expect(policyLink.className)
.toContain('inlineAnchor')
expect(screeningLink.className)
.toContain('inlineAnchor')
expect(faqLink.className)
.toContain('inlineAnchor')
expect(policyLink.parentElement)
.toHaveTextContent('the Policy.')
expect(screeningLink.parentElement)
.toHaveTextContent('how to pass screening.')
expect(faqLink.parentElement)
.toHaveTextContent('Trouble formatting your submission or want to learn more? Read the FAQ.')
})
})
38 changes: 25 additions & 13 deletions src/apps/opportunities/src/components/ChallengeSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,14 +294,14 @@ export const ChallengeSidebar: FC<ChallengeSidebarProps> = props => {
</SidebarCard>
<SidebarCard icon={<img alt='' aria-hidden='true' src={sidebarBookIcon} />} title='Educational Materials'>
<p>Read educational material in Topcoder Thrive.</p>
<a href={CHALLENGE_EXPLAINED_URL} rel='noreferrer' target='_blank'>Topcoder Challenge Explained</a>
<a href={CHALLENGE_EXPLAINED_URL} rel='noreferrer' target='_blank'>Topcoder Challenges Explained</a>
{designChallenge && (
<>
<a href={DESIGN_CHALLENGE_LEARNING_URL} rel='noreferrer' target='_blank'>
How to Compete in Design Challenges
How to compete in design challenges
</a>
<a href={CHECKPOINT_FEEDBACK_LEARNING_URL} rel='noreferrer' target='_blank'>
How to Approach the Checkpoint Feed
How to approach the checkpoint feedback
</a>
</>
)}
Expand All @@ -317,22 +317,22 @@ export const ChallengeSidebar: FC<ChallengeSidebarProps> = props => {
<ol>
<li>Look for instructions in this challenge regarding what files to provide.</li>
<li>
<strong>Submission.zip:</strong>
<strong className={styles.fileNameLabel}>Submission.zip:</strong>
{' '}
Place your submission files into a zip file.
</li>
<li>
<strong>Source.zip:</strong>
<strong className={styles.fileNameLabel}>Source.zip:</strong>
{' '}
Place all of your source files into a zip file.
</li>
<li>
<strong>Declaration.txt:</strong>
<strong className={styles.fileNameLabel}>Declaration.txt:</strong>
{' '}
Declare your fonts, stock photos, and icons in a txt file.
</li>
<li>
<strong>Preview.jpg:</strong>
<strong className={styles.fileNameLabel}>Preview.jpg:</strong>
{' '}
Create a 1024 x 1024 px preview image file.
</li>
Expand All @@ -344,7 +344,14 @@ export const ChallengeSidebar: FC<ChallengeSidebarProps> = props => {
<p>
Trouble formatting your submission or want to learn more?
{' '}
<a href={CHALLENGE_EXPLAINED_URL} rel='noreferrer' target='_blank'>Read the FAQ.</a>
<a
className={styles.inlineAnchor}
href={CHALLENGE_EXPLAINED_URL}
rel='noreferrer'
target='_blank'
>
Read the FAQ.
</a>
</p>
</div>
<div className={styles.infoSection}>
Expand All @@ -356,11 +363,11 @@ export const ChallengeSidebar: FC<ChallengeSidebarProps> = props => {
All fonts, stock photos, and icons within your design must be declared when you
submit. DO NOT include any 3rd party files in your submission or source files.
{' '}
Read about the
Read about
{' '}
<a href={FILE_SUBMISSION_POLICY_URL}>
Policy
</a>
the
{' '}
<a className={styles.inlineAnchor} href={FILE_SUBMISSION_POLICY_URL}>Policy</a>
.
</p>
</div>
Expand All @@ -375,7 +382,12 @@ export const ChallengeSidebar: FC<ChallengeSidebarProps> = props => {
{' '}
Don&apos;t let your hard work go to waste. Learn more about
{' '}
<a href={SCREENING_LEARNING_URL} rel='noreferrer' target='_blank'>
<a
className={styles.inlineAnchor}
href={SCREENING_LEARNING_URL}
rel='noreferrer'
target='_blank'
>
how to pass screening
</a>
.
Expand Down
Loading
Loading