diff --git a/.circleci/config.yml b/.circleci/config.yml index 4c623cb2e..16e161844 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -262,7 +262,7 @@ workflows: branches: only: - dev - - talent-search + - onboarding - deployQa: context : org-global diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..a2a88ec68 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "vue.features.codeActions.enable": false +} \ No newline at end of file diff --git a/package.json b/package.json index 925fae1b7..e1ffe6a62 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "react-helmet": "^6.1.0", "react-html-parser": "^2.0.2", "react-markdown": "8.0.6", + "react-overlays": "^5.2.1", "react-redux": "^8.0.4", "react-redux-toastr": "^7.6.10", "react-responsive": "^9.0.0-beta.5", diff --git a/src/apps/onboarding/index.ts b/src/apps/onboarding/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/onboarding/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/onboarding/src/assets/images/avatar-placeholder.png b/src/apps/onboarding/src/assets/images/avatar-placeholder.png new file mode 100644 index 000000000..78a9e9f51 Binary files /dev/null and b/src/apps/onboarding/src/assets/images/avatar-placeholder.png differ diff --git a/src/apps/onboarding/src/assets/images/calendar.svg b/src/apps/onboarding/src/assets/images/calendar.svg new file mode 100644 index 000000000..f33a6a342 --- /dev/null +++ b/src/apps/onboarding/src/assets/images/calendar.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/apps/onboarding/src/components/DateInput/index.tsx b/src/apps/onboarding/src/components/DateInput/index.tsx new file mode 100644 index 000000000..df6a40251 --- /dev/null +++ b/src/apps/onboarding/src/components/DateInput/index.tsx @@ -0,0 +1,99 @@ +/** + * DateInput + * + * Date Input control. + */ +import { Portal } from 'react-overlays' +import { createRef, FC, useState } from 'react' +import DatePicker from 'react-datepicker' +import cn from 'classnames' +import moment from 'moment' +import 'react-datepicker/dist/react-datepicker.css' + +import { IconOutline } from '~/libs/ui' + +import { ReactComponent as CalendarIcon } from '../../assets/images/calendar.svg' + +import styles from './styles.module.scss' + +interface CalendarContainerProps { + children?: any +} +const CalendarContainer: FC = (props: CalendarContainerProps) => { + const el: any = document.getElementById('calendar-portal') + + return {props.children} +} + +interface DateInputProps { + style2?: boolean + className?: string + placeholder?: string + value?: Date + onChange?: (date: Date | null) => void + onBlur?: () => void + onFocus?: () => void + allowFutureDate?: boolean + disabled?: boolean +} + +const DateInput: FC = (props: DateInputProps) => { + const [open, setOpen] = useState(false) + const calendarRef: any = createRef() + return ( +
+
+ +
+ +
+ +
+
+ ) +} + +export default DateInput diff --git a/src/apps/onboarding/src/components/DateInput/styles.module.scss b/src/apps/onboarding/src/components/DateInput/styles.module.scss new file mode 100644 index 000000000..b898c441f --- /dev/null +++ b/src/apps/onboarding/src/components/DateInput/styles.module.scss @@ -0,0 +1,77 @@ +@import '@libs/ui/styles/includes'; + +.datepicker-wrapper { + position: relative; + padding: 0 10px; + height: 36px; + display: flex; + align-items: center; + + .icon { + position: absolute; + display: flex; + padding: 8px 0 8px 4px; + align-items: center; + + &>svg { + width: 20px; + height: 20px; + } + } + + .icon-calendar { + left: 8px; + cursor: pointer; + } + + .icon-arrow { + right: 8px; + top: 0; + + &>svg { + color: hsl(0, 0%, 80%); + } + + &:hover { + &>svg { + color: hsl(0, 0%, 60%); + } + } + + &.icon-arrow-open { + &>svg { + color: hsl(0, 0%, 40%); + } + } + } + + &.error { + input { + border-color: #fe665d; + } + } + + &>div:nth-child(2) { + margin-left: 24px; + } + + &.style2 input { + border: none !important; + box-shadow: none !important; + margin-bottom: 0 !important; + font-size: 14px; + font-weight: 400; + + &::placeholder { + color: $black-60; + font-size: 14px; + font-weight: 400; + text-transform: none !important; + opacity: 1; + } + } +} + +.datepicker-wrapper>div:nth-child(2)>div:nth-child(2)>div:nth-child(2) { + z-index: 100; +} \ No newline at end of file diff --git a/src/apps/onboarding/src/components/FieldAvatar/index.tsx b/src/apps/onboarding/src/components/FieldAvatar/index.tsx new file mode 100644 index 000000000..8d9f86df7 --- /dev/null +++ b/src/apps/onboarding/src/components/FieldAvatar/index.tsx @@ -0,0 +1,135 @@ +/** + * FieldAvatar + * + * A Form Field Is a wrapper for input to add the label to it + */ +import { Dispatch, FC, SetStateAction, useEffect, useState } from 'react' +import classNames from 'classnames' + +import { Button, IconOutline } from '~/libs/ui' + +import AvatarPlaceholder from '../../assets/images/avatar-placeholder.png' +import MemberInfo from '../../models/MemberInfo' +import ModalUploadPhoto from '../modal-upload-photo' + +import styles from './styles.module.scss' + +interface FieldAvatarProps { + className?: string + memberInfo?: MemberInfo, + setMemberPhotoUrl: (photoUrl: string) => void + updateMemberPhotoUrl: (photoUrl: string) => void +} + +const FieldAvatar: FC = (props: FieldAvatarProps) => { + const [imgUrl, setImgUrl] = useState('') + useEffect(() => { + if (props.memberInfo) { + setImgUrl(props.memberInfo.photoURL) + } + /* eslint-disable react-hooks/exhaustive-deps */ + }, [props.memberInfo]) + + const [isPhotoEditMode, setIsPhotoEditMode]: [boolean, Dispatch>] + = useState(false) + const [isSaving, setIsSaving]: [boolean, Dispatch>] + = useState(false) + + function handleModifyPhotoModalClose(): void { + setIsPhotoEditMode(false) + } + + async function handleRemovePhoto(): Promise { + setIsSaving(true) + try { + await props.updateMemberPhotoUrl('') + } catch (error) { + } + + setIsSaving(false) + } + + function showEditPhoto(): void { + setIsPhotoEditMode(true) + } + + return ( +
+

Photo

+
+
+ {imgUrl ? ( + avatar + ) : ( + avatar + )} +
+
+ + Make a great first impression to potential customers with a + professional photo that represents your style. + + {imgUrl ? ( +
+ + +
+ ) : ( + + )} +
+
+ + { + isPhotoEditMode && props.memberInfo && ( + + ) + } +
+ ) +} + +export default FieldAvatar diff --git a/src/apps/onboarding/src/components/FieldAvatar/styles.module.scss b/src/apps/onboarding/src/components/FieldAvatar/styles.module.scss new file mode 100644 index 000000000..f8d472596 --- /dev/null +++ b/src/apps/onboarding/src/components/FieldAvatar/styles.module.scss @@ -0,0 +1,36 @@ +.container { + strong { + font-weight: bold; + } + + ul { + list-style: unset; + padding-left: 30px; + } +} + +.blockImg { + width: 120px; + height: 120px; + display: flex; + border-radius: 100%; + overflow: hidden; + border: 3px dashed white; + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.09); + flex-shrink: 0; + + .img { + width: 100%; + height: 100%; + object-fit: contain; + } + + .imgPlaceholder { + width: 144px; + height: 144px; + object-fit: contain; + max-width: none; + margin-left: -15px; + margin-top: -11px; + } +} \ No newline at end of file diff --git a/src/apps/onboarding/src/components/FormField/index.tsx b/src/apps/onboarding/src/components/FormField/index.tsx new file mode 100644 index 000000000..64ead7b7f --- /dev/null +++ b/src/apps/onboarding/src/components/FormField/index.tsx @@ -0,0 +1,53 @@ +/** + * FormField + * + * A Form Field Is a wrapper for input to add the label to it + */ +import React, { FC } from 'react' +import classNames from 'classnames' + +import { IconSolid } from '~/libs/ui' + +import styles from './styles.module.scss' + +interface FormFieldProps { + children?: any + className?: string + label?: string + error?: string +} + +const FormField: FC = (componentProps: FormFieldProps) => { + const { + children, + className, + label, + ...props + }: FormFieldProps = componentProps + + function handleClick(e: any): void { + // focus on input label click + const inputElement: any = e.target.closest('.form-field') + .querySelector('input') + inputElement?.focus() + } + + return ( +
+
+
+ {label} +
+ {children} +
+
+ {props.error ? () : undefined} + {props.error} +
+
+ ) +} + +export default FormField diff --git a/src/apps/onboarding/src/components/FormField/styles.module.scss b/src/apps/onboarding/src/components/FormField/styles.module.scss new file mode 100644 index 000000000..9a277e30b --- /dev/null +++ b/src/apps/onboarding/src/components/FormField/styles.module.scss @@ -0,0 +1,32 @@ +$green1: #137d60; + +.form-field-wrapper { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-start; + margin-bottom: 0; + + .form-field { + border: 1px solid #b7b7b7; + border-radius: 4px; + background: white; + padding-top: 24px; + margin-bottom: 10px; + + .label { + position: absolute; + top: 5px; + left: 15px; + color: $green1; + font-size: 11px; + font-weight: 500; + } + } + + .error { + color: #ec2710; + font-size: 11px; + gap: 4px; + } +} \ No newline at end of file diff --git a/src/apps/onboarding/src/components/InputTextAutoSave/index.tsx b/src/apps/onboarding/src/components/InputTextAutoSave/index.tsx new file mode 100644 index 000000000..bb98db09b --- /dev/null +++ b/src/apps/onboarding/src/components/InputTextAutoSave/index.tsx @@ -0,0 +1,50 @@ +/** + * InputTextAutoSave + * + * A Form Field Is a wrapper for input to add the label to it + */ +import { FC, FocusEvent, useEffect, useState } from 'react' + +import { InputText, InputValue } from '~/libs/ui' +import { InputTextTypes } from '~/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText' + +export interface InputTextProps { + readonly checked?: boolean + readonly className?: string + readonly dirty?: boolean + readonly disabled?: boolean + readonly error?: string + readonly hideInlineErrors?: boolean + readonly hint?: string + readonly label?: string | JSX.Element + readonly name: string + readonly onChange: (value?: string) => void + readonly placeholder?: string + readonly readonly?: boolean + readonly spellCheck?: boolean + readonly tabIndex: number + readonly value?: InputValue + readonly type: InputTextTypes +} + +const InputTextAutoSave: FC = (props: InputTextProps) => { + const [value, setValue] = useState('') + useEffect(() => { + setValue(props.value) + }, [props.value]) + + return ( + ) { + setValue(event.target.value) + }} + onBlur={function onBlur() { + props.onChange(`${value}`) + }} + /> + ) +} + +export default InputTextAutoSave diff --git a/src/apps/onboarding/src/components/InputTextAutoSave/styles.module.scss b/src/apps/onboarding/src/components/InputTextAutoSave/styles.module.scss new file mode 100644 index 000000000..204049cc2 --- /dev/null +++ b/src/apps/onboarding/src/components/InputTextAutoSave/styles.module.scss @@ -0,0 +1,2 @@ +.container { +} \ No newline at end of file diff --git a/src/apps/onboarding/src/components/InputTextareaAutoSave/index.tsx b/src/apps/onboarding/src/components/InputTextareaAutoSave/index.tsx new file mode 100644 index 000000000..6e162560b --- /dev/null +++ b/src/apps/onboarding/src/components/InputTextareaAutoSave/index.tsx @@ -0,0 +1,45 @@ +/** + * InputTextareaAutoSave + * + * A Form Field Is a wrapper for input to add the label to it + */ +import { FC, FocusEvent, useEffect, useState } from 'react' + +import { InputTextarea } from '~/libs/ui' + +export interface InputTextareaProps { + readonly dirty?: boolean + readonly disabled?: boolean + readonly error?: string + readonly hideInlineErrors?: boolean + readonly hint?: string + readonly label?: string + readonly name: string + readonly onChange: (value?: string) => void + readonly placeholder?: string + readonly spellCheck?: boolean + readonly tabIndex: number + readonly value?: string | number +} + +const InputTextareaAutoSave: FC = (props: InputTextareaProps) => { + const [value, setValue] = useState('') + useEffect(() => { + setValue(props.value) + }, [props.value]) + + return ( + ) { + setValue(event.target.value) + }} + onBlur={function onBlur() { + props.onChange(`${value}`) + }} + /> + ) +} + +export default InputTextareaAutoSave diff --git a/src/apps/onboarding/src/components/InputTextareaAutoSave/styles.module.scss b/src/apps/onboarding/src/components/InputTextareaAutoSave/styles.module.scss new file mode 100644 index 000000000..204049cc2 --- /dev/null +++ b/src/apps/onboarding/src/components/InputTextareaAutoSave/styles.module.scss @@ -0,0 +1,2 @@ +.container { +} \ No newline at end of file diff --git a/src/apps/onboarding/src/components/card-item/index.tsx b/src/apps/onboarding/src/components/card-item/index.tsx new file mode 100644 index 000000000..757f67f18 --- /dev/null +++ b/src/apps/onboarding/src/components/card-item/index.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react' +import classNames from 'classnames' + +import { IconOutline } from '~/libs/ui' + +import styles from './styles.module.scss' + +interface CardItemProps { + title: string + subTitle: string + description: string + className?: string + disabled?: boolean + onEdit?: () => void + onDelete?: () => void +} + +export const CardItem: FC = (props: CardItemProps) => ( +
+
+ {props.title} + +
+ + +
+
+ {props.subTitle} + {props.description} +
+) + +export default CardItem diff --git a/src/apps/onboarding/src/components/card-item/styles.module.scss b/src/apps/onboarding/src/components/card-item/styles.module.scss new file mode 100644 index 000000000..ebc49a55e --- /dev/null +++ b/src/apps/onboarding/src/components/card-item/styles.module.scss @@ -0,0 +1,42 @@ +@import '../../../../../libs/ui/lib/styles/includes'; + +.container { + border-radius: 8px; + border: 1px solid $black-20; + padding: 24px; +} + +.textTitle { + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: 24px; +} + +.textSubTitle { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 22px; +} + +.textDescription { + color: $black-60; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 22px; +} + +.blockBtns { + flex-shrink: 0; +} + +.btn { + flex-shrink: 0; + color: $turq-160; + + &:disabled { + opacity: 0.2; + } +} diff --git a/src/apps/onboarding/src/components/modal-add-education/index.tsx b/src/apps/onboarding/src/components/modal-add-education/index.tsx new file mode 100644 index 000000000..5b7cc5bb2 --- /dev/null +++ b/src/apps/onboarding/src/components/modal-add-education/index.tsx @@ -0,0 +1,190 @@ +import { FC, FocusEvent, useEffect, useState } from 'react' +import _ from 'lodash' +import classNames from 'classnames' +import moment from 'moment' + +import { BaseModal, Button, InputText } from '~/libs/ui' + +import DateInput from '../DateInput' +import EducationInfo, { emptyEducationInfo } from '../../models/EducationInfo' +import FormField from '../FormField' + +import styles from './styles.module.scss' + +interface ModalAddEducationProps { + onClose?: () => void + editingEducation?: EducationInfo | null + onAdd?: (educationInfo: EducationInfo) => void + onEdit?: (educationInfo: EducationInfo) => void +} + +const ModalAddEducation: FC = (props: ModalAddEducationProps) => { + const [educationInfo, setEducationInfo] = useState(emptyEducationInfo()) + const [formErrors, setFormErrors] = useState({ + collegeName: undefined, + major: undefined, + startDate: undefined, + }) + + const validateDate: any = (startDate: Date | undefined, endDate: Date | undefined) => { + const isInValid: any = endDate + && startDate + && moment(endDate) + .isSameOrBefore(startDate) + return !isInValid + } + + const validateField: any = () => { + const errorTmp: any = {} + if (!educationInfo.collegeName) { + errorTmp.collegeName = 'Required' + } + + if (!educationInfo.major) { + errorTmp.major = 'Required' + } + + if (!validateDate(educationInfo.startDate, educationInfo.endDate)) { + errorTmp.startDate = 'Start Date should be before End Date' + } + + setFormErrors(errorTmp) + return _.isEmpty(errorTmp) + } + + useEffect(() => { + if (props.editingEducation) { + setEducationInfo(props.editingEducation) + } + }, [props.editingEducation]) + + return ( + + + + + ) +} + +const mapStateToProps: any = (state: any) => { + const { + loadingMemberTraits, + loadingMemberInfo, + address, + connectInfo, + memberInfo, + }: any = state.member + + return { + loadingMemberInfo, + loadingMemberTraits, + reduxAddress: address, + reduxConnectInfo: connectInfo, + reduxMemberInfo: memberInfo, + } +} + +const mapDispatchToProps: any = { + createMemberConnectInfos, + updateMemberConnectInfos, + updateMemberHomeAddresss, +} + +export const PageAccountDetails: any = connect(mapStateToProps, mapDispatchToProps)(PageAccountDetailsContent) + +export default PageAccountDetails diff --git a/src/apps/onboarding/src/pages/account-details/styles.module.scss b/src/apps/onboarding/src/pages/account-details/styles.module.scss new file mode 100644 index 000000000..5803711ff --- /dev/null +++ b/src/apps/onboarding/src/pages/account-details/styles.module.scss @@ -0,0 +1,17 @@ +.container { + min-height: 100%; + + strong { + font-weight: bold; + } +} + +.blockContent { + margin-bottom: 40px; +} + +.ProgressBar { + margin-top: auto; + margin-bottom: 32px; + width: 100%; +} diff --git a/src/apps/onboarding/src/pages/educations/index.tsx b/src/apps/onboarding/src/pages/educations/index.tsx new file mode 100644 index 000000000..c7a95e45c --- /dev/null +++ b/src/apps/onboarding/src/pages/educations/index.tsx @@ -0,0 +1,174 @@ +import { FC, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { connect } from 'react-redux' +import _ from 'lodash' +import classNames from 'classnames' + +import { Button, IconOutline, PageDivider } from '~/libs/ui' + +import { createMemberEducations, updateMemberEducations } from '../../redux/actions/member' +import { ProgressBar } from '../../components/progress-bar' +import CardItem from '../../components/card-item' +import EducationInfo from '../../models/EducationInfo' +import ModalAddEducation from '../../components/modal-add-education' + +import styles from './styles.module.scss' + +export const PageEducationsContent: FC<{ + reduxEducations: EducationInfo[] | undefined + updateMemberEducations: (educations: EducationInfo[]) => void + createMemberEducations: (educations: EducationInfo[]) => void + loadingMemberTraits: boolean +}> = props => { + const navigate: any = useNavigate() + const [editingEducation, setEditingEducation] = useState(undefined) + const [educations, setEducations] = useState(undefined) + const [educationId, setEducationId] = useState(10) + const [showAddEducationModal, setShowAddEducationModal] = useState(false) + const [loading, setLoading] = useState(false) + useEffect(() => { + if (!educations && props.reduxEducations) { + setEducations(props.reduxEducations) + if (props.reduxEducations.length > 0) { + setEducationId(props.reduxEducations[props.reduxEducations.length - 1].id + 1) + } + } + /* eslint-disable react-hooks/exhaustive-deps */ + }, [props.reduxEducations]) + + useEffect(() => { + const saveData: any = async () => { + setLoading(true) + if (!props.reduxEducations) { + await props.createMemberEducations(educations || []) + } else { + await props.updateMemberEducations(educations || []) + } + + setLoading(false) + } + + if (!!educations && !_.isEqual(props.reduxEducations, educations)) { + saveData() + .then(_.noop) + } + /* eslint-disable react-hooks/exhaustive-deps */ + }, [educations]) + + return ( +
+

Education

+ + +
+

Add your education

+ + + Relevant education details will help make your profile more valuable to potential employers. + + + {(educations || []).length > 0 ? ( +
+ {(educations || []).map(education => ( + w.id !== education.id)) + }} + /> + ))} +
+ ) : undefined} + + +
+ + + +
+ +
+ {showAddEducationModal ? ( + (w.id !== editEducation.id ? w : editEducation))) + }} + /> + ) : undefined} +
+ ) +} + +const mapStateToProps: any = (state: any) => { + const { + educations, + loadingMemberTraits, + }: any = state.member + + return { + loadingMemberTraits, + reduxEducations: educations, + } +} + +const mapDispatchToProps: any = { + createMemberEducations, + updateMemberEducations, +} + +export const PageEducations: any = connect(mapStateToProps, mapDispatchToProps)(PageEducationsContent) + +export default PageEducations diff --git a/src/apps/onboarding/src/pages/educations/styles.module.scss b/src/apps/onboarding/src/pages/educations/styles.module.scss new file mode 100644 index 000000000..a3c4f0fd9 --- /dev/null +++ b/src/apps/onboarding/src/pages/educations/styles.module.scss @@ -0,0 +1,17 @@ +.container { + min-height: 100%; +} + +.blockContent { + margin-bottom: 40px; +} + +.ProgressBar { + margin-top: auto; + margin-bottom: 32px; + width: 100%; +} + +.btn:disabled { + opacity: 0.2; +} diff --git a/src/apps/onboarding/src/pages/onboarding/index.tsx b/src/apps/onboarding/src/pages/onboarding/index.tsx new file mode 100644 index 000000000..3b61e9d42 --- /dev/null +++ b/src/apps/onboarding/src/pages/onboarding/index.tsx @@ -0,0 +1,71 @@ +import { FC, useContext, useEffect } from 'react' +import { Outlet, Routes } from 'react-router-dom' +import { connect, Provider } from 'react-redux' +import classNames from 'classnames' + +import { routerContext, RouterContextData } from '~/libs/core' +import { Member } from '~/apps/talent-search/src/lib/models' +import { EnvironmentConfig } from '~/config' + +import { onboardRouteId } from '../../onboarding.routes' +import { fetchMemberInfo, fetchMemberTraits } from '../../redux/actions/member' +import store from '../../redux/store' +import '../../styles/global/_index.scss' + +import styles from './styles.module.scss' + +const OnboardingContent: FC<{ + fetchMemberInfo: () => void + fetchMemberTraits: () => void + reduxMemberInfo: Member | undefined +}> = props => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + useEffect(() => { + props.fetchMemberInfo() + props.fetchMemberTraits() + /* eslint-disable react-hooks/exhaustive-deps */ + }, []) + + return ( + <> +
+ + + {getChildRoutes(onboardRouteId)} + +
+
+ + I will complete this onboarding later, + skip for now + . + + + ) +} + +const mapStateToProps: any = (state: any) => { + const { + memberInfo, + }: any = state.member + + return { + reduxMemberInfo: memberInfo, + } +} + +const mapDispatchToProps: any = { + fetchMemberInfo, + fetchMemberTraits, +} +const Onboarding: any = connect(mapStateToProps, mapDispatchToProps)(OnboardingContent) + +export const OnboardingWrapper: FC<{}> = () => ( +
+ + + +
+) + +export default OnboardingWrapper diff --git a/src/apps/onboarding/src/pages/onboarding/styles.module.scss b/src/apps/onboarding/src/pages/onboarding/styles.module.scss new file mode 100644 index 000000000..ebc62e409 --- /dev/null +++ b/src/apps/onboarding/src/pages/onboarding/styles.module.scss @@ -0,0 +1,53 @@ +@import '@libs/ui/styles/includes'; + +.blockWrapper { + background-color: #EAF6FD; + padding: 64px; + min-height: 100%; + + @include sm { + padding: 0; + } +} + +.container { + padding: 48px; + font-size: 16px; + line-height: 24px; + background-color: white; + width: 768px; + max-width: 100%; + min-height: calc(100% - 88px); + border-radius: 16px; + box-shadow: 0px 8px 24px 0px rgba(144, 144, 144, 0.15); + + h2 { + font-family: $font-barlow; + color: $black-100; + font-size: 34px; + font-style: normal; + font-weight: 600; + line-height: 40px; + text-transform: none; + } + + h3 { + font-family: $font-roboto; + color: $black-100; + font-size: 20px; + font-style: normal; + font-weight: 700; + line-height: 26px; + text-transform: none; + } +} + +.textFooter { + color: $black-80; + margin-top: 64px; + + a { + color: $turq-160; + font-weight: 500; + } +} \ No newline at end of file diff --git a/src/apps/onboarding/src/pages/open-to-work/index.tsx b/src/apps/onboarding/src/pages/open-to-work/index.tsx new file mode 100644 index 000000000..899e235e3 --- /dev/null +++ b/src/apps/onboarding/src/pages/open-to-work/index.tsx @@ -0,0 +1,153 @@ +import { useNavigate } from 'react-router-dom' +import { FC, MutableRefObject, useEffect, useMemo, useRef } from 'react' +import { connect } from 'react-redux' +import classNames from 'classnames' + +import { Button, IconOutline, PageDivider } from '~/libs/ui' +import { FormInputCheckbox } from '~/apps/self-service/src/components/form-elements' + +import { createMemberPersonalizations, updateMemberPersonalizations } from '../../redux/actions/member' +import { ProgressBar } from '../../components/progress-bar' +import { useAutoSavePersonalization, useAutoSavePersonalizationType } from '../../hooks/useAutoSavePersonalization' +import PersonalizationInfo, { emptyPersonalizationInfo } from '../../models/PersonalizationInfo' + +import styles from './styles.module.scss' + +const FormInputCheckboxMiddleware: any = FormInputCheckbox as any + +const blankPersonalizationInfo: PersonalizationInfo = emptyPersonalizationInfo() + +export const PageOpenToWorkContent: FC<{ + reduxPersonalization: PersonalizationInfo | undefined + updateMemberPersonalizations: (infos: PersonalizationInfo[]) => void + createMemberPersonalizations: (infos: PersonalizationInfo[]) => void + loadingMemberTraits: boolean +}> = props => { + const navigate: any = useNavigate() + + const shouldSavingData: MutableRefObject = useRef(false) + const shouldNavigateTo: MutableRefObject = useRef('') + + const { + loading, + personalizationInfo, + setPersonalizationInfo, + }: useAutoSavePersonalizationType = useAutoSavePersonalization( + props.reduxPersonalization, + props.updateMemberPersonalizations, + props.createMemberPersonalizations, + shouldSavingData, + ) + + const availableForGigsValue: boolean | undefined = useMemo(() => { + if (!personalizationInfo || personalizationInfo.availableForGigs === undefined) { + return blankPersonalizationInfo.availableForGigs + } + + return personalizationInfo.availableForGigs + }, [personalizationInfo]) + + useEffect(() => { + if (!loading && !shouldSavingData.current && !!shouldNavigateTo.current) { + navigate(shouldNavigateTo.current) + } + /* eslint-disable react-hooks/exhaustive-deps */ + }, [loading]) + + function checkToNavigateNextPage(pageUrl: string): void { + if (!personalizationInfo || personalizationInfo.availableForGigs === undefined) { + shouldNavigateTo.current = pageUrl + setPersonalizationInfo({ + ...(personalizationInfo || blankPersonalizationInfo), + availableForGigs: blankPersonalizationInfo.availableForGigs, + }) + } else { + navigate(pageUrl) + } + } + + return ( +
+

+ Are you open to work? +

+ +
+
+

Don’t miss work opportunities.

+ + By selecting “Yes, I’m open to work” our customers will know that you are + available for job opportunities. + You will have the option to change this at any time on your member profile. + + +
+ +
+
+
+ + + +
+ +
+
+ ) +} + +const mapStateToProps: any = (state: any) => { + const { + loadingMemberTraits, + personalization, + }: any = state.member + + return { + loadingMemberTraits, + reduxPersonalization: personalization, + } +} + +const mapDispatchToProps: any = { + createMemberPersonalizations, + updateMemberPersonalizations, +} + +export const PageOpenToWork: any = connect(mapStateToProps, mapDispatchToProps)(PageOpenToWorkContent) + +export default PageOpenToWork diff --git a/src/apps/onboarding/src/pages/open-to-work/styles.module.scss b/src/apps/onboarding/src/pages/open-to-work/styles.module.scss new file mode 100644 index 000000000..c30de304f --- /dev/null +++ b/src/apps/onboarding/src/pages/open-to-work/styles.module.scss @@ -0,0 +1,17 @@ +.container { + min-height: 100%; +} + +.blockContent { + margin-bottom: 40px; +} + +.ProgressBar { + margin-top: auto; + margin-bottom: 32px; + width: 100%; +} + +.blockSkilTags { + gap: 10px; +} \ No newline at end of file diff --git a/src/apps/onboarding/src/pages/personalization/index.tsx b/src/apps/onboarding/src/pages/personalization/index.tsx new file mode 100644 index 000000000..b55de90e8 --- /dev/null +++ b/src/apps/onboarding/src/pages/personalization/index.tsx @@ -0,0 +1,173 @@ +import { useNavigate } from 'react-router-dom' +import { connect } from 'react-redux' +import { FC, MutableRefObject, useEffect, useRef } from 'react' +import classNames from 'classnames' + +import { Button, IconOutline, PageDivider } from '~/libs/ui' +import { EnvironmentConfig } from '~/config' + +import { useAutoSavePersonalization, useAutoSavePersonalizationType } from '../../hooks/useAutoSavePersonalization' +import { ProgressBar } from '../../components/progress-bar' +import { + createMemberPersonalizations, + setMemberPhotoUrl, + updateMemberPersonalizations, + updateMemberPhotoUrl, +} from '../../redux/actions/member' +import FieldAvatar from '../../components/FieldAvatar' +import InputTextAutoSave from '../../components/InputTextAutoSave' +import InputTextareaAutoSave from '../../components/InputTextareaAutoSave' +import MemberInfo from '../../models/MemberInfo' +import PersonalizationInfo, { emptyPersonalizationInfo } from '../../models/PersonalizationInfo' + +import styles from './styles.module.scss' + +const blankPersonalizationInfo: PersonalizationInfo = emptyPersonalizationInfo() + +const PagePersonalizationContent: FC<{ + memberInfo?: MemberInfo, + reduxPersonalization: PersonalizationInfo | undefined + updateMemberPersonalizations: (infos: PersonalizationInfo[]) => void + createMemberPersonalizations: (infos: PersonalizationInfo[]) => void + setMemberPhotoUrl: (photoUrl: string) => void + updateMemberPhotoUrl: (photoUrl: string) => void + loadingMemberTraits: boolean +}> = props => { + const navigate: any = useNavigate() + + const shouldSavingData: MutableRefObject = useRef(false) + const shouldNavigateTo: MutableRefObject = useRef('') + + const { + loading, + personalizationInfo, + setPersonalizationInfo, + }: useAutoSavePersonalizationType = useAutoSavePersonalization( + props.reduxPersonalization, + props.updateMemberPersonalizations, + props.createMemberPersonalizations, + shouldSavingData, + ) + + useEffect(() => { + if (!loading && !shouldSavingData.current && !!shouldNavigateTo.current) { + if (shouldNavigateTo.current.startsWith('../')) { + navigate(shouldNavigateTo.current) + } else { + window.location.href = shouldNavigateTo.current + } + } + /* eslint-disable react-hooks/exhaustive-deps */ + }, [loading]) + + return ( +
+

Make it personal

+ +
+ + +

Bio

+ This is where we can really get to know you. + + + +
+ + + +
+ +
+
+ ) +} + +const mapStateToProps: any = (state: any) => { + const { + loadingMemberTraits, + personalization, + memberInfo, + }: any = state.member + + return { + loadingMemberTraits, + memberInfo, + reduxPersonalization: personalization, + } +} + +const mapDispatchToProps: any = { + createMemberPersonalizations, + setMemberPhotoUrl, + updateMemberPersonalizations, + updateMemberPhotoUrl, +} + +export const PagePersonalization: any = connect(mapStateToProps, mapDispatchToProps)(PagePersonalizationContent) + +export default PagePersonalization diff --git a/src/apps/onboarding/src/pages/personalization/styles.module.scss b/src/apps/onboarding/src/pages/personalization/styles.module.scss new file mode 100644 index 000000000..056aad63c --- /dev/null +++ b/src/apps/onboarding/src/pages/personalization/styles.module.scss @@ -0,0 +1,17 @@ +.container { + min-height: 100%; +} + +.blockContent { + margin-bottom: 40px; +} + +.ProgressBar { + margin-top: auto; + margin-bottom: 32px; + width: 100%; +} + +.blockHandleSelect { + max-width: 621px; +} diff --git a/src/apps/onboarding/src/pages/skills/index.tsx b/src/apps/onboarding/src/pages/skills/index.tsx new file mode 100644 index 000000000..8d931d7b3 --- /dev/null +++ b/src/apps/onboarding/src/pages/skills/index.tsx @@ -0,0 +1,87 @@ +import { useNavigate } from 'react-router-dom' +import { FC, useState } from 'react' +import { connect } from 'react-redux' +import classNames from 'classnames' + +import { Button, PageDivider } from '~/libs/ui' +import { Member } from '~/apps/talent-search/src/lib/models' +import { MemberSkillEditor, useMemberSkillEditor } from '~/libs/shared' + +import { ProgressBar } from '../../components/progress-bar' + +import styles from './styles.module.scss' + +export const PageSkillsContent: FC<{ + reduxMemberInfo: Member | undefined +}> = props => { + const navigate: any = useNavigate() + const [loading, setLoading] = useState(false) + const { formInput: emsiFormInput, saveSkills: saveEmsiSkills }: MemberSkillEditor = useMemberSkillEditor() + + async function saveSkills(): Promise { + setLoading(true) + try { + await saveEmsiSkills() + } catch (error) { + } + + setLoading(false) + navigate('../open-to-work') + } + + return ( +
+

+ Welcome + {` ${props.reduxMemberInfo?.firstName || ''}`} + ! +
+ Let’s get acquainted. +

+ +
+
+

What are your skills?

+ + Understanding your skills will allow us to connect you to the right opportunities. + +
+ {emsiFormInput} +
+
+
+ + + +
+ +
+
+ ) +} + +const mapStateToProps: any = (state: any) => { + const { + memberInfo, + }: any = state.member + + return { + reduxMemberInfo: memberInfo, + } +} + +export const PageSkills: any = connect(mapStateToProps, undefined)(PageSkillsContent) + +export default PageSkills diff --git a/src/apps/onboarding/src/pages/skills/styles.module.scss b/src/apps/onboarding/src/pages/skills/styles.module.scss new file mode 100644 index 000000000..c30de304f --- /dev/null +++ b/src/apps/onboarding/src/pages/skills/styles.module.scss @@ -0,0 +1,17 @@ +.container { + min-height: 100%; +} + +.blockContent { + margin-bottom: 40px; +} + +.ProgressBar { + margin-top: auto; + margin-bottom: 32px; + width: 100%; +} + +.blockSkilTags { + gap: 10px; +} \ No newline at end of file diff --git a/src/apps/onboarding/src/pages/start/index.tsx b/src/apps/onboarding/src/pages/start/index.tsx new file mode 100644 index 000000000..6ab589cca --- /dev/null +++ b/src/apps/onboarding/src/pages/start/index.tsx @@ -0,0 +1,81 @@ +import { FC } from 'react' +import { useNavigate } from 'react-router-dom' +import classNames from 'classnames' + +import { Button, PageDivider } from '~/libs/ui' + +import { ProgressBar } from '../../components/progress-bar' + +import styles from './styles.module.scss' + +export const PageStart: FC<{}> = () => { + const navigate: any = useNavigate() + + return ( +
+

It's easy to create an awesome profile!

+ + +
+ + When you share your skills, education and work experience, your Topcoder profile will + help put your best foot forward. + + Let potential employers and others in our network see your exceptional talent! +

How would you like to share your skills and experience?

+ +
+
+ We can extract data from a digital version of your resume. +
+ +
+
+

OR

+
+ You can enter your information manually +
+ +
+
+
+
+ + + +
+ +
+
+ ) +} + +export default PageStart diff --git a/src/apps/onboarding/src/pages/start/styles.module.scss b/src/apps/onboarding/src/pages/start/styles.module.scss new file mode 100644 index 000000000..d9353b612 --- /dev/null +++ b/src/apps/onboarding/src/pages/start/styles.module.scss @@ -0,0 +1,22 @@ +.container { + min-height: 100%; +} + +.blockContent { + margin-bottom: 40px; +} + +.ProgressBar { + margin-top: auto; + margin-bottom: 32px; +} + +.blockImportButtons { + gap: 30px; + margin-top: 30px; +} + +.blockOr { + max-width: 1000px; + gap: 20px; +} diff --git a/src/apps/onboarding/src/pages/works/index.tsx b/src/apps/onboarding/src/pages/works/index.tsx new file mode 100644 index 000000000..7c2ffd213 --- /dev/null +++ b/src/apps/onboarding/src/pages/works/index.tsx @@ -0,0 +1,179 @@ +import { FC, useEffect, useState } from 'react' +import { connect } from 'react-redux' +import { useNavigate } from 'react-router-dom' +import _ from 'lodash' +import classNames from 'classnames' + +import { Button, IconOutline, PageDivider } from '~/libs/ui' + +import { createMemberWorks, updateMemberWorks } from '../../redux/actions/member' +import { ProgressBar } from '../../components/progress-bar' +import CardItem from '../../components/card-item' +import ModalAddWork from '../../components/modal-add-work' +import WorkInfo from '../../models/WorkInfo' + +import styles from './styles.module.scss' + +export const PageWorksContent: FC<{ + reduxWorks: WorkInfo[] | undefined + updateMemberWorks: (works: WorkInfo[]) => void + createMemberWorks: (works: WorkInfo[]) => void + loadingMemberTraits: boolean +}> = props => { + const navigate: any = useNavigate() + const [editingWork, setEditingWork] = useState(undefined) + const [works, setWorks] = useState(undefined) + const [workId, setWorkId] = useState(10) + const [showAddWorkModal, setShowAddWorkModal] = useState(false) + const [loading, setLoading] = useState(false) + useEffect(() => { + if (!works && props.reduxWorks) { + setWorks(props.reduxWorks) + if (props.reduxWorks.length > 0) { + setWorkId(props.reduxWorks[props.reduxWorks.length - 1].id + 1) + } + } + /* eslint-disable react-hooks/exhaustive-deps */ + }, [props.reduxWorks]) + + useEffect(() => { + const saveData: any = async () => { + setLoading(true) + if (!props.reduxWorks) { + await props.createMemberWorks(works || []) + } else { + await props.updateMemberWorks(works || []) + } + + setLoading(false) + } + + if (!!works && !_.isEqual(props.reduxWorks, works)) { + saveData() + .then(_.noop) + } + /* eslint-disable react-hooks/exhaustive-deps */ + }, [works]) + + return ( +
+

Show us what you have done!

+ + +
+
+

Add your experience

+ + + Add details for career experiences that demonstrate your abilities. + + + {(works || []).length > 0 ? ( +
+ {(works || []).map(work => ( + w.id !== work.id)) + }} + /> + ))} +
+ ) : undefined} + + +
+
+ + + +
+ +
+ {showAddWorkModal ? ( + (w.id !== editWork.id ? w : editWork)), + ) + }} + /> + ) : undefined} +
+ ) +} + +const mapStateToProps: any = (state: any) => { + const { + loadingMemberTraits, + works, + }: any = state.member + + return { + loadingMemberTraits, + reduxWorks: works, + } +} + +const mapDispatchToProps: any = { + createMemberWorks, + updateMemberWorks, +} + +export const PageWorks: any = connect(mapStateToProps, mapDispatchToProps)(PageWorksContent) + +export default PageWorks diff --git a/src/apps/onboarding/src/pages/works/styles.module.scss b/src/apps/onboarding/src/pages/works/styles.module.scss new file mode 100644 index 000000000..a22a37a44 --- /dev/null +++ b/src/apps/onboarding/src/pages/works/styles.module.scss @@ -0,0 +1,13 @@ +.container { + min-height: 100%; +} + +.blockContent { + margin-bottom: 40px; +} + +.ProgressBar { + margin-top: auto; + width: 100%; + margin-bottom: 32px; +} diff --git a/src/apps/onboarding/src/redux/actions/member.ts b/src/apps/onboarding/src/redux/actions/member.ts new file mode 100644 index 000000000..2965a59d0 --- /dev/null +++ b/src/apps/onboarding/src/redux/actions/member.ts @@ -0,0 +1,446 @@ +import _ from 'lodash' +import moment from 'moment' + +import { TokenModel } from '~/libs/core' +import { getAsync as getAsyncToken } from '~/libs/core/lib/auth/token-functions/token.functions' +import { + createMemberTraits, + updateMemberTraits, +} from '~/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store' + +import { ACTIONS } from '../../config' +import { getMemberInfo, getMemberTraits, putMemberInfo } from '../../services/members' +import ConnectInfo from '../../models/ConnectInfo' +import EducationInfo from '../../models/EducationInfo' +import MemberAddress from '../../models/MemberAddress' +import MemberInfo from '../../models/MemberInfo' +import PersonalizationInfo from '../../models/PersonalizationInfo' +import WorkInfo from '../../models/WorkInfo' + +export const updateMemberInfo: any = (memberInfo: MemberInfo) => ({ + payload: memberInfo, + type: ACTIONS.MEMBER.GET_MEMBER, +}) + +export const updateWorks: any = (works: WorkInfo[]) => ({ + payload: works, + type: ACTIONS.MEMBER.SET_WORKS, +}) + +export const updateEducations: any = (educations: EducationInfo[]) => ({ + payload: educations, + type: ACTIONS.MEMBER.SET_EDUCATIONS, +}) + +export const updatePersonalization: any = (personalization: PersonalizationInfo) => ({ + payload: personalization, + type: ACTIONS.MEMBER.SET_PERSONALIZATION, +}) + +export const updateConnectInfo: any = (connectInfo: ConnectInfo) => ({ + payload: connectInfo, + type: ACTIONS.MEMBER.SET_CONNECT_INFO, +}) + +export const updateAddress: any = (address: MemberAddress) => ({ + payload: address, + type: ACTIONS.MEMBER.SET_ADDRESS, +}) + +export const updateLoadingMemberTraits: any = (loading: boolean) => ({ + payload: loading, + type: ACTIONS.MEMBER.SET_LOADING_MEMBER_TRAITS, +}) + +export const updateLoadingMemberInfo: any = (loading: boolean) => ({ + payload: loading, + type: ACTIONS.MEMBER.SET_LOADING_MEMBER_INFO, +}) +export const fetchMemberInfo: any = () => async (dispatch: any) => { + let tokenInfo: TokenModel + let memberInfo: MemberInfo | undefined + dispatch(updateLoadingMemberInfo(true)) + try { + tokenInfo = await getAsyncToken() + memberInfo = await getMemberInfo(tokenInfo.handle || '') + } catch (error) { + } + + dispatch(updateLoadingMemberInfo(false)) + if (memberInfo) { + dispatch(updateMemberInfo(memberInfo)) + + if (memberInfo.addresses) { + const addresses: MemberAddress[] = memberInfo.addresses.map(address => ({ + ...address, + city: address.city, + stateCode: address.stateCode, + streetAddr1: address.streetAddr1, + streetAddr2: address.streetAddr2, + zip: address.zip, + })) + const matchAddress: MemberAddress = _.find(addresses, { type: 'HOME' }) as MemberAddress + if (matchAddress) { + dispatch(updateAddress(matchAddress)) + } + } + } +} + +const dateTimeToDate: any = (s: string) => (s ? new Date(s) : undefined) +export const fetchMemberTraits: any = () => async (dispatch: any) => { + const tokenInfo: TokenModel = await getAsyncToken() + let memberTraits: any = [] + dispatch(updateLoadingMemberTraits(true)) + try { + memberTraits = await getMemberTraits(tokenInfo.handle || '') + } catch (error) { + } + + dispatch(updateLoadingMemberTraits(false)) + + const workExp: any = memberTraits.find((t: any) => t.traitId === 'work') + const workExpValue: any = workExp?.traits?.data + if (workExpValue) { + // workExpValue is array of works. fill it to state + const works: WorkInfo[] = workExpValue.map((j: any, index: number) => { + const startDate: Date | undefined = dateTimeToDate(j.timePeriodFrom) + const endDate: Date | undefined = dateTimeToDate(j.timePeriodTo) + let endDateString: string = endDate ? moment(endDate) + .format('YYYY') : '' + if (j.working) { + endDateString = 'current' + } + + let startDateString: string = startDate ? moment(startDate) + .format('YYYY') : '' + if (startDateString) { + startDateString += '-' + } + + const dateDescription: string = ( + startDate || endDate + ) ? `${startDateString}${endDateString}` : '' + return ({ + city: j.cityTown, + company: j.company, + currentlyWorking: j.working, + dateDescription, + endDate, + id: index + 1, + industry: j.industry, + position: j.position, + startDate, + }) + }) + dispatch(updateWorks(works)) + } + + const educationExp: any = memberTraits.find( + (t: any) => t.traitId === 'education', + ) + const educationExpValue: any = educationExp?.traits?.data + if (educationExpValue) { + // educationExpValue is array of educations. fill it to state + const educations: EducationInfo[] = educationExpValue.map((e: any, index: number) => { + const startDate: Date | undefined = dateTimeToDate(e.timePeriodFrom) + const endDate: Date | undefined = dateTimeToDate(e.timePeriodTo) + const endDateString: string = endDate ? moment(endDate) + .format('YYYY') : '' + + let startDateString: string = startDate ? moment(startDate) + .format('YYYY') : '' + if (startDateString && endDateString) { + startDateString += '-' + } + + const dateDescription: string = ( + startDate || endDate + ) ? `${startDateString}${endDateString}` : '' + return ({ + collegeName: e.schoolCollegeName, + dateDescription, + endDate, + id: index + 1, + major: e.major, + startDate, + }) + }) + dispatch(updateEducations(educations)) + } + + const personalizationExp: any = memberTraits.find( + (t: any) => t.traitId === 'personalization', + ) + const personalizationExpValue: any = personalizationExp?.traits?.data + if (personalizationExpValue) { + const personalizations: PersonalizationInfo[] = personalizationExpValue.map((e: any) => ({ + ...e, + availableForGigs: e.availableForGigs, + profileSelfTitle: e.profileSelfTitle, + referAs: e.referAs, + shortBio: e.shortBio, + })) + dispatch(updatePersonalization(personalizations[0])) + } + + const connectInfoExp: any = memberTraits.find( + (t: any) => t.traitId === 'connect_info', + ) + const connectInfoExpValue: any = connectInfoExp?.traits?.data + if (connectInfoExpValue) { + const connectInfos: ConnectInfo[] = connectInfoExpValue.map((e: any) => ({ + ...e, + country: e.country, + phoneNumber: e.phoneNumber, + })) + dispatch(updateConnectInfo(connectInfos[0])) + } +} + +const createWorksPayloadData: any = (works: WorkInfo[]) => { + const data: any = works.map(work => { + const { + company, + position, + industry, + city, + startDate, + endDate, + currentlyWorking, + }: any = work + return { + cityTown: city, + company, + industry, + position, + timePeriodFrom: startDate ? startDate.toISOString() : '', + timePeriodTo: endDate ? endDate.toISOString() : '', + working: currentlyWorking, + } + }) + + const payload: any = { + categoryName: 'Work', + traitId: 'work', + traits: { + data, + }, + } + return [payload] +} + +export const updateMemberWorks: any = (works: WorkInfo[]) => async (dispatch: any) => { + try { + const tokenInfo: TokenModel = await getAsyncToken() + + await updateMemberTraits(tokenInfo.handle || '', createWorksPayloadData(works)) + dispatch(updateWorks(works)) + } catch (error) { + } +} + +export const createMemberWorks: any = (works: WorkInfo[]) => async (dispatch: any) => { + let isCreatedSuccess = false + try { + const tokenInfo: TokenModel = await getAsyncToken() + + await createMemberTraits(tokenInfo.handle || '', createWorksPayloadData(works)) + isCreatedSuccess = true + dispatch(updateWorks(works)) + } catch (error) { + } + + if (!isCreatedSuccess) { + await dispatch(updateMemberWorks(works)) + } +} + +const createEducationsPayloadData: any = (educations: EducationInfo[]) => { + const data: any = educations.map(education => { + const { + collegeName, + major, + startDate, + endDate, + }: any = education + return { + major, + schoolCollegeName: collegeName, + timePeriodFrom: startDate ? startDate.toISOString() : '', + timePeriodTo: endDate ? endDate.toISOString() : '', + } + }) + + const payload: any = { + categoryName: 'Education', + traitId: 'education', + traits: { + data, + }, + } + return [payload] +} + +export const updateMemberEducations: any = (educations: EducationInfo[]) => async (dispatch: any) => { + try { + const tokenInfo: TokenModel = await getAsyncToken() + + await updateMemberTraits(tokenInfo.handle || '', createEducationsPayloadData(educations)) + dispatch(updateEducations(educations)) + } catch (error) { + } +} + +export const createMemberEducations: any = (educations: EducationInfo[]) => async (dispatch: any) => { + let isCreatedSuccess = false + try { + const tokenInfo: TokenModel = await getAsyncToken() + + await createMemberTraits(tokenInfo.handle || '', createEducationsPayloadData(educations)) + isCreatedSuccess = true + dispatch(updateEducations(educations)) + } catch (error) { + } + + if (!isCreatedSuccess) { + await dispatch(updateMemberEducations(educations)) + } +} + +const createPersonalizationsPayloadData: any = (personalizations: PersonalizationInfo[]) => { + const data: any = personalizations.map(personalization => { + const { + referAs, + profileSelfTitle, + shortBio, + availableForGigs, + }: any = personalization + return { + ...personalization, + availableForGigs, + profileSelfTitle, + referAs, + shortBio, + } + }) + + const payload: any = { + categoryName: 'Personalization', + traitId: 'personalization', + traits: { + data, + }, + } + return [payload] +} + +export const updateMemberPersonalizations: any = (personalizations: PersonalizationInfo[]) => async (dispatch: any) => { + try { + const tokenInfo: TokenModel = await getAsyncToken() + + await updateMemberTraits(tokenInfo.handle || '', createPersonalizationsPayloadData(personalizations)) + dispatch(updatePersonalization(personalizations[0])) + } catch (error) { + } +} + +export const createMemberPersonalizations: any = (personalizations: PersonalizationInfo[]) => async (dispatch: any) => { + let isCreatedSuccess = false + try { + const tokenInfo: TokenModel = await getAsyncToken() + + await createMemberTraits(tokenInfo.handle || '', createPersonalizationsPayloadData(personalizations)) + isCreatedSuccess = true + dispatch(updatePersonalization(personalizations[0])) + } catch (error) { + } + + if (!isCreatedSuccess) { + await dispatch(updateMemberPersonalizations(personalizations)) + } +} + +const createConnectInfosPayloadData: any = (connectInfos: ConnectInfo[]) => { + const data: any = connectInfos.map(connectInfo => { + const { + country, + phoneNumber, + }: any = connectInfo + return { + ...connectInfo, + country, + phoneNumber, + } + }) + + const payload: any = { + categoryName: 'Connect User Information', + traitId: 'connect_info', + traits: { + data, + }, + } + return [payload] +} + +export const updateMemberConnectInfos: any = (connectInfos: ConnectInfo[]) => async (dispatch: any) => { + try { + const tokenInfo: TokenModel = await getAsyncToken() + + await updateMemberTraits(tokenInfo.handle || '', createConnectInfosPayloadData(connectInfos)) + dispatch(updateConnectInfo(connectInfos[0])) + } catch (error) { + } +} + +export const createMemberConnectInfos: any = (connectInfos: ConnectInfo[]) => async (dispatch: any) => { + let isCreatedSuccess = false + try { + const tokenInfo: TokenModel = await getAsyncToken() + + await createMemberTraits(tokenInfo.handle || '', createConnectInfosPayloadData(connectInfos)) + isCreatedSuccess = true + dispatch(updateConnectInfo(connectInfos[0])) + } catch (error) { + } + + if (!isCreatedSuccess) { + await dispatch(updateMemberConnectInfos(connectInfos)) + } +} + +export const setMemberPhotoUrl: any = (photoUrl: string) => ({ + payload: photoUrl, + type: ACTIONS.MEMBER.UPDATE_MEMBER_PHOTO_URL, +}) + +export const updateMemberHomeAddresss: any = (addresses: MemberAddress[]) => async (dispatch: any) => { + try { + const tokenInfo: TokenModel = await getAsyncToken() + await putMemberInfo(tokenInfo.handle || '', { + addresses: addresses.map(address => ({ + ...address, + city: address.city, + stateCode: address.stateCode, + streetAddr1: address.streetAddr1, + streetAddr2: address.streetAddr2, + type: 'HOME', + zip: address.zip, + })), + }) + dispatch(updateAddress(addresses[0])) + } catch (error) { + } +} + +export const updateMemberPhotoUrl: any = (photoURL: string) => async (dispatch: any) => { + try { + const tokenInfo: TokenModel = await getAsyncToken() + await putMemberInfo(tokenInfo.handle || '', { + photoURL, + }) + dispatch(setMemberPhotoUrl(photoURL)) + } catch (error) { + } +} diff --git a/src/apps/onboarding/src/redux/reducers/index.ts b/src/apps/onboarding/src/redux/reducers/index.ts new file mode 100644 index 000000000..87392cd93 --- /dev/null +++ b/src/apps/onboarding/src/redux/reducers/index.ts @@ -0,0 +1,13 @@ +/** + * Root Redux Reducer + */ +import { combineReducers } from 'redux' + +import memberReducer from './member' + +// redux root reducer +const rootReducer: any = combineReducers({ + member: memberReducer, +}) + +export default rootReducer diff --git a/src/apps/onboarding/src/redux/reducers/member.ts b/src/apps/onboarding/src/redux/reducers/member.ts new file mode 100644 index 000000000..630389a23 --- /dev/null +++ b/src/apps/onboarding/src/redux/reducers/member.ts @@ -0,0 +1,90 @@ +import { Member } from '~/apps/talent-search/src/lib/models' + +import { ACTIONS } from '../../config' +import ConnectInfo from '../../models/ConnectInfo' +import EducationInfo from '../../models/EducationInfo' +import MemberAddress from '../../models/MemberAddress' +import PersonalizationInfo from '../../models/PersonalizationInfo' +import WorkInfo from '../../models/WorkInfo' + +const initialState: { + memberInfo?: Member + works?: WorkInfo[] + educations?: EducationInfo[] + personalization?: PersonalizationInfo + address?: MemberAddress + connectInfo?: ConnectInfo + loadingMemberTraits?: boolean + loadingMemberInfo?: boolean +} = { +} + +const memberReducer: any = ( + state = initialState, + action: { type: any; payload: any; } = { + payload: undefined, + type: '', + }, +) => { + switch (action.type) { + case ACTIONS.MEMBER.GET_MEMBER: + return { + ...state, + memberInfo: action.payload, + } + case ACTIONS.MEMBER.SET_WORKS: + return { + ...state, + works: action.payload, + } + case ACTIONS.MEMBER.SET_PERSONALIZATION: + return { + ...state, + personalization: action.payload, + } + case ACTIONS.MEMBER.SET_ADDRESS: + return { + ...state, + address: action.payload, + } + case ACTIONS.MEMBER.SET_CONNECT_INFO: + return { + ...state, + connectInfo: action.payload, + } + case ACTIONS.MEMBER.SET_LOADING_MEMBER_TRAITS: + return { + ...state, + loadingMemberTraits: action.payload, + } + case ACTIONS.MEMBER.SET_LOADING_MEMBER_INFO: + return { + ...state, + loadingMemberInfo: action.payload, + } + case ACTIONS.MEMBER.SET_EDUCATIONS: + return { + ...state, + educations: action.payload, + } + + case ACTIONS.MEMBER.UPDATE_MEMBER_PHOTO_URL: { + if (!state.memberInfo) { + return state + } + + return { + ...state, + memberInfo: { + ...state.memberInfo, + photoURL: action.payload, + }, + } + } + + default: + return state + } +} + +export default memberReducer diff --git a/src/apps/onboarding/src/redux/store.ts b/src/apps/onboarding/src/redux/store.ts new file mode 100644 index 000000000..742c7b3e7 --- /dev/null +++ b/src/apps/onboarding/src/redux/store.ts @@ -0,0 +1,29 @@ +/** + * Configure Redux Store + */ +import { applyMiddleware, compose, createStore } from 'redux' +import { createPromise } from 'redux-promise-middleware' +import thunk from 'redux-thunk' + +import rootReducer from './reducers' + +const middlewares: any = [ + // if payload of action is promise it would split action into 3 states + createPromise({ + promiseTypeSuffixes: ['PENDING', 'SUCCESS', 'ERROR'], + }), + thunk, +] + +// enable Redux Logger in in DEV environment +if (process.env.APPMODE !== 'production') { + /* eslint-disable-next-line global-require, @typescript-eslint/no-var-requires */ + const { createLogger }: any = require('redux-logger') + + const logger: any = createLogger() + middlewares.push(logger) +} + +const store: any = createStore(rootReducer, compose(applyMiddleware(...middlewares))) + +export default store diff --git a/src/apps/onboarding/src/services/members.ts b/src/apps/onboarding/src/services/members.ts new file mode 100644 index 000000000..74356b83a --- /dev/null +++ b/src/apps/onboarding/src/services/members.ts @@ -0,0 +1,16 @@ +import { getAsync, putAsync } from '~/libs/core/lib/xhr/xhr-functions/xhr.functions' +import { profile } from '~/libs/core/lib/profile/profile-functions/profile-store/profile-endpoint.config' + +import MemberInfo from '../models/MemberInfo' + +export async function getMemberInfo(handle: string): Promise { + return getAsync(profile(handle)) +} + +export async function getMemberTraits(handle: string): Promise { + return getAsync(`${profile(handle)}/traits`) +} + +export async function putMemberInfo(handle: string, data: any): Promise { + return putAsync(profile(handle || ''), data) +} diff --git a/src/apps/onboarding/src/styles/global/_color.scss b/src/apps/onboarding/src/styles/global/_color.scss new file mode 100644 index 000000000..2ebc44d1d --- /dev/null +++ b/src/apps/onboarding/src/styles/global/_color.scss @@ -0,0 +1,7 @@ +.color-black-80 { + color: $black-80; +} + +.color-black-60 { + color: $black-60; +} diff --git a/src/apps/onboarding/src/styles/global/_flex.scss b/src/apps/onboarding/src/styles/global/_flex.scss new file mode 100644 index 000000000..8fa521057 --- /dev/null +++ b/src/apps/onboarding/src/styles/global/_flex.scss @@ -0,0 +1,89 @@ +.flex-1 { + flex: 1 1 0%; +} +.flex-none { + flex: none; +} +.gap-8 { + gap: 8px; +} +.gap-16 { + gap: 16px; +} +.gap-20 { + gap: 20px; +} +.gap-30 { + gap: 30px; +} +.gap-50 { + gap: 50px; +} +.gap-100 { + gap: 100px; +} +.d-flex { + display: flex; + + &.flex-wrap { + flex-wrap: wrap; + } + + &.align-items- { + ¢er { + align-items: center; + } + + &stretch { + align-items: stretch; + } + + &end { + align-items: flex-end; + } + + &start { + align-items: flex-start; + } + } + + .align-self- { + ¢er { + align-self: center; + } + + &start { + align-self: flex-start; + } + + &end { + align-self: flex-end; + } + } + + &.justify-content- { + ¢er { + justify-content: center; + } + + &end { + justify-content: flex-end; + } + + &between { + justify-content: space-between; + } + + &around { + justify-content: space-around; + } + } + + &.flex-column { + flex-direction: column; + } + + &.flex-row { + flex-direction: row; + } +} diff --git a/src/apps/onboarding/src/styles/global/_grid.scss b/src/apps/onboarding/src/styles/global/_grid.scss new file mode 100644 index 000000000..292cf4f15 --- /dev/null +++ b/src/apps/onboarding/src/styles/global/_grid.scss @@ -0,0 +1,15 @@ +.d-grid { + display: grid; +} + +.grid-2-column { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.gap-column-16 { + column-gap: 16px; +} + +.gap-row-8 { + row-gap: 8px; +} \ No newline at end of file diff --git a/src/apps/onboarding/src/styles/global/_index.scss b/src/apps/onboarding/src/styles/global/_index.scss new file mode 100644 index 000000000..5e9ce5dac --- /dev/null +++ b/src/apps/onboarding/src/styles/global/_index.scss @@ -0,0 +1,14 @@ +@import '@libs/ui/styles/includes'; +@import "flex"; +@import "grid"; +@import "layout"; +@import "color"; +@import "text"; + +.react-datepicker-popper { + z-index: 9999 !important; +} + +.full-width { + width: 100%; +} \ No newline at end of file diff --git a/src/apps/onboarding/src/styles/global/_layout.scss b/src/apps/onboarding/src/styles/global/_layout.scss new file mode 100644 index 000000000..4b2e4f677 --- /dev/null +++ b/src/apps/onboarding/src/styles/global/_layout.scss @@ -0,0 +1,31 @@ +.mt-30 { + margin-top: 30px; +} + +.mt-8 { + margin-top: 8px; +} + +.mt-16 { + margin-top: 16px; +} + +.mt-24 { + margin-top: 24px; +} + +.mt-26 { + margin-top: 26px; +} + +.mt-48 { + margin-top: 48px; +} + +.mb-30 { + margin-bottom: 30px; +} + +.mb-24 { + margin-bottom: 24px; +} diff --git a/src/apps/onboarding/src/styles/global/_text.scss b/src/apps/onboarding/src/styles/global/_text.scss new file mode 100644 index 000000000..fa92e2448 --- /dev/null +++ b/src/apps/onboarding/src/styles/global/_text.scss @@ -0,0 +1,3 @@ +.text-bold { + font-weight: 700; +} \ No newline at end of file diff --git a/src/apps/onboarding/src/utils/validation.ts b/src/apps/onboarding/src/utils/validation.ts new file mode 100644 index 000000000..2edde9c8f --- /dev/null +++ b/src/apps/onboarding/src/utils/validation.ts @@ -0,0 +1,11 @@ +export const validatePhonenumber: (phone: string) => boolean = (phone: string) => { + if (!phone) { + return true + } + + if (/[- +()0-9]+/.test(phone)) { + return true + } + + return false +} diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index ff88720ae..eb69e5806 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -8,6 +8,7 @@ import { selfServiceRoutes } from '~/apps/self-service' import { profilesRoutes } from '~/apps/profiles' import { talentSearchRoutes } from '~/apps/talent-search' import { accountsRoutes } from '~/apps/accounts' +import { onboardingRoutes } from '~/apps/onboarding' const Home: LazyLoadedComponent = lazyLoad(() => import('./routes/home'), 'HomePage') @@ -24,6 +25,7 @@ export const platformRoutes: Array = [ // is determined by finding the first route // that matches the current path ...selfServiceRoutes, + ...onboardingRoutes, ...devCenterRoutes, // ...earnRoutes, ...learnRoutes, diff --git a/src/apps/self-service/src/components/radio-button/RadioButton.jsx b/src/apps/self-service/src/components/radio-button/RadioButton.jsx index 8cfa5c3b7..4325b7196 100644 --- a/src/apps/self-service/src/components/radio-button/RadioButton.jsx +++ b/src/apps/self-service/src/components/radio-button/RadioButton.jsx @@ -9,7 +9,7 @@ import React, { useEffect, useState } from "react"; import styles from "./styles.module.scss"; import classNames from "classnames"; -function RadioButton({ options, onChange, size, errorMsg }) { +function RadioButton({ options, onChange, size, errorMsg, disabled }) { const [internalOptions, setInternalOptions] = useState( (options || []).map((o, i) => ({ ...o, key: i })) ); @@ -57,6 +57,7 @@ function RadioButton({ options, onChange, size, errorMsg }) { { const newOptions = internalOptions.map((oWithKeyTmp) => ({ ...oWithKeyTmp, @@ -81,6 +82,7 @@ RadioButton.defaultProps = { onChange: () => { }, size: "sm", errorMsg: "", + disabled: false, }; RadioButton.propTypes = { @@ -93,6 +95,7 @@ RadioButton.propTypes = { onChange: PT.func, size: PT.oneOf(["xs", "sm", "lg"]), errorMsg: PT.string, + disabled: PT.bool, }; export default RadioButton; diff --git a/src/apps/self-service/src/components/radio-button/styles.module.scss b/src/apps/self-service/src/components/radio-button/styles.module.scss index 6621e1400..0db4319c5 100644 --- a/src/apps/self-service/src/components/radio-button/styles.module.scss +++ b/src/apps/self-service/src/components/radio-button/styles.module.scss @@ -62,6 +62,12 @@ opacity: 0; cursor: pointer; + &:disabled { + &~.checkmark { + opacity: 0.2; + } + } + /* When the radio button is checked, add a blue background */ &:checked~.checkmark { background-color: $gui-kit-level-2; diff --git a/src/config/constants.ts b/src/config/constants.ts index f6f25100c..0d6d7858f 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -17,6 +17,7 @@ export enum ToolTitle { profiles = 'Profiles', tcAcademy = 'Topcoder Academy', selfService = 'Self Service Challenges', + onboarding = ' ', talentSearch = 'Talent Search' } diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index 9e728c9c4..ed9683888 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -13,6 +13,7 @@ export const TC_DOMAIN: string = get({ export const TOPCODER_URL: string = `https://www.${TC_DOMAIN}` export const PLATFORMUI_URL: string = `https://platform-ui.${TC_DOMAIN}` +export const USER_PROFILE_URL: string = `https://profiles.${TC_DOMAIN}` export const API = { V1: `https://api.${TC_DOMAIN}/v1`, diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts index db79f75bb..adcc3d426 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -2,6 +2,7 @@ export interface GlobalConfig { TC_DOMAIN: string TOPCODER_URL: string PLATFORMUI_URL: string + USER_PROFILE_URL: string API: { V1: string V3: string diff --git a/src/libs/shared/lib/components/index.ts b/src/libs/shared/lib/components/index.ts index 732eb1045..e3a4f8034 100644 --- a/src/libs/shared/lib/components/index.ts +++ b/src/libs/shared/lib/components/index.ts @@ -1,2 +1,3 @@ export * from './contact-support-form' export * from './modals' +export * from './member-skill-editor' diff --git a/src/libs/shared/lib/components/input-skill-selector/InputSkillSelector.tsx b/src/libs/shared/lib/components/input-skill-selector/InputSkillSelector.tsx index 78f0800ed..9b206b30e 100644 --- a/src/libs/shared/lib/components/input-skill-selector/InputSkillSelector.tsx +++ b/src/libs/shared/lib/components/input-skill-selector/InputSkillSelector.tsx @@ -1,9 +1,16 @@ import { ChangeEvent, FC } from 'react' import { noop } from 'lodash' -import { InputMultiselect } from '~/libs/ui' +import { InputMultiselect, InputMultiselectOption } from '~/libs/ui' -import { autoCompleteSkills } from '../../services/emsi-skills' +import { autoCompleteSkills, EmsiSkill, EmsiSkillSources } from '../../services/emsi-skills' + +const mapEmsiSkillToInputOption = (s: EmsiSkill): InputMultiselectOption => ({ + ...s, + label: s.name, + value: s.skillId, + verified: s.skillSources.includes(EmsiSkillSources.challengeWin), +}) interface Option { label: string @@ -21,6 +28,8 @@ const fetchSkills = (queryTerm: string): Promise => ( ) interface InputSkillSelectorProps { + readonly loading?: boolean + readonly value?: EmsiSkill[] readonly onChange?: (event: ChangeEvent) => void } @@ -31,6 +40,8 @@ const InputSkillSelector: FC = props => ( onFetchOptions={fetchSkills} name='skills' onChange={props.onChange ?? noop} + value={props.value?.map(mapEmsiSkillToInputOption)} + loading={props.loading} /> ) diff --git a/src/libs/shared/lib/components/member-skill-editor/index.ts b/src/libs/shared/lib/components/member-skill-editor/index.ts new file mode 100644 index 000000000..3e9f3be1a --- /dev/null +++ b/src/libs/shared/lib/components/member-skill-editor/index.ts @@ -0,0 +1 @@ +export * from './use-member-skill-editor' diff --git a/src/libs/shared/lib/components/member-skill-editor/use-member-skill-editor.tsx b/src/libs/shared/lib/components/member-skill-editor/use-member-skill-editor.tsx new file mode 100644 index 000000000..5e9cf2ee6 --- /dev/null +++ b/src/libs/shared/lib/components/member-skill-editor/use-member-skill-editor.tsx @@ -0,0 +1,130 @@ +import { ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { differenceWith } from 'lodash' + +import { profileContext, ProfileContextData } from '~/libs/core' + +import { + createMemberEmsiSkills, + EmsiSkill, + EmsiSkillSources, + fetchMemberSkills, + updateMemberEmsiSkills, +} from '../../services/emsi-skills' +import { InputSkillSelector } from '../input-skill-selector' + +export interface MemberSkillEditor { + formInput: ReactNode + saveSkills: () => Promise, +} + +/** + * Hook to provide functionality for using the member skill editor + * Usage example: + * ``` + * const { formInput: emsiFormInput, saveSkills: saveEmsiSkills }: MemberSkillEditor = useMemberSkillEditor() + * ... + * <> + * {emsiFormInput} + * + * + * ``` + * @returns + */ + +export const useMemberSkillEditor = (): MemberSkillEditor => { + const { profile }: ProfileContextData = useContext(profileContext) + const [isEmsiInitialized, setIsEmsiInitialized] = useState(false) + const [skills, setSkills] = useState([]) + const [loading, setLoading] = useState(true) + const [, setError] = useState() + + // Function that saves the updated emsi skills, will be called from outside + const saveSkills = useCallback(async () => { + if (!profile?.userId) { + return + } + + const emsiSkills = skills.map(s => ({ emsiId: s.skillId, name: s.name, sources: s.skillSources })) + if (!isEmsiInitialized) { + await createMemberEmsiSkills(profile.userId, emsiSkills) + setIsEmsiInitialized(true) + return + } + + updateMemberEmsiSkills(profile.userId, emsiSkills) + }, [isEmsiInitialized, profile?.userId, skills]) + + // Handle user changes + + const handleRemoveSkill = useCallback((skillId: string): void => { + const skill = skills.find(s => s.skillId === skillId) + if (!skill) { + return + } + + if (skill.skillSources.includes(EmsiSkillSources.challengeWin)) { + return + } + + setSkills(skills.filter(s => s.skillId !== skillId)) + }, [skills]) + + const handleAddSkill = useCallback((skillData: any): void => { + if (skills.find(s => s.skillId === skillData.value)) { + return + } + + setSkills([...skills, { + name: skillData.label, + skillId: skillData.value, + skillSources: [EmsiSkillSources.selfPicked], + }]) + }, [skills]) + + const handleOnChange = useCallback(({ target: { value } }: any): void => { + const removed = differenceWith(skills, value, (s, v: any) => s.skillId === v.value) + if (removed.length) { + removed.map(s => handleRemoveSkill(s.skillId)) + } + + const added = differenceWith(value, skills, (v: any, s: any) => v.value === s.skillId) + if (added.length) { + added.forEach(handleAddSkill) + } + }, [handleAddSkill, handleRemoveSkill, skills]) + + // Load member's emsi skills, set loading state & isEmsiInitialized + useEffect(() => { + if (!profile?.userId) { + return undefined + } + + let mounted = true + fetchMemberSkills(profile.userId) + .catch(e => { + setError(e?.message ?? e) + return [] + }) + .then(emsiSkills => { + if (!mounted) { + return + } + + setIsEmsiInitialized(emsiSkills?.length > 0) + setSkills(emsiSkills) + setLoading(false) + }) + + return () => { mounted = false } + }, [profile?.userId]) + + // build the form input + const formInput = useMemo(() => ( + + ), [skills, handleOnChange, loading]) + + return { + formInput, + saveSkills, + } +} diff --git a/src/libs/shared/lib/services/emsi-skills/emsi-skills.service.ts b/src/libs/shared/lib/services/emsi-skills/emsi-skills.service.ts index 0adc93c5d..835fcd9a3 100644 --- a/src/libs/shared/lib/services/emsi-skills/emsi-skills.service.ts +++ b/src/libs/shared/lib/services/emsi-skills/emsi-skills.service.ts @@ -1,8 +1,25 @@ import { EnvironmentConfig } from '~/config' -import { xhrGetAsync } from '~/libs/core' +import { xhrGetAsync, xhrPostAsync, xhrPutAsync } from '~/libs/core' -import Skill from './skill.model' +import { EmsiSkill, Skill } from './skill.model' export async function autoCompleteSkills(queryTerm: string): Promise { return xhrGetAsync(`${EnvironmentConfig.API.V5}/emsi-skills/skills/auto-complete?term=${queryTerm}`) } + +export async function fetchMemberSkills(userId?: string | number): Promise { + return xhrGetAsync(`${EnvironmentConfig.API.V5}/emsi-skills/member-emsi-skills/${userId}`) +} + +export async function createMemberEmsiSkills(userId: number, skills: Skill[]): Promise { + return xhrPostAsync(`${EnvironmentConfig.API.V5}/emsi-skills/member-emsi-skills`, { + emsiSkills: skills, + userId, + }) +} + +export async function updateMemberEmsiSkills(userId: string | number, skills: Skill[]): Promise { + return xhrPutAsync(`${EnvironmentConfig.API.V5}/emsi-skills/member-emsi-skills/${userId}`, { + emsiSkills: skills, + }) +} diff --git a/src/libs/shared/lib/services/emsi-skills/skill.model.ts b/src/libs/shared/lib/services/emsi-skills/skill.model.ts index e9c21d4fc..abde46c47 100644 --- a/src/libs/shared/lib/services/emsi-skills/skill.model.ts +++ b/src/libs/shared/lib/services/emsi-skills/skill.model.ts @@ -1,4 +1,16 @@ -export default interface Skill { +export enum EmsiSkillSources { + selfPicked = 'SelfPicked', + challengeWin = 'ChallengeWin', +} + +export interface Skill { name: string; emsiId: string; + sources?: EmsiSkillSources[]; +} + +export interface EmsiSkill { + name: string; + skillId: string; + skillSources: EmsiSkillSources[] } diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.tsx index 33bd0405b..c28e60dda 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.tsx +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.tsx @@ -15,10 +15,12 @@ import styles from './InputMultiselect.module.scss' export interface InputMultiselectOption { label?: ReactNode value: string + verified?: boolean } interface InputMultiselectProps { readonly dirty?: boolean + readonly loading?: boolean readonly disabled?: boolean readonly error?: string readonly hideInlineErrors?: boolean @@ -29,13 +31,19 @@ interface InputMultiselectProps { readonly options?: ReadonlyArray readonly placeholder?: string readonly tabIndex?: number - readonly value?: string + readonly value?: InputMultiselectOption[] readonly onFetchOptions?: (query: string) => Promise } const MultiValueRemove: FC = (props: any) => ( - + {props.data.verified ? ( + + + + ) : ( + + )} ) @@ -70,10 +78,9 @@ const InputMultiselect: FC = (props: InputMultiselectProp onChange={handleOnChange} onBlur={noop} blurInputOnSelect={false} - components={{ - // MultiValueLabel: () => - MultiValueRemove, - }} + isLoading={props.loading} + components={{ MultiValueRemove }} + value={props.value} /> )