diff --git a/.circleci/config.yml b/.circleci/config.yml
index c0b8f6fccb..d9e4f88a90 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -230,13 +230,14 @@ workflows:
filters:
branches:
only:
- - develop
+ - feature-contentful
# This is alternate dev env for parallel testing
- "build-test":
context : org-global
filters:
branches:
only:
+ - feature-contentful
- hot-fix
# This is alternate dev env for parallel testing
- "build-qa":
@@ -258,6 +259,7 @@ workflows:
filters:
branches:
only:
+ - feature-contentful
- develop
# Production builds are exectuted
# when PR is merged to the master
diff --git a/Dockerfile b/Dockerfile
index 4defb8130c..49e686a794 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -43,6 +43,7 @@ ARG CONTENTFUL_EDU_PREVIEW_API_KEY
ARG FILESTACK_API_KEY
ARG FILESTACK_SUBMISSION_CONTAINER
+ARG RECRUITCRM_API_KEY
# Credentials for Mailchimp service
ARG MAILCHIMP_API_KEY
@@ -115,6 +116,7 @@ ENV CONTENTFUL_MANAGEMENT_TOKEN=$CONTENTFUL_MANAGEMENT_TOKEN
ENV CONTENTFUL_EDU_SPACE_ID=$CONTENTFUL_EDU_SPACE_ID
ENV CONTENTFUL_EDU_CDN_API_KEY=$CONTENTFUL_EDU_CDN_API_KEY
ENV CONTENTFUL_EDU_PREVIEW_API_KEY=$CONTENTFUL_EDU_PREVIEW_API_KEY
+ENV RECRUITCRM_API_KEY=$RECRUITCRM_API_KEY
################################################################################
# Testing and build of the application inside the container.
diff --git a/__tests__/shared/components/Header/__snapshots__/index.jsx.snap b/__tests__/shared/components/Header/__snapshots__/index.jsx.snap
index 9ef50e5635..9f0f7a4186 100644
--- a/__tests__/shared/components/Header/__snapshots__/index.jsx.snap
+++ b/__tests__/shared/components/Header/__snapshots__/index.jsx.snap
@@ -62,7 +62,7 @@ exports[`Default render 1`] = `
"title": "Competitive Programming",
},
Object {
- "href": "/community/taas",
+ "href": "/gigs",
"title": "Gig Work",
},
Object {
diff --git a/build.sh b/build.sh
index bb62f0c990..64affb8170 100755
--- a/build.sh
+++ b/build.sh
@@ -42,7 +42,8 @@ docker build -t $TAG \
--build-arg TC_M2M_GRANT_TYPE=$TC_M2M_GRANT_TYPE \
--build-arg CONTENTFUL_COMCAST_SPACE_ID=$CONTENTFUL_COMCAST_SPACE_ID \
--build-arg CONTENTFUL_COMCAST_CDN_API_KEY=$CONTENTFUL_COMCAST_CDN_API_KEY \
- --build-arg CONTENTFUL_COMCAST_PREVIEW_API_KEY=$CONTENTFUL_COMCAST_PREVIEW_API_KEY .
+ --build-arg CONTENTFUL_COMCAST_PREVIEW_API_KEY=$CONTENTFUL_COMCAST_PREVIEW_API_KEY \
+ --build-arg RECRUITCRM_API_KEY=$RECRUITCRM_API_KEY .
# Copies "node_modules" from the created image, if necessary for caching.
docker create --name app $TAG
diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js
index 0dbd155bac..51264f7d73 100644
--- a/config/custom-environment-variables.js
+++ b/config/custom-environment-variables.js
@@ -95,6 +95,8 @@ module.exports = {
AUTH0_PROXY_SERVER_URL: 'TC_M2M_AUTH0_PROXY_SERVER_URL',
TOKEN_CACHE_TIME: 'TOKEN_CACHE_TIME',
},
+
+ RECRUITCRM_API_KEY: 'RECRUITCRM_API_KEY',
},
AUTH_CONFIG: {
AUTH0_URL: 'TC_M2M_AUTH0_URL',
diff --git a/config/default.js b/config/default.js
index 483681f0dd..e9c1df07aa 100644
--- a/config/default.js
+++ b/config/default.js
@@ -247,6 +247,8 @@ module.exports = {
AUTH0_URL: '',
TOKEN_CACHE_TIME: '',
},
+
+ RECRUITCRM_API_KEY: '',
},
AUTH_CONFIG: {
@@ -312,7 +314,7 @@ module.exports = {
},
{
title: 'Gig Work',
- href: '/community/taas',
+ href: '/gigs',
},
{
title: 'Practice',
@@ -406,4 +408,5 @@ module.exports = {
TC_EDU_SEARCH_PATH: '/search',
TC_EDU_SEARCH_BAR_MAX_RESULTS_EACH_GROUP: 3,
POLICY_PAGES_PATH: '/policy',
+ GIGS_PAGES_PATH: '/gigs',
};
diff --git a/config/production.js b/config/production.js
index 35adcd73a3..d62be0f17d 100644
--- a/config/production.js
+++ b/config/production.js
@@ -115,7 +115,7 @@ module.exports = {
},
{
title: 'Gig Work',
- href: '/community/taas',
+ href: '/gigs',
},
{
title: 'Practice',
diff --git a/package-lock.json b/package-lock.json
index 7d082a3e47..b4b54b597b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18331,6 +18331,14 @@
"prop-types": "^15.7.2"
}
},
+ "react-paginate": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/react-paginate/-/react-paginate-6.3.2.tgz",
+ "integrity": "sha512-Ch++Njfv8UHpLtIMiQouAPeJQA5Ki86kIYfCer6c1B96Rvn3UF27se+goCilCP8oHNXNsA2R2kxvzanY1YIkyg==",
+ "requires": {
+ "prop-types": "^15.6.1"
+ }
+ },
"react-player": {
"version": "0.24.6",
"resolved": "https://registry.npmjs.org/react-player/-/react-player-0.24.6.tgz",
@@ -33196,9 +33204,9 @@
"integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="
},
"topcoder-react-lib": {
- "version": "1000.19.48",
- "resolved": "https://registry.npmjs.org/topcoder-react-lib/-/topcoder-react-lib-1000.19.48.tgz",
- "integrity": "sha512-TguboxXulPHmE8FGGBgxYlYNjEU7mDZwOT74SYzeAJLl4bZr6z7yQCwGIlksB9dzq6beAvDIm4sYXqTCRuUInw==",
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/topcoder-react-lib/-/topcoder-react-lib-1.0.0.tgz",
+ "integrity": "sha512-JomJKRPAEKPTgcSiCapQe/o2f4jUuW2K5X61FT47OSDVQj7HbKoXZ1cM814Nn/x80wwniFQ71czdFiSz0aYGMQ==",
"requires": {
"auth0-js": "^6.8.4",
"config": "^3.2.0",
@@ -33428,9 +33436,9 @@
}
},
"topcoder-react-ui-kit": {
- "version": "1000.0.4",
- "resolved": "https://registry.npmjs.org/topcoder-react-ui-kit/-/topcoder-react-ui-kit-1000.0.4.tgz",
- "integrity": "sha512-VvVvrVPhcnaJYLNv3YJLIY+CI+4sLLrhdqfulngMmTcUZft01BUorhMYEBtwhODrfotnx48GXnxu1TQmI3L6dA==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/topcoder-react-ui-kit/-/topcoder-react-ui-kit-2.0.0.tgz",
+ "integrity": "sha512-9Ph8fRzRjVVB0VH13s8/N4+/ZWPLflrnW7D0fmS+oeDwnQe6k5wMknyFcBCI/pqWHr/11nhclXX7np3LdyWSwA==",
"requires": {
"prop-types": "^15.6.2",
"react": "^16.4.1",
diff --git a/package.json b/package.json
index e8542c7a64..3c9ed33fbe 100644
--- a/package.json
+++ b/package.json
@@ -107,6 +107,7 @@
"react-infinite-scroller": "^1.1.4",
"react-inlinesvg": "^0.8.4",
"react-markdown": "^4.3.1",
+ "react-paginate": "^6.3.2",
"react-player": "^0.24.1",
"react-redux": "^5.0.7",
"react-redux-toastr": "^7.2.6",
diff --git a/src/assets/images/icon-black-calendar.svg b/src/assets/images/icon-black-calendar.svg
new file mode 100644
index 0000000000..5833517734
--- /dev/null
+++ b/src/assets/images/icon-black-calendar.svg
@@ -0,0 +1,36 @@
+
+
+
+ E4093E96-1304-4DDD-B1C6-AAD5B3E791FD
+ Created with sketchtool.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/images/icon-black-duration.svg b/src/assets/images/icon-black-duration.svg
new file mode 100644
index 0000000000..d9e8d83053
--- /dev/null
+++ b/src/assets/images/icon-black-duration.svg
@@ -0,0 +1,26 @@
+
+
+
+ E108B11C-D7C2-408D-B020-088D167D4630
+ Created with sketchtool.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/images/icon-black-location.svg b/src/assets/images/icon-black-location.svg
new file mode 100644
index 0000000000..e8335f35f2
--- /dev/null
+++ b/src/assets/images/icon-black-location.svg
@@ -0,0 +1,25 @@
+
+
+
+ E0744452-EE76-4789-8A85-0562AC9A587C
+ Created with sketchtool.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/images/icon-black-payment.svg b/src/assets/images/icon-black-payment.svg
new file mode 100644
index 0000000000..16883c87b0
--- /dev/null
+++ b/src/assets/images/icon-black-payment.svg
@@ -0,0 +1,26 @@
+
+
+
+ 8918DD34-77B4-4058-BE0F-975E843D7D81
+ Created with sketchtool.
+
+
+
+
+
+
+
+
+
+
+ $
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/images/icon-calendar-gig.svg b/src/assets/images/icon-calendar-gig.svg
new file mode 100644
index 0000000000..14b2544617
--- /dev/null
+++ b/src/assets/images/icon-calendar-gig.svg
@@ -0,0 +1,30 @@
+
+
+
+ 7AC9DEAC-3F2A-487E-B616-A085E6B4D7FA
+ Created with sketchtool.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/images/icon-clear-search.svg b/src/assets/images/icon-clear-search.svg
new file mode 100644
index 0000000000..228051d680
--- /dev/null
+++ b/src/assets/images/icon-clear-search.svg
@@ -0,0 +1,13 @@
+
+
+
+ E008ABA4-32F2-42CD-9934-378FD0AB461B
+ Created with sketchtool.
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/images/icon-duration.svg b/src/assets/images/icon-duration.svg
new file mode 100644
index 0000000000..3eeeb3eddb
--- /dev/null
+++ b/src/assets/images/icon-duration.svg
@@ -0,0 +1,20 @@
+
+
+
+ 52C90FFB-CC27-4B10-8E63-415ACA6557E4
+ Created with sketchtool.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/images/icon-location.svg b/src/assets/images/icon-location.svg
new file mode 100644
index 0000000000..a63b486f66
--- /dev/null
+++ b/src/assets/images/icon-location.svg
@@ -0,0 +1,19 @@
+
+
+
+ AAEC0EB0-9F48-4361-A4E3-F440FA4B8F40
+ Created with sketchtool.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/images/icon-payment.svg b/src/assets/images/icon-payment.svg
new file mode 100644
index 0000000000..c0eeee663e
--- /dev/null
+++ b/src/assets/images/icon-payment.svg
@@ -0,0 +1,18 @@
+
+
+
+ D27E7EE2-D0BE-43EA-B61C-C22ADE2FA375
+ Created with sketchtool.
+
+
+
+
+
+
+ $
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/images/icon-skills.svg b/src/assets/images/icon-skills.svg
new file mode 100644
index 0000000000..e2c6de4376
--- /dev/null
+++ b/src/assets/images/icon-skills.svg
@@ -0,0 +1,31 @@
+
+
+
+ 52CD6D74-4265-497A-89D8-FF91F35564F8
+ Created with sketchtool.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/images/icon-timezone.svg b/src/assets/images/icon-timezone.svg
new file mode 100644
index 0000000000..39158cee60
--- /dev/null
+++ b/src/assets/images/icon-timezone.svg
@@ -0,0 +1,20 @@
+
+
+
+ 90608BD4-776C-4ABD-B264-60B29B9673AC
+ Created with sketchtool.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/server/index.js b/src/server/index.js
index 38706d87ff..ed9afd3008 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -26,6 +26,7 @@ import { toJson as xmlToJson } from 'utils/xml2json';
import cdnRouter from './routes/cdn';
import mailChimpRouter from './routes/mailchimp';
import mockDocuSignFactory from './__mocks__/docu-sign-mock';
+import recruitCRMRouter from './routes/recruitCRM';
/* Dome API for topcoder communities */
import tcCommunitiesDemoApi from './tc-communities';
@@ -133,6 +134,7 @@ async function onExpressJsSetup(server) {
server.use('/api/cdn', cdnRouter);
server.use('/api/mailchimp', mailChimpRouter);
+ server.use('/api/recruit', recruitCRMRouter);
// serve demo api
server.use(
diff --git a/src/server/routes/recruitCRM.js b/src/server/routes/recruitCRM.js
new file mode 100644
index 0000000000..5047dcdb0d
--- /dev/null
+++ b/src/server/routes/recruitCRM.js
@@ -0,0 +1,23 @@
+/**
+ * The routes related to RecruitCRM.io integration
+ */
+
+import express from 'express';
+import RecruitCRMService from '../services/recruitCRM';
+
+const cors = require('cors');
+
+const routes = express.Router();
+
+// Enables CORS on those routes according config above
+// ToDo configure CORS for set of our trusted domains
+routes.use(cors());
+routes.options('*', cors());
+
+routes.get('/jobs', (req, res, next) => new RecruitCRMService().getAllJobs(req, res, next));
+
+routes.get('/jobs/search', (req, res, next) => new RecruitCRMService().getJobs(req, res, next));
+
+routes.get('/jobs/:id', (req, res, next) => new RecruitCRMService().getJob(req, res, next));
+
+export default routes;
diff --git a/src/server/services/recruitCRM.js b/src/server/services/recruitCRM.js
new file mode 100644
index 0000000000..4f7fb95189
--- /dev/null
+++ b/src/server/services/recruitCRM.js
@@ -0,0 +1,137 @@
+/**
+ * Server-side functions necessary for effective integration with recruitCRM
+ */
+import fetch from 'isomorphic-fetch';
+import config from 'config';
+import qs from 'qs';
+import _ from 'lodash';
+
+/**
+ * Auxiliary class that handles communication with recruitCRM
+ */
+export default class RecruitCRMService {
+ /**
+ * Creates a new service instance.
+ * @param {String} baseUrl The base API endpoint.
+ */
+ constructor(baseUrl = 'https://api.recruitcrm.io') {
+ this.private = {
+ baseUrl,
+ apiKey: config.SECRET.RECRUITCRM_API_KEY,
+ authorization: `Bearer ${config.SECRET.RECRUITCRM_API_KEY}`,
+ };
+ }
+
+ /**
+ * Gets jobs endpoint.
+ * @return {Promise}
+ * @param {Object} the request.
+ */
+ async getJobs(req, res, next) {
+ try {
+ const response = await fetch(`${this.private.baseUrl}/v1/jobs/search?${qs.stringify(req.query)}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': req.headers['content-type'],
+ Authorization: this.private.authorization,
+ },
+ });
+ if (response.status === 429) {
+ await new Promise(resolve => setTimeout(resolve, 30000)); // wait 30sec
+ return this.getJobs(req, res, next);
+ }
+ if (response.status >= 400) {
+ return res.send({
+ error: true,
+ status: response.status,
+ url: `${this.private.baseUrl}/v1/jobs/search?${qs.stringify(req.query)}`,
+ });
+ }
+ const data = await response.json();
+ return res.send(data);
+ } catch (err) {
+ return next(err);
+ }
+ }
+
+ /**
+ * Gets job by id endpoint.
+ * @return {Promise}
+ * @param {Object} the request.
+ */
+ async getJob(req, res, next) {
+ try {
+ const response = await fetch(`${this.private.baseUrl}/v1/jobs/${req.params.id}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': req.headers['content-type'],
+ Authorization: this.private.authorization,
+ },
+ });
+ if (response.status === 429) {
+ await new Promise(resolve => setTimeout(resolve, 30000)); // wait 30sec
+ return this.getJob(req, res, next);
+ }
+ if (response.status >= 400) {
+ return res.send({
+ error: true,
+ status: response.status,
+ url: `${this.private.baseUrl}/v1/jobs/${req.params.id}`,
+ });
+ }
+ const data = await response.json();
+ return res.send(data);
+ } catch (err) {
+ return next(err);
+ }
+ }
+
+ /**
+ * Gets all jobs endpoint.
+ * @return {Promise}
+ * @param {Object} the request.
+ */
+ async getAllJobs(req, res, next) {
+ try {
+ const response = await fetch(`${this.private.baseUrl}/v1/jobs/search?${qs.stringify(req.query)}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': req.headers['content-type'],
+ Authorization: this.private.authorization,
+ },
+ });
+ if (response.status === 429) {
+ await new Promise(resolve => setTimeout(resolve, 30000)); // wait 30sec
+ return this.getJobs(req, res, next);
+ }
+ if (response.status >= 400) {
+ return res.send({
+ error: true,
+ status: response.status,
+ url: `${this.private.baseUrl}/v1/jobs/search?${qs.stringify(req.query)}`,
+ });
+ }
+ const data = await response.json();
+ if (data.current_page < data.last_page) {
+ const pages = _.range(2, data.last_page + 1);
+ // eslint-disable-next-line no-restricted-syntax
+ for (const page of pages) {
+ // eslint-disable-next-line no-await-in-loop
+ const pageDataRsp = await fetch(`${this.private.baseUrl}/v1/jobs/search?${qs.stringify(req.query)}&page=${page}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': req.headers['content-type'],
+ Authorization: this.private.authorization,
+ },
+ });
+ // eslint-disable-next-line no-await-in-loop
+ const pageData = await pageDataRsp.json();
+ data.data = _.flatten(data.data.concat(pageData.data));
+ }
+ }
+ return res.send(data.data);
+ } catch (err) {
+ return next(err);
+ }
+ }
+}
diff --git a/src/shared/actions/leaderboard.js b/src/shared/actions/leaderboard.js
index e1216d3301..b379260372 100644
--- a/src/shared/actions/leaderboard.js
+++ b/src/shared/actions/leaderboard.js
@@ -43,8 +43,8 @@ async function getTcoHistoryChallengesDone(url, competitor) {
.then(response => response.json())
.then(jsonResponse => ({
challenges: _.filter(jsonResponse, challenge => (
- challenge['tco_leaderboard.user_id']
- ? (challenge['tco_leaderboard.user_id'] === competitor['tco_leaderboard.user_id'])
+ challenge['member_profile_basic.user_id']
+ ? (challenge['member_profile_basic.user_id'] === competitor['member_profile_basic.user_id'])
: (challenge.userid === competitor.userid)
)),
}));
diff --git a/src/shared/actions/recruitCRM.js b/src/shared/actions/recruitCRM.js
new file mode 100644
index 0000000000..670b71878a
--- /dev/null
+++ b/src/shared/actions/recruitCRM.js
@@ -0,0 +1,50 @@
+import { redux } from 'topcoder-react-utils';
+import Service from 'services/recruitCRM';
+
+/**
+ * Jobs page fetch init
+ */
+function getJobsInit() {
+ return {};
+}
+
+/**
+ * Jobs page fetch done
+ */
+async function getJobsDone(query) {
+ const ss = new Service();
+ const res = await ss.getAllJobs(query);
+
+ return {
+ data: res,
+ };
+}
+
+/**
+ * Job fetch init
+ */
+function getJobInit(id) {
+ return { id };
+}
+
+/**
+ * Job fetch done
+ */
+async function getJobDone(id) {
+ const ss = new Service();
+ const res = await ss.getJob(id);
+
+ return {
+ id,
+ data: res,
+ };
+}
+
+export default redux.createActions({
+ RECRUIT: {
+ GET_JOBS_INIT: getJobsInit,
+ GET_JOBS_DONE: getJobsDone,
+ GET_JOB_INIT: getJobInit,
+ GET_JOB_DONE: getJobDone,
+ },
+});
diff --git a/src/shared/components/Contentful/AppComponent/index.jsx b/src/shared/components/Contentful/AppComponent/index.jsx
index ab310304a0..5238ae7638 100644
--- a/src/shared/components/Contentful/AppComponent/index.jsx
+++ b/src/shared/components/Contentful/AppComponent/index.jsx
@@ -9,6 +9,7 @@ import PT from 'prop-types';
import React from 'react';
import { errors } from 'topcoder-react-lib';
import Leaderboard from 'containers/tco/Leaderboard';
+import RecruitCRMJobs from 'containers/Gigs/RecruitCRMJobs';
const { fireErrorMessage } = errors;
@@ -30,6 +31,9 @@ export function AppComponentSwitch(appComponent) {
/>
);
}
+ if (appComponent.fields.type === 'RecruitCRM-Jobs') {
+ return ;
+ }
fireErrorMessage('Unsupported app component type from contentful', '');
return null;
}
diff --git a/src/shared/components/GUIKit/Dropdown/index.jsx b/src/shared/components/GUIKit/Dropdown/index.jsx
new file mode 100644
index 0000000000..605f542276
--- /dev/null
+++ b/src/shared/components/GUIKit/Dropdown/index.jsx
@@ -0,0 +1,54 @@
+/* eslint-disable jsx-a11y/label-has-for */
+/**
+ * Dropdown component.
+ */
+import React from 'react';
+import PT from 'prop-types';
+import ReactSelect from 'react-select';
+import './style.scss';
+
+function Dropdown({
+ options,
+ selectedId,
+ placeholder,
+ label,
+ onChange,
+}) {
+ return (
+
+ {label}
+ (
+
+ {option.name}
+
+ )}
+ />
+
+ );
+}
+
+Dropdown.defaultProps = {
+ options: [],
+ selectedId: null,
+ placeholder: '',
+ label: '',
+};
+
+Dropdown.propTypes = {
+ options: PT.arrayOf(PT.shape),
+ selectedId: PT.string,
+ placeholder: PT.string,
+ label: PT.string,
+ onChange: PT.func.isRequired,
+};
+
+export default Dropdown;
diff --git a/src/shared/components/GUIKit/Dropdown/style.scss b/src/shared/components/GUIKit/Dropdown/style.scss
new file mode 100644
index 0000000000..01dc2ec294
--- /dev/null
+++ b/src/shared/components/GUIKit/Dropdown/style.scss
@@ -0,0 +1,27 @@
+.container {
+ :global {
+ @import '~react-select/dist/react-select';
+
+ width: 100%;
+
+ input.Select-input,
+ input.Select-input:focus {
+ background-color: transparent !important;
+ margin-left: 0 !important;
+ padding-right: 6px !important;
+ color: red;
+ }
+
+ .Select-multi-value-wrapper {
+ width: 100% !important;
+ }
+ }
+
+ .label {
+ position: relative;
+ }
+
+ .active-option {
+ color: red;
+ }
+}
diff --git a/src/shared/components/GUIKit/JobListCard/index.jsx b/src/shared/components/GUIKit/JobListCard/index.jsx
new file mode 100644
index 0000000000..d94f4dc175
--- /dev/null
+++ b/src/shared/components/GUIKit/JobListCard/index.jsx
@@ -0,0 +1,58 @@
+/* eslint-disable max-len */
+/**
+ * SearchCombo component.
+ */
+import React from 'react';
+import PT from 'prop-types';
+import { config, Link } from 'topcoder-react-utils';
+import { getSalaryType, getCustomField } from 'utils/gigs';
+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.svg';
+
+export default function JobListCard({
+ job,
+}) {
+ let skills = getCustomField(job.custom_fields, 'Technologies Required');
+ if (skills !== 'n/a') {
+ skills = skills.split(',');
+ if (skills.length > 2) {
+ skills = `${skills.slice(0, 2).join(', ')},...`;
+ } else {
+ skills = skills.join(', ');
+ }
+ }
+
+ return (
+
+
{job.name}
+
+
+ {skills}
+
+
+ {job.country}
+
+
+ ${job.min_annual_salary} - ${job.max_annual_salary} / {getSalaryType(job.salary_type)}
+
+
+ {getCustomField(job.custom_fields, 'Duration')}
+
+
+ VIEW DETAILS
+
+
+
+ );
+}
+
+JobListCard.defaultProps = {
+
+};
+
+JobListCard.propTypes = {
+ job: PT.shape().isRequired,
+};
diff --git a/src/shared/components/GUIKit/JobListCard/style.scss b/src/shared/components/GUIKit/JobListCard/style.scss
new file mode 100644
index 0000000000..1cff03c199
--- /dev/null
+++ b/src/shared/components/GUIKit/JobListCard/style.scss
@@ -0,0 +1,107 @@
+@import "~components/GUIKit/default";
+@import "~components/Contentful/default";
+
+.container {
+ border: 1px solid #e9e9e9;
+ border-radius: 10px;
+ display: flex;
+ flex-direction: column;
+ color: #2a2a2a;
+ padding: 25px 35px;
+ margin-bottom: 15px;
+
+ @include gui-kit-headers;
+ @include gui-kit-content;
+ @include roboto-regular;
+
+ .gig-name,
+ .gig-name:visited,
+ .gig-name:active,
+ .gig-name:hover {
+ color: #1e94a3;
+ margin-top: 0;
+ margin-bottom: 12px;
+ text-decoration: none;
+ font-family: Barlow, sans-serif;
+ font-size: 20px;
+ font-weight: 600;
+ line-height: 24px;
+ text-transform: uppercase;
+
+ @include xs-to-sm {
+ margin-bottom: 20px;
+ }
+ }
+
+ .job-infos {
+ display: flex;
+
+ @include xs-to-sm {
+ flex-direction: column;
+ }
+
+ .icon-val {
+ display: flex;
+ align-items: center;
+
+ @include xs-to-sm {
+ margin-bottom: 20px;
+ }
+
+ &:first-child {
+ width: 200px;
+ }
+
+ &:nth-child(2) {
+ width: 204px;
+ }
+
+ &:nth-child(3) {
+ width: 263px;
+ }
+
+ &:nth-child(4) {
+ width: 255px;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ width: 141px;
+ }
+
+ svg {
+ margin-right: 7px;
+ }
+ }
+
+ .row-btn {
+ display: flex;
+ justify-content: flex-end;
+ flex: 1;
+
+ @include xs-to-sm {
+ justify-content: flex-start;
+ }
+
+ button.primary-green-md {
+ outline: none;
+
+ @include primary-green;
+ @include md;
+
+ &:hover {
+ @include primary-green;
+ }
+
+ &:disabled,
+ &:hover:disabled {
+ background-color: #e9e9e9 !important;
+ border: none !important;
+ text-decoration: none !important;
+ color: #fafafb !important;
+ box-shadow: none !important;
+ }
+ }
+ }
+ }
+}
diff --git a/src/shared/components/GUIKit/Paginate/index.jsx b/src/shared/components/GUIKit/Paginate/index.jsx
new file mode 100644
index 0000000000..7dc2eb3c8d
--- /dev/null
+++ b/src/shared/components/GUIKit/Paginate/index.jsx
@@ -0,0 +1,48 @@
+/**
+ * Paginate component.
+ * Based on https://github.com/AdeleD/react-paginate
+ */
+import React from 'react';
+import ReactPaginate from 'react-paginate';
+import PT from 'prop-types';
+import './style.scss';
+
+function Paginate({
+ pages,
+ page,
+ onChange,
+}) {
+ return (
+
+
+
+ );
+}
+
+Paginate.defaultProps = {
+ page: 1,
+};
+
+Paginate.propTypes = {
+ pages: PT.number.isRequired,
+ page: PT.number,
+ onChange: PT.func.isRequired,
+};
+
+export default Paginate;
diff --git a/src/shared/components/GUIKit/Paginate/style.scss b/src/shared/components/GUIKit/Paginate/style.scss
new file mode 100644
index 0000000000..70a82393b3
--- /dev/null
+++ b/src/shared/components/GUIKit/Paginate/style.scss
@@ -0,0 +1,95 @@
+@import "~components/GUIKit/default";
+
+.container {
+ display: flex;
+ justify-content: center;
+
+ :global {
+ .paginator {
+ display: flex;
+
+ @include xs-to-sm {
+ padding: 0 15px;
+ }
+ }
+
+ .previous-link,
+ .next-link {
+ border-radius: 15px;
+ width: 77px;
+ height: 30px;
+ margin: 0;
+ border: 1px solid #137d60;
+ color: #229174;
+ font-family: Roboto, sans-serif;
+ font-size: 12px;
+ font-weight: bold;
+ outline: none;
+ padding: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ }
+
+ .previous-link {
+ margin-right: 5px;
+
+ &::before {
+ content: '\02039';
+ font-size: 28px;
+ padding-bottom: 5px;
+ margin-right: 4px;
+ }
+ }
+
+ .next-link {
+ margin-left: 5px;
+
+ &::after {
+ content: '\0203A';
+ font-size: 28px;
+ padding-bottom: 5px;
+ margin-left: 4px;
+ }
+ }
+
+ .paginator-btn {
+ background-color: #fff;
+ border: 1px solid #137d60;
+ color: #229174;
+ height: 30px;
+ width: 30px;
+ font-size: 12px;
+ font-family: Roboto, sans-serif;
+ font-weight: bold;
+ outline: none;
+ padding: 0;
+ border-radius: 100%;
+ margin: 0 5px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ }
+
+ .paginator-btn-active {
+ color: #2a2a2a;
+ border: 1px solid #555;
+ }
+
+ .paginator-break-link {
+ color: #d4d4d4;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 30px;
+ font-weight: bolder;
+ margin: 0 5px;
+ padding-bottom: 11px;
+ cursor: pointer;
+ outline: none;
+ font-size: 22px;
+ }
+ }
+}
diff --git a/src/shared/components/GUIKit/SearchCombo/index.jsx b/src/shared/components/GUIKit/SearchCombo/index.jsx
new file mode 100644
index 0000000000..29400e9cb0
--- /dev/null
+++ b/src/shared/components/GUIKit/SearchCombo/index.jsx
@@ -0,0 +1,52 @@
+/**
+ * SearchCombo component.
+ */
+import React, { useState } from 'react';
+import PT from 'prop-types';
+import './style.scss';
+import IconClearSearch from 'assets/images/icon-clear-search.svg';
+
+function SearchCombo({
+ term,
+ placeholder,
+ btnText,
+ onSearch,
+}) {
+ const [inputVal, setVal] = useState(term);
+ const clearSearch = () => {
+ setVal('');
+ onSearch('');
+ };
+
+ return (
+
+
+ {
+ !inputVal ? {placeholder} : null
+ }
+ setVal(event.target.value)} />
+ {
+ inputVal ? : null
+ }
+
+
onSearch(inputVal)} disabled={!inputVal}>
+ {btnText}
+
+
+ );
+}
+
+SearchCombo.defaultProps = {
+ term: '',
+ placeholder: '',
+ btnText: 'SEARCH',
+};
+
+SearchCombo.propTypes = {
+ term: PT.string,
+ placeholder: PT.string,
+ btnText: PT.string,
+ onSearch: PT.func.isRequired,
+};
+
+export default SearchCombo;
diff --git a/src/shared/components/GUIKit/SearchCombo/style.scss b/src/shared/components/GUIKit/SearchCombo/style.scss
new file mode 100644
index 0000000000..a47b4940ec
--- /dev/null
+++ b/src/shared/components/GUIKit/SearchCombo/style.scss
@@ -0,0 +1,72 @@
+@import "~components/GUIKit/default";
+
+.container {
+ display: flex;
+ align-items: center;
+ width: 100%;
+
+ .input-wrap {
+ width: 100%;
+ position: relative;
+ margin-right: 10px;
+ z-index: 1;
+
+ input.input {
+ background: transparent;
+ border: 1px solid #aaa;
+ border-radius: 6px;
+ height: 39px;
+ margin: 0;
+ }
+
+ .search-placeholder {
+ color: #aaa;
+ font-size: 14px;
+ font-family: Roboto, sans-serif;
+ line-height: 22px;
+ text-transform: none;
+ position: absolute;
+ z-index: 0;
+ top: 8px;
+ left: 15px;
+ }
+
+ .clear-search {
+ position: absolute;
+ top: calc(50% - 5px);
+ right: 15px;
+ cursor: pointer;
+ }
+ }
+
+ button.primary-green-md {
+ outline: none;
+ display: flex;
+ align-items: center;
+
+ @include primary-green;
+ @include md;
+
+ &:hover {
+ @include primary-green;
+ }
+
+ &:disabled,
+ &:hover:disabled {
+ background-color: #e9e9e9 !important;
+ border: none !important;
+ text-decoration: none !important;
+ color: #fafafb !important;
+ box-shadow: none !important;
+ }
+
+ &::before {
+ content: '';
+ background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='16px' height='16px' viewBox='0 0 16 16' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: sketchtool 63.1 (101010) - https://sketch.com --%3E%3Ctitle%3E8A0513C9-B831-4C33-8BAA-A13D6D42B150%3C/title%3E%3Cdesc%3ECreated with sketchtool.%3C/desc%3E%3Cdefs%3E%3Cpath d='M12.7,11.2298137 C13.6,10.0372671 14.1,8.64596273 14.1,7.05590062 C14.1,3.18012422 11,0 7.1,0 C3.2,0 0,3.18012422 0,7.05590062 C0,10.931677 3.2,14.1118012 7.1,14.1118012 C8.7,14.1118012 10.2,13.6149068 11.3,12.7204969 L14.3,15.7018634 C14.5,15.9006211 14.8,16 15,16 C15.2,16 15.5,15.9006211 15.7,15.7018634 C16.1,15.3043478 16.1,14.7080745 15.7,14.310559 L12.7,11.2298137 Z M7.1,12.0248447 C4.3,12.0248447 2,9.83850932 2,7.05590062 C2,4.27329193 4.3,1.98757764 7.1,1.98757764 C9.9,1.98757764 12.2,4.27329193 12.2,7.05590062 C12.2,9.83850932 9.9,12.0248447 7.1,12.0248447 L7.1,12.0248447 Z' id='path-1'%3E%3C/path%3E%3C/defs%3E%3Cg id='TaaS' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cg id='02-2-Gig-Work-Listing-Page-2----Approved' transform='translate(-773.000000, -308.000000)'%3E%3Cg id='UI-Kit/Button/Medium/primary' transform='translate(753.000000, 296.000000)'%3E%3Cg id='button-md'%3E%3Cg id='Stacked-Group' transform='translate(20.000000, 0.000000)'%3E%3Cg id='UI-Kit/Icons/magnifying-glass/normal' transform='translate(0.000000, 12.000000)'%3E%3Cmask id='mask-2' fill='white'%3E%3Cuse xlink:href='%23path-1'%3E%3C/use%3E%3C/mask%3E%3Cuse id='icon-color' fill='%23FFFFFF' fill-rule='evenodd' xlink:href='%23path-1'%3E%3C/use%3E%3C/g%3E%3C/g%3E%3C/g%3E%3C/g%3E%3C/g%3E%3C/g%3E%3C/svg%3E") !important;
+ background-repeat: no-repeat;
+ width: 16px;
+ height: 16px;
+ margin-right: 5px;
+ }
+ }
+}
diff --git a/src/shared/components/GUIKit/_default.scss b/src/shared/components/GUIKit/_default.scss
new file mode 100644
index 0000000000..b88b18f6e3
--- /dev/null
+++ b/src/shared/components/GUIKit/_default.scss
@@ -0,0 +1,2 @@
+@import "~components/Contentful/default";
+@import "~components/buttons/themed/tc";
diff --git a/src/shared/components/Gigs/GigDetails.jsx b/src/shared/components/Gigs/GigDetails.jsx
new file mode 100644
index 0000000000..cbf3602331
--- /dev/null
+++ b/src/shared/components/Gigs/GigDetails.jsx
@@ -0,0 +1,146 @@
+/* eslint-disable max-len */
+/**
+ * The Gig details component.
+ */
+
+import React from 'react';
+import PT from 'prop-types';
+import { isomorphy, Link, config } from 'topcoder-react-utils';
+import ReactHtmlParser from 'react-html-parser';
+import { getSalaryType, getCustomField } from 'utils/gigs';
+import './style.scss';
+import IconFacebook from 'assets/images/icon-facebook.svg';
+import IconTwitter from 'assets/images/icon-twitter.svg';
+import IconLinkedIn from 'assets/images/icon-linkedIn.svg';
+import IconLocation from 'assets/images/icon-location.svg';
+import IconMoney from 'assets/images/icon-payment.svg';
+import IconDuration from 'assets/images/icon-calendar-gig.svg';
+import IconHours from 'assets/images/icon-duration.svg';
+import IconTimezone from 'assets/images/icon-timezone.svg';
+
+// Cleanup HTML from style tags
+// so it won't affect other parts of the UI
+const ReactHtmlParserOptions = {
+ // eslint-disable-next-line consistent-return
+ transform: (node) => {
+ if (node.type === 'style' && node.name === 'style') {
+ return null;
+ }
+ },
+};
+
+export default function GigDetails(props) {
+ const { job } = props;
+ let shareUrl;
+ if (isomorphy.isClientSide()) {
+ shareUrl = encodeURIComponent(window.location.href);
+ }
+ let skills = getCustomField(job.custom_fields, 'Technologies Required');
+ if (skills !== 'n/a') skills = skills.split(',').join(', ');
+
+ return (
+
+ {
+ job.error ? (
+
+
Gig does not exist.
+
+ VIEW OTHER JOBS
+
+
+ ) : (
+
+
{job.name}
+
+
+
+
+ Location
+ {job.country}
+
+
+
+
+
+ Compensation
+ ${job.min_annual_salary} - ${job.max_annual_salary} / {getSalaryType(job.salary_type)}
+
+
+
+
+
+ Duration
+ {getCustomField(job.custom_fields, 'Duration')}
+
+
+
+
+
+ Hours
+ {getCustomField(job.custom_fields, 'Hours per week')} hours / week
+
+
+
+
+
+ Timezone
+ {getCustomField(job.custom_fields, 'Timezone')}
+
+
+
+
+
+
Required Tech Skills
+
{skills}
+
Description
+
{ReactHtmlParser(job.job_description_text, ReactHtmlParserOptions)}
+
+
Notes
+
+
+ * Topcoder does not provide visa sponsorship nor will we work with Staffing Agencies.
+
+
+ ** Topcoder and Wipro employees are not eligible for Gig work opportunities. Do not apply and send questions to support@topcoder.com .
+
+
+
+
+
+
+
+
Thank you for checking out our latest gig at Topcoder. Gig work through us is simple and effective for those that would like traditional freelance work. To learn more about how Gigs work with us, go here .
+
At Topcoder, we pride ourselves in bringing our customers the very best candidates to help fill their needs. Want to improve your chances? You can do a few things:
+
+ Check out our Topcoder challenges and participate. Challenges showing your technology skills make you a “qualified” candidate so we know you’re good. The proof is in the pudding!
+ Make sure your Topcoder profile says it all. Fill out your profile to the best of your ability. Your skills, your location, your devices, etc, all help you improve your chances of being selected for a gig
+ Let us know you’re here! Check in on our Gig Work forum and tell us you’re looking for a gig. It’s great visibility for the Gig team
+ Subscribe to our Gig notifications email. We’ll send you a weekly update on gigs available so you don’t miss a beat. Find the button at the top of this page.
+
+
+
+
+
+ )
+ }
+
+ );
+}
+
+GigDetails.propTypes = {
+ job: PT.shape().isRequired,
+};
diff --git a/src/shared/components/Gigs/style.scss b/src/shared/components/Gigs/style.scss
new file mode 100644
index 0000000000..03756963e1
--- /dev/null
+++ b/src/shared/components/Gigs/style.scss
@@ -0,0 +1,223 @@
+@import '~styles/mixins';
+@import "~components/Contentful/default";
+
+.container {
+ max-width: $screen-lg;
+ min-height: 50vh;
+ margin: auto;
+ color: #2a2a2a;
+
+ @include gui-kit-headers;
+ @include gui-kit-content;
+ @include roboto-regular;
+
+ @include xs-to-md {
+ padding: 0 15px;
+ }
+
+ .error {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 27px;
+ height: 80vh;
+ }
+
+ .wrap {
+ h2 {
+ color: #26b3c5;
+ text-align: center;
+ margin-top: 47px;
+ margin-bottom: 40px;
+
+ @include xs-to-md {
+ text-align: left;
+ }
+ }
+
+ .infos {
+ display: flex;
+ justify-content: center;
+ border-bottom: 1px solid #e9e9e9;
+ padding-bottom: 52px;
+
+ @include xs-to-md {
+ flex-direction: column;
+ }
+
+ .infos-item {
+ display: flex;
+ margin-right: 70px;
+
+ @include xs-to-md {
+ margin-bottom: 15px;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ }
+
+ svg {
+ margin-right: 8px;
+ margin-top: 6px;
+ }
+
+ .infos-data {
+ display: flex;
+ flex-direction: column;
+
+ strong {
+ font-weight: bold;
+ line-height: 30px;
+ }
+ }
+ }
+ }
+
+ .content {
+ display: flex;
+ margin-top: 44px;
+ margin-bottom: 192px;
+
+ @include xs-to-md {
+ flex-direction: column;
+ }
+
+ .right {
+ min-width: 408px;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+
+ @include xs-to-md {
+ margin-top: 47px;
+ }
+
+ .shareButtons {
+ display: flex;
+ align-items: center;
+
+ a {
+ margin-right: 5px;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+ }
+
+ .info-area {
+ background-color: #f4f4f4;
+ padding: 20px;
+ border-radius: 10px;
+ margin-top: 18px;
+
+ /* stylelint-disable */
+ p,
+ a,
+ li {
+ font-size: 14px;
+ line-height: 26px;
+ }
+
+ /* stylelint-enable */
+
+ ul {
+ margin-bottom: 0;
+
+ li {
+ margin-bottom: 15px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+
+ .gig-skills {
+ display: flex;
+ }
+ }
+
+ .left {
+ flex: 2;
+ margin-right: 80px;
+
+ /* stylelint-disable */
+ div strong {
+ font-weight: bold;
+ line-height: 30px;
+ display: inline-block;
+ }
+ /* stylelint-enable */
+
+ h4 {
+ margin-top: 35px;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+ }
+ }
+ }
+
+ .cta-buttons {
+ display: flex;
+ justify-content: center;
+ margin-top: 47px;
+
+ @include xs-to-sm {
+ flex-direction: column;
+ }
+
+ /* stylelint-disable */
+ a {
+ background-color: #fff;
+ border: 1px solid #137d60;
+ border-radius: 20px;
+ color: #229174;
+ font-size: 14px;
+ font-weight: bolder;
+ text-decoration: none;
+ text-transform: uppercase;
+ line-height: 40px;
+ padding: 0 20px;
+
+ &:hover {
+ box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.2);
+ }
+
+ @include xs-to-sm {
+ text-align: center;
+ }
+ }
+
+ /* stylelint-enable */
+
+ .primaryBtn {
+ background-color: #137d60;
+ border-radius: 20px;
+ color: #fff;
+ font-size: 14px;
+ font-weight: bolder;
+ text-decoration: none;
+ text-transform: uppercase;
+ line-height: 40px;
+ padding: 0 20px;
+ border: none;
+ outline: none;
+ margin-right: 20px;
+
+ &:hover {
+ box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.2);
+ background-color: #0ab88a;
+ }
+
+ @include xs-to-sm {
+ margin-bottom: 20px;
+ }
+ }
+ }
+}
diff --git a/src/shared/components/Leaderboard/ChallengeHistoryModal/index.jsx b/src/shared/components/Leaderboard/ChallengeHistoryModal/index.jsx
index 6793da87a5..8ebdbb6725 100644
--- a/src/shared/components/Leaderboard/ChallengeHistoryModal/index.jsx
+++ b/src/shared/components/Leaderboard/ChallengeHistoryModal/index.jsx
@@ -117,7 +117,7 @@ class ChallengeHistoryModal extends Component {
- {challenge['challenge.challenge_name'] || challenge['tco_leaderboard.challenge_id'] || challenge.challenge_id}
+ {challenge.challenge_name || challenge['challenge.challenge_name'] || challenge['tco_leaderboard.challenge_id'] || challenge.challenge_id}
{
diff --git a/src/shared/containers/Gigs/RecruitCRMJobDetails.jsx b/src/shared/containers/Gigs/RecruitCRMJobDetails.jsx
new file mode 100644
index 0000000000..b69aae2efe
--- /dev/null
+++ b/src/shared/containers/Gigs/RecruitCRMJobDetails.jsx
@@ -0,0 +1,69 @@
+/**
+ * A block that fetches and renders a job details page
+ * driven by recruitCRM
+ */
+
+import actions from 'actions/recruitCRM';
+import LoadingIndicator from 'components/LoadingIndicator';
+import GigDetails from 'components/Gigs/GigDetails';
+import PT from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+
+class RecruitCRMJobDetailsContainer extends React.Component {
+ componentDidMount() {
+ const {
+ getJob,
+ id,
+ } = this.props;
+
+ getJob(id);
+ }
+
+ render() {
+ const {
+ loading,
+ job,
+ } = this.props;
+
+ if (loading) {
+ return ;
+ }
+
+ return ;
+ }
+}
+
+RecruitCRMJobDetailsContainer.defaultProps = {
+ job: {},
+};
+
+RecruitCRMJobDetailsContainer.propTypes = {
+ getJob: PT.func.isRequired,
+ loading: PT.bool.isRequired,
+ job: PT.shape(),
+ id: PT.string.isRequired,
+};
+
+function mapStateToProps(state, ownProps) {
+ const data = state.recruitCRM[ownProps.id];
+ return {
+ job: data ? data.job : {},
+ loading: data ? data.loading : true,
+ };
+}
+
+function mapDispatchToActions(dispatch) {
+ const a = actions.recruit;
+ return {
+ getJob: (id) => {
+ dispatch(a.getJobInit(id));
+ dispatch(a.getJobDone(id));
+ },
+ };
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToActions,
+)(RecruitCRMJobDetailsContainer);
diff --git a/src/shared/containers/Gigs/RecruitCRMJobs.jsx b/src/shared/containers/Gigs/RecruitCRMJobs.jsx
new file mode 100644
index 0000000000..eacff10bde
--- /dev/null
+++ b/src/shared/containers/Gigs/RecruitCRMJobs.jsx
@@ -0,0 +1,161 @@
+/**
+ * A block that fetches and renders a job listing page
+ * driven by recruitCRM
+ */
+import _ from 'lodash';
+import actions from 'actions/recruitCRM';
+import LoadingIndicator from 'components/LoadingIndicator';
+import SearchCombo from 'components/GUIKit/SearchCombo';
+import Paginate from 'components/GUIKit/Paginate';
+import JobListCard from 'components/GUIKit/JobListCard';
+import PT from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import './jobLisingStyles.scss';
+
+const GIGS_PER_PAGE = 10;
+
+class RecruitCRMJobsContainer extends React.Component {
+ constructor(props) {
+ super(props);
+ // Filter initial state
+ this.state = {
+ term: '',
+ page: 0,
+ sortBy: 'updated_on',
+ };
+
+ this.onSearch = this.onSearch.bind(this);
+ this.onPaginate = this.onPaginate.bind(this);
+ this.onFilter = this.onFilter.bind(this);
+ }
+
+ componentDidMount() {
+ const {
+ getJobs,
+ jobs,
+ } = this.props;
+
+ // This gets all jobs.
+ // Pagination and filtering on front-side
+ if (!jobs.length) {
+ getJobs({
+ job_status: 1, // Open jobs only
+ });
+ }
+ }
+
+ /**
+ * Wraps all calls to setState
+ * @param {Object} newState the state update
+ */
+ onFilter(newState) {
+ // Do updates
+
+ // update the state
+ this.setState(newState);
+ }
+
+ onSearch(newTerm) {
+ this.onFilter({
+ term: newTerm,
+ page: 0,
+ });
+ }
+
+ onPaginate(newPage) {
+ this.onFilter({
+ page: newPage.selected,
+ });
+ }
+
+ render() {
+ const {
+ loading,
+ jobs,
+ } = this.props;
+ const {
+ term,
+ page,
+ sortBy,
+ } = this.state;
+
+ if (loading) {
+ return ;
+ }
+
+ let jobsToDisplay = jobs;
+ // Filter by term
+ if (term) {
+ jobsToDisplay = _.filter(jobs, (job) => {
+ // eslint-disable-next-line no-underscore-dangle
+ const _term = term.toLowerCase();
+ // name search
+ if (job.name.toLowerCase().includes(_term)) return true;
+ // skills search
+ const skills = _.find(job.custom_fields, ['field_name', 'Technologies Required']);
+ if (skills && skills.value && skills.value.toLowerCase().includes(_term)) return true;
+ // location
+ if (job.country.toLowerCase().includes(_term)) return true;
+ // no match
+ return false;
+ });
+ }
+ // Sort controlled by sortBy state
+ jobsToDisplay = jobsToDisplay.sort((a, b) => new Date(b[sortBy]) - new Date(a[sortBy]));
+ // Calc pages
+ const pages = Math.ceil(jobsToDisplay.length / GIGS_PER_PAGE);
+ // Paginate the results
+ jobsToDisplay = _.slice(
+ jobsToDisplay,
+ page * GIGS_PER_PAGE, (page * GIGS_PER_PAGE) + GIGS_PER_PAGE,
+ );
+
+ return (
+
+
+
+
+
+ {
+ jobsToDisplay.map(job => )
+ }
+
+
+
+ );
+ }
+}
+
+RecruitCRMJobsContainer.defaultProps = {
+ jobs: [],
+};
+
+RecruitCRMJobsContainer.propTypes = {
+ getJobs: PT.func.isRequired,
+ loading: PT.bool.isRequired,
+ jobs: PT.arrayOf(PT.shape),
+};
+
+function mapStateToProps(state) {
+ const data = state.recruitCRM;
+ return {
+ jobs: data ? data.jobs : [],
+ loading: data ? data.loading : true,
+ };
+}
+
+function mapDispatchToActions(dispatch) {
+ const a = actions.recruit;
+ return {
+ getJobs: (ownProps) => {
+ dispatch(a.getJobsInit(ownProps));
+ dispatch(a.getJobsDone(ownProps));
+ },
+ };
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToActions,
+)(RecruitCRMJobsContainer);
diff --git a/src/shared/containers/Gigs/jobLisingStyles.scss b/src/shared/containers/Gigs/jobLisingStyles.scss
new file mode 100644
index 0000000000..ef403335ee
--- /dev/null
+++ b/src/shared/containers/Gigs/jobLisingStyles.scss
@@ -0,0 +1,20 @@
+@import "~styles/mixins";
+
+.container {
+ max-width: $screen-lg;
+ margin: auto;
+
+ @include xs-to-sm {
+ padding: 0 15px;
+ }
+
+ .filters {
+ display: flex;
+ }
+
+ .jobs-list-container {
+ display: flex;
+ flex-direction: column;
+ margin: 20px 0 50px 0;
+ }
+}
diff --git a/src/shared/containers/GigsPages.jsx b/src/shared/containers/GigsPages.jsx
new file mode 100644
index 0000000000..65b18b48a8
--- /dev/null
+++ b/src/shared/containers/GigsPages.jsx
@@ -0,0 +1,41 @@
+/**
+ * Connects the Redux store to the GigsPages component.
+ */
+import React from 'react';
+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 RecruitCRMJobDetails from 'containers/Gigs/RecruitCRMJobDetails';
+
+
+export default function GigsPagesContainer(props) {
+ const { match } = props;
+ const { id } = match.params;
+ return (
+
+
+ {
+ id ? (
+
+ ) : (
+
+ )
+ }
+
+
+ );
+}
+
+GigsPagesContainer.defaultProps = {
+};
+
+GigsPagesContainer.propTypes = {
+ match: PT.shape().isRequired,
+};
diff --git a/src/shared/reducers/index.js b/src/shared/reducers/index.js
index dac86033f4..6c6baa6ddc 100644
--- a/src/shared/reducers/index.js
+++ b/src/shared/reducers/index.js
@@ -36,6 +36,7 @@ import { factory as leaderboardFactory } from './leaderboard';
import { factory as scoreboardFactory } from './tco/scoreboard';
import { factory as termsFactory } from './terms';
import newsletterPreferences from './newsletterPreferences';
+import recruitCRM from './recruitCRM';
/**
* Given HTTP request, generates options for SSR by topcoder-react-lib's reducer
@@ -140,6 +141,7 @@ export function factory(req) {
challengesBlock,
policyPages,
newsletterPreferences,
+ recruitCRM,
}));
}
diff --git a/src/shared/reducers/recruitCRM.js b/src/shared/reducers/recruitCRM.js
new file mode 100644
index 0000000000..599080cc17
--- /dev/null
+++ b/src/shared/reducers/recruitCRM.js
@@ -0,0 +1,77 @@
+/**
+ * Reducer for state.recruit
+ */
+import _ from 'lodash';
+import actions from 'actions/recruitCRM';
+import { handleActions } from 'redux-actions';
+
+/**
+ * Handles recruit.getJobsInit action.
+ * @param {Object} state Previous state.
+ */
+function onInit(state) {
+ return {
+ ...state,
+ jobs: {},
+ loading: true,
+ };
+}
+
+/**
+ * Handles recruit.getJobsDone action.
+ * @param {Object} state Previous state.
+ * @param {Object} action The action.
+ */
+function onDone(state, { payload }) {
+ return {
+ ...state,
+ loading: false,
+ jobs: _.filter(payload.data, job => job.enable_job_application_form === 1),
+ };
+}
+
+/**
+ * Handles recruit.getJobInit action.
+ * @param {Object} state Previous state.
+ */
+function onJobInit(state, { payload }) {
+ return {
+ ...state,
+ [payload.id]: {
+ loading: true,
+ },
+ };
+}
+
+/**
+ * Handles recruit.getJobDone action.
+ * @param {Object} state Previous state.
+ * @param {Object} action The action.
+ */
+function onJobDone(state, { payload }) {
+ return {
+ ...state,
+ [payload.id]: {
+ loading: false,
+ job: payload.data,
+ },
+ };
+}
+
+/**
+ * Creates recruitCRM reducer with the specified initial state.
+ * @param {Object} state Optional. If not given, the default one is
+ * generated automatically.
+ * @return {Function} Reducer.
+ */
+function create(state = {}) {
+ return handleActions({
+ [actions.recruit.getJobsInit]: onInit,
+ [actions.recruit.getJobsDone]: onDone,
+ [actions.recruit.getJobInit]: onJobInit,
+ [actions.recruit.getJobDone]: onJobDone,
+ }, state);
+}
+
+/* Reducer with the default initial state. */
+export default create();
diff --git a/src/shared/routes/GigsPages.jsx b/src/shared/routes/GigsPages.jsx
new file mode 100644
index 0000000000..dca924c079
--- /dev/null
+++ b/src/shared/routes/GigsPages.jsx
@@ -0,0 +1,21 @@
+/**
+ * The loader of Gigs page webpack chunks.
+ */
+import React from 'react';
+
+import LoadingPagePlaceholder from 'components/LoadingPagePlaceholder';
+import { AppChunk } from 'topcoder-react-utils';
+
+export default function GigsPagesRoute(props) {
+ return (
+ import(/* webpackChunkName: "gigsPages/chunk" */ 'containers/GigsPages')
+ .then(({ default: GigsPagesContainer }) => (
+
+ ))
+ }
+ renderPlaceholder={() => }
+ />
+ );
+}
diff --git a/src/shared/routes/index.jsx b/src/shared/routes/index.jsx
index 04f1f6d25f..9c8eb25569 100644
--- a/src/shared/routes/index.jsx
+++ b/src/shared/routes/index.jsx
@@ -23,6 +23,7 @@ import Sandbox from './Sandbox';
import Topcoder from './Topcoder';
import TrackHomePages from './TrackHomePages';
import PolicyPages from './PolicyPages';
+import GigsPages from './GigsPages';
function Routes({ communityId }) {
const metaTags = (
@@ -97,6 +98,11 @@ function Routes({ communityId }) {
exact
path={`${config.POLICY_PAGES_PATH}/:slug?`}
/>
+
diff --git a/src/shared/services/recruitCRM.js b/src/shared/services/recruitCRM.js
new file mode 100644
index 0000000000..0c124ef6ee
--- /dev/null
+++ b/src/shared/services/recruitCRM.js
@@ -0,0 +1,48 @@
+import fetch from 'isomorphic-fetch';
+import { logger } from 'topcoder-react-lib';
+import qs from 'qs';
+
+const PROXY_ENDPOINT = '/api/recruit';
+
+export default class Service {
+ baseUrl = PROXY_ENDPOINT;
+
+ /**
+ * Get jobs by query
+ * @param {*} query The request query
+ */
+ async getJobs(query) {
+ const res = await fetch(`${this.baseUrl}/jobs/search?${qs.stringify(query)}`);
+ if (!res.ok) {
+ const error = new Error('Failed to get jobs');
+ logger.error(error, res);
+ }
+ return res.json();
+ }
+
+ /**
+ * Get job by id
+ * @param {*} id The request id
+ */
+ async getJob(id) {
+ const res = await fetch(`${this.baseUrl}/jobs/${id}`);
+ if (!res.ok) {
+ const error = new Error(`Failed to get job ${id}`);
+ logger.error(error, res);
+ }
+ return res.json();
+ }
+
+ /**
+ * Get all jobs
+ * @param {*} query The request query
+ */
+ async getAllJobs(query) {
+ const res = await fetch(`${this.baseUrl}/jobs?${qs.stringify(query)}`);
+ if (!res.ok) {
+ const error = new Error('Failed to get jobs');
+ logger.error(error, res);
+ }
+ return res.json();
+ }
+}
diff --git a/src/shared/utils/gigs.js b/src/shared/utils/gigs.js
new file mode 100644
index 0000000000..63d42a1201
--- /dev/null
+++ b/src/shared/utils/gigs.js
@@ -0,0 +1,28 @@
+/**
+ * Gig work utils
+ */
+
+import _ from 'lodash';
+
+/**
+ * Salary Type mapper
+ * @param {Object} data the data
+ */
+export function getSalaryType(data) {
+ switch (data.id) {
+ case 2: return 'annual';
+ case 3: return 'week';
+ default: return 'n/a';
+ }
+}
+
+/**
+ * Custom Field mapper
+ * @param {Array} data the data
+ */
+export function getCustomField(data, key) {
+ const val = _.find(data, {
+ field_name: key,
+ });
+ return val && val.value ? val.value : 'n/a';
+}