diff --git a/.circleci/config.yml b/.circleci/config.yml index 3b0049b420..7d107a6dc6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -283,21 +283,21 @@ workflows: filters: branches: only: - - route-redirects + - gig-application-update # This is alternate dev env for parallel testing - "build-qa": context : org-global filters: branches: only: - - tco-leaderboard-fix + - free # This is beta env for production soft releases - "build-prod-beta": context : org-global filters: branches: only: - - free + - develop # This is stage env for production QA releases - "build-prod-staging": context : org-global diff --git a/src/assets/images/checkmark-green.svg b/src/assets/images/checkmark-green.svg new file mode 100644 index 0000000000..a31e6590cf --- /dev/null +++ b/src/assets/images/checkmark-green.svg @@ -0,0 +1,12 @@ + + + CA804B2A-E950-4496-A96D-0236E1B4CB62@2x + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/social.jpg b/src/assets/images/social.jpg deleted file mode 100644 index 1d33709a45..0000000000 Binary files a/src/assets/images/social.jpg and /dev/null differ diff --git a/src/assets/images/social.png b/src/assets/images/social.png new file mode 100644 index 0000000000..f64e599238 Binary files /dev/null and b/src/assets/images/social.png differ diff --git a/src/server/services/recruitCRM.js b/src/server/services/recruitCRM.js index 1320f95467..4f8ee4e2e1 100644 --- a/src/server/services/recruitCRM.js +++ b/src/server/services/recruitCRM.js @@ -187,7 +187,9 @@ export default class RecruitCRMService { const { body, file } = req; const form = JSON.parse(body.form); const fileData = new FormData(); - fileData.append('resume', file.buffer, file.originalname); + if (file) { + fileData.append('resume', file.buffer, file.originalname); + } let candidateSlug; let referralCookie = req.cookies[config.GROWSURF_COOKIE]; if (referralCookie) referralCookie = JSON.parse(referralCookie); @@ -272,29 +274,31 @@ export default class RecruitCRMService { }); } candidateData = await workCandidateResponse.json(); - // Attach resume to candidate - const formHeaders = fileData.getHeaders(); - const fileCandidateResponse = await fetch(`${this.private.baseUrl}/v1/candidates/${candidateData.slug}`, { - method: 'POST', - headers: { - Authorization: this.private.authorization, - ...formHeaders, - }, - body: fileData, - }); - if (fileCandidateResponse.status >= 300) { - return res.send({ - error: true, - status: fileCandidateResponse.status, - url: `${this.private.baseUrl}/v1/candidates/${candidateData.slug}`, - form, - fileData, - file, - formHeaders, - errObj: await fileCandidateResponse.json(), + // Attach resume to candidate if uploaded + if (file) { + const formHeaders = fileData.getHeaders(); + const fileCandidateResponse = await fetch(`${this.private.baseUrl}/v1/candidates/${candidateData.slug}`, { + method: 'POST', + headers: { + Authorization: this.private.authorization, + ...formHeaders, + }, + body: fileData, }); + if (fileCandidateResponse.status >= 300) { + return res.send({ + error: true, + status: fileCandidateResponse.status, + url: `${this.private.baseUrl}/v1/candidates/${candidateData.slug}`, + form, + fileData, + file, + formHeaders, + errObj: await fileCandidateResponse.json(), + }); + } + candidateData = await fileCandidateResponse.json(); } - candidateData = await fileCandidateResponse.json(); // Candidate ready to apply for job const applyResponse = await fetch(`${this.private.baseUrl}/v1/candidates/${candidateData.slug}/assign?job_slug=${id}`, { method: 'POST', diff --git a/src/shared/actions/recruitCRM.js b/src/shared/actions/recruitCRM.js index 1b9b127f25..cc82b1446d 100644 --- a/src/shared/actions/recruitCRM.js +++ b/src/shared/actions/recruitCRM.js @@ -58,47 +58,45 @@ function normalizeRecruitPayload(job, payload) { const perJob = [ `${job.name} ->`, `Pay Expectation: ${payload.payExpectation}`, - `Date Available: ${new Date(payload.availFrom).toDateString()}`, - `Heard About Gig: ${payload.reffereal}`, - `Why fit: ${payload.whyFit}`, `Able to work during timezone? ${payload.timezoneConfirm.filter(s => s.value).map(() => getCustomField(job.custom_fields, 'Timezone')).join(',')}`, - `Am I ok to work the duration? ${payload.durationConfirm.filter(s => s.value).map(s => s.label).join(',')}`, - `Notes: ${payload.notes}`, + `Am I ok to work the duration? ${payload.durationConfirm.filter(s => s.value).map(() => getCustomField(job.custom_fields, 'Duration')).join(',')}`, ]; - return { + const referral = _.find(payload.reffereal, { selected: true }); + const normalized = { last_name: payload.lname, first_name: payload.fname, email: payload.email, contact_number: payload.phone, city: payload.city, locality: _.find(payload.country, { selected: true }).label, - available_from: payload.availFrom, salary_expectation: payload.payExpectation, skill: payload.skills.filter(s => s.selected).map(s => s.label).join(','), custom_fields: [ - { - field_id: 13, - value: payload.reffereal || '', - }, { field_id: 1, - value: payload.tcProfileLink || (payload.handle ? `topcoder.com/members/${payload.handle}` : ''), + value: payload.tcProfileLink || (payload.handle ? `https://topcoder.com/members/${payload.handle}` : ''), }, { field_id: 2, value: payload.handle || '', }, - { - field_id: 3, - value: payload.whyFit || '', - }, { field_id: 14, value: perJob.join(','), }, ], - resume: payload.fileCV, }; + if (referral) { + normalized.custom_fields.push({ + field_id: 13, + value: referral.label, + }); + } + if (payload.fileCV) { + normalized.resume = payload.fileCV; + } + + return normalized; } /** @@ -124,6 +122,37 @@ async function applyForJobDone(job, payload) { } } +/** + * Search for cnadidate in recruit + */ +function searchCandidatesInit(email) { + return { email }; +} + +/** + * Search for cnadidate in recruit and get profile if available + * @param {string} email the email to search + */ +async function searchCandidatesDone(email) { + const ss = new Service(); + try { + const res = await ss.searchCandidates(email); + + return { + email, + data: res, + }; + } catch (error) { + return { + email, + data: { + error: true, + errorObj: error, + }, + }; + } +} + export default redux.createActions({ RECRUIT: { GET_JOBS_INIT: getJobsInit, @@ -132,5 +161,7 @@ export default redux.createActions({ GET_JOB_DONE: getJobDone, APPLY_FOR_JOB_INIT: applyForJobInit, APPLY_FOR_JOB_DONE: applyForJobDone, + SEARCH_CANDIDATES_INIT: searchCandidatesInit, + SEARCH_CANDIDATES_DONE: searchCandidatesDone, }, }); diff --git a/src/shared/components/GUIKit/Assets/Styles/Includes/_mixin.scss b/src/shared/components/GUIKit/Assets/Styles/Includes/_mixin.scss index 5589e9dc31..ef6b4576f7 100644 --- a/src/shared/components/GUIKit/Assets/Styles/Includes/_mixin.scss +++ b/src/shared/components/GUIKit/Assets/Styles/Includes/_mixin.scss @@ -4,6 +4,7 @@ $gui-kit-gray-90: #2a2a2a; $gui-kit-level-2: #0ab88a; $gui-kit-level-5: #ef476f; $gui-kit-active-label: #229174; +$gui-kit-readonly: #d4d4d4; @mixin textInputLabel { font-size: 12px; diff --git a/src/shared/components/GUIKit/DropdownTerms/index.jsx b/src/shared/components/GUIKit/DropdownTerms/index.jsx index 1ef2c69792..74001e88bd 100644 --- a/src/shared/components/GUIKit/DropdownTerms/index.jsx +++ b/src/shared/components/GUIKit/DropdownTerms/index.jsx @@ -41,6 +41,9 @@ function DropdownTerms({ selectInput[0].style.borderTop = 'none'; } }, [focused, selectedOption]); + useEffect(() => { + setInternalTerms(terms); + }, [terms]); const CustomReactSelectRow = React.forwardRef(({ className, diff --git a/src/shared/components/GUIKit/JobListCard/index.jsx b/src/shared/components/GUIKit/JobListCard/index.jsx index 135a4845ba..6502ff43bd 100644 --- a/src/shared/components/GUIKit/JobListCard/index.jsx +++ b/src/shared/components/GUIKit/JobListCard/index.jsx @@ -15,6 +15,7 @@ import iconBlackSkills from 'assets/images/icon-skills.png'; export default function JobListCard({ job, }) { + const duration = getCustomField(job.custom_fields, 'Duration'); let skills = getCustomField(job.custom_fields, 'Technologies Required'); if (skills !== 'n/a') { skills = skills.split(','); @@ -39,7 +40,7 @@ export default function JobListCard({ ${job.min_annual_salary} - {job.max_annual_salary} (USD) / {getSalaryType(job.salary_type)}
- {getCustomField(job.custom_fields, 'Duration')} + {/^\d+$/.test(duration) ? `${duration} Weeks` : duration}
VIEW DETAILS diff --git a/src/shared/components/GUIKit/TextInput/index.jsx b/src/shared/components/GUIKit/TextInput/index.jsx index d4ef9938c9..43d910a76a 100644 --- a/src/shared/components/GUIKit/TextInput/index.jsx +++ b/src/shared/components/GUIKit/TextInput/index.jsx @@ -28,7 +28,7 @@ function TextInput({ const sizeStyle = size === 'lg' ? 'lgSize' : 'xsSize'; return ( -
+
@@ -74,7 +77,7 @@ export default function GigApply(props) { VIEW OTHER GIGS ) : ( - GO TO GIG LIST + GO TO GIGS LIST ) }
@@ -92,8 +95,16 @@ export default function GigApply(props) { { !application && !applying ? (
+ {!_.isEmpty(recruitProfile) + && ( +
+
It looks like you have applied to a gig previously. Perfect!
+

We have most of your information. Is there anything you would like to update to your Gig Work Profile?

+
+ )}

PERSONAL INFORMATION

-

Welcome to Topcoder Gigs! We’d like to get to know you.

+ {_.isEmpty(recruitProfile) + &&

Welcome to Topcoder Gigs! We’d like to get to know you.

}
@@ -151,7 +164,8 @@ export default function GigApply(props) { />
-

TOPCODER INFORMATION

+ {_.isEmpty(recruitProfile) &&

TOPCODER INFORMATION

} + {_.isEmpty(recruitProfile) && (
-

SHARE YOUR EXPECTATIONS

-

Your Professional Work History

+ )} +

SHARE YOUR WEEKLY PAY EXPECTATIONS

onFormInputChange('payExpectation', val)} errorMsg={formErrors.payExpectation} value={formData.payExpectation} - /> - onFormInputChange('availFrom', val ? val.toISOString() : null)} - errorMsg={formErrors.availFrom} - value={formData.availFrom} + required />

RESUME & SKILLS

-

Upload Your Resume or CV

+ { + recruitProfile.resume ? ( +

Upload Your Resume or CV, {recruitProfile.resume.filename}

+ ) : ( +

Upload Your Resume or CV

+ ) + }

FINAL QUESTIONS

-

Please Complete the Following Questions

- onFormInputChange('reffereal', val)} errorMsg={formErrors.reffereal} - value={formData.reffereal} + options={formData.reffereal} required /> + )}
- onFormInputChange('whyFit', val)} - errorMsg={formErrors.whyFit} - value={formData.whyFit} - /> -

