From 4860b558df048381d80154935cefe3eeaa753a38 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 4 Feb 2021 10:27:26 +0200 Subject: [PATCH 01/19] Growsurf for gigs init --- config/custom-environment-variables.js | 3 + config/default.js | 10 + package.json | 1 + src/server/index.js | 2 + src/server/routes/growsurf.js | 20 ++ src/server/routes/mailchimp.js | 3 + src/server/services/growsurf.js | 95 +++++++++ src/server/services/recruitCRM.js | 39 +++- src/server/services/sendGrid.js | 30 +++ .../components/Gigs/GigDetails/index.jsx | 44 ++++- .../components/Gigs/GigDetails/style.scss | 57 ++++-- .../components/Gigs/ReferralModal/index.jsx | 182 ++++++++++++++++++ .../components/Gigs/ReferralModal/modal.scss | 113 +++++++++++ .../containers/Gigs/RecruitCRMJobDetails.jsx | 164 +++++++++++++++- 14 files changed, 733 insertions(+), 30 deletions(-) create mode 100644 src/server/routes/growsurf.js create mode 100644 src/server/services/growsurf.js create mode 100644 src/server/services/sendGrid.js create mode 100644 src/shared/components/Gigs/ReferralModal/index.jsx create mode 100644 src/shared/components/Gigs/ReferralModal/modal.scss diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js index 51264f7d73..bc900c19ce 100644 --- a/config/custom-environment-variables.js +++ b/config/custom-environment-variables.js @@ -97,7 +97,10 @@ module.exports = { }, RECRUITCRM_API_KEY: 'RECRUITCRM_API_KEY', + GROWSURF_API_KEY: 'GROWSURF_API_KEY', + SENDGRID_API_KEY: 'SENDGRID_API_KEY', }, + GROWSURF_CAMPAIGN_ID: 'GROWSURF_CAMPAIGN_ID', AUTH_CONFIG: { AUTH0_URL: 'TC_M2M_AUTH0_URL', AUTH0_AUDIENCE: 'TC_M2M_AUDIENCE', diff --git a/config/default.js b/config/default.js index 9f7fed509e..1ec5cbf3a8 100644 --- a/config/default.js +++ b/config/default.js @@ -247,6 +247,16 @@ module.exports = { }, RECRUITCRM_API_KEY: '', + GROWSURF_API_KEY: '', + SENDGRID_API_KEY: '', + }, + + GROWSURF_CAMPAIGN_ID: '', + GROWSURF_COOKIE: '_tc_gigs_ref', + GROWSURF_COOKIE_SETTINGS: { + secure: true, + domain: '', + expires: 7, // days }, AUTH_CONFIG: { diff --git a/package.json b/package.json index d0e7836e7d..2e524b7bb8 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@hapi/joi": "^16.1.4", + "@sendgrid/mail": "^7.4.2", "@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/server/index.js b/src/server/index.js index b4892eb12f..b6b81007de 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -28,6 +28,7 @@ import mailChimpRouter from './routes/mailchimp'; import mockDocuSignFactory from './__mocks__/docu-sign-mock'; import recruitCRMRouter from './routes/recruitCRM'; import mmLeaderboardRouter from './routes/mmLeaderboard'; +import growsurfRouter from './routes/growsurf'; /* Dome API for topcoder communities */ import tcCommunitiesDemoApi from './tc-communities'; @@ -137,6 +138,7 @@ async function onExpressJsSetup(server) { server.use('/api/mailchimp', mailChimpRouter); server.use('/api/recruit', recruitCRMRouter); server.use('/api/mml', mmLeaderboardRouter); + server.use('/api/growsurf', growsurfRouter); // serve demo api server.use( diff --git a/src/server/routes/growsurf.js b/src/server/routes/growsurf.js new file mode 100644 index 0000000000..7e7fa2c1a8 --- /dev/null +++ b/src/server/routes/growsurf.js @@ -0,0 +1,20 @@ +/** + * The routes related to Growsurf integration + */ + +import express from 'express'; +import GrowsurfService from '../services/growsurf'; + +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('/participants', (req, res) => new GrowsurfService().getParticipant(req, res).then(res.send.bind(res))); +routes.post('/participants', (req, res) => new GrowsurfService().getOrCreateParticipant(req, res).then(res.send.bind(res))); + +export default routes; diff --git a/src/server/routes/mailchimp.js b/src/server/routes/mailchimp.js index 0b8b2a98fe..b773e50366 100644 --- a/src/server/routes/mailchimp.js +++ b/src/server/routes/mailchimp.js @@ -4,6 +4,7 @@ import express from 'express'; import MailchimpService from '../services/mailchimp'; +import { sendEmail } from '../services/sendGrid'; const routes = express.Router(); /* Sets Access-Control-Allow-Origin header to avoid CORS error. @@ -28,4 +29,6 @@ routes.get('/campaign-folders', (req, res) => new MailchimpService().getCampaign routes.get('/campaigns', (req, res) => new MailchimpService().getCampaigns(req).then(res.send.bind(res))); +routes.post('/email', (req, res) => sendEmail(req, res).then(res.send.bind(res))); + export default routes; diff --git a/src/server/services/growsurf.js b/src/server/services/growsurf.js new file mode 100644 index 0000000000..c9a3ef5f84 --- /dev/null +++ b/src/server/services/growsurf.js @@ -0,0 +1,95 @@ +/** + * Server-side functions necessary for effective integration with growsurf + */ +import fetch from 'isomorphic-fetch'; +import config from 'config'; + +/** + * Auxiliary class that handles communication with growsurf + */ +export default class GrowsurfService { + /** + * Creates a new service instance. + * @param {String} baseUrl The base API endpoint. + */ + constructor(baseUrl = 'https://api.growsurf.com/v2') { + this.private = { + baseUrl, + apiKey: config.SECRET.GROWSURF_API_KEY, + authorization: `Bearer ${config.SECRET.GROWSURF_API_KEY}`, + }; + } + + /** + * Gets get participant by email or id. + * @return {Promise} + * @param {Object} req the request. + * @param {Object} res the response. + */ + async getParticipant(req, res) { + const { participantId } = req.query; + const response = await fetch(`${this.private.baseUrl}/campaign/${config.GROWSURF_CAMPAIGN_ID}/participant/${participantId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: this.private.authorization, + }, + }); + if (response.status >= 300) { + res.status(response.status); + return { + error: await response.json(), + code: response.status, + url: `${this.private.baseUrl}/campaign/${config.GROWSURF_CAMPAIGN_ID}/participant/${participantId}`, + }; + } + const data = await response.json(); + return data; + } + + /** + * Add participant + * @return {Promise} + * @param {Object} body the request payload. + */ + async addParticipant(body) { + const response = await fetch(`${this.private.baseUrl}/campaign/${config.GROWSURF_CAMPAIGN_ID}/participant`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: this.private.authorization, + }, + body, + }); + if (response.status >= 300) { + return { + error: await response.json(), + code: response.status, + url: `${this.private.baseUrl}/campaign/${config.GROWSURF_CAMPAIGN_ID}/participant`, + body, + }; + } + const data = await response.json(); + return data; + } + + /** + * Gets get participant by email or id + * if not exxists create it + * @return {Promise} + * @param {Object} req the request. + * @param {Object} res the response. + */ + async getOrCreateParticipant(req, res) { + const { body } = req; + const result = await this.addParticipant(JSON.stringify({ + email: body.email, + firstName: body.firstName, + lastName: body.lastName, + })); + if (result.error) { + res.status(result.code); + } + return result; + } +} diff --git a/src/server/services/recruitCRM.js b/src/server/services/recruitCRM.js index 6c1b712773..33c44d72ff 100644 --- a/src/server/services/recruitCRM.js +++ b/src/server/services/recruitCRM.js @@ -5,6 +5,7 @@ import fetch from 'isomorphic-fetch'; import config from 'config'; import qs from 'qs'; import _ from 'lodash'; +import GrowsurfService from './growsurf'; const FormData = require('form-data'); @@ -188,7 +189,35 @@ export default class RecruitCRMService { const fileData = new FormData(); fileData.append('resume', file.buffer, file.originalname); let candidateSlug; + let referralCookie = req.cookies[config.GROWSURF_COOKIE]; + if (referralCookie) referralCookie = JSON.parse(referralCookie); try { + // referral tracking via growsurf + if (referralCookie && referralCookie.gigId === id) { + const gs = new GrowsurfService(); + const growRes = await gs.addParticipant(JSON.stringify({ + email: form.email, + referredBy: referralCookie.referralId, + referralStatus: 'CREDIT_PENDING', + firstName: form.first_name, + lastName: form.last_name, + metadata: { + gigId: id, + }, + })); + // If everything set in Growsurf + // add referral link to candidate profile in recruitCRM + if (!growRes.error) { + form.custom_fields.push({ + field_id: 6, value: `https://app.growsurf.com/dashboard/campaign/${config.GROWSURF_CAMPAIGN_ID}/participant/${growRes.id}`, + }); + } + // clear the cookie + res.cookie(config.GROWSURF_COOKIE, '', { + maxAge: 0, + overwrite: true, + }); + } // Check if candidate exsits in the system? const candidateResponse = await fetch(`${this.private.baseUrl}/v1/candidates/search?email=${form.email}`, { method: 'GET', @@ -197,7 +226,7 @@ export default class RecruitCRMService { Authorization: this.private.authorization, }, }); - if (candidateResponse.status >= 400) { + if (candidateResponse.status >= 300) { return res.send({ error: true, status: candidateResponse.status, @@ -231,7 +260,7 @@ export default class RecruitCRMService { }, body: JSON.stringify(form), }); - if (workCandidateResponse.status >= 400) { + if (workCandidateResponse.status >= 300) { return res.send({ error: true, status: workCandidateResponse.status, @@ -251,7 +280,7 @@ export default class RecruitCRMService { }, body: fileData, }); - if (fileCandidateResponse.status >= 400) { + if (fileCandidateResponse.status >= 300) { return res.send({ error: true, status: fileCandidateResponse.status, @@ -272,7 +301,7 @@ export default class RecruitCRMService { Authorization: this.private.authorization, }, }); - if (applyResponse.status >= 400) { + if (applyResponse.status >= 300) { const errObj = await applyResponse.json(); if (errObj.errorCode === 422 && errObj.errorMessage === 'Candidate is already assigned to this job') { return res.send({ @@ -301,7 +330,7 @@ export default class RecruitCRMService { status_id: '10', }), }); - if (hireStageResponse.status >= 400) { + if (hireStageResponse.status >= 300) { return res.send({ error: true, status: hireStageResponse.status, diff --git a/src/server/services/sendGrid.js b/src/server/services/sendGrid.js new file mode 100644 index 0000000000..9660051211 --- /dev/null +++ b/src/server/services/sendGrid.js @@ -0,0 +1,30 @@ +/** + * Server-side functions necessary for sending emails via Sendgrid APIs + */ +import config from 'config'; + +const sgMail = require('@sendgrid/mail'); + +sgMail.setApiKey(config.SECRET.SENDGRID_API_KEY); + +/** + * Sends emails via the Sendgrid API + * https://sendgrid.com/docs/for-developers/sending-email/quickstart-nodejs/#starting-the-project + * @param {Object} req the request + * @param {Object} res the response + */ +export async function sendEmail(req, res) { + const { body } = req; + try { + const result = await sgMail.send(body); + if (result.status >= 300) { + res.status(result.status); + } + return result; + } catch (e) { + res.status(500); + return { message: e.message }; + } +} + +export default undefined; diff --git a/src/shared/components/Gigs/GigDetails/index.jsx b/src/shared/components/Gigs/GigDetails/index.jsx index 985384872b..fdcf678306 100644 --- a/src/shared/components/Gigs/GigDetails/index.jsx +++ b/src/shared/components/Gigs/GigDetails/index.jsx @@ -3,11 +3,12 @@ * The Gig details component. */ -import React from 'react'; +import React, { useState } 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 { getQuery } from 'utils/url'; import SubscribeMailChimpTag from 'containers/SubscribeMailChimpTag'; import './style.scss'; import IconFacebook from 'assets/images/icon-facebook.svg'; @@ -23,6 +24,7 @@ import iconLabel1 from 'assets/images/l1.png'; import iconLabel2 from 'assets/images/l2.png'; import iconLabel3 from 'assets/images/l3.png'; import SadFace from 'assets/images/sad-face-icon.svg'; +import ReferralModal from '../ReferralModal'; // Cleanup HTML from style tags // so it won't affect other parts of the UI @@ -36,16 +38,24 @@ const ReactHtmlParserOptions = { }; export default function GigDetails(props) { - const { job, application } = props; + const { + job, application, profile, onSendClick, isReferrSucess, formData, formErrors, onFormInputChange, isReferrError, getReferralId, referralId, + } = props; let shareUrl; + let showModalInitially = false; if (isomorphy.isClientSide()) { shareUrl = encodeURIComponent(window.location.href); + const query = getQuery(); + showModalInitially = !!query.referr; + if (showModalInitially) getReferralId(); } let skills = getCustomField(job.custom_fields, 'Technologies Required'); if (skills !== 'n/a') skills = skills.split(',').join(', '); const hPerW = getCustomField(job.custom_fields, 'Hours per week'); const compens = job.min_annual_salary === job.max_annual_salary ? job.max_annual_salary : `${job.min_annual_salary} - ${job.max_annual_salary} (USD)`; + const [isModalOpen, setModalOpen] = useState(showModalInitially); + return (
{ @@ -161,6 +171,25 @@ export default function GigDetails(props) {
If you have any questions or doubts, don’t hesitate to email support@topcoder.com.
+
+ + { + isModalOpen + && ( + setModalOpen(false)} + onSendClick={onSendClick} + isReferrSucess={isReferrSucess} + formErrors={formErrors} + formData={formData} + onFormInputChange={onFormInputChange} + isReferrError={isReferrError} + referralId={referralId} + /> + ) + } +
@@ -172,9 +201,20 @@ export default function GigDetails(props) { GigDetails.defaultProps = { application: null, + profile: {}, + referralId: null, }; GigDetails.propTypes = { job: PT.shape().isRequired, application: PT.shape(), + profile: PT.shape(), + onSendClick: PT.func.isRequired, + isReferrSucess: PT.bool.isRequired, + formErrors: PT.shape().isRequired, + formData: PT.shape().isRequired, + onFormInputChange: PT.func.isRequired, + isReferrError: PT.shape().isRequired, + getReferralId: PT.func.isRequired, + referralId: PT.string, }; diff --git a/src/shared/components/Gigs/GigDetails/style.scss b/src/shared/components/Gigs/GigDetails/style.scss index 74c0cf3502..af1b9dac7d 100644 --- a/src/shared/components/Gigs/GigDetails/style.scss +++ b/src/shared/components/Gigs/GigDetails/style.scss @@ -1,6 +1,37 @@ @import '~styles/mixins'; @import "~components/Contentful/default"; +@mixin primaryBtn { + background-color: #137d60; + border-radius: 20px; + color: #fff !important; + font-size: 14px !important; + font-weight: bolder !important; + text-decoration: none !important; + text-transform: uppercase !important; + line-height: 40px !important; + padding: 0 20px !important; + border: none; + outline: none; + + &:hover { + box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.2); + background-color: #0ab88a; + } + + @include xs-to-sm { + margin-bottom: 20px; + } +} + +.primaryBtn { + @include primaryBtn; +} + +.referral { + display: flex; +} + .container { max-width: $screen-lg; min-height: 50vh; @@ -145,6 +176,7 @@ font-weight: bold; } } + /* stylelint-enable */ ul { @@ -195,6 +227,7 @@ line-height: 30px; display: inline-block; } + /* stylelint-enable */ h4 { @@ -209,10 +242,12 @@ display: flex; align-items: center; line-height: 21px; + /* stylelint-disable */ img { margin-right: 8px; } + /* stylelint-enable */ } } @@ -253,27 +288,9 @@ /* 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; - } + @include primaryBtn; } } -} +} \ No newline at end of file diff --git a/src/shared/components/Gigs/ReferralModal/index.jsx b/src/shared/components/Gigs/ReferralModal/index.jsx new file mode 100644 index 0000000000..da9d571be7 --- /dev/null +++ b/src/shared/components/Gigs/ReferralModal/index.jsx @@ -0,0 +1,182 @@ +/** + * The modal used for gig referral flow + */ + +/* global window */ + +import { isEmpty } from 'lodash'; +import PT from 'prop-types'; +import React from 'react'; +import { Modal, PrimaryButton } from 'topcoder-react-ui-kit'; +import { config, Link } from 'topcoder-react-utils'; +import TextInput from 'components/GUIKit/TextInput'; +import Textarea from 'components/GUIKit/Textarea'; +import tc from 'components/buttons/themed/tc.scss'; +import ContentBlock from 'components/Contentful/ContentBlock'; +import LoadingIndicator from 'components/LoadingIndicator'; +import modalStyle from './modal.scss'; + +/** Themes for buttons + * those overwrite PrimaryButton style to match achieve various styles. + * Should implement pattern of classes. + */ +const buttonThemes = { + tc, +}; + +// help article link +const HELP_INFO_LINK = '/community/getting-the-gig'; + +function ReferralModal({ + profile, + onCloseButton, + onSendClick, + isReferrSucess, + formErrors, + formData, + onFormInputChange, + isReferrError, + referralId, +}) { + return ( + + { !isEmpty(profile) ? ( +
+ { + !isReferrSucess && !isReferrError && referralId ? ( +
+ + onFormInputChange('email', val)} + errorMsg={formErrors.email} + value={formData.email} + required + /> +
+