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/__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/__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/__tests__/shared/containers/challenge-detail/index.jsx b/__tests__/shared/containers/challenge-detail/index.jsx new file mode 100644 index 000000000..cff13840e --- /dev/null +++ b/__tests__/shared/containers/challenge-detail/index.jsx @@ -0,0 +1,50 @@ +import { getDisplayWinners, 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); + }); +}); + +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/__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/__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/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, 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}
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 d7b79956a..d63b588ef 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 { 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/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, diff --git a/src/shared/containers/ErrorMessage.jsx b/src/shared/containers/ErrorMessage.jsx index 5fb3ac372..e28dc4eea 100644 --- a/src/shared/containers/ErrorMessage.jsx +++ b/src/shared/containers/ErrorMessage.jsx @@ -7,24 +7,95 @@ * 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@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 +112,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..0fb5040d5 --- /dev/null +++ b/src/shared/containers/ErrorMessage.scss @@ -0,0 +1,51 @@ +@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; + } +} + +.wiproSupport { + text-align: center; +} + +.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 57a312467..3327d436b 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,41 @@ 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'; +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. + * @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 || '')); +} + +/** + * 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'; + }); +} function hasRenderableStatisticsData(statisticsData) { return Array.isArray(statisticsData) @@ -373,8 +408,20 @@ 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, + WIPRO_REGISTRATION_SUPPORT_MESSAGE, + ); + return; + } + if (!auth.tokenV3) { const utmSource = communityId || 'community-app-main'; window.location.href = appendUtmParamsToUrl( @@ -512,10 +559,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)) { @@ -726,6 +770,7 @@ class ChallengeDetailPageContainer extends React.Component { ((prizeSet && prizeSet.type) || '').toLowerCase() === 'placement',