├── .gitignore ├── README.md ├── client ├── .eslintrc.cjs ├── .gitignore ├── index.html ├── package.json ├── public │ ├── favicon.ico │ └── vite.svg ├── src │ ├── App.jsx │ ├── assets │ │ ├── README.md │ │ ├── css │ │ │ └── index.css │ │ ├── images │ │ │ ├── avatar-1.jpg │ │ │ ├── avatar-2.jpg │ │ │ ├── favicon.ico │ │ │ ├── logo.svg │ │ │ ├── main-alternative.svg │ │ │ ├── main.svg │ │ │ └── not-found.svg │ │ ├── react.svg │ │ └── wrappers │ │ │ ├── BigSidebar.js │ │ │ ├── ChartsContainer.js │ │ │ ├── Dashboard.js │ │ │ ├── DashboardFormPage.js │ │ │ ├── ErrorPage.js │ │ │ ├── Job.js │ │ │ ├── JobInfo.js │ │ │ ├── JobsContainer.js │ │ │ ├── LandingPage.js │ │ │ ├── LogoutContainer.js │ │ │ ├── Navbar.js │ │ │ ├── PageBtnContainer.js │ │ │ ├── RegisterAndLoginPage.js │ │ │ ├── SmallSidebar.js │ │ │ ├── StatItem.js │ │ │ ├── StatsContainer.js │ │ │ ├── Testing.js │ │ │ └── ThemeToggle.js │ ├── components │ │ ├── AreaChart.jsx │ │ ├── BarChart.jsx │ │ ├── BigSidebar.jsx │ │ ├── ChartsContainer.jsx │ │ ├── ErrorElement.jsx │ │ ├── FormRow.jsx │ │ ├── FormRowSelect.jsx │ │ ├── Job.jsx │ │ ├── JobInfo.jsx │ │ ├── JobsContainer.jsx │ │ ├── Loading.jsx │ │ ├── Logo.jsx │ │ ├── LogoutContainer.jsx │ │ ├── NavLinks.jsx │ │ ├── Navbar.jsx │ │ ├── PageBtnContainer.jsx │ │ ├── SearchContainer.jsx │ │ ├── SmallSidebar.jsx │ │ ├── StatItem.jsx │ │ ├── StatsContainer.jsx │ │ ├── SubmitBtn.jsx │ │ ├── ThemeToggle.jsx │ │ └── index.js │ ├── index.css │ ├── main.jsx │ ├── pages │ │ ├── AddJob.jsx │ │ ├── Admin.jsx │ │ ├── AllJobs.jsx │ │ ├── DashboardLayout.jsx │ │ ├── DeleteJob.jsx │ │ ├── EditJob.jsx │ │ ├── Error.jsx │ │ ├── HomeLayout.jsx │ │ ├── Landing.jsx │ │ ├── Login.jsx │ │ ├── Profile.jsx │ │ ├── Register.jsx │ │ ├── Stats.jsx │ │ └── index.js │ └── utils │ │ ├── customFetch.js │ │ └── links.jsx └── vite.config.js ├── controllers ├── authController.js ├── jobController.js └── userController.js ├── errors └── customErrors.js ├── middleware ├── authMiddleware.js ├── errorHandlerMiddleware.js ├── multerMiddleware.js └── validationMiddleware.js ├── models ├── JobModel.js └── UserModel.js ├── package.json ├── populate.js ├── routes ├── authRouter.js ├── jobRouter.js └── userRouter.js ├── server.js └── utils ├── constants.js ├── mockData.json ├── passwordUtils.js └── tokenUtils.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | package-lock.json 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .env -------------------------------------------------------------------------------- /client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:react/recommended', 6 | 'plugin:react/jsx-runtime', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 10 | settings: { react: { version: '18.2' } }, 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': 'warn', 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | package-lock.json 10 | 11 | node_modules 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Jobify 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@tanstack/react-query": "^4.29.5", 14 | "@tanstack/react-query-devtools": "^4.29.6", 15 | "axios": "^1.3.6", 16 | "dayjs": "^1.11.7", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "react-icons": "^4.8.0", 20 | "react-router-dom": "^6.10.0", 21 | "react-toastify": "^9.1.2", 22 | "recharts": "^2.5.0", 23 | "styled-components": "^5.3.10" 24 | }, 25 | "devDependencies": { 26 | "@types/react": "^18.0.37", 27 | "@types/react-dom": "^18.0.11", 28 | "@vitejs/plugin-react": "^4.0.0", 29 | "eslint": "^8.38.0", 30 | "eslint-plugin-react": "^7.32.2", 31 | "eslint-plugin-react-hooks": "^4.6.0", 32 | "eslint-plugin-react-refresh": "^0.3.4", 33 | "vite": "^4.3.9" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-smilga/mern-jobify-v2/97e87999c3fcac2ac22a0aac323c02bee97b17aa/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { RouterProvider, createBrowserRouter } from 'react-router-dom'; 2 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 3 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 4 | 5 | import { 6 | HomeLayout, 7 | Landing, 8 | Register, 9 | Login, 10 | DashboardLayout, 11 | Error, 12 | AddJob, 13 | Stats, 14 | AllJobs, 15 | Profile, 16 | Admin, 17 | EditJob, 18 | } from './pages'; 19 | 20 | import { action as registerAction } from './pages/Register'; 21 | import { action as loginAction } from './pages/Login'; 22 | import { loader as dashboardLoader } from './pages/DashboardLayout'; 23 | import { action as addJobAction } from './pages/AddJob'; 24 | import { loader as allJobsLoader } from './pages/AllJobs'; 25 | import { loader as editJobLoader } from './pages/EditJob'; 26 | import { action as editJobAction } from './pages/EditJob'; 27 | import { action as deleteJobAction } from './pages/DeleteJob'; 28 | import { loader as adminLoader } from './pages/Admin'; 29 | import { action as profileAction } from './pages/Profile'; 30 | import { loader as statsLoader } from './pages/Stats'; 31 | import ErrorElement from './components/ErrorElement'; 32 | 33 | export const checkDefaultTheme = () => { 34 | const isDarkTheme = localStorage.getItem('darkTheme') === 'true'; 35 | document.body.classList.toggle('dark-theme', isDarkTheme); 36 | return isDarkTheme; 37 | }; 38 | 39 | checkDefaultTheme(); 40 | 41 | const queryClient = new QueryClient({ 42 | defaultOptions: { 43 | queries: { 44 | staleTime: 1000 * 60 * 5, 45 | }, 46 | }, 47 | }); 48 | 49 | const router = createBrowserRouter([ 50 | { 51 | path: '/', 52 | element: , 53 | errorElement: , 54 | children: [ 55 | { 56 | index: true, 57 | element: , 58 | }, 59 | { 60 | path: 'register', 61 | element: , 62 | action: registerAction, 63 | }, 64 | { 65 | path: 'login', 66 | element: , 67 | action: loginAction(queryClient), 68 | }, 69 | { 70 | path: 'dashboard', 71 | element: , 72 | loader: dashboardLoader(queryClient), 73 | children: [ 74 | { 75 | index: true, 76 | element: , 77 | action: addJobAction(queryClient), 78 | }, 79 | { 80 | path: 'stats', 81 | element: , 82 | loader: statsLoader(queryClient), 83 | errorElement: , 84 | }, 85 | { 86 | path: 'all-jobs', 87 | element: , 88 | loader: allJobsLoader(queryClient), 89 | errorElement: , 90 | }, 91 | { 92 | path: 'profile', 93 | element: , 94 | action: profileAction(queryClient), 95 | }, 96 | { 97 | path: 'admin', 98 | element: , 99 | loader: adminLoader, 100 | }, 101 | { 102 | path: 'edit-job/:id', 103 | element: , 104 | loader: editJobLoader(queryClient), 105 | action: editJobAction(queryClient), 106 | }, 107 | { path: 'delete-job/:id', action: deleteJobAction(queryClient) }, 108 | ], 109 | }, 110 | ], 111 | }, 112 | ]); 113 | 114 | const App = () => { 115 | return ( 116 | 117 | 118 | 119 | 120 | ); 121 | }; 122 | export default App; 123 | -------------------------------------------------------------------------------- /client/src/assets/css/index.css: -------------------------------------------------------------------------------- 1 | /* ============= GLOBAL CSS =============== */ 2 | 3 | *, 4 | ::after, 5 | ::before { 6 | margin: 0; 7 | padding: 0; 8 | box-sizing: border-box; 9 | } 10 | 11 | html { 12 | font-size: 100%; 13 | } /*16px*/ 14 | 15 | :root { 16 | /* colors */ 17 | --primary-50: #e0fcff; 18 | --primary-100: #bef8fd; 19 | --primary-200: #87eaf2; 20 | --primary-300: #54d1db; 21 | --primary-400: #38bec9; 22 | --primary-500: #2cb1bc; 23 | --primary-600: #14919b; 24 | --primary-700: #0e7c86; 25 | --primary-800: #0a6c74; 26 | --primary-900: #044e54; 27 | 28 | /* grey */ 29 | --grey-50: #f8fafc; 30 | --grey-100: #f1f5f9; 31 | --grey-200: #e2e8f0; 32 | --grey-300: #cbd5e1; 33 | --grey-400: #94a3b8; 34 | --grey-500: #64748b; 35 | --grey-600: #475569; 36 | --grey-700: #334155; 37 | --grey-800: #1e293b; 38 | --grey-900: #0f172a; 39 | /* rest of the colors */ 40 | --black: #222; 41 | --white: #fff; 42 | --red-light: #f8d7da; 43 | --red-dark: #842029; 44 | --green-light: #d1e7dd; 45 | --green-dark: #0f5132; 46 | 47 | --small-text: 0.875rem; 48 | --extra-small-text: 0.7em; 49 | /* rest of the vars */ 50 | 51 | --border-radius: 0.25rem; 52 | --letter-spacing: 1px; 53 | --transition: 0.3s ease-in-out all; 54 | --max-width: 1120px; 55 | --fixed-width: 600px; 56 | --fluid-width: 90vw; 57 | --nav-height: 6rem; 58 | /* box shadow*/ 59 | --shadow-1: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); 60 | --shadow-2: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 61 | 0 2px 4px -1px rgba(0, 0, 0, 0.06); 62 | --shadow-3: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 63 | 0 4px 6px -2px rgba(0, 0, 0, 0.05); 64 | --shadow-4: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 65 | 0 10px 10px -5px rgba(0, 0, 0, 0.04); 66 | /* DARK MODE */ 67 | 68 | --dark-mode-bg-color: #333; 69 | --dark-mode-text-color: #f0f0f0; 70 | --dark-mode-bg-secondary-color: #3f3f3f; 71 | --dark-mode-text-secondary-color: var(--grey-300); 72 | 73 | --background-color: var(--grey-50); 74 | --text-color: var(--grey-900); 75 | --background-secondary-color: var(--white); 76 | --text-secondary-color: var(--grey-500); 77 | } 78 | 79 | .dark-theme { 80 | --text-color: var(--dark-mode-text-color); 81 | --background-color: var(--dark-mode-bg-color); 82 | --text-secondary-color: var(--dark-mode-text-secondary-color); 83 | --background-secondary-color: var(--dark-mode-bg-secondary-color); 84 | } 85 | 86 | body { 87 | background: var(--background-color); 88 | color: var(--text-color); 89 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 90 | Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 91 | font-weight: 400; 92 | line-height: 1; 93 | } 94 | p { 95 | margin: 0; 96 | } 97 | h1, 98 | h2, 99 | h3, 100 | h4, 101 | h5 { 102 | margin: 0; 103 | font-weight: 400; 104 | line-height: 1; 105 | text-transform: capitalize; 106 | letter-spacing: var(--letter-spacing); 107 | } 108 | 109 | h1 { 110 | font-size: clamp(2rem, 5vw, 5rem); /* Large heading */ 111 | } 112 | 113 | h2 { 114 | font-size: clamp(1.5rem, 3vw, 3rem); /* Medium heading */ 115 | } 116 | 117 | h3 { 118 | font-size: clamp(1.25rem, 2.5vw, 2.5rem); /* Small heading */ 119 | } 120 | 121 | h4 { 122 | font-size: clamp(1rem, 2vw, 2rem); /* Extra small heading */ 123 | } 124 | 125 | h5 { 126 | font-size: clamp(0.875rem, 1.5vw, 1.5rem); /* Tiny heading */ 127 | } 128 | 129 | /* BIGGER FONTS */ 130 | /* h1 { 131 | font-size: clamp(3rem, 6vw, 6rem); 132 | } 133 | 134 | h2 { 135 | font-size: clamp(2.5rem, 5vw, 5rem); 136 | } 137 | 138 | h3 { 139 | font-size: clamp(2rem, 4vw, 4rem); 140 | } 141 | 142 | h4 { 143 | font-size: clamp(1.5rem, 3vw, 3rem); 144 | } 145 | 146 | h5 { 147 | font-size: clamp(1rem, 2vw, 2rem); 148 | } 149 | */ 150 | 151 | .text { 152 | margin-bottom: 1.5rem; 153 | max-width: 40em; 154 | } 155 | 156 | small, 157 | .text-small { 158 | font-size: var(--small-text); 159 | } 160 | 161 | a { 162 | text-decoration: none; 163 | } 164 | ul { 165 | list-style-type: none; 166 | padding: 0; 167 | } 168 | 169 | .img { 170 | width: 100%; 171 | display: block; 172 | object-fit: cover; 173 | } 174 | /* buttons */ 175 | 176 | .btn { 177 | cursor: pointer; 178 | color: var(--white); 179 | background: var(--primary-500); 180 | border: transparent; 181 | border-radius: var(--border-radius); 182 | letter-spacing: var(--letter-spacing); 183 | padding: 0.375rem 0.75rem; 184 | box-shadow: var(--shadow-1); 185 | transition: var(--transition); 186 | text-transform: capitalize; 187 | display: inline-block; 188 | } 189 | .btn:hover { 190 | background: var(--primary-700); 191 | box-shadow: var(--shadow-3); 192 | } 193 | .btn-hipster { 194 | color: var(--primary-500); 195 | background: var(--primary-200); 196 | } 197 | .btn-hipster:hover { 198 | color: var(--primary-200); 199 | background: var(--primary-700); 200 | } 201 | .btn-block { 202 | width: 100%; 203 | } 204 | button:disabled { 205 | cursor: wait; 206 | } 207 | .danger-btn { 208 | color: var(--red-dark); 209 | background: var(--red-light); 210 | } 211 | .danger-btn:hover { 212 | color: var(--white); 213 | background: var(--red-dark); 214 | } 215 | /* alerts */ 216 | .alert { 217 | padding: 0.375rem 0.75rem; 218 | margin-bottom: 1rem; 219 | border-color: transparent; 220 | border-radius: var(--border-radius); 221 | } 222 | 223 | .alert-danger { 224 | color: var(--red-dark); 225 | background: var(--red-light); 226 | } 227 | .alert-success { 228 | color: var(--green-dark); 229 | background: var(--green-light); 230 | } 231 | /* form */ 232 | 233 | .form { 234 | width: 90vw; 235 | max-width: var(--fixed-width); 236 | background: var(--background-secondary-color); 237 | border-radius: var(--border-radius); 238 | box-shadow: var(--shadow-2); 239 | padding: 2rem 2.5rem; 240 | margin: 3rem auto; 241 | } 242 | .form-label { 243 | display: block; 244 | font-size: var(--small-text); 245 | margin-bottom: 0.75rem; 246 | text-transform: capitalize; 247 | letter-spacing: var(--letter-spacing); 248 | line-height: 1.5; 249 | } 250 | .form-input, 251 | .form-textarea, 252 | .form-select { 253 | width: 100%; 254 | padding: 0.375rem 0.75rem; 255 | border-radius: var(--border-radius); 256 | background: var(--background-color); 257 | border: 1px solid var(--grey-300); 258 | color: var(--text-color); 259 | } 260 | .form-input, 261 | .form-select, 262 | .form-btn { 263 | height: 35px; 264 | } 265 | .form-row { 266 | margin-bottom: 1rem; 267 | } 268 | 269 | .form-textarea { 270 | height: 7rem; 271 | } 272 | ::placeholder { 273 | font-family: inherit; 274 | color: var(--grey-400); 275 | } 276 | .form-alert { 277 | color: var(--red-dark); 278 | letter-spacing: var(--letter-spacing); 279 | text-transform: capitalize; 280 | } 281 | /* alert */ 282 | 283 | @keyframes spinner { 284 | to { 285 | transform: rotate(360deg); 286 | } 287 | } 288 | 289 | .loading { 290 | width: 6rem; 291 | height: 6rem; 292 | border: 5px solid var(--grey-400); 293 | border-radius: 50%; 294 | border-top-color: var(--primary-500); 295 | animation: spinner 0.6s linear infinite; 296 | } 297 | 298 | /* title */ 299 | 300 | .title { 301 | text-align: center; 302 | } 303 | 304 | .title-underline { 305 | background: var(--primary-500); 306 | width: 7rem; 307 | height: 0.25rem; 308 | margin: 0 auto; 309 | margin-top: 1rem; 310 | } 311 | 312 | .container { 313 | width: var(--fluid-width); 314 | max-width: var(--max-width); 315 | margin: 0 auto; 316 | } 317 | 318 | /* BUTTONS AND BADGES */ 319 | .pending { 320 | background: #fef3c7; 321 | color: #f59e0b; 322 | } 323 | 324 | .interview { 325 | background: #e0e8f9; 326 | color: #647acb; 327 | } 328 | .declined { 329 | background: #ffeeee; 330 | color: #d66a6a; 331 | } 332 | -------------------------------------------------------------------------------- /client/src/assets/images/avatar-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-smilga/mern-jobify-v2/97e87999c3fcac2ac22a0aac323c02bee97b17aa/client/src/assets/images/avatar-1.jpg -------------------------------------------------------------------------------- /client/src/assets/images/avatar-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-smilga/mern-jobify-v2/97e87999c3fcac2ac22a0aac323c02bee97b17aa/client/src/assets/images/avatar-2.jpg -------------------------------------------------------------------------------- /client/src/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-smilga/mern-jobify-v2/97e87999c3fcac2ac22a0aac323c02bee97b17aa/client/src/assets/images/favicon.ico -------------------------------------------------------------------------------- /client/src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /client/src/assets/images/main-alternative.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/main.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/not-found.svg: -------------------------------------------------------------------------------- 1 | page not found -------------------------------------------------------------------------------- /client/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/BigSidebar.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.aside` 4 | display: none; 5 | @media (min-width: 992px) { 6 | display: block; 7 | box-shadow: 1px 0px 0px 0px rgba(0, 0, 0, 0.1); 8 | .sidebar-container { 9 | background: var(--background-secondary-color); 10 | min-height: 100vh; 11 | height: 100%; 12 | width: 250px; 13 | margin-left: -250px; 14 | transition: margin-left 0.3s ease-in-out; 15 | } 16 | .content { 17 | position: sticky; 18 | top: 0; 19 | } 20 | .show-sidebar { 21 | margin-left: 0; 22 | } 23 | header { 24 | height: 6rem; 25 | display: flex; 26 | align-items: center; 27 | padding-left: 2.5rem; 28 | } 29 | .nav-links { 30 | padding-top: 2rem; 31 | display: flex; 32 | flex-direction: column; 33 | } 34 | .nav-link { 35 | display: flex; 36 | align-items: center; 37 | color: var(--text-secondary-color); 38 | padding: 1rem 0; 39 | padding-left: 2.5rem; 40 | text-transform: capitalize; 41 | transition: padding-left 0.3s ease-in-out; 42 | } 43 | .nav-link:hover { 44 | padding-left: 3rem; 45 | color: var(--primary-500); 46 | transition: var(--transition); 47 | } 48 | .icon { 49 | font-size: 1.5rem; 50 | margin-right: 1rem; 51 | display: grid; 52 | place-items: center; 53 | } 54 | .active { 55 | color: var(--primary-500); 56 | } 57 | .pending { 58 | background: var(--background-color); 59 | } 60 | } 61 | `; 62 | export default Wrapper; 63 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/ChartsContainer.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.section` 4 | margin-top: 4rem; 5 | text-align: center; 6 | button { 7 | background: transparent; 8 | border-color: transparent; 9 | text-transform: capitalize; 10 | color: var(--primary-500); 11 | font-size: 1.25rem; 12 | cursor: pointer; 13 | } 14 | h4 { 15 | text-align: center; 16 | margin-bottom: 0.75rem; 17 | } 18 | `; 19 | 20 | export default Wrapper; 21 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/Dashboard.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.section` 4 | .dashboard { 5 | display: grid; 6 | grid-template-columns: 1fr; 7 | } 8 | .dashboard-page { 9 | width: 90vw; 10 | margin: 0 auto; 11 | padding: 2rem 0; 12 | } 13 | @media (min-width: 992px) { 14 | .dashboard { 15 | grid-template-columns: auto 1fr; 16 | } 17 | .dashboard-page { 18 | width: 90%; 19 | } 20 | } 21 | `; 22 | export default Wrapper; 23 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/DashboardFormPage.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.section` 4 | border-radius: var(--border-radius); 5 | width: 100%; 6 | background: var(--background-secondary-color); 7 | padding: 3rem 2rem 4rem; 8 | .form-title { 9 | margin-bottom: 2rem; 10 | } 11 | .form { 12 | margin: 0; 13 | border-radius: 0; 14 | box-shadow: none; 15 | padding: 0; 16 | max-width: 100%; 17 | width: 100%; 18 | } 19 | .form-row { 20 | margin-bottom: 0; 21 | } 22 | .form-center { 23 | display: grid; 24 | row-gap: 1rem; 25 | } 26 | .form-btn { 27 | align-self: end; 28 | margin-top: 1rem; 29 | display: grid; 30 | place-items: center; 31 | } 32 | @media (min-width: 992px) { 33 | .form-center { 34 | grid-template-columns: 1fr 1fr; 35 | align-items: center; 36 | column-gap: 1rem; 37 | } 38 | } 39 | @media (min-width: 1120px) { 40 | .form-center { 41 | grid-template-columns: 1fr 1fr 1fr; 42 | } 43 | } 44 | `; 45 | 46 | export default Wrapper; 47 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/ErrorPage.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.main` 4 | min-height: 100vh; 5 | text-align: center; 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | img { 10 | width: 90vw; 11 | max-width: 600px; 12 | display: block; 13 | margin-bottom: 2rem; 14 | margin-top: -3rem; 15 | } 16 | h3 { 17 | margin-bottom: 0.5rem; 18 | } 19 | p { 20 | line-height: 1.5; 21 | margin-top: 0.5rem; 22 | margin-bottom: 1rem; 23 | color: var(--text-secondary-color); 24 | } 25 | a { 26 | color: var(--primary-500); 27 | text-transform: capitalize; 28 | } 29 | `; 30 | 31 | export default Wrapper; 32 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/Job.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.article` 4 | background: var(--background-secondary-color); 5 | border-radius: var(--border-radius); 6 | display: grid; 7 | grid-template-rows: 1fr auto; 8 | box-shadow: var(--shadow-2); 9 | header { 10 | padding: 1rem 1.5rem; 11 | border-bottom: 1px solid var(--grey-100); 12 | display: grid; 13 | grid-template-columns: auto 1fr; 14 | align-items: center; 15 | } 16 | .main-icon { 17 | width: 60px; 18 | height: 60px; 19 | display: grid; 20 | place-items: center; 21 | background: var(--primary-500); 22 | border-radius: var(--border-radius); 23 | font-size: 1.5rem; 24 | font-weight: 700; 25 | text-transform: uppercase; 26 | color: var(--white); 27 | margin-right: 2rem; 28 | } 29 | .info { 30 | h5 { 31 | margin-bottom: 0.5rem; 32 | } 33 | p { 34 | margin: 0; 35 | text-transform: capitalize; 36 | letter-spacing: var(--letter-spacing); 37 | color: var(--text-secondary-color); 38 | } 39 | } 40 | .content { 41 | padding: 1rem 1.5rem; 42 | } 43 | .content-center { 44 | display: grid; 45 | margin-top: 1rem; 46 | margin-bottom: 1.5rem; 47 | grid-template-columns: 1fr; 48 | row-gap: 1.5rem; 49 | align-items: center; 50 | @media (min-width: 576px) { 51 | grid-template-columns: 1fr 1fr; 52 | } 53 | } 54 | .status { 55 | border-radius: var(--border-radius); 56 | text-transform: capitalize; 57 | letter-spacing: var(--letter-spacing); 58 | text-align: center; 59 | width: 100px; 60 | height: 30px; 61 | display: grid; 62 | align-items: center; 63 | } 64 | .actions { 65 | margin-top: 1rem; 66 | display: flex; 67 | align-items: center; 68 | } 69 | .edit-btn, 70 | .delete-btn { 71 | height: 30px; 72 | font-size: 0.85rem; 73 | display: flex; 74 | align-items: center; 75 | } 76 | .edit-btn { 77 | margin-right: 0.5rem; 78 | } 79 | `; 80 | 81 | export default Wrapper; 82 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/JobInfo.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.div` 4 | display: flex; 5 | align-items: center; 6 | .job-icon { 7 | font-size: 1rem; 8 | margin-right: 1rem; 9 | display: flex; 10 | align-items: center; 11 | svg { 12 | color: var(--text-secondary-color); 13 | } 14 | } 15 | .job-text { 16 | text-transform: capitalize; 17 | letter-spacing: var(--letter-spacing); 18 | } 19 | `; 20 | export default Wrapper; 21 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/JobsContainer.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.section` 4 | margin-top: 4rem; 5 | h2 { 6 | text-transform: none; 7 | } 8 | & > h5 { 9 | font-weight: 700; 10 | margin-bottom: 1.5rem; 11 | } 12 | .jobs { 13 | display: grid; 14 | grid-template-columns: 1fr; 15 | row-gap: 2rem; 16 | } 17 | @media (min-width: 1120px) { 18 | .jobs { 19 | grid-template-columns: 1fr 1fr; 20 | gap: 2rem; 21 | } 22 | } 23 | `; 24 | export default Wrapper; 25 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/LandingPage.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.section` 4 | nav { 5 | width: var(--fluid-width); 6 | max-width: var(--max-width); 7 | margin: 0 auto; 8 | height: var(--nav-height); 9 | display: flex; 10 | align-items: center; 11 | } 12 | .page { 13 | min-height: calc(100vh - var(--nav-height)); 14 | display: grid; 15 | align-items: center; 16 | margin-top: -3rem; 17 | } 18 | h1 { 19 | font-weight: 700; 20 | span { 21 | color: var(--primary-500); 22 | } 23 | margin-bottom: 1.5rem; 24 | } 25 | p { 26 | line-height: 2; 27 | color: var(--text-secondary-color); 28 | margin-bottom: 1.5rem; 29 | max-width: 35em; 30 | } 31 | .register-link { 32 | margin-right: 1rem; 33 | } 34 | .main-img { 35 | display: none; 36 | } 37 | .btn { 38 | padding: 0.75rem 1rem; 39 | } 40 | @media (min-width: 992px) { 41 | .page { 42 | grid-template-columns: 1fr 400px; 43 | column-gap: 3rem; 44 | } 45 | .main-img { 46 | display: block; 47 | } 48 | } 49 | `; 50 | export default Wrapper; 51 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/LogoutContainer.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.div` 4 | position: relative; 5 | .logout-btn { 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | gap: 0 0.5rem; 10 | } 11 | .img { 12 | width: 25px; 13 | height: 25px; 14 | border-radius: 50%; 15 | } 16 | .dropdown { 17 | position: absolute; 18 | top: 45px; 19 | left: 0; 20 | width: 100%; 21 | box-shadow: var(--shadow-2); 22 | text-align: center; 23 | visibility: hidden; 24 | border-radius: var(--border-radius); 25 | background: var(--primary-500); 26 | } 27 | .show-dropdown { 28 | visibility: visible; 29 | } 30 | .dropdown-btn { 31 | border-radius: var(--border-radius); 32 | padding: 0.5rem; 33 | background: transparent; 34 | border-color: transparent; 35 | color: var(--white); 36 | letter-spacing: var(--letter-spacing); 37 | text-transform: capitalize; 38 | cursor: pointer; 39 | width: 100%; 40 | height: 100%; 41 | } 42 | `; 43 | 44 | export default Wrapper; 45 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/Navbar.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.nav` 4 | height: var(--nav-height); 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.1); 9 | background: var(--background-secondary-color); 10 | .nav-center { 11 | display: flex; 12 | width: 90vw; 13 | align-items: center; 14 | justify-content: space-between; 15 | } 16 | .toggle-btn { 17 | background: transparent; 18 | border-color: transparent; 19 | font-size: 1.75rem; 20 | color: var(--primary-500); 21 | cursor: pointer; 22 | display: flex; 23 | align-items: center; 24 | } 25 | .logo-text { 26 | display: none; 27 | } 28 | .logo { 29 | display: flex; 30 | align-items: center; 31 | width: 100px; 32 | } 33 | .btn-container { 34 | display: flex; 35 | align-items: center; 36 | } 37 | @media (min-width: 992px) { 38 | position: sticky; 39 | top: 0; 40 | .nav-center { 41 | width: 90%; 42 | } 43 | .logo { 44 | display: none; 45 | } 46 | .logo-text { 47 | display: block; 48 | } 49 | } 50 | `; 51 | export default Wrapper; 52 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/PageBtnContainer.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.section` 4 | height: 6rem; 5 | margin-top: 2rem; 6 | display: flex; 7 | align-items: center; 8 | justify-content: end; 9 | flex-wrap: wrap; 10 | gap: 1rem; 11 | .btn-container { 12 | background: var(--background-secondary-color); 13 | border-radius: var(--border-radius); 14 | display: flex; 15 | } 16 | .page-btn { 17 | background: transparent; 18 | border-color: transparent; 19 | width: 50px; 20 | height: 40px; 21 | font-weight: 700; 22 | font-size: 1.25rem; 23 | color: var(--primary-500); 24 | border-radius: var(--border-radius); 25 | cursor:pointer: 26 | } 27 | .active{ 28 | background:var(--primary-500); 29 | color: var(--white); 30 | 31 | } 32 | .prev-btn,.next-btn{ 33 | background: var(--background-secondary-color); 34 | border-color: transparent; 35 | border-radius: var(--border-radius); 36 | 37 | width: 100px; 38 | height: 40px; 39 | color: var(--primary-500); 40 | text-transform:capitalize; 41 | letter-spacing:var(--letter-spacing); 42 | display:flex; 43 | align-items:center; 44 | justify-content:center; 45 | gap:0.5rem; 46 | cursor:pointer; 47 | } 48 | .prev-btn:hover,.next-btn:hover{ 49 | background:var(--primary-500); 50 | color: var(--white); 51 | transition:var(--transition); 52 | } 53 | .dots{ 54 | display:grid; 55 | place-items:center; 56 | cursor:text; 57 | } 58 | `; 59 | export default Wrapper; 60 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/RegisterAndLoginPage.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.section` 4 | min-height: 100vh; 5 | display: grid; 6 | align-items: center; 7 | .logo { 8 | display: block; 9 | margin: 0 auto; 10 | margin-bottom: 1.38rem; 11 | } 12 | .form { 13 | max-width: 400px; 14 | border-top: 5px solid var(--primary-500); 15 | } 16 | h4 { 17 | text-align: center; 18 | margin-bottom: 1.38rem; 19 | } 20 | p { 21 | margin-top: 1rem; 22 | text-align: center; 23 | line-height: 1.5; 24 | } 25 | .btn { 26 | margin-top: 1rem; 27 | } 28 | .member-btn { 29 | color: var(--primary-500); 30 | letter-spacing: var(--letter-spacing); 31 | margin-left: 0.25rem; 32 | } 33 | `; 34 | export default Wrapper; 35 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/SmallSidebar.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.aside` 4 | @media (min-width: 992px) { 5 | display: none; 6 | } 7 | .sidebar-container { 8 | position: fixed; 9 | inset: 0; 10 | background: rgba(0, 0, 0, 0.7); 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | z-index: -1; 15 | opacity: 0; 16 | transition: var(--transition); 17 | visibility: hidden; 18 | } 19 | .show-sidebar { 20 | z-index: 99; 21 | opacity: 1; 22 | visibility: visible; 23 | } 24 | .content { 25 | background: var(--background-secondary-color); 26 | width: var(--fluid-width); 27 | height: 95vh; 28 | border-radius: var(--border-radius); 29 | padding: 4rem 2rem; 30 | position: relative; 31 | display: flex; 32 | align-items: center; 33 | flex-direction: column; 34 | } 35 | .close-btn { 36 | position: absolute; 37 | top: 10px; 38 | left: 10px; 39 | background: transparent; 40 | border-color: transparent; 41 | font-size: 2rem; 42 | color: var(--red-dark); 43 | cursor: pointer; 44 | } 45 | .nav-links { 46 | padding-top: 2rem; 47 | display: flex; 48 | flex-direction: column; 49 | } 50 | .nav-link { 51 | display: flex; 52 | align-items: center; 53 | color: var(--text-secondary-color); 54 | padding: 1rem 0; 55 | text-transform: capitalize; 56 | transition: var(--transition); 57 | } 58 | .nav-link:hover { 59 | color: var(--primary-500); 60 | } 61 | .icon { 62 | font-size: 1.5rem; 63 | margin-right: 1rem; 64 | display: grid; 65 | place-items: center; 66 | } 67 | .active { 68 | color: var(--primary-500); 69 | } 70 | `; 71 | export default Wrapper; 72 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/StatItem.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.article` 4 | padding: 2rem; 5 | background: var(--background-secondary-color); 6 | border-bottom: 5px solid ${(props) => props.color}; 7 | border-radius: var(--border-radius); 8 | 9 | header { 10 | display: flex; 11 | align-items: center; 12 | justify-content: space-between; 13 | } 14 | .count { 15 | display: block; 16 | font-weight: 700; 17 | font-size: 50px; 18 | color: ${(props) => props.color}; 19 | line-height: 2; 20 | } 21 | .title { 22 | margin: 0; 23 | text-transform: capitalize; 24 | letter-spacing: var(--letter-spacing); 25 | text-align: left; 26 | margin-top: 0.5rem; 27 | font-size: 1.25rem; 28 | } 29 | .icon { 30 | width: 70px; 31 | height: 60px; 32 | background: ${(props) => props.bcg}; 33 | border-radius: var(--border-radius); 34 | display: flex; 35 | align-items: center; 36 | justify-content: center; 37 | svg { 38 | font-size: 2rem; 39 | color: ${(props) => props.color}; 40 | } 41 | } 42 | `; 43 | 44 | export default Wrapper; 45 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/StatsContainer.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.section` 4 | display: grid; 5 | row-gap: 2rem; 6 | @media (min-width: 768px) { 7 | grid-template-columns: 1fr 1fr; 8 | column-gap: 1rem; 9 | } 10 | @media (min-width: 1120px) { 11 | grid-template-columns: 1fr 1fr 1fr; 12 | } 13 | `; 14 | export default Wrapper; 15 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/Testing.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Wrapper = styled.main` 4 | nav { 5 | width: var(--fluid-width); 6 | max-width: var(--max-width); 7 | margin: 0 auto; 8 | height: var(--nav-height); 9 | display: flex; 10 | align-items: center; 11 | } 12 | .page { 13 | min-height: calc(100vh - var(--nav-height)); 14 | display: grid; 15 | align-items: center; 16 | margin-top: -3rem; 17 | } 18 | h1 { 19 | font-weight: 700; 20 | span { 21 | color: var(--primary-500); 22 | } 23 | } 24 | p { 25 | color: var(--grey-600); 26 | } 27 | .main-img { 28 | display: none; 29 | } 30 | @media (min-width: 992px) { 31 | .page { 32 | grid-template-columns: 1fr 1fr; 33 | column-gap: 3rem; 34 | } 35 | .main-img { 36 | display: block; 37 | } 38 | } 39 | ` 40 | export default Wrapper 41 | -------------------------------------------------------------------------------- /client/src/assets/wrappers/ThemeToggle.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.button` 4 | background: transparent; 5 | border-color: transparent; 6 | width: 3.5rem; 7 | height: 2rem; 8 | display: grid; 9 | place-items: center; 10 | cursor: pointer; 11 | .toggle-icon { 12 | font-size: 1.15rem; 13 | color: var(--text-color); 14 | } 15 | `; 16 | export default Wrapper; 17 | -------------------------------------------------------------------------------- /client/src/components/AreaChart.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | ResponsiveContainer, 3 | AreaChart, 4 | Area, 5 | XAxis, 6 | YAxis, 7 | CartesianGrid, 8 | Tooltip, 9 | } from 'recharts'; 10 | 11 | const AreaChartComponent = ({ data }) => { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | export default AreaChartComponent; 25 | -------------------------------------------------------------------------------- /client/src/components/BarChart.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | BarChart, 3 | Bar, 4 | XAxis, 5 | YAxis, 6 | CartesianGrid, 7 | Tooltip, 8 | ResponsiveContainer, 9 | } from 'recharts'; 10 | 11 | const BarChartComponent = ({ data }) => { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | export default BarChartComponent; 25 | -------------------------------------------------------------------------------- /client/src/components/BigSidebar.jsx: -------------------------------------------------------------------------------- 1 | import Wrapper from '../assets/wrappers/BigSidebar'; 2 | import NavLinks from './NavLinks'; 3 | import Logo from './Logo'; 4 | import { useDashboardContext } from '../pages/DashboardLayout'; 5 | const BigSidebar = () => { 6 | const { showSidebar } = useDashboardContext(); 7 | 8 | return ( 9 | 10 |
15 |
16 |
17 | 18 |
19 | 20 |
21 |
22 |
23 | ); 24 | }; 25 | export default BigSidebar; 26 | -------------------------------------------------------------------------------- /client/src/components/ChartsContainer.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import BarChart from './BarChart'; 4 | import AreaChart from './AreaChart'; 5 | import Wrapper from '../assets/wrappers/ChartsContainer'; 6 | const ChartsContainer = ({ data }) => { 7 | const [barChart, setBarChart] = useState(true); 8 | 9 | return ( 10 | 11 |

Monthly Applications

12 | 15 | {barChart ? : } 16 |
17 | ); 18 | }; 19 | export default ChartsContainer; 20 | -------------------------------------------------------------------------------- /client/src/components/ErrorElement.jsx: -------------------------------------------------------------------------------- 1 | import { useRouteError } from 'react-router-dom'; 2 | 3 | const ErrorElement = () => { 4 | const error = useRouteError(); 5 | console.log(error); 6 | return

There was an error...

; 7 | }; 8 | export default ErrorElement; 9 | -------------------------------------------------------------------------------- /client/src/components/FormRow.jsx: -------------------------------------------------------------------------------- 1 | const FormRow = ({ type, name, labelText, defaultValue, onChange }) => { 2 | return ( 3 |
4 | 7 | 16 |
17 | ); 18 | }; 19 | export default FormRow; 20 | -------------------------------------------------------------------------------- /client/src/components/FormRowSelect.jsx: -------------------------------------------------------------------------------- 1 | const FormRowSelect = ({ 2 | name, 3 | labelText, 4 | list, 5 | defaultValue = '', 6 | onChange, 7 | }) => { 8 | return ( 9 |
10 | 13 | 28 |
29 | ); 30 | }; 31 | export default FormRowSelect; 32 | -------------------------------------------------------------------------------- /client/src/components/Job.jsx: -------------------------------------------------------------------------------- 1 | import { FaLocationArrow, FaBriefcase, FaCalendarAlt } from 'react-icons/fa'; 2 | import { Link, Form } from 'react-router-dom'; 3 | import Wrapper from '../assets/wrappers/Job'; 4 | import JobInfo from './JobInfo'; 5 | import day from 'dayjs'; 6 | import advancedFormat from 'dayjs/plugin/advancedFormat'; 7 | day.extend(advancedFormat); 8 | 9 | const Job = ({ 10 | _id, 11 | position, 12 | company, 13 | jobLocation, 14 | jobType, 15 | createdAt, 16 | jobStatus, 17 | }) => { 18 | const date = day(createdAt).format('MMM Do, YYYY'); 19 | return ( 20 | 21 |
22 |
{company.charAt(0)}
23 |
24 |
{position}
25 |

{company}

26 |
27 |
28 |
29 |
30 | } text={jobLocation} /> 31 | } text={date} /> 32 | } text={jobType} /> 33 |
{jobStatus}
34 |
35 |
36 | 37 | Edit 38 | 39 |
40 | 43 |
44 |
45 |
46 |
47 | ); 48 | }; 49 | export default Job; 50 | -------------------------------------------------------------------------------- /client/src/components/JobInfo.jsx: -------------------------------------------------------------------------------- 1 | import Wrapper from '../assets/wrappers/JobInfo'; 2 | 3 | const JobInfo = ({ icon, text }) => { 4 | return ( 5 | 6 | {icon} 7 | {text} 8 | 9 | ); 10 | }; 11 | export default JobInfo; 12 | -------------------------------------------------------------------------------- /client/src/components/JobsContainer.jsx: -------------------------------------------------------------------------------- 1 | import Job from './Job'; 2 | import Wrapper from '../assets/wrappers/JobsContainer'; 3 | import { useAllJobsContext } from '../pages/AllJobs'; 4 | import PageBtnContainer from './PageBtnContainer'; 5 | const JobsContainer = () => { 6 | const { data } = useAllJobsContext(); 7 | 8 | const { jobs, totalJobs, numOfPages } = data; 9 | if (jobs.length === 0) { 10 | return ( 11 | 12 |

No jobs to display...

13 |
14 | ); 15 | } 16 | return ( 17 | 18 |
19 | {totalJobs} job{jobs.length > 1 && 's'} found 20 |
21 |
22 | {jobs.map((job) => { 23 | return ; 24 | })} 25 |
26 | {numOfPages > 1 && } 27 |
28 | ); 29 | }; 30 | export default JobsContainer; 31 | -------------------------------------------------------------------------------- /client/src/components/Loading.jsx: -------------------------------------------------------------------------------- 1 | const Loading = () => { 2 | return
; 3 | }; 4 | export default Loading; 5 | -------------------------------------------------------------------------------- /client/src/components/Logo.jsx: -------------------------------------------------------------------------------- 1 | import logo from '../assets/images/logo.svg'; 2 | 3 | const Logo = () => { 4 | return jobify; 5 | }; 6 | 7 | export default Logo; 8 | -------------------------------------------------------------------------------- /client/src/components/LogoutContainer.jsx: -------------------------------------------------------------------------------- 1 | import { FaUserCircle, FaCaretDown } from 'react-icons/fa'; 2 | import Wrapper from '../assets/wrappers/LogoutContainer'; 3 | import { useState } from 'react'; 4 | import { useDashboardContext } from '../pages/DashboardLayout'; 5 | 6 | const LogoutContainer = () => { 7 | const [showLogout, setShowLogout] = useState(false); 8 | const { user, logoutUser } = useDashboardContext(); 9 | 10 | return ( 11 | 12 | 25 |
26 | 29 |
30 |
31 | ); 32 | }; 33 | export default LogoutContainer; 34 | -------------------------------------------------------------------------------- /client/src/components/NavLinks.jsx: -------------------------------------------------------------------------------- 1 | import { useDashboardContext } from '../pages/DashboardLayout'; 2 | import links from '../utils/links'; 3 | import { NavLink } from 'react-router-dom'; 4 | 5 | const NavLinks = ({ isBigSidebar }) => { 6 | const { toggleSidebar, user } = useDashboardContext(); 7 | return ( 8 |
9 | {links.map((link) => { 10 | const { text, path, icon } = link; 11 | const { role } = user; 12 | if (path === 'admin' && role !== 'admin') return; 13 | return ( 14 | 21 | {icon} 22 | {text} 23 | 24 | ); 25 | })} 26 |
27 | ); 28 | }; 29 | export default NavLinks; 30 | -------------------------------------------------------------------------------- /client/src/components/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import Wrapper from '../assets/wrappers/Navbar'; 2 | import { FaAlignLeft } from 'react-icons/fa'; 3 | import Logo from './Logo'; 4 | import { useDashboardContext } from '../pages/DashboardLayout'; 5 | import LogoutContainer from './LogoutContainer'; 6 | import ThemeToggle from './ThemeToggle'; 7 | const Navbar = () => { 8 | const { toggleSidebar } = useDashboardContext(); 9 | return ( 10 | 11 |
12 | 15 |
16 | 17 |

dashboard

18 |
19 |
20 | 21 | 22 |
23 |
24 |
25 | ); 26 | }; 27 | export default Navbar; 28 | -------------------------------------------------------------------------------- /client/src/components/PageBtnContainer.jsx: -------------------------------------------------------------------------------- 1 | import { HiChevronDoubleLeft, HiChevronDoubleRight } from 'react-icons/hi'; 2 | import Wrapper from '../assets/wrappers/PageBtnContainer'; 3 | import { useLocation, Link, useNavigate } from 'react-router-dom'; 4 | import { useAllJobsContext } from '../pages/AllJobs'; 5 | 6 | const PageBtnContainer = () => { 7 | const { 8 | data: { numOfPages, currentPage }, 9 | } = useAllJobsContext(); 10 | const pages = Array.from({ length: numOfPages }, (_, index) => { 11 | return index + 1; 12 | }); 13 | 14 | const { search, pathname } = useLocation(); 15 | const navigate = useNavigate(); 16 | 17 | const handlePageChange = (pageNumber) => { 18 | const searchParams = new URLSearchParams(search); 19 | searchParams.set('page', pageNumber); 20 | navigate(`${pathname}?${searchParams.toString()}`); 21 | }; 22 | 23 | const addPageButton = ({ pageNumber, activeClass }) => { 24 | return ( 25 | 32 | ); 33 | }; 34 | 35 | const renderPageButtons = () => { 36 | const pageButtons = []; 37 | // first page 38 | pageButtons.push( 39 | addPageButton({ pageNumber: 1, activeClass: currentPage === 1 }) 40 | ); 41 | // dots 42 | 43 | if (currentPage > 3) { 44 | pageButtons.push( 45 | 46 | ... 47 | 48 | ); 49 | } 50 | // one before current page 51 | if (currentPage !== 1 && currentPage !== 2) { 52 | pageButtons.push( 53 | addPageButton({ 54 | pageNumber: currentPage - 1, 55 | activeClass: false, 56 | }) 57 | ); 58 | } 59 | // current page 60 | if (currentPage !== 1 && currentPage !== numOfPages) { 61 | pageButtons.push( 62 | addPageButton({ 63 | pageNumber: currentPage, 64 | activeClass: true, 65 | }) 66 | ); 67 | } 68 | // one after current page 69 | 70 | if (currentPage !== numOfPages && currentPage !== numOfPages - 1) { 71 | pageButtons.push( 72 | addPageButton({ 73 | pageNumber: currentPage + 1, 74 | activeClass: false, 75 | }) 76 | ); 77 | } 78 | if (currentPage < numOfPages - 2) { 79 | pageButtons.push( 80 | 81 | ... 82 | 83 | ); 84 | } 85 | pageButtons.push( 86 | addPageButton({ 87 | pageNumber: numOfPages, 88 | activeClass: currentPage === numOfPages, 89 | }) 90 | ); 91 | return pageButtons; 92 | }; 93 | 94 | return ( 95 | 96 | 107 |
{renderPageButtons()}
108 | 119 |
120 | ); 121 | }; 122 | export default PageBtnContainer; 123 | -------------------------------------------------------------------------------- /client/src/components/SearchContainer.jsx: -------------------------------------------------------------------------------- 1 | import { FormRow, FormRowSelect, SubmitBtn } from '.'; 2 | import Wrapper from '../assets/wrappers/DashboardFormPage'; 3 | import { Form, useSubmit, Link } from 'react-router-dom'; 4 | import { JOB_TYPE, JOB_STATUS, JOB_SORT_BY } from '../../../utils/constants'; 5 | import { useAllJobsContext } from '../pages/AllJobs'; 6 | 7 | const SearchContainer = () => { 8 | const { searchValues } = useAllJobsContext(); 9 | const { search, jobStatus, jobType, sort } = searchValues; 10 | const submit = useSubmit(); 11 | 12 | const debounce = (onChange) => { 13 | let timeout; 14 | return (e) => { 15 | const form = e.currentTarget.form; 16 | clearTimeout(timeout); 17 | timeout = setTimeout(() => { 18 | onChange(form); 19 | }, 2000); 20 | }; 21 | }; 22 | return ( 23 | 24 |
25 |
search form
26 |
27 | { 32 | submit(form); 33 | })} 34 | /> 35 | 36 | { 42 | submit(e.currentTarget.form); 43 | }} 44 | /> 45 | { 51 | submit(e.currentTarget.form); 52 | }} 53 | /> 54 | { 59 | submit(e.currentTarget.form); 60 | }} 61 | /> 62 | 63 | Reset Search Values 64 | 65 |
66 |
67 |
68 | ); 69 | }; 70 | export default SearchContainer; 71 | -------------------------------------------------------------------------------- /client/src/components/SmallSidebar.jsx: -------------------------------------------------------------------------------- 1 | import { FaTimes } from 'react-icons/fa'; 2 | import Wrapper from '../assets/wrappers/SmallSidebar'; 3 | import { useDashboardContext } from '../pages/DashboardLayout'; 4 | import Logo from './Logo'; 5 | 6 | import NavLinks from './NavLinks'; 7 | const SmallSidebar = () => { 8 | const { showSidebar, toggleSidebar } = useDashboardContext(); 9 | 10 | return ( 11 | 12 |
17 |
18 | 21 |
22 | 23 |
24 | 25 |
26 |
27 |
28 | ); 29 | }; 30 | export default SmallSidebar; 31 | -------------------------------------------------------------------------------- /client/src/components/StatItem.jsx: -------------------------------------------------------------------------------- 1 | import Wrapper from '../assets/wrappers/StatItem'; 2 | 3 | const StatItem = ({ count, title, icon, color, bcg }) => { 4 | return ( 5 | 6 |
7 | {count} 8 | {icon} 9 |
10 |
{title}
11 |
12 | ); 13 | }; 14 | export default StatItem; 15 | -------------------------------------------------------------------------------- /client/src/components/StatsContainer.jsx: -------------------------------------------------------------------------------- 1 | import { FaSuitcaseRolling, FaCalendarCheck, FaBug } from 'react-icons/fa'; 2 | import Wrapper from '../assets/wrappers/StatsContainer'; 3 | import StatItem from './StatItem'; 4 | const StatsContainer = ({ defaultStats }) => { 5 | const stats = [ 6 | { 7 | title: 'pending applications', 8 | count: defaultStats?.pending || 0, 9 | icon: , 10 | color: '#f59e0b', 11 | bcg: '#fef3c7', 12 | }, 13 | { 14 | title: 'interviews scheduled', 15 | count: defaultStats?.interview || 0, 16 | icon: , 17 | color: '#647acb', 18 | bcg: '#e0e8f9', 19 | }, 20 | { 21 | title: 'jobs declined', 22 | count: defaultStats?.declined || 0, 23 | icon: , 24 | color: '#d66a6a', 25 | bcg: '#ffeeee', 26 | }, 27 | ]; 28 | return ( 29 | 30 | {stats.map((item) => { 31 | return ; 32 | })} 33 | 34 | ); 35 | }; 36 | export default StatsContainer; 37 | -------------------------------------------------------------------------------- /client/src/components/SubmitBtn.jsx: -------------------------------------------------------------------------------- 1 | import { useNavigation } from 'react-router-dom'; 2 | const SubmitBtn = ({ formBtn }) => { 3 | const navigation = useNavigation(); 4 | const isSubmitting = navigation.state === 'submitting'; 5 | return ( 6 | 13 | ); 14 | }; 15 | export default SubmitBtn; 16 | -------------------------------------------------------------------------------- /client/src/components/ThemeToggle.jsx: -------------------------------------------------------------------------------- 1 | import { BsFillSunFill, BsFillMoonFill } from 'react-icons/bs'; 2 | import Wrapper from '../assets/wrappers/ThemeToggle'; 3 | import { useDashboardContext } from '../pages/DashboardLayout'; 4 | 5 | const ThemeToggle = () => { 6 | const { isDarkTheme, toggleDarkTheme } = useDashboardContext(); 7 | return ( 8 | 9 | {isDarkTheme ? ( 10 | 11 | ) : ( 12 | 13 | )} 14 | 15 | ); 16 | }; 17 | export default ThemeToggle; 18 | -------------------------------------------------------------------------------- /client/src/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as Logo } from './Logo'; 2 | export { default as FormRow } from './FormRow'; 3 | export { default as BigSidebar } from './BigSidebar'; 4 | export { default as SmallSidebar } from './SmallSidebar'; 5 | export { default as Navbar } from './Navbar'; 6 | export { default as FormRowSelect } from './FormRowSelect'; 7 | export { default as JobsContainer } from './JobsContainer'; 8 | export { default as SearchContainer } from './SearchContainer'; 9 | export { default as StatItem } from './StatItem'; 10 | export { default as SubmitBtn } from './SubmitBtn'; 11 | export { default as ChartsContainer } from './ChartsContainer'; 12 | export { default as StatsContainer } from './StatsContainer'; 13 | export { default as Loading } from './Loading'; 14 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | /* ============= GLOBAL CSS =============== */ 2 | 3 | *, 4 | ::after, 5 | ::before { 6 | margin: 0; 7 | padding: 0; 8 | box-sizing: border-box; 9 | } 10 | 11 | html { 12 | font-size: 100%; 13 | } /*16px*/ 14 | 15 | :root { 16 | /* colors */ 17 | --primary-50: #e0fcff; 18 | --primary-100: #bef8fd; 19 | --primary-200: #87eaf2; 20 | --primary-300: #54d1db; 21 | --primary-400: #38bec9; 22 | --primary-500: #2cb1bc; 23 | --primary-600: #14919b; 24 | --primary-700: #0e7c86; 25 | --primary-800: #0a6c74; 26 | --primary-900: #044e54; 27 | 28 | /* grey */ 29 | --grey-50: #f8fafc; 30 | --grey-100: #f1f5f9; 31 | --grey-200: #e2e8f0; 32 | --grey-300: #cbd5e1; 33 | --grey-400: #94a3b8; 34 | --grey-500: #64748b; 35 | --grey-600: #475569; 36 | --grey-700: #334155; 37 | --grey-800: #1e293b; 38 | --grey-900: #0f172a; 39 | /* rest of the colors */ 40 | --black: #222; 41 | --white: #fff; 42 | --red-light: #f8d7da; 43 | --red-dark: #842029; 44 | --green-light: #d1e7dd; 45 | --green-dark: #0f5132; 46 | 47 | --small-text: 0.875rem; 48 | --extra-small-text: 0.7em; 49 | /* rest of the vars */ 50 | 51 | --border-radius: 0.25rem; 52 | --letter-spacing: 1px; 53 | --transition: 0.3s ease-in-out all; 54 | --max-width: 1120px; 55 | --fixed-width: 600px; 56 | --fluid-width: 90vw; 57 | --nav-height: 6rem; 58 | /* box shadow*/ 59 | --shadow-1: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); 60 | --shadow-2: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 61 | 0 2px 4px -1px rgba(0, 0, 0, 0.06); 62 | --shadow-3: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 63 | 0 4px 6px -2px rgba(0, 0, 0, 0.05); 64 | --shadow-4: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 65 | 0 10px 10px -5px rgba(0, 0, 0, 0.04); 66 | /* DARK MODE */ 67 | 68 | --dark-mode-bg-color: #333; 69 | --dark-mode-text-color: #f0f0f0; 70 | --dark-mode-bg-secondary-color: #3f3f3f; 71 | --dark-mode-text-secondary-color: var(--grey-300); 72 | 73 | --background-color: var(--grey-50); 74 | --text-color: var(--grey-900); 75 | --background-secondary-color: var(--white); 76 | --text-secondary-color: var(--grey-500); 77 | } 78 | 79 | .dark-theme { 80 | --text-color: var(--dark-mode-text-color); 81 | --background-color: var(--dark-mode-bg-color); 82 | --text-secondary-color: var(--dark-mode-text-secondary-color); 83 | --background-secondary-color: var(--dark-mode-bg-secondary-color); 84 | } 85 | 86 | body { 87 | background: var(--background-color); 88 | color: var(--text-color); 89 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 90 | Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 91 | font-weight: 400; 92 | line-height: 1; 93 | } 94 | p { 95 | margin: 0; 96 | } 97 | h1, 98 | h2, 99 | h3, 100 | h4, 101 | h5 { 102 | margin: 0; 103 | font-weight: 400; 104 | line-height: 1; 105 | text-transform: capitalize; 106 | letter-spacing: var(--letter-spacing); 107 | } 108 | 109 | h1 { 110 | font-size: clamp(2rem, 5vw, 5rem); /* Large heading */ 111 | } 112 | 113 | h2 { 114 | font-size: clamp(1.5rem, 3vw, 3rem); /* Medium heading */ 115 | } 116 | 117 | h3 { 118 | font-size: clamp(1.25rem, 2.5vw, 2.5rem); /* Small heading */ 119 | } 120 | 121 | h4 { 122 | font-size: clamp(1rem, 2vw, 2rem); /* Extra small heading */ 123 | } 124 | 125 | h5 { 126 | font-size: clamp(0.875rem, 1.5vw, 1.5rem); /* Tiny heading */ 127 | } 128 | 129 | /* BIGGER FONTS */ 130 | /* h1 { 131 | font-size: clamp(3rem, 6vw, 6rem); 132 | } 133 | 134 | h2 { 135 | font-size: clamp(2.5rem, 5vw, 5rem); 136 | } 137 | 138 | h3 { 139 | font-size: clamp(2rem, 4vw, 4rem); 140 | } 141 | 142 | h4 { 143 | font-size: clamp(1.5rem, 3vw, 3rem); 144 | } 145 | 146 | h5 { 147 | font-size: clamp(1rem, 2vw, 2rem); 148 | } 149 | */ 150 | 151 | .text { 152 | margin-bottom: 1.5rem; 153 | max-width: 40em; 154 | } 155 | 156 | small, 157 | .text-small { 158 | font-size: var(--small-text); 159 | } 160 | 161 | a { 162 | text-decoration: none; 163 | } 164 | ul { 165 | list-style-type: none; 166 | padding: 0; 167 | } 168 | 169 | .img { 170 | width: 100%; 171 | display: block; 172 | object-fit: cover; 173 | } 174 | /* buttons */ 175 | 176 | .btn { 177 | cursor: pointer; 178 | color: var(--white); 179 | background: var(--primary-500); 180 | border: transparent; 181 | border-radius: var(--border-radius); 182 | letter-spacing: var(--letter-spacing); 183 | padding: 0.375rem 0.75rem; 184 | box-shadow: var(--shadow-1); 185 | transition: var(--transition); 186 | text-transform: capitalize; 187 | display: inline-block; 188 | } 189 | .btn:hover { 190 | background: var(--primary-700); 191 | box-shadow: var(--shadow-3); 192 | } 193 | .btn-hipster { 194 | color: var(--primary-500); 195 | background: var(--primary-200); 196 | } 197 | .btn-hipster:hover { 198 | color: var(--primary-200); 199 | background: var(--primary-700); 200 | } 201 | .btn-block { 202 | width: 100%; 203 | } 204 | button:disabled { 205 | cursor: wait; 206 | } 207 | .danger-btn { 208 | color: var(--red-dark); 209 | background: var(--red-light); 210 | } 211 | .danger-btn:hover { 212 | color: var(--white); 213 | background: var(--red-dark); 214 | } 215 | /* alerts */ 216 | .alert { 217 | padding: 0.375rem 0.75rem; 218 | margin-bottom: 1rem; 219 | border-color: transparent; 220 | border-radius: var(--border-radius); 221 | } 222 | 223 | .alert-danger { 224 | color: var(--red-dark); 225 | background: var(--red-light); 226 | } 227 | .alert-success { 228 | color: var(--green-dark); 229 | background: var(--green-light); 230 | } 231 | /* form */ 232 | 233 | .form { 234 | width: 90vw; 235 | max-width: var(--fixed-width); 236 | background: var(--background-secondary-color); 237 | border-radius: var(--border-radius); 238 | box-shadow: var(--shadow-2); 239 | padding: 2rem 2.5rem; 240 | margin: 3rem auto; 241 | } 242 | .form-label { 243 | display: block; 244 | font-size: var(--small-text); 245 | margin-bottom: 0.75rem; 246 | text-transform: capitalize; 247 | letter-spacing: var(--letter-spacing); 248 | line-height: 1.5; 249 | } 250 | .form-input, 251 | .form-textarea, 252 | .form-select { 253 | width: 100%; 254 | padding: 0.375rem 0.75rem; 255 | border-radius: var(--border-radius); 256 | background: var(--background-color); 257 | border: 1px solid var(--grey-300); 258 | color: var(--text-color); 259 | } 260 | .form-input, 261 | .form-select, 262 | .form-btn { 263 | height: 35px; 264 | } 265 | .form-row { 266 | margin-bottom: 1rem; 267 | } 268 | 269 | .form-textarea { 270 | height: 7rem; 271 | } 272 | ::placeholder { 273 | font-family: inherit; 274 | color: var(--grey-400); 275 | } 276 | .form-alert { 277 | color: var(--red-dark); 278 | letter-spacing: var(--letter-spacing); 279 | text-transform: capitalize; 280 | } 281 | /* alert */ 282 | 283 | @keyframes spinner { 284 | to { 285 | transform: rotate(360deg); 286 | } 287 | } 288 | 289 | .loading { 290 | width: 6rem; 291 | height: 6rem; 292 | border: 5px solid var(--grey-400); 293 | border-radius: 50%; 294 | border-top-color: var(--primary-500); 295 | animation: spinner 0.6s linear infinite; 296 | } 297 | 298 | /* title */ 299 | 300 | .title { 301 | text-align: center; 302 | } 303 | 304 | .title-underline { 305 | background: var(--primary-500); 306 | width: 7rem; 307 | height: 0.25rem; 308 | margin: 0 auto; 309 | margin-top: 1rem; 310 | } 311 | 312 | .container { 313 | width: var(--fluid-width); 314 | max-width: var(--max-width); 315 | margin: 0 auto; 316 | } 317 | 318 | /* BUTTONS AND BADGES */ 319 | .pending { 320 | background: #fef3c7; 321 | color: #f59e0b; 322 | } 323 | 324 | .interview { 325 | background: #e0e8f9; 326 | color: #647acb; 327 | } 328 | .declined { 329 | background: #ffeeee; 330 | color: #d66a6a; 331 | } 332 | -------------------------------------------------------------------------------- /client/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App.jsx'; 4 | import 'react-toastify/dist/ReactToastify.css'; 5 | import './index.css'; 6 | import { ToastContainer } from 'react-toastify'; 7 | ReactDOM.createRoot(document.getElementById('root')).render( 8 | <> 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /client/src/pages/AddJob.jsx: -------------------------------------------------------------------------------- 1 | import { FormRow, FormRowSelect, SubmitBtn } from '../components'; 2 | import Wrapper from '../assets/wrappers/DashboardFormPage'; 3 | import { useOutletContext } from 'react-router-dom'; 4 | import { JOB_STATUS, JOB_TYPE } from '../../../utils/constants'; 5 | import { Form, redirect } from 'react-router-dom'; 6 | import { toast } from 'react-toastify'; 7 | import customFetch from '../utils/customFetch'; 8 | 9 | export const action = 10 | (queryClient) => 11 | async ({ request }) => { 12 | const formData = await request.formData(); 13 | const data = Object.fromEntries(formData); 14 | try { 15 | await customFetch.post('/jobs', data); 16 | queryClient.invalidateQueries(['jobs']); 17 | toast.success('Job added successfully '); 18 | return redirect('all-jobs'); 19 | } catch (error) { 20 | toast.error(error?.response?.data?.msg); 21 | return error; 22 | } 23 | }; 24 | 25 | const AddJob = () => { 26 | const { user } = useOutletContext(); 27 | 28 | return ( 29 | 30 |
31 |

add job

32 |
33 | 34 | 35 | 41 | 47 | 53 | 54 |
55 |
56 |
57 | ); 58 | }; 59 | export default AddJob; 60 | -------------------------------------------------------------------------------- /client/src/pages/Admin.jsx: -------------------------------------------------------------------------------- 1 | import { FaSuitcaseRolling, FaCalendarCheck } from 'react-icons/fa'; 2 | import { useLoaderData, redirect } from 'react-router-dom'; 3 | import customFetch from '../utils/customFetch'; 4 | import Wrapper from '../assets/wrappers/StatsContainer'; 5 | import { toast } from 'react-toastify'; 6 | import { StatItem } from '../components'; 7 | 8 | export const loader = async () => { 9 | try { 10 | const response = await customFetch.get('/users/admin/app-stats'); 11 | return response.data; 12 | } catch (error) { 13 | toast.error('You are not authorized to view this page'); 14 | return redirect('/dashboard'); 15 | } 16 | }; 17 | 18 | const Admin = () => { 19 | const { users, jobs } = useLoaderData(); 20 | return ( 21 | 22 | } 28 | /> 29 | } 35 | /> 36 | 37 | ); 38 | }; 39 | export default Admin; 40 | -------------------------------------------------------------------------------- /client/src/pages/AllJobs.jsx: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify'; 2 | import { JobsContainer, SearchContainer } from '../components'; 3 | import customFetch from '../utils/customFetch'; 4 | import { useLoaderData } from 'react-router-dom'; 5 | import { useContext, createContext } from 'react'; 6 | import { useQuery } from '@tanstack/react-query'; 7 | 8 | const allJobsQuery = (params) => { 9 | const { search, jobStatus, jobType, sort, page } = params; 10 | return { 11 | queryKey: [ 12 | 'jobs', 13 | search ?? '', 14 | jobStatus ?? 'all', 15 | jobType ?? 'all', 16 | sort ?? 'newest', 17 | page ?? 1, 18 | ], 19 | queryFn: async () => { 20 | const { data } = await customFetch.get('/jobs', { 21 | params, 22 | }); 23 | return data; 24 | }, 25 | }; 26 | }; 27 | 28 | export const loader = 29 | (queryClient) => 30 | async ({ request }) => { 31 | const params = Object.fromEntries([ 32 | ...new URL(request.url).searchParams.entries(), 33 | ]); 34 | 35 | await queryClient.ensureQueryData(allJobsQuery(params)); 36 | return { searchValues: { ...params } }; 37 | }; 38 | 39 | const AllJobsContext = createContext(); 40 | const AllJobs = () => { 41 | const { searchValues } = useLoaderData(); 42 | const { data } = useQuery(allJobsQuery(searchValues)); 43 | return ( 44 | 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export const useAllJobsContext = () => useContext(AllJobsContext); 52 | 53 | export default AllJobs; 54 | -------------------------------------------------------------------------------- /client/src/pages/DashboardLayout.jsx: -------------------------------------------------------------------------------- 1 | import { Outlet, redirect, useNavigate, useNavigation } from 'react-router-dom'; 2 | import Wrapper from '../assets/wrappers/Dashboard'; 3 | import { BigSidebar, Navbar, SmallSidebar, Loading } from '../components'; 4 | import { createContext, useContext, useEffect, useState } from 'react'; 5 | import customFetch from '../utils/customFetch'; 6 | import { toast } from 'react-toastify'; 7 | import { useQuery } from '@tanstack/react-query'; 8 | import { checkDefaultTheme } from '../App'; 9 | const userQuery = { 10 | queryKey: ['user'], 11 | queryFn: async () => { 12 | const { data } = await customFetch.get('/users/current-user'); 13 | return data; 14 | }, 15 | }; 16 | 17 | export const loader = (queryClient) => async () => { 18 | try { 19 | return await queryClient.ensureQueryData(userQuery); 20 | } catch (error) { 21 | return redirect('/'); 22 | } 23 | }; 24 | 25 | const DashboardContext = createContext(); 26 | 27 | const DashboardLayout = ({ queryClient }) => { 28 | const { user } = useQuery(userQuery).data; 29 | const navigate = useNavigate(); 30 | const navigation = useNavigation(); 31 | const isPageLoading = navigation.state === 'loading'; 32 | const [showSidebar, setShowSidebar] = useState(false); 33 | const [isDarkTheme, setIsDarkTheme] = useState(checkDefaultTheme()); 34 | const [isAuthError, setIsAuthError] = useState(false); 35 | 36 | const toggleDarkTheme = () => { 37 | const newDarkTheme = !isDarkTheme; 38 | setIsDarkTheme(newDarkTheme); 39 | document.body.classList.toggle('dark-theme', newDarkTheme); 40 | localStorage.setItem('darkTheme', newDarkTheme); 41 | }; 42 | 43 | const toggleSidebar = () => { 44 | setShowSidebar(!showSidebar); 45 | }; 46 | 47 | const logoutUser = async () => { 48 | navigate('/'); 49 | await customFetch.get('/auth/logout'); 50 | queryClient.invalidateQueries(); 51 | toast.success('Logging out...'); 52 | }; 53 | 54 | customFetch.interceptors.response.use( 55 | (response) => { 56 | return response; 57 | }, 58 | (error) => { 59 | if (error?.response?.status === 401) { 60 | setIsAuthError(true); 61 | } 62 | return Promise.reject(error); 63 | } 64 | ); 65 | 66 | useEffect(() => { 67 | if (!isAuthError) return; 68 | logoutUser(); 69 | }, [isAuthError]); 70 | 71 | return ( 72 | 82 | 83 |
84 | 85 | 86 |
87 | 88 |
89 | {isPageLoading ? : } 90 |
91 |
92 |
93 |
94 |
95 | ); 96 | }; 97 | export const useDashboardContext = () => useContext(DashboardContext); 98 | export default DashboardLayout; 99 | -------------------------------------------------------------------------------- /client/src/pages/DeleteJob.jsx: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify'; 2 | import customFetch from '../utils/customFetch'; 3 | import { redirect } from 'react-router-dom'; 4 | 5 | export const action = 6 | (queryClient) => 7 | async ({ params }) => { 8 | try { 9 | await customFetch.delete(`/jobs/${params.id}`); 10 | queryClient.invalidateQueries(['jobs']); 11 | 12 | toast.success('Job deleted successfully'); 13 | } catch (error) { 14 | toast.error(error?.response?.data?.msg); 15 | } 16 | return redirect('/dashboard/all-jobs'); 17 | }; 18 | -------------------------------------------------------------------------------- /client/src/pages/EditJob.jsx: -------------------------------------------------------------------------------- 1 | import { FormRow, FormRowSelect, SubmitBtn } from '../components'; 2 | import Wrapper from '../assets/wrappers/DashboardFormPage'; 3 | import { useLoaderData, useParams } from 'react-router-dom'; 4 | import { JOB_STATUS, JOB_TYPE } from '../../../utils/constants'; 5 | import { Form, redirect } from 'react-router-dom'; 6 | import { toast } from 'react-toastify'; 7 | import customFetch from '../utils/customFetch'; 8 | import { useQuery } from '@tanstack/react-query'; 9 | 10 | const singleJobQuery = (id) => { 11 | return { 12 | queryKey: ['job', id], 13 | queryFn: async () => { 14 | const { data } = await customFetch.get(`/jobs/${id}`); 15 | return data; 16 | }, 17 | }; 18 | }; 19 | 20 | export const loader = 21 | (queryClient) => 22 | async ({ params }) => { 23 | try { 24 | await queryClient.ensureQueryData(singleJobQuery(params.id)); 25 | return params.id; 26 | } catch (error) { 27 | toast.error(error?.response?.data?.msg); 28 | return redirect('/dashboard/all-jobs'); 29 | } 30 | }; 31 | export const action = 32 | (queryClient) => 33 | async ({ request, params }) => { 34 | const formData = await request.formData(); 35 | const data = Object.fromEntries(formData); 36 | try { 37 | await customFetch.patch(`/jobs/${params.id}`, data); 38 | queryClient.invalidateQueries(['jobs']); 39 | 40 | toast.success('Job edited successfully'); 41 | return redirect('/dashboard/all-jobs'); 42 | } catch (error) { 43 | toast.error(error?.response?.data?.msg); 44 | return error; 45 | } 46 | }; 47 | 48 | const EditJob = () => { 49 | const id = useLoaderData(); 50 | 51 | const { 52 | data: { job }, 53 | } = useQuery(singleJobQuery(id)); 54 | 55 | return ( 56 | 57 |
58 |

