Skip to content

PM-1188 copilot opportunities on challenge feed #7094

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 19, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/backup-default.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ module.exports = {
/* This is the same value as above, but it is used by topcoder-react-lib,
* as a more verbose name for the param. */
COMMUNITY_APP: 'https://community-app.topcoder-dev.com',
COPILOTS_URL: 'https://copilots.topcoder-dev.com',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is hard-coded value we need to add it also in https://github.com/topcoder-platform/community-app/blob/develop/config/production.js

CHALLENGES_URL: 'https://www.topcoder-dev.com/challenges',
TCO_OPEN_URL: 'https://www.topcoder-dev.com/community/member-programs/topcoder-open',
ARENA: 'https://arena.topcoder-dev.com',
Expand Down
1 change: 1 addition & 0 deletions config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ module.exports = {
* as a more verbose name for the param. */
COMMUNITY_APP: 'https://community-app.topcoder-dev.com',
CHALLENGES_URL: 'https://www.topcoder-dev.com/challenges',
COPILOTS_URL: 'https://copilots.topcoder-dev.com',
TCO_OPEN_URL: 'https://www.topcoder-dev.com/community/member-programs/topcoder-open',
ARENA: 'https://arena.topcoder-dev.com',
AUTH: 'https://accounts-auth0.topcoder-dev.com',
Expand Down
21 changes: 21 additions & 0 deletions src/shared/actions/challenge-listing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { processSRM } from 'utils/tc';
import { errors, services } from 'topcoder-react-lib';
import { BUCKETS } from 'utils/challenge-listing/buckets';
import SORT from 'utils/challenge-listing/sort';
import getCopilotOpportunities from '../../services/copilotOpportunities';

const { fireErrorMessage } = errors;
const { getService } = services.challenge;
Expand All @@ -25,6 +26,8 @@ const PAGE_SIZE = 10;
*/
const REVIEW_OPPORTUNITY_PAGE_SIZE = 1000;

const COPILOT_OPPORTUNITY_PAGE_SIZE = 20;

/**
* Private. Loads from the backend all challenges matching some conditions.
* @param {Function} getter Given params object of shape { limit, offset }
Expand Down Expand Up @@ -496,6 +499,21 @@ function getReviewOpportunitiesDone(uuid, page, tokenV3) {
});
}

/**
* Action to get a list of currently open Copilot Opportunities using V5 API
* @param {String} uuid Unique identifier for init/done instance from shortid module
* @param {Number} page Page of copilot opportunities to fetch (1-based)
* @return {Promise<{uuid: string, loaded: object}>} Action result
*/
function getCopilotOpportunitiesDone(uuid, page) {
return getCopilotOpportunities(page, COPILOT_OPPORTUNITY_PAGE_SIZE)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider handling the case where getCopilotOpportunities might return a non-object or unexpected data structure. This could prevent potential runtime errors if the API response changes.

.then(loaded => ({ uuid, loaded }))
.catch((error) => {
fireErrorMessage('Error Getting Copilot Opportunities', error.content || error);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fireErrorMessage function is called with error.content || error. Ensure that error.content is a valid property and consider logging the entire error object for better debugging information.

return Promise.reject(error);
});
}

/**
* Payload creator for the action that inits the loading of SRMs.
* @param {String} uuid
Expand Down Expand Up @@ -610,6 +628,9 @@ export default createActions({
GET_REVIEW_OPPORTUNITIES_INIT: (uuid, page) => ({ uuid, page }),
GET_REVIEW_OPPORTUNITIES_DONE: getReviewOpportunitiesDone,

GET_COPILOT_OPPORTUNITIES_INIT: (uuid, page) => ({ uuid, page }),
GET_COPILOT_OPPORTUNITIES_DONE: getCopilotOpportunitiesDone,

GET_SRMS_INIT: getSrmsInit,
GET_SRMS_DONE: getSrmsDone,

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Component for rendering a Copilot Opportunity and associated Challenge
* information. Will be contained within a Bucket.
*/
import _ from 'lodash';
import { config } from 'topcoder-react-utils';
import moment from 'moment';
import React, { useMemo } from 'react';
import PT from 'prop-types';

import Tags from '../Tags';

import './style.scss';

const PROJECT_TYPE_LABELS = {
dev: 'Development',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a default label for project types that are not listed in PROJECT_TYPE_LABELS to handle unexpected values gracefully.

ai: 'AI (Artificial Intelligence)',
design: 'Design',
datascience: 'DataScience',
qa: 'Quality Assurance',
};

function CopilotOpportunityCard({
opportunity,
}) {
const skills = useMemo(() => _.uniq((opportunity.skills || []).map(skill => skill.name)), [
opportunity.skills,
]);
const start = moment(opportunity.startDate);

return (
<div styleName="copilotOpportunityCard">
<div styleName="left-panel">

<div styleName="challenge-details">
<a
href={`${config.URL.COPILOTS_URL}/opportunity/${opportunity.id}`}
target="_blank"
rel="noopener noreferrer"
>
{opportunity.project.name}
</a>

<div styleName="details-footer">
<span styleName="date">
Starts {start.format('MMM DD')}
</span>
{ skills.length > 0
&& (
<Tags
skills={skills}
/>
) }
</div>
</div>
</div>

<div styleName="right-panel">
<div styleName="type">
<span>{PROJECT_TYPE_LABELS[opportunity.type]}</span>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a fallback for PROJECT_TYPE_LABELS[opportunity.type] in case opportunity.type is not a key in PROJECT_TYPE_LABELS to prevent rendering undefined.

</div>
<div styleName={`status ${opportunity.status === 'completed' ? 'completed' : ''}`}>
<span>{opportunity.status}</span>
</div>
<div styleName="numHours">
<span>{opportunity.numHoursPerWeek} hours/week</span>
</div>
</div>
</div>
);
}

CopilotOpportunityCard.propTypes = {
opportunity: PT.shape().isRequired,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opportunity prop type should be more specific. Define the shape with expected properties and their types to improve type safety and documentation.

};

export default CopilotOpportunityCard;
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
@import '~styles/mixins';

$challenge-space-10: $base-unit * 2;
$challenge-space-15: $base-unit * 3;
$challenge-space-20: $base-unit * 4;
$challenge-space-30: $base-unit * 6;
$challenge-space-40: $base-unit * 8;
$challenge-space-45: $base-unit * 9;
$challenge-space-50: $base-unit * 10;
$challenge-space-90: $base-unit * 18;
$status-space-10: $base-unit * 2;
$status-space-15: $base-unit * 3;
$status-space-20: $base-unit * 4;
$status-space-25: $base-unit * 5;
$status-space-30: $base-unit * 6;
$status-space-40: $base-unit * 8;
$status-space-50: $base-unit * 10;
$status-radius-1: $corner-radius / 2;
$status-radius-4: $corner-radius * 2;

.copilotOpportunityCard {
@include roboto-medium;

display: flex;
justify-content: flex-start;
position: relative;
background: $tc-white;
padding: $challenge-space-20 0;
border-top: 1px solid $tc-gray-10;
color: $tc-black;
font-size: 15px;
margin-left: 24px;

&:last-child {
border-bottom: 1px solid $tc-gray-10;
}

@include xs-to-md {
flex-wrap: wrap;
padding: $base-unit * 3 0;
margin-left: 0;
flex-direction: column;
}

@include xs-to-sm {
position: relative;
}

a,
a:visited {
color: $tc-black;
}

a:hover {
color: $tc-dark-blue-110;
}

.left-panel {
display: flex;
justify-content: flex-start;
width: 45.5%;

@include xs-to-md {
width: 100%;
padding: 0 16px;
}
}

.right-panel {
display: flex;
justify-content: space-between;
width: 50%;

@include xs-to-md {
width: 100%;
display: flex;
}

@include xs-to-sm {
display: flex;
}
}

// Challenge title, end date & technologies
.challenge-details {
display: inline-block;
vertical-align: baseline;
width: 82%;
margin-right: $challenge-space-30;

@include md {
margin-right: 180px;
}

@include xs-to-sm {
margin-right: 0;
}

a {
line-height: $challenge-space-20;

@include xs-to-sm {
display: inline-block;
}
}
}

.details-footer {
width: 100%;
margin-top: 16px;
display: flex;

.date {
font-size: 12px;
color: $tc-gray-60;
margin-right: $challenge-space-10;
line-height: ($challenge-space-10) + 2;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The line-height calculation ($challenge-space-10) + 2 might not work as intended. Consider using calc() for arithmetic operations in CSS, like calc(#{$challenge-space-10} + 2px).

font-weight: normal;
margin-top: 2px;
}
}

// Review payment
.status {
@include roboto-medium;

display: inline-block;
font-size: 13px;
font-weight: 500;
color: green;
line-height: $challenge-space-20;
margin-right: $challenge-space-20;
min-width: $challenge-space-50 + 2;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The min-width property value $challenge-space-50 + 2 might not be calculated correctly. Consider using calc() for arithmetic operations, like calc(#{$challenge-space-50} + 2px).

width: 30%;

&.completed {
color: $tc-orange;
}

@include xs-to-md {
position: absolute;
right: 0;
top: 20px;
margin-right: $challenge-space-20;
margin-bottom: $challenge-space-20;
}

@include xs-to-sm {
position: relative;
display: block;
margin-top: $challenge-space-30;
margin-bottom: $challenge-space-45;
margin-left: $challenge-space-15;
top: 0;
}

@include md {
right: 108px;
}

// $ Symbol
span {
font-weight: 500;
font-size: 14px;
line-height: 16px;
text-transform: capitalize;
}
}

.type {
@include roboto-medium;

width: 40%;

@include xs-to-md {
position: absolute;
right: 0;
top: 20px;
margin-right: $challenge-space-20;
margin-bottom: $challenge-space-20;
}

@include xs-to-sm {
position: relative;
display: block;
margin-top: $challenge-space-30;
margin-bottom: $challenge-space-45;
margin-left: $challenge-space-15;
top: 0;
}

@include md {
right: 108px;
}

span {
color: $tc-black;
font-weight: 500;
font-size: 14px;
line-height: 16px;
text-transform: capitalize;
}
}

.numHours {

width: 30%;

@include xs-to-md {
position: absolute;
right: 0;
top: 20px;
margin-right: $challenge-space-20;
margin-bottom: $challenge-space-20;
}

@include xs-to-sm {
position: relative;
display: block;
margin-top: $challenge-space-30;
margin-bottom: $challenge-space-45;
margin-left: $challenge-space-15;
top: 0;
}

@include md {
right: 108px;
}

span {
color: $tc-black;
font-weight: 500;
font-size: 14px;
line-height: 16px;
}
}


}

Loading