diff --git a/admin-frontend/src/App.js b/admin-frontend/src/App.js index 353e48a..2e0456f 100644 --- a/admin-frontend/src/App.js +++ b/admin-frontend/src/App.js @@ -23,7 +23,11 @@ import AdminNotificationPage from "./components/notification/AdminNotificationPa import VerifyEmailPage from "./components/VerifyEmailPage"; import ViewActiveBookingsPage from "./components/booking/ViewActiveBookingsPage"; import ViewPastBookingsPage from "./components/booking/ViewPastBookingsPage"; +import SubmittedSurvey from "./components/survey/SubmittedSurvey"; +import SubmittedSurveys from "./components/survey/SubmittedSurveys"; import ActivityThemesPage from "./components/activitytheme/ActivityThemesPage"; +import ActivityReviews from "./components/review/ActivityReviews"; +import ManageReviewsForActivity from "./components/review/ManageReviewsForActivity"; import AdminChatpage from "./components/Chat/AdminChatPage"; function App() { @@ -204,6 +208,42 @@ function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> { User Management - {userManagementList.map((item, index) => ( @@ -146,6 +149,30 @@ const SideNavBar = ({ isSidebarOpen }) => { ))} + {" "} + + + + Survey Management + + + + {surveyManagementList.map((item, index) => ( + + + handleItemClick(Object.keys(item)[0])} + > + + + + + ))} diff --git a/admin-frontend/src/components/review/ActivityReviews.jsx b/admin-frontend/src/components/review/ActivityReviews.jsx new file mode 100644 index 0000000..8b2eb6c --- /dev/null +++ b/admin-frontend/src/components/review/ActivityReviews.jsx @@ -0,0 +1,54 @@ +import React from "react"; + +import styled from "@emotion/styled"; +import { useTheme } from "@emotion/react"; +import { Badge, CircularProgress, Tabs, Tab, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { + useActivityStore, + useAdminSurveyResponseStore, + useBookingStore, + useSnackbarStore, +} from "../../zustand/GlobalStore"; +import MainBodyContainer from "../common/MainBodyContainer"; + +import ReviewActivityTable from "./ReviewActivityTable"; +function ActivityReviews() { + const theme = useTheme(); + + const { openSnackbar } = useSnackbarStore(); + const { activities, getActivity, isLoading, pendingApprovalActivities } = + useActivityStore(); + + useEffect(() => { + const fetchData = async () => { + await getActivity(); + }; + fetchData(); + }, [getActivity]); + + if (isLoading) { + return ; + } + return ( + + + Manage Reviews + + {activities && } + + ); +} + +export default ActivityReviews; diff --git a/admin-frontend/src/components/review/ManageReviewsForActivity.jsx b/admin-frontend/src/components/review/ManageReviewsForActivity.jsx new file mode 100644 index 0000000..175f3a4 --- /dev/null +++ b/admin-frontend/src/components/review/ManageReviewsForActivity.jsx @@ -0,0 +1,68 @@ +import React from "react"; + +import { useTheme } from "@emotion/react"; +import { CircularProgress, Typography } from "@mui/material"; +import { useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { + useActivityStore, + useReviewStore, + useSnackbarStore +} from "../../zustand/GlobalStore"; +import MainBodyContainer from "../common/MainBodyContainer"; + +import ManageReviewsForActivityTable from "./ManageReviewsForActivityTable"; + +function ManageReviewsForActivity() { + const theme = useTheme(); + const { activityId } = useParams(); + + const { openSnackbar } = useSnackbarStore(); + const { activities, getActivity, isLoading, pendingApprovalActivities } = + useActivityStore(); + + const { reviews, activity, getReviewsForActivity, toggleReviewVisibility } = + useReviewStore(); + + useEffect(() => { + const fetchData = async () => { + await getReviewsForActivity(activityId); + }; + fetchData(); + }, [getActivity]); + console.log(reviews); + + const handleToggle = async (reviewId) => { + try { + await toggleReviewVisibility(reviewId); + console.log("after handle toggle", reviews) + } catch (error) { + console.error(error); + openSnackbar("An error occurred", "error"); + } + }; + if (isLoading) { + return ; + } + return ( + + + Manage Reviews For {activity?.title} + + {reviews && } + + ); +} + +export default ManageReviewsForActivity; diff --git a/admin-frontend/src/components/review/ManageReviewsForActivityTable.jsx b/admin-frontend/src/components/review/ManageReviewsForActivityTable.jsx new file mode 100644 index 0000000..8dd7fbb --- /dev/null +++ b/admin-frontend/src/components/review/ManageReviewsForActivityTable.jsx @@ -0,0 +1,170 @@ +import { + Dialog, + DialogContent, + Rating, + Stack, + Switch, + Typography, +} from "@mui/material"; + +import Box from "@mui/material/Box"; +import { DataGrid, GridToolbarFilterButton } from "@mui/x-data-grid"; +import PropTypes from "prop-types"; +import React, { useEffect, useState } from "react"; + +const ManageReviewsForActivityTable = ({ reviews, handleToggle }) => { + const [currentTabRows, setCurrentTabRows] = useState(reviews); + const [selectedReview, setSelectedReview] = useState(null); + + useEffect(() => { + setCurrentTabRows(reviews); + }, [reviews]); + + const columns = [ + { + field: "rating", + headerName: "Rating", + flex: 1, + renderCell: (params) => { + const ratingValue = params.value; + return ( + + +
{ratingValue}
+
+ ); + }, + }, + { + field: "comment", + headerName: "Client Comment", + flex: 1, + }, + { + field: "client", + headerName: "By Client", + flex: 1, + renderCell: (params) => { + return params.row.client.name; + }, + }, + { + field: "date", + headerName: "Date Submitted", + flex: 1, + renderCell: (params) => { + const date = new Date(params.value); + const formattedDate = date.toLocaleDateString(undefined, { + year: "2-digit", + month: "2-digit", + day: "2-digit", + }); + const formattedTime = date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "numeric", + hour12: true, + }); + return ( +
+ + Submitted on  + + {formattedDate} at {formattedTime} + + +
+ ); + }, + }, + { + field: "actions", + type: "actions", + flex: 1, + headerName: "Shown", + renderCell: (params) => { + const isChecked = !params.row.hidden; + return ( +
+ handleToggle(params.row._id)} + /> +
+ ); + }, + }, + ]; + + const handleRowClick = (params) => { + setSelectedReview(params.row); + }; + + const handleClose = () => { + setSelectedReview(null); + }; + + return ( + +
+ row._id} + rows={currentTabRows} + columns={columns} + slots={{ + toolbar: GridToolbarFilterButton, + }} + disableRowSelectionOnClick + getRowHeight={() => "auto"} + onRowClick={handleRowClick} + sx={{ + borderRadius: "10px", + boxShadow: "4px 4px 0px 0px rgb(159 145 204 / 40%)", + border: "none", + backgroundColor: "white", + "& .MuiDataGrid-cell:hover": { + cursor: "pointer", + }, + }} + /> +
+ + + {selectedReview && ( +
+ Rating: + + Comment: + {selectedReview.comment} + By: + {selectedReview.client.name} +
+ )} +
+
+
+ ); +}; + +ManageReviewsForActivityTable.propTypes = { + reviews: PropTypes.array.isRequired, +}; + +export default ManageReviewsForActivityTable; diff --git a/admin-frontend/src/components/review/ReviewActivityTable.jsx b/admin-frontend/src/components/review/ReviewActivityTable.jsx new file mode 100644 index 0000000..d1b3142 --- /dev/null +++ b/admin-frontend/src/components/review/ReviewActivityTable.jsx @@ -0,0 +1,178 @@ +import { + Button, + Typography +} from "@mui/material"; +import { useNavigate } from "react-router-dom"; + +import ReviewsIcon from '@mui/icons-material/Reviews'; +import Box from "@mui/material/Box"; +import { DataGrid, GridToolbarFilterButton } from "@mui/x-data-grid"; +import PropTypes from "prop-types"; +import React, { useEffect, useState } from "react"; + +const WrappedTextCell = (params) => { + return
{params.value}
; +}; + +const ReviewActivityTable = ({ activities }) => { + const [currentTabRows, setCurrentTabRows] = useState(activities); + + const navigate = useNavigate(); + + const handleGoToReviews = (activity) => { + navigate(`/reviews/activity/${activity._id}`); + }; + + useEffect(() => { + const filteredActivities = activities.filter(activity => activity.approvalStatus === "Published"); + setCurrentTabRows(filteredActivities); + + }, [activities]); + + // const handleRowClick = async (activity) => { + // const res = await AxiosConnect.get(`/activity/getImages/${activity._id}`); + // setImgs(res.data.activityImages); + // setVendorProfile(res.data.vendorProfileImage); + // setOpenViewModal(true); + // setSelectedActivity(activity); + // }; + + const columns = []; + + columns.push( + { + field: "title", + headerName: "Title", + flex: 1, + }, + { + field: "linkedVendor", + headerName: "Hosted By", + flex: 1, + valueGetter: (params) => { + return params.value?.companyName; + }, + }, + { + field: "activityType", + headerName: "Activity Type", + flex: 1, + renderCell: (params) => , + }, + { + field: "theme", + headerName: "Theme", + flex: 1, + valueGetter: (params) => { + return params.value?.name; + }, + }, + { + field: "subtheme", + headerName: "Learning Topics", + flex: 1, + valueGetter: (params) => { + return params.value.map((x) => x.name); + }, + }, + { + field: "duration", + headerName: "Duration", + flex: 1, + valueFormatter: (params) => { + return `${params.value} min`; + }, + }, + + { + field: "modifiedDate", + headerName: "Published", + flex: 1, + renderCell: (params) => { + const date = new Date(params.value); + const formattedDate = date.toLocaleDateString(undefined, { + year: "2-digit", + month: "2-digit", + day: "2-digit", + }); + const formattedTime = date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "numeric", + hour12: true, + }); + return ( +
+ + Published on  + + {formattedDate} at {formattedTime} + + +
+ ); + }, + },{ + field: "go", + type: "actions", + flex: 1, + headerName: "View Reviews", + renderCell: (params) => { + return ( +
+ +
+ ); + }, + }, + + + ); + + return ( + +
+ row._id} + rows={currentTabRows} + columns={columns} + slots={{ + toolbar: GridToolbarFilterButton, + }} + getRowHeight={() => "auto"} + // onRowClick={(params) => handleRowClick(params.row)} + disableRowSelectionOnClick + sx={{ + borderRadius: "10px", + boxShadow: "4px 4px 0px 0px rgb(159 145 204 / 40%)", + border: "none", + backgroundColor: "white", + "& .MuiDataGrid-cell:hover": { + cursor: "pointer", + }, + }} + /> +
+
+ ); +}; +ReviewActivityTable.propTypes = { + activities: PropTypes.array.isRequired, +}; +export default ReviewActivityTable; diff --git a/admin-frontend/src/components/survey/SubmittedSurvey.jsx b/admin-frontend/src/components/survey/SubmittedSurvey.jsx new file mode 100644 index 0000000..c4fd08a --- /dev/null +++ b/admin-frontend/src/components/survey/SubmittedSurvey.jsx @@ -0,0 +1,108 @@ +import React from "react"; + +import styled from "@emotion/styled"; +import { useTheme } from "@emotion/react"; +import { + Badge, + CircularProgress, + Tabs, + Tab, + Typography, + Divider, + Box, +} from "@mui/material"; +import { useEffect, useState } from "react"; +import { + useAdminSurveyResponseStore, + useBookingStore, + useSnackbarStore, +} from "../../zustand/GlobalStore"; +import InfoIcon from "@mui/icons-material/Info"; +import MainBodyContainer from "../common/MainBodyContainer"; +import SurveyDetails from "./SurveyDetails"; +import { useParams } from "react-router-dom"; + +const convertISOtoDate = (value) => { + const date = new Date(value); + const options = { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }; + const formattedDate = date.toLocaleDateString("en-SG", options); + return formattedDate; +}; +function SubmittedSurvey() { + const theme = useTheme(); + const { surveyId } = useParams(); + const { survey, getSurveyDetails, isLoading } = useAdminSurveyResponseStore(); + const { openSnackbar } = useSnackbarStore(); + + useEffect(() => { + const get = async () => { + try { + await getSurveyDetails(surveyId); + } catch (err) { + openSnackbar("Error retrieving survey.", "error"); + } + }; + get(); + }, []); + + if (isLoading) { + return ; + } + + return ( + + + Survey Response for {survey?.activity.title} by{" "} + {survey?.booking.clientId.name} + + + + + Event Date: {convertISOtoDate(survey?.booking.endDateTime)} + + + Response Date: {convertISOtoDate(survey?.created)} + + + + + + + {survey && } + + ); +} + +export default SubmittedSurvey; diff --git a/admin-frontend/src/components/survey/SubmittedSurveys.jsx b/admin-frontend/src/components/survey/SubmittedSurveys.jsx new file mode 100644 index 0000000..3f14f1d --- /dev/null +++ b/admin-frontend/src/components/survey/SubmittedSurveys.jsx @@ -0,0 +1,56 @@ +import React from "react"; + +import styled from "@emotion/styled"; +import { useTheme } from "@emotion/react"; +import { Badge, CircularProgress, Tabs, Tab, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { + useAdminSurveyResponseStore, + useBookingStore, + useSnackbarStore, +} from "../../zustand/GlobalStore"; +import MainBodyContainer from "../common/MainBodyContainer"; +import SubmittedSurveysTable from "./SubmittedSurveysTable"; +function SubmittedSurveys() { + const theme = useTheme(); + const { getSubmittedSurveys, surveys, isLoading } = + useAdminSurveyResponseStore(); + const { openSnackbar } = useSnackbarStore(); + + useEffect(() => { + const get = async () => { + try { + await getSubmittedSurveys(); + } catch (err) { + openSnackbar("Error retrieving surveys.", "error"); + } + }; + + get(); + }, []); + + if (isLoading) { + return ; + } + return ( + + + Submitted Feedback Surveys + + + + ); +} + +export default SubmittedSurveys; diff --git a/admin-frontend/src/components/survey/SubmittedSurveysTable.jsx b/admin-frontend/src/components/survey/SubmittedSurveysTable.jsx new file mode 100644 index 0000000..6eae1a1 --- /dev/null +++ b/admin-frontend/src/components/survey/SubmittedSurveysTable.jsx @@ -0,0 +1,231 @@ +import ArticleIcon from "@mui/icons-material/Article"; +import CancelIcon from "@mui/icons-material/Cancel"; +import EventAvailableIcon from "@mui/icons-material/EventAvailable"; +import EventBusyIcon from "@mui/icons-material/EventBusy"; +import PaidIcon from "@mui/icons-material/Paid"; +import ThumbUpAltIcon from "@mui/icons-material/ThumbUpAlt"; +import { Button, Stack, Typography, useTheme } from "@mui/material"; +import { DataGrid, GridToolbarFilterButton } from "@mui/x-data-grid"; +import PropTypes from "prop-types"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { convertISOtoShortDate } from "../../utils/TimeFormatter"; + +const SubmittedSurveysTable = ({ allSurveys, openSnackbar }) => { + const [surveys, setSurveys] = useState([]); + const theme = useTheme(); + const navigate = useNavigate(); + + useEffect(() => { + setSurveys(allSurveys); + }, [allSurveys]); + + const statusIcons = { + CONFIRMED: { icon: , text: "Confirmed" }, + REJECTED: { icon: , text: "Rejected" }, + CANCELLED: { icon: , text: "Cancelled" }, + PENDING_PAYMENT: { + icon: , + text: "Pending Payment", + }, + PAID: { icon: , text: "Paid" }, + }; + + const handleGoToSurvey = (booking) => { + navigate(`/surveys/${booking._id}`); + }; + + const columns = [ + { + field: "activityTitle", + flex: 1, + renderHeader: (params) => { + return ( + + Activity Title + + ); + }, + renderCell: (params) => { + return params.row.activity.title; + }, + valueGetter: (params) => { + return params.row.activity.title; + }, + }, + { + field: "clientId", + flex: 1, + renderHeader: (params) => { + return ( + + Client Company + + ); + }, + renderCell: (params) => { + return params.row.booking.clientId.name; + }, + valueGetter: (params) => { + return params.row.booking.clientId.name; + }, + }, + { + field: "vendorName", + flex: 1, + renderHeader: (params) => { + return ( + + Vendor + + ); + }, + renderCell: (params) => { + return params.row.activity.linkedVendor.companyName; + }, + valueGetter: (params) => { + return params.row.activity.linkedVendor.companyName; + }, + }, + { + field: "startDateTime", + headerName: "Event Date", + type: "date", + flex: 1, + valueGetter: (params) => { + return new Date(params.row.booking.startDateTime); + }, + }, + { + field: "status", + flex: 1, + renderHeader: (params) => { + return ( + + Status + + ); + }, + renderCell: (params) => { + const rowStatus = params.row.booking.status; + return ( + + {statusIcons[rowStatus].icon} {statusIcons[rowStatus].text} + + ); + }, + valueGetter: (params) => { + return params.row.booking.status; + }, + }, + { + field: "date", + flex: 1, + renderHeader: (params) => { + return ( + + Date Submitted + + ); + }, + renderCell: (params) => { + const date = params.row.created; + return ( +
+ + {convertISOtoShortDate(date)} + +
+ ); + }, + valueGetter: (params) => { + return params.row.created; + }, + }, + + { + field: "go", + type: "actions", + flex: 1, + renderHeader: (params) => { + return ( + + Go to Survey + + ); + }, + renderCell: (params) => { + return ( +
+ +
+ ); + }, + }, + ]; + + return ( +
+ row._id} + rows={surveys} + columns={columns} + slots={{ + toolbar: GridToolbarFilterButton, + }} + getRowHeight={() => "auto"} + sx={{ + borderRadius: "10px", + boxShadow: "4px 4px 0px 0px rgb(159 145 204 / 40%)", + border: "none", + backgroundColor: "white", + "& .MuiDataGrid-cell:hover": { + cursor: "pointer", + }, + }} + /> +
+ ); +}; + +SubmittedSurveysTable.propTypes = { + allBookings: PropTypes.array.isRequired, +}; + +export default SubmittedSurveysTable; diff --git a/admin-frontend/src/components/survey/SurveyDetails.jsx b/admin-frontend/src/components/survey/SurveyDetails.jsx new file mode 100644 index 0000000..2b2de9a --- /dev/null +++ b/admin-frontend/src/components/survey/SurveyDetails.jsx @@ -0,0 +1,222 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { + Box, + Button, + Checkbox, + CircularProgress, + Divider, + FormControlLabel, + Grid, + Paper, + Rating, + Stack, + TextField, + Typography, +} from "@mui/material"; +import InfoIcon from "@mui/icons-material/Info"; +import AddIcon from "@mui/icons-material/Add"; + +const StyledPaper = styled(Paper)` + padding: 20px; + padding-top: 6px; + border-radius: 10px; + border: 1px solid rgb(159, 145, 204); + box-shadow: 3px 3px 0px 0px rgb(159, 145, 204, 40%); +`; + +function SurveyDetails({ surveyData }) { + console.log(surveyData); + return ( + <> + + + + Feedback + + + + + + How likely is it that you would recommend this experience to a + friend or colleague? + + + + + + + + When do you intend to organize your next activity? + + + + + + + + How likely is it that you would choose to repeat the same activity + with the same vendor? + + + i.e. you would choose to repeat everything as it happened for + future events or batches of participants + + + + + + + + How likely is it that you would choose to repeat the same activity + with a different vendor? + + + i.e. you would choose a similar activity from our catalogue, but + run by a different facilitator with a difference in the programme + + + + + + + + + How likely is it that you would choose a different activity from + our catalogue for your next event? + + + + + + + Likes & Dislikes + + + + + + What did you like about the activity? + + + + + + + + What do you think could be improved about the activity? + + + + + + + + Testimonial + + + + + + + + + If you were satisfied with your experience, we would appreciate a + short testimonial that we can share on our platforms. + + + + + + + + Please indicate your preferred display name and role that we can + use with the testimonial. + + + + + + + Referrals + + + + + + Is there someone you know, whether in your own company or + elsewhere, who might benefit from our activities? + + + + + + + ); +} + +export default SurveyDetails; diff --git a/admin-frontend/src/utils/TimeFormatter.js b/admin-frontend/src/utils/TimeFormatter.js new file mode 100644 index 0000000..b2cf789 --- /dev/null +++ b/admin-frontend/src/utils/TimeFormatter.js @@ -0,0 +1,33 @@ +export const convertISOtoShortDate = (value) => { + const date = new Date(value); + const options = { + year: "2-digit", + month: "2-digit", + day: "2-digit", + }; + const formattedDate = date.toLocaleDateString("en-SG", options); + return formattedDate; +}; +export const convertISOtoDate = (value) => { + const date = new Date(value); + const options = { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }; + const formattedDate = date.toLocaleDateString("en-SG", options); + return formattedDate; +}; + +export const convertISOtoTime = (value) => { + const date = new Date(value); + const formattedTime = date + .toLocaleTimeString("en-SG", { + hour: "numeric", + minute: "numeric", + hour12: true, + }) + .toUpperCase(); + return formattedTime; +}; diff --git a/admin-frontend/src/zustand/GlobalStore.js b/admin-frontend/src/zustand/GlobalStore.js index 0c662d9..f074a30 100644 --- a/admin-frontend/src/zustand/GlobalStore.js +++ b/admin-frontend/src/zustand/GlobalStore.js @@ -633,6 +633,81 @@ export const useBookingStore = create((set) => ({ }, })); +export const useAdminSurveyResponseStore = create((set) => ({ + survey: null, + surveys: [], + isLoading: true, + getSubmittedSurveys: async () => { + try { + const response = await AxiosConnect.get("/survey/submitted"); + console.log("getSubmittedSurveys", response.data); + set({ surveys: response.data, isLoading: false }); + } catch (error) { + console.error(error); + throw error; + } + }, + getSurveyDetails: async (surveyId) => { + try { + const response = await AxiosConnect.get(`/survey/${surveyId}`); + console.log("getSurveyDetails", response.data); + set({ survey: response.data, isLoading: false }); + } catch (error) { + console.error(error); + throw error; + } + }, +})); + +export const useReviewStore = create((set) => ({ + reviews: [], + activity: null, + isLoading: true, + getReviewsForActivity: async (id) => { + try { + const response = await AxiosConnect.get(`/review/activity/${id}`); + console.log("getReviewsForActivity", response.data); + set({ + reviews: response.data.reviews, + activity: response.data.activity, + isLoading: false, + }); + } catch (error) { + console.error(error); + throw error; + } + }, + toggleReviewVisibility: async (reviewId) => { + try { + const response = await AxiosConnect.get( + `/review/${reviewId}/toggleVisibility`, + ); + const updatedReview = response.data; + + set((state) => { + const updatedReviews = state.reviews.map((review) => { + if (review._id === updatedReview._id) { + return { + ...review, + hidden: updatedReview.hidden, + }; + } + return review; + + }); + return { + ...state, + reviews: updatedReviews, + isLoading: false, + }; + }); + } catch (error) { + console.error(error); + throw error; + } + }, +})); + export const useImageUploadTestStore = create((set) => ({ testActivities: [], setTestActivities: (newActivityList) => { diff --git a/client-frontend/src/App.js b/client-frontend/src/App.js index 4d7f98b..c80e83c 100644 --- a/client-frontend/src/App.js +++ b/client-frontend/src/App.js @@ -31,6 +31,8 @@ import EditActivityDraftPage from "./containers/Vendor/Activity/EditActivityDraf import BlockoutDashboard from "./containers/Vendor/Blockout/BlockoutDashboard"; import BlockoutMultipleActivities from "./containers/Vendor/Blockout/BlockoutMultipleActivities"; import BlockoutSingleActivity from "./containers/Vendor/Blockout/BlockoutSingleActivity"; +import FillSurvey from "./containers/Client/Survey/FillSurvey"; +import ViewSurvey from "./containers/Client/Survey/ViewSurvey"; import BookingsPage from "./containers/Vendor/Booking/BookingsPage"; import VendorResetPassword from "./containers/Vendor/Password/ResetPassword"; import VendorForgotPassword from "./containers/Vendor/Password/VendorForgotPassword"; @@ -41,6 +43,7 @@ import VendorRegisterPage from "./containers/Vendor/VendorRegisterPage"; import VerifyEmailVendor from "./containers/Vendor/VerifyEmailVendor"; import useClientStore from "./zustand/ClientStore"; import useVendorStore from "./zustand/VendorStore"; +import PendingSurveys from "./containers/Client/Survey/PendingSurveys"; import ChatPage from "./containers/ChatPage"; function App() { @@ -160,6 +163,36 @@ function App() { } /> + + + + + } + /> + + + + + } + /> + + + + + } + /> My Bookings + { + navigate("/surveys"); + }} + > + + + + Post-Event Surveys + diff --git a/client-frontend/src/containers/Client/Survey/FillSurvey.jsx b/client-frontend/src/containers/Client/Survey/FillSurvey.jsx new file mode 100644 index 0000000..9d23f70 --- /dev/null +++ b/client-frontend/src/containers/Client/Survey/FillSurvey.jsx @@ -0,0 +1,507 @@ +import styled from "@emotion/styled"; +import { + Box, + Button, + Checkbox, + CircularProgress, + Divider, + FormControlLabel, + Grid, + Paper, + Rating, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { useParams, Link, useNavigate, useLocation } from "react-router-dom"; +import React, { useEffect, useState } from "react"; +import useAdminSurveyResponseStore from "../../../zustand/AdminSurveyResponseStore"; +import useSnackbarStore from "../../../zustand/SnackbarStore"; +import MainBodyContainer from "../../../components/Common/MainBodyContainer"; + +const StyledPaper = styled(Paper)` + padding: 20px; + padding-top: 6px; + border-radius: 10px; + border: 1px solid rgb(159, 145, 204); + box-shadow: 3px 3px 0px 0px rgb(159, 145, 204, 40%); +`; + +function FillSurvey() { + const { bookingId } = useParams(); + + const { + submitSurveyForBooking, + getSurveyForBooking, + isLoadingSurvey, + isSurveyAvailable, + } = useAdminSurveyResponseStore(); + + const { openSnackbar } = useSnackbarStore(); + const navigate = useNavigate(); + + const [surveyData, setSurveyData] = useState({ + recommendationScore: 0, + potentialNextActivityDate: "", + repeatActivityScore: 0, + repeatActivityDifferentVendorScore: 0, + differentActivityScore: 0, + activityLiked: "", + activityImprovements: "", + testimonial: "", + displayName: "", + potentialReferrals: "", + }); + + const [reviewData, setReviewData] = useState({ + rating: 0, + comment: "", + }); + + const [surveyErrorData, setErrorData] = useState({ + recommendationScore: "", + potentialNextActivityDate: "", + repeatActivityScore: "", + repeatActivityDifferentVendorScore: "", + differentActivityScore: "", + activityLiked: "", + activityImprovements: "", + testimonial: "", + displayName: "", + potentialReferrals: "", + }); + + useEffect(() => { + const getSurvey = async () => { + try { + const data = await getSurveyForBooking(bookingId); + + if (data.survey) { + setSurveyData(data.survey); + } + + if (data.review) { + setReviewData(data.review); + } + } catch (err) { + console.log(err); + } + }; + + getSurvey(); + }, []); + + // const handleFieldChange = (field, value) => { + // setSurveyData({ + // ...surveyData, + // [field]: value, + // }); + // }; + + const handleFieldChange = (name, value) => { + setSurveyData((prevData) => ({ + ...prevData, + [name]: value, + })); + + const validateIsRequired = (data, errors, fieldName) => { + if (data === "") { + errors[fieldName] = `This field is required`; + } else { + errors[fieldName] = ""; + } + return errors; + }; + console.log(name); + + if (name === "activityImprovements" || name === "activityLiked") { + console.log("here"); + const newErrors = validateIsRequired(value, surveyErrorData, name); + console.log(newErrors); + + setErrorData((prevData) => ({ + ...prevData, + [name]: newErrors[name] || "", + })); + } + }; + + const [wantsToLeaveReview, setWantsToLeaveReview] = useState(false); + + const handleToggleReview = () => { + setWantsToLeaveReview(!wantsToLeaveReview); + }; + const handleReviewFieldChange = (field, value) => { + setReviewData({ + ...reviewData, + [field]: value, + }); + }; + + const disableButton = () => { + for (const field in surveyErrorData) { + if ( + Object.hasOwnProperty.call(surveyErrorData, field) && + surveyErrorData[field] !== "" + ) { + return true; + } + } + return false; + }; + + const handleSubmit = () => { + try { + console.log(surveyData); + console.log(reviewData); + submitSurveyForBooking( + bookingId, + surveyData, + reviewData, + wantsToLeaveReview, + ); + openSnackbar("Submitted!", "success"); + navigate(`/booking/${bookingId}`); + } catch (err) { + console.log(err); + openSnackbar("There was an error.", "error"); + } + }; + + if (isLoadingSurvey) + return ( + + + + ); + + if (!isSurveyAvailable) + return ( + + + + Survey not available + + + + There is no survey available for this booking. + + + + ); + + return ( + + + + Post-Activity Survey + + + + Thank you for choosing one of our activities! Your response will help + us improve to meet your needs better. + + +
+ + + } + label="Do you want to leave a review on this activity?" + /> + + + {wantsToLeaveReview && ( + <> + + + Activity Review + + + This will be a publicly posted review on the activity. + + + + + + What would you rate this activity experience overall? + + + handleReviewFieldChange("rating", newValue) + } + /> + + + + + + What were your thoughts about the activity? + + + handleReviewFieldChange("comment", e.target.value) + } + /> + + + + )} + + + + Feedback for Urban Origins + + + This data is shared with Urban Origins and will not be shown + publicly. Thank you for booking on Gleek! + + + + + + How likely is it that you would recommend this experience to a + friend or colleague? + + + handleFieldChange("recommendationScore", newValue) + } + /> + + + + + + When do you intend to organize your next activity? + + + handleFieldChange( + "potentialNextActivityDate", + e.target.value, + ) + } + /> + + + + + + How likely is it that you would choose to repeat the same + activity with the same vendor? + + + i.e. you would choose to repeat everything as it happened for + future events or batches of participants + + + handleFieldChange("repeatActivityScore", newValue) + } + /> + + + + + + How likely is it that you would choose to repeat the same + activity with a different vendor? + + + i.e. you would choose a similar activity from our catalogue, + but run by a different facilitator with a difference in the + programme + + + + handleFieldChange( + "repeatActivityDifferentVendorScore", + newValue, + ) + } + /> + + + + + + How likely is it that you would choose a different activity + from our catalogue for your next event? + + + handleFieldChange("differentActivityScore", newValue) + } + /> + + + + + + What did you like about the activity? + + + handleFieldChange("activityLiked", e.target.value) + } + helperText={surveyErrorData.activityLiked} + error={!!surveyErrorData.activityLiked} + /> + + + + + + What do you think could be improved about the activity? + + + handleFieldChange("activityImprovements", e.target.value) + } + helperText={surveyErrorData.activityImprovements} + error={!!surveyErrorData.activityImprovements} + /> + + + + + + If you were satisfied with your experience, we would + appreciate a short testimonial that we can share on our + platforms. + + + handleFieldChange("testimonial", e.target.value) + } + /> + + + + + + Please indicate your preferred display name and role that we + can use with the testimonial. + + + handleFieldChange("displayName", e.target.value) + } + /> + + + + + + Is there someone you know, whether in your own company or + elsewhere, who might benefit from our activities? + + + handleFieldChange("potentialReferrals", e.target.value) + } + /> + + + + + + +
+
+
+ ); +} + +export default FillSurvey; diff --git a/client-frontend/src/containers/Client/Survey/PendingSurveys.jsx b/client-frontend/src/containers/Client/Survey/PendingSurveys.jsx new file mode 100644 index 0000000..bc82a60 --- /dev/null +++ b/client-frontend/src/containers/Client/Survey/PendingSurveys.jsx @@ -0,0 +1,58 @@ +import React, { useEffect } from "react"; +import styled from "@emotion/styled"; +import { Box, CircularProgress, Grid, Paper, Typography } from "@mui/material"; +import { useParams } from "react-router-dom"; +import useBookingStore from "../../../zustand/BookingStore"; +import PendingSurveysTable from "./PendingSurveysTable"; +import MainBodyContainer from "../../../components/Common/MainBodyContainer"; + +const StyledPaper = styled(Paper)` + padding: 20px; + border-radius: 10px; + border: 1px solid rgb(159, 145, 204); + box-shadow: 3px 3px 0px 0px rgb(159, 145, 204, 40%); +`; + +function PendingSurveys() { + const { bookings, getBookingsWithPendingSurvey, isLoading } = + useBookingStore(); + + useEffect(() => { + const get = async () => { + await getBookingsWithPendingSurvey(); + }; + + get(); + }, []); + + return ( + + + Pending Post-Event Surveys + + {isLoading ? ( + + + + ) : bookings && bookings.length > 0 ? ( + + ) : ( + + + No surveys open + + + You have no pending surveys at the moment. + + + )} + + ); +} + +export default PendingSurveys; diff --git a/client-frontend/src/containers/Client/Survey/PendingSurveysTable.jsx b/client-frontend/src/containers/Client/Survey/PendingSurveysTable.jsx new file mode 100644 index 0000000..5f8fcbe --- /dev/null +++ b/client-frontend/src/containers/Client/Survey/PendingSurveysTable.jsx @@ -0,0 +1,249 @@ +import AccessTimeIcon from "@mui/icons-material/AccessTime"; +import CancelIcon from "@mui/icons-material/Cancel"; +import EditNoteIcon from "@mui/icons-material/EditNote"; +import EventAvailableIcon from "@mui/icons-material/EventAvailable"; +import EventBusyIcon from "@mui/icons-material/EventBusy"; +import PaidIcon from "@mui/icons-material/Paid"; +import ThumbUpAltIcon from "@mui/icons-material/ThumbUpAlt"; +import { + Chip, + IconButton, + Stack, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import { DataGrid, GridToolbarFilterButton } from "@mui/x-data-grid"; +import PropTypes from "prop-types"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { convertISOtoDate, convertISOtoTime } from "../../../utils/TimeFormatter"; + +const PendingSurveysTable = ({ + allBookings, + + openSnackbar, +}) => { + const [bookings, setBookings] = useState([]); + const theme = useTheme(); + const navigate = useNavigate(); + + useEffect(() => { + setBookings(allBookings); + console.log(bookings); + }, [allBookings]); + + const statusIcons = { + CONFIRMED: { icon: , text: "Confirmed" }, + REJECTED: { icon: , text: "Rejected" }, + CANCELLED: { icon: , text: "Cancelled" }, + PENDING_PAYMENT: { + icon: , + text: "Pending Payment", + }, + PAID: { icon: , text: "Paid" }, + }; + + const handleGoToSurvey = (booking) => { + navigate(`/booking/${booking._id}/survey/edit`); + }; + + const columns = [ + { + field: "activityTitle", + flex: 1, + renderHeader: (params) => { + return ( + + Booked Activity + + ); + }, + }, + { + field: "vendor", + flex: 1, + renderHeader: (params) => { + return ( + + Vendor + + ); + }, + renderCell: (params) => { + return params.row.activityId.linkedVendor.companyName; + }, + valueGetter: (params) => { + return params.row.activityId.linkedVendor.companyName; + }, + }, + { + field: "status", + flex: 1, + renderHeader: (params) => { + return ( + + Status + + ); + }, + renderCell: (params) => { + const rowStatus = params.row.status; + return ( + + {statusIcons[rowStatus].icon} {statusIcons[rowStatus].text} + + ); + }, + }, + { + field: "date", + flex: 1, + renderHeader: (params) => { + return ( + + Date + + ); + }, + renderCell: (params) => { + const date = params.row.startDateTime; + return ( +
+ + {convertISOtoDate(date)} + +
+ ); + }, + valueGetter: (params) => { + return params.row.startDateTime; + }, + }, + { + field: "time", + flex: 1, + renderHeader: (params) => { + return ( + + Time Slot + + ); + }, + renderCell: (params) => { + const startTime = params.row.startDateTime; + const endTime = params.row.endDateTime; + return ( +
+ } + label={ + + {convertISOtoTime(startTime)} - {convertISOtoTime(endTime)} + + } + sx={{ backgroundColor: theme.palette.pale_purple.main }} + /> +
+ ); + }, + valueGetter: (params) => { + return params.row.startDateTime; + }, + }, + + { + field: "go", + type: "actions", + flex: 1, + renderHeader: (params) => { + return ( + + Go to Survey + + ); + }, + renderCell: (params) => { + return ( +
+ handleGoToSurvey(params.row)} + > + + +
+ ); + }, + }, + ]; + + return ( +
+ row._id} + rows={bookings} + columns={columns} + slots={{ + toolbar: GridToolbarFilterButton, + }} + getRowHeight={() => "auto"} + sx={{ + borderRadius: "10px", + boxShadow: "4px 4px 0px 0px rgb(159 145 204 / 40%)", + border: "none", + backgroundColor: "white", + "& .MuiDataGrid-cell:hover": { + cursor: "pointer", + }, + }} + /> +
+ ); +}; + +PendingSurveysTable.propTypes = { + allBookings: PropTypes.array.isRequired, +}; + +export default PendingSurveysTable; diff --git a/client-frontend/src/containers/Client/Survey/ViewSurvey.jsx b/client-frontend/src/containers/Client/Survey/ViewSurvey.jsx new file mode 100644 index 0000000..2fa04ea --- /dev/null +++ b/client-frontend/src/containers/Client/Survey/ViewSurvey.jsx @@ -0,0 +1,389 @@ +import styled from "@emotion/styled"; +import { + Box, + CircularProgress, + Grid, + Paper, + Rating, + TextField, + Typography +} from "@mui/material"; +import React, { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import MainBodyContainer from "../../../components/Common/MainBodyContainer"; +import useAdminSurveyResponseStore from "../../../zustand/AdminSurveyResponseStore"; +import useSnackbarStore from "../../../zustand/SnackbarStore"; + +const StyledPaper = styled(Paper)` + padding: 20px; + padding-top: 6px; + border-radius: 10px; + border: 1px solid rgb(159, 145, 204); + box-shadow: 3px 3px 0px 0px rgb(159, 145, 204, 40%); +`; + +function FillSurvey() { + const { bookingId } = useParams(); + + const { getSurveyForBooking, isLoadingSurvey, isSurveyAvailable } = + useAdminSurveyResponseStore(); + + const { openSnackbar } = useSnackbarStore(); + + const [surveyData, setSurveyData] = useState({ + email: "", + name: "", + company: "", + activity: "", + date: "", + recommendationScore: 0, + potentialNextActivityDate: "", + repeatActivityScore: 0, + repeatActivityDifferentVendorScore: 0, + differentActivityScore: 0, + activityLiked: "", + activityImprovements: "", + testimonial: "", + displayName: "", + potentialReferrals: "", + }); + + const [reviewData, setReviewData] = useState(null); + const [hasReview, setHasReview] = useState(false); + + useEffect(() => { + const getSurvey = async () => { + try { + const data = await getSurveyForBooking(bookingId); + + setSurveyData(data.survey); + setReviewData(data.review); + if (data.review != null) { + setHasReview(true); + } + } catch (err) { + console.log(err); + } + }; + + getSurvey(); + }, []); + + const handleFieldChange = (field, value) => { + setSurveyData({ + ...surveyData, + [field]: value, + }); + }; + + if (isLoadingSurvey) + return ( + + + + ); + + if (!isSurveyAvailable) + return ( + + + + Survey not available + + + + There is no survey available for this booking. + + + + ); + + return ( + + + + Post-Activity Survey Response + + + +
+ + {hasReview && ( + <> + + + Activity Review + + + + + + What would you rate this activity experience overall? + + + + + + + + What were your thoughts about the activity? + + + + + + )} + + {!hasReview && ( + <> + + + Activity Review + + + You didn't leave a review for this activity and booking. + + + + )} + + + + Feedback for Urban Origins + + + + + + + How likely is it that you would recommend this experience to a + friend or colleague? + + + handleFieldChange("recommendationScore", newValue) + } + /> + + + + + + When do you intend to organize your next activity? + + + handleFieldChange( + "potentialNextActivityDate", + e.target.value, + ) + } + /> + + + + + + How likely is it that you would choose to repeat the same + activity with the same vendor? + + + i.e. you would choose to repeat everything as it happened for + future events or batches of participants + + + handleFieldChange("repeatActivityScore", newValue) + } + /> + + + + + + How likely is it that you would choose to repeat the same + activity with a different vendor? + + + i.e. you would choose a similar activity from our catalogue, + but run by a different facilitator with a difference in the + programme + + + + handleFieldChange( + "repeatActivityDifferentVendorScore", + newValue, + ) + } + /> + + + + + + How likely is it that you would choose a different activity + from our catalogue for your next event? + + + handleFieldChange("differentActivityScore", newValue) + } + /> + + + + + + What did you like about the activity? + + + handleFieldChange("activityLiked", e.target.value) + } + /> + + + + + + What do you think could be improved about the activity? + + + handleFieldChange("activityImprovements", e.target.value) + } + /> + + + + + + If you were satisfied with your experience, we would + appreciate a short testimonial that we can share on our + platforms. + + + handleFieldChange("testimonial", e.target.value) + } + /> + + + + + + Please indicate your preferred display name and role that we + can use with the testimonial. + + + handleFieldChange("displayName", e.target.value) + } + /> + + + + + + Is there someone you know, whether in your own company or + elsewhere, who might benefit from our activities? + + + handleFieldChange("potentialReferrals", e.target.value) + } + /> + + + +
+
+
+ ); +} + +export default FillSurvey; diff --git a/client-frontend/src/zustand/AdminSurveyResponseStore.js b/client-frontend/src/zustand/AdminSurveyResponseStore.js new file mode 100644 index 0000000..5597e69 --- /dev/null +++ b/client-frontend/src/zustand/AdminSurveyResponseStore.js @@ -0,0 +1,123 @@ +import { create } from "zustand"; +import axios from "axios"; +import AxiosConnect from "../utils/AxiosConnect"; + +const useAdminSurveyResponseStore = create((set) => ({ + survey: null, + surveys: null, + isLoadingSurvey: true, + isSurveyAvailable: false, + + // Get a survey by ID + getSurvey: async (surveyId) => { + try { + const response = await AxiosConnect.get(`/gleek/survey/${surveyId}`); + set({ survey: response.data, isLoadingSurvey: false }); + } catch (error) { + console.error(error); + throw error; + } + }, + + // Get the survey for a booking + // {survey, review} + getSurveyForBooking: async (bookingId) => { + try { + const response = await AxiosConnect.get( + `/gleek/survey/booking/${bookingId}`, + ); + console.log(response.data); + if (response.data) { + set({ + survey: response.data.survey, + isLoadingSurvey: false, + isSurveyAvailable: true, + }); + } + + return response.data; + } catch (error) { + console.error(error); + set({ isSurveyAvailable: false, isLoadingSurvey: false }); + throw error; + } + }, + + // Submit survey + submitSurveyForBooking: async ( + bookingId, + survey, + review, + wantsToLeaveReview, + ) => { + try { + const response = await AxiosConnect.post( + `/gleek/survey/booking/${bookingId}/submit`, + { survey, review, wantsToLeaveReview }, + ); + console.log(response.data); + set({ survey: response.data, isLoadingSurvey: false }); + return response.data; + } catch (error) { + console.error(error); + throw error; + } + }, + + // Get the survey for a booking + surveyDraftForBooking: async (bookingId, formData) => { + try { + const response = await AxiosConnect.post( + `/gleek/survey/booking/${bookingId}/draft`, + formData, + ); + console.log(response.data); + set({ survey: response.data, isLoadingSurvey: false }); + return response.data; + } catch (error) { + console.error(error); + throw error; + } + }, + + // Get all pending surveys + getAllPendingSurveys: async () => { + try { + const response = await AxiosConnect.get("/gleek/survey/pending"); + set({ surveys: response.data, isLoadingSurvey: false }); + } catch (error) { + console.error(error); + throw error; + } + }, + + // // Update a survey by ID + // updateSurvey: async (surveyId, updateData) => { + // try { + // const response = await AxiosConnect.post( + // `/gleek/survey/${surveyId}`, + // updateData, + // ); + // set({ survey: response.data, isLoadingSurvey: false }); + // } catch (error) { + // console.error(error); + // throw error; + // } + // }, + + // // Submit a new survey + // submitSurvey: async (surveyId, surveyData) => { + // try { + // const response = await AxiosConnect.post( + // `/gleek/survey/${surveyId}/submit`, + // surveyData, + // ); + // set({ survey: response.data, isLoadingSurvey: false }); + // } catch (error) { + // console.error(error); + // throw error; + // } + // }, +})); + +export default useAdminSurveyResponseStore; diff --git a/client-frontend/src/zustand/BookingStore.js b/client-frontend/src/zustand/BookingStore.js index 0368004..0f922a9 100644 --- a/client-frontend/src/zustand/BookingStore.js +++ b/client-frontend/src/zustand/BookingStore.js @@ -23,6 +23,18 @@ const useBookingStore = create((set) => ({ console.error(error.message); } }, + getBookingsWithPendingSurvey: async () => { + try { + set({ isLoading: true }); + const response = await AxiosConnect.get("/gleek/booking/pendingSurvey"); + + set({ bookings: response.data.bookings }); + set({ isLoading: false }); + } catch (error) { + console.error(error.message); + throw error; + } + }, approveBooking: async (bookingId) => { try { set({ isLoading: true }); diff --git a/client-frontend/src/zustand/CartStore.js b/client-frontend/src/zustand/CartStore.js index 89866b4..1c1716b 100644 --- a/client-frontend/src/zustand/CartStore.js +++ b/client-frontend/src/zustand/CartStore.js @@ -16,7 +16,7 @@ const useCartStore = create((set) => ({ ...item.cartItem, isItemStillAvailable: item.isItemStillAvailable, })); - console.log(combinedDataArray); + //console.log(combinedDataArray); set({ cartItems: combinedDataArray }); } catch (error) { console.error(error); diff --git a/server/controller/bookingController.js b/server/controller/bookingController.js index 76a9916..f5209c6 100644 --- a/server/controller/bookingController.js +++ b/server/controller/bookingController.js @@ -40,6 +40,37 @@ export const getAllBookings = async (req, res) => { } }; +// GET /booking/pendingSurvey +export const getBookingsWithPendingSurvey = async (req, res) => { + try { + const client = req.user; + + const bookings = await BookingModel.find({ + clientId: client._id, + $and: [ + { status: { $in: ["PENDING_PAYMENT", "PAID"] } }, + { isSurveySubmitted: false }, + ], + }).populate({ + path: "activityId", + populate: { + path: "linkedVendor", + select: "companyName companyEmail", + }, + }); + + console.log("getBookingsWithPendingSurvey", bookings.length); + + res.status(200).json({ bookings }); + } catch (error) { + console.error(error); + res.status(500).json({ + message: "Server Error! Unable to get bookings.", + error: error.message, + }); + } +}; + // GET /booking/getBookingById/:id export const getBookingById = async (req, res) => { try { @@ -165,9 +196,9 @@ export function generateAllTimeslots( }; }); - console.log("Existing bookings on selected day", bookings); - console.log("Blocked timeslots on selected day: ", blockedTimeslots); - console.log("All timeslots: ", allTimeslots); + // console.log("Existing bookings on selected day", bookings); + // console.log("Blocked timeslots on selected day: ", blockedTimeslots); + // console.log("All timeslots: ", allTimeslots); return allTimeslots; } diff --git a/server/controller/cartItemController.js b/server/controller/cartItemController.js index b4c32d2..ada04a9 100644 --- a/server/controller/cartItemController.js +++ b/server/controller/cartItemController.js @@ -14,7 +14,7 @@ export const addCartItem = async (req, res) => { const errors = validationResult(req); const client = req.user; - console.log("Client", client); + //console.log("Client", client); if (!errors.isEmpty()) { // 422 status due to validation errors @@ -127,7 +127,7 @@ export const getCartItemsByClientId = async (req, res) => { const errors = validationResult(req); const client = req.user; - console.log("Client", client); + //console.log("Client", client); if (!errors.isEmpty()) { // 422 status due to validation errors @@ -168,7 +168,7 @@ export const deleteCartItem = async (req, res) => { const errors = validationResult(req); const client = req.user; - console.log("Client", client); + //console.log("Client", client); if (!errors.isEmpty()) { // 422 status due to validation errors diff --git a/server/controller/reviewController.js b/server/controller/reviewController.js new file mode 100644 index 0000000..e3e954f --- /dev/null +++ b/server/controller/reviewController.js @@ -0,0 +1,75 @@ +import ActivityModel from "../model/activityModel.js"; +import ReviewModel from "../model/reviewModel.js"; + +export const getAllReviews = async (req, res) => { + try { + const reviews = await ReviewModel.find() + .populate({ + path: "client", + select: "-password", + }) + .populate({ + path: "activity", + }) + .populate({ + path: "booking", + }); + + res.status(200).json(reviews); + } catch (error) { + console.error(error); + res.status(500).json({ + message: "Server Error! Unable to get reviews.", + error: error.message, + }); + } +}; + +export const getAllReviewsForActivity = async (req, res) => { + try { + const activityId = req.params.activityId; + + const activity = await ActivityModel.findById(activityId); + const reviews = await ReviewModel.find({ activity: activityId }) + .populate({ + path: "client", + select: "-password", + }) + .populate({ + path: "activity", + }) + .populate({ + path: "booking", + }); + + res.status(200).json({ activity: activity, reviews: reviews }); + } catch (error) { + console.error(error); + res.status(500).json({ + message: "Server Error! Unable to get reviews.", + error: error.message, + }); + } +}; + +export const toggleReviewVisibility = async (req, res) => { + try { + const reviewId = req.params.reviewId; + console.log("toggleReviewVisibility", reviewId); + const review = await ReviewModel.findById(reviewId); + + if (!review) { + return res.status(404).json({ message: "Review not found." }); + } + review.hidden = !review.hidden; + await review.save(); + + res.status(200).json(review); + } catch (error) { + console.error(error); + res.status(500).json({ + message: "Server Error! Unable to toggle review visibility.", + error: error.message, + }); + } +}; diff --git a/server/controller/surveyController.js b/server/controller/surveyController.js new file mode 100644 index 0000000..b8ff8d2 --- /dev/null +++ b/server/controller/surveyController.js @@ -0,0 +1,326 @@ +import AdminSurveyResponseModel from "../model/AdminSurveyResponseModel.js"; +import SurveyResponse from "../model/AdminSurveyResponseModel.js"; +import Booking from "../model/bookingModel.js"; +import Review from "../model/reviewModel.js"; + +/* + * Get the survey for a booking, or return nothing if no survey exists. + */ +export const getSurveyForBooking = async (req, res) => { + try { + const client = req.user; + + const bookingId = req.params.bookingId; + const booking = await Booking.findById(bookingId); + + if (!booking) { + return res.status(404).json({ message: "Booking not found" }); + } + + // console.log("client", client._id); + // console.log("booking", booking.clientId); + + if (!client._id.equals(booking.clientId)) { + return res.status(403).json({ message: "Unauthorised." }); + } + + const survey = await SurveyResponse.findOne({ booking: bookingId }); + const review = await Review.findOne({ booking: bookingId }); + + // console.log("review", review) + + if (!survey) { + // return empty survey object + return res.status(200).send({}); + } + + return res + .status(200) + .json({ survey: survey, review: review, booking: booking }); + } catch (err) { + console.error(err); + return res.status(500).send({ message: "Server error" }); + } +}; + +export const getAllSubmittedSurveys = async (req, res) => { + try { + const surveys = await SurveyResponse.find({ + status: { $in: ["SUBMITTED"] }, + }) + .populate({ + path: "activity", + populate: { + path: "linkedVendor", + select: "companyName companyEmail", + }, + }) + .populate({ + path: "booking", + populate: { + path: "clientId", + select: "email name", + }, + }); + + return res.status(200).json(surveys); + } catch (err) { + console.error(err); + return res.status(500).send({ message: "Server error" }); + } +}; + +export const submitSurveyForBooking = async (req, res) => { + try { + const client = req.user; + + const bookingId = req.params.bookingId; + const booking = await Booking.findById(bookingId); + + const wantsToLeaveReview = req.body.wantsToLeaveReview; + + const { + feedbackRating, + recommendationScore, + potentialNextActivityDate, + repeatActivityScore, + repeatActivityDifferentVendorScore, + differentActivityScore, + generalFeedback, + activityLiked, + activityImprovements, + testimonial, + displayName, + potentialReferrals, + } = req.body.survey; + + const updateFields = { + booking: bookingId, + activity: booking.activityId, + feedbackRating, + recommendationScore, + potentialNextActivityDate, + repeatActivityScore, + repeatActivityDifferentVendorScore, + differentActivityScore, + generalFeedback, + activityLiked, + activityImprovements, + testimonial, + displayName, + potentialReferrals, + status: "SUBMITTED", + }; + + let survey = await SurveyResponse.findOneAndUpdate( + // in case we want draft surveys + { booking: bookingId }, + updateFields, + { new: true, upsert: true }, + ); + + let review; + + if (wantsToLeaveReview) { + const { rating, comment } = req.body.review; + const reviewUpdateFields = { + rating, + comment, + booking: bookingId, + activity: booking.activityId, + client: client._id, + }; + review = await Review.findOneAndUpdate( + { booking: bookingId }, + reviewUpdateFields, + { new: true, upsert: true }, + ); + } + + await Booking.findByIdAndUpdate( + bookingId, + { isSurveySubmitted: true }, + { new: true }, + ); + + return res.status(200).json({ survey, review }); + } catch (err) { + console.error(err); + return res.status(500).json({ message: "Server error" }); + } +}; + +// export const getSurveysForClient = async (req, res) => { +// try { +// const client = req.user; +// const bookings = await Booking.find({ +// clientId: client._id, +// status: { $in: ["PENDING_PAYMENT", "PAID"] }, +// }); + +// const surveyResponses = []; + +// for (const booking of bookings) { +// let survey = await SurveyResponse.findOne({ booking: booking._id }) +// .populate("activity") +// .populate("booking"); + +// if (!survey) { +// const newSurvey = new SurveyResponse({ +// booking: booking._id, +// activity: booking.activityId, +// }); + +// await newSurvey.save(); + +// booking.adminSurveyResponse = newSurvey._id; +// await booking.save(); + +// survey = await SurveyResponse.findById(newSurvey._id) +// .populate("activity") +// .populate("booking"); + +// surveyResponses.push(survey); +// } else { +// surveyResponses.push(survey); +// } +// } + +// return res.status(200).json(surveyResponses); +// } catch (err) { +// console.error(err); +// return res.status(500).json({ message: "Server error" }); +// } +//} + +// Using surveyId + +export const updateSurvey = async (req, res) => { + try { + const surveyId = req.params.surveyId; + + const { + feedbackRating, + recommendationScore, + potentialNextActivityDate, + repeatActivityScore, + repeatActivityDifferentVendorScore, + differentActivityScore, + generalFeedback, + activityLiked, + activityImprovements, + testimonial, + displayName, + potentialReferrals, + } = req.body; + + const updateFields = { + feedbackRating, + recommendationScore, + potentialNextActivityDate, + repeatActivityScore, + repeatActivityDifferentVendorScore, + differentActivityScore, + generalFeedback, + activityLiked, + activityImprovements, + testimonial, + displayName, + potentialReferrals, + }; + const survey = await SurveyResponse.findByIdAndUpdate( + surveyId, + updateFields, + { new: true }, + ); + + return res.status(200).json(survey); + } catch (err) { + console.error(err); + return res.status(500).json({ message: "Server error" }); + } +}; + +export const submitSurvey = async (req, res) => { + try { + const surveyId = req.params.surveyId; + + const { + feedbackRating, + recommendationScore, + potentialNextActivityDate, + repeatActivityScore, + repeatActivityDifferentVendorScore, + differentActivityScore, + generalFeedback, + activityLiked, + activityImprovements, + testimonial, + displayName, + potentialReferrals, + } = req.body.survey; + + const updateFields = { + feedbackRating, + recommendationScore, + potentialNextActivityDate, + repeatActivityScore, + repeatActivityDifferentVendorScore, + differentActivityScore, + generalFeedback, + activityLiked, + activityImprovements, + testimonial, + displayName, + potentialReferrals, + status: "SUBMITTED", + }; + + const survey = await SurveyResponse.findByIdAndUpdate( + surveyId, + updateFields, + { new: true }, + ); + + await Booking.findByIdAndUpdate( + survey.booking, + { isSurveySubmitted: true }, + { new: true }, + ); + + return res.status(200).json(survey); + } catch (err) { + console.error(err); + return res.status(500).json({ message: "Server error" }); + } +}; + +export const getSurveyWithSurveyId = async (req, res) => { + try { + const surveyId = req.params.surveyId; + const survey = await SurveyResponse.findById(surveyId) + .populate({ + path: "activity", + populate: { + path: "linkedVendor", + select: "companyName companyEmail", + }, + }) + .populate({ + path: "booking", + populate: { + path: "clientId", + select: "email name", + }, + }); + + if (!survey) { + return res.status(404).json({ message: "Survey not found" }); + } + + return res.status(200).json(survey); + } catch (err) { + console.error(err); + return res.status(500).json({ message: "Server error" }); + } +}; diff --git a/server/model/adminSurveyResponseModel.js b/server/model/adminSurveyResponseModel.js new file mode 100644 index 0000000..62c74a4 --- /dev/null +++ b/server/model/adminSurveyResponseModel.js @@ -0,0 +1,39 @@ +import mongoose from "mongoose"; + +const adminSurveyResponseSchema = new mongoose.Schema({ + created: { type: Date, required: true, default: Date.now }, + updated: { type: Date, required: true, default: Date.now }, + booking: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: "Booking", + }, + activity: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: "Activity", + }, + feedbackRating: { type: Number, required: false }, + recommendationScore: { type: Number, required: false }, + potentialNextActivityDate: { type: String, required: false }, + repeatActivityScore: { type: Number, required: false }, + repeatActivityDifferentVendorScore: { type: Number, required: false }, + differentActivityScore: { type: Number, required: false }, + generalFeedback: { type: String, required: false }, + activityLiked: { type: String, required: false }, + activityImprovements: { type: String, required: false }, + testimonial: { type: String, required: false }, + displayName: { type: String, required: false }, + potentialReferrals: { type: String, required: false }, + status: { + type: String, + enum: ["UNAVAILABLE", "AVAILABLE", "SUBMITTED"], + }, +}); + +const AdminSurveyResponseModel = mongoose.model( + "AdminSurveyResponse", + adminSurveyResponseSchema, +); + +export default AdminSurveyResponseModel; diff --git a/server/model/bookingModel.js b/server/model/bookingModel.js index fe29307..26c88db 100644 --- a/server/model/bookingModel.js +++ b/server/model/bookingModel.js @@ -33,7 +33,7 @@ const bookingSchema = new mongoose.Schema({ }, totalVendorAmount: { type: Number, - required: true, + required: false, }, totalPax: { type: Number, @@ -111,6 +111,14 @@ const bookingSchema = new mongoose.Schema({ ], default: "PENDING_CONFIRMATION", }, + isSurveySubmitted: { + type: Boolean, + default: false, + }, + adminSurveyResponse: { + type: mongoose.Schema.Types.ObjectId, + ref: "AdminSurveyResponse", + }, rejectionReason: { type: String }, creationDateTime: { type: Date, diff --git a/server/model/reviewModel.js b/server/model/reviewModel.js new file mode 100644 index 0000000..82dbd65 --- /dev/null +++ b/server/model/reviewModel.js @@ -0,0 +1,40 @@ +import mongoose from "mongoose"; + +const reviewSchema = new mongoose.Schema({ + activity: { + type: mongoose.Schema.Types.ObjectId, + ref: "Activity", + required: true, + }, + booking: { + type: mongoose.Schema.Types.ObjectId, + ref: "Booking", + required: true, + }, + client: { + type: mongoose.Schema.Types.ObjectId, + ref: "Client", + required: true, + }, + rating: { + type: Number, + required: true, + }, + comment: { + type: String, + required: false, + }, + date: { + type: Date, + default: Date.now, + }, + hidden: { + type: Boolean, + default: false, + }, +}); + +const Review = mongoose.model("Review", reviewSchema); + +export default Review; + diff --git a/server/model/testimonialModel.js b/server/model/testimonialModel.js new file mode 100644 index 0000000..0453e22 --- /dev/null +++ b/server/model/testimonialModel.js @@ -0,0 +1,13 @@ +import mongoose from "mongoose"; + +const testimonialSchema = new mongoose.Schema({ + id: { type: mongoose.Schema.Types.ObjectId, required: true }, + testimonialBody: { type: String, required: true }, + displayName: { type: String, required: true }, + isShown: { type: Boolean, required: true, default: false }, + created: { type: Date, required: true }, +}); + +const Testimonial = mongoose.model("Testimonial", testimonialSchema); + +module.exports = Testimonial; diff --git a/server/routes/gleek/booking.js b/server/routes/gleek/booking.js index 25b9076..2fd525d 100644 --- a/server/routes/gleek/booking.js +++ b/server/routes/gleek/booking.js @@ -5,11 +5,20 @@ import { getAllBookingsForClient, getBookingById, updateBookingStatus, + getBookingsWithPendingSurvey } from "../../controller/bookingController.js"; import { verifyToken } from "../../middleware/clientAuth.js"; const router = express.Router(); +// Get bookings with pending survey +router.get( + "/pendingSurvey", + verifyToken, + getBookingsWithPendingSurvey, +); + + // Booking router.get( "/getAvailableBookingTimeslots/:activityId/:selectedDate", diff --git a/server/routes/gleek/gleek.js b/server/routes/gleek/gleek.js index 8f2e67c..f0616d1 100644 --- a/server/routes/gleek/gleek.js +++ b/server/routes/gleek/gleek.js @@ -7,6 +7,7 @@ import bookingRoutes from "./booking.js"; import timeslotRoutes from "./timeslot.js"; import bookmarkRoutes from "./bookmark.js"; import activityRoutes from "./activity.js"; +import surveyRoutes from "./survey.js"; import cartRoutes from "./cart.js"; import { userRouter } from "../../controller/gleekUserRouterController.js"; const router = express.Router(); @@ -35,4 +36,6 @@ router.use("/bookmark", bookmarkRoutes); router.use("/activity", activityRoutes); // /gleek/cart router.use("/cart", cartRoutes); +// /gleek/survey +router.use("/survey", surveyRoutes); export default router; diff --git a/server/routes/gleek/survey.js b/server/routes/gleek/survey.js new file mode 100644 index 0000000..d325e3d --- /dev/null +++ b/server/routes/gleek/survey.js @@ -0,0 +1,42 @@ +import express from "express"; +import { + getSurveyForBooking, + getSurveyWithSurveyId, + submitSurvey, + submitSurveyForBooking, + updateSurvey, +} from "../../controller/surveyController.js"; +import { verifyToken } from "../../middleware/clientAuth.js"; + +const router = express.Router(); + +/* + * GET /gleek/survey/booking/bookingId + * Get survey by booking id + */ +router.get("/booking/:bookingId", verifyToken, getSurveyForBooking); +// router.get("/pending", verifyToken, getSurveysForClient); +/* + * POST /gleek/survey/booking/bookingId/submit + * Submit survey for booking with bookingId + */ +router.post("/booking/:bookingId/submit", verifyToken, submitSurveyForBooking); + +/* + * GET /gleek/survey/surveyId + * Get survey with known survey id + */ +router.get("/:surveyId", getSurveyWithSurveyId); + +/* + * POST /gleek/survey/surveyId + * Update survey with survey id + */ +router.post("/:surveyId", updateSurvey); +/* + * POST /gleek/survey/surveyId + * Submit survey with survey id + */ +router.post("/:surveyId/submit", submitSurvey); + +export default router; diff --git a/server/routes/gleekAdmin/reviewRoute.js b/server/routes/gleekAdmin/reviewRoute.js new file mode 100644 index 0000000..9286f58 --- /dev/null +++ b/server/routes/gleekAdmin/reviewRoute.js @@ -0,0 +1,15 @@ + +import express from "express"; +import { + getAllReviews, + getAllReviewsForActivity, + toggleReviewVisibility, +} from "../../controller/reviewController.js"; + +const router = express.Router(); +router.get("/", getAllReviews); +router.get("/activity/:activityId", getAllReviewsForActivity); +router.get("/:reviewId/toggleVisibility", toggleReviewVisibility); + + +export default router; diff --git a/server/routes/gleekAdmin/surveyRoute.js b/server/routes/gleekAdmin/surveyRoute.js new file mode 100644 index 0000000..f1417da --- /dev/null +++ b/server/routes/gleekAdmin/surveyRoute.js @@ -0,0 +1,24 @@ +import express from "express"; +import { + getAllSubmittedSurveys, + getSurveyWithSurveyId, + submitSurvey, + updateSurvey +} from "../../controller/surveyController.js"; + +const router = express.Router(); + +/* + * GET /gleek/survey/surveyId + * Get survey with known survey id + */ +router.get("/submitted", getAllSubmittedSurveys); + +/* + * GET /gleek/survey/surveyId + * Get survey with known survey id + */ +router.get("/:surveyId", getSurveyWithSurveyId); + + +export default router; diff --git a/server/server.js b/server/server.js index d2253d0..f5278d5 100644 --- a/server/server.js +++ b/server/server.js @@ -8,6 +8,8 @@ import activityRoutes from "./routes/gleekAdmin/activityRoute.js"; import gleekAdminRoutes from "./routes/gleekAdmin/gleekAdmin.js"; import gleekVendorRoutes from "./routes/gleekVendor/gleekVendor.js"; import vendorRoutes from "./routes/gleekAdmin/vendorRoute.js"; +import surveyRoutes from "./routes/gleekAdmin/surveyRoute.js"; +import reviewRoutes from "./routes/gleekAdmin/reviewRoute.js"; import bookingRoutes from "./routes/gleekAdmin/bookingRoute.js"; import client from "./routes/gleekAdmin/client.js"; import activityTestController from "./controller/activityTestController.js"; @@ -48,6 +50,8 @@ app.use("/vendor", vendorRoutes); app.use("/activity", activityRoutes); app.use("/client", client); app.use("/booking", bookingRoutes); +app.use("/survey", surveyRoutes); +app.use("/review", reviewRoutes); /** * For Client application diff --git a/server/service/surveyService.js b/server/service/surveyService.js new file mode 100644 index 0000000..9b47b9e --- /dev/null +++ b/server/service/surveyService.js @@ -0,0 +1,3 @@ +import AdminSurveyResponseModel from "../model/AdminSurveyResponseModel"; +import BookingModel from "../model/bookingModel"; +