diff --git a/src/apps/copilots/src/copilots.routes.spec.tsx b/src/apps/copilots/src/copilots.routes.spec.tsx index f9da7aa91..43eff4de9 100644 --- a/src/apps/copilots/src/copilots.routes.spec.tsx +++ b/src/apps/copilots/src/copilots.routes.spec.tsx @@ -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 }) @@ -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') + }) }) diff --git a/src/apps/copilots/src/copilots.routes.tsx b/src/apps/copilots/src/copilots.routes.tsx index 439e9a221..243aa6c96 100644 --- a/src/apps/copilots/src/copilots.routes.tsx +++ b/src/apps/copilots/src/copilots.routes.tsx @@ -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 = [ { diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index 0b270535b..bd0aabde6 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -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. @@ -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 diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.module.scss b/src/apps/opportunities/src/components/ChallengeSidebar.module.scss index de1d098dc..ba3eb180c 100644 --- a/src/apps/opportunities/src/components/ChallengeSidebar.module.scss +++ b/src/apps/opportunities/src/components/ChallengeSidebar.module.scss @@ -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; diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx b/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx index b21451bc5..fd1692697 100644 --- a/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx +++ b/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx @@ -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', () => ({ @@ -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', })) @@ -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() @@ -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.') }) }) diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.tsx b/src/apps/opportunities/src/components/ChallengeSidebar.tsx index 2de48a423..4a2528ee8 100644 --- a/src/apps/opportunities/src/components/ChallengeSidebar.tsx +++ b/src/apps/opportunities/src/components/ChallengeSidebar.tsx @@ -294,14 +294,14 @@ export const ChallengeSidebar: FC = props => {