edit job

59 |
60 | 61 | 62 | 68 | 74 | 80 | 81 |
82 |
83 |
84 | ); 85 | }; 86 | export default EditJob; 87 | -------------------------------------------------------------------------------- /client/src/pages/Error.jsx: -------------------------------------------------------------------------------- 1 | import { Link, useRouteError } from 'react-router-dom'; 2 | import Wrapper from '../assets/wrappers/ErrorPage'; 3 | import img from '../assets/images/not-found.svg'; 4 | const Error = () => { 5 | const error = useRouteError(); 6 | console.log(error); 7 | if (error.status === 404) { 8 | return ( 9 | 10 |
11 | not found 12 |

Ohh! page not found

13 |

we can't seem to find the page you are looking for

14 | back home 15 |
16 |
17 | ); 18 | } 19 | return ( 20 | 21 |
22 |

something went wrong

23 |
24 |
25 | ); 26 | }; 27 | export default Error; 28 | -------------------------------------------------------------------------------- /client/src/pages/HomeLayout.jsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | const HomeLayout = () => { 3 | return ( 4 | <> 5 | 6 | 7 | ); 8 | }; 9 | export default HomeLayout; 10 | -------------------------------------------------------------------------------- /client/src/pages/Landing.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Wrapper from '../assets/wrappers/LandingPage'; 3 | import main from '../assets/images/main.svg'; 4 | import { Link } from 'react-router-dom'; 5 | import { Logo } from '../components'; 6 | 7 | const Landing = () => { 8 | return ( 9 | 10 | 13 |
14 |
15 |

16 | job tracking app 17 |

18 |

19 | I'm baby wayfarers hoodie next level taiyaki brooklyn cliche blue 20 | bottle single-origin coffee chia. Aesthetic post-ironic venmo, 21 | quinoa lo-fi tote bag adaptogen everyday carry meggings +1 brunch 22 | narwhal. 23 |

24 | 25 | Register 26 | 27 | 28 | Login / Demo User 29 | 30 |
31 | job hunt 32 |
33 |
34 | ); 35 | }; 36 | 37 | export default Landing; 38 | -------------------------------------------------------------------------------- /client/src/pages/Login.jsx: -------------------------------------------------------------------------------- 1 | import { Link, Form, redirect, useNavigate } from 'react-router-dom'; 2 | import Wrapper from '../assets/wrappers/RegisterAndLoginPage'; 3 | import { FormRow, Logo, SubmitBtn } from '../components'; 4 | import customFetch from '../utils/customFetch'; 5 | import { toast } from 'react-toastify'; 6 | 7 | export const action = 8 | (queryClient) => 9 | async ({ request }) => { 10 | const formData = await request.formData(); 11 | const data = Object.fromEntries(formData); 12 | try { 13 | await customFetch.post('/auth/login', data); 14 | queryClient.invalidateQueries(); 15 | toast.success('Login successful'); 16 | return redirect('/dashboard'); 17 | } catch (error) { 18 | toast.error(error?.response?.data?.msg); 19 | return error; 20 | } 21 | }; 22 | 23 | const Login = () => { 24 | const navigate = useNavigate(); 25 | 26 | const loginDemoUser = async () => { 27 | const data = { 28 | email: 'test@test.com', 29 | password: 'secret123', 30 | }; 31 | try { 32 | await customFetch.post('/auth/login', data); 33 | toast.success('Take a test drive'); 34 | navigate('/dashboard'); 35 | } catch (error) { 36 | toast.error(error?.response?.data?.msg); 37 | } 38 | }; 39 | return ( 40 | 41 |
42 | 43 |

login

44 | 45 | 46 | 47 | 50 |

51 | Not a member yet? 52 | 53 | Register 54 | 55 |

56 | 57 |
58 | ); 59 | }; 60 | export default Login; 61 | -------------------------------------------------------------------------------- /client/src/pages/Profile.jsx: -------------------------------------------------------------------------------- 1 | import { FormRow, SubmitBtn } from '../components'; 2 | import Wrapper from '../assets/wrappers/DashboardFormPage'; 3 | import { useOutletContext, redirect } from 'react-router-dom'; 4 | import { Form } from 'react-router-dom'; 5 | import customFetch from '../utils/customFetch'; 6 | import { toast } from 'react-toastify'; 7 | 8 | export const action = 9 | (queryClient) => 10 | async ({ request }) => { 11 | const formData = await request.formData(); 12 | const file = formData.get('avatar'); 13 | if (file && file.size > 500000) { 14 | toast.error('Image size too large'); 15 | return null; 16 | } 17 | try { 18 | await customFetch.patch('/users/update-user', formData); 19 | queryClient.invalidateQueries(['user']); 20 | toast.success('Profile updated successfully'); 21 | return redirect('/dashboard'); 22 | } catch (error) { 23 | toast.error(error?.response?.data?.msg); 24 | return null; 25 | } 26 | }; 27 | 28 | const Profile = () => { 29 | const { user } = useOutletContext(); 30 | 31 | const { name, lastName, email, location } = user; 32 | 33 | return ( 34 | 35 |
36 |

profile

37 |
38 |
39 | 42 | 49 |
50 | 51 | 57 | 58 | 59 | 60 |
61 |
62 |
63 | ); 64 | }; 65 | export default Profile; 66 | -------------------------------------------------------------------------------- /client/src/pages/Register.jsx: -------------------------------------------------------------------------------- 1 | import { Form, redirect, Link } from 'react-router-dom'; 2 | import Wrapper from '../assets/wrappers/RegisterAndLoginPage'; 3 | import { FormRow, Logo, SubmitBtn } from '../components'; 4 | import customFetch from '../utils/customFetch'; 5 | import { toast } from 'react-toastify'; 6 | export const action = async ({ request }) => { 7 | const formData = await request.formData(); 8 | const data = Object.fromEntries(formData); 9 | 10 | try { 11 | await customFetch.post('/auth/register', data); 12 | toast.success('Registration successful'); 13 | return redirect('/login'); 14 | } catch (error) { 15 | toast.error(error?.response?.data?.msg); 16 | 17 | return error; 18 | } 19 | }; 20 | const Register = () => { 21 | return ( 22 | 23 |
24 | 25 |

Register

26 | 27 | 28 | 29 | 30 | 31 | 32 |

33 | Already a member? 34 | 35 | Login 36 | 37 |

38 | 39 |
40 | ); 41 | }; 42 | export default Register; 43 | -------------------------------------------------------------------------------- /client/src/pages/Stats.jsx: -------------------------------------------------------------------------------- 1 | import { ChartsContainer, StatsContainer } from '../components'; 2 | import customFetch from '../utils/customFetch'; 3 | import { useLoaderData } from 'react-router-dom'; 4 | import { useQuery } from '@tanstack/react-query'; 5 | 6 | const statsQuery = { 7 | queryKey: ['stats'], 8 | queryFn: async () => { 9 | const response = await customFetch.get('/jobs/stats'); 10 | return response.data; 11 | }, 12 | }; 13 | 14 | export const loader = (queryClient) => async () => { 15 | const data = await queryClient.ensureQueryData(statsQuery); 16 | return null; 17 | }; 18 | 19 | const Stats = () => { 20 | const { data } = useQuery(statsQuery); 21 | const { defaultStats, monthlyApplications } = data; 22 | 23 | return ( 24 | <> 25 | 26 | {monthlyApplications?.length > 1 && ( 27 | 28 | )} 29 | 30 | ); 31 | }; 32 | export default Stats; 33 | -------------------------------------------------------------------------------- /client/src/pages/index.js: -------------------------------------------------------------------------------- 1 | export { default as DashboardLayout } from './DashboardLayout'; 2 | export { default as Landing } from './Landing'; 3 | export { default as HomeLayout } from './HomeLayout'; 4 | export { default as Register } from './Register'; 5 | export { default as Login } from './Login'; 6 | export { default as Error } from './Error'; 7 | export { default as Stats } from './Stats'; 8 | export { default as AllJobs } from './AllJobs'; 9 | export { default as AddJob } from './AddJob'; 10 | export { default as EditJob } from './EditJob'; 11 | export { default as Profile } from './Profile'; 12 | export { default as Admin } from './Admin'; 13 | -------------------------------------------------------------------------------- /client/src/utils/customFetch.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const customFetch = axios.create({ 4 | baseURL: '/api/v1', 5 | }); 6 | 7 | export default customFetch; 8 | -------------------------------------------------------------------------------- /client/src/utils/links.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { IoBarChartSharp } from 'react-icons/io5'; 4 | import { MdQueryStats } from 'react-icons/md'; 5 | import { FaWpforms } from 'react-icons/fa'; 6 | import { ImProfile } from 'react-icons/im'; 7 | import { MdAdminPanelSettings } from 'react-icons/md'; 8 | 9 | const links = [ 10 | { 11 | text: 'add job', 12 | path: '.', 13 | icon: , 14 | }, 15 | { 16 | text: 'all jobs', 17 | path: 'all-jobs', 18 | icon: , 19 | }, 20 | { 21 | text: 'stats', 22 | path: 'stats', 23 | icon: , 24 | }, 25 | { 26 | text: 'profile', 27 | path: 'profile', 28 | icon: , 29 | }, 30 | { 31 | text: 'admin', 32 | path: 'admin', 33 | icon: , 34 | }, 35 | ]; 36 | 37 | export default links; 38 | -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | proxy: { 9 | '/api': { 10 | target: 'http://localhost:5100/api', 11 | changeOrigin: true, 12 | rewrite: (path) => path.replace(/^\/api/, ''), 13 | }, 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /controllers/authController.js: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | import User from '../models/UserModel.js'; 3 | import { comparePassword, hashPassword } from '../utils/passwordUtils.js'; 4 | import { UnauthenticatedError } from '../errors/customErrors.js'; 5 | import { createJWT } from '../utils/tokenUtils.js'; 6 | 7 | export const register = async (req, res) => { 8 | const isFirstAccount = (await User.countDocuments()) === 0; 9 | req.body.role = isFirstAccount ? 'admin' : 'user'; 10 | 11 | const hashedPassword = await hashPassword(req.body.password); 12 | req.body.password = hashedPassword; 13 | 14 | const user = await User.create(req.body); 15 | res.status(StatusCodes.CREATED).json({ msg: 'user created' }); 16 | }; 17 | export const login = async (req, res) => { 18 | const user = await User.findOne({ email: req.body.email }); 19 | 20 | const isValidUser = 21 | user && (await comparePassword(req.body.password, user.password)); 22 | 23 | if (!isValidUser) throw new UnauthenticatedError('invalid credentials'); 24 | 25 | const token = createJWT({ userId: user._id, role: user.role }); 26 | 27 | const oneDay = 1000 * 60 * 60 * 24; 28 | 29 | res.cookie('token', token, { 30 | httpOnly: true, 31 | expires: new Date(Date.now() + oneDay), 32 | secure: process.env.NODE_ENV === 'production', 33 | }); 34 | res.status(StatusCodes.OK).json({ msg: 'user logged in' }); 35 | }; 36 | 37 | export const logout = (req, res) => { 38 | res.cookie('token', 'logout', { 39 | httpOnly: true, 40 | expires: new Date(Date.now()), 41 | }); 42 | res.status(StatusCodes.OK).json({ msg: 'user logged out!' }); 43 | }; 44 | -------------------------------------------------------------------------------- /controllers/jobController.js: -------------------------------------------------------------------------------- 1 | import Job from '../models/JobModel.js'; 2 | import { StatusCodes } from 'http-status-codes'; 3 | import mongoose from 'mongoose'; 4 | import day from 'dayjs'; 5 | 6 | export const getAllJobs = async (req, res) => { 7 | const { search, jobStatus, jobType, sort } = req.query; 8 | 9 | const queryObject = { 10 | createdBy: req.user.userId, 11 | }; 12 | 13 | if (search) { 14 | queryObject.$or = [ 15 | { position: { $regex: search, $options: 'i' } }, 16 | { company: { $regex: search, $options: 'i' } }, 17 | ]; 18 | } 19 | 20 | if (jobStatus && jobStatus !== 'all') { 21 | queryObject.jobStatus = jobStatus; 22 | } 23 | if (jobType && jobType !== 'all') { 24 | queryObject.jobType = jobType; 25 | } 26 | 27 | const sortOptions = { 28 | newest: '-createdAt', 29 | oldest: 'createdAt', 30 | 'a-z': 'position', 31 | 'z-a': '-position', 32 | }; 33 | 34 | const sortKey = sortOptions[sort] || sortOptions.newest; 35 | 36 | // setup pagination 37 | 38 | const page = Number(req.query.page) || 1; 39 | const limit = Number(req.query.limit) || 10; 40 | const skip = (page - 1) * limit; 41 | 42 | const jobs = await Job.find(queryObject) 43 | .sort(sortKey) 44 | .skip(skip) 45 | .limit(limit); 46 | 47 | const totalJobs = await Job.countDocuments(queryObject); 48 | const numOfPages = Math.ceil(totalJobs / limit); 49 | res 50 | .status(StatusCodes.OK) 51 | .json({ totalJobs, numOfPages, currentPage: page, jobs }); 52 | }; 53 | 54 | export const createJob = async (req, res) => { 55 | req.body.createdBy = req.user.userId; 56 | const job = await Job.create(req.body); 57 | res.status(StatusCodes.CREATED).json({ job }); 58 | }; 59 | 60 | export const getJob = async (req, res) => { 61 | const job = await Job.findById(req.params.id); 62 | res.status(StatusCodes.OK).json({ job }); 63 | }; 64 | 65 | export const updateJob = async (req, res) => { 66 | const updatedJob = await Job.findByIdAndUpdate(req.params.id, req.body, { 67 | new: true, 68 | }); 69 | 70 | res.status(StatusCodes.OK).json({ msg: 'job modified', job: updatedJob }); 71 | }; 72 | 73 | export const deleteJob = async (req, res) => { 74 | const removedJob = await Job.findByIdAndDelete(req.params.id); 75 | res.status(StatusCodes.OK).json({ msg: 'job deleted', job: removedJob }); 76 | }; 77 | 78 | export const showStats = async (req, res) => { 79 | let stats = await Job.aggregate([ 80 | { $match: { createdBy: new mongoose.Types.ObjectId(req.user.userId) } }, 81 | { $group: { _id: '$jobStatus', count: { $sum: 1 } } }, 82 | ]); 83 | 84 | stats = stats.reduce((acc, curr) => { 85 | const { _id: title, count } = curr; 86 | acc[title] = count; 87 | return acc; 88 | }, {}); 89 | 90 | const defaultStats = { 91 | pending: stats.pending || 0, 92 | interview: stats.interview || 0, 93 | declined: stats.declined || 0, 94 | }; 95 | 96 | let monthlyApplications = await Job.aggregate([ 97 | { $match: { createdBy: new mongoose.Types.ObjectId(req.user.userId) } }, 98 | { 99 | $group: { 100 | _id: { year: { $year: '$createdAt' }, month: { $month: '$createdAt' } }, 101 | count: { $sum: 1 }, 102 | }, 103 | }, 104 | { $sort: { '_id.year': -1, '_id.month': -1 } }, 105 | { $limit: 6 }, 106 | ]); 107 | 108 | monthlyApplications = monthlyApplications 109 | .map((item) => { 110 | const { 111 | _id: { year, month }, 112 | count, 113 | } = item; 114 | 115 | const date = day() 116 | .month(month - 1) 117 | .year(year) 118 | .format('MMM YY'); 119 | 120 | return { date, count }; 121 | }) 122 | .reverse(); 123 | 124 | res.status(StatusCodes.OK).json({ defaultStats, monthlyApplications }); 125 | }; 126 | -------------------------------------------------------------------------------- /controllers/userController.js: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | import User from '../models/UserModel.js'; 3 | import Job from '../models/JobModel.js'; 4 | import cloudinary from 'cloudinary'; 5 | import { formatImage } from '../middleware/multerMiddleware.js'; 6 | 7 | export const getCurrentUser = async (req, res) => { 8 | const user = await User.findOne({ _id: req.user.userId }); 9 | const userWithoutPassword = user.toJSON(); 10 | res.status(StatusCodes.OK).json({ user: userWithoutPassword }); 11 | }; 12 | export const getApplicationStats = async (req, res) => { 13 | const users = await User.countDocuments(); 14 | const jobs = await Job.countDocuments(); 15 | res.status(StatusCodes.OK).json({ users, jobs }); 16 | }; 17 | export const updateUser = async (req, res) => { 18 | const newUser = { ...req.body }; 19 | delete newUser.password; 20 | delete newUser.role; 21 | 22 | if (req.file) { 23 | const file = formatImage(req.file); 24 | const response = await cloudinary.v2.uploader.upload(file); 25 | newUser.avatar = response.secure_url; 26 | newUser.avatarPublicId = response.public_id; 27 | } 28 | const updatedUser = await User.findByIdAndUpdate(req.user.userId, newUser); 29 | 30 | if (req.file && updatedUser.avatarPublicId) { 31 | await cloudinary.v2.uploader.destroy(updatedUser.avatarPublicId); 32 | } 33 | 34 | res.status(StatusCodes.OK).json({ msg: 'update user' }); 35 | }; 36 | -------------------------------------------------------------------------------- /errors/customErrors.js: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | export class NotFoundError extends Error { 4 | constructor(message) { 5 | super(message); 6 | this.name = 'NotFoundError'; 7 | this.statusCode = StatusCodes.NOT_FOUND; 8 | } 9 | } 10 | export class BadRequestError extends Error { 11 | constructor(message) { 12 | super(message); 13 | this.name = 'BadRequestError'; 14 | this.statusCode = StatusCodes.BAD_REQUEST; 15 | } 16 | } 17 | export class UnauthenticatedError extends Error { 18 | constructor(message) { 19 | super(message); 20 | this.name = 'UnauthenticatedError'; 21 | this.statusCode = StatusCodes.UNAUTHORIZED; 22 | } 23 | } 24 | export class UnauthorizedError extends Error { 25 | constructor(message) { 26 | super(message); 27 | this.name = 'UnauthorizedError'; 28 | this.statusCode = StatusCodes.FORBIDDEN; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /middleware/authMiddleware.js: -------------------------------------------------------------------------------- 1 | import { 2 | UnauthenticatedError, 3 | UnauthorizedError, 4 | BadRequestError, 5 | } from '../errors/customErrors.js'; 6 | import { verifyJWT } from '../utils/tokenUtils.js'; 7 | 8 | export const authenticateUser = (req, res, next) => { 9 | const { token } = req.cookies; 10 | if (!token) throw new UnauthenticatedError('authentication invalid'); 11 | 12 | try { 13 | const { userId, role } = verifyJWT(token); 14 | const testUser = userId === '64b2c07ccac2efc972ab0eca'; 15 | req.user = { userId, role, testUser }; 16 | next(); 17 | } catch (error) { 18 | throw new UnauthenticatedError('authentication invalid'); 19 | } 20 | }; 21 | 22 | export const authorizePermissions = (...roles) => { 23 | return (req, res, next) => { 24 | if (!roles.includes(req.user.role)) { 25 | throw new UnauthorizedError('Unauthorized to access this route'); 26 | } 27 | next(); 28 | }; 29 | }; 30 | 31 | export const checkForTestUser = (req, res, next) => { 32 | if (req.user.testUser) throw new BadRequestError('Demo User. Read Only!'); 33 | next(); 34 | }; 35 | -------------------------------------------------------------------------------- /middleware/errorHandlerMiddleware.js: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | const errorHandlerMiddleware = (err, req, res, next) => { 4 | console.log(err); 5 | const statusCode = err.statusCode || StatusCodes.INTERNAL_SERVER_ERROR; 6 | const msg = err.message || 'something went wrong, try again later'; 7 | res.status(statusCode).json({ msg }); 8 | }; 9 | 10 | export default errorHandlerMiddleware; 11 | -------------------------------------------------------------------------------- /middleware/multerMiddleware.js: -------------------------------------------------------------------------------- 1 | import multer from 'multer'; 2 | import DataParser from 'datauri/parser.js'; 3 | import path from 'path'; 4 | 5 | const storage = multer.memoryStorage(); 6 | 7 | const upload = multer({ storage }); 8 | 9 | const parser = new DataParser(); 10 | 11 | export const formatImage = (file) => { 12 | const fileExtension = path.extname(file.originalname).toString(); 13 | return parser.format(fileExtension, file.buffer).content; 14 | }; 15 | 16 | export default upload; 17 | -------------------------------------------------------------------------------- /middleware/validationMiddleware.js: -------------------------------------------------------------------------------- 1 | import { body, param, validationResult } from 'express-validator'; 2 | import { 3 | BadRequestError, 4 | NotFoundError, 5 | UnauthorizedError, 6 | } from '../errors/customErrors.js'; 7 | import { JOB_STATUS, JOB_TYPE } from '../utils/constants.js'; 8 | import mongoose from 'mongoose'; 9 | import Job from '../models/JobModel.js'; 10 | import User from '../models/UserModel.js'; 11 | 12 | const withValidationErrors = (validateValues) => { 13 | return [ 14 | validateValues, 15 | (req, res, next) => { 16 | const errors = validationResult(req); 17 | if (!errors.isEmpty()) { 18 | const errorMessages = errors.array().map((error) => error.msg); 19 | 20 | const firstMessage = errorMessages[0]; 21 | console.log(Object.getPrototypeOf(firstMessage)); 22 | if (errorMessages[0].startsWith('no job')) { 23 | throw new NotFoundError(errorMessages); 24 | } 25 | if (errorMessages[0].startsWith('not authorized')) { 26 | throw new UnauthorizedError('not authorized to access this route'); 27 | } 28 | throw new BadRequestError(errorMessages); 29 | } 30 | next(); 31 | }, 32 | ]; 33 | }; 34 | 35 | export const validateJobInput = withValidationErrors([ 36 | body('company').notEmpty().withMessage('company is required'), 37 | body('position').notEmpty().withMessage('position is required'), 38 | body('jobLocation').notEmpty().withMessage('job location is required'), 39 | body('jobStatus') 40 | .isIn(Object.values(JOB_STATUS)) 41 | .withMessage('invalid status value'), 42 | body('jobType') 43 | .isIn(Object.values(JOB_TYPE)) 44 | .withMessage('invalid type value'), 45 | ]); 46 | 47 | export const validateIdParam = withValidationErrors([ 48 | param('id').custom(async (value, { req }) => { 49 | const isValidMongoId = mongoose.Types.ObjectId.isValid(value); 50 | if (!isValidMongoId) throw new BadRequestError('invalid MongoDB id'); 51 | const job = await Job.findById(value); 52 | if (!job) throw new NotFoundError(`no job with id ${value}`); 53 | const isAdmin = req.user.role === 'admin'; 54 | const isOwner = req.user.userId === job.createdBy.toString(); 55 | 56 | if (!isAdmin && !isOwner) 57 | throw new UnauthorizedError('not authorized to access this route'); 58 | }), 59 | ]); 60 | 61 | export const validateRegisterInput = withValidationErrors([ 62 | body('name').notEmpty().withMessage('name is required'), 63 | body('email') 64 | .notEmpty() 65 | .withMessage('email is required') 66 | .isEmail() 67 | .withMessage('invalid email format') 68 | .custom(async (email) => { 69 | const user = await User.findOne({ email }); 70 | if (user) { 71 | throw new BadRequestError('email already exists'); 72 | } 73 | }), 74 | body('password') 75 | .notEmpty() 76 | .withMessage('password is required') 77 | .isLength({ min: 8 }) 78 | .withMessage('password must be at least 8 characters long'), 79 | body('location').notEmpty().withMessage('location is required'), 80 | body('lastName').notEmpty().withMessage('last name is required'), 81 | ]); 82 | 83 | export const validateLoginInput = withValidationErrors([ 84 | body('email') 85 | .notEmpty() 86 | .withMessage('email is required') 87 | .isEmail() 88 | .withMessage('invalid email format'), 89 | body('password').notEmpty().withMessage('password is required'), 90 | ]); 91 | 92 | export const validateUpdateUserInput = withValidationErrors([ 93 | body('name').notEmpty().withMessage('name is required'), 94 | body('email') 95 | .notEmpty() 96 | .withMessage('email is required') 97 | .isEmail() 98 | .withMessage('invalid email format') 99 | .custom(async (email, { req }) => { 100 | const user = await User.findOne({ email }); 101 | if (user && user._id.toString() !== req.user.userId) { 102 | throw new BadRequestError('email already exists'); 103 | } 104 | }), 105 | 106 | body('location').notEmpty().withMessage('location is required'), 107 | body('lastName').notEmpty().withMessage('last name is required'), 108 | ]); 109 | -------------------------------------------------------------------------------- /models/JobModel.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { JOB_STATUS, JOB_TYPE } from '../utils/constants.js'; 3 | const JobSchema = new mongoose.Schema( 4 | { 5 | company: String, 6 | position: String, 7 | jobStatus: { 8 | type: String, 9 | enum: Object.values(JOB_STATUS), 10 | default: JOB_STATUS.PENDING, 11 | }, 12 | jobType: { 13 | type: String, 14 | enum: Object.values(JOB_TYPE), 15 | default: JOB_TYPE.FULL_TIME, 16 | }, 17 | jobLocation: { 18 | type: String, 19 | default: 'my city', 20 | }, 21 | createdBy: { 22 | type: mongoose.Types.ObjectId, 23 | ref: 'User', 24 | }, 25 | }, 26 | { timestamps: true } 27 | ); 28 | 29 | export default mongoose.model('Job', JobSchema); 30 | -------------------------------------------------------------------------------- /models/UserModel.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const UserSchema = new mongoose.Schema({ 4 | name: String, 5 | email: String, 6 | password: String, 7 | lastName: { 8 | type: String, 9 | default: 'lastName', 10 | }, 11 | location: { 12 | type: String, 13 | default: 'my city', 14 | }, 15 | role: { 16 | type: String, 17 | enum: ['user', 'admin'], 18 | default: 'user', 19 | }, 20 | avatar: String, 21 | avatarPublicId: String, 22 | }); 23 | 24 | UserSchema.methods.toJSON = function () { 25 | let obj = this.toObject(); 26 | delete obj.password; 27 | return obj; 28 | }; 29 | 30 | export default mongoose.model('User', UserSchema); 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jobify", 3 | "version": "1.0.0", 4 | "description": "[Jobify](https://mern-jobify-v2.onrender.com/)", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "setup-project": "npm i && cd client && npm i", 9 | "setup-production-app": "npm i && cd client && npm i && npm run build ", 10 | "server": "nodemon server", 11 | "client": "cd client && npm run dev", 12 | "dev": "concurrently --kill-others-on-fail \"npm run server\" \"npm run client\"" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "bcryptjs": "^2.4.3", 19 | "cloudinary": "^1.37.3", 20 | "concurrently": "^8.0.1", 21 | "cookie-parser": "^1.4.6", 22 | "datauri": "^4.1.0", 23 | "dayjs": "^1.11.7", 24 | "dotenv": "^16.0.3", 25 | "express": "^4.18.2", 26 | "express-async-errors": "^3.1.1", 27 | "express-mongo-sanitize": "^2.2.0", 28 | "express-rate-limit": "^6.8.0", 29 | "express-validator": "^7.0.1", 30 | "helmet": "^7.0.0", 31 | "http-status-codes": "^2.2.0", 32 | "jsonwebtoken": "^9.0.0", 33 | "mongoose": "^7.0.5", 34 | "morgan": "^1.10.0", 35 | "multer": "^1.4.5-lts.1", 36 | "nanoid": "^4.0.2", 37 | "nodemon": "^2.0.22" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /populate.js: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises'; 2 | import mongoose from 'mongoose'; 3 | import dotenv from 'dotenv'; 4 | dotenv.config(); 5 | 6 | import Job from './models/JobModel.js'; 7 | import User from './models/UserModel.js'; 8 | 9 | try { 10 | await mongoose.connect(process.env.MONGO_URL); 11 | const user = await User.findOne({ email: 'john@gmail.com' }); 12 | const jsonJobs = JSON.parse( 13 | await readFile(new URL('./utils/mockData.json', import.meta.url)) 14 | ); 15 | const jobs = jsonJobs.map((job) => { 16 | return { ...job, createdBy: user._id }; 17 | }); 18 | await Job.deleteMany({ createdBy: user._id }); 19 | await Job.create(jobs); 20 | console.log('Success!!!'); 21 | process.exit(0); 22 | } catch (error) { 23 | console.log(error); 24 | process.exit(1); 25 | } 26 | -------------------------------------------------------------------------------- /routes/authRouter.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | const router = Router(); 3 | import { login, logout, register } from '../controllers/authController.js'; 4 | import { 5 | validateRegisterInput, 6 | validateLoginInput, 7 | } from '../middleware/validationMiddleware.js'; 8 | 9 | import rateLimiter from 'express-rate-limit'; 10 | 11 | const apiLimiter = rateLimiter({ 12 | windowMs: 15 * 60 * 1000, // 15 minutes 13 | max: 20, 14 | message: { msg: 'IP rate limit exceeded, retry in 15 minutes.' }, 15 | }); 16 | 17 | router.post('/register', apiLimiter, validateRegisterInput, register); 18 | router.post('/login', apiLimiter, validateLoginInput, login); 19 | router.get('/logout', logout); 20 | 21 | export default router; 22 | -------------------------------------------------------------------------------- /routes/jobRouter.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | const router = Router(); 3 | import { 4 | getAllJobs, 5 | getJob, 6 | createJob, 7 | updateJob, 8 | deleteJob, 9 | showStats, 10 | } from '../controllers/jobController.js'; 11 | import { 12 | validateJobInput, 13 | validateIdParam, 14 | } from '../middleware/validationMiddleware.js'; 15 | import { checkForTestUser } from '../middleware/authMiddleware.js'; 16 | 17 | // router.get('/',getAllJobs) 18 | // router.post('/',createJob) 19 | 20 | router 21 | .route('/') 22 | .get(getAllJobs) 23 | .post(checkForTestUser, validateJobInput, createJob); 24 | 25 | router.route('/stats').get(showStats); 26 | 27 | router 28 | .route('/:id') 29 | .get(validateIdParam, getJob) 30 | .patch(checkForTestUser, validateJobInput, validateIdParam, updateJob) 31 | .delete(checkForTestUser, validateIdParam, deleteJob); 32 | 33 | export default router; 34 | -------------------------------------------------------------------------------- /routes/userRouter.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { 3 | getApplicationStats, 4 | getCurrentUser, 5 | updateUser, 6 | } from '../controllers/userController.js'; 7 | import { validateUpdateUserInput } from '../middleware/validationMiddleware.js'; 8 | import { 9 | authorizePermissions, 10 | checkForTestUser, 11 | } from '../middleware/authMiddleware.js'; 12 | import upload from '../middleware/multerMiddleware.js'; 13 | const router = Router(); 14 | 15 | router.get('/current-user', getCurrentUser); 16 | router.get('/admin/app-stats', [ 17 | authorizePermissions('admin'), 18 | getApplicationStats, 19 | ]); 20 | router.patch( 21 | '/update-user', 22 | checkForTestUser, 23 | upload.single('avatar'), 24 | validateUpdateUserInput, 25 | updateUser 26 | ); 27 | 28 | export default router; 29 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import 'express-async-errors'; 2 | import * as dotenv from 'dotenv'; 3 | dotenv.config(); 4 | import express from 'express'; 5 | const app = express(); 6 | import morgan from 'morgan'; 7 | import mongoose from 'mongoose'; 8 | import cookieParser from 'cookie-parser'; 9 | import cloudinary from 'cloudinary'; 10 | import helmet from 'helmet'; 11 | import mongoSanitize from 'express-mongo-sanitize'; 12 | 13 | // routers 14 | import jobRouter from './routes/jobRouter.js'; 15 | import authRouter from './routes/authRouter.js'; 16 | import userRouter from './routes/userRouter.js'; 17 | // public 18 | import { dirname } from 'path'; 19 | import { fileURLToPath } from 'url'; 20 | import path from 'path'; 21 | 22 | // middleware 23 | import errorHandlerMiddleware from './middleware/errorHandlerMiddleware.js'; 24 | import { authenticateUser } from './middleware/authMiddleware.js'; 25 | 26 | cloudinary.config({ 27 | cloud_name: process.env.CLOUD_NAME, 28 | api_key: process.env.CLOUD_API_KEY, 29 | api_secret: process.env.CLOUD_API_SECRET, 30 | }); 31 | 32 | const __dirname = dirname(fileURLToPath(import.meta.url)); 33 | if (process.env.NODE_ENV === 'development') { 34 | app.use(morgan('dev')); 35 | } 36 | app.use(express.static(path.resolve(__dirname, './client/dist'))); 37 | app.use(cookieParser()); 38 | app.use(express.json()); 39 | app.use(helmet()); 40 | app.use(mongoSanitize()); 41 | 42 | app.get('/', (req, res) => { 43 | res.send('Hello World'); 44 | }); 45 | 46 | app.get('/api/v1/test', (req, res) => { 47 | res.json({ msg: 'test route' }); 48 | }); 49 | 50 | app.use('/api/v1/jobs', authenticateUser, jobRouter); 51 | app.use('/api/v1/users', authenticateUser, userRouter); 52 | app.use('/api/v1/auth', authRouter); 53 | 54 | app.get('*', (req, res) => { 55 | res.sendFile(path.resolve(__dirname, './client/dist', 'index.html')); 56 | }); 57 | 58 | app.use('*', (req, res) => { 59 | res.status(404).json({ msg: 'not found' }); 60 | }); 61 | 62 | app.use(errorHandlerMiddleware); 63 | 64 | const port = process.env.PORT || 5100; 65 | 66 | try { 67 | await mongoose.connect(process.env.MONGO_URL); 68 | app.listen(port, () => { 69 | console.log(`server running on PORT ${port}...`); 70 | }); 71 | } catch (error) { 72 | console.log(error); 73 | process.exit(1); 74 | } 75 | -------------------------------------------------------------------------------- /utils/constants.js: -------------------------------------------------------------------------------- 1 | export const JOB_STATUS = { 2 | PENDING: 'pending', 3 | INTERVIEW: 'interview', 4 | DECLINED: 'declined', 5 | }; 6 | 7 | export const JOB_TYPE = { 8 | FULL_TIME: 'full-time', 9 | PART_TIME: 'part-time', 10 | INTERNSHIP: 'internship', 11 | }; 12 | 13 | export const JOB_SORT_BY = { 14 | NEWEST_FIRST: 'newest', 15 | OLDEST_FIRST: 'oldest', 16 | ASCENDING: 'a-z', 17 | DESCENDING: 'z-a', 18 | }; 19 | -------------------------------------------------------------------------------- /utils/mockData.json: -------------------------------------------------------------------------------- 1 | [{"company":"Skinte","position":"Senior Financial Analyst","jobLocation":"Tonshayevo","jobStatus":"pending","jobType":"internship","createdAt":"2022-09-25T00:02:40Z"}, 2 | {"company":"Livefish","position":"VP Marketing","jobLocation":"Vale de Açores","jobStatus":"declined","jobType":"internship","createdAt":"2023-04-16T22:34:17Z"}, 3 | {"company":"Demimbu","position":"Administrative Officer","jobLocation":"Palmar de Varela","jobStatus":"interview","jobType":"internship","createdAt":"2022-10-15T07:44:21Z"}, 4 | {"company":"Gigashots","position":"Account Representative III","jobLocation":"Jazovo","jobStatus":"pending","jobType":"part-time","createdAt":"2023-04-25T18:44:34Z"}, 5 | {"company":"Feedspan","position":"Automation Specialist IV","jobLocation":"Botoh","jobStatus":"interview","jobType":"full-time","createdAt":"2023-07-07T09:29:32Z"}, 6 | {"company":"Camimbo","position":"Accountant III","jobLocation":"Tsapêraī","jobStatus":"declined","jobType":"full-time","createdAt":"2023-04-04T08:46:19Z"}, 7 | {"company":"Avamm","position":"Financial Analyst","jobLocation":"Kobe","jobStatus":"interview","jobType":"part-time","createdAt":"2022-09-19T06:17:02Z"}, 8 | {"company":"Dynazzy","position":"Junior Executive","jobLocation":"Uliastay","jobStatus":"declined","jobType":"full-time","createdAt":"2023-03-17T16:19:19Z"}, 9 | {"company":"Youspan","position":"Assistant Media Planner","jobLocation":"Mumias","jobStatus":"interview","jobType":"full-time","createdAt":"2023-01-16T05:28:12Z"}, 10 | {"company":"Shuffletag","position":"VP Sales","jobLocation":"Quangang","jobStatus":"interview","jobType":"internship","createdAt":"2022-08-31T08:58:00Z"}, 11 | {"company":"Npath","position":"Desktop Support Technician","jobLocation":"Arzakan","jobStatus":"interview","jobType":"internship","createdAt":"2023-06-06T10:08:31Z"}, 12 | {"company":"Meevee","position":"Analyst Programmer","jobLocation":"Pršovce","jobStatus":"declined","jobType":"full-time","createdAt":"2023-06-05T05:51:02Z"}, 13 | {"company":"Meembee","position":"Office Assistant IV","jobLocation":"Radashkovichy","jobStatus":"pending","jobType":"full-time","createdAt":"2023-04-11T20:20:22Z"}, 14 | {"company":"Twitternation","position":"Speech Pathologist","jobLocation":"Agen","jobStatus":"declined","jobType":"part-time","createdAt":"2023-02-18T07:00:55Z"}, 15 | {"company":"Meejo","position":"Pharmacist","jobLocation":"Deshan","jobStatus":"pending","jobType":"internship","createdAt":"2023-02-02T04:00:43Z"}, 16 | {"company":"Wikizz","position":"Safety Technician IV","jobLocation":"Ube","jobStatus":"pending","jobType":"part-time","createdAt":"2022-11-05T09:32:23Z"}, 17 | {"company":"Muxo","position":"Senior Developer","jobLocation":"São Sepé","jobStatus":"interview","jobType":"internship","createdAt":"2023-05-03T04:09:56Z"}, 18 | {"company":"Meevee","position":"Actuary","jobLocation":"Manturovo","jobStatus":"interview","jobType":"part-time","createdAt":"2023-05-24T07:55:12Z"}, 19 | {"company":"Eadel","position":"Quality Control Specialist","jobLocation":"San Antonio","jobStatus":"interview","jobType":"internship","createdAt":"2022-12-03T20:21:49Z"}, 20 | {"company":"Mydo","position":"Physical Therapy Assistant","jobLocation":"Madīnat ash Shamāl","jobStatus":"pending","jobType":"part-time","createdAt":"2023-02-28T23:10:51Z"}, 21 | {"company":"Oba","position":"Social Worker","jobLocation":"Quinta dos Frades","jobStatus":"interview","jobType":"internship","createdAt":"2022-11-24T15:12:17Z"}, 22 | {"company":"Photobug","position":"Payment Adjustment Coordinator","jobLocation":"Chengdong","jobStatus":"pending","jobType":"full-time","createdAt":"2023-04-05T23:51:36Z"}, 23 | {"company":"Quimm","position":"Structural Engineer","jobLocation":"Strasbourg","jobStatus":"pending","jobType":"part-time","createdAt":"2022-09-26T20:00:08Z"}, 24 | {"company":"Flashdog","position":"Senior Editor","jobLocation":"Wilmington","jobStatus":"declined","jobType":"full-time","createdAt":"2023-02-19T17:59:12Z"}, 25 | {"company":"Fatz","position":"Help Desk Technician","jobLocation":"Verona","jobStatus":"pending","jobType":"part-time","createdAt":"2022-07-12T13:10:32Z"}, 26 | {"company":"Youopia","position":"Staff Accountant IV","jobLocation":"Río Tercero","jobStatus":"declined","jobType":"full-time","createdAt":"2022-07-12T00:04:36Z"}, 27 | {"company":"Abata","position":"Analyst Programmer","jobLocation":"Araguari","jobStatus":"declined","jobType":"full-time","createdAt":"2022-09-28T19:05:28Z"}, 28 | {"company":"Ntag","position":"Compensation Analyst","jobLocation":"Kowingir","jobStatus":"pending","jobType":"part-time","createdAt":"2023-06-18T08:52:02Z"}, 29 | {"company":"Bluejam","position":"Statistician I","jobLocation":"Levallois-Perret","jobStatus":"interview","jobType":"internship","createdAt":"2022-09-28T04:02:05Z"}, 30 | {"company":"Skynoodle","position":"Programmer II","jobLocation":"Dallas","jobStatus":"pending","jobType":"part-time","createdAt":"2022-08-03T19:41:57Z"}, 31 | {"company":"Zoovu","position":"Occupational Therapist","jobLocation":"Jiaocun","jobStatus":"declined","jobType":"full-time","createdAt":"2023-05-12T08:05:46Z"}, 32 | {"company":"Wordware","position":"Associate Professor","jobLocation":"Tanumshede","jobStatus":"declined","jobType":"part-time","createdAt":"2023-01-13T04:42:33Z"}, 33 | {"company":"Skippad","position":"Project Manager","jobLocation":"Pantai","jobStatus":"interview","jobType":"internship","createdAt":"2022-07-15T16:38:48Z"}, 34 | {"company":"Devbug","position":"Recruiter","jobLocation":"Basen","jobStatus":"declined","jobType":"part-time","createdAt":"2022-07-17T22:42:40Z"}, 35 | {"company":"Eabox","position":"Programmer II","jobLocation":"An Lão","jobStatus":"interview","jobType":"part-time","createdAt":"2023-01-08T16:27:44Z"}, 36 | {"company":"Thoughtstorm","position":"Occupational Therapist","jobLocation":"Tari","jobStatus":"declined","jobType":"part-time","createdAt":"2023-01-23T03:57:18Z"}, 37 | {"company":"Wikido","position":"Recruiting Manager","jobLocation":"Pontian","jobStatus":"interview","jobType":"internship","createdAt":"2022-12-12T08:53:15Z"}, 38 | {"company":"Midel","position":"Financial Advisor","jobLocation":"Finspång","jobStatus":"declined","jobType":"part-time","createdAt":"2022-12-19T14:02:56Z"}, 39 | {"company":"Dynabox","position":"Human Resources Manager","jobLocation":"Pashiya","jobStatus":"pending","jobType":"internship","createdAt":"2022-09-09T22:32:52Z"}, 40 | {"company":"Brainbox","position":"Software Test Engineer III","jobLocation":"Krajan Siki","jobStatus":"pending","jobType":"full-time","createdAt":"2022-08-22T10:10:58Z"}, 41 | {"company":"Devbug","position":"Web Designer I","jobLocation":"Baisha","jobStatus":"interview","jobType":"full-time","createdAt":"2022-08-22T10:45:19Z"}, 42 | {"company":"Trupe","position":"Geological Engineer","jobLocation":"El Cubolero","jobStatus":"declined","jobType":"internship","createdAt":"2022-12-27T18:49:49Z"}, 43 | {"company":"Quire","position":"Safety Technician IV","jobLocation":"Anastácio","jobStatus":"pending","jobType":"part-time","createdAt":"2022-12-23T12:33:46Z"}, 44 | {"company":"Jaxnation","position":"Staff Accountant IV","jobLocation":"Delanggu","jobStatus":"pending","jobType":"internship","createdAt":"2023-04-24T17:11:13Z"}, 45 | {"company":"Skyble","position":"Internal Auditor","jobLocation":"Pristina","jobStatus":"declined","jobType":"full-time","createdAt":"2023-02-01T05:15:56Z"}, 46 | {"company":"Gevee","position":"Sales Representative","jobLocation":"Huineno","jobStatus":"declined","jobType":"internship","createdAt":"2022-10-30T06:45:32Z"}, 47 | {"company":"Thoughtmix","position":"Media Manager IV","jobLocation":"Unity","jobStatus":"interview","jobType":"full-time","createdAt":"2022-12-09T15:03:18Z"}, 48 | {"company":"Fivespan","position":"Mechanical Systems Engineer","jobLocation":"Sua","jobStatus":"interview","jobType":"internship","createdAt":"2022-09-05T20:44:56Z"}, 49 | {"company":"LiveZ","position":"Computer Systems Analyst II","jobLocation":"Huanggong","jobStatus":"declined","jobType":"full-time","createdAt":"2022-09-06T17:06:56Z"}, 50 | {"company":"Topicware","position":"Statistician I","jobLocation":"Ngilengan","jobStatus":"pending","jobType":"full-time","createdAt":"2023-04-29T22:25:49Z"}, 51 | {"company":"Ntag","position":"VP Quality Control","jobLocation":"Florencia","jobStatus":"declined","jobType":"internship","createdAt":"2022-07-11T05:54:35Z"}, 52 | {"company":"Digitube","position":"Senior Sales Associate","jobLocation":"Mibu","jobStatus":"declined","jobType":"internship","createdAt":"2022-09-06T16:24:05Z"}, 53 | {"company":"Thoughtbeat","position":"Associate Professor","jobLocation":"Bęczarka","jobStatus":"interview","jobType":"internship","createdAt":"2023-06-08T04:19:45Z"}, 54 | {"company":"Mynte","position":"GIS Technical Architect","jobLocation":"Palompon","jobStatus":"interview","jobType":"part-time","createdAt":"2022-11-21T04:11:59Z"}, 55 | {"company":"Midel","position":"Business Systems Development Analyst","jobLocation":"Waso","jobStatus":"declined","jobType":"full-time","createdAt":"2023-04-25T23:15:23Z"}, 56 | {"company":"Twiyo","position":"Professor","jobLocation":"Cigalontang","jobStatus":"pending","jobType":"full-time","createdAt":"2022-11-29T17:03:56Z"}, 57 | {"company":"Meembee","position":"Administrative Officer","jobLocation":"Bavorov","jobStatus":"interview","jobType":"full-time","createdAt":"2023-06-17T05:21:49Z"}, 58 | {"company":"Avavee","position":"Safety Technician II","jobLocation":"Merdeka","jobStatus":"declined","jobType":"internship","createdAt":"2022-08-09T23:39:20Z"}, 59 | {"company":"Camido","position":"Associate Professor","jobLocation":"Grytviken","jobStatus":"interview","jobType":"full-time","createdAt":"2023-01-05T12:50:09Z"}, 60 | {"company":"Skyba","position":"Professor","jobLocation":"Sibubuhan","jobStatus":"declined","jobType":"full-time","createdAt":"2023-03-25T10:56:49Z"}, 61 | {"company":"Thoughtmix","position":"Administrative Assistant IV","jobLocation":"Gandu","jobStatus":"declined","jobType":"full-time","createdAt":"2023-04-04T16:19:08Z"}, 62 | {"company":"Thoughtbeat","position":"Actuary","jobLocation":"Paris 09","jobStatus":"pending","jobType":"full-time","createdAt":"2022-12-11T09:24:39Z"}, 63 | {"company":"Twitterlist","position":"Business Systems Development Analyst","jobLocation":"Dagou","jobStatus":"pending","jobType":"part-time","createdAt":"2022-10-21T10:08:02Z"}, 64 | {"company":"Divape","position":"Actuary","jobLocation":"Des Moines","jobStatus":"pending","jobType":"internship","createdAt":"2023-05-17T05:53:45Z"}, 65 | {"company":"Kayveo","position":"Food Chemist","jobLocation":"Tene","jobStatus":"interview","jobType":"part-time","createdAt":"2022-08-07T17:22:15Z"}, 66 | {"company":"Jabbersphere","position":"Occupational Therapist","jobLocation":"Jiayuguan","jobStatus":"pending","jobType":"full-time","createdAt":"2023-01-27T07:51:21Z"}, 67 | {"company":"Demimbu","position":"Media Manager I","jobLocation":"Navotas","jobStatus":"interview","jobType":"full-time","createdAt":"2022-11-01T08:43:56Z"}, 68 | {"company":"Cogilith","position":"Recruiting Manager","jobLocation":"Kemiri","jobStatus":"pending","jobType":"part-time","createdAt":"2023-05-17T21:01:01Z"}, 69 | {"company":"Dabtype","position":"Pharmacist","jobLocation":"Barranquilla","jobStatus":"declined","jobType":"internship","createdAt":"2022-11-25T10:11:06Z"}, 70 | {"company":"Twitterwire","position":"Programmer Analyst II","jobLocation":"Jangkungkusumo","jobStatus":"declined","jobType":"internship","createdAt":"2022-11-09T19:12:11Z"}, 71 | {"company":"Oba","position":"Human Resources Assistant III","jobLocation":"Fontaínhas","jobStatus":"declined","jobType":"internship","createdAt":"2023-04-04T00:06:28Z"}, 72 | {"company":"Oloo","position":"Senior Financial Analyst","jobLocation":"Cabeças Verdes","jobStatus":"declined","jobType":"internship","createdAt":"2023-05-22T08:15:39Z"}, 73 | {"company":"Edgewire","position":"Electrical Engineer","jobLocation":"Jianping","jobStatus":"declined","jobType":"full-time","createdAt":"2022-12-18T09:50:49Z"}, 74 | {"company":"Zazio","position":"Chemical Engineer","jobLocation":"Linköping","jobStatus":"interview","jobType":"internship","createdAt":"2023-07-04T09:45:27Z"}, 75 | {"company":"Linkbuzz","position":"Research Nurse","jobLocation":"Nytva","jobStatus":"declined","jobType":"internship","createdAt":"2022-12-28T03:53:15Z"}, 76 | {"company":"Skyndu","position":"Senior Sales Associate","jobLocation":"Kendung Timur","jobStatus":"declined","jobType":"full-time","createdAt":"2023-06-22T21:37:51Z"}, 77 | {"company":"Livetube","position":"General Manager","jobLocation":"Tegarenkrajan","jobStatus":"declined","jobType":"part-time","createdAt":"2023-02-26T14:53:31Z"}, 78 | {"company":"Babbleopia","position":"Health Coach I","jobLocation":"Huancheng","jobStatus":"interview","jobType":"full-time","createdAt":"2023-05-02T06:19:25Z"}, 79 | {"company":"Dabjam","position":"Dental Hygienist","jobLocation":"Trpinja","jobStatus":"declined","jobType":"internship","createdAt":"2023-02-17T01:35:08Z"}, 80 | {"company":"Tazz","position":"Project Manager","jobLocation":"Caacupé","jobStatus":"declined","jobType":"full-time","createdAt":"2023-03-20T22:34:44Z"}, 81 | {"company":"Meejo","position":"Registered Nurse","jobLocation":"Santa Fé do Sul","jobStatus":"declined","jobType":"full-time","createdAt":"2023-05-02T03:44:18Z"}, 82 | {"company":"Omba","position":"Geologist III","jobLocation":"Biliri","jobStatus":"pending","jobType":"part-time","createdAt":"2022-09-29T04:05:34Z"}, 83 | {"company":"Thoughtstorm","position":"Design Engineer","jobLocation":"Damu","jobStatus":"pending","jobType":"full-time","createdAt":"2022-11-08T17:01:10Z"}, 84 | {"company":"Voolith","position":"General Manager","jobLocation":"Kuala Terengganu","jobStatus":"declined","jobType":"full-time","createdAt":"2023-01-31T11:40:44Z"}, 85 | {"company":"Wordpedia","position":"Software Consultant","jobLocation":"Ashibetsu","jobStatus":"declined","jobType":"part-time","createdAt":"2022-08-03T14:48:48Z"}, 86 | {"company":"Gevee","position":"Clinical Specialist","jobLocation":"Wujiashan","jobStatus":"interview","jobType":"part-time","createdAt":"2023-07-08T07:21:54Z"}, 87 | {"company":"Twitterlist","position":"Operator","jobLocation":"Mikhaylovsk","jobStatus":"pending","jobType":"part-time","createdAt":"2023-03-22T17:42:55Z"}, 88 | {"company":"Tekfly","position":"Research Assistant I","jobLocation":"Patarrá","jobStatus":"pending","jobType":"internship","createdAt":"2023-03-23T14:17:24Z"}, 89 | {"company":"Plambee","position":"Analog Circuit Design manager","jobLocation":"Les Coteaux","jobStatus":"declined","jobType":"internship","createdAt":"2022-11-24T07:53:51Z"}, 90 | {"company":"Roombo","position":"Community Outreach Specialist","jobLocation":"Yanglinshi","jobStatus":"interview","jobType":"part-time","createdAt":"2023-05-30T07:03:17Z"}, 91 | {"company":"Abata","position":"Account Representative II","jobLocation":"Shixi","jobStatus":"declined","jobType":"part-time","createdAt":"2023-03-27T22:14:04Z"}, 92 | {"company":"Layo","position":"Junior Executive","jobLocation":"Ikongo","jobStatus":"interview","jobType":"full-time","createdAt":"2022-09-24T20:05:33Z"}, 93 | {"company":"DabZ","position":"Staff Accountant I","jobLocation":"Mandiana","jobStatus":"declined","jobType":"full-time","createdAt":"2023-04-14T09:42:11Z"}, 94 | {"company":"Devify","position":"Operator","jobLocation":"Tabalosos","jobStatus":"declined","jobType":"part-time","createdAt":"2022-10-25T14:23:45Z"}, 95 | {"company":"Blognation","position":"Social Worker","jobLocation":"Křenovice","jobStatus":"declined","jobType":"full-time","createdAt":"2023-06-06T18:39:47Z"}, 96 | {"company":"Topdrive","position":"Graphic Designer","jobLocation":"Xinquansi","jobStatus":"pending","jobType":"full-time","createdAt":"2022-12-30T23:14:12Z"}, 97 | {"company":"Zooxo","position":"Human Resources Assistant III","jobLocation":"Gaotuo","jobStatus":"interview","jobType":"full-time","createdAt":"2022-11-19T04:23:28Z"}, 98 | {"company":"Demimbu","position":"Data Coordinator","jobLocation":"Al Buţayḩah","jobStatus":"interview","jobType":"internship","createdAt":"2023-06-12T12:29:54Z"}, 99 | {"company":"Zava","position":"Graphic Designer","jobLocation":"Calebasses","jobStatus":"interview","jobType":"internship","createdAt":"2022-08-03T14:02:24Z"}, 100 | {"company":"Janyx","position":"Budget/Accounting Analyst I","jobLocation":"Kosino","jobStatus":"interview","jobType":"part-time","createdAt":"2022-09-30T01:02:16Z"}] -------------------------------------------------------------------------------- /utils/passwordUtils.js: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs'; 2 | 3 | export const hashPassword = async (password) => { 4 | const salt = await bcrypt.genSalt(10); 5 | const hashedPassword = await bcrypt.hash(password, salt); 6 | return hashedPassword; 7 | }; 8 | 9 | export const comparePassword = async (password, hashedPassword) => { 10 | const isMatch = await bcrypt.compare(password, hashedPassword); 11 | return isMatch; 12 | }; 13 | -------------------------------------------------------------------------------- /utils/tokenUtils.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | 3 | export const createJWT = (payload) => { 4 | const token = jwt.sign(payload, process.env.JWT_SECRET, { 5 | expiresIn: process.env.JWT_EXPIRES_IN, 6 | }); 7 | return token; 8 | }; 9 | 10 | export const verifyJWT = (token) => { 11 | const decoded = jwt.verify(token, process.env.JWT_SECRET); 12 | return decoded; 13 | }; 14 | --------------------------------------------------------------------------------