diff --git a/package-lock.json b/package-lock.json index d56d81b..a687f95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "react-jwt": "^1.2.0", + "react-modal": "^3.16.1", "react-router": "^6.13.0", "react-router-dom": "^6.13.0", "react-scripts": "5.0.1", @@ -8446,6 +8447,11 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exenv": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==" + }, "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -14981,6 +14987,29 @@ "react": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "node_modules/react-modal": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz", + "integrity": "sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==", + "dependencies": { + "exenv": "^1.2.0", + "prop-types": "^15.7.2", + "react-lifecycles-compat": "^3.0.0", + "warning": "^4.0.3" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18", + "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18" + } + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -17250,6 +17279,14 @@ "makeerror": "1.0.12" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/package.json b/package.json index 25691fd..54a3d2a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "react-jwt": "^1.2.0", + "react-modal": "^3.16.1", "react-router": "^6.13.0", "react-router-dom": "^6.13.0", "react-scripts": "5.0.1", diff --git a/src/components/Dropdown/Dropdown.js b/src/components/Dropdown/Dropdown.js index 5a8344e..575dc17 100644 --- a/src/components/Dropdown/Dropdown.js +++ b/src/components/Dropdown/Dropdown.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import toast, { Toaster } from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; import Box from '@mui/material/Box'; @@ -16,11 +16,12 @@ import HelpIcon from '@mui/icons-material/Help'; import MessageIcon from '@mui/icons-material/Message'; import PolicyIcon from '@mui/icons-material/Policy'; import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; -import { useEffect } from 'react'; +import { CaptureFeedback } from '../Feedback/CaptureFeedback'; // export default function Dropdown() { const [anchorEl, setAnchorEl] = useState(null); const [loggedIn, setLoggedIn] = useState(false); // Change the state name to 'loggedIn' + const [feedbackVisible, setFeedbackVisible] = useState(false); // Toggle visibility of feedback modal const open = Boolean(anchorEl); const navigate = useNavigate(); @@ -62,6 +63,12 @@ export default function Dropdown() { navigate('/privacy-policy'); } + // Toggle CaptureFeedback modal component visibility + function toggleFeedbackModal() { + setFeedbackVisible((prevFeedbackVisible) => !prevFeedbackVisible); + handleClose(); + } + return (   Help - +   Feedback @@ -162,6 +168,13 @@ export default function Dropdown() { )} + {/* Conditionally render CaptureFeedback modal component */} + {feedbackVisible && ( + + )} ); } diff --git a/src/components/Feedback/CaptureFeedback.jsx b/src/components/Feedback/CaptureFeedback.jsx new file mode 100644 index 0000000..334de18 --- /dev/null +++ b/src/components/Feedback/CaptureFeedback.jsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import Modal from 'react-modal'; +import PropTypes from 'prop-types'; +import { Toaster } from 'react-hot-toast'; +import DirectFeedback from './DirectFeedback'; +import EmailFeedback from './EmailFeedback'; +import Typography from '@mui/material/Typography'; + +Modal.setAppElement('#root'); + +export function CaptureFeedback({ isActive, onClose }) { + const [formToShow, setFormToShow] = useState('initialPrompt'); + const [rating, setRating] = useState(0); + + const handleFormSwitch = (formType) => { + setRating(0); + setFormToShow(formType); + }; + + const onPointerMove = (value) => { + if (rating === 0) { + setRating(value); + } + }; + + return ( + + + +
+ + We appreciate your feedback.
+ Please let us know how we can improve. +
+ {formToShow === 'initialPrompt' && ( +
+ + +
+ )} + {formToShow === 'directFeedback' && ( + + )} + {formToShow === 'emailFeedback' && ( + + )} +
+
+ +
+ ); +} + +CaptureFeedback.propTypes = { + isActive: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/components/Feedback/DirectFeedback.jsx b/src/components/Feedback/DirectFeedback.jsx new file mode 100644 index 0000000..4d4a000 --- /dev/null +++ b/src/components/Feedback/DirectFeedback.jsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { Formik, Form, Field } from 'formik'; +import { Rating } from 'react-simple-star-rating'; +import * as Yup from 'yup'; +import axios from 'axios'; +import PropTypes from 'prop-types'; +import toast, { Toaster } from 'react-hot-toast'; +import './Feedback.css'; + +const feedbackValidationSchema = Yup.object().shape({ + feedback: Yup.string() + .trim() + .required('Feedback is required') + .max(2000, 'Feedback should not exceed 2000 characters'), + rating: Yup.number() + .required('Rating is required') + .min(0, 'Rating should be at least 0') + .max(5, 'Rating should not exceed 5'), +}); + +export default function DirectFeedback({ + handleFormSwitch, + rating, + onPointerMove, + setRating, + onClose, +}) { + return ( + + { + //Get token and user info + const token = localStorage.getItem('token'); + const user = JSON.parse(localStorage.getItem('user')); + + if (token && user) { + // User logged in and user details are available + const userId = user._id; + axios + .post( + 'http://localhost:3001/api/user/feedback/new', + { + feedback: values.feedback, + rating: values.rating, + author: userId, + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + } + ) + .then((response) => {}) + .then(() => { + setSubmitting(false); + onClose(); + toast.success( + 'Thank you, your feedback has been submitted!' + ); + }) + .catch((error) => { + console.error( + 'There was an error submitting the feedback', + error + ); + }); + } else { + // User is not logged in or user details are not available + console.error( + 'User is not authenticated or user details are missing.' + ); + } + }} + > + {({ isSubmitting, setFieldValue }) => ( +
+ +
+
+ { + setRating(value); + setFieldValue('rating', value); + }} + onPointerMove={onPointerMove} + initialValue={rating} + /> +
+
+
+ + +
+ + )} +
+ +
+ ); +} + +DirectFeedback.propTypes = { + handleFormSwitch: PropTypes.func.isRequired, + rating: PropTypes.number.isRequired, + onPointerMove: PropTypes.func.isRequired, + setRating: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/components/Feedback/EmailFeedback.jsx b/src/components/Feedback/EmailFeedback.jsx new file mode 100644 index 0000000..cd95308 --- /dev/null +++ b/src/components/Feedback/EmailFeedback.jsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { Formik, Form, Field } from 'formik'; +import { Rating } from 'react-simple-star-rating'; +import * as Yup from 'yup'; +import axios from 'axios'; +import PropTypes from 'prop-types'; +import toast, { Toaster } from 'react-hot-toast'; +import './Feedback.css'; + +const feedbackValidationSchema = Yup.object().shape({ + feedback: Yup.string() + .trim() + .required('Feedback is required') + .max(2000, 'Feedback should not exceed 2000 characters'), + rating: Yup.number() + .required('Rating is required') + .min(1, 'Rating should be at least 1') + .max(5, 'Rating should not exceed 5'), +}); + +export default function EmailFeedback({ + handleFormSwitch, + rating, + onPointerMove, + setRating, + onClose, +}) { + return ( + + { + const token = localStorage.getItem('token'); + const user = JSON.parse(localStorage.getItem('user')); + const userId = user._id; + const userEmail = user.email; + const username = user.username; + + if (token && user) { + // User logged in and user details are available + axios + .post( + 'http://localhost:3001/api/user/feedback/mail', + { + feedback: values.feedback, + rating: values.rating, + userId: userId, + userEmail: userEmail, + username: username, + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + } + ) + .then((response) => {}) + .then(() => { + setSubmitting(false); + onClose(); + toast.success( + 'Thank you, your feedback has been submitted!' + ); + }) + .catch((error) => { + console.error( + 'There was an error submitting the feedback', + error + ); + }); + } else { + // User is not logged in or user details are not available + console.error( + 'User is not authenticated or user details are missing.' + ); + } + }} + > + {({ isSubmitting, setFieldValue }) => ( +
+ +
+ { + setRating(value); + setFieldValue('rating', value); + }} + onPointerMove={(value) => { + if (!isSubmitting) { + //IF statement should prevent rating state change when submitting but not working? + onPointerMove(value); + } + }} + initialValue={rating} + /> +
+
+ + +
+ + )} +
+ +
+ ); +} + +EmailFeedback.propTypes = { + handleFormSwitch: PropTypes.func.isRequired, + rating: PropTypes.number.isRequired, + onPointerMove: PropTypes.func.isRequired, + setRating: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/components/Feedback/Feedback.css b/src/components/Feedback/Feedback.css new file mode 100644 index 0000000..a427dc4 --- /dev/null +++ b/src/components/Feedback/Feedback.css @@ -0,0 +1,89 @@ +/* MODAL Styling */ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); +} + +.feedback-modal { + position: absolute; + top: 50%; + left: 50%; + width: 55%; + height: 60%; + transform: translate(-50%, -50%); + background: white; + padding: 20px; + border-radius: 10px; +} + +.close-button { + position: relative; + z-index: 9999; + border: none; + font-weight: bold; + font-size: 1.6vw; + cursor: pointer; + background: none; +} + +/* FORM Styling */ +.modal-form { + display: flex; + flex-direction: column; + justify-content: space-around; + height: 80%; +} + +.feedback-header { + text-align: center; + font-weight: bold; + font-size: 2vw; +} + +.feedback-textarea { + width: 100%; + height: 120px; + margin-bottom: 10px; + padding: 10px; + border-radius: 5px; + border: 1px solid #ccc; + resize: none; +} + +.button-group { + display: flex; + justify-content: space-around; + margin-top: auto; +} + +.button-group button { + border-radius: 3px; + color: white; + background-color: rgb(46, 125, 50); + font-weight: bold; + font-size: 1em; + padding: 0.5em; + cursor: pointer; + transition: box-shadow 0.3s ease; +} + +.button-group button:hover { + background-color: rgb(36, 110, 40); +} + +.feedback-textarea { + resize: none; +} + +.rating-container { + display: flex; + justify-content: center; + margin-bottom: 20px; +} + + + diff --git a/src/server/.variable.env b/src/server/.variable.env index 431e35b..f4e7468 100644 --- a/src/server/.variable.env +++ b/src/server/.variable.env @@ -1,7 +1,6 @@ -<<<<<<< HEAD MONGO_DB= PORT= -======= -MONGO_DB=mongodb://username:password@localhost:27017/codebooker -PORT=3001 ->>>>>>> origin/master + +//Credentials for sending/receiving feedback from server +MAIL_USERNAME="senderAddressEmail" +MAIL_PASSWORD="senderAddressPassword" \ No newline at end of file diff --git a/src/server/controller/feedBackController.js b/src/server/controller/feedBackController.js index aa50d54..27f8863 100644 --- a/src/server/controller/feedBackController.js +++ b/src/server/controller/feedBackController.js @@ -1,3 +1,4 @@ +const nodemailer = require('nodemailer'); const FeedBack = require('../model/feedBackModel'); module.exports.addfeedback = async (req, res) => { @@ -12,6 +13,52 @@ module.exports.addfeedback = async (req, res) => { } }; +// TO DO: +// - feedback module reopens once server successfully sends the email? + +module.exports.emailFeedback = async (req, res) => { + try { + const { username, userId, userEmail, feedback, rating } = req.body; + + // message to send + const message = ` +

${username} has submitted some feedback.

+

userId: ${userId}

+

User email: ${userEmail}

+

Feedback: ${feedback}

+

Rating: ${rating}

+ `; + + const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: process.env.MAIL_USERNAME, + pass: process.env.MAIL_PASSWORD, + }, + }); + + const mailOptions = { + from: process.env.MAIL_USERNAME, + to: process.env.MAIL_USERNAME, + subject: 'CodeBooker: New Feedback Received', + html: message, + }; + + transporter.sendMail(mailOptions, (error, info) => { + if (error) { + console.log(error); + } else { + res.status(200).json('Email sent!'); + console.log('Email sent: ' + info.response); + } + }); + } catch (err) { + // Add catch block to handle exceptions + console.log(err.message); + res.status(500).json('Something went wrong...'); + } +}; + module.exports.allfeedback = async (req, res) => { try { const newfeedback = await FeedBack.find(); diff --git a/src/server/controller/userController.js b/src/server/controller/userController.js index aa2b03e..ea499a6 100644 --- a/src/server/controller/userController.js +++ b/src/server/controller/userController.js @@ -84,6 +84,7 @@ module.exports.login = async (req, res, next) => { if (err) { return next(err); } + req.session.userId = user._id; //Save userId to session for feedback submission if (req.body.rememberMe) { token = jwt.sign( { email: user.email, id: user._id }, diff --git a/src/server/package.json b/src/server/package.json index 4dc47ef..e838387 100644 --- a/src/server/package.json +++ b/src/server/package.json @@ -6,6 +6,7 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, + "proxy": "http://localhost:3001", "repository": { "type": "git", "url": "git+https://github.com/gbowne1/codebooker.git" diff --git a/src/server/routes/userRoutes.js b/src/server/routes/userRoutes.js index 26808ed..573bd1c 100644 --- a/src/server/routes/userRoutes.js +++ b/src/server/routes/userRoutes.js @@ -7,7 +7,11 @@ const { forgotPassword, resetPassword, } = require('../controller/userController'); -const { allfeedback, addfeedback } = require('../controller/feedBackController'); +const { + allfeedback, + addfeedback, + emailFeedback, +} = require('../controller/feedBackController'); //auth router.post('/login', login); @@ -21,5 +25,6 @@ router.put('/reset-password/:token', resetPassword); //feedback routes router.get('/feedback/all', allfeedback); router.post('/feedback/new', addfeedback); +router.post('/feedback/mail', emailFeedback); module.exports = router;