Are you able to work during the specified timezone? ({`${getCustomField(job.custom_fields, 'Timezone')}`})

+

Are you able to work during the specified timezone? ({`${getCustomField(job.custom_fields, 'Timezone')}`}) *

onFormInputChange('timezoneConfirm', val)} errorMsg={formErrors.timezoneConfirm} options={formData.timezoneConfirm} size="lg" /> -

Are you ok to work with the duration of the gig? ({`${getCustomField(job.custom_fields, 'Duration')}`})

- onFormInputChange('durationConfirm', val)} - errorMsg={formErrors.durationConfirm} - options={formData.durationConfirm} - size="lg" - />
- onFormInputChange('notes', val)} - errorMsg={formErrors.notes} +

Are you ok to work with the duration of the gig? ({/^\d+$/.test(duration) ? `${duration} Weeks` : duration}) *

+ onFormInputChange('durationConfirm', val)} + errorMsg={formErrors.durationConfirm} + options={formData.durationConfirm} + size="lg" />
@@ -313,4 +315,5 @@ GigApply.propTypes = { applying: PT.bool, application: PT.shape(), user: PT.shape(), + recruitProfile: PT.shape().isRequired, }; diff --git a/src/shared/components/Gigs/GigApply/style.scss b/src/shared/components/Gigs/GigApply/style.scss index b807f0b8ab..ec860228fc 100644 --- a/src/shared/components/Gigs/GigApply/style.scss +++ b/src/shared/components/Gigs/GigApply/style.scss @@ -148,6 +148,25 @@ } } + .info-text { + font-size: 16px; + margin-top: 55px; + + h6 { + font-family: Barlow, sans-serif; + font-size: 16px; + line-height: 20px; + font-weight: 600; + margin-bottom: 8px; + display: flex; + align-items: center; + + svg { + margin-left: 5px; + } + } + } + .form-section { margin: 13px 0 50px; diff --git a/src/shared/components/Gigs/GigDetails/index.jsx b/src/shared/components/Gigs/GigDetails/index.jsx index c6faae5040..a28eb790af 100644 --- a/src/shared/components/Gigs/GigDetails/index.jsx +++ b/src/shared/components/Gigs/GigDetails/index.jsx @@ -57,6 +57,7 @@ export default function GigDetails(props) { const [isModalOpen, setModalOpen] = useState(false); const [isLoginModalOpen, setLoginModalOpen] = useState(false); let inputRef; + const duration = getCustomField(job.custom_fields, 'Duration'); useEffect(() => { if (referralId && formData.email && isEmpty(formErrors)) onSendClick(); @@ -95,7 +96,7 @@ export default function GigDetails(props) {
Duration - {getCustomField(job.custom_fields, 'Duration')} + {/^\d+$/.test(duration) ? `${duration} Weeks` : duration}
diff --git a/src/shared/components/Gigs/LoginModal/modal.scss b/src/shared/components/Gigs/LoginModal/modal.scss index ad4df8efb7..7b6dea3fb2 100644 --- a/src/shared/components/Gigs/LoginModal/modal.scss +++ b/src/shared/components/Gigs/LoginModal/modal.scss @@ -29,7 +29,7 @@ color: #ef476f; font-size: 24px; line-height: 36px; - margin-bottom: 40px; + margin-bottom: 20px; } .ctaButtons { diff --git a/src/shared/containers/Gigs/RecruitCRMJobApply.jsx b/src/shared/containers/Gigs/RecruitCRMJobApply.jsx index c0b847e339..f418265630 100644 --- a/src/shared/containers/Gigs/RecruitCRMJobApply.jsx +++ b/src/shared/containers/Gigs/RecruitCRMJobApply.jsx @@ -5,6 +5,7 @@ import _ from 'lodash'; import actions from 'actions/recruitCRM'; import GigApply from 'components/Gigs/GigApply'; +import LoadingIndicator from 'components/LoadingIndicator'; import PT from 'prop-types'; import React from 'react'; import { connect } from 'react-redux'; @@ -21,12 +22,19 @@ class RecruitCRMJobApplyContainer extends React.Component { this.state = { formErrors: {}, formData: { - availFrom: new Date().toISOString(), skills: _.map(techSkills, label => ({ label, selected: false })), durationConfirm: [{ label: 'Yes', value: false }, { label: 'No', value: false }], timezoneConfirm: [{ label: 'Yes', value: false }, { label: 'No', value: false }], agreedTerms: false, country: _.map(countries.getNames('en'), val => ({ label: val, selected: false })), + reffereal: [ + { label: 'Google', selected: false }, + { label: 'LinkedIn', selected: false }, + { label: 'Other Ad or Promotion', selected: false }, + { label: 'Quora', selected: false }, + { label: 'Uprisor Podcast', selected: false }, + { label: 'YouTube or Video Ad', selected: false }, + ], // eslint-disable-next-line react/destructuring-assignment }, }; @@ -37,12 +45,66 @@ class RecruitCRMJobApplyContainer extends React.Component { this.validateForm = this.validateForm.bind(this); } + // eslint-disable-next-line consistent-return componentDidMount() { const { formData } = this.state; - const { user } = this.props; - this.setState({ - formData: _.merge(formData, user), - }); + const { user, recruitProfile, searchCandidates } = this.props; + if (user) { + if (!recruitProfile) searchCandidates(user.email); + else { + const { country, skills } = formData; + const recruitSkills = recruitProfile.skill.split(',').map(s => s.toLowerCase()); + return this.setState({ + formData: _.merge(formData, user, { + phone: recruitProfile.contact_number, + country: _.map( + country, + c => ({ + label: c.label, + selected: c.label.toLowerCase() === recruitProfile.locality.toLowerCase(), + }), + ), + skills: skills.map(s => ({ + label: s.label, + selected: recruitSkills.includes(s.label.toLowerCase()), + })), + payExpectation: recruitProfile.salary_expectation, + }), + }); + } + this.setState({ + formData: _.merge(formData, user), + }); + } + } + + componentDidUpdate(prevProps) { + const { recruitProfile, user } = this.props; + if (recruitProfile !== prevProps.recruitProfile && !_.isEmpty(recruitProfile)) { + // when recruit profile loaded + const { formData } = this.state; + const { country, skills } = formData; + const recruitSkills = recruitProfile.skill.split(',').map(s => s.toLowerCase()); + const updatedForm = { + formData: _.merge(formData, user, { + phone: recruitProfile.contact_number, + country: _.map( + country, + c => ({ + label: c.label, + selected: c.label.toLowerCase() === recruitProfile.locality.toLowerCase(), + }), + ), + skills: skills.map(s => ({ + label: s.label, + selected: recruitSkills.includes(s.label.toLowerCase()), + })), + payExpectation: recruitProfile.salary_expectation, + }), + }; + // eslint-disable-next-line react/no-did-update-set-state + this.setState(updatedForm); + } } onFormInputChange(key, value) { @@ -71,9 +133,10 @@ class RecruitCRMJobApplyContainer extends React.Component { validateForm(prop) { this.setState((state) => { const { formData, formErrors } = state; + const { recruitProfile } = this.props; // Form validation happens here const requiredTextFields = [ - 'fname', 'lname', 'city', 'reffereal', 'phone', 'email', + 'fname', 'lname', 'city', 'phone', 'email', ]; // check required text fields for value // check min/max lengths @@ -85,10 +148,6 @@ class RecruitCRMJobApplyContainer extends React.Component { else if (formData[key] && _.trim(formData[key]).length < 2) formErrors[key] = 'Must be at least 2 characters'; else if (formData[key] && _.trim(formData[key]).length > 2) { switch (key) { - case 'reffereal': - if (_.trim(formData[key]).length > 2000) formErrors[key] = 'Must be max 2000 characters'; - else delete formErrors[key]; - break; case 'city': case 'phone': if (_.trim(formData[key]).length > 50) formErrors[key] = 'Must be max 50 characters'; @@ -106,12 +165,19 @@ class RecruitCRMJobApplyContainer extends React.Component { if (!_.find(formData.country, { selected: true })) formErrors.country = 'Please, select your country'; else delete formErrors.country; } + // check for selected reffereal + if (!prop || prop === 'reffereal') { + if (_.isEmpty(recruitProfile)) { + if (!_.find(formData.reffereal, { selected: true })) formErrors.reffereal = 'Please, select your reffereal'; + else delete formErrors.reffereal; + } + } // check payExpectation to be a number if (!prop || prop === 'payExpectation') { if (formData.payExpectation && _.trim(formData.payExpectation)) { if (!_.isInteger(_.toNumber(formData.payExpectation))) formErrors.payExpectation = 'Must be integer value in $'; else delete formErrors.payExpectation; - } else delete formErrors.payExpectation; + } else formErrors.payExpectation = 'Required field'; } // check for valid email if (!prop || prop === 'email') { @@ -134,8 +200,8 @@ class RecruitCRMJobApplyContainer extends React.Component { } // has CV file ready for upload if (!prop || prop === 'fileCV') { - if (!formData.fileCV) formErrors.fileCV = 'Please, pick your CV file for uploading'; - else { + if (!formData.fileCV && _.isEmpty(recruitProfile)) formErrors.fileCV = 'Please, pick your CV file for uploading'; + else if (formData.fileCV) { const sizeInMB = (formData.fileCV.size / (1024 * 1024)).toFixed(2); if (sizeInMB > 8) { formErrors.fileCV = 'Max file size is limited to 8 MB'; @@ -147,6 +213,24 @@ class RecruitCRMJobApplyContainer extends React.Component { } } } + // timezone + if (!prop || prop === 'timezoneConfirm') { + const a = _.find(formData[prop], { value: true }); + if (a) { + if (a.label === 'No') formErrors[prop] = 'Sorry, we are only looking for candidates that can work the hours and duration listed'; + else delete formErrors[prop]; + } else if (prop) formErrors[prop] = 'Required field'; + else if (!prop && !_.find(formData.timezoneConfirm, { value: true })) formErrors.timezoneConfirm = 'Required field'; + } + // duration + if (!prop || prop === 'durationConfirm') { + const a = _.find(formData[prop], { value: true }); + if (a) { + if (a.label === 'No') formErrors[prop] = 'Sorry, we are only looking for candidates that can work the hours and duration listed'; + else delete formErrors[prop]; + } else if (prop) formErrors[prop] = 'Required field'; + else if (!prop && !_.find(formData.durationConfirm, { value: true })) formErrors.durationConfirm = 'Required field'; + } // updated state return { ...state, @@ -157,7 +241,8 @@ class RecruitCRMJobApplyContainer extends React.Component { render() { const { formErrors, formData } = this.state; - return ( + const { recruitProfile, user } = this.props; + return !recruitProfile && user ? : ( { + dispatch(a.searchCandidatesInit(email)); + dispatch(a.searchCandidatesDone(email)); + }, }; } diff --git a/src/shared/reducers/recruitCRM.js b/src/shared/reducers/recruitCRM.js index 786caef018..0ff13090e3 100644 --- a/src/shared/reducers/recruitCRM.js +++ b/src/shared/reducers/recruitCRM.js @@ -84,6 +84,32 @@ function onApplyForJobDone(state, { payload }) { return r; } +/** + * Handles recruit.applyForJobInit action. + * @param {Object} state Previous state. + */ +function onSearchCandidatesInit(state, { payload }) { + const r = { + ...state, + }; + r[payload.email] = {}; + return r; +} + +/** + * Handles recruit.applyForJobDone action. + * @param {Object} state Previous state. + * @param {Object} action The action. + */ +function onSearchCandidatesDone(state, { payload }) { + const r = { + ...state, + }; + const profile = _.isArray(payload.data) ? {} : payload.data.data[0]; + r[payload.email].profile = profile; + return r; +} + /** * Creates recruitCRM reducer with the specified initial state. * @param {Object} state Optional. If not given, the default one is @@ -98,6 +124,8 @@ function create(state = {}) { [actions.recruit.getJobDone]: onJobDone, [actions.recruit.applyForJobInit]: onApplyForJobInit, [actions.recruit.applyForJobDone]: onApplyForJobDone, + [actions.recruit.searchCandidatesInit]: onSearchCandidatesInit, + [actions.recruit.searchCandidatesDone]: onSearchCandidatesDone, }, state); } diff --git a/src/shared/routes/index.jsx b/src/shared/routes/index.jsx index 53ead154ec..34782cdfc0 100644 --- a/src/shared/routes/index.jsx +++ b/src/shared/routes/index.jsx @@ -18,7 +18,7 @@ import PT from 'prop-types'; import { connect } from 'react-redux'; -import socialImage from 'assets/images/social.jpg'; +import socialImage from 'assets/images/social.png'; import Communities from './Communities'; import Examples from './Examples'; diff --git a/src/shared/services/recruitCRM.js b/src/shared/services/recruitCRM.js index e20cccb5f4..c71bae877b 100644 --- a/src/shared/services/recruitCRM.js +++ b/src/shared/services/recruitCRM.js @@ -67,4 +67,17 @@ export default class Service { } return res.json(); } + + /** + * Search for candidate + * @param {object} email The email to search with + */ + async searchCandidates(email) { + const res = await fetch(`${this.baseUrl}/candidates/search?email=${email}`); + if (!res.ok) { + const error = new Error('Failed to search for candidates'); + logger.error(error, res); + } + return res.json(); + } }