From 5117f3fbc7d7da077d6b6153389ebe386cfa35f3 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 22 Feb 2026 16:41:43 +1100 Subject: [PATCH 01/13] PM-1839: show leaderboard scoring label for fun challenges What was broken\nMarathon tournament fun challenges still rendered the standard "1st " header prize display on challenge details, which implies a direct payout that does not exist.\n\nRoot cause\nHeader prize rendering had no branch for a fun challenge flag and always rendered placement prize cards.\n\nWhat was changed\nAdded fun-challenge-aware rendering in header prizes to show "No individual prize - leaderboard scoring" when the flag is true, wired funChallenge from header props, and added matching styles.\n\nAny added/updated tests\nAdded header prize component tests and snapshots for both fun-challenge and standard prize rendering paths. --- .../challenge-detail/Header/Prizes.jsx | 30 ++++++++++++ .../Header/__snapshots__/Prizes.jsx.snap | 48 +++++++++++++++++++ .../challenge-detail/Header/Prizes.jsx | 16 ++++++- .../challenge-detail/Header/index.jsx | 8 +++- .../challenge-detail/Header/style.scss | 16 +++++++ 5 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 __tests__/shared/components/challenge-detail/Header/Prizes.jsx create mode 100644 __tests__/shared/components/challenge-detail/Header/__snapshots__/Prizes.jsx.snap diff --git a/__tests__/shared/components/challenge-detail/Header/Prizes.jsx b/__tests__/shared/components/challenge-detail/Header/Prizes.jsx new file mode 100644 index 000000000..2c459dea8 --- /dev/null +++ b/__tests__/shared/components/challenge-detail/Header/Prizes.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import Renderer from 'react-test-renderer/shallow'; + +import Prizes from 'components/challenge-detail/Header/Prizes'; + +describe('Challenge detail header prizes', () => { + test('renders leaderboard-scoring label for fun challenges', () => { + const renderer = new Renderer(); + renderer.render(( + + )); + expect(renderer.getRenderOutput()).toMatchSnapshot(); + }); + + test('renders normal placement prizes when fun challenge is false', () => { + const renderer = new Renderer(); + renderer.render(( + + )); + expect(renderer.getRenderOutput()).toMatchSnapshot(); + }); +}); diff --git a/__tests__/shared/components/challenge-detail/Header/__snapshots__/Prizes.jsx.snap b/__tests__/shared/components/challenge-detail/Header/__snapshots__/Prizes.jsx.snap new file mode 100644 index 000000000..56b0089c6 --- /dev/null +++ b/__tests__/shared/components/challenge-detail/Header/__snapshots__/Prizes.jsx.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Challenge detail header prizes renders leaderboard-scoring label for fun challenges 1`] = ` +
+

+ No individual prize - leaderboard scoring +

