├── .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 |
6 |
--------------------------------------------------------------------------------
/client/src/assets/images/main-alternative.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/images/main.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/images/not-found.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
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 |
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
;
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 |
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 |
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 |
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 |
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 |

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 |

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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------