diff --git a/config/dev.js b/config/dev.js index dfe168fd..e3082f1d 100644 --- a/config/dev.js +++ b/config/dev.js @@ -13,4 +13,10 @@ module.exports = { V5: "https://api.topcoder-dev.com/v5", //"http://localhost:3030/api/v5" V3: "https://api.topcoder-dev.com/v3", }, + + /** + * The interview api url is the mock server url. + * This has to be replaced with the real url once the API is implemented. + */ + INTERVIEW_API_URL: "http://localhost:5555/", }; diff --git a/src/assets/images/icon-cross-black.svg b/src/assets/images/icon-cross-black.svg new file mode 100644 index 00000000..0c1ebca1 --- /dev/null +++ b/src/assets/images/icon-cross-black.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M15.6571 0.342857C15.2 -0.114286 14.5143 -0.114286 14.0571 0.342857L8 6.4L1.94286 0.342857C1.48571 -0.114286 0.8 -0.114286 0.342857 0.342857C-0.114286 0.8 -0.114286 1.48571 0.342857 1.94286L6.4 8L0.342857 14.0571C-0.114286 14.5143 -0.114286 15.2 0.342857 15.6571C0.571429 15.8857 0.8 16 1.14286 16C1.48571 16 1.71429 15.8857 1.94286 15.6571L8 9.6L14.0571 15.6571C14.2857 15.8857 14.6286 16 14.8571 16C15.0857 16 15.4286 15.8857 15.6571 15.6571C16.1143 15.2 16.1143 14.5143 15.6571 14.0571L9.6 8L15.6571 1.94286C16.1143 1.48571 16.1143 0.8 15.6571 0.342857Z" fill="#2A2A2A"/> +</svg> diff --git a/src/assets/images/icon-delete-slot.svg b/src/assets/images/icon-delete-slot.svg new file mode 100644 index 00000000..1680f2ff --- /dev/null +++ b/src/assets/images/icon-delete-slot.svg @@ -0,0 +1,4 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="8" cy="8" r="8" fill="#137D60"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M11.7661 4.23395C11.454 3.92202 10.9482 3.92202 10.6362 4.23395L8.005 6.86516L5.37379 4.23395C5.06026 3.93113 4.5619 3.93547 4.25369 4.24368C3.94547 4.55189 3.94114 5.05026 4.24396 5.36378L6.87517 7.995L4.24396 10.6262C4.03624 10.8268 3.95294 11.1239 4.02606 11.4033C4.09919 11.6826 4.31736 11.9008 4.59672 11.9739C4.87609 12.0471 5.17317 11.9638 5.37379 11.756L8.005 9.12483L10.6362 11.756C10.9497 12.0589 11.4481 12.0545 11.7563 11.7463C12.0645 11.4381 12.0689 10.9397 11.7661 10.6262L9.13484 7.995L11.7661 5.36378C12.078 5.05176 12.078 4.54597 11.7661 4.23395Z" fill="white"/> +</svg> diff --git a/src/assets/images/icon-down-arrow-black.svg b/src/assets/images/icon-down-arrow-black.svg new file mode 100644 index 00000000..27857c4b --- /dev/null +++ b/src/assets/images/icon-down-arrow-black.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M8.00001 11.9654L1.33334 5.2987L2.54546 4L8.00001 9.45455L13.4546 4L14.6667 5.2987L8.00001 11.9654Z" fill="#7F7F7F"/> +</svg> diff --git a/src/assets/images/icon-google.svg b/src/assets/images/icon-google.svg new file mode 100644 index 00000000..a1a5cef3 --- /dev/null +++ b/src/assets/images/icon-google.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M11.0934 4.28446C10.5156 3.7422 9.50224 3.09331 8.00004 3.09331C5.8845 3.09331 4.08896 4.48889 3.44001 6.41777L3.43997 6.41774L3.43997 6.41777C3.27997 6.91554 3.18223 7.44886 3.18223 8C3.18223 8.55109 3.27997 9.08446 3.44885 9.58223L3.44889 9.58223C4.08896 11.5111 5.8845 12.9066 8.00004 12.9066C9.1911 12.9066 10.0978 12.5778 10.7733 12.1067L10.7733 12.1066C11.84 11.36 12.32 10.2489 12.4089 9.51108H8V6.54223H15.5111C15.6266 7.04 15.68 7.52 15.68 8.17777C15.68 10.6133 14.8089 12.6666 13.2978 14.0622L13.2978 14.0622C11.9733 15.2889 10.16 16 8.00004 16C4.87112 16 2.16889 14.2044 0.853353 11.5911L0.853313 11.5911C0.311115 10.5066 0 9.28889 0 8C0 6.71112 0.311115 5.49331 0.853313 4.40886L0.853364 4.40883C2.16891 1.79553 4.87113 0 8.00004 0C10.16 0 11.9645 0.791114 13.3511 2.08L11.0934 4.28446Z" fill="white"/> +</svg> diff --git a/src/assets/images/icon-interview-type.svg b/src/assets/images/icon-interview-type.svg new file mode 100644 index 00000000..1b596892 --- /dev/null +++ b/src/assets/images/icon-interview-type.svg @@ -0,0 +1,8 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0145 2C13.7026 2.00003 11.4268 2.57256 9.39015 3.66647C7.3535 4.76038 5.61945 6.34162 4.34282 8.269C3.0662 10.1964 2.28673 12.4099 2.07403 14.7119C1.86133 17.014 2.22201 19.3328 3.12387 21.4615C4.02573 23.5901 5.4407 25.4623 7.24242 26.9109C9.04415 28.3595 11.1766 29.3393 13.4492 29.7629C15.7219 30.1866 18.0641 30.0408 20.2668 29.3386C22.4694 28.6365 24.4639 27.3998 26.0721 25.739C26.4563 25.3423 27.0893 25.3321 27.4861 25.7163C27.8828 26.1005 27.893 26.7336 27.5088 27.1303C25.6709 29.0284 23.3915 30.4417 20.8742 31.2442C18.3569 32.0466 15.6801 32.2132 13.0828 31.7291C10.4854 31.2449 8.04836 30.1251 5.98925 28.4696C3.93013 26.8141 2.31303 24.6745 1.28233 22.2417C0.251638 19.8089 -0.16057 17.1588 0.0825162 14.5279C0.325603 11.897 1.21642 9.36729 2.67542 7.16457C4.13442 4.96185 6.11619 3.15472 8.44379 1.90454C10.7714 0.654358 13.3723 3.63192e-05 16.0144 0C16.5667 -7.6293e-06 17.0144 0.447702 17.0144 0.999986C17.0145 1.55227 16.5667 1.99999 16.0145 2Z" fill="#1E94A3"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0146 9.66675C16.5669 9.66675 17.0146 10.1145 17.0146 10.6667V16.2243L23.3246 21.9601C23.7333 22.3316 23.7634 22.964 23.392 23.3727C23.0205 23.7814 22.388 23.8115 21.9794 23.4401L15.342 17.4067C15.1335 17.2172 15.0146 16.9485 15.0146 16.6667V10.6667C15.0146 10.1145 15.4624 9.66675 16.0146 9.66675Z" fill="#1E94A3"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M19.1715 1.29872C19.3231 0.767654 19.8765 0.460056 20.4076 0.611679C21.322 0.872753 22.2113 1.21503 23.0648 1.63443C23.5604 1.878 23.7648 2.47728 23.5212 2.97295C23.2777 3.46862 22.6784 3.67299 22.1827 3.42942C21.4362 3.06258 20.6584 2.76319 19.8585 2.53483C19.3275 2.38321 19.0199 1.82978 19.1715 1.29872Z" fill="#1E94A3"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M26.1039 4.85C26.5005 4.46565 27.1336 4.47559 27.5179 4.87219C28.1798 5.55513 28.7789 6.29616 29.308 7.08636C29.6153 7.54525 29.4925 8.16638 29.0336 8.47368C28.5747 8.78099 27.9535 8.6581 27.6462 8.19921C27.1838 7.50858 26.6601 6.86092 26.0817 6.26404C25.6973 5.86744 25.7073 5.23435 26.1039 4.85Z" fill="#1E94A3"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M30.3273 11.3875C30.863 11.2531 31.4061 11.5784 31.5405 12.1141C31.7719 13.0367 31.92 13.9781 31.983 14.9272C32.0196 15.4782 31.6025 15.9546 31.0515 15.9912C30.5004 16.0278 30.024 15.6107 29.9874 15.0597C29.9323 14.2301 29.8029 13.4071 29.6006 12.6007C29.4662 12.065 29.7916 11.5218 30.3273 11.3875Z" fill="#1E94A3"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M30.7153 19.1608C31.2465 19.3122 31.5544 19.8654 31.403 20.3966C31.1424 21.3112 30.8 22.2006 30.3797 23.0538C30.1357 23.5492 29.5362 23.753 29.0408 23.509C28.5453 23.265 28.3415 22.6655 28.5856 22.1701C28.9527 21.4247 29.2519 20.6477 29.4796 19.8486C29.6309 19.3174 30.1842 19.0095 30.7153 19.1608Z" fill="#1E94A3"/> +</svg> diff --git a/src/assets/images/icon-microsoft.svg b/src/assets/images/icon-microsoft.svg new file mode 100644 index 00000000..3ae47f4f --- /dev/null +++ b/src/assets/images/icon-microsoft.svg @@ -0,0 +1,3 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M0.839966 7.60256H8.44374V0H0.839966V7.60256ZM9.23555 7.60256H16.8393V0H9.23555V7.60256ZM8.44374 16H0.839966V8.39744H8.44374V16ZM9.23555 16H16.8393V8.39744H9.23555V16Z" fill="white"/> +</svg> diff --git a/src/assets/images/icon-plus.svg b/src/assets/images/icon-plus.svg new file mode 100644 index 00000000..7bf4798d --- /dev/null +++ b/src/assets/images/icon-plus.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M8.66668 4.66659H7.33334V7.33325H4.66668V8.66659H7.33334V11.3333H8.66668V8.66659H11.3333V7.33325H8.66668V4.66659ZM8.00001 1.33325C4.32001 1.33325 1.33334 4.31992 1.33334 7.99992C1.33334 11.6799 4.32001 14.6666 8.00001 14.6666C11.68 14.6666 14.6667 11.6799 14.6667 7.99992C14.6667 4.31992 11.68 1.33325 8.00001 1.33325ZM8.00001 13.3333C5.06001 13.3333 2.66668 10.9399 2.66668 7.99992C2.66668 5.05992 5.06001 2.66659 8.00001 2.66659C10.94 2.66659 13.3333 5.05992 13.3333 7.99992C13.3333 10.9399 10.94 13.3333 8.00001 13.3333Z" fill="#137D60"/> +</svg> diff --git a/src/components/BaseModal/index.jsx b/src/components/BaseModal/index.jsx index 88f31ce5..95179a7a 100644 --- a/src/components/BaseModal/index.jsx +++ b/src/components/BaseModal/index.jsx @@ -10,6 +10,7 @@ import React from "react"; import PT from "prop-types"; +import cn from "classnames"; import { Modal } from "react-responsive-modal"; import Button from "../Button"; import IconCross from "../../assets/images/icon-cross-light.svg"; @@ -17,7 +18,7 @@ import "./styles.module.scss"; const modalStyle = { borderRadius: "8px", - padding: "32px 32px 22px 32px", + padding: "20px 24px", maxWidth: "640px", width: "100%", margin: 0, @@ -28,6 +29,11 @@ const containerStyle = { padding: "10px", }; +const closeButton = { + right: "24px", + top: "24px", +}; + function BaseModal({ open, onClose, @@ -37,31 +43,47 @@ function BaseModal({ closeButtonText, disabled, extraModalStyle, + alignTitleCenter = false, + showButton = true, + closeIcon: CloseIcon, }) { return ( <Modal open={open} onClose={onClose} - closeIcon={<IconCross width="15px" height="15px" />} + closeIcon={ + CloseIcon ? CloseIcon : <IconCross width="15px" height="15px" /> + } styles={{ modal: { ...modalStyle, ...extraModalStyle }, modalContainer: containerStyle, + closeButton, }} center={true} > - {title && <h2 styleName="title">{title}</h2>} - <div styleName="content">{children}</div> - <div styleName="button-group"> - {button && button} - <Button - type="secondary" - size="medium" - onClick={onClose} - disabled={disabled} + {title && ( + <h2 + styleName={cn("title", { + "center-title": alignTitleCenter, + })} > - {closeButtonText ? closeButtonText : "Cancel"} - </Button> - </div> + {title} + </h2> + )} + <div styleName="content">{children}</div> + {showButton && ( + <div styleName="button-group"> + {button && button} + <Button + type="secondary" + size="medium" + onClick={onClose} + disabled={disabled} + > + {closeButtonText ? closeButtonText : "Cancel"} + </Button> + </div> + )} </Modal> ); } @@ -75,6 +97,9 @@ BaseModal.propTypes = { closeButtonText: PT.string, disabled: PT.bool, extraModalStyle: PT.object, + showButton: PT.bool, + alignTitleCenter: PT.bool, + closeIcon: PT.node, }; export default BaseModal; diff --git a/src/components/BaseModal/styles.module.scss b/src/components/BaseModal/styles.module.scss index 418bd607..8c111175 100644 --- a/src/components/BaseModal/styles.module.scss +++ b/src/components/BaseModal/styles.module.scss @@ -5,14 +5,24 @@ svg { } .title { - @include font-barlow-condensed; + @include font-barlow; font-weight: normal; - font-size: 34px; - line-height: 40px; + font-weight: 600; + font-size: 24px; + line-height: 24px; margin: 0 0 24px 0; overflow-wrap: anywhere; padding: 0; text-transform: uppercase; + border-bottom: 2px solid #e9e9e9; + padding-bottom: 20px; + color: #2a2a2a; +} + +.center-title { + text-align: center; + border-bottom-color: transparent; + margin-bottom: 0; } .content { diff --git a/src/components/Button/styles.module.scss b/src/components/Button/styles.module.scss index b77a2846..e0ba60fa 100644 --- a/src/components/Button/styles.module.scss +++ b/src/components/Button/styles.module.scss @@ -52,6 +52,17 @@ text-transform: uppercase; } +.size-xSmall { + border-radius: 15px; + font-size: 10px; + font-weight: 700; + line-height: 24px; + letter-spacing: 0.8px; + height: 24px; + padding: 0 11px; + text-transform: uppercase; +} + .type-primary { border: 1px solid #137d60; background-color: #137d60; diff --git a/src/components/Select/index.jsx b/src/components/Select/index.jsx index a7b5b88f..55ac1dd3 100644 --- a/src/components/Select/index.jsx +++ b/src/components/Select/index.jsx @@ -1,4 +1,5 @@ import React, { useCallback } from "react"; +import cn from "classnames"; import PT from "prop-types"; import "./styles.module.scss"; @@ -13,9 +14,19 @@ const Select = ({ options, value, onChange, label, className }) => { return ( <div styleName="select-wrapper" className={className}> {!!label && <label styleName="select-label">{label}</label>} - <select value={value} onChange={onChangeHandler} styleName="select"> + <select + value={value} + onChange={onChangeHandler} + styleName={cn("select", { + "empty-value": value === "", + })} + > {options.map((option) => ( - <option key={option.value} value={option.value}> + <option + key={option.value} + value={option.value} + disabled={option.disabled} + > {option.label} </option> ))} @@ -29,6 +40,7 @@ Select.propTypes = { PT.shape({ label: PT.string.isRequired, value: PT.oneOfType([PT.string, PT.number]), + disabled: PT.bool, }) ), value: PT.oneOfType([PT.string, PT.number]), diff --git a/src/components/Select/styles.module.scss b/src/components/Select/styles.module.scss index 5a1e0c5e..77c5f023 100644 --- a/src/components/Select/styles.module.scss +++ b/src/components/Select/styles.module.scss @@ -9,13 +9,19 @@ border: 0; color: #2a2a2a; outline: none; - font-size: 14px; + font-size: 16px; height: 100%; line-height: 22px; padding-left: 15px; width: 100%; } +.empty-value { + color: #aaaaaa; + font-weight: 400; + font-size: 16px; +} + .select-wrapper { align-items: center; border: 1px solid #aaaaaa; diff --git a/src/components/TimezoneSelector/index.jsx b/src/components/TimezoneSelector/index.jsx new file mode 100644 index 00000000..beac4612 --- /dev/null +++ b/src/components/TimezoneSelector/index.jsx @@ -0,0 +1,28 @@ +import React from "react"; +import PT from "prop-types"; +import { TIME_ZONES } from "constants"; +import moment from "moment"; +import "./styles.module.scss"; + +const TimezoneSelector = ({ value, onChange }) => { + return ( + <div styleName="timezone-selector"> + <div styleName="label">Interview Timezone</div> + <select styleName="select" onChange={onChange}> + {TIME_ZONES.map((zone) => ( + <option + value={zone} + selected={zone === value} + >{`${zone} - UTC${moment().tz(zone).format("Z")}`}</option> + ))} + </select> + </div> + ); +}; + +TimezoneSelector.propTypes = { + value: PT.string, + onChange: PT.func, +}; + +export default TimezoneSelector; diff --git a/src/components/TimezoneSelector/styles.module.scss b/src/components/TimezoneSelector/styles.module.scss new file mode 100644 index 00000000..a543c687 --- /dev/null +++ b/src/components/TimezoneSelector/styles.module.scss @@ -0,0 +1,71 @@ +@import "styles/include"; + +.select { + /* Reset Select */ + appearance: none; + outline: 0; + border: 0; + box-shadow: none; + /* Personalize */ + @include font-roboto; + flex: 1; + color: #2a2a2a; + margin-left: 10px; + font-size: 16px; + font-weight: 400; + background-image: none; + cursor: pointer; + margin-bottom: 7px; +} + +/* Remove IE arrow */ +.select::-ms-expand { + display: none; +} + +/* Custom Select wrapper */ +.timezone-selector { + position: relative; + display: flex; + width: 384px; + height: 48px; + overflow: hidden; + margin-top: 59px; + flex-direction: column; + border: 1px solid #aaaaaa; + border-radius: 4px; +} + +.label { + @include font-roboto; + color: #137d60; + font-weight: 500; + font-size: 11px; + margin-left: 10px; +} + +/* Arrow */ +.timezone-selector::after { + content: ""; + position: absolute; + background-image: url(../../assets/images/icon-down-arrow-black.svg); + background-size: 16px; + background-repeat: no-repeat; + bottom: 10px; + right: 10px; + width: 16px; + height: 16px; + transition: 0.25s all ease; + pointer-events: none; +} + +/* Transition */ +.timezone-selector:hover::after { + color: #f39c12; +} + +@media (max-width: 420px) { + .timezone-selector { + width: 100%; + } +} diff --git a/src/constants/index.js b/src/constants/index.js index ab58e3cf..ff52aacf 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -66,6 +66,7 @@ export const BUTTON_SIZE = { SMALL: "small", MEDIUM: "medium", LARGE: "large", + EXTRA_SMALL: "xSmall", }; /** @@ -408,3 +409,283 @@ export const MAX_SELECTED_SKILLS = 3; * There are 3 stages, so total search takes 3x this number */ export const SEARCH_STAGE_TIME = 1500; + +/** + * These are the stages of the interview scheduling popup + */ +export const POPUP_STAGES = { + SCHEDULE_INTERVIEW: "scheduleInterview", + CONNECT_CALENDAR: "connectCalendar", + MANAGE_CALENDAR: "manageCalendar", + MANAGE_AVAILABILITY: "manageAvailability", + SELECT_DURATION: "selectDuration", + SEND_INTERVIEW_INVITE: "sendInterviewInvite", + SUCCESS: "sucess", + CLOSE: "close", +}; + +/** + * These are the states supported by the schedule interview pop up supports + */ +export const INTERVIEW_POPUP_STAGES = [ + { + id: POPUP_STAGES.SCHEDULE_INTERVIEW, + title: "Schedule Interview", + }, + { + id: POPUP_STAGES.CONNECT_CALENDAR, + title: "Connect your calendar", + }, + { + id: POPUP_STAGES.MANAGE_CALENDAR, + title: "Manage Connected calendar", + }, + { + id: POPUP_STAGES.MANAGE_AVAILABILITY, + title: "Manage Availability", + isCenterAligned: true, + }, + { + id: POPUP_STAGES.SELECT_DURATION, + title: "Select Duration", + isCenterAligned: true, + }, + { + id: POPUP_STAGES.SEND_INTERVIEW_INVITE, + title: "Send Interview Invite", + isCenterAligned: true, + }, + { + id: POPUP_STAGES.SUCCESS, + title: "Success! Interview invite sent", + }, +]; + +/** + * These are the list of steps in the schedule interview phase + */ +export const SCHEDULE_INTERVIEW_STEPS = [ + { + id: "availability", + label: "Availability", + }, + { + id: "selectType", + label: "Select Type", + }, + { + id: "confirm", + label: "Confirm", + }, +]; + +/** + * The list of time zones which lists in interview scheduler popup + */ +export const TIME_ZONES = [ + "America/Los_Angeles", + "America/Chicago", + "America/Denver", + "America/New_York", + "Pacific/Midway", + "Pacific/Niue", + "Pacific/Pago_Pago", + "Pacific/Samoa", + "Pacific/Honolulu", + "Pacific/Tahiti", + "Pacific/Marquesas", + "America/Adak", + "Pacific/Gambier", + "America/Anchorage", + "America/Metlakatla", + "Pacific/Pitcairn", + "America/Phoenix", + "America/Tijuana", + "America/Whitehorse", + "Etc/GMT+7", + "America/Chihuahua", + "America/Managua", + "America/Mazatlan", + "Canada/Saskatchewan", + "Pacific/Easter", + "Pacific/Galapagos", + "America/Bogota", + "America/Lima", + "America/Mexico_City", + "America/Monterrey", + "America/Panama", + "America/Asuncion", + "America/Campo_Grande", + "America/Caracas", + "America/Grand_Turk", + "America/Havana", + "America/La_Paz", + "America/Port-au-Prince", + "America/Santiago", + "America/Santo_Domingo", + "America/Buenos_Aires", + "America/Fortaleza", + "America/Halifax", + "America/Sao_Paulo", + "Antarctica/Palmer", + "Canada/Atlantic", + "America/St_Johns", + "Canada/Newfoundland", + "America/Godthab", + "America/Miquelon", + "America/Noronha", + "Atlantic/Cape_Verde", + "Africa/Abidjan", + "Africa/Monrovia", + "Africa/Sao_Tome", + "Atlantic/Azores", + "UTC", + "Africa/Algiers", + "Africa/Casablanca", + "Africa/Lagos", + "Etc/GMT-1", + "Europe/Dublin", + "Europe/Lisbon", + "Europe/London", + "Africa/Cairo", + "Africa/Harare", + "Africa/Johannesburg", + "Africa/Juba", + "Africa/Khartoum", + "Africa/Maputo", + "Africa/Windhoek", + "Antarctica/Troll", + "Etc/GMT-2", + "Europe/Amsterdam", + "Europe/Belgrade", + "Europe/Berlin", + "Europe/Bratislava", + "Europe/Brussels", + "Europe/Budapest", + "Europe/Copenhagen", + "Europe/Ljubljana", + "Europe/Madrid", + "Europe/Paris", + "Europe/Prague", + "Europe/Rome", + "Europe/Sarajevo", + "Europe/Skopje", + "Europe/Stockholm", + "Europe/Vienna", + "Europe/Warsaw", + "Europe/Zagreb", + "MET", + "Africa/Nairobi", + "Asia/Amman", + "Asia/Baghdad", + "Asia/Beirut", + "Asia/Damascus", + "Asia/Famagusta", + "Asia/Gaza", + "Asia/Jerusalem", + "Asia/Kuwait", + "Asia/Riyadh", + "Europe/Athens", + "Europe/Bucharest", + "Europe/Chisinau", + "Europe/Helsinki", + "Europe/Istanbul", + "Europe/Minsk", + "Europe/Moscow", + "Europe/Riga", + "Europe/Sofia", + "Europe/Tallinn", + "Europe/Vilnius", + "Europe/Volgograd", + "Asia/Baku", + "Asia/Dubai", + "Asia/Muscat", + "Asia/Tbilisi", + "Asia/Yerevan", + "Europe/Saratov", + "Europe/Ulyanovsk", + "Asia/Kabul", + "Asia/Tehran", + "Asia/Karachi", + "Asia/Qyzylorda", + "Asia/Tashkent", + "Asia/Yekaterinburg", + "Asia/Calcutta", + "Asia/Colombo", + "Asia/Kolkata", + "Asia/Kathmandu", + "Asia/Katmandu", + "Asia/Almaty", + "Asia/Dhaka", + "Asia/Urumqi", + "Asia/Rangoon", + "Asia/Bangkok", + "Asia/Barnaul", + "Asia/Hovd", + "Asia/Jakarta", + "Asia/Krasnoyarsk", + "Asia/Novosibirsk", + "Asia/Tomsk", + "Asia/Chongqing", + "Asia/Hong_Kong", + "Asia/Irkutsk", + "Asia/Kuala_Lumpur", + "Asia/Makassar", + "Asia/Manila", + "Asia/Shanghai", + "Asia/Singapore", + "Asia/Taipei", + "Asia/Ulaanbaatar", + "Asia/Ulan_Bator", + "Australia/Perth", + "Australia/Eucla", + "Asia/Chita", + "Asia/Jayapura", + "Asia/Pyongyang", + "Asia/Seoul", + "Asia/Tokyo", + "Asia/Yakutsk", + "Australia/Adelaide", + "Australia/Darwin", + "Asia/Vladivostok", + "Australia/Brisbane", + "Australia/Canberra", + "Australia/Hobart", + "Australia/Melbourne", + "Australia/Sydney", + "Pacific/Guam", + "Pacific/Port_Moresby", + "Australia/Lord_Howe", + "Antarctica/Casey", + "Asia/Magadan", + "Asia/Sakhalin", + "Pacific/Bougainville", + "Pacific/Norfolk", + "Asia/Kamchatka", + "Pacific/Auckland", + "Pacific/Fiji", + "Pacific/Kwajalein", + "Pacific/Chatham", + "Pacific/Apia", + "Pacific/Fakaofo", + "Pacific/Tongatapu", + "Pacific/Kiritimati", +]; + +/** + * Days mapping + */ +export const DAYS = { + U: "S", + M: "M", + T: "T", + W: "W", + R: "T", + F: "F", + S: "S", +}; + +/** + * These are the default selected days + */ +export const DEFAULT_SELECTED_DAYS = ["M", "T", "W", "R", "F"]; diff --git a/src/root.component.jsx b/src/root.component.jsx index 3267c309..9c892346 100644 --- a/src/root.component.jsx +++ b/src/root.component.jsx @@ -1,7 +1,10 @@ import React, { useLayoutEffect } from "react"; import { Provider } from "react-redux"; import { Router, Redirect } from "@reach/router"; -import { setNotificationPlatform, PLATFORM } from "@topcoder/micro-frontends-navbar-app"; +import { + setNotificationPlatform, + PLATFORM, +} from "@topcoder/micro-frontends-navbar-app"; import MyTeamsList from "./routes/MyTeamsList"; import MyTeamsDetails from "./routes/MyTeamsDetails"; import PositionDetails from "./routes/PositionDetails"; diff --git a/src/routes/CreateNewTeam/components/NoMatchingProfilesResultCard/styles.module.scss b/src/routes/CreateNewTeam/components/NoMatchingProfilesResultCard/styles.module.scss index 008b04f7..e84a1ebb 100644 --- a/src/routes/CreateNewTeam/components/NoMatchingProfilesResultCard/styles.module.scss +++ b/src/routes/CreateNewTeam/components/NoMatchingProfilesResultCard/styles.module.scss @@ -15,7 +15,7 @@ padding: 30px 0 60px 0; margin-bottom: 14px; color: #fff; - background-image: linear-gradient(90deg, #F45500 0%, #FF940F 100%); + background-image: linear-gradient(90deg, #f45500 0%, #ff940f 100%); position: relative; text-align: center; border-radius: 8px 8px 0 0; diff --git a/src/routes/CreateNewTeam/components/ResultCard/index.jsx b/src/routes/CreateNewTeam/components/ResultCard/index.jsx index 8957bd6d..1827f8bd 100644 --- a/src/routes/CreateNewTeam/components/ResultCard/index.jsx +++ b/src/routes/CreateNewTeam/components/ResultCard/index.jsx @@ -245,9 +245,7 @@ function ResultCard({ </div> </div> <div styleName="timeline-info"> - <div> - (NEED TO DEVELOP TEXT W/ ANNIKA) - </div> + <div>(NEED TO DEVELOP TEXT W/ ANNIKA)</div> </div> </div> </div> diff --git a/src/routes/CreateNewTeam/components/SearchContainer/index.jsx b/src/routes/CreateNewTeam/components/SearchContainer/index.jsx index bd481eb3..ff98f6b1 100644 --- a/src/routes/CreateNewTeam/components/SearchContainer/index.jsx +++ b/src/routes/CreateNewTeam/components/SearchContainer/index.jsx @@ -77,7 +77,9 @@ function SearchContainer({ currentRole={currentRole} /> ); - return <NoMatchingProfilesResultCard role={matchingRole} onSubmit={onSubmit}/>; + return ( + <NoMatchingProfilesResultCard role={matchingRole} onSubmit={onSubmit} /> + ); }; const progressBarPercentage = useMemo( diff --git a/src/routes/CreateNewTeam/components/SubmitContainer/index.jsx b/src/routes/CreateNewTeam/components/SubmitContainer/index.jsx index 3c18064e..5dbe245e 100644 --- a/src/routes/CreateNewTeam/components/SubmitContainer/index.jsx +++ b/src/routes/CreateNewTeam/components/SubmitContainer/index.jsx @@ -176,7 +176,10 @@ function SubmitContainer({ currentRole={currentRole} /> ) : ( - <NoMatchingProfilesResultCard role={matchingRole} onSubmit={()=> setAddAnotherOpen(true)}/> + <NoMatchingProfilesResultCard + role={matchingRole} + onSubmit={() => setAddAnotherOpen(true)} + /> )} <div styleName="right-side"> <AddedRolesAccordion addedRoles={addedRoles} /> diff --git a/src/routes/CreateNewTeam/index.jsx b/src/routes/CreateNewTeam/index.jsx index fd30406c..792b3234 100644 --- a/src/routes/CreateNewTeam/index.jsx +++ b/src/routes/CreateNewTeam/index.jsx @@ -36,7 +36,6 @@ const CreateNewTeam = (props) => { }; }, []); const { isLoading } = useSelector((state) => state.searchedRoles); - return ( <div> {props.children} diff --git a/src/routes/CreateNewTeam/pages/CreateTaasPayment/styles.module.scss b/src/routes/CreateNewTeam/pages/CreateTaasPayment/styles.module.scss index 14ee4892..0fcc2ce3 100644 --- a/src/routes/CreateNewTeam/pages/CreateTaasPayment/styles.module.scss +++ b/src/routes/CreateNewTeam/pages/CreateTaasPayment/styles.module.scss @@ -169,7 +169,7 @@ } .trusted { - background-color: #FFF; + background-color: #fff; border-radius: 8px; padding: 12px 10px 15px 10px; width: 250px; @@ -178,7 +178,7 @@ @include font-barlow; font-weight: 600; text-align: center; - color: #9D41C9; + color: #9d41c9; font-size: 16px; margin-bottom: 10px; text-transform: uppercase; diff --git a/src/routes/JobForm/index.jsx b/src/routes/JobForm/index.jsx index 00c00182..ecd2ef92 100644 --- a/src/routes/JobForm/index.jsx +++ b/src/routes/JobForm/index.jsx @@ -8,7 +8,7 @@ import React, { useState, useEffect } from "react"; import PT from "prop-types"; import { toastr } from "react-redux-toastr"; -import moment from 'moment'; +import moment from "moment"; import _ from "lodash"; import store from "../../store"; import Page from "components/Page"; @@ -35,7 +35,7 @@ const JobForm = ({ teamId, jobId }) => { const onSubmit = async (values) => { if (values.startDate) { - values.startDate = moment(values.startDate).format('YYYY-MM-DD') + values.startDate = moment(values.startDate).format("YYYY-MM-DD"); } if (isEdit) { await updateJob(values, jobId).then( diff --git a/src/routes/PositionDetails/components/InterviewDetailsPopup/index.jsx b/src/routes/PositionDetails/components/InterviewDetailsPopup/index.jsx deleted file mode 100644 index 63afb146..00000000 --- a/src/routes/PositionDetails/components/InterviewDetailsPopup/index.jsx +++ /dev/null @@ -1,244 +0,0 @@ -/** - * InterviewDetailsPopup - * - * Popup that allows user to schedule an interview - * Calls addInterview action - */ -import React, { useCallback, useEffect, useState } from "react"; -import { getAuthUserProfile } from "@topcoder/micro-frontends-navbar-app"; -import { Form } from "react-final-form"; -import arrayMutators from "final-form-arrays"; -import { FieldArray } from "react-final-form-arrays"; -import { toastr } from "react-redux-toastr"; -import { useDispatch, useSelector } from "react-redux"; -import { addInterview } from "../../actions"; -import User from "components/User"; -import BaseModal from "components/BaseModal"; -import FormField from "components/FormField"; -import Button from "components/Button"; -import { FORM_FIELD_TYPE, MAX_ALLOWED_INTERVIEWS } from "constants"; -import "./styles.module.scss"; -import RadioFieldGroup from "components/RadioFieldGroup"; - -/* Validators for Form */ - -const validateIsEmail = (value) => { - if (!value) return undefined; - return /\S+@\S+\.\S+/.test(value) ? undefined : "Please enter valid email"; -}; - -const validator = (values) => { - const errors = {}; - - errors.emails = []; - if (values.emails) { - for (const email of values.emails) { - errors.emails.push(validateIsEmail(email)); - } - } - - return errors; -}; - -/********************* */ -// TODO: preserve form input in case of error -function InterviewDetailsPopup({ open, onClose, candidate, openNext }) { - const [isLoading, setIsLoading] = useState(true); - const [myEmail, setMyEmail] = useState(""); - const { loading } = useSelector((state) => state.positionDetails); - const dispatch = useDispatch(); - - useEffect(() => { - getAuthUserProfile().then((res) => { - setMyEmail(res.email || ""); - setIsLoading(false); - }); - }, []); - - const onSubmitCallback = useCallback( - async (formData) => { - const hostEmail = formData.emails[0]; - const guestEmails = - formData.emails - .slice(1) - .filter((email) => typeof email === "string" && email.length > 0) || - []; - const interviewData = { - templateUrl: formData.time, - hostEmail, - guestEmails, - }; - - try { - await dispatch(addInterview(candidate.id, interviewData)); - } catch (err) { - toastr.error("Interview Creation Failed", err.message); - throw err; - } - }, - [dispatch, candidate] - ); - - // show the warning if exceeds MAX_ALLOWED_INTERVIEW - if ( - candidate && - candidate.interviews && - candidate.interviews.length >= MAX_ALLOWED_INTERVIEWS - ) { - return ( - <BaseModal - open={open} - onClose={onClose} - closeButtonText="Close" - title="Schedule an Interview" - > - <p styleName="exceeds-max-number-txt"> - You've reached the cap of {MAX_ALLOWED_INTERVIEWS} interviews with - this candidate. Now please make your decision to Select and Decline - them. - </p> - </BaseModal> - ); - } - - return isLoading ? null : ( - <Form - initialValues={{ - time: "interview-30", - emails: [myEmail], - }} - onSubmit={onSubmitCallback} - mutators={{ - ...arrayMutators, - }} - validate={validator} - > - {({ - handleSubmit, - form: { - mutators: { push }, - reset, - }, - submitting, - hasValidationErrors, - }) => ( - <BaseModal - open={open} - onClose={() => { - reset(); - onClose(); - }} - title="Schedule an Interview" - button={ - <Button - onClick={() => { - handleSubmit().then(() => { - reset(); - onClose(); - openNext(); - }); - }} - size="medium" - isSubmit - disabled={submitting || hasValidationErrors || loading} - > - Begin scheduling - </Button> - } - disabled={submitting} - > - <div> - <div styleName="top"> - <div styleName="user"> - {candidate === null ? ( - "" - ) : ( - <User - user={{ - ...candidate, - photoUrl: candidate.photo_url, - }} - hideFullName - /> - )} - <p styleName="max-warning-txt"> - You may have as many as {MAX_ALLOWED_INTERVIEWS} interviews - with each candidate for the job. - </p> - </div> - <RadioFieldGroup - name="time" - isHorizontal - radios={[ - { - label: "30 Minute Interview", - value: "interview-30", - }, - { - label: "60 Minute Interview", - value: "interview-60", - }, - ]} - /> - </div> - <div styleName="center"> - <h4 styleName="center-header">Attendees:</h4> - <p styleName="modal-text"> - Please provide email addresses for all parties you would like - involved with the interview. - </p> - <FieldArray name="emails"> - {({ fields }) => { - return fields.map((name, index) => ( - <div styleName="array-item"> - <div styleName="array-input"> - <FormField - key={name} - field={{ - name, - type: FORM_FIELD_TYPE.TEXT, - placeholder: "Email Address", - label: "Email Address", - maxLength: 320, - customValidator: true, - disabled: index === 0, - }} - /> - </div> - {index > 0 && ( - <span - tabIndex={0} - title="Remove" - role="button" - onClick={() => fields.remove(index)} - styleName="remove-item" - > - × - </span> - )} - </div> - )); - }} - </FieldArray> - <button - styleName="add-more modal-text" - onClick={() => push("emails")} - > - Add more - </button> - </div> - <div styleName="bottom"> - <p styleName="modal-text"> - Selecting “Begin Scheduling” will initiate emails to all - attendees to coordinate availability. Please check your email to - input your availability. - </p> - </div> - </div> - </BaseModal> - )} - </Form> - ); -} - -export default InterviewDetailsPopup; diff --git a/src/routes/PositionDetails/components/InterviewDetailsPopup/styles.module.scss b/src/routes/PositionDetails/components/InterviewDetailsPopup/styles.module.scss deleted file mode 100644 index 952ec3fe..00000000 --- a/src/routes/PositionDetails/components/InterviewDetailsPopup/styles.module.scss +++ /dev/null @@ -1,89 +0,0 @@ -@import "styles/include"; - -.exceeds-max-number-txt { - padding: 15px; - letter-spacing: 0.5px; -} - -.user { - font-size: 14px; - color: #0d61bf; - max-width: 37%; - .max-warning-txt { - padding-top: 5px; - padding-left: 5px; - font-size: 11px; - color: gray; - } -} - -.top { - width: 100%; - padding-bottom: 25px; - border-bottom: 1px solid #e9e9e9; - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; -} - -.center { - padding: 25px 0; - border-bottom: 1px solid #e9e9e9; -} - -.center-header { - @include font-barlow; - font-weight: 600; - font-size: 20px; - margin: 0 0 10px 0; - padding: 0; - text-transform: uppercase; -} - -.modal-text { - @include font-roboto; - font-size: 14px; -} - -.bottom { - padding-top: 25px; - padding-bottom: 8px; -} - -.add-more { - outline: none; - background: #fff; - margin: 10px 0 0 0; - padding: 0; - color: #0d61bf; - border: none; - border-radius: 0; - - &:hover { - text-decoration: underline; - } -} - -.array-item { - display: flex; - flex-direction: row; - align-items: stretch; - justify-content: space-between; -} - -.array-input { - width: 100%; -} - -.remove-item { - position: absolute; - right: 45px; - margin-top: 33px; - font-size: 33px; - color: #ef476f; - cursor: pointer; - &:focus { - outline: none; - } -} diff --git a/src/routes/PositionDetails/components/InterviewPopup/Confirm/index.jsx b/src/routes/PositionDetails/components/InterviewPopup/Confirm/index.jsx new file mode 100644 index 00000000..e45af1ac --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/Confirm/index.jsx @@ -0,0 +1,97 @@ +import React from "react"; +import PT from "prop-types"; +import Button from "components/Button"; +import { toastr } from "react-redux-toastr"; + +import StepsIndicator from "../../StepsIndicator"; +import { confirmInterview } from "../../../../../services/interviews"; +import { + SCHEDULE_INTERVIEW_STEPS, + BUTTON_SIZE, + BUTTON_TYPE, + POPUP_STAGES, +} from "constants"; +import "./styles.module.scss"; + +/** + * This component is used to get the confirmation before scheduling the interview + */ +const Confirm = ({ + scheduleDetails, + candidateId, + userProfile, + onGoBack, + onContinue, + onShowingLoader, +}) => { + const { handle } = userProfile; + const { duration } = scheduleDetails; + + /** + * This will trigger the API call to the server to request an interview + */ + const onContinueAhead = () => { + const params = { + timezone: scheduleDetails.timezone, + duration: scheduleDetails.duration, + availableTime: scheduleDetails.slots.map((slot) => ({ + ...slot, + end: `${slot.end}:00`, + start: `${slot.start}:00`, + })), + }; + onShowingLoader(true); + + confirmInterview(candidateId, params) + .then(() => { + onContinue(POPUP_STAGES.SUCCESS); + }) + .catch((e) => { + toastr.error(e.message); + }) + .finally(() => { + onShowingLoader(false); + }); + }; + + return ( + <div styleName="confirm-wrapper"> + <StepsIndicator steps={SCHEDULE_INTERVIEW_STEPS} currentStep="confirm" /> + <div styleName="confirm-text"> + Send a <span styleName="confirm-text-bold">{duration} Minute</span>{" "} + Interview invite to <span styleName="confirm-text-bold">{handle}</span>. + This invite will allow {handle} to select and schedule an interview date + and time based on your availability. + </div> + + <div styleName="button-wrapper"> + <Button + styleName="back-button" + onClick={() => onGoBack()} + size={BUTTON_SIZE.MEDIUM} + type={BUTTON_TYPE.SECONDARY} + > + Back + </Button> + <Button + onClick={() => onContinueAhead()} + size={BUTTON_SIZE.MEDIUM} + type={BUTTON_TYPE.PRIMARY} + > + Confirm + </Button> + </div> + </div> + ); +}; + +Confirm.propTypes = { + scheduleDetails: PT.object, + userProfile: PT.object, + candidateId: PT.string, + onGoBack: PT.func, + onContinue: PT.func, + onShowingLoader: PT.func, +}; + +export default Confirm; diff --git a/src/routes/PositionDetails/components/InterviewPopup/Confirm/styles.module.scss b/src/routes/PositionDetails/components/InterviewPopup/Confirm/styles.module.scss new file mode 100644 index 00000000..bcb94119 --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/Confirm/styles.module.scss @@ -0,0 +1,42 @@ +@import "styles/include"; + +.confirm-wrapper { +} + +.confirm-text { + @include font-roboto; + font-size: 16px; + line-height: 26px; + font-weight: 400; + color: #000000; + margin-top: 59px; + min-height: 257px; +} + +.confirm-text-bold { + @include font-roboto; + font-size: 16px; + line-height: 26px; + font-weight: 500; +} + +.button-wrapper { + display: flex; + align-items: center; + margin-top: 16px; + justify-content: flex-end; +} + +.manage-calendar { + flex: 1; + color: #0d61bf; + @include font-roboto; + font-weight: 400; + font-size: 14px; + cursor: pointer; + text-decoration: underline; +} + +.back-button { + margin-right: 8px; +} diff --git a/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/index.jsx b/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/index.jsx new file mode 100644 index 00000000..3247a0e8 --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/index.jsx @@ -0,0 +1,123 @@ +import Button from "components/Button"; +import cn from "classnames"; +import PT from "prop-types"; +import { BUTTON_SIZE, BUTTON_TYPE } from "constants"; +import GoogleLogo from "../../../../../assets/images/icon-google.svg"; +import MicrosoftLogo from "../../../../../assets/images/icon-microsoft.svg"; +import { deleteCalendar } from "../../../../../services/interviews"; +import React, { useState } from "react"; +import { toastr } from "react-redux-toastr"; +import Spinner from "components/CenteredSpinner"; +import "./styles.module.scss"; + +/** + * The component shown to connect to a new calendar if no calendar is connected for the user so far + * Also, the component act as the connected calendar manager where the user can switch the calendar + * @param {*} param0 + * @returns + */ +const ConnectCalendar = ({ + isConnected, + calendar, + userProfile, + onGoingBack, + onCalendarRemoved, +}) => { + const [showLoader, setLoader] = useState(); + + const onRemoveCalendar = (calendar) => { + const { id } = userProfile; + const { id: calendarId } = calendar; + setLoader(true); + deleteCalendar(id, calendarId) + .then((res) => { + onCalendarRemoved(); + toastr.success("Calendar was successfully disconnected"); + }) + .catch((e) => { + toastr.error("Error disconnecting calendar", e.message); + }) + .finally(() => { + setLoader(false); + }); + }; + + return ( + <div styleName="connect-calendar"> + {!isConnected && ( + <div styleName="information"> + Share your availability from your Google or Outlook calendar to make + scheduling easier.{" "} + </div> + )} + + {isConnected && ( + <> + <div styleName="connected-section"> + <span styleName="connected-to-text">Currently connected to: </span> + <span styleName="email-address"> + {` ${calendar && calendar.accountEmail}`} + </span> + {!showLoader && ( + <button + styleName="remove-button" + onClick={() => onRemoveCalendar(calendar)} + > + Remove + </button> + )} + + {showLoader && <Spinner stype="Oval" width="20" height="20" />} + </div> + <div styleName="switch-calendar">Switch to a New calendar</div> + </> + )} + <div styleName="button-wrapper"> + <Button size={BUTTON_SIZE.SMALL} styleName={cn("button", "google-btn")}> + <GoogleLogo styleName="icons-wrapper" /> + Sign in with google + </Button> + <Button size={BUTTON_SIZE.SMALL}> + <MicrosoftLogo styleName="icons-wrapper" /> + Sign in with microsoft + </Button> + </div> + + <div styleName="description"> + Our calendar integration only checks the duration and free/busy status + of the events in your calendar so that we don’t book you when you’re + busy. We never store who you are meeting with, their email address, the + meeting title, or any other details about the appointments in your + connected calendar. + </div> + + <div + styleName={cn("footer-button-wrapper", { + "connected-account": isConnected, + })} + > + <Button + size={BUTTON_SIZE.MEDIUM} + type={BUTTON_TYPE.SECONDARY} + onClick={onGoingBack} + > + Back + </Button> + </div> + </div> + ); +}; + +ConnectCalendar.propTypes = { + isConnected: PT.bool.isRequired, + userProfile: PT.object, + calendar: PT.shape({ + accountProvider: PT.string, + accountEmail: PT.string, + isPrimary: PT.string, + }), + onCalendarRemoved: PT.func, + onGoingBack: PT.func, +}; + +export default ConnectCalendar; diff --git a/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/styles.module.scss b/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/styles.module.scss new file mode 100644 index 00000000..8ef2dfaa --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/ConnectCalendar/styles.module.scss @@ -0,0 +1,114 @@ +@import "styles/include"; + +.connect-calendar { +} + +.button { + height: 32px; +} + +.google-btn { + margin-right: 16px; +} + +.icons-wrapper { + width: 16px; + height: 16px; + margin-right: 5px; +} + +.information { + @include font-roboto; + font-weight: 400; + font-size: 16px; + line-height: 26px; + margin-bottom: 24px; + color: #2a2a2a; +} + +.button-wrapper { + margin-bottom: 40px; + display: flex; + flex-direction: row; +} + +.description { + @include font-roboto; + font-weight: 400; + font-size: 14px; + line-height: 24px; + color: #2a2a2a; +} + +// Section shown when email is connected + +.connected-section { + @include font-roboto; + margin-bottom: 32px; + color: #2a2a2a; + display: flex; +} + +.connected-to-text { + @include font-roboto; + font-weight: 400; + font-size: 16px; + line-height: 26px; +} + +.email-address { + @include font-roboto; + font-weight: 500; + font-size: 16px; + line-height: 26px; + margin-right: 8px; +} + +.remove-button { + @include font-roboto; + font-weight: 700; + font-size: 10px; + line-height: 12px; + background-color: #aaaaaa; + padding: 4px 15px; + height: 24px; + color: #ffffff; + border-radius: 25px; + text-transform: uppercase; + outline: none; + border-color: transparent; +} + +.switch-calendar { + @include font-barlow; + font-size: 16px; + font-weight: 600; + line-height: 16px; + text-transform: uppercase; + margin-bottom: 24px; + color: #2a2a2a; +} + +.footer-button-wrapper { + display: flex; + justify-content: flex-end; + margin-top: 83px; +} + +.connected-account { + margin-top: 60px; +} + +@media (max-width: 420px) { + .button-wrapper { + flex-direction: column; + } + .google-btn { + margin-right: 0; + margin-bottom: 12px; + } + + .connected-section { + display: block; + } +} diff --git a/src/routes/PositionDetails/components/InterviewPopup/ManageAvailability/index.jsx b/src/routes/PositionDetails/components/InterviewPopup/ManageAvailability/index.jsx new file mode 100644 index 00000000..19b7ae2e --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/ManageAvailability/index.jsx @@ -0,0 +1,282 @@ +import React, { useState, useEffect } from "react"; +import cn from "classnames"; +import PT from "prop-types"; +import { + SCHEDULE_INTERVIEW_STEPS, + DAYS, + DEFAULT_SELECTED_DAYS, + BUTTON_TYPE, + BUTTON_SIZE, + POPUP_STAGES, +} from "constants"; +import moment from "moment"; +import StepsIndicator from "../../StepsIndicator"; +import TimezoneSelector from "components/TimezoneSelector"; +import Select from "components/Select"; +import IconDelete from "../../../../../assets/images/icon-delete-slot.svg"; +import IconPlus from "../../../../../assets/images/icon-plus.svg"; +import "./styles.module.scss"; +import Button from "components/Button"; + +/** + * This component lets user to choose the availability of the interview candidate + * @returns + */ +const ManageAvailability = ({ scheduleDetails, onContinue }) => { + const timezones = moment.tz.names(); + const emptySlot = [ + { + start: "", + end: "", + days: DEFAULT_SELECTED_DAYS, + }, + ]; + const [slots, setSlots] = useState(emptySlot); + const [timezone, setTimezone] = useState(moment.tz.guess(true)); + const days = Object.values(DAYS); + const daysMapped = Object.keys(DAYS); + const timeSlots = new Array(24) + .join(",") + .split(",") + .map((item, index) => index) + .reduce( + (acc, currentVal) => [ + ...acc, + { + time12HourFormat: + currentVal < 12 + ? `${!currentVal ? 12 : currentVal}:00` + : `${Math.abs((currentVal === 12 ? 0 : currentVal) - 12)}:00`, + time24HourFormat: currentVal + 1, + suffix: currentVal < 12 ? "AM" : "PM", + }, + ], + [] + ); + + useEffect(() => { + if (scheduleDetails.slots && scheduleDetails.slots.length > 0) { + setSlots(scheduleDetails.slots); + } + }, [scheduleDetails]); + + /** + * This adds a new slot to the existing slots + */ + const onAddSlot = () => { + const copied = [...slots, ...emptySlot]; + setSlots(copied); + }; + + /** + * The on change time handler + */ + const onChangeTime = (changedIndex, field, value) => { + const changed = slots.map((item, index) => { + if (index === changedIndex) { + return { + ...item, + [field]: value, + }; + } + + return item; + }); + + setSlots(changed); + }; + + /** + * This function called when the day is changed + */ + const onDayChanged = (changedDay, slotIndex) => { + const changed = slots.map((item, index) => { + const isDeselect = item.days.indexOf(changedDay) > -1; + const days = isDeselect + ? item.days.filter((item) => item !== changedDay) + : [...item.days, changedDay]; + if (index === slotIndex) { + return { + ...item, + days, + }; + } + + return item; + }); + + setSlots(changed); + }; + + /** + * The function called when the slot is removed + */ + const onRemoveSlot = (slotIndex) => { + const changed = slots.filter((item, index) => { + if (index === slotIndex) { + return false; + } + + return true; + }); + + setSlots(changed); + }; + + // Set time zone + const onChangeTimezone = (value) => { + setTimezone(value); + }; + + /** + * Validation function which determines whether to disable the button on not + */ + const isDisabled = () => { + let isDisable = false; + + for (let i = 0; i < slots.length; i++) { + const item = slots[i]; + isDisable = !(item.days.length > 0 && !!item.start && !!item.end); + if (isDisable) { + return isDisable; + } + } + + isDisable = !!!timezone; + + return isDisable; + }; + + const onContinueAhead = () => { + onContinue(POPUP_STAGES.SELECT_DURATION, { + timezone, + slots, + }); + }; + + return ( + <div styleName="manage-availability"> + <StepsIndicator + steps={SCHEDULE_INTERVIEW_STEPS} + currentStep="availability" + /> + <TimezoneSelector onChange={onChangeTimezone} value={timezone} /> + <div styleName="when-to-schedule">When can interviews be scheduled?</div> + <div styleName="interview-slots"> + {slots.map((slot, slotIndex) => { + const timeSlotsOptions = timeSlots.map((item) => ({ + label: `${item.time12HourFormat} ${item.suffix}`, + value: item.time24HourFormat, + })); + const startTimeOptions = [ + { label: "Start Time", value: "" }, + ...timeSlotsOptions, + ]; + const endTimeOptions = [ + { label: "End Time", value: "" }, + ...timeSlotsOptions.map((item) => ({ + label: item.label, + value: item.value, + disabled: slot.start >= item.value, + })), + ]; + + return ( + <div styleName="slot"> + <div styleName="days"> + {days.map((day, index) => ( + <div + onClick={() => onDayChanged(daysMapped[index], slotIndex)} + styleName={cn("day", { + "selected-day": slot.days.indexOf(daysMapped[index]) > -1, + })} + > + {day} + </div> + ))} + </div> + <div styleName="time-wrapper"> + <div styleName="start-time"> + <Select + options={startTimeOptions} + value={slot.start} + onChange={(value) => + onChangeTime(slotIndex, "start", value) + } + /> + </div> + <div styleName="end-time"> + <Select + options={endTimeOptions} + value={slot.end} + onChange={(value) => onChangeTime(slotIndex, "end", value)} + /> + </div> + {slots.length !== 1 && ( + <div + styleName="delete-slot-wrapper" + onClick={() => onRemoveSlot(slotIndex)} + > + <IconDelete /> + </div> + )} + </div> + </div> + ); + })} + <div styleName="add-button-wrapper"> + <Button + onClick={onAddSlot} + type={BUTTON_TYPE.SECONDARY} + size={BUTTON_SIZE.EXTRA_SMALL} + > + <div styleName="plus-icon-wrapper"> + <IconPlus /> + </div> + ADD + </Button> + </div> + </div> + + <div styleName="button-wrapper"> + <div + onClick={() => + onContinue(POPUP_STAGES.MANAGE_CALENDAR, { + timezone, + slots, + }) + } + styleName="manage-calendar" + > + Manage connected calendar + </div> + <Button + onClick={() => onContinueAhead()} + disabled={isDisabled()} + size={BUTTON_SIZE.MEDIUM} + type={BUTTON_TYPE.PRIMARY} + > + Continue + </Button> + </div> + </div> + ); +}; + +ManageAvailability.propTypes = { + scheduleDetails: PT.arrayOf( + PT.shape({ + timezone: PT.string, + slots: PT.arrayOf( + PT.shape({ + days: PT.array, + end: PT.string, + start: PT.string, + }) + ), + }) + ), + onContinue: PT.func, +}; + +export default ManageAvailability; diff --git a/src/routes/PositionDetails/components/InterviewPopup/ManageAvailability/styles.module.scss b/src/routes/PositionDetails/components/InterviewPopup/ManageAvailability/styles.module.scss new file mode 100644 index 00000000..86f7ddf5 --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/ManageAvailability/styles.module.scss @@ -0,0 +1,117 @@ +@import "styles/include"; + +.manage-availability { +} + +.when-to-schedule { + @include font-barlow; + font-weight: 600; + font-size: 16px; + line-height: 16px; + color: #2a2a2a; + margin-top: 32px; + margin-bottom: 13px; + text-transform: uppercase; +} + +.interview-slots { + max-height: 152px; + min-height: 152px; + overflow-y: auto; + overflow-x: hidden; +} + +.slot { + display: flex; + align-items: center; + padding-top: 21px; + padding-bottom: 19px; + border-bottom: 1px solid #d4d4d4; +} + +.days { + display: flex; + flex-direction: row; + margin-right: 12px; +} + +.day { + color: #555555; + height: 32px; + width: 32px; + border-radius: 50%; + border: 1px solid #0ab88a; + margin-right: 4px; + @include font-roboto; + font-weight: 400; + font-size: 16px; + line-height: 26px; + align-items: center; + justify-content: center; + display: flex; + cursor: pointer; +} + +.selected-day { + background-color: #0ab88a; + color: #ffffff; +} + +.time-wrapper { + display: flex; + align-items: center; +} + +.start-time { + width: 120px; + margin-right: 8px; +} +.end-time { + width: 120px; +} + +.delete-slot-wrapper { + margin-left: 8px; + width: 16px; + height: 16px; + cursor: pointer; +} + +.add-button-wrapper { + margin-top: 8px; +} + +.plus-icon-wrapper { + margin-right: 5px; + align-items: center; + justify-content: center; + display: flex; +} + +.button-wrapper { + display: flex; + align-items: center; + margin-top: 12px; +} + +.manage-calendar { + flex: 1; + color: #0d61bf; + @include font-roboto; + font-weight: 400; + font-size: 14px; + cursor: pointer; + text-decoration: underline; + margin-right: 12px; +} + +@media (max-width: 420px) { + .slot { + flex-direction: column; + align-items: flex-start; + } + + .time-wrapper { + margin-top: 12px; + } +} diff --git a/src/routes/PositionDetails/components/InterviewPopup/ScheduleInterview/index.jsx b/src/routes/PositionDetails/components/InterviewPopup/ScheduleInterview/index.jsx new file mode 100644 index 00000000..d39f672d --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/ScheduleInterview/index.jsx @@ -0,0 +1,39 @@ +import Button from "components/Button"; +import PT from "prop-types"; +import { BUTTON_SIZE, POPUP_STAGES } from "constants"; + +import React from "react"; +import "./styles.module.scss"; + +/** + * The component shown to select either to add + * interviewee's availability or to connect to + * the calendar + * @param {*} param0 + * @returns + */ +const ScheduleInterview = ({ onClick }) => { + return ( + <div styleName="schedule-interview"> + <Button + size={BUTTON_SIZE.LARGE} + onClick={() => onClick(POPUP_STAGES.MANAGE_AVAILABILITY)} + > + Add your availability + </Button> + <div styleName="or-label">OR</div> + <Button + size={BUTTON_SIZE.LARGE} + onClick={() => onClick(POPUP_STAGES.CONNECT_CALENDAR)} + > + Connect your calendar + </Button> + </div> + ); +}; + +ScheduleInterview.propTypes = { + onClick: PT.func, +}; + +export default ScheduleInterview; diff --git a/src/routes/PositionDetails/components/InterviewPopup/ScheduleInterview/styles.module.scss b/src/routes/PositionDetails/components/InterviewPopup/ScheduleInterview/styles.module.scss new file mode 100644 index 00000000..c6d96743 --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/ScheduleInterview/styles.module.scss @@ -0,0 +1,17 @@ +@import "styles/include"; + +.schedule-interview { + padding: 79px 0 143px 0; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.or-label { + @include font-barlow; + padding: 16px 0; + font-size: 16px; + line-height: 16px; + color: #000000; +} diff --git a/src/routes/PositionDetails/components/InterviewPopup/SelectDuration/index.jsx b/src/routes/PositionDetails/components/InterviewPopup/SelectDuration/index.jsx new file mode 100644 index 00000000..b81b26a3 --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/SelectDuration/index.jsx @@ -0,0 +1,112 @@ +import React, { useState, useEffect } from "react"; +import cn from "classnames"; +import PT from "prop-types"; +import Button from "components/Button"; +import StepsIndicator from "../../StepsIndicator"; +import InterviewTypeIcon from "../../../../../assets/images/icon-interview-type.svg"; +import { + SCHEDULE_INTERVIEW_STEPS, + BUTTON_SIZE, + BUTTON_TYPE, + POPUP_STAGES, +} from "constants"; +import "./styles.module.scss"; + +/** + * The select duration will let user to select the duration of the interview + */ +const SelectDuration = ({ onContinue, onGoBack, scheduleDetails }) => { + const [duration, setDuration] = useState(30); + + useEffect(() => { + if (scheduleDetails.duration) { + setDuration(scheduleDetails.duration); + } + }, [scheduleDetails]); + + const changeDuration = (changedDuration) => { + setDuration(changedDuration); + }; + + const onContinueAhead = () => { + onContinue(POPUP_STAGES.SEND_INTERVIEW_INVITE, { + duration, + }); + }; + + return ( + <div styleName="select-duration"> + <StepsIndicator + steps={SCHEDULE_INTERVIEW_STEPS} + currentStep="selectType" + /> + <div styleName="interview-types"> + <div + styleName={cn("interview", "thirty-minutes", { + "selected-duration": duration === 30, + })} + onClick={() => changeDuration(30)} + > + <div + styleName={cn("dot", { + "selected-dot": duration === 30, + })} + /> + <InterviewTypeIcon /> + <div styleName="interview-type-text">30 Minute Interview</div> + </div> + <div + styleName={cn("interview", { + "selected-duration": duration === 60, + })} + onClick={() => changeDuration(60)} + > + <div + styleName={cn("dot", { + "selected-dot": duration === 60, + })} + /> + <InterviewTypeIcon /> + <div styleName="interview-type-text">60 Minute Interview</div> + </div> + </div> + <div styleName="button-wrapper"> + <div + onClick={() => + onContinue(POPUP_STAGES.MANAGE_CALENDAR, { + duration, + }) + } + styleName="manage-calendar" + > + Manage connected calendar + </div> + <div styleName="button-wrapper-right-pane"> + <Button + styleName="back-button" + onClick={() => onGoBack()} + size={BUTTON_SIZE.MEDIUM} + type={BUTTON_TYPE.SECONDARY} + > + Back + </Button> + <Button + onClick={() => onContinueAhead()} + size={BUTTON_SIZE.MEDIUM} + type={BUTTON_TYPE.PRIMARY} + > + Continue + </Button> + </div> + </div> + </div> + ); +}; + +SelectDuration.propTypes = { + scheduleDetails: PT.object, + onContinue: PT.func, + onGoBack: PT.func, +}; + +export default SelectDuration; diff --git a/src/routes/PositionDetails/components/InterviewPopup/SelectDuration/styles.module.scss b/src/routes/PositionDetails/components/InterviewPopup/SelectDuration/styles.module.scss new file mode 100644 index 00000000..40f48513 --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/SelectDuration/styles.module.scss @@ -0,0 +1,90 @@ +@import "styles/include"; + +.select-duration { +} + +.interview-types { + display: flex; + margin-top: 59px; + margin-bottom: 157px; +} + +.thirty-minutes { + margin-right: 16px; +} + +.interview { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + border: 1px solid #aaaaaa; + position: relative; + border-radius: 8px; + padding: 24px 0; +} + +.selected-duration { + border: 2px solid #0ab88a; +} + +.dot { + width: 16px; + height: 16px; + position: absolute; + top: 8px; + right: 8px; + border: 1px solid #aaaaaa; + border-radius: 50%; +} + +.selected-dot { + border: 4px solid #0ab88a; +} + +.interview-type-text { + @include font-barlow; + font-weight: 600; + font-size: 16px; + line-height: 16px; + text-transform: uppercase; + margin-top: 16px; + color: #1e94a3; + text-align: center; +} + +.button-wrapper { + display: flex; + align-items: center; + margin-top: 16px; +} + +.manage-calendar { + flex: 1; + color: #0d61bf; + @include font-roboto; + font-weight: 400; + font-size: 14px; + cursor: pointer; + text-decoration: underline; +} + +.back-button { + margin-right: 8px; +} + +.button-wrapper-right-pane { + display: flex; +} + +@media (max-width: 420px) { + .button-wrapper { + flex-direction: column; + align-items: flex-start; + } + .button-wrapper-right-pane { + display: flex; + margin-top: 8px; + } +} diff --git a/src/routes/PositionDetails/components/InterviewPopup/Success/index.jsx b/src/routes/PositionDetails/components/InterviewPopup/Success/index.jsx new file mode 100644 index 00000000..853a8423 --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/Success/index.jsx @@ -0,0 +1,35 @@ +import React from "react"; +import PT from "prop-types"; +import Button from "components/Button"; +import { BUTTON_SIZE, BUTTON_TYPE, POPUP_STAGES } from "constants"; +import "./styles.module.scss"; + +/** + * The success component shown once the interview is scheduled successfully + */ +const Success = ({ userProfile, onContinue }) => { + const { handle } = userProfile; + return ( + <div styleName="success-wrapper"> + <div styleName="success-text"> + Your interview invite for {handle} was sent successfully. Once your + candidate selects a time, you will receive a confirmation email. + </div> + <div styleName="button-wrapper"> + <Button + onClick={() => onContinue(POPUP_STAGES.CLOSE)} + size={BUTTON_SIZE.MEDIUM} + type={BUTTON_TYPE.PRIMARY} + > + Close + </Button> + </div> + </div> + ); +}; + +Success.propTypes = { + userProfile: PT.object, +}; + +export default Success; diff --git a/src/routes/PositionDetails/components/InterviewPopup/Success/styles.module.scss b/src/routes/PositionDetails/components/InterviewPopup/Success/styles.module.scss new file mode 100644 index 00000000..e7e6163c --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/Success/styles.module.scss @@ -0,0 +1,20 @@ +@import "styles/include"; + +.success-wrapper { +} + +.success-text { + margin-top: 70px; + margin-bottom: 232px; + @include font-roboto; + font-size: 16px; + line-height: 26px; + font-weight: 400; +} + +.button-wrapper { + display: flex; + align-items: center; + margin-top: 16px; + justify-content: flex-end; +} diff --git a/src/routes/PositionDetails/components/InterviewPopup/index.jsx b/src/routes/PositionDetails/components/InterviewPopup/index.jsx new file mode 100644 index 00000000..7b879c73 --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/index.jsx @@ -0,0 +1,298 @@ +import React, { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import cn from "classnames"; +import PT from "prop-types"; +import Spinner from "components/CenteredSpinner"; +import { toastr } from "react-redux-toastr"; + +import ScheduleInterview from "./ScheduleInterview"; +import BaseModal from "components/BaseModal"; +import ConnectCalendar from "./ConnectCalendar"; +import SelectDuration from "./SelectDuration"; +import Confirm from "./Confirm"; +import Success from "./Success"; + +import { getUserSettings } from "../../../../services/interviews"; +import IconCrossBlack from "../../../../assets/images/icon-cross-black.svg"; +import ManageAvailability from "./ManageAvailability"; +import { POPUP_STAGES, INTERVIEW_POPUP_STAGES } from "constants"; + +import "./styles.module.scss"; + +/** + * Interview pop up component which contains the step by step process of + * scheduling or rescheduling the interviews + * @param {*} + * @returns + */ +const InterviewPopup = ({ + initialStage = POPUP_STAGES.SCHEDULE_INTERVIEW, + open = false, + onClose, + candidate, +}) => { + const [stage, setStage] = useState(initialStage); + const [previousStage, setPreviousStage] = useState(initialStage); + const [isLoading, setLoading] = useState(false); + const [userSettings, setUserSettings] = useState(); + const { v5UserProfile } = useSelector((state) => state.authUser); + const [scheduleDetails, setScheduleDetails] = useState({ + timezone: "", + slots: [], + }); + + useEffect(() => { + if (open) { + getSettings(); + } + }, [open]); + + const onCloseInterviewPopup = () => { + setStage(""); + setScheduleDetails({ + timezone: "", + slots: [], + }); + onClose(); + }; + + const prepareSlots = (userSettings) => ({ + timezone: userSettings.defaultTimezone, + slots: + (userSettings.defaultAvailableTime && + userSettings.defaultAvailableTime.map((item) => ({ + days: item.days, + end: parseInt(item.end.split(":")[0]), + start: parseInt(item.start.split(":")[0]), + }))) || + [], + }); + + /** + * Gets the settings from the backend and checks if the calendar is already available + */ + const getSettings = () => { + setLoading(true); + getUserSettings(v5UserProfile.id) + .then((res) => { + setUserSettings(res.data); + setScheduleDetails(prepareSlots(res.data)); + setStage(POPUP_STAGES.MANAGE_AVAILABILITY); + }) + .catch((e) => { + if (e.response && e.response.status === 404) { + setStage(POPUP_STAGES.SCHEDULE_INTERVIEW); + } else { + toastr.error("Failed to get user settings"); + onCloseInterviewPopup(); + } + }) + .finally(() => { + setLoading(false); + }); + }; + + /** + * Get the calendar which is not of provider nylas + * @param {*} settings + * @returns + */ + const getCalendar = (settings) => { + if (!settings) return null; + const calendar = + (settings.nylasCalendars && + settings.nylasCalendars.filter( + (item) => item.accountProvider !== "nylas" + )) || + []; + // Take the first calendar which are other than nylas calendar + return calendar[0]; + }; + + /** + * Handler to change to the next stage + * @param {*} stage + */ + const onChangeStage = (nextState, supportData) => { + setPreviousStage(stage); + if (nextState === POPUP_STAGES.CONNECT_CALENDAR) { + const calendar = getCalendar(userSettings); + if (calendar && calendar.isPrimary) { + setStage(POPUP_STAGES.MANAGE_CALENDAR); + } else { + setStage(nextState); + } + } else if (nextState === POPUP_STAGES.MANAGE_CALENDAR) { + const calendar = getCalendar(userSettings); + + if (!calendar) { + setStage(POPUP_STAGES.CONNECT_CALENDAR); + } else { + setStage(nextState); + } + } else if (nextState === POPUP_STAGES.CLOSE) { + onClose(); + } else { + setStage(nextState); + } + + if (supportData) { + setScheduleDetails({ + ...scheduleDetails, + ...supportData, + }); + } + }; + + const onShowingLoader = (loading) => { + setLoading(loading); + }; + + /** + * Removes the calendar from the state once it is removed from the server + */ + const onCalendarRemoved = () => { + setUserSettings({ + ...userSettings, + nylasCalendars: [], + }); + + setStage(POPUP_STAGES.CONNECT_CALENDAR); + }; + + /** + * Get the component by current stage + * @returns + */ + const getComponentByState = () => { + switch (stage) { + case POPUP_STAGES.SCHEDULE_INTERVIEW: + return <ScheduleInterview onClick={onChangeStage} />; + case POPUP_STAGES.CONNECT_CALENDAR: + return ( + <ConnectCalendar isConnected={false} onGoingBack={onGoingBack} /> + ); + case POPUP_STAGES.MANAGE_CALENDAR: + const calendar = getCalendar(userSettings); + return ( + <ConnectCalendar + isConnected + userProfile={v5UserProfile} + calendar={calendar} + onGoingBack={onGoingBack} + onCalendarRemoved={onCalendarRemoved} + /> + ); + case POPUP_STAGES.MANAGE_AVAILABILITY: + return ( + <ManageAvailability + scheduleDetails={scheduleDetails} + onContinue={onChangeStage} + onGoBack={onGoingBack} + /> + ); + case POPUP_STAGES.SELECT_DURATION: + return ( + <SelectDuration + scheduleDetails={scheduleDetails} + onContinue={onChangeStage} + onGoBack={onGoingBack} + /> + ); + case POPUP_STAGES.SEND_INTERVIEW_INVITE: + return ( + <Confirm + scheduleDetails={scheduleDetails} + userProfile={v5UserProfile} + onContinue={onChangeStage} + onGoBack={onGoingBack} + onShowingLoader={onShowingLoader} + candidateId={candidate.id} + /> + ); + case POPUP_STAGES.SUCCESS: + return ( + <Success userProfile={v5UserProfile} onContinue={onChangeStage} /> + ); + default: + return null; + } + }; + + /** + * Logic to go back to the previous step in the interview scheduling process + */ + const onGoingBack = () => { + // Only if the current stage is + if ( + stage === POPUP_STAGES.MANAGE_CALENDAR || + stage === POPUP_STAGES.CONNECT_CALENDAR + ) { + setStage(previousStage); + return; + } + + switch (stage) { + case POPUP_STAGES.SELECT_DURATION: + setStage(POPUP_STAGES.MANAGE_AVAILABILITY); + break; + case POPUP_STAGES.SEND_INTERVIEW_INVITE: + setStage(POPUP_STAGES.SELECT_DURATION); + break; + default: + setStage(POPUP_STAGES.SCHEDULE_INTERVIEW); + break; + } + }; + + // Gets the current stage of the process + const getCurrentStage = () => + INTERVIEW_POPUP_STAGES.find((item) => item.id === stage); + + const currentStage = getCurrentStage(); + + const extraModalStyle = { + minHeight: "480px", + maxWidth: "600px", + }; + + return ( + <div> + <BaseModal + open={open} + title={currentStage && currentStage.title} + alignTitleCenter={currentStage && currentStage.isCenterAligned} + showButton={false} + onClose={onCloseInterviewPopup} + extraModalStyle={extraModalStyle} + closeIcon={<IconCrossBlack width="15px" height="15px" />} + > + <div styleName="modal-content"> + <div + styleName={cn("spinner-wrapper", { + "show-spinner": isLoading, + })} + > + <Spinner stype="Oval" width="80" height="80" /> + </div> + <div + styleName={cn("component-wrapper", { + "show-component-wrapper": !isLoading, + })} + > + {!isLoading && getComponentByState()} + </div> + </div> + </BaseModal> + </div> + ); +}; + +InterviewPopup.propTypes = { + candidate: PT.object.isRequired, + open: PT.bool, + initialStage: PT.string, + onClose: PT.func.isRequired, +}; + +export default InterviewPopup; diff --git a/src/routes/PositionDetails/components/InterviewPopup/styles.module.scss b/src/routes/PositionDetails/components/InterviewPopup/styles.module.scss new file mode 100644 index 00000000..c281d9c7 --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/styles.module.scss @@ -0,0 +1,25 @@ +.modal-content { + align-items: center; +} + +.spinner-wrapper { + padding-top: 103px; + display: none; +} + +.title-style { + text-align: center; + border-bottom-color: transparent; +} + +.show-spinner { + display: block; +} + +.component-wrapper { + display: none; +} + +.show-component-wrapper { + display: block; +} diff --git a/src/routes/PositionDetails/components/PositionCandidates/index.jsx b/src/routes/PositionDetails/components/PositionCandidates/index.jsx index 59f4ac76..24421aaa 100644 --- a/src/routes/PositionDetails/components/PositionCandidates/index.jsx +++ b/src/routes/PositionDetails/components/PositionCandidates/index.jsx @@ -30,10 +30,10 @@ import { PERMISSIONS } from "constants/permissions"; import { hasPermission } from "utils/permissions"; import ActionsMenu from "components/ActionsMenu"; import LatestInterview from "../LatestInterview"; -import InterviewDetailsPopup from "../InterviewDetailsPopup"; import PreviousInterviewsPopup from "../PreviousInterviewsPopup"; import InterviewConfirmPopup from "../InterviewConfirmPopup"; import SelectCandidatePopup from "../SelectCandidatePopup"; +import InterviewPopup from "../InterviewPopup"; /** * Generates a function to sort candidates @@ -366,12 +366,14 @@ const PositionCandidates = ({ position, statusFilterKey, updateCandidate }) => { onClose={() => setPrevInterviewsOpen(false)} candidate={selectedCandidate} /> - <InterviewDetailsPopup + + <InterviewPopup open={interviewDetailsOpen} + candidate={selectedCandidate} onClose={() => setInterviewDetailsOpen(false)} candidate={selectedCandidate} - openNext={() => setInterviewConfirmOpen(true)} /> + <InterviewConfirmPopup open={interviewConfirmOpen} onClose={() => setInterviewConfirmOpen(false)} diff --git a/src/routes/PositionDetails/components/StepsIndicator/index.jsx b/src/routes/PositionDetails/components/StepsIndicator/index.jsx new file mode 100644 index 00000000..87174a97 --- /dev/null +++ b/src/routes/PositionDetails/components/StepsIndicator/index.jsx @@ -0,0 +1,68 @@ +import React from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import "./styles.module.scss"; + +/** + * The StepsIndicator component shows in what step + * the user is right now + * @param {*} param0 + * @returns + */ +const StepsIndicator = ({ steps, currentStep }) => { + const currentStepIndex = steps.findIndex((item) => item.id === currentStep); + const completionPercentage = + 100 - 100 * (currentStepIndex / (steps.length - 1)); + + const progressLineStyles = { + right: + completionPercentage === 0 + ? completionPercentage + : `calc(${completionPercentage}% - 24px)`, + }; + + return ( + <div styleName="steps-indicator"> + <div styleName="default-line" /> + <div styleName="progress-line" style={progressLineStyles} /> + <div styleName="dots-wrapper"> + {steps.map((step, index) => { + const position = 100 * (index / (steps.length - 1)); + const styles = { + left: position > 0 ? `calc(${position}% + 2px)` : `${position}%`, + }; + + const isCompleted = index <= currentStepIndex; + + return ( + <> + <div styleName="outer-dot" style={styles} /> + <div + styleName={cn("dot", { "dot-completed": isCompleted })} + style={styles} + /> + <div + styleName={cn("step-label", { "step-completed": isCompleted })} + style={styles} + > + {step.label} + </div> + </> + ); + })} + </div> + </div> + ); +}; + +StepsIndicator.propTypes = { + steps: PT.arrayOf( + PT.shape({ + id: PT.string, + lable: PT.string, + }) + ), + currentStep: PT.string, +}; + +export default StepsIndicator; diff --git a/src/routes/PositionDetails/components/StepsIndicator/styles.module.scss b/src/routes/PositionDetails/components/StepsIndicator/styles.module.scss new file mode 100644 index 00000000..ce7916b2 --- /dev/null +++ b/src/routes/PositionDetails/components/StepsIndicator/styles.module.scss @@ -0,0 +1,66 @@ +@import "styles/include"; + +.steps-indicator { + position: relative; + display: inline-block; + width: 100%; +} + +.default-line { + height: 2px; + background-color: #c4c4c4; + margin: 0 24px; + position: absolute; + left: 0; + right: 0; +} + +.progress-line { + height: 2px; + background-color: #6569ff; + margin: 0 24px; + position: absolute; + left: 0; +} + +.dots-wrapper { + margin: 0 24px; + position: relative; +} + +.dot { + width: 12px; + height: 12px; + background-color: #aaaaaa; + position: absolute; + border-radius: 50%; + top: -6px; +} + +.dot-completed { + background-color: #6569ff; +} + +.outer-dot { + width: 16px; + height: 16px; + background-color: #ffffff; + position: absolute; + border-radius: 50%; + top: -8px; +} + +.step-label { + @include font-roboto; + font-weight: 500; + font-size: 12px; + line-height: 16px; + position: absolute; + transform: translateX(-40%); + margin-top: 11px; + color: #aaaaaa; +} + +.step-completed { + color: #6569ff; +} diff --git a/src/services/interviews.js b/src/services/interviews.js new file mode 100644 index 00000000..ffde9820 --- /dev/null +++ b/src/services/interviews.js @@ -0,0 +1,21 @@ +import { axiosInstance as axios } from "./requestInterceptor"; +import config from "../../config"; + +export const getUserSettings = (userId) => { + return axios.get( + `${config.INTERVIEW_API_URL}taas/user-meeting-settings/${userId}` + ); +}; + +export const deleteCalendar = (userId, calendarId) => { + return axios.delete( + `${config.INTERVIEW_API_URL}taas/user-meeting-settings/${userId}/calendars/${calendarId}` + ); +}; + +export const confirmInterview = (candidateJobId, data) => { + return axios.patch( + `${config.INTERVIEW_API_URL}jobCandidates/${candidateJobId}/requestInterview`, + data + ); +};