+
+`; + +exports[`Challenge detail header prizes renders normal placement prizes when fun challenge is false 1`] = ` +
+
+
+ +
+ +
+
+`; diff --git a/src/shared/components/challenge-detail/Header/Prizes.jsx b/src/shared/components/challenge-detail/Header/Prizes.jsx index ae07e8aa7..dd0e6886c 100644 --- a/src/shared/components/challenge-detail/Header/Prizes.jsx +++ b/src/shared/components/challenge-detail/Header/Prizes.jsx @@ -7,13 +7,25 @@ import PT from 'prop-types'; import './style.scss'; +const FUN_CHALLENGE_PRIZE_LABEL = 'No individual prize - leaderboard scoring'; + function getOrdinal(num) { const ordinals = ['th', 'st', 'nd', 'rd']; const v = num % 100; return ordinals[(v - 20) % 10] || ordinals[v] || ordinals[0]; } -export default function Prizes({ pointPrizes, prizes }) { +export default function Prizes({ isFunChallenge, pointPrizes, prizes }) { + if (isFunChallenge) { + return ( +
+

+ {FUN_CHALLENGE_PRIZE_LABEL} +

+
+ ); + } + const prizeLength = Math.max(pointPrizes.length, prizes.length); return (
@@ -62,11 +74,13 @@ export default function Prizes({ pointPrizes, prizes }) { } Prizes.defaultProps = { + isFunChallenge: false, pointPrizes: [], prizes: [], }; Prizes.propTypes = { + isFunChallenge: PT.bool, pointPrizes: PT.arrayOf(PT.number), prizes: PT.arrayOf(PT.shape()), }; diff --git a/src/shared/components/challenge-detail/Header/index.jsx b/src/shared/components/challenge-detail/Header/index.jsx index c9a6b0019..4779b90c4 100644 --- a/src/shared/components/challenge-detail/Header/index.jsx +++ b/src/shared/components/challenge-detail/Header/index.jsx @@ -75,6 +75,7 @@ export default function ChallengeHeader(props) { pointPrizes, events, prizeSets, + funChallenge, reliabilityBonus, numOfRegistrants, numOfCheckpointSubmissions, @@ -379,7 +380,11 @@ export default function ChallengeHeader(props) {
- + { bonusType ? (
@@ -584,6 +589,7 @@ ChallengeHeader.propTypes = { platforms: PT.any, tags: PT.any, skills: PT.any, + funChallenge: PT.bool, prizes: PT.any, timelineTemplateId: PT.string, reliabilityBonus: PT.any, diff --git a/src/shared/components/challenge-detail/Header/style.scss b/src/shared/components/challenge-detail/Header/style.scss index d75b659b7..ffee24c89 100644 --- a/src/shared/components/challenge-detail/Header/style.scss +++ b/src/shared/components/challenge-detail/Header/style.scss @@ -406,6 +406,22 @@ background-color: #d98f64; } } + + .fun-challenge-prize { + @include roboto-bold; + + color: #2a2a2a; + font-size: 24px; + font-weight: 500; + line-height: 32px; + margin: 0; + + @include xs-to-md { + font-size: 14px; + line-height: 30px; + text-align: center; + } + } } .bonus-div { From 75054cb98401140193af5186f7b5b06f51ca2871 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 22 Feb 2026 16:58:13 +1100 Subject: [PATCH 02/13] PM-1644: Enforce wiproAllowed for challenge registration What was broken:\nCommunity App allowed @wipro.com users to start challenge registration flow even when a challenge disallowed Wipro participants.\n\nRoot cause:\nThe challenge registration entrypoint in challenge detail did not check the new challenge-level wiproAllowed flag against the logged-in member email domain before continuing.\n\nWhat was changed:\nAdded a Wipro eligibility helper in challenge detail, wired the registration click handler to block when email is @wipro.com and wiproAllowed is false, and surfaced the required user message in CA.\n\nAny added/updated tests:\nAdded unit tests for the Wipro registration guard helper covering blocked and allowed scenarios, including case-insensitive domain matching. --- .../containers/challenge-detail/index.jsx | 19 +++++++++++++++ .../containers/challenge-detail/index.jsx | 24 ++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 __tests__/shared/containers/challenge-detail/index.jsx diff --git a/__tests__/shared/containers/challenge-detail/index.jsx b/__tests__/shared/containers/challenge-detail/index.jsx new file mode 100644 index 000000000..863c3a894 --- /dev/null +++ b/__tests__/shared/containers/challenge-detail/index.jsx @@ -0,0 +1,19 @@ +import { isWiproRegistrationBlocked } from 'containers/challenge-detail'; + +describe('Challenge detail Wipro registration guard', () => { + test('blocks Wipro members when challenge disallows Wipro participation', () => { + expect(isWiproRegistrationBlocked('member@wipro.com', false)).toBe(true); + }); + + test('does not block Wipro members when challenge allows Wipro participation', () => { + expect(isWiproRegistrationBlocked('member@wipro.com', true)).toBe(false); + }); + + test('does not block non-Wipro members when challenge disallows Wipro participation', () => { + expect(isWiproRegistrationBlocked('member@example.com', false)).toBe(false); + }); + + test('matches Wipro domain case-insensitively and ignores surrounding spaces', () => { + expect(isWiproRegistrationBlocked(' MEMBER@WIPRO.COM ', false)).toBe(true); + }); +}); diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index bd6586cfb..151119ce4 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -52,7 +52,7 @@ import { hasOpenSubmissionPhase } from 'utils/challengePhases'; import { config } from 'topcoder-react-utils'; import MetaTags from 'components/MetaTags'; import { decodeToken } from '@topcoder-platform/tc-auth-lib'; -import { actions, services } from 'topcoder-react-lib'; +import { actions, errors, services } from 'topcoder-react-lib'; import { getService } from 'services/contentful'; import { getSubmissionArtifacts as getSubmissionArtifactsService } from 'services/submissions'; import getReviewSummationsService from 'services/reviewSummations'; @@ -98,6 +98,19 @@ import './styles.scss'; const MIN = 60 * 1000; const DAY = 24 * 60 * MIN; const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); +const { fireErrorMessage } = errors; +const WIPRO_REGISTRATION_BLOCKED_MESSAGE = 'Wipro employees are not allowed to participate in this Topcoder challenge'; + +/** + * Checks whether challenge registration should be blocked for Wipro members. + * @param {String} email User email. + * @param {Boolean} wiproAllowed Challenge-level flag. + * @return {Boolean} + */ +export function isWiproRegistrationBlocked(email, wiproAllowed) { + if (wiproAllowed !== false) return false; + return /@wipro\.com$/i.test(_.trim(email || '')); +} /** * Given challenge details object, it returns the URL of the image to be used in @@ -345,8 +358,17 @@ class ChallengeDetailPageContainer extends React.Component { registerForChallenge() { const { auth, + challenge, communityId, } = this.props; + const userEmail = _.get(auth, 'user.email'); + const wiproAllowed = _.get(challenge, 'wiproAllowed'); + + if (isWiproRegistrationBlocked(userEmail, wiproAllowed)) { + fireErrorMessage(WIPRO_REGISTRATION_BLOCKED_MESSAGE); + return; + } + if (!auth.tokenV3) { const utmSource = communityId || 'community-app-main'; window.location.href = appendUtmParamsToUrl( From 6bc2a3320e2041b55d67c34466ce7aaa1683468b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 22 Feb 2026 19:37:09 +1100 Subject: [PATCH 03/13] PM-2686: fix winners tab for legacy winner types What was broken: Some older challenges did not show the Winners tab even when final winners existed. Root cause: Winner filtering in challenge detail accepted only lowercase 'final' (w.type === 'final'). Legacy payloads can contain 'Final', which got filtered out, making winner count zero and hiding the tab. What was changed: Added getDisplayWinners() in challenge detail container to normalize winner type matching case-insensitively for non-task challenges. Replaced inline winners filtering with getDisplayWinners() in the render path so tab visibility uses normalized winners. Any added/updated tests: Updated __tests__/shared/containers/challenge-detail/index.jsx with regression coverage for legacy 'Final' winners on non-task challenges and preserved task challenge behavior. --- .../containers/challenge-detail/index.jsx | 33 ++++++++++++++++++- .../containers/challenge-detail/index.jsx | 26 ++++++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/__tests__/shared/containers/challenge-detail/index.jsx b/__tests__/shared/containers/challenge-detail/index.jsx index 863c3a894..cff13840e 100644 --- a/__tests__/shared/containers/challenge-detail/index.jsx +++ b/__tests__/shared/containers/challenge-detail/index.jsx @@ -1,4 +1,4 @@ -import { isWiproRegistrationBlocked } from 'containers/challenge-detail'; +import { getDisplayWinners, isWiproRegistrationBlocked } from 'containers/challenge-detail'; describe('Challenge detail Wipro registration guard', () => { test('blocks Wipro members when challenge disallows Wipro participation', () => { @@ -17,3 +17,34 @@ describe('Challenge detail Wipro registration guard', () => { expect(isWiproRegistrationBlocked(' MEMBER@WIPRO.COM ', false)).toBe(true); }); }); + +describe('Challenge detail winners filter', () => { + test('includes legacy winners with "Final" type for non-task challenges', () => { + const winners = getDisplayWinners({ + type: 'Challenge', + winners: [ + { handle: 'legacyFinal', type: 'Final' }, + { handle: 'newFinal', type: 'final' }, + { handle: 'provisionalWinner', type: 'provisional' }, + ], + }); + + expect(winners).toEqual([ + { handle: 'legacyFinal', type: 'Final' }, + { handle: 'newFinal', type: 'final' }, + ]); + }); + + test('does not filter winners for task challenges', () => { + const winners = getDisplayWinners({ + type: 'Task', + winners: [ + { handle: 'taskWinner', type: 'provisional' }, + ], + }); + + expect(winners).toEqual([ + { handle: 'taskWinner', type: 'provisional' }, + ]); + }); +}); diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index 151119ce4..127bcdafa 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -112,6 +112,27 @@ export function isWiproRegistrationBlocked(email, wiproAllowed) { return /@wipro\.com$/i.test(_.trim(email || '')); } +/** + * Returns winners to display in challenge detail. + * For non-task challenges we only keep final winners, while supporting + * legacy winner type values like "Final". + * + * @param {Object} challenge Challenge details object. + * @returns {Array} Winners for display. + */ +export function getDisplayWinners(challenge = {}) { + const winners = _.get(challenge, 'winners', []); + + if (getTypeName(challenge) === 'Task') { + return winners; + } + + return winners.filter((winner = {}) => { + const winnerType = _.toLower(_.trim(_.toString(winner.type))); + return !winner.type || winnerType === 'final'; + }); +} + /** * Given challenge details object, it returns the URL of the image to be used in * OpenGraph (i.e. in social sharing posts). @@ -506,10 +527,7 @@ class ChallengeDetailPageContainer extends React.Component { return ; } - let winners = challenge.winners || []; - if (getTypeName(challenge) !== 'Task') { - winners = winners.filter(w => !w.type || w.type === 'final'); - } + const winners = getDisplayWinners(challenge); let hasFirstPlacement = false; if (!_.isEmpty(winners)) { From 1a61a1347cd01de3b70d4d0299af02eb0bcc77f7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 22 Feb 2026 20:20:49 +1100 Subject: [PATCH 04/13] PM-3195: handle grouped challenge review opportunity details What was broken: Review opportunity details always fetched /v6/challenges/:id and rejected the page load when that request was forbidden, so grouped challenges in the review feed stayed stuck/loading or showed stale prior details. Root cause: Grouped challenges can expose public review opportunities while blocking direct challenge detail access. The details service treated any non-200 challenge response as fatal. What was changed: Added a fallback path in review opportunity details loading: when challenge details return 403, build challenge details from the review opportunity payload (challenge/challengeData) with safe defaults for id/name/type/phases/terms. Kept the existing challenge API path for normal successful responses and preserved existing error behavior for non-403 failures. Any added/updated tests: Added __tests__/shared/services/reviewOpportunities.js covering successful challenge fetch, 403 fallback behavior, and opportunity-fetch failure handling. --- .../shared/services/reviewOpportunities.js | 101 ++++++++++++++++++ src/shared/services/reviewOpportunities.js | 40 ++++++- 2 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 __tests__/shared/services/reviewOpportunities.js diff --git a/__tests__/shared/services/reviewOpportunities.js b/__tests__/shared/services/reviewOpportunities.js new file mode 100644 index 000000000..0877bc99d --- /dev/null +++ b/__tests__/shared/services/reviewOpportunities.js @@ -0,0 +1,101 @@ +import { getDetails } from 'services/reviewOpportunities'; + +describe('shared/services/reviewOpportunities.getDetails', () => { + let originalFetch; + + beforeAll(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.clearAllMocks(); + }); + + test('returns challenge API details when challenge request succeeds', async () => { + const opportunityPayload = { + result: { + content: { + id: 'opp-1', + payments: [{ role: 'Reviewer', payment: 100 }], + }, + }, + }; + const challengePayload = { + id: '12345', + name: 'Challenge from API', + type: 'Challenge', + phases: [], + terms: [], + }; + + global.fetch = jest.fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(opportunityPayload), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(challengePayload), + }); + + const result = await getDetails('12345', 'opp-1'); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(result.challenge).toEqual(challengePayload); + }); + + test('falls back to challenge data from opportunity payload when challenge request is forbidden', async () => { + const opportunityPayload = { + result: { + content: { + id: 'opp-2', + payments: [{ role: 'Reviewer', payment: 20 }], + challengeData: { + title: 'Grouped Challenge', + subTrack: 'CODE', + }, + }, + }, + }; + + global.fetch = jest.fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(opportunityPayload), + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + }); + + const result = await getDetails('grouped-challenge-id', 'opp-2'); + + expect(result.challenge).toEqual({ + id: 'grouped-challenge-id', + name: 'Grouped Challenge', + phases: [], + subTrack: 'CODE', + terms: [], + title: 'Grouped Challenge', + type: 'CODE', + }); + }); + + test('rejects when review opportunity request fails', async () => { + global.fetch = jest.fn() + .mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found', + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + await expect(getDetails('12345', 'opp-3')).rejects.toThrow( + 'Failed to load review opportunity: Not Found', + ); + }); +}); diff --git a/src/shared/services/reviewOpportunities.js b/src/shared/services/reviewOpportunities.js index 6fcbd7ffd..cdae89907 100644 --- a/src/shared/services/reviewOpportunities.js +++ b/src/shared/services/reviewOpportunities.js @@ -3,6 +3,30 @@ import { withEstimatedReviewerPayments } from 'utils/reviewOpportunities'; const v6ApiUrl = config.API.V6; +/** + * Normalizes challenge details pulled from review opportunity payloads. + * This is used as fallback when challenge API access is restricted (for example + * grouped challenges whose review opportunities are public). + * + * @param {Object} opportunity Review opportunity details payload. + * @param {string|number} challengeId Challenge id from route. + * @returns {Object} Challenge-like object expected by the details page. + */ +function buildChallengeFallback(opportunity, challengeId) { + const challenge = opportunity + ? (opportunity.challenge || opportunity.challengeData || {}) + : {}; + + return { + ...challenge, + id: challenge.id || challengeId, + name: challenge.name || challenge.title || '', + type: challenge.type || challenge.subTrack || '', + phases: Array.isArray(challenge.phases) ? challenge.phases : [], + terms: Array.isArray(challenge.terms) ? challenge.terms : [], + }; +} + /** * Fetches copilot opportunities. * @@ -47,15 +71,21 @@ export async function getDetails(challengeId, opportunityId) { if (!opportunityRes.ok) { throw new Error(`Failed to load review opportunity: ${opportunityRes.statusText}`); } - if (!challengeRes.ok) { - throw new Error(`Failed to load challenge details: ${challengeRes.statusText}`); - } const opportunityData = await opportunityRes.json(); - const challengeData = await challengeRes.json(); + const opportunityDetails = withEstimatedReviewerPayments(opportunityData.result.content); + + let challengeData; + if (challengeRes.ok) { + challengeData = await challengeRes.json(); + } else if (challengeRes.status === 403) { + challengeData = buildChallengeFallback(opportunityDetails, challengeId); + } else { + throw new Error(`Failed to load challenge details: ${challengeRes.statusText}`); + } return { - ...withEstimatedReviewerPayments(opportunityData.result.content), + ...opportunityDetails, challenge: challengeData, }; } catch (err) { From 891312b703d9be62475e489a6c2a4323cfb55062 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 22 Feb 2026 20:40:35 +1100 Subject: [PATCH 05/13] PM-2731: Fix task visibility ID comparison in challenge listing What was broken Open for Registration and My Challenges badges could show counts that did not match rendered cards for users with assigned Task challenges. Root cause The listing filter compared assignee and current user using numeric coercion (Number(memberId) !== Number(userId)). With non-numeric/string IDs this comparison failed and hid valid assigned Task cards. What was changed Updated assigned-task visibility check to compare IDs as strings in challenge listing bucket filtering. Updated related prop types to accept string or numeric user IDs in listing bucket and challenge card components. Any added/updated tests Updated __tests__/shared/components/challenge-listing/Listing/Bucket.jsx with focused tests verifying assigned Task cards are shown when memberId/userId strings match and hidden when they do not. --- .../challenge-listing/Listing/Bucket.jsx | 137 ++++++++++++++++-- .../challenge-listing/ChallengeCard/index.jsx | 2 +- .../Listing/Bucket/index.jsx | 4 +- 3 files changed, 125 insertions(+), 18 deletions(-) diff --git a/__tests__/shared/components/challenge-listing/Listing/Bucket.jsx b/__tests__/shared/components/challenge-listing/Listing/Bucket.jsx index 29384748e..31b86086e 100644 --- a/__tests__/shared/components/challenge-listing/Listing/Bucket.jsx +++ b/__tests__/shared/components/challenge-listing/Listing/Bucket.jsx @@ -4,6 +4,7 @@ import _ from 'lodash'; import Renderer from 'react-test-renderer/shallow'; // import TU from 'react-dom/test-utils'; import Bucket from 'components/challenge-listing/Listing/Bucket'; +import ChallengeCard from 'components/challenge-listing/ChallengeCard'; import reduxStoreFactory from 'redux-mock-store'; import { Provider } from 'react-redux'; import { StaticRouter } from 'react-router-dom'; @@ -15,6 +16,23 @@ const expand = jest.fn(); const loadMore = jest.fn(); const setFilterState = jest.fn(); const setSort = jest.fn(); +const setSearchText = jest.fn(); + +const challengeTypes = [ + { + name: 'Challenge', + abbreviation: 'CH', + }, { + name: 'First2Finish', + abbreviation: 'F2F', + }, { + name: 'Task', + abbreviation: 'TSK', + }, { + name: 'Marathon Match', + abbreviation: 'MM', + }, +]; const mockDatas = [{ bucket: 'all', @@ -47,21 +65,7 @@ const mockDatas = [{ totalPrize: 1800, users: {}, }], - challengeTypes: [ - { - name: 'Challenge', - abbreviation: 'CH', - }, { - name: 'First2Finish', - abbreviation: 'F2F', - }, { - name: 'Task', - abbreviation: 'TSK', - }, { - name: 'Marathon Match', - abbreviation: 'MM', - }, - ], + challengeTypes, loading: false, loadMore, setFilterState, @@ -84,6 +88,109 @@ test('Matches shallow shapshot', () => { }); }); +function countElementsByType(element, type) { + if (!React.isValidElement(element)) { + return 0; + } + + let count = element.type === type ? 1 : 0; + React.Children.forEach(element.props.children, (child) => { + count += countElementsByType(child, type); + }); + + return count; +} + +test('Shows assigned task when memberId and userId are matching strings', () => { + const renderer = new Renderer(); + renderer.render(( + + )); + + expect(countElementsByType(renderer.getRenderOutput(), ChallengeCard)).toBe(1); +}); + +test('Hides assigned task when memberId and userId do not match', () => { + const renderer = new Renderer(); + renderer.render(( + + )); + + expect(countElementsByType(renderer.getRenderOutput(), ChallengeCard)).toBe(0); +}); + // class Wrapper extends React.Component { // componentDidMount() {} diff --git a/src/shared/components/challenge-listing/ChallengeCard/index.jsx b/src/shared/components/challenge-listing/ChallengeCard/index.jsx index 80803a771..cb5f0d539 100644 --- a/src/shared/components/challenge-listing/ChallengeCard/index.jsx +++ b/src/shared/components/challenge-listing/ChallengeCard/index.jsx @@ -163,7 +163,7 @@ ChallengeCard.propTypes = { openChallengesInNewTabs: PT.bool, sampleWinnerProfile: PT.shape(), selectChallengeDetailsTab: PT.func.isRequired, - userId: PT.number, + userId: PT.oneOfType([PT.number, PT.string]), expandedTags: PT.arrayOf(PT.number), expandTag: PT.func, domRef: PT.func, diff --git a/src/shared/components/challenge-listing/Listing/Bucket/index.jsx b/src/shared/components/challenge-listing/Listing/Bucket/index.jsx index 7021ad799..76078acc4 100644 --- a/src/shared/components/challenge-listing/Listing/Bucket/index.jsx +++ b/src/shared/components/challenge-listing/Listing/Bucket/index.jsx @@ -91,7 +91,7 @@ export default function Bucket({ && ch.task && ch.task.isTask && ch.task.isAssigned - && Number(ch.task.memberId) !== Number(userId)) { + && `${ch.task.memberId}` !== `${userId}`) { return null; } return ch; @@ -313,7 +313,7 @@ Bucket.propTypes = { setFilterState: PT.func.isRequired, setSort: PT.func.isRequired, sort: PT.string, - userId: PT.number, + userId: PT.oneOfType([PT.number, PT.string]), auth: PT.shape(), expandedTags: PT.arrayOf(PT.number), expandTag: PT.func, From 5c711259f995df5e0f0bb868392a8fa35751514c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 23 Feb 2026 11:05:42 +1100 Subject: [PATCH 06/13] Updates to Wipro user not allowed messaging --- src/shared/containers/ErrorMessage.jsx | 99 ++++++++++++++++--- src/shared/containers/ErrorMessage.scss | 47 +++++++++ .../containers/challenge-detail/index.jsx | 6 +- 3 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 src/shared/containers/ErrorMessage.scss diff --git a/src/shared/containers/ErrorMessage.jsx b/src/shared/containers/ErrorMessage.jsx index 5fb3ac372..823463ebc 100644 --- a/src/shared/containers/ErrorMessage.jsx +++ b/src/shared/containers/ErrorMessage.jsx @@ -7,24 +7,93 @@ * be used directly. */ import { actions } from 'topcoder-react-lib'; +import { DangerButton, Modal } from 'topcoder-react-ui-kit'; import React from 'react'; import PT from 'prop-types'; import { connect } from 'react-redux'; -import { ErrorMessage } from 'topcoder-react-ui-kit'; +import style from './ErrorMessage.scss'; -function ErrorMessageContainer({ error, clearError }) { - return ( -
- { error - ? ( - clearError()} - /> - ) : undefined } -
- ); +const WIPRO_REGISTRATION_BLOCKED_MESSAGE = 'Wipro employees are not allowed to participate in this Topcoder challenge'; +const WIPRO_REGISTRATION_SUPPORT_MESSAGE = 'If you think this is an error, please contact support support@topcoder.com'; +const SCROLLING_DISABLED_CLASS_NAME = 'scrolling-disabled-by-modal'; + +/** + * Detects if the current error is the Wipro registration blocked popup. + * @param {Object} error Error payload. + * @return {Boolean} + */ +function isWiproRegistrationBlockedError(error) { + const title = error && error.title ? error.title : ''; + return title.trim().toLowerCase() === WIPRO_REGISTRATION_BLOCKED_MESSAGE.toLowerCase(); +} + +class ErrorMessageContainer extends React.Component { + componentDidMount() { + const { error } = this.props; + if (error) { + document.body.classList.add(SCROLLING_DISABLED_CLASS_NAME); + } + } + + componentDidUpdate(prevProps) { + const { error } = this.props; + if (!prevProps.error && error) { + document.body.classList.add(SCROLLING_DISABLED_CLASS_NAME); + } + if (prevProps.error && !error) { + document.body.classList.remove(SCROLLING_DISABLED_CLASS_NAME); + } + } + + componentWillUnmount() { + document.body.classList.remove(SCROLLING_DISABLED_CLASS_NAME); + } + + renderSupportMessage() { + const { error } = this.props; + if (isWiproRegistrationBlockedError(error)) { + return WIPRO_REGISTRATION_SUPPORT_MESSAGE; + } + + return ( + + We are sorry that you have encountered this problem. Please, contact + {' '} + our support + {' '} + support@topcoder.com + {' '} + to help us resolve it as soon as possible. + + ); + } + + render() { + const { error, clearError } = this.props; + const isWiproError = isWiproRegistrationBlockedError(error); + + return ( +
+ {error ? ( + +

{error.title}

+ {error.details && !isWiproError ? ( +

{error.details}

+ ) : null} +

{this.renderSupportMessage()}

+ { + e.preventDefault(); + clearError(); + }} + > + OK + +
+ ) : undefined} +
+ ); + } } /** @@ -41,7 +110,7 @@ ErrorMessageContainer.propTypes = { clearError: PT.func.isRequired, error: PT.shape({ title: PT.string.isRequired, - details: PT.string.isRequired, + details: PT.string, }), }; diff --git a/src/shared/containers/ErrorMessage.scss b/src/shared/containers/ErrorMessage.scss new file mode 100644 index 000000000..64ceef5f3 --- /dev/null +++ b/src/shared/containers/ErrorMessage.scss @@ -0,0 +1,47 @@ +@import '~styles/mixins'; + +$sm-space-10: $base-unit * 2; +$sm-space-15: $base-unit * 3; +$sm-space-25: $base-unit * 5; +$sm-space-40: $base-unit * 8; + +.container { + @include roboto-regular; + + overflow: hidden; + padding: 8 * $base-unit; + text-align: center; + + @include xs-to-sm { + padding: 40px 10px; + } +} + +.details { + font-weight: 400; + font-size: 13px; + color: $tc-gray-60; + line-height: $sm-space-25; + padding: 0 $sm-space-15; + margin-bottom: $sm-space-10; + text-align: justify; + + a { + color: $tc-dark-blue; + text-decoration: underline; + } +} + +.title { + color: $tc-red; + font-size: 15px; + font-weight: bold; + line-height: $sm-space-25; + margin-bottom: $sm-space-10; + padding: 0 $sm-space-15; + + .id { + color: #000; + font-weight: 500; + } +} diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index 127bcdafa..69fb56d6a 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -100,6 +100,7 @@ const DAY = 24 * 60 * MIN; const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); const { fireErrorMessage } = errors; const WIPRO_REGISTRATION_BLOCKED_MESSAGE = 'Wipro employees are not allowed to participate in this Topcoder challenge'; +const WIPRO_REGISTRATION_SUPPORT_MESSAGE = 'If you think this is an error, please contact support support@topcoder.com'; /** * Checks whether challenge registration should be blocked for Wipro members. @@ -386,7 +387,10 @@ class ChallengeDetailPageContainer extends React.Component { const wiproAllowed = _.get(challenge, 'wiproAllowed'); if (isWiproRegistrationBlocked(userEmail, wiproAllowed)) { - fireErrorMessage(WIPRO_REGISTRATION_BLOCKED_MESSAGE); + fireErrorMessage( + WIPRO_REGISTRATION_BLOCKED_MESSAGE, + WIPRO_REGISTRATION_SUPPORT_MESSAGE, + ); return; } From c37c38a50f6236c3cce20e753da6738c67229b9d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 24 Feb 2026 07:50:08 +1100 Subject: [PATCH 07/13] Fix for review summations not showing on NASA crater challenge --- .../containers/challenge-detail/index.jsx | 21 +++++ src/shared/utils/mm-review-summations.js | 79 +++++++++++++++++-- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index 69fb56d6a..3b972b09e 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -290,12 +290,14 @@ class ChallengeDetailPageContainer extends React.Component { componentWillReceiveProps(nextProps) { const { + auth, challengeId, reloadChallengeDetails, // getAllRecommendedChallenges, // recommendedChallenges, // auth, challenge, + statisticsData, // loadingRecommendedChallengesUUID, history, selectedTab, @@ -345,6 +347,25 @@ class ChallengeDetailPageContainer extends React.Component { reloadChallengeDetails(nextProps.auth, challengeId); } + const previousToken = _.get(auth, 'tokenV3'); + const nextToken = _.get(nextProps, 'auth.tokenV3'); + const hasStatisticsData = Array.isArray(statisticsData) && statisticsData.length > 0; + const nextHasStatisticsData = Array.isArray(nextProps.statisticsData) + && nextProps.statisticsData.length > 0; + const enteringMmDashboard = selectedTab !== DETAIL_TABS.MM_DASHBOARD + && nextProps.selectedTab === DETAIL_TABS.MM_DASHBOARD; + const tokenBecameAvailable = !previousToken && !!nextToken; + + if ( + checkIsMM(nextProps.challenge) + && nextToken + && (tokenBecameAvailable || enteringMmDashboard) + && !hasStatisticsData + && !nextHasStatisticsData + ) { + nextProps.fetchChallengeStatistics(nextProps.auth, nextProps.challenge); + } + const { track } = nextProps.challenge; if (track !== COMPETITION_TRACKS.DES && thriveArticles.length === 0) { // filter all tags with value 'Other' diff --git a/src/shared/utils/mm-review-summations.js b/src/shared/utils/mm-review-summations.js index dd9abe2dc..b5973f92c 100644 --- a/src/shared/utils/mm-review-summations.js +++ b/src/shared/utils/mm-review-summations.js @@ -21,6 +21,33 @@ function getSummationTimestamp(summation) { return _.find(candidates, value => !!value) || null; } +function getSummationScoreClassification(summation) { + const metadata = _.isObject(_.get(summation, 'metadata')) + ? _.get(summation, 'metadata') + : {}; + const type = _.toLower(_.toString(_.get(summation, 'type', '')).trim()); + const stage = _.toLower(_.toString(_.get(metadata, 'stage', '')).trim()); + const testType = _.toLower(_.toString(_.get(metadata, 'testType', '')).trim()); + + const isProvisional = Boolean( + _.get(summation, 'isProvisional') + || _.get(summation, 'is_provisional') + || type === 'provisional' + || testType === 'provisional', + ); + const isFinal = Boolean( + _.get(summation, 'isFinal') + || _.get(summation, 'is_final') + || type === 'final' + || stage === 'final', + ); + + return { + isProvisional, + isFinal, + }; +} + function toTimestampValue(value) { if (!value) { return 0; @@ -373,7 +400,10 @@ export function buildMmSubmissionData(reviewSummations = []) { const normalizedScore = normalizeScoreValue( _.get(summation, 'aggregateScore'), ); - const isProvisional = Boolean(summation.isProvisional); + const scoreType = getSummationScoreClassification(summation); + // Most MM review summations are provisional updates; if an entry does not + // explicitly identify itself as final, treat it as provisional. + const isProvisional = scoreType.isProvisional || !scoreType.isFinal; const isLatest = _.isNil(summation.isLatest) ? null : Boolean(summation.isLatest); @@ -415,16 +445,35 @@ export function buildMmSubmissionData(reviewSummations = []) { - toTimestampValue(a.submissionTime), ); - const hasLatestFlag = submissions.some(s => !_.isNil(s.isLatest)); + const latestProvisionalScore = _.chain(submissions) + .map(s => normalizeScoreValue(s.provisionalScore)) + .find(score => !_.isNil(score)) + .value(); + + const submissionsWithProvisionalFallback = submissions.map((submission) => { + const hasFinalScore = !_.isNil(normalizeScoreValue(submission.finalScore)); + const hasProvisionalScore = !_.isNil( + normalizeScoreValue(submission.provisionalScore), + ); + if (!hasFinalScore || hasProvisionalScore || _.isNil(latestProvisionalScore)) { + return submission; + } + return { + ...submission, + provisionalScore: latestProvisionalScore, + }; + }); + + const hasLatestFlag = submissionsWithProvisionalFallback.some(s => !_.isNil(s.isLatest)); const latestSubmissions = hasLatestFlag - ? submissions.filter(s => s.isLatest) - : submissions; + ? submissionsWithProvisionalFallback.filter(s => s.isLatest) + : submissionsWithProvisionalFallback; const candidates = latestSubmissions.length ? latestSubmissions - : submissions; + : submissionsWithProvisionalFallback; const latestSubmissionForRanking = (latestSubmissions.length ? latestSubmissions - : submissions)[0] || null; + : submissionsWithProvisionalFallback)[0] || null; // Provisional ranks should be based solely on the most recent submission, // not the best historical one. const bestProvisionalScore = normalizeScoreValue( @@ -463,7 +512,7 @@ export function buildMmSubmissionData(reviewSummations = []) { rating, provisionalRank: null, finalRank: null, - submissions, + submissions: submissionsWithProvisionalFallback, bestProvisionalScore, bestProvisionalTimestamp, bestFinalScore, @@ -524,6 +573,13 @@ export function buildStatisticsData(reviewSummations = []) { return []; } + const includeOnlyProvisional = reviewSummations.some((summation) => { + if (!summation) { + return false; + } + return getSummationScoreClassification(summation).isProvisional; + }); + const grouped = new Map(); reviewSummations.forEach((summation, index) => { @@ -550,6 +606,15 @@ export function buildStatisticsData(reviewSummations = []) { const timestamp = getSummationTimestamp(summation); const timestampValue = toTimestampValue(timestamp); const score = normalizeScoreValue(_.get(summation, 'aggregateScore')); + if (_.isNil(score)) { + return; + } + if (includeOnlyProvisional) { + const scoreType = getSummationScoreClassification(summation); + if (!scoreType.isProvisional) { + return; + } + } const rawSubmissionId = _.get( summation, From 56c9101710f622902093c78dbe78cacf08bd6e18 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 24 Feb 2026 11:21:28 +1100 Subject: [PATCH 08/13] Test fixes --- src/shared/utils/mm-review-summations.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/shared/utils/mm-review-summations.js b/src/shared/utils/mm-review-summations.js index d910c1ca5..89071b46b 100644 --- a/src/shared/utils/mm-review-summations.js +++ b/src/shared/utils/mm-review-summations.js @@ -619,19 +619,6 @@ export function buildStatisticsData(reviewSummations = []) { entry.rating = rating; } - const timestamp = getSummationTimestamp(summation); - const timestampValue = toTimestampValue(timestamp); - const score = normalizeScoreValue(_.get(summation, 'aggregateScore')); - if (_.isNil(score)) { - return; - } - if (includeOnlyProvisional) { - const scoreType = getSummationScoreClassification(summation); - if (!scoreType.isProvisional) { - return; - } - } - const rawSubmissionId = _.get( summation, 'submissionId', From f230dd8a0c49425aa663e2e728d88e04e0d70786 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 24 Feb 2026 18:42:34 +1100 Subject: [PATCH 09/13] PM-4027: show Fun label for home feed fun challenges What was broken Fun challenges in the /home opportunities feed displayed "$0" instead of a fun-specific label. Root cause The dashboard opportunities component always rendered the summed placement prize value and ignored the funChallenge flag. What was changed - Added fun challenge handling in dashboard opportunities prize rendering so challenge.funChallenge === true displays "Fun". - Preserved existing USD/POINT amount rendering for non-fun challenges. - Replaced Array.prototype.flatMap with _.flatMap in this component for Node 10 compatibility. Any added/updated tests - Added __tests__/shared/components/Dashboard/Challenges/index.jsx with regression coverage for fun challenge label rendering and regular prize rendering. --- .../components/Dashboard/Challenges/index.jsx | 49 +++++++++++++++++++ .../components/Dashboard/Challenges/index.jsx | 13 +++-- 2 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 __tests__/shared/components/Dashboard/Challenges/index.jsx diff --git a/__tests__/shared/components/Dashboard/Challenges/index.jsx b/__tests__/shared/components/Dashboard/Challenges/index.jsx new file mode 100644 index 000000000..c6e5af21c --- /dev/null +++ b/__tests__/shared/components/Dashboard/Challenges/index.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; + +import ChallengesFeed from 'components/Dashboard/Challenges'; + +function renderChallenge(challenge) { + return renderer.create(( + + )).toJSON(); +} + +describe('Dashboard Challenges feed', () => { + test('shows Fun label for fun challenges', () => { + const view = renderChallenge({ + id: 'challenge-fun', + name: 'Challenge one', + funChallenge: true, + prizeSets: [ + { + type: 'PLACEMENT', + prizes: [{ type: 'USD', value: 0 }], + }, + ], + }); + + const text = JSON.stringify(view); + expect(text).toContain('Fun'); + expect(text).not.toContain('$0'); + }); + + test('shows calculated prize amount for regular challenges', () => { + const view = renderChallenge({ + id: 'challenge-regular', + name: 'Challenge two', + funChallenge: false, + prizeSets: [ + { + type: 'PLACEMENT', + prizes: [{ type: 'USD', value: 44 }], + }, + ], + }); + + expect(JSON.stringify(view)).toContain('$44'); + }); +}); diff --git a/src/shared/components/Dashboard/Challenges/index.jsx b/src/shared/components/Dashboard/Challenges/index.jsx index 251c2b3c9..88e516f69 100644 --- a/src/shared/components/Dashboard/Challenges/index.jsx +++ b/src/shared/components/Dashboard/Challenges/index.jsx @@ -39,13 +39,18 @@ export default function ChallengesFeed({ ) : ( (challenges || []).map((challenge) => { - const placementPrizes = challenge.prizeSets - .filter(set => set.type === 'PLACEMENT') - .flatMap(item => item.prizes); + const isFunChallenge = challenge.funChallenge === true; + const placementPrizes = _.flatMap( + (challenge.prizeSets || []).filter(set => set.type === 'PLACEMENT'), + item => item.prizes, + ); const prizeTotal = _.sum(placementPrizes.map(prize => prize.value)); const prizeType = placementPrizes.length > 0 ? placementPrizes[0].type : null; const isPointBasedPrize = prizeType === 'POINT'; const prizeSymbol = isPointBasedPrize ? '' : '$'; + const prizeDisplay = isFunChallenge + ? 'Fun' + : `${prizeSymbol}${prizeTotal.toLocaleString()}`; return (
@@ -58,7 +63,7 @@ export default function ChallengesFeed({
- {`${prizeSymbol}${prizeTotal.toLocaleString()}`} + {prizeDisplay}
From 85beb768be5235fbbd040f8472106a7703bfab4a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 24 Feb 2026 18:51:13 +1100 Subject: [PATCH 10/13] PM-4025: show Fun label for fun challenges in challenge feed What was broken:\nFun challenges in the challenge listing feed displayed a blank prize amount area.\n\nRoot cause:\nThe prize renderer only handled placement prize sets and returned null when none were present, which is the case for fun challenges.\n\nWhat was changed:\nUpdated getPrizePointsUI to return "Fun" when challenge.funChallenge is true, before placement-prize handling.\n\nAny added/updated tests:\nAdded __tests__/shared/utils/challenge-detail/helper.jsx to verify fun challenges without placement prizes render "Fun". --- __tests__/shared/utils/challenge-detail/helper.jsx | 12 ++++++++++++ src/shared/utils/challenge-detail/helper.jsx | 4 ++++ 2 files changed, 16 insertions(+) create mode 100644 __tests__/shared/utils/challenge-detail/helper.jsx diff --git a/__tests__/shared/utils/challenge-detail/helper.jsx b/__tests__/shared/utils/challenge-detail/helper.jsx new file mode 100644 index 000000000..3f403de2a --- /dev/null +++ b/__tests__/shared/utils/challenge-detail/helper.jsx @@ -0,0 +1,12 @@ +import { getPrizePointsUI } from 'utils/challenge-detail/helper'; + +describe('utils/challenge-detail/helper', () => { + describe('getPrizePointsUI', () => { + test('returns Fun for fun challenges without placement prizes', () => { + expect(getPrizePointsUI({ + funChallenge: true, + prizeSets: [], + })).toBe('Fun'); + }); + }); +}); diff --git a/src/shared/utils/challenge-detail/helper.jsx b/src/shared/utils/challenge-detail/helper.jsx index b0d7d7398..4b6ad7435 100644 --- a/src/shared/utils/challenge-detail/helper.jsx +++ b/src/shared/utils/challenge-detail/helper.jsx @@ -160,6 +160,10 @@ export function getPrizePurseUI( * @param {Object} challenge challenge info */ export function getPrizePointsUI(challenge) { + if (challenge.funChallenge === true) { + return 'Fun'; + } + const placementPrizes = _.find( challenge.prizeSets, prizeSet => ((prizeSet && prizeSet.type) || '').toLowerCase() === 'placement', From 1ada131c7452d9b9b2b7af202d150623d101856e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 25 Feb 2026 12:06:51 +1100 Subject: [PATCH 11/13] Fix for wipro flag error message spacing --- src/shared/containers/ErrorMessage.jsx | 6 ++++-- src/shared/containers/ErrorMessage.scss | 4 ++++ src/shared/containers/challenge-detail/index.jsx | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/shared/containers/ErrorMessage.jsx b/src/shared/containers/ErrorMessage.jsx index 823463ebc..e28dc4eea 100644 --- a/src/shared/containers/ErrorMessage.jsx +++ b/src/shared/containers/ErrorMessage.jsx @@ -14,7 +14,7 @@ import { connect } from 'react-redux'; import style from './ErrorMessage.scss'; const WIPRO_REGISTRATION_BLOCKED_MESSAGE = 'Wipro employees are not allowed to participate in this Topcoder challenge'; -const WIPRO_REGISTRATION_SUPPORT_MESSAGE = 'If you think this is an error, please contact support support@topcoder.com'; +const WIPRO_REGISTRATION_SUPPORT_MESSAGE = 'If you think this is an error, please contact support@topcoder.com'; const SCROLLING_DISABLED_CLASS_NAME = 'scrolling-disabled-by-modal'; /** @@ -80,7 +80,9 @@ class ErrorMessageContainer extends React.Component { {error.details && !isWiproError ? (

{error.details}

) : null} -

{this.renderSupportMessage()}

+

+ {this.renderSupportMessage()} +

{ e.preventDefault(); diff --git a/src/shared/containers/ErrorMessage.scss b/src/shared/containers/ErrorMessage.scss index 64ceef5f3..0fb5040d5 100644 --- a/src/shared/containers/ErrorMessage.scss +++ b/src/shared/containers/ErrorMessage.scss @@ -32,6 +32,10 @@ $sm-space-40: $base-unit * 8; } } +.wiproSupport { + text-align: center; +} + .title { color: $tc-red; font-size: 15px; diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index a10e0e7be..66f59c5f1 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -100,7 +100,7 @@ const DAY = 24 * 60 * MIN; const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); const { fireErrorMessage } = errors; const WIPRO_REGISTRATION_BLOCKED_MESSAGE = 'Wipro employees are not allowed to participate in this Topcoder challenge'; -const WIPRO_REGISTRATION_SUPPORT_MESSAGE = 'If you think this is an error, please contact support support@topcoder.com'; +const WIPRO_REGISTRATION_SUPPORT_MESSAGE = 'If you think this is an error, please contact support@topcoder.com'; /** * Checks whether challenge registration should be blocked for Wipro members. From 266f455286301cb6342ef6a97b5bd6047e741e59 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 27 Feb 2026 14:11:11 +1100 Subject: [PATCH 12/13] Fun challenge prize display handling --- .../challenge-detail/Registrants/index.jsx | 2 +- .../challenge-detail/Winners/Winner/index.jsx | 13 ++++++++----- .../components/challenge-detail/Winners/index.jsx | 4 ++++ src/shared/containers/challenge-detail/index.jsx | 1 + 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/shared/components/challenge-detail/Registrants/index.jsx b/src/shared/components/challenge-detail/Registrants/index.jsx index 0e90622b4..9b24d8989 100644 --- a/src/shared/components/challenge-detail/Registrants/index.jsx +++ b/src/shared/components/challenge-detail/Registrants/index.jsx @@ -293,7 +293,7 @@ export default class Registrants extends React.Component { prizeSets, prizeSet => ((prizeSet && prizeSet.type) || '').toLowerCase() === 'placement', ); - const { prizes } = placementPrizes || []; + const prizes = _.get(placementPrizes, 'prizes', []); const checkpoints = challenge.checkpoints || []; diff --git a/src/shared/components/challenge-detail/Winners/Winner/index.jsx b/src/shared/components/challenge-detail/Winners/Winner/index.jsx index 7cb6a54cc..aa311f262 100644 --- a/src/shared/components/challenge-detail/Winners/Winner/index.jsx +++ b/src/shared/components/challenge-detail/Winners/Winner/index.jsx @@ -21,6 +21,7 @@ export default function Winner({ isMM, isRDM, prizes, + isFunChallenge, submissions, viewable, winner, @@ -52,12 +53,12 @@ export default function Winner({ prizeType = prizes[prizeIndex].type; } - // Handle point prizes on the winners display + // Hide prize text for fun challenges, as they do not have individual payouts. let prizeText = ''; - if (prizeType === 'POINT') { - prizeText = numberWithCommas(prize); - } else { - prizeText = `$${numberWithCommas(prize)}`; + if (!isFunChallenge) { + prizeText = prizeType === 'POINT' + ? numberWithCommas(prize) + : `$${numberWithCommas(prize)}`; } return ( @@ -157,6 +158,7 @@ export default function Winner({ Winner.defaultProps = { prizes: [], + isFunChallenge: false, }; Winner.propTypes = { @@ -164,6 +166,7 @@ Winner.propTypes = { isMM: PT.bool.isRequired, isRDM: PT.bool.isRequired, prizes: PT.arrayOf(PT.shape()), + isFunChallenge: PT.bool, submissions: PT.arrayOf(PT.object).isRequired, viewable: PT.bool.isRequired, winner: PT.shape({ diff --git a/src/shared/components/challenge-detail/Winners/index.jsx b/src/shared/components/challenge-detail/Winners/index.jsx index a29dca041..4499dd48c 100644 --- a/src/shared/components/challenge-detail/Winners/index.jsx +++ b/src/shared/components/challenge-detail/Winners/index.jsx @@ -18,6 +18,7 @@ const { getService } = services.submissions; export default function Winners({ winners, prizes, + isFunChallenge, submissions, viewable, isDesign, @@ -94,6 +95,7 @@ export default function Winners({ isRDM={isRDM} key={`${w.handle}-${w.placement}`} prizes={prizes} + isFunChallenge={isFunChallenge} submissions={submissions} viewable={viewable} winner={w} @@ -109,6 +111,7 @@ export default function Winners({ Winners.defaultProps = { winners: [], prizes: [], + isFunChallenge: false, submissions: [], viewable: false, isDesign: false, @@ -121,6 +124,7 @@ Winners.defaultProps = { Winners.propTypes = { winners: PT.arrayOf(PT.shape()), prizes: PT.arrayOf(PT.shape()), + isFunChallenge: PT.bool, submissions: PT.arrayOf(PT.shape()), viewable: PT.bool, isDesign: PT.bool, diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index 66f59c5f1..3327d436b 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -770,6 +770,7 @@ class ChallengeDetailPageContainer extends React.Component { Date: Fri, 6 Mar 2026 10:39:00 +1100 Subject: [PATCH 13/13] Filter to only show OPEN status engagements (PM-4200) --- src/shared/actions/engagements.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/shared/actions/engagements.js b/src/shared/actions/engagements.js index 16dbc8cd6..ec53c2dd4 100644 --- a/src/shared/actions/engagements.js +++ b/src/shared/actions/engagements.js @@ -11,9 +11,14 @@ function getEngagementsInit(uuid, page, filters) { return { uuid, page, filters }; } +/** + * Fetches public engagements for the requested page and filters. + * The `status` filter is always forced to `OPEN` for the public feed. + */ async function getEngagementsDone(uuid, page, filters, tokenV3) { try { - const { engagements, meta } = await getEngagements(page, PAGE_SIZE, filters, tokenV3); + const publicFilters = { ...filters, status: 'OPEN' }; + const { engagements, meta } = await getEngagements(page, PAGE_SIZE, publicFilters, tokenV3); return { uuid,