diff --git a/client/Dockerfile b/client/Dockerfile index 9f2a70d4b02313cc3169c1b14a31b41276ca525c..4a1664c0911d5b65f6c67e052ac4ffa1fc2e4d14 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -1,8 +1,8 @@ -FROM node:18-alpine +FROM node:18-alpine AS build WORKDIR /app -COPY package.json . +COPY package.json package-lock.json ./ RUN npm install @@ -10,6 +10,12 @@ COPY . . RUN npm run build -EXPOSE 8080 +FROM nginx:alpine -CMD [ "npm", "run", "preview" ] \ No newline at end of file +COPY --from=build /app/dist /usr/share/nginx/html + +COPY nginx.conf /etc/nginx/nginx.conf + +EXPOSE 4200 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/client/nginx.conf b/client/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..3cdf0cf6541a390af6f9d2bd2e20695d65200264 --- /dev/null +++ b/client/nginx.conf @@ -0,0 +1,23 @@ +events {} + +http { + server { + listen 4200; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + types { + text/html html; + application/javascript js; + text/css css; + image/png png; + image/jpeg jpg; + } + + location / { + try_files $uri $uri/ /index.html; + } + } +} diff --git a/client/package-lock.json b/client/package-lock.json index c840bd23dd09e77b8e2d3681a9baa4238031f1ed..831fa4a986d3bf00b5f692a09a273020d2c14687 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.51.1", "react-router-dom": "^6.22.3" }, "devDependencies": { @@ -2916,6 +2917,21 @@ "react": "^18.2.0" } }, + "node_modules/react-hook-form": { + "version": "7.51.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.1.tgz", + "integrity": "sha512-ifnBjl+kW0ksINHd+8C/Gp6a4eZOdWyvRv0UBaByShwU8JbVx5hTcTWEcd5VdybvmPTATkVVXk9npXArHmo56w==", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-refresh": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", diff --git a/client/package.json b/client/package.json index fc1425e7f33e53b900550791c9427bb12da93d17..14ef04fe6ae997f5911e2e34388789c7cdf243e0 100644 --- a/client/package.json +++ b/client/package.json @@ -12,6 +12,7 @@ "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.51.1", "react-router-dom": "^6.22.3" }, "devDependencies": { diff --git a/client/src/App.css b/client/src/App.css deleted file mode 100644 index 3876ac70a4d8e150fd30970bb8d172a08238dddb..0000000000000000000000000000000000000000 --- a/client/src/App.css +++ /dev/null @@ -1,8 +0,0 @@ -.wrapper { - display: flex; - flex-direction: column; -} - -.main { - height: 85vh; -} \ No newline at end of file diff --git a/client/src/App.scss b/client/src/App.scss new file mode 100644 index 0000000000000000000000000000000000000000..859adee2c1a10d1779f529e7c580a9c393c53aae --- /dev/null +++ b/client/src/App.scss @@ -0,0 +1,15 @@ +.wrapper { + display: flex; + flex-direction: column; + height: 100vh; +} + +.main { + height: 100%; + overflow-y: scroll; + padding: 2rem 0; +} + +.footer-wrapper { + margin-top: auto; +} \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index fb484f54bc588df66f48174497ed3f58bfcfdc72..dcb35e75b78dc174e79d6937110815a5c77dc0e0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,7 +1,7 @@ -import './App.css'; import Header from './components/Header/Header'; import Footer from './components/Footer/Footer'; import { Outlet } from 'react-router-dom'; +import './App.scss'; function App() { return ( @@ -11,7 +11,9 @@ function App() { <div className='main'> <Outlet></Outlet> </div> - <Footer></Footer> + <div className='footer-wrapper'> + <Footer></Footer> + </div> </div> </> ); diff --git a/client/src/components/.gitkeep b/client/src/components/.gitkeep deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/client/src/components/CustomerDashboard/CustomerDashboard.scss b/client/src/components/CustomerDashboard/CustomerDashboard.scss new file mode 100644 index 0000000000000000000000000000000000000000..559bc051b0f758a22437dd4468203ffcade689d7 --- /dev/null +++ b/client/src/components/CustomerDashboard/CustomerDashboard.scss @@ -0,0 +1,105 @@ +.customer-dashboard { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + gap: 2rem; +} + +.avatar { + width: 150px; +} + +.customer-profile { + display: flex; + flex-direction: row; + gap: 1.5rem; +} + +.bio-card { + padding: 1.5rem 5rem !important; // Sorry +} + +.form-h { + display: flex; + flex-direction: column; + gap: 1rem; + + .form-group-h { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + + label { + font-weight: 700; + width: 180px; + } + + input { + font-size: 1rem; + padding: 6px 12px; + border: 1px solid gray; + border-radius: 5px; + width: 300px; + } + + button { + font-size: 1rem; + padding: 6px 12px; + border: 1px solid gray; + border-radius: 5px; + background-color: var(--main); + color: white; + } + + button:hover { + filter: brightness(50%); + cursor: pointer; + } + + button:disabled { + filter: brightness(40%); + } + + button:disabled:hover { + cursor: auto; + } + + span { + overflow: hidden; + color: red; + } + } +} + +.flights-title { + font-size: 2rem; +} + +.flights { + display: flex; + flex-direction: column; + width: 80vw; + gap: 0.5rem; +} + +.flight-list { + display: flex; + gap: 1rem; +} + +.view-more { + font-size: 1rem; + padding: 6px 12px; + border: 1px solid gray; + border-radius: 5px; + background-color: var(--main); + color: white; +} + +.view-more:hover { + filter: brightness(50%); + cursor: pointer; +} \ No newline at end of file diff --git a/client/src/components/CustomerDashboard/CustomerDashboard.tsx b/client/src/components/CustomerDashboard/CustomerDashboard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..006b7dda350f5c8376938dfa61fd91babf31b3a2 --- /dev/null +++ b/client/src/components/CustomerDashboard/CustomerDashboard.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { useLoaderData } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { ICustomerDashboardData } from '../../services/CustomerDashboard/CustomerDashboard'; +import FlightCard from './FlightCard/FlightCard'; +import avatar from './avatar.jpg'; +import './CustomerDashboard.scss'; + +interface ICustomerDashboardForm { + name: string; + email: string; + password: string; + confirmPassword: string; +} + +function CustomerDashboard() { + const [error, setError] = useState(''); + const [disabled, setDisabled] = useState(true); + const data = useLoaderData() as ICustomerDashboardData; + const formValues: ICustomerDashboardForm = { + name: data.name, + email: data.email, + password: '', + confirmPassword: '' + }; + const { register, handleSubmit } = useForm<ICustomerDashboardForm>({mode: 'onChange', defaultValues: formValues}); + + const toggleEdit = () => { + setDisabled(!disabled); + }; + + const onSubmit = (formValue: ICustomerDashboardForm) => { + if (formValue.password.length < 7) { + setError('password length must be greater than 7 characters'); + return; + } + + if (formValue.password !== formValue.confirmPassword) { + setError('password and confirm password must match'); + return; + } + + setError(''); + console.log('ready to make update details api call'); + }; + + return ( + <> + <div className='customer-dashboard'> + <div className='customer-profile'> + <div className='customer-profile-bio'> + <div className='card bio-card'> + <div className='flex'> + <img src={avatar} alt='avatar' className='avatar'></img> + <span>{data.name}</span> + <span>Loyal Customer</span> + </div> + </div> + </div> + + <div className='customer-profile-fields'> + <div className='card'> + <form onSubmit={handleSubmit(onSubmit)}> + <div className='form-h'> + <div className='form-group-h'> + <label>Full Name</label> + <input type='text' {...register('name', { required: true, disabled })} /> + </div> + + <div className='form-group-h'> + <label>Email</label> + <input type='email' {...register('email', { required: true, disabled })} /> + </div> + + <div className='form-group-h'> + <label>Password</label> + <input type='password' placeholder='Enter new password' {...register('password', { required: true, disabled })} /> + </div> + + <div className='form-group-h'> + <label>Confirm Password</label> + <input type='password' placeholder='Confirm new password' {...register('confirmPassword', { required: true, disabled })} /> + </div> + + <div className='form-group-h'> + <button type='button' onClick={toggleEdit}>Toggle Edit</button> + <button type='submit' disabled={disabled}>Submit</button> + </div> + + <div className='form-group-h'> + {error && <span>{error}</span>} + </div> + </div> + </form> + </div> + </div> + </div> + + <div className='flights'> + <span className='flights-title'>Upcoming Flights</span> + <div className='flight-list'> + {data.upcomingFlights.length > 0 + ? data.upcomingFlights.map((flight) => { + return <FlightCard key={flight.id} flight={flight}></FlightCard> + }) + : <div>No Upcoming Flights</div>} + </div> + </div> + + <div className='flights'> + <span className='flights-title'>Flights History</span> + <div className='flight-list'> + {data.upcomingFlights.length > 0 + ? data.flightsHistory.map((flight) => { + return <FlightCard key={flight.id} flight={flight}></FlightCard> + }) + : <div>No Flights History</div>} + </div> + <div> + <button type='button' className='view-more'>View More</button> + </div> + </div> + </div> + </> + ); +} + +export default CustomerDashboard; \ No newline at end of file diff --git a/client/src/components/CustomerDashboard/FlightCard/FlightCard.scss b/client/src/components/CustomerDashboard/FlightCard/FlightCard.scss new file mode 100644 index 0000000000000000000000000000000000000000..639c3a9598ca594a7abf90b6cb70ff7498ba02ce --- /dev/null +++ b/client/src/components/CustomerDashboard/FlightCard/FlightCard.scss @@ -0,0 +1,16 @@ +.flight-card { + display: flex; + flex-direction: column; + gap: 1rem; + border: 1px solid #ddd; + border-radius: 4px; + background-color: white; + padding: 1rem; + box-shadow: 0 1px 3px 0 rgba(0,0,0,.1), 0 1px 2px 0 rgba(0,0,0,.06); + width: max-content; +} + +.flight-path { + color: #777; + font-size: 0.8rem; +} \ No newline at end of file diff --git a/client/src/components/CustomerDashboard/FlightCard/FlightCard.tsx b/client/src/components/CustomerDashboard/FlightCard/FlightCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9e20e2b1af7b11c7302a951085858012948aad26 --- /dev/null +++ b/client/src/components/CustomerDashboard/FlightCard/FlightCard.tsx @@ -0,0 +1,28 @@ +import { Link } from 'react-router-dom'; +import './FlightCard.scss'; + +interface IFlight { + id: number; + flightNumber: string; + flightPath: string; + flightPathFull: string; +} + +interface IFlightCard { + flight: IFlight; +} + +function FlightCard({ flight }: IFlightCard) { + return ( + <> + <div className='flight-card'> + <span>{flight.flightNumber}</span> + <span className='flight-path'>{flight.flightPath}</span> + <span>{flight.flightPathFull}</span> + <Link to={'/flights/' + flight.id}>View Flight</Link> + </div> + </> + ); +} + +export default FlightCard; \ No newline at end of file diff --git a/client/src/components/CustomerDashboard/avatar.jpg b/client/src/components/CustomerDashboard/avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6cc1140f400c6e47c6ac1ddf985ee188f92c4ae2 Binary files /dev/null and b/client/src/components/CustomerDashboard/avatar.jpg differ diff --git a/client/src/components/Login/Login.scss b/client/src/components/Login/Login.scss new file mode 100644 index 0000000000000000000000000000000000000000..1d2060b561763fe1b18e3ca65d965688d744dbc6 --- /dev/null +++ b/client/src/components/Login/Login.scss @@ -0,0 +1,12 @@ +.login { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.login-card { + width: 20vw; + min-width: 350px; +} \ No newline at end of file diff --git a/client/src/components/Login/Login.tsx b/client/src/components/Login/Login.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3c3ecf232ce68c4ff92b11173b38d45b9b9b36af --- /dev/null +++ b/client/src/components/Login/Login.tsx @@ -0,0 +1,48 @@ +import { useForm } from 'react-hook-form'; +import { useState } from 'react'; +import './Login.scss'; + +interface ILogin { + email: string; + password: string; +} + +export function Login() { + const [error, setError] = useState(''); + const { register, handleSubmit } = useForm<ILogin>({mode: 'onChange'}); + + const onSubmit = (formValue: ILogin) => { + setError('TODO: remove me once actual errors are implemented'); + console.log('ready to make login api call', formValue); + }; + + return ( + <> + <div className='login'> + <form onSubmit={handleSubmit(onSubmit)}> + <div className='card login-card'> + <div className='form-group'> + <label>Email Address</label> + <input type='email' placeholder='Enter email' {...register('email', { required: true })} /> + </div> + + <div className='form-group'> + <label>Password</label> + <input type='password' placeholder='Enter password' {...register('password', { required: true })} /> + </div> + + <div className='form-group'> + <button type='submit'>Submit</button> + </div> + + <div className='form-group'> + {error && <span>{error}</span>} + </div> + </div> + </form> + </div> + </> + ); +} + +export default Login; \ No newline at end of file diff --git a/client/src/components/Register/Register.scss b/client/src/components/Register/Register.scss new file mode 100644 index 0000000000000000000000000000000000000000..c5cef15a81dd904f3284c00cb0c1a361b1072d2a --- /dev/null +++ b/client/src/components/Register/Register.scss @@ -0,0 +1,12 @@ +.register { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.register-card { + width: 20vw; + min-width: 350px; +} \ No newline at end of file diff --git a/client/src/components/Register/Register.tsx b/client/src/components/Register/Register.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8c216b6fd6bfd66db4c80a04d8f47b8a5aa5f830 --- /dev/null +++ b/client/src/components/Register/Register.tsx @@ -0,0 +1,70 @@ +import { useForm } from 'react-hook-form'; +import { useState } from 'react'; +import './Register.scss'; + +interface IRegister { + name: string; + email: string; + password: string; + confirmPassword: string; +} + +export function Register() { + const [error, setError] = useState(''); + const { register, handleSubmit } = useForm<IRegister>({mode: 'onChange'}); + + const onSubmit = (formValue: IRegister) => { + if (formValue.password.length < 7) { + setError('password length must be greater than 6 characters'); + return; + } + + if (formValue.password !== formValue.confirmPassword) { + setError('password and confirm password must match'); + return; + } + + setError(''); + console.log('ready to make register api call'); + }; + + return ( + <> + <div className='register'> + <form onSubmit={handleSubmit(onSubmit)}> + <div className='card register-card'> + <div className='form-group'> + <label>Full Name</label> + <input type='text' placeholder='Full name' {...register('name', { required: true })} /> + </div> + + <div className='form-group'> + <label>Email Address</label> + <input type='email' placeholder='Enter email' {...register('email', { required: true })} /> + </div> + + <div className='form-group'> + <label>Password</label> + <input type='password' placeholder='Enter password' {...register('password', { required: true })} /> + </div> + + <div className='form-group'> + <label>Confirm Password</label> + <input type='password' placeholder='Confirm password' {...register('confirmPassword', { required: true })} /> + </div> + + <div className='form-group'> + <button type='submit'>Submit</button> + </div> + + <div className='form-group'> + {error && <span>{error}</span>} + </div> + </div> + </form> + </div> + </> + ); +} + +export default Register; \ No newline at end of file diff --git a/client/src/index.css b/client/src/index.css deleted file mode 100644 index d8a001f0ec7136515ce361a7c36f6859ef69031b..0000000000000000000000000000000000000000 --- a/client/src/index.css +++ /dev/null @@ -1,18 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Lato&display=swap'); - -/* Unifies font across frontend */ -* { - font-family: "Lato", sans-serif; - font-weight: 400; - font-style: normal; -} - -/* Stops whitespace surrounding page */ -body { - margin: 0; -} - -/* CSS Variables */ -:root { - --main: #000055; -} \ No newline at end of file diff --git a/client/src/index.scss b/client/src/index.scss new file mode 100644 index 0000000000000000000000000000000000000000..7c138bb925cbdd8450961fe028f74e52289f6638 --- /dev/null +++ b/client/src/index.scss @@ -0,0 +1,78 @@ +@import url('https://fonts.googleapis.com/css2?family=Lato&display=swap'); + +/* Unifies font across frontend */ +* { + font-family: "Lato", sans-serif; + font-weight: 400; + font-style: normal; +} + +/* Stops whitespace surrounding page */ +body { + margin: 0; +} + +/* CSS Variables */ +:root { + --main: #000055; +} + +/* +** +** Global CSS classes: +** +*/ + +.card { + display: flex; + flex-direction: column; + gap: 1rem; + border: 1px solid gray; + border-radius: 5px; + background-color: white; + padding: 1.5rem; +} + +.form-group { + display: flex; + flex-direction: column; + + label { + font-weight: 700; + margin-bottom: 0.5rem; + } + + input { + font-size: 1rem; + padding: 6px 12px; + border: 1px solid gray; + border-radius: 5px; + } + + button { + font-size: 1rem; + padding: 6px 12px; + border: 1px solid gray; + border-radius: 5px; + background-color: var(--main); + color: white; + } + + button:hover { + filter: brightness(50%); + cursor: pointer; + } + + span { + overflow: hidden; + color: red; + } +} + +.flex { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; +} \ No newline at end of file diff --git a/client/src/main.tsx b/client/src/main.tsx index 8e2d8876ac7420870818462eee6fbba239479052..ac1959e4c66fbec6b3dfc478c1a672924dc0186e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -2,17 +2,29 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import App from './App.tsx'; -import './index.css'; +import Login from './components/Login/Login.tsx'; +import Register from './components/Register/Register.tsx'; +import CustomerDashboard from './components/CustomerDashboard/CustomerDashboard.tsx'; +import { GetCustomerDashboardData } from './services/CustomerDashboard/CustomerDashboard.ts'; +import './index.scss'; const router = createBrowserRouter([ { path: '/', element: <App></App>, children: [ - // TODO: Remove this path { - path: 'test', - element: <div>test</div> + path: 'login', + element: <Login></Login> + }, + { + path: 'register', + element: <Register></Register> + }, + { + path: 'customer-dashboard', + loader: GetCustomerDashboardData, + element: <CustomerDashboard></CustomerDashboard> } ] } diff --git a/client/src/services/CustomerDashboard/CustomerDashboard.ts b/client/src/services/CustomerDashboard/CustomerDashboard.ts new file mode 100644 index 0000000000000000000000000000000000000000..81c852efa4bd605cf45282c2f66e463fb7fd395d --- /dev/null +++ b/client/src/services/CustomerDashboard/CustomerDashboard.ts @@ -0,0 +1,61 @@ + +export interface IFlight { + id: number; + flightNumber: string; + flightPath: string; + flightPathFull: string; +} + +export interface ICustomerDashboardData { + name: string; + email: string; + upcomingFlights: IFlight[]; + flightsHistory: IFlight[]; +} + +export async function GetCustomerDashboardData(): Promise<ICustomerDashboardData> { + return { + name: 'fname lname', + email: 'test@test.com', + upcomingFlights: [ + { + id: 4, + flightNumber: '0004', + flightPath: 'LTN - MLG', + flightPathFull: 'London(LTN) - Spain(MLG)' + }, + { + id: 5, + flightNumber: '0005', + flightPath: 'LTN - MLG', + flightPathFull: 'London(LTN) - Spain(MLG)' + }, + { + id: 6, + flightNumber: '0006', + flightPath: 'LTN - MLG', + flightPathFull: 'London(LTN) - Spain(MLG)' + } + ], + flightsHistory: [ + { + id: 1, + flightNumber: '0001', + flightPath: 'LTN - MLG', + flightPathFull: 'London(LTN) - Spain(MLG)' + }, + { + id: 2, + flightNumber: '0002', + flightPath: 'LTN - MLG', + flightPathFull: 'London(LTN) - Spain(MLG)' + }, + { + id: 3, + flightNumber: '0003', + flightPath: 'LTN - MLG', + flightPathFull: 'London(LTN) - Spain(MLG)' + } + ] + } +} \ No newline at end of file