Commit e9e446b1 authored by H.M.C. Nadunithara Wijerathne's avatar H.M.C. Nadunithara Wijerathne

Merge branch 'it19243122' into 'master'

Organization profile update

See merge request !6
parents 5eda1d85 ab94d701
......@@ -5,6 +5,7 @@ import Landing from "./views/Landing.view";
import Home from "./views/Home.view";
import Settings from "./views/Settings.view";
import Applications from "./views/Applications.view";
import DialogBox from "./components/Modals/DialogBox";
import AppState from "./components/AppState";
......@@ -29,6 +30,7 @@ function App() {
</Route>
</Routes>
<AppState />
<DialogBox />
</Router>
);
}
......
......@@ -30,3 +30,17 @@ p {
}
}
}
.hidden-input {
display: none;
}
.profile-card {
display: flex;
align-items: center;
flex-direction: column;
text-align: center;
h5 {
margin-top: 10px;
}
}
......@@ -30,3 +30,8 @@ export const updateApplication = (
payload,
success,
});
export const setDialogBox = (payload: string | null) => ({
type: ACTIONS.SET_DIALOG,
payload,
});
......@@ -9,6 +9,9 @@ export enum ACTIONS {
UPDATE_CANDIDATE = "UPDATE_CANDIDATE",
APPLY_FOR_JOB = "APPLY_FOR_JOB",
UPDATE_ORG = "UPDATE_ORG",
SET_APP_STATE = "SET_APP_STATE",
SET_JOBS = "SET_JOBS",
......@@ -16,4 +19,5 @@ export enum ACTIONS {
CREATE_JOB = "CREATE_JOB",
UPDATE_JOB = "UPDATE_JOB",
UPDATE_APPLICATION = "UPDATE_APPLICATION",
SET_DIALOG = "SET_DIALOG",
}
import { ACTIONS } from ".";
import { CandidateType } from "../types";
import { CandidateType, OrganizationType } from "../types";
export const updateCandidate = (payload: Partial<CandidateType>) => ({
export const updateCandidate = (
payload: Partial<CandidateType>,
success?: (candidate: CandidateType) => void
) => ({
type: ACTIONS.UPDATE_CANDIDATE,
payload,
success,
});
export const updateOrganization = (
payload: Partial<OrganizationType>,
success?: (organization: OrganizationType) => void
) => ({
type: ACTIONS.UPDATE_ORG,
payload,
success,
});
export const applyForJob = (payload: string) => ({
type: ACTIONS.APPLY_FOR_JOB,
payload,
});
\ No newline at end of file
import { request } from "../lib/api";
import { CandidateType } from "../types";
import { CandidateType, OrganizationType } from "../types";
export default class UserAPI {
static updateCandidate = (payload: Partial<CandidateType>) =>
request("<BASE_URL>/user/candidate", "POST", payload);
static updateOrganization = (payload: Partial<OrganizationType>) =>
request("<BASE_URL>/user/organization", "POST", payload);
}
import { Area } from "react-easy-crop";
export const createImage = (url: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image));
image.addEventListener("error", (error) => reject(error));
image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox
image.src = url;
});
export function getRadianAngle(degreeValue: number) {
return (degreeValue * Math.PI) / 180;
}
/**
* Returns the new bounding area of a rotated rectangle.
*/
export function rotateSize(width: number, height: number, rotation: number) {
const rotRad = getRadianAngle(rotation);
return {
width:
Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
height:
Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height),
};
}
/**
* This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
*/
export default async function getCroppedImg(
imageSrc: string,
pixelCrop: Area,
rotation = 0,
flip = { horizontal: false, vertical: false }
) {
const image = await createImage(imageSrc);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
return null;
}
const rotRad = getRadianAngle(rotation);
// calculate bounding box of the rotated image
const { width: bBoxWidth, height: bBoxHeight } = rotateSize(
image.width,
image.height,
rotation
);
// set canvas size to match the bounding box
canvas.width = bBoxWidth;
canvas.height = bBoxHeight;
// translate canvas context to a central location to allow rotating and flipping around the center
ctx.translate(bBoxWidth / 2, bBoxHeight / 2);
ctx.rotate(rotRad);
ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1);
ctx.translate(-image.width / 2, -image.height / 2);
// draw rotated image
ctx.drawImage(image, 0, 0);
// croppedAreaPixels values are bounding box relative
// extract the cropped image using these values
const data = ctx.getImageData(
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height
);
// set canvas width to final desired crop size - this will clear existing context
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
// paste generated rotate image at the top left corner
ctx.putImageData(data, 0, 0);
// As Base64 string
// return canvas.toDataURL('image/jpeg');
// As a blob
return new Promise((resolve, reject) => {
canvas.toBlob((file: any) => {
file.name = "cropped.jpeg";
resolve({ file: file, url: URL.createObjectURL(file) });
}, "image/jpeg");
});
}
......@@ -64,7 +64,7 @@ export const getStatusColor = (status?: string) => {
return color;
};
export const getAddress = (address: AddressType) => {
export const getAddress = (address?: AddressType) => {
if (!address) return "";
return `${address.addressLine}, ${address.city}, ${address.country}`;
};
......
......@@ -3,6 +3,7 @@ import { CommonReducer } from "../types";
const INITIAL_STATE: CommonReducer = {
jobs: [],
dialogAlert: null,
};
const commonReducer = (
......@@ -13,6 +14,9 @@ const commonReducer = (
case ACTIONS.SET_JOBS:
return { ...state, jobs: payload };
case ACTIONS.SET_DIALOG:
return { ...state, dialogAlert: payload };
case ACTIONS.SIGN_OUT_USER:
return INITIAL_STATE;
......
......@@ -3,13 +3,15 @@ import { ACTIONS } from "../actions";
import CommonAPI from "../apis/common";
import UserAPI from "../apis/user";
import { getProfile } from "../lib/util";
import { APP_STATE, CandidateType, JobType, Reducers } from "../types";
import { APP_STATE, CandidateType, JobType, OrganizationType, Reducers } from "../types";
function* updateCandidate({
payload,
success,
}: {
type: typeof ACTIONS.UPDATE_CANDIDATE;
payload: Partial<CandidateType>;
success?: (candidate?: CandidateType | null) => void;
}) {
try {
yield put({
......@@ -18,20 +20,58 @@ function* updateCandidate({
});
yield call(UserAPI.updateCandidate, payload);
const candidate: CandidateType = yield select(getProfile);
const newCandidate = { ...candidate, ...payload };
yield put({
type: ACTIONS.SET_AUTH,
payload: { candidate: { ...candidate, ...payload } },
payload: { candidate: newCandidate },
});
yield put({
type: ACTIONS.SET_APP_STATE,
payload: { state: APP_STATE.SUCCESS },
});
success?.(newCandidate);
} catch (error) {
yield put({
type: ACTIONS.SET_APP_STATE,
payload: { state: APP_STATE.FAILED },
});
success?.(null);
}
}
function* updateOrganization({
payload,
success,
}: {
type: typeof ACTIONS.UPDATE_ORG;
payload: Partial<OrganizationType>;
success?: (organization?: OrganizationType | null) => void;
}) {
try {
yield put({
type: ACTIONS.SET_APP_STATE,
payload: { state: APP_STATE.LOADING },
});
yield call(UserAPI.updateOrganization, payload);
const org: OrganizationType = yield select(getProfile);
const newOrg = { ...org, ...payload };
yield put({
type: ACTIONS.SET_AUTH,
payload: { organization: newOrg },
});
yield put({
type: ACTIONS.SET_APP_STATE,
payload: { state: APP_STATE.SUCCESS },
});
success?.(newOrg);
} catch (error) {
yield put({
type: ACTIONS.SET_APP_STATE,
payload: { state: APP_STATE.FAILED },
});
success?.(null);
}
}
......@@ -89,6 +129,7 @@ function* applyForJob({
type: ACTIONS.SET_APP_STATE,
payload: { state: APP_STATE.SUCCESS },
});
window.location.reload();
} catch (error) {
yield put({
type: ACTIONS.SET_APP_STATE,
......@@ -97,7 +138,10 @@ function* applyForJob({
}
}
export default function* authSaga() {
yield takeLeading(ACTIONS.UPDATE_CANDIDATE, updateCandidate);
yield takeLeading(ACTIONS.UPDATE_ORG, updateOrganization);
yield takeLeading(ACTIONS.APPLY_FOR_JOB, applyForJob);
}
......@@ -9,6 +9,12 @@ export enum APP_STATE {
SUCCESS = "success",
}
export enum OnboardSteps {
RESUME = "RESUME",
SELF_INTRO = "SELF_INTRO",
COMPLETE = "COMPLETE",
}
export type KeyDetails = {
key: any;
code: any;
......@@ -104,7 +110,7 @@ export type OrganizationType = {
description: string;
contacts: {
email: string;
phone: string[];
phone: string;
address: AddressType;
website: string;
};
......@@ -250,6 +256,7 @@ export type AuthReducer = {
export type CommonReducer = {
jobs: JobType[] | JobPayloadType[];
dialogAlert: string | null;
};
export type Reducers = {
......
import { getDownloadURL, ref, uploadBytesResumable } from "firebase/storage";
import React, { useState, useEffect, ChangeEvent } from "react";
import React, { useState, useEffect, ChangeEvent, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { ACTIONS } from "../common/actions";
import { updateCandidate } from "../common/actions/user";
import { fileStorage } from "../common/config";
import { getProfile } from "../common/lib/util";
import { APP_STATE, CandidateType } from "../common/types";
import { APP_STATE, CandidateType, OnboardSteps, USER_TYPE } from "../common/types";
import Avatar from "./Avatar";
import Progress from "./Progress";
import Onboarder from "./Onboarder";
import ProfilePicCrop from "./Modals/ProfilePicCrop";
const CandidateProfile = () => {
const dispath = useDispatch();
......@@ -16,10 +18,18 @@ const CandidateProfile = () => {
const [resume, setResume] = useState<File | null>(null);
const [selfIntro, setSelfIntro] = useState<File | null>(null);
const [progress, setProgress] = useState<number>(0);
const [onboardStep, setOnboardStep] = useState<OnboardSteps>(
OnboardSteps.COMPLETE
);
const [profilePicPrev, setprofilePicPrev] = useState<string | null>(null);
const _resumeInput = useRef<HTMLInputElement>(null);
const _introInput = useRef<HTMLInputElement>(null);
const _profilePicInput = useRef<HTMLInputElement>(null);
useEffect(() => {
if (candidate) {
setProfile(candidate);
checkOnboardingStatus(candidate);
}
}, [candidate]);
......@@ -50,8 +60,11 @@ const CandidateProfile = () => {
});
};
const onUpdateCandidate = (payload: Partial<CandidateType>) => {
dispath(updateCandidate(payload));
const onUpdateCandidate = (
payload: Partial<CandidateType>,
callBack?: (candidate: CandidateType) => void
) => {
dispath(updateCandidate(payload, callBack));
};
const uploadSingleFile = (path: string, file: File) => {
......@@ -131,6 +144,60 @@ const CandidateProfile = () => {
}
};
const onSelectProfilePic = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setprofilePicPrev(URL.createObjectURL(e.target.files[0]));
}
};
const onPressOnboard = async () => {
const update: any = {};
if (onboardStep === OnboardSteps.RESUME && resume) {
const url = await uploadSingleFile(`/resumes/${profile._id}.pdf`, resume);
update.resume = url;
}
if (onboardStep === OnboardSteps.SELF_INTRO && selfIntro) {
const url = await uploadSingleFile(
`/selfIntros/${profile._id}.mp4`,
selfIntro
);
update.selfIntro = url;
}
onUpdateCandidate(update, checkOnboardingStatus);
};
const checkOnboardingStatus = (_candidate: CandidateType) => {
if (_candidate) {
if (!_candidate?.resume) {
setOnboardStep(OnboardSteps.RESUME);
} else if (!_candidate?.selfIntro) {
setOnboardStep(OnboardSteps.SELF_INTRO);
} else {
setOnboardStep(OnboardSteps.COMPLETE);
}
}
};
const onPressUploadProfilePic = () => {
_profilePicInput?.current?.click();
};
const onPressUploadResume = () => {
_resumeInput?.current?.click();
};
const onPressUploadSelfIntro = () => {
_introInput?.current?.click();
};
const onRequestCloseProfilePic = () => {
setprofilePicPrev(null);
};
const isOnboarded = onboardStep === OnboardSteps.COMPLETE;
return (
<div className="card p-4 mb-3">
<h5>Edit/Update profile</h5>
......@@ -218,28 +285,47 @@ const CandidateProfile = () => {
target="_blank"
rel="noreferrer"
>
Preview
Preview resume
</a>
)}
<input
type="file"
className="form-control"
onChange={onSelectResume}
accept="application/pdf"
/>
<div className="d-md-block">
<button
type="button"
className="btn btn-warning btn-sm"
style={{ width: "100%" }}
onClick={onPressUploadResume}
>
Select Resume
</button>
</div>
</div>
</div>
</div>
<div className="col-sm-6">
<div className="row">
<label className="col-sm-3 col-form-label">Self Intro</label>
<div className="col-sm-9">
<input
type="file"
className="form-control"
accept="video/mp4"
onChange={onSelectSelfIntro}
/>
<label className="col-sm-4 col-form-label">Self Intro</label>
<div className="col-sm-8">
{profile?.selfIntro && (
<a
className="btn btn-secondary mb-2"
href={profile.selfIntro}
target="_blank"
rel="noreferrer"
>
Preview video
</a>
)}
<div className="d-md-block">
<button
type="button"
className="btn btn-warning btn-sm"
style={{ width: "100%" }}
onClick={onPressUploadSelfIntro}
>
Select Video
</button>
</div>
</div>
</div>
</div>
......@@ -260,6 +346,7 @@ const CandidateProfile = () => {
type="button"
className="btn btn-link"
style={{ padding: "0" }}
onClick={onPressUploadProfilePic}
>
Upload picture
</button>
......@@ -279,6 +366,44 @@ const CandidateProfile = () => {
</button>
</div>
<Progress progress={progress} />
<input
type="file"
className="form-control hidden-input"
onChange={onSelectResume}
accept="application/pdf"
ref={_resumeInput}
/>
<input
type="file"
className="form-control hidden-input"
accept="video/mp4"
onChange={onSelectSelfIntro}
ref={_introInput}
/>
<input
type="file"
className="form-control hidden-input"
accept="image/*"
onChange={onSelectProfilePic}
ref={_profilePicInput}
/>
<Onboarder
isOpen={!isOnboarded}
onboardStep={onboardStep}
onPressUploadResume={onPressUploadResume}
onPressUploadSelfIntro={onPressUploadSelfIntro}
progress={progress}
onPressOnboard={onPressOnboard}
/>
<ProfilePicCrop
onRequestClose={onRequestCloseProfilePic}
profilePicPrev={profilePicPrev}
userId={profile._id}
onUpdateCandidate={onUpdateCandidate}
userType={USER_TYPE.CANDIDATE}
/>
</div>
);
};
......
......@@ -46,8 +46,6 @@ const Login = ({ userType, setAlert }: OwnProps) => {
signIn(data, (state) => {
if (state === "failed") {
setAlert("Attempt failed! Try again");
} else if (state === "INTIAL") {
navigate("/settings");
} else {
navigate("/home");
}
......
import React from "react";
import Modal from "./Modal";
import { useDispatch, useSelector } from "react-redux";
import { Reducers } from "../../common/types";
import { setDialogBox } from "../../common/actions/common";
const DialogBox = () => {
const dispatch = useDispatch();
const dialogString = useSelector(
(state: Reducers) => state.common.dialogAlert
);
if (!dialogString) return null;
const onPressCancel = () => {
dispatch(setDialogBox(null));
};
const isOpen = dialogString !== null;
return (
<Modal isOpen={isOpen} onRequestClose={onPressCancel}>
<p>{dialogString}</p>
<div style={{ display: "block" }}>
<button
type="button"
className="btn btn-primary btn-sm mt-3"
style={{ float: "right" }}
onClick={onPressCancel}
>
Got it
</button>
</div>
</Modal>
);
};
export default DialogBox;
......@@ -16,7 +16,7 @@ const CustomModal = ({
size = "md",
shouldCloseOnOverlayClick = true,
}: OwnProps) => {
const width = size === "md" ? "400px" : "500px";
const width = size === "md" ? "400px" : "600px";
return (
<Modal
isOpen={isOpen}
......
import React, { useRef, useState } from "react";
import { getDownloadURL, ref, uploadBytesResumable } from "firebase/storage";
import Cropper, { Area } from "react-easy-crop";
import getCroppedImg from "../../common/lib/crop";
import { fileStorage } from "../../common/config";
import { USER_TYPE } from "../../common/types";
type OwnProps = {
onRequestClose: () => void;
profilePicPrev: string | null;
userId?: string;
onUpdateCandidate: (update: any) => void;
userType:USER_TYPE
};
const ProfilePicCrop = ({
onRequestClose,
profilePicPrev,
userId,
onUpdateCandidate,
userType
}: OwnProps) => {
const [progress, setprogress] = useState(0);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const _croppedAreaPixels = useRef<Area>();
if (!profilePicPrev) return null;
const onCropComplete = (croppedArea: Area, croppedAreaPixels: Area) => {
_croppedAreaPixels.current = croppedAreaPixels;
};
const onChangeZoom = (e: React.ChangeEvent<HTMLInputElement>) => {
setZoom(Number(e.target.value));
};
const onPressSelect = async () => {
if (_croppedAreaPixels.current) {
const { file, _ }: any = await getCroppedImg(
profilePicPrev,
_croppedAreaPixels.current
);
const extension = file.name.split(".")[1];
const uploadRef = ref(fileStorage, `/${userType}/${userId}.${extension}`);
const uploadTask = uploadBytesResumable(uploadRef, file);
uploadTask.on(
"state_changed",
(snapshot) => {
const percent = Math.round(
(snapshot.bytesTransferred / snapshot.totalBytes) * 100
);
setprogress(percent);
},
(err) => {},
() => {
getDownloadURL(uploadTask.snapshot.ref).then((url) => {
setprogress(0);
onUpdateCandidate({ profilePicture: url });
onRequestClose();
});
}
);
}
};
return (
<div
style={{