- }
- sx={{ textTransform: 'none', fontSize: '1em' }}
+
- Write a comment
-
+ }
+ sx={{ textTransform: 'none', fontSize: '1em' }}
+ >
+ Write a Review
+
+
diff --git a/client/src/pages/LocationPage/CommentSection/Comment.scss b/client/src/pages/LocationPage/ReviewSection/Review.scss
similarity index 73%
rename from client/src/pages/LocationPage/CommentSection/Comment.scss
rename to client/src/pages/LocationPage/ReviewSection/Review.scss
index be46184..4e5ea1f 100644
--- a/client/src/pages/LocationPage/CommentSection/Comment.scss
+++ b/client/src/pages/LocationPage/ReviewSection/Review.scss
@@ -1,15 +1,15 @@
-.comment-container {
+.review-container {
margin-bottom: 3em;
}
-.comment-header-container {
+.review-header-container {
display: table;
border-spacing: 1.5em 0;
vertical-align: middle;
margin: 0 -1.5em 1.5em;
}
-.comment-header-leftside {
+.review-header-leftside {
display: table-cell;
box-sizing: border-box;
vertical-align: top;
@@ -17,26 +17,26 @@
border-collapse: collapse;
}
-.comment-header-rightside {
+.review-header-rightside {
display: table-cell;
box-sizing: border-box;
vertical-align: top;
}
-.comment-header-leftside-body {
+.review-header-leftside-body {
display: table;
border-collapse: separate;
border-spacing: 1.5em 0;
margin: 0 -1.5em;
}
-.comment-profile-image-container {
+.review-profile-image-container {
display: table-cell;
border-radius: 50%;
overflow: hidden;
}
-.comment-profile-image {
+.review-profile-image {
height: 4em;
width: 4em;
aspect-ratio: auto 1;
@@ -44,23 +44,23 @@
box-sizing: border-box;
}
-.comment-user-info-container {
+.review-user-info-container {
display: table-cell;
vertical-align: middle;
width: 100%;
}
-.comment-user-info {
+.review-user-info {
display: block;
font-size: larger;
font-weight: 700;
}
-.comment-text-container {
+.review-text-container {
width: 90%;
}
-.comment-text {
+.review-text {
margin: 0;
font-size: smaller;
line-height: 1.7em;
diff --git a/client/src/pages/LocationPage/ReviewSection/Review.tsx b/client/src/pages/LocationPage/ReviewSection/Review.tsx
new file mode 100644
index 0000000..1aef162
--- /dev/null
+++ b/client/src/pages/LocationPage/ReviewSection/Review.tsx
@@ -0,0 +1,51 @@
+import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
+import { IconButton } from '@mui/material';
+import type { UserType } from '../../../types/UserType';
+import './Review.scss';
+
+export type ReviewType = {
+ _id: string;
+ author_id: string;
+ body: string;
+ liked_by: Array
;
+ created_at: string;
+ author?: UserType;
+};
+
+const Review = ({ comm }: { comm: ReviewType }) => {
+ console.log(comm);
+ console.log(comm.author);
+ return (
+
+
+
+
+
+
+
+
+
+ {comm.author ? comm.author.first_name : 'Unknown'}
+
+
{comm.created_at}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Review;
diff --git a/client/src/pages/LocationPage/ReviewSection/index.scss b/client/src/pages/LocationPage/ReviewSection/index.scss
new file mode 100644
index 0000000..03b1d95
--- /dev/null
+++ b/client/src/pages/LocationPage/ReviewSection/index.scss
@@ -0,0 +1,5 @@
+.review-section-container {
+ margin-top: 40px;
+ padding: 0;
+ margin: 0;
+}
diff --git a/client/src/pages/LocationPage/ReviewSection/index.tsx b/client/src/pages/LocationPage/ReviewSection/index.tsx
new file mode 100644
index 0000000..85b716a
--- /dev/null
+++ b/client/src/pages/LocationPage/ReviewSection/index.tsx
@@ -0,0 +1,41 @@
+import { useState, useEffect } from 'react';
+import axios from 'axios';
+import { ReviewType } from './Review';
+import Review from './Review';
+import type { UserType } from '../../../types/UserType';
+import './index.scss';
+
+const ReviewSection = ({ id }: { id: string }) => {
+ const [backendComments, setBackendComments] = useState>([]);
+
+ useEffect(() => {
+ axios
+ .get>(`/api/v1/reviews/${id}`, {
+ params: { filter_by: 'location' },
+ })
+ .then(async (res) => {
+ setBackendComments(
+ await Promise.all(
+ res.data.map((comment) => {
+ return axios
+ .get(`/api/v1/users/${comment.author_id}`)
+ .then((res) => {
+ comment.author = res.data;
+ return comment;
+ });
+ })
+ )
+ );
+ });
+ }, [id]);
+
+ return (
+
+ {backendComments.map((rootComment: ReviewType) => (
+
+ ))}
+
+ );
+};
+
+export default ReviewSection;
diff --git a/client/src/pages/LocationPage/index.tsx b/client/src/pages/LocationPage/index.tsx
index fe502fe..135b851 100644
--- a/client/src/pages/LocationPage/index.tsx
+++ b/client/src/pages/LocationPage/index.tsx
@@ -36,7 +36,9 @@ function LocationPage() {
/>
- {locationInfo && }
+ {locationInfo && (
+
+ )}
diff --git a/client/src/pages/WriteReviewPage/__snapshots__/index.test.tsx.snap b/client/src/pages/WriteReviewPage/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000..6a29a48
--- /dev/null
+++ b/client/src/pages/WriteReviewPage/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,29 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`WriteReviewPage should render successfully 1`] = `
+
+
+
+`;
diff --git a/client/src/pages/WriteReviewPage/index.scss b/client/src/pages/WriteReviewPage/index.scss
new file mode 100644
index 0000000..6749ee0
--- /dev/null
+++ b/client/src/pages/WriteReviewPage/index.scss
@@ -0,0 +1,65 @@
+#write-review-page-container {
+ display: table;
+ margin-top: 4em;
+ margin-bottom: 10em;
+}
+
+#write-review-page-inner {
+ float: left;
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 550px;
+ text-align: left;
+}
+
+%write-review-textinput {
+ display: block;
+ margin: 0 0 1em;
+ padding: 0.4em 0.8em;
+ border: 1px solid #999;
+ border-radius: 0.3em;
+ width: 100%;
+ font-size: 1em;
+ box-sizing: border-box;
+}
+
+%write-review-textinput:focus {
+ outline: none;
+}
+
+#write-review-page-inner p {
+ margin-bottom: 2em;
+}
+
+#write-review-page-inner textarea {
+ @extend %write-review-textinput;
+ resize: vertical;
+ height: 20em;
+ padding: 1.5em;
+}
+
+#write-review-submit-button {
+ color: #fff;
+ background-color: #1a8ceb;
+ border: 0;
+ padding: 0.4em 1.4em;
+ border-radius: 0.3em;
+ font-weight: bold;
+ font-size: 1em;
+ text-transform: none;
+}
+
+#write-review-submit-button:hover {
+ background-color: #2592ff;
+}
+
+#write-review-cancel-link {
+ color: #1577da;
+ text-decoration: none;
+ font-size: 1em;
+ margin-left: 1em;
+}
+
+#write-review-cancel-link:hover {
+ text-decoration: underline;
+}
diff --git a/client/src/pages/WriteReviewPage/index.test.tsx b/client/src/pages/WriteReviewPage/index.test.tsx
new file mode 100644
index 0000000..65903c9
--- /dev/null
+++ b/client/src/pages/WriteReviewPage/index.test.tsx
@@ -0,0 +1,16 @@
+import ShallowRenderer from 'react-test-renderer/shallow';
+import { Provider } from 'react-redux';
+import store from '../../reducers';
+import WriteReviewPage from './index';
+
+describe('WriteReviewPage', () => {
+ it('should render successfully', () => {
+ const renderer = ShallowRenderer.createRenderer();
+ const tree = renderer.render(
+
+
+
+ );
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/client/src/pages/WriteReviewPage/index.tsx b/client/src/pages/WriteReviewPage/index.tsx
new file mode 100644
index 0000000..54ef830
--- /dev/null
+++ b/client/src/pages/WriteReviewPage/index.tsx
@@ -0,0 +1,76 @@
+/* eslint-disable eqeqeq */
+import { useSelector } from 'react-redux';
+import { RootState } from '../../reducers';
+import { useParams, Link } from 'react-router-dom';
+import { useState, useEffect } from 'react';
+import { Container, Button } from '@mui/material';
+import { useForm } from 'react-hook-form';
+import axios from 'axios';
+import { getLocationInfo } from '../../api/LocationAPI';
+import type { LocationType } from '../../types/LocationType';
+import './index.scss';
+
+function WriteReviewPage() {
+ const { id } = useParams<{ id: string }>();
+ const [locationInfo, setLocationInfo] = useState
(null);
+ const mapInstance = useSelector((state: RootState) => state.mapInstance.map);
+ const { register, setValue, handleSubmit } = useForm<{
+ body: string;
+ }>();
+
+ // Set review
+ useEffect(() => {
+ setValue('body', '');
+ }, [id, setValue]);
+
+ // Set point
+ useEffect(() => {
+ if (mapInstance) {
+ getLocationInfo(id, mapInstance).then((res) => {
+ if (res) {
+ setLocationInfo(res);
+ }
+ });
+ }
+ }, [id, mapInstance]);
+
+ return (
+
+
+
{locationInfo && locationInfo.name}
+
All reviews are subject to moderation.
+
+
+
+ );
+}
+
+export default WriteReviewPage;
diff --git a/client/src/types/UserType.tsx b/client/src/types/UserType.tsx
new file mode 100644
index 0000000..70228c6
--- /dev/null
+++ b/client/src/types/UserType.tsx
@@ -0,0 +1,7 @@
+export type UserType = {
+ _id: string;
+ first_name: string;
+ last_name: string;
+ profile_picture: string;
+ account_type: 'User' | 'Admin';
+};
diff --git a/src/models/locationsModel.ts b/src/models/locationsModel.ts
index 3a98298..279cf3b 100644
--- a/src/models/locationsModel.ts
+++ b/src/models/locationsModel.ts
@@ -6,7 +6,7 @@ export interface ILocation extends Document {
type?: string;
}
-export const locationsSchemaValues = {
+export const locationsSchema = new Schema({
id: {
type: String,
required: true,
@@ -57,9 +57,7 @@ export const locationsSchemaValues = {
required: true,
},
},
-};
-
-const locationsSchema = new Schema(locationsSchemaValues);
+});
const Location = mongoose.model('Location', locationsSchema);
diff --git a/src/models/reviewsModel.ts b/src/models/reviewsModel.ts
new file mode 100644
index 0000000..c7efb6a
--- /dev/null
+++ b/src/models/reviewsModel.ts
@@ -0,0 +1,34 @@
+import mongoose, { Schema, Document } from 'mongoose';
+
+export interface IReview extends Document {
+ _id: string;
+ author_id: string;
+ location_id: string;
+ body: string;
+ likes?: number;
+ created_at?: Date;
+}
+
+const reviewsSchema = new Schema({
+ author_id: {
+ type: String,
+ required: true,
+ },
+ location_id: {
+ type: String,
+ required: true,
+ },
+ body: {
+ type: String,
+ required: true,
+ },
+ liked_by: [String],
+ created_at: {
+ type: Date,
+ default: Date.now,
+ },
+});
+
+const Review = mongoose.model('Review', reviewsSchema);
+
+export default Review;
diff --git a/src/routes/index.ts b/src/routes/index.ts
index a471be1..841fe3a 100644
--- a/src/routes/index.ts
+++ b/src/routes/index.ts
@@ -2,6 +2,7 @@ import express from 'express';
import auth from './auth';
import users from './users';
import locations from './locations';
+import reviews from './reviews';
import '../config/passport';
const router = express.Router();
@@ -9,5 +10,6 @@ const router = express.Router();
router.use('/', auth);
router.use('/', users);
router.use('/', locations);
+router.use('/', reviews);
export default router;
diff --git a/src/routes/reviews.ts b/src/routes/reviews.ts
new file mode 100644
index 0000000..b1fc5f3
--- /dev/null
+++ b/src/routes/reviews.ts
@@ -0,0 +1,72 @@
+import express from 'express';
+import { Error } from 'mongoose';
+import requireLogin from '../middlewares/requireLogin';
+import Location, { ILocation } from '../models/locationsModel';
+import Review, { IReview } from '../models/reviewsModel';
+
+const router = express.Router();
+
+// Get location reviews
+// filter_by: 'author' | 'location'
+router.get('/v1/reviews/:id', (req, res) => {
+ Review.find(
+ {
+ [req.body.filter_by === 'author' ? 'author_id' : 'location_id']:
+ req.params.id,
+ },
+ (err: Error, reviews: Array) => {
+ if (!err && reviews) {
+ res.status(200).send(reviews);
+ } else {
+ res.status(404).send('Reviews not found');
+ }
+ }
+ );
+});
+
+// Create or edit review for location
+router.post('/v1/review/:id', requireLogin, (req, res) => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const user_id = req.user._id;
+
+ Location.findOne({ id: req.params.id }, (err: Error, location: ILocation) => {
+ if (!err && location) {
+ Review.findOne(
+ { author_id: user_id, location_id: req.params.id },
+ (err: Error, review: IReview) => {
+ if (!err && review) {
+ // User already has review for location. Edit review instead.
+ review.body = req.body.body;
+ review.save((err: Error, review: IReview) => {
+ if (err) {
+ console.log(err);
+ res.status(500).send(err);
+ } else {
+ res.status(201).send(review);
+ }
+ });
+ } else {
+ // No review exists for this user and location. Create a new one.
+ new Review({
+ author_id: user_id,
+ location_id: req.params.id,
+ body: req.body.body,
+ }).save((err: Error, review: IReview) => {
+ if (err) {
+ console.log(err);
+ res.status(500).send(err);
+ } else {
+ res.status(201).send(review);
+ }
+ });
+ }
+ }
+ );
+ } else {
+ res.status(404).send('Location not found');
+ }
+ });
+});
+
+export default router;
diff --git a/src/routes/users.ts b/src/routes/users.ts
index ca8e6f0..a3181de 100644
--- a/src/routes/users.ts
+++ b/src/routes/users.ts
@@ -1,4 +1,6 @@
import express from 'express';
+import { Error } from 'mongoose';
+import User, { IUser } from '../models/usersModel';
const router = express.Router();
@@ -10,4 +12,15 @@ router.get('/v1/users/current-user', (req, res) => {
}
});
+// Get user by id
+router.get('/v1/users/:id', (req, res) => {
+ User.findById(req.params.id, (err: Error, user: IUser) => {
+ if (err) {
+ res.status(500).send(err);
+ } else {
+ res.status(200).send(user);
+ }
+ });
+});
+
export default router;