diff --git a/package-lock.json b/package-lock.json index 8232f10..df92756 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "axios": "^1.6.7", + "js-cookie": "^3.0.5", "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^9.1.0", @@ -5508,6 +5510,29 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "dependencies": { + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axobject-query": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", @@ -12486,6 +12511,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15705,6 +15738,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", diff --git a/package.json b/package.json index 4c692b4..96aa1aa 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "axios": "^1.6.7", + "js-cookie": "^3.0.5", "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^9.1.0", diff --git a/src/App.js b/src/App.js index 9d9431d..5a40662 100644 --- a/src/App.js +++ b/src/App.js @@ -1,24 +1,34 @@ import { Route, Routes } from 'react-router-dom'; import './App.css'; -import Doctors from './pages/Doctors'; -import Navbar from './components/navbar/Navbar'; import MyAppointments from './pages/MyAppointments'; import AddDoctor from './pages/AddDoctor'; +import Doctors from './pages/Doctors'; import BookAppointment from './pages/BookAppointment'; import DeleteDoctor from './pages/DeleteDoctor'; import DoctorDetails from './pages/DoctorDetails'; +import SplashScreen from './components/splashScreen/SplashScreen'; +import Login from './components/login/login'; +import Register from './components/register/Register'; +import ProtectedRoute from './components/ProtectedRoute'; +import Layout from './components/layout'; function App() { return (
- - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + +
); diff --git a/src/assets/logo.png b/src/assets/logo.png new file mode 100644 index 0000000..7e601b3 Binary files /dev/null and b/src/assets/logo.png differ diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..e677017 --- /dev/null +++ b/src/components/ProtectedRoute.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { Outlet, Navigate } from 'react-router-dom'; +import Cookies from 'js-cookie'; + +const ProtectedRoute = () => { + const token = Cookies.get('jwt_token'); + return token ? : ; +}; + +export default ProtectedRoute; diff --git a/src/components/layout.js b/src/components/layout.js new file mode 100644 index 0000000..24e852d --- /dev/null +++ b/src/components/layout.js @@ -0,0 +1,16 @@ +import { Outlet } from 'react-router-dom'; +import Navbar from './navbar/Navbar'; +import layout from './layout.module.css'; + +const Layout = () => ( +
+
+ +
+
+ +
+
+); + +export default Layout; diff --git a/src/components/layout.module.css b/src/components/layout.module.css new file mode 100644 index 0000000..abde53a --- /dev/null +++ b/src/components/layout.module.css @@ -0,0 +1,3 @@ +.container { + display: flex; +} diff --git a/src/components/login/login.js b/src/components/login/login.js new file mode 100644 index 0000000..3fc4360 --- /dev/null +++ b/src/components/login/login.js @@ -0,0 +1,68 @@ +import React, { useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { NavLink, useNavigate } from 'react-router-dom'; +import { loginAsync } from '../../redux/user/userSlice'; +import login from './login.module.css'; + +const Login = () => { + const { loginError, success } = useSelector((store) => store.user); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const formRef = useRef(); + + const handleSubmit = async (e) => { + e.preventDefault(); + + const formData = new FormData(formRef.current); + const email = formData.get('email'); + const password = formData.get('password'); + + const data = { + user: { email, password }, + }; + + try { + await dispatch(loginAsync(data)); + navigate('/doctors'); + e.target.reset(); + } catch (error) { + throw new Error(error); + } + }; + + return ( +
+
+

Login

+
+ + +
+ + + + +
+ {loginError &&

{loginError}

} + {success &&

{success}

} +
+
+
+ ); +}; + +export default Login; diff --git a/src/components/login/login.module.css b/src/components/login/login.module.css new file mode 100644 index 0000000..962686f --- /dev/null +++ b/src/components/login/login.module.css @@ -0,0 +1,7 @@ +.container { + width: 100%; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/components/navbar/Navbar.jsx b/src/components/navbar/Navbar.jsx index 068f3e4..af365dd 100644 --- a/src/components/navbar/Navbar.jsx +++ b/src/components/navbar/Navbar.jsx @@ -1,21 +1,58 @@ -import { NavLink } from 'react-router-dom'; -import './navbar.css'; +import { NavLink, useLocation, useNavigate } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import Cookies from 'js-cookie'; +import { logout } from '../../redux/user/userSlice'; +import logo from '../../assets/logo.png'; +import navbar from './navbar.module.css'; -const Navbar = () => ( - -); +const Navbar = () => { + const { userData } = useSelector((store) => store.user); + const location = useLocation(); + + const dispatch = useDispatch(); + const navigate = useNavigate(); + const logoutUser = () => { + dispatch(logout()); + Cookies.remove('jwt_token'); + Cookies.remove('user_info'); + navigate('/'); + }; + return ( + + ); +}; export default Navbar; diff --git a/src/components/navbar/navbar.css b/src/components/navbar/navbar.css deleted file mode 100644 index 9787c13..0000000 --- a/src/components/navbar/navbar.css +++ /dev/null @@ -1,36 +0,0 @@ -.nav-container { - display: flex; - flex-direction: column; - width: 20%; - height: 100vh; - padding: 0 0 0 12px; - border-right: 1px solid var(--text-color-secondary); -} - -.profile { - margin-bottom: 120px; -} - -.nav-links { - position: relative; - display: flex; - margin-left: 12px; - flex-direction: column; -} - -.nav-links > a { - text-decoration: none; - color: #000; - padding: 12px 0; - text-transform: uppercase; - font-weight: 700; -} - -.nav-links a.active { - background-color: var(--bg-button-color) !important; - color: #fff !important; -} - -.logout { - margin-top: auto; -} diff --git a/src/components/navbar/navbar.module.css b/src/components/navbar/navbar.module.css new file mode 100644 index 0000000..3a2e3b8 --- /dev/null +++ b/src/components/navbar/navbar.module.css @@ -0,0 +1,63 @@ +.nav_container { + display: flex; + flex-direction: column; + height: 100vh; + border-right: 1px solid var(--text-color-secondary); +} + +.logo { + width: 140px; + margin-bottom: 20px; + padding: 0 20px; +} + +.profile { + margin: 30px 0 40px 0; + padding: 0 20px; +} + +.profile p { + font-weight: bold; +} + +.nav_links { + position: relative; + display: flex; + flex-direction: column; +} + +.nav_links > a, +.logout { + font-size: 14px; + text-decoration: none; + color: #000; + padding: 15px 20px; + border: none; + border-bottom: 1px solid var(--text-color-secondary); + text-transform: uppercase; + font-weight: 700; +} + +.active { + background-color: var(--bg-button-color) !important; + color: #fff !important; +} + +.logout { + background: none; + text-align: left; + margin-bottom: 30px; +} + +.social_links { + list-style-type: none; + display: flex; + flex-wrap: wrap; + justify-content: space-around; + padding-left: 0; +} + +.copyright { + color: grey; + padding: 0 20px; +} diff --git a/src/components/register/Register.js b/src/components/register/Register.js new file mode 100644 index 0000000..9008cd1 --- /dev/null +++ b/src/components/register/Register.js @@ -0,0 +1,106 @@ +import React, { useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { NavLink, useNavigate } from 'react-router-dom'; +import { signUpAsync } from '../../redux/user/userSlice'; +import register from './register.module.css'; + +const Register = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const MINIMUM_PASSWORD_LENGTH = 6; + + const { signError, success } = useSelector((store) => store.user); + const [errors, setErrors] = useState(''); + const [mismatch, setMismatch] = useState(''); + const formRef = useRef(); + + const handleSubmit = (e) => { + e.preventDefault(); + + const formData = new FormData(formRef.current); + + const name = formData.get('name'); + const email = formData.get('email'); + const password = formData.get('password'); + const passwordConfirmation = formData.get('password_confirmation'); + + if (password.length < MINIMUM_PASSWORD_LENGTH) { + setErrors(`Password must be at least ${MINIMUM_PASSWORD_LENGTH} characters!`); + return; + } + + if (password !== passwordConfirmation) { + setMismatch('Passwords do not match'); + return; + } + + setErrors(''); + setMismatch(''); + + const data = { + user: { + name, + email, + password, + password_confirmation: passwordConfirmation, + }, + }; + + dispatch(signUpAsync(data)).then((result) => { + if (result && result.error) return; + navigate('/login'); + }); + }; + + return ( +
+ {signError && {signError}} + {errors && {errors}} +
+

Sign Up

+
+ + + + +
+ + + + +
+

{mismatch}

+

{success}

+
+
+
+ ); +}; + +export default Register; diff --git a/src/components/register/register.module.css b/src/components/register/register.module.css new file mode 100644 index 0000000..962686f --- /dev/null +++ b/src/components/register/register.module.css @@ -0,0 +1,7 @@ +.container { + width: 100%; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/components/splashScreen/Splash.module.css b/src/components/splashScreen/Splash.module.css new file mode 100644 index 0000000..962686f --- /dev/null +++ b/src/components/splashScreen/Splash.module.css @@ -0,0 +1,7 @@ +.container { + width: 100%; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/components/splashScreen/SplashScreen.jsx b/src/components/splashScreen/SplashScreen.jsx new file mode 100644 index 0000000..9612adf --- /dev/null +++ b/src/components/splashScreen/SplashScreen.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; +import styles from './Splash.module.css'; + +const SplashScreen = () => ( +
+
+

Healthcare App

+
+ + + + + + +
+
+
+); + +export default SplashScreen; diff --git a/src/index.js b/src/index.js index 556b8d2..16d286e 100644 --- a/src/index.js +++ b/src/index.js @@ -2,13 +2,17 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import store from './redux/store'; import App from './App'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( - + + + , ); diff --git a/src/redux/store.js b/src/redux/store.js new file mode 100644 index 0000000..21e35d3 --- /dev/null +++ b/src/redux/store.js @@ -0,0 +1,10 @@ +import { configureStore } from '@reduxjs/toolkit'; +import userReducer from './user/userSlice'; + +const store = configureStore({ + reducer: { + user: userReducer, + }, +}); + +export default store; diff --git a/src/redux/user/userSlice.js b/src/redux/user/userSlice.js new file mode 100644 index 0000000..109d1da --- /dev/null +++ b/src/redux/user/userSlice.js @@ -0,0 +1,93 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import axios from 'axios'; +import Cookies from 'js-cookie'; + +const initialState = { + userData: null, + isAuthenticated: false, + success: false, + signError: null, + loginError: null, +}; + +const url = 'http://localhost:3001'; + +export const signUpAsync = createAsyncThunk( + 'signup/Async', + async (FormData) => { + try { + const res = await axios.post(`${url}/signup`, FormData, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + const { token } = res.data; + const expirationTimeInMinutes = 10; + Cookies.set('jwt_token', token, { expires: expirationTimeInMinutes }); + return res.data; + } catch (error) { + if (error.response && error.response.data && error.response.data.error) { + throw new Error(error.response.data.error); + } + throw new Error('Unknow action error!'); + } + }, +); + +export const loginAsync = createAsyncThunk( + 'login/Async', + async (formData) => { + try { + const res = await axios.post(`${url}/login`, formData, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + const { token } = res.data; + const userInfo = res.data.user; + const expirationTimeInMinutes = 5; + Cookies.set('jwt_token', token, { expires: expirationTimeInMinutes / (24 * 60) }); + Cookies.set('user_info', JSON.stringify(userInfo), { expires: expirationTimeInMinutes / (24 * 60) }); + return res.data; + } catch (error) { + if (error.response && error.response.data && error.response.data.error) { + throw new Error(error.response.data.error); + } + throw new Error('Unknown action error!'); + } + }, +); + +const userSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + logout: (state) => ({ + ...state, + userData: null, + isAuthenticated: false, + }), + }, + extraReducers(builder) { + builder.addCase(signUpAsync.fulfilled, (state, action) => ({ + ...state, + userData: action.payload, + })).addCase(signUpAsync.rejected, (state, action) => ({ + ...state, + isAuthenticated: false, + signError: action.error.message, + })).addCase(loginAsync.fulfilled, (state, action) => ({ + ...state, + userData: action.payload, + isAuthenticated: true, + })).addCase(loginAsync.rejected, (state, action) => ({ + ...state, + isAuthenticated: false, + loginError: action.error.message, + })); + }, +}); +export const { logout } = userSlice.actions; +export default userSlice.reducer;