diff --git a/.circleci/config.yml b/.circleci/config.yml
index 5e180c60ef..d723a4d47b 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -276,15 +276,13 @@ workflows:
branches:
only:
- develop
- - remove-outage-banner
# This is alternate dev env for parallel testing
- "build-test":
context : org-global
filters:
branches:
- only:
+ only:
- free
-
# This is alternate dev env for parallel testing
- "build-qa":
context : org-global
@@ -306,7 +304,6 @@ workflows:
branches:
only:
- develop
- - remove-outage-banner
- "approve-smoke-test-on-staging":
type: approval
requires:
diff --git a/Dockerfile b/Dockerfile
index 97e99ee223..522cbc8681 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -72,6 +72,9 @@ ARG SENDGRID_API_KEY
ARG GROWSURF_API_KEY
ARG GROWSURF_CAMPAIGN_ID
+# Optimizely
+ARG OPTIMIZELY_SDK_KEY
+
################################################################################
# Setting of environment variables in the Docker image.
@@ -131,6 +134,9 @@ ENV GROWSURF_API_KEY=$GROWSURF_API_KEY
ENV GROWSURF_CAMPAIGN_ID=$GROWSURF_CAMPAIGN_ID
ENV GSHEETS_API_KEY=$GSHEETS_API_KEY
+# Optimizely
+ENV OPTIMIZELY_SDK_KEY=$OPTIMIZELY_SDK_KEY
+
################################################################################
# Testing and build of the application inside the container.
diff --git a/build.sh b/build.sh
index 32b4414968..7a52674ba8 100755
--- a/build.sh
+++ b/build.sh
@@ -48,6 +48,7 @@ docker build -t $TAG \
--build-arg GROWSURF_API_KEY=$GROWSURF_API_KEY \
--build-arg GROWSURF_CAMPAIGN_ID=$GROWSURF_CAMPAIGN_ID \
--build-arg GSHEETS_API_KEY=$GSHEETS_API_KEY \
+ --build-arg OPTIMIZELY_SDK_KEY=$OPTIMIZELY_SDK_KEY \
--build-arg COMMUNITY_APP_URL=$COMMUNITY_APP_URL .
# Copies "node_modules" from the created image, if necessary for caching.
diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js
index 34ffc3cb89..53674442de 100644
--- a/config/custom-environment-variables.js
+++ b/config/custom-environment-variables.js
@@ -108,4 +108,7 @@ module.exports = {
TOKEN_CACHE_TIME: 'TOKEN_CACHE_TIME',
},
GSHEETS_API_KEY: 'GSHEETS_API_KEY',
+ OPTIMIZELY: {
+ SDK_KEY: 'OPTIMIZELY_SDK_KEY',
+ },
};
diff --git a/config/default.js b/config/default.js
index 161136e9e2..ec258f40b2 100644
--- a/config/default.js
+++ b/config/default.js
@@ -426,4 +426,7 @@ module.exports = {
DEBOUNCE_ON_CHANGE_TIME: 150,
},
ENABLE_RECOMMENDER: true,
+ OPTIMIZELY: {
+ SDK_KEY: '7V4CJhurXT3Y3bnzv1hv1',
+ },
};
diff --git a/package.json b/package.json
index 28e53ff3ad..4c43b84f24 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
},
"dependencies": {
"@hapi/joi": "^16.1.4",
+ "@optimizely/react-sdk": "^2.5.0",
"@topcoder-platform/tc-auth-lib": "topcoder-platform/tc-auth-lib#1.0.4",
"aos": "^2.3.4",
"atob": "^2.1.1",
diff --git a/src/assets/images/gig-work/tag-dolars.png b/src/assets/images/gig-work/tag-dolars.png
new file mode 100644
index 0000000000..ec9ca00301
Binary files /dev/null and b/src/assets/images/gig-work/tag-dolars.png differ
diff --git a/src/assets/images/gig-work/tag-hot.png b/src/assets/images/gig-work/tag-hot.png
new file mode 100644
index 0000000000..5bc7676d60
Binary files /dev/null and b/src/assets/images/gig-work/tag-hot.png differ
diff --git a/src/assets/images/gig-work/tag-new.png b/src/assets/images/gig-work/tag-new.png
new file mode 100644
index 0000000000..819c49cc10
Binary files /dev/null and b/src/assets/images/gig-work/tag-new.png differ
diff --git a/src/server/services/recruitCRM.js b/src/server/services/recruitCRM.js
index 036271d110..10c9c1f97e 100644
--- a/src/server/services/recruitCRM.js
+++ b/src/server/services/recruitCRM.js
@@ -26,6 +26,7 @@ const JOB_FIELDS_RESPONSE = [
'salary_type',
'max_annual_salary',
'job_description_text',
+ 'job_status',
];
const CANDIDATE_FIELDS_RESPONSE = [
'id',
diff --git a/src/shared/components/GUIKit/JobListCard/index.jsx b/src/shared/components/GUIKit/JobListCard/index.jsx
index adfdfe4f48..536a4f647e 100644
--- a/src/shared/components/GUIKit/JobListCard/index.jsx
+++ b/src/shared/components/GUIKit/JobListCard/index.jsx
@@ -6,14 +6,24 @@ import React from 'react';
import PT from 'prop-types';
import { config, Link } from 'topcoder-react-utils';
import { getSalaryType, getCustomField } from 'utils/gigs';
+import { withOptimizely } from '@optimizely/react-sdk';
import './style.scss';
import IconBlackDuration from 'assets/images/icon-black-duration.svg';
import IconBlackLocation from 'assets/images/icon-black-location.svg';
import IconBlackPayment from 'assets/images/icon-black-payment.svg';
import iconBlackSkills from 'assets/images/icon-skills.png';
+import newTag from 'assets/images/gig-work/tag-new.png';
+import hotTag from 'assets/images/gig-work/tag-hot.png';
+import dolarsTag from 'assets/images/gig-work/tag-dolars.png';
-export default function JobListCard({
+const TAGS = {
+ New: newTag,
+ Hot: hotTag,
+ $$$: dolarsTag,
+};
+function JobListCard({
job,
+ optimizely,
}) {
const duration = getCustomField(job.custom_fields, 'Duration');
let skills = getCustomField(job.custom_fields, 'Technologies Required');
@@ -25,10 +35,17 @@ export default function JobListCard({
skills = skills.join(', ');
}
}
+ const tag = getCustomField(job.custom_fields, 'Job Tag');
+ const onHotlistApply = () => {
+ optimizely.track('View Details Click');
+ };
return (
-
{job.name}
+ {
+ tag !== 'n/a' &&

+ }
+
{job.name}

{skills}
@@ -43,7 +60,7 @@ export default function JobListCard({
{/^\d+$/.test(duration) ? `${duration} Weeks` : duration}
- VIEW DETAILS
+ VIEW DETAILS
@@ -56,4 +73,7 @@ JobListCard.defaultProps = {
JobListCard.propTypes = {
job: PT.shape().isRequired,
+ optimizely: PT.shape().isRequired,
};
+
+export default withOptimizely(JobListCard);
diff --git a/src/shared/components/GUIKit/JobListCard/style.scss b/src/shared/components/GUIKit/JobListCard/style.scss
index 1252ab2fd4..9cc3ca808a 100644
--- a/src/shared/components/GUIKit/JobListCard/style.scss
+++ b/src/shared/components/GUIKit/JobListCard/style.scss
@@ -7,13 +7,23 @@
display: flex;
flex-direction: column;
color: #2a2a2a;
- padding: 25px 35px;
+ padding: 25px 35px 25px 44px;
margin-bottom: 15px;
+ position: relative;
@include gui-kit-headers;
@include gui-kit-content;
@include roboto-regular;
+ .gig-tag {
+ position: absolute;
+ top: -1px;
+ left: 10px;
+ width: 24px;
+ height: 56px;
+ border-radius: 0;
+ }
+
.gig-name,
.gig-name:visited,
.gig-name:active,
@@ -50,18 +60,38 @@
&:first-child {
width: 250px;
+
+ @media (max-width: 1280px) {
+ width: auto;
+ margin-right: 20px;
+ }
}
&:nth-child(2) {
width: 204px;
+
+ @media (max-width: 1280px) {
+ width: auto;
+ margin-right: 20px;
+ }
}
&:nth-child(3) {
width: 263px;
+
+ @media (max-width: 1280px) {
+ width: auto;
+ margin-right: 20px;
+ }
}
&:nth-child(4) {
width: 255px;
+
+ @media (max-width: 1280px) {
+ width: auto;
+ margin-right: 20px;
+ }
}
&:last-child {
diff --git a/src/shared/components/Gigs/GigDetails/index.jsx b/src/shared/components/Gigs/GigDetails/index.jsx
index a28eb790af..0da0bca08d 100644
--- a/src/shared/components/Gigs/GigDetails/index.jsx
+++ b/src/shared/components/Gigs/GigDetails/index.jsx
@@ -66,7 +66,7 @@ export default function GigDetails(props) {
return (
{
- job.error || job.enable_job_application_form !== 1 ? (
+ job.error || job.job_status.id !== 1 || job.enable_job_application_form !== 1 ? (
{ job.error ?
: null }
{ job.error ? 'Gig does not exist' : 'This Gig has been Fulfilled'}
diff --git a/src/shared/containers/Gigs/RecruitCRMJobApply.jsx b/src/shared/containers/Gigs/RecruitCRMJobApply.jsx
index f418265630..cba026118b 100644
--- a/src/shared/containers/Gigs/RecruitCRMJobApply.jsx
+++ b/src/shared/containers/Gigs/RecruitCRMJobApply.jsx
@@ -10,8 +10,10 @@ import PT from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { isValidEmail } from 'utils/tc';
+import { withOptimizely } from '@optimizely/react-sdk';
import techSkills from './techSkills';
+
const countries = require('i18n-iso-countries');
countries.registerLocale(require('i18n-iso-countries/langs/en.json'));
@@ -120,12 +122,13 @@ class RecruitCRMJobApplyContainer extends React.Component {
}
onApplyClick() {
- const { applyForJob, job } = this.props;
+ const { applyForJob, job, optimizely } = this.props;
const { formData } = this.state;
this.validateForm();
this.setState((state) => {
if (_.isEmpty(state.formErrors)) {
applyForJob(job, formData);
+ optimizely.track('Submit Application Form');
}
});
}
@@ -269,6 +272,7 @@ RecruitCRMJobApplyContainer.propTypes = {
application: PT.shape(),
searchCandidates: PT.func.isRequired,
recruitProfile: PT.shape(),
+ optimizely: PT.shape().isRequired,
};
function mapStateToProps(state, ownProps) {
@@ -312,4 +316,4 @@ function mapDispatchToActions(dispatch) {
export default connect(
mapStateToProps,
mapDispatchToActions,
-)(RecruitCRMJobApplyContainer);
+)(withOptimizely(RecruitCRMJobApplyContainer));
diff --git a/src/shared/containers/Gigs/RecruitCRMJobs.jsx b/src/shared/containers/Gigs/RecruitCRMJobs.jsx
index 77c4770fed..cfb3f2aadf 100644
--- a/src/shared/containers/Gigs/RecruitCRMJobs.jsx
+++ b/src/shared/containers/Gigs/RecruitCRMJobs.jsx
@@ -12,9 +12,14 @@ import Dropdown from 'components/GUIKit/Dropdown';
import PT from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
+import { getSalaryType, getCustomField } from 'utils/gigs';
+import IconBlackLocation from 'assets/images/icon-black-location.svg';
+import { config, Link, isomorphy } from 'topcoder-react-utils';
import { getQuery, updateQuery } from 'utils/url';
+import { withOptimizely } from '@optimizely/react-sdk';
import './jobLisingStyles.scss';
+const CONTENT_PREVIEW_LENGTH = 175;
const GIGS_PER_PAGE = 10;
// Sort by dropdown
const sortByOptions = [
@@ -42,6 +47,7 @@ class RecruitCRMJobsContainer extends React.Component {
this.onFilter = this.onFilter.bind(this);
this.onLocation = this.onLocation.bind(this);
this.onSort = this.onSort.bind(this);
+ this.onHotlistApply = this.onHotlistApply.bind(this);
}
componentDidMount() {
@@ -115,10 +121,16 @@ class RecruitCRMJobsContainer extends React.Component {
});
}
+ onHotlistApply() {
+ const { optimizely } = this.props;
+ optimizely.track('Hotlist ads click');
+ }
+
render() {
const {
loading,
jobs,
+ optimizely,
} = this.props;
const {
term,
@@ -136,7 +148,19 @@ class RecruitCRMJobsContainer extends React.Component {
);
}
+ // optimizely decide
+ let decision = { enabled: true };
+ if (isomorphy.isClientSide()) {
+ decision = optimizely.decide('gig_listing_hotlist');
+ }
let jobsToDisplay = jobs;
+ // build hotlist of jobs if present
+ let hotlistJobs = _.filter(jobs, (job) => {
+ const showInHotlist = _.find(job.custom_fields, ['field_name', 'Show in Hotlist']);
+ return showInHotlist && showInHotlist.value;
+ });
+ hotlistJobs = hotlistJobs.sort((a, b) => new Date(b.updated_on) - new Date(a.updated_on));
+ hotlistJobs = _.slice(hotlistJobs, 0, 4);
// build current locations dropdown based on all data
// and filter by selected location
jobsToDisplay = _.filter(jobs, (job) => {
@@ -177,7 +201,14 @@ class RecruitCRMJobsContainer extends React.Component {
});
}
// Sort controlled by sortBy state
- jobsToDisplay = jobsToDisplay.sort((a, b) => new Date(b[sortBy]) - new Date(a[sortBy]));
+ jobsToDisplay = jobsToDisplay.sort((a, b) => {
+ // sort tags first no matter the sortBy
+ const tagA = getCustomField(a.custom_fields, 'Job Tag');
+ const tagB = getCustomField(b.custom_fields, 'Job Tag');
+ if (tagB !== 'n/a' && tagA === 'n/a') return Number.MAX_VALUE;
+ if (tagB === 'n/a' && tagA !== 'n/a') return -Number.MIN_VALUE;
+ return new Date(b[sortBy]) - new Date(a[sortBy]);
+ });
// Calc pages
const pages = Math.ceil(jobsToDisplay.length / GIGS_PER_PAGE);
// Paginate the results
@@ -187,22 +218,58 @@ class RecruitCRMJobsContainer extends React.Component {
);
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {
+ jobsToDisplay.length
+ ? jobsToDisplay.map(job => )
+ : No Results
+ }
+
{
jobsToDisplay.length
- ? jobsToDisplay.map(job =>
)
- :
No Results
+ ?
: null
}
{
- jobsToDisplay.length
- ?
: null
+ hotlistJobs.length && decision.enabled && (
+
+
HOT THIS WEEK
+
+ {
+ hotlistJobs.map((hjob, indx) => (indx <= 1 ? (
+
+
{hjob.country}
+
{hjob.name}
+
${hjob.min_annual_salary} - {hjob.max_annual_salary} / {getSalaryType(hjob.salary_type)}
+
+ ) : (
+
+
{hjob.country}
+
{hjob.name}
+
${hjob.min_annual_salary} - {hjob.max_annual_salary} / {getSalaryType(hjob.salary_type)}
+ {
+ getCustomField(hjob.custom_fields, 'Hotlist excerpt') !== 'n/a' ? (
+
+ {
+ `${getCustomField(hjob.custom_fields, 'Hotlist excerpt').substring(0, CONTENT_PREVIEW_LENGTH)}${getCustomField(hjob.custom_fields, 'Hotlist excerpt').length > CONTENT_PREVIEW_LENGTH ? '...' : ''}`
+ }
+
+ ) : null
+ }
+
Apply Now
+
+ )))
+ }
+
+
+ )
}
);
@@ -218,6 +285,7 @@ RecruitCRMJobsContainer.propTypes = {
getJobs: PT.func.isRequired,
loading: PT.bool,
jobs: PT.arrayOf(PT.shape),
+ optimizely: PT.shape().isRequired,
};
function mapStateToProps(state) {
@@ -241,4 +309,4 @@ function mapDispatchToActions(dispatch) {
export default connect(
mapStateToProps,
mapDispatchToActions,
-)(RecruitCRMJobsContainer);
+)(withOptimizely(RecruitCRMJobsContainer));
diff --git a/src/shared/containers/Gigs/jobLisingStyles.scss b/src/shared/containers/Gigs/jobLisingStyles.scss
index d1997b2f7c..535a92cb16 100644
--- a/src/shared/containers/Gigs/jobLisingStyles.scss
+++ b/src/shared/containers/Gigs/jobLisingStyles.scss
@@ -1,3 +1,4 @@
+/* stylelint-disable no-descending-specificity */
@import "~styles/mixins";
.loading-text {
@@ -8,14 +9,19 @@
text-align: center;
}
-.container {
+.container,
+.container-with-hotlist {
max-width: $screen-lg;
margin: auto;
- @include xs-to-sm {
+ @media (max-width: 1280px) {
padding: 0 15px;
}
+ .gigs {
+ display: block;
+ }
+
.filters {
display: flex;
align-items: flex-end;
@@ -54,3 +60,156 @@
}
}
}
+
+.container-with-hotlist {
+ display: flex;
+
+ .gigs {
+ width: 956px;
+
+ @media (max-width: 1280px) {
+ max-width: none;
+ flex: 1;
+ }
+ }
+
+ .filters {
+ > div {
+ margin-right: 20px;
+
+ @include xs-to-sm {
+ margin-right: 0;
+ }
+
+ &:nth-child(2) {
+ min-width: 194px;
+ }
+
+ &:last-child {
+ flex: 2;
+ max-width: 223px;
+
+ @include xs-to-sm {
+ max-width: none;
+ }
+ }
+ }
+ }
+
+ .hotlist {
+ display: flex;
+ flex-direction: column;
+ margin-left: 28px;
+ flex: 1;
+
+ @media (max-width: 1280px) {
+ display: none;
+ }
+
+ h5 {
+ font-family: Barlow, sans-serif;
+ font-size: 20px;
+ line-height: 24px;
+ text-transform: uppercase;
+ font-weight: 600;
+ margin: 7px 0 31px 6px;
+ color: #2a2a2a;
+ }
+
+ .hotlist-items {
+ .hotlist-item-1,
+ .hotlist-item-2,
+ .hotlist-item-3,
+ .hotlist-item-4 {
+ display: flex;
+ flex-direction: column;
+ border-radius: 10px;
+ padding: 20px 20px 12px;
+ font-family: Roboto, sans-serif;
+ margin-bottom: 16px;
+ color: #2a2a2a;
+
+ .location {
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+
+ svg {
+ margin-right: 5px;
+ width: 15px;
+ height: 17px;
+ }
+ }
+
+ .job-title {
+ margin: 0;
+ }
+
+ .job-money {
+ line-height: 30px;
+ }
+
+ .job-desc {
+ font-family: Roboto, sans-serif;
+ line-height: 24px;
+ margin-top: 13px;
+ }
+ }
+
+ .hotlist-item-1,
+ .hotlist-item-2,
+ .hotlist-item-4 {
+ color: #fff;
+
+ .location svg g {
+ stroke: #fff;
+ }
+
+ .job-title,
+ .job-desc {
+ color: #fff;
+ }
+ }
+
+ .hotlist-item-1 {
+ background-image: linear-gradient(305.22deg, #9d41c9 0.01%, #ef476f 100%);
+ }
+
+ .hotlist-item-2 {
+ background-image: linear-gradient(140.77deg, #9d41c9 0%, #50ade8 100%);
+ }
+
+ .hotlist-item-3 {
+ background-image: linear-gradient(133.83deg, #f4f4f4 0%, #d4d4d4 100%);
+ }
+
+ .hotlist-item-4 {
+ background-image: linear-gradient(359.14deg, #555 0%, #2a2a2a 100%);
+ }
+
+ .hotlist-item-button-3,
+ .hotlist-item-button-4 {
+ font-family: Roboto, sans-serif;
+ font-size: 12px;
+ letter-spacing: 0.8px;
+ line-height: 30px;
+ padding: 0 15px;
+ text-transform: uppercase;
+ font-weight: bold;
+ margin: 22px 0 9px;
+ max-width: 104px;
+ border-radius: 15px;
+ }
+
+ .hotlist-item-button-3 {
+ background-color: #137d60;
+ color: #fff;
+ }
+
+ .hotlist-item-button-4 {
+ background-color: #fff;
+ color: #229174;
+ }
+ }
+ }
+}
diff --git a/src/shared/containers/GigsPages.jsx b/src/shared/containers/GigsPages.jsx
index 9945ba4962..44db1f7058 100644
--- a/src/shared/containers/GigsPages.jsx
+++ b/src/shared/containers/GigsPages.jsx
@@ -6,19 +6,50 @@ import PT from 'prop-types';
import Header from 'containers/TopcoderHeader';
import Footer from 'components/TopcoderFooter';
import Viewport from 'components/Contentful/Viewport';
-import { config } from 'topcoder-react-utils';
+import { config, isomorphy } from 'topcoder-react-utils';
import RecruitCRMJobDetails from 'containers/Gigs/RecruitCRMJobDetails';
import { Helmet } from 'react-helmet';
import MetaTags from 'components/MetaTags';
+import { OptimizelyProvider, createInstance } from '@optimizely/react-sdk';
+import { connect } from 'react-redux';
+import _ from 'lodash';
+import { v4 as uuidv4 } from 'uuid';
+const optimizelyClient = createInstance({
+ sdkKey: config.OPTIMIZELY.SDK_KEY,
+});
+const cookies = require('browser-cookies');
-export default function GigsPagesContainer(props) {
- const { match } = props;
- const { id } = match.params;
+function GigsPagesContainer(props) {
+ const { match, profile } = props;
+ const optProfile = {
+ attributes: {},
+ };
+ if (!_.isEmpty(profile)) {
+ optProfile.id = String(profile.userId);
+ optProfile.attributes.TC_Handle = profile.handle;
+ optProfile.attributes.HomeCountryCode = profile.homeCountryCode;
+ optProfile.attributes.email = profile.email;
+ } else if (isomorphy.isClientSide()) {
+ const idCookie = cookies.get('_tc.aid');
+ if (idCookie) {
+ optProfile.id = JSON.parse(idCookie).aid;
+ } else {
+ optProfile.id = uuidv4();
+ cookies.set('_tc.aid', JSON.stringify({
+ aid: optProfile.id,
+ }), {
+ secure: true,
+ domain: '',
+ expires: 365, // days
+ });
+ }
+ }
+ const { id, type } = match.params;
const isApply = `${config.GIGS_PAGES_PATH}/${id}/apply` === match.url;
const title = 'Gig Work | Topcoder Community | Topcoder';
const description = 'Compete and build up your profiles and skills! Topcoder members become eligible to work on Gig Work projects by first proving themselves in various skill sets through Topcoder competitions.';
- return (
+ const inner = (