├── .gitignore
├── frontend
├── src
│ ├── components
│ │ ├── ListingPage
│ │ │ ├── ImageViewer
│ │ │ │ ├── ImageViewerImage
│ │ │ │ │ ├── ImageViewerImage.css
│ │ │ │ │ └── index.js
│ │ │ │ ├── ImageViewer.css
│ │ │ │ └── index.js
│ │ │ ├── BookingBox
│ │ │ │ ├── AvailabilityCalendar
│ │ │ │ │ ├── AvailabilityCalendar.css
│ │ │ │ │ └── index.js
│ │ │ │ ├── BookingBox.css
│ │ │ │ └── index.js
│ │ │ ├── ConfirmBooking
│ │ │ │ ├── ConfirmBooking.css
│ │ │ │ └── index.js
│ │ │ ├── ReviewSection
│ │ │ │ ├── ReviewSection.css
│ │ │ │ └── index.js
│ │ │ ├── DeleteListing
│ │ │ │ ├── DeleteListing.css
│ │ │ │ └── index.js
│ │ │ ├── ReviewCreate
│ │ │ │ ├── ReviewCreate.css
│ │ │ │ └── index.js
│ │ │ └── ListingPage.css
│ │ ├── HomePage
│ │ │ ├── office.jpg
│ │ │ ├── index.js
│ │ │ └── HomePage.css
│ │ ├── FormErrors
│ │ │ ├── FormErrors.css
│ │ │ └── index.js
│ │ ├── ListingPost
│ │ │ ├── ListingForm
│ │ │ │ ├── UploadedImage
│ │ │ │ │ ├── UploadedImage.css
│ │ │ │ │ └── index.js
│ │ │ │ ├── ListingForm.css
│ │ │ │ └── index.js
│ │ │ ├── index.js
│ │ │ └── ListingPost.css
│ │ ├── ListingEdit
│ │ │ ├── ListingEdit.css
│ │ │ └── index.js
│ │ ├── Footer
│ │ │ ├── Footer.css
│ │ │ └── index.js
│ │ ├── SearchOverlay
│ │ │ └── index.js
│ │ ├── AuthModal
│ │ │ ├── AuthForms
│ │ │ │ ├── AuthForm.css
│ │ │ │ ├── LoginForm.js
│ │ │ │ └── SignupForm.js
│ │ │ ├── Modal.css
│ │ │ └── index.js
│ │ ├── Navigation
│ │ │ ├── ProfileButton.js
│ │ │ ├── index.js
│ │ │ └── Navigation.css
│ │ ├── SearchBar
│ │ │ ├── SearchBar.css
│ │ │ └── index.js
│ │ └── ListingSearch
│ │ │ ├── ListingSearch.css
│ │ │ └── index.js
│ ├── assets
│ │ └── images
│ │ │ ├── flowers.jpg
│ │ │ ├── white-office.jpg
│ │ │ ├── work-in-logo.png
│ │ │ └── orange-office.jpg
│ ├── utils
│ │ ├── hooks.js
│ │ └── date.js
│ ├── index.css
│ ├── store
│ │ ├── search.js
│ │ ├── csrf.js
│ │ ├── imageViewer.js
│ │ ├── index.js
│ │ ├── userBookings.js
│ │ ├── booking.js
│ │ ├── session.js
│ │ ├── reviews.js
│ │ └── modals.js
│ ├── index.js
│ └── App.js
├── public
│ ├── favicon.ico
│ ├── manifest.json
│ └── index.html
├── .gitignore
└── package.json
├── backend
├── .env.example
├── utils
│ ├── parse.js
│ ├── validation.js
│ └── auth.js
├── .sequelizerc
├── bin
│ └── www
├── db
│ ├── models
│ │ ├── image.js
│ │ ├── review.js
│ │ ├── booking.js
│ │ ├── listing.js
│ │ ├── index.js
│ │ └── user.js
│ ├── migrations
│ │ ├── 20210719194102-create-image.js
│ │ ├── 20210722202834-create-booking.js
│ │ ├── 20210723223212-create-review.js
│ │ ├── 20210714233753-create-user.js
│ │ └── 20210718191204-create-listing.js
│ └── seeders
│ │ ├── 20210714234806-demo-user.js
│ │ └── 20210719172258-listing-data.js
├── config
│ ├── index.js
│ └── database.js
├── routes
│ ├── api
│ │ ├── index.js
│ │ ├── image.js
│ │ ├── search.js
│ │ ├── session.js
│ │ ├── users.js
│ │ ├── booking.js
│ │ ├── review.js
│ │ └── listings.js
│ └── index.js
├── package.json
├── app.js
└── awsS3.js
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env
3 | build
4 | .DS_Store
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/ImageViewer/ImageViewerImage/ImageViewerImage.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/BookingBox/AvailabilityCalendar/AvailabilityCalendar.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KagenLH/Work-In/HEAD/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/src/assets/images/flowers.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KagenLH/Work-In/HEAD/frontend/src/assets/images/flowers.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/images/white-office.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KagenLH/Work-In/HEAD/frontend/src/assets/images/white-office.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/images/work-in-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KagenLH/Work-In/HEAD/frontend/src/assets/images/work-in-logo.png
--------------------------------------------------------------------------------
/frontend/src/components/HomePage/office.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KagenLH/Work-In/HEAD/frontend/src/components/HomePage/office.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/images/orange-office.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KagenLH/Work-In/HEAD/frontend/src/assets/images/orange-office.jpg
--------------------------------------------------------------------------------
/backend/.env.example:
--------------------------------------------------------------------------------
1 | PORT=5000
2 | DB_USERNAME=workin_app
3 | DB_DATABASE=workin_development
4 | DB_PASSWORD=password
5 | DB_HOST=localhost
6 | JWT_SECRET=secret
7 | JWT_EXPIRES_IN=608000
--------------------------------------------------------------------------------
/frontend/src/components/FormErrors/FormErrors.css:
--------------------------------------------------------------------------------
1 | .form-errors {
2 | list-style: none;
3 | margin-left: 15px;
4 | }
5 |
6 | .form-error {
7 | font-size: 14px;
8 | color: red;
9 | }
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React Template",
3 | "name": "Create React App Template",
4 | "start_url": ".",
5 | "display": "standalone",
6 | "theme_color": "#000000",
7 | "background_color": "#ffffff"
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/BookingBox/AvailabilityCalendar/index.js:
--------------------------------------------------------------------------------
1 | import './AvailabilityCalendar.css';
2 |
3 | export default function AvailabilityCalendar() {
4 | return (
5 |
6 |
7 |
8 | )
9 | }
--------------------------------------------------------------------------------
/backend/utils/parse.js:
--------------------------------------------------------------------------------
1 | const parseSearchString = (req, res, next) => {
2 | const { keyword } = req.params;
3 |
4 | req.keyword = decodeURIComponent(keyword);
5 | next();
6 | };
7 |
8 | module.exports = {
9 | parseSearchString,
10 | };
--------------------------------------------------------------------------------
/frontend/src/components/ListingPost/ListingForm/UploadedImage/UploadedImage.css:
--------------------------------------------------------------------------------
1 | .listing-create__uploaded-image {
2 | position: relative;
3 | height: 100%;
4 | width: 100%;
5 | vertical-align: top;
6 | border-radius: 20px;
7 | margin: 5px;
8 | }
--------------------------------------------------------------------------------
/frontend/src/utils/hooks.js:
--------------------------------------------------------------------------------
1 | export const useBookingPast = (booking) => {
2 | if(!booking) return false;
3 |
4 | const endDate = new Date(booking.endTime);
5 | const now = new Date();
6 |
7 | return now.getTime() - endDate.getTime() > 0;
8 | };
9 |
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | /* TODO Add site wide styles */
2 | body,
3 | * {
4 | font-family: Arial, Helvetica, sans-serif;
5 | box-sizing: border-box;
6 | margin: 0;
7 | padding: 0;
8 | }
9 |
10 | body {
11 | overflow: hidden;
12 | }
13 |
14 |
15 |
--------------------------------------------------------------------------------
/backend/.sequelizerc:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | config: path.resolve('config', 'database.js'),
5 | 'models-path': path.resolve('db', 'models'),
6 | 'seeders-path': path.resolve('db', 'seeders'),
7 | 'migrations-path': path.resolve('db', 'migrations')
8 | };
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/ImageViewer/ImageViewerImage/index.js:
--------------------------------------------------------------------------------
1 | import '../ImageViewer.css';
2 |
3 | export default function ImageViewerImage({ image }) {
4 | return (
5 |
6 |
7 |
8 | )
9 | }
--------------------------------------------------------------------------------
/backend/bin/www:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const { port } = require("../config");
3 |
4 | const app = require("../app");
5 | const db = require("../db/models");
6 |
7 | // Check the database connection before starting the app
8 | db.sequelize
9 | .authenticate()
10 | .then(() => {
11 |
12 | app.listen(port, () => {});
13 | })
14 | .catch((err) => {
15 | console.error(err);
16 | });
--------------------------------------------------------------------------------
/frontend/src/components/ListingPost/ListingForm/UploadedImage/index.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import './UploadedImage.css';
3 |
4 | export default function UploadedImage({ image, context }) {
5 | useEffect(() => {
6 |
7 | }, []);
8 | return (
9 |
10 | )
11 | }
--------------------------------------------------------------------------------
/backend/db/models/image.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | module.exports = (sequelize, DataTypes) => {
3 | const Image = sequelize.define('Image', {
4 | listingId: DataTypes.INTEGER,
5 | url: DataTypes.TEXT
6 | }, {});
7 | Image.associate = function(models) {
8 | // associations can be defined here
9 | Image.belongsTo(models.Listing, { foreignKey: 'listingId', onDelete: 'CASCADE' });
10 | };
11 | return Image;
12 | };
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 | .eslintcache
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
--------------------------------------------------------------------------------
/backend/config/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | environment: process.env.NODE_ENV || 'development',
3 | port: process.env.PORT || 5000,
4 | db: {
5 | username: process.env.DB_USERNAME,
6 | password: process.env.DB_PASSWORD,
7 | database: process.env.DB_DATABASE,
8 | host: process.env.DB_HOST
9 | },
10 | jwtConfig: {
11 | secret: process.env.JWT_SECRET,
12 | expiresIn: process.env.JWT_EXPIRES_IN,
13 | },
14 | };
--------------------------------------------------------------------------------
/frontend/src/components/ListingEdit/ListingEdit.css:
--------------------------------------------------------------------------------
1 | .listing-edit__container-outer {
2 | position: fixed;
3 | top: 75px;
4 | height: 100%;
5 | width: 100%;
6 | border-top: 1px solid rgba(0, 0, 0, 0.2);
7 | overflow-y: auto;
8 | }
9 |
10 | .listing-edit__container {
11 | position: relative;
12 | width: 1000px;
13 | padding: 25px;
14 | left: 51%;
15 | transform: translateX(-50%);
16 | }
17 |
18 | .listing-edit__header {
19 | margin-left: 350px;
20 | }
--------------------------------------------------------------------------------
/frontend/src/components/FormErrors/index.js:
--------------------------------------------------------------------------------
1 | import './FormErrors.css';
2 |
3 | export default function FormErrors({ errors, keyword, context }) {
4 | return (
5 |
6 | {errors?.filter(error => error.toLowerCase().includes(keyword))
7 | .map(error => (
8 |
9 | {error}
10 |
11 | ))}
12 |
13 | )
14 | }
--------------------------------------------------------------------------------
/backend/db/models/review.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | module.exports = (sequelize, DataTypes) => {
3 | const Review = sequelize.define('Review', {
4 | userId: DataTypes.INTEGER,
5 | bookingId: DataTypes.INTEGER,
6 | numStars: DataTypes.DECIMAL,
7 | content: DataTypes.TEXT
8 | }, {});
9 | Review.associate = function(models) {
10 | // associations can be defined here
11 | Review.belongsTo(models.User, { foreignKey: "userId" });
12 | Review.belongsTo(models.Booking, { foreignKey: "bookingId" });
13 | };
14 | return Review;
15 | };
--------------------------------------------------------------------------------
/frontend/src/components/ListingEdit/index.js:
--------------------------------------------------------------------------------
1 | import ListingForm from "../ListingPost/ListingForm";
2 |
3 | import './ListingEdit.css';
4 |
5 | export default function ListingEdit() {
6 | return (
7 |
8 |
9 |
12 | Edit your listing
13 |
14 |
15 |
16 |
17 | )
18 | };
--------------------------------------------------------------------------------
/frontend/src/components/ListingPost/index.js:
--------------------------------------------------------------------------------
1 | import ListingForm from './ListingForm';
2 |
3 | import './ListingPost.css';
4 |
5 | export default function ListingPost() {
6 | return (
7 |
10 |
13 |
15 | Create a Workspace Listing
16 |
17 |
18 |
19 |
20 | );
21 | }
--------------------------------------------------------------------------------
/backend/db/models/booking.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | module.exports = (sequelize, DataTypes) => {
3 | const Booking = sequelize.define('Booking', {
4 | listingId: DataTypes.INTEGER,
5 | userId: DataTypes.INTEGER,
6 | startTime: DataTypes.DATE,
7 | endTime: DataTypes.DATE
8 | }, {});
9 | Booking.associate = function(models) {
10 | // associations can be defined here
11 | Booking.belongsTo(models.User, { foreignKey: 'userId' });
12 | Booking.belongsTo(models.Listing, { foreignKey: 'listingId' });
13 | Booking.hasOne(models.Review, { foreignKey: "bookingId", onDelete: 'CASCADE', hooks: true });
14 | };
15 | return Booking;
16 | };
--------------------------------------------------------------------------------
/frontend/src/components/HomePage/index.js:
--------------------------------------------------------------------------------
1 | import SearchBar from '../SearchBar';
2 |
3 | import './HomePage.css'
4 |
5 | export default function HomePage() {
6 | return (
7 |
8 |
9 |
10 | Welcome to Work-In.
11 |
12 |
13 | Find your future workspace here.
14 |
15 |
16 |
17 |
18 | );
19 | }
--------------------------------------------------------------------------------
/backend/utils/validation.js:
--------------------------------------------------------------------------------
1 | const { validationResult } = require("express-validator");
2 |
3 | // Middleware for formatting the validation errors from the middleware
4 | const handleValidationErrors = (req, res, next) => {
5 | const validationErrors = validationResult(req);
6 | if(!validationErrors.isEmpty()) {
7 | const errors = validationErrors.array().map((error) => `${error.msg}`);
8 |
9 | const err = Error('Bad request.');
10 | err.errors = errors;
11 | err.status = 400;
12 | err.title = 'Bad request.';
13 | next(err);
14 | }
15 |
16 | next();
17 | };
18 |
19 | module.exports = {
20 | handleValidationErrors,
21 | }
--------------------------------------------------------------------------------
/backend/config/database.js:
--------------------------------------------------------------------------------
1 | const config = require('./index');
2 |
3 | const db = config.db;
4 | const username = db.username;
5 | const password = db.password;
6 | const database = db.database;
7 | const host = db.host;
8 |
9 | module.exports = {
10 | development: {
11 | username,
12 | password,
13 | database,
14 | host,
15 | dialect: 'postgres',
16 | seederStorage: 'sequelize',
17 | },
18 | production: {
19 | use_env_variable: 'DATABASE_URL',
20 | dialect: 'postgres',
21 | seederStorage: 'sequelize',
22 | dialectOptions: {
23 | ssl: {
24 | require: true,
25 | rejectUnauthorized: false,
26 | },
27 | },
28 | },
29 | };
30 |
--------------------------------------------------------------------------------
/frontend/src/components/Footer/Footer.css:
--------------------------------------------------------------------------------
1 | .footer {
2 | position: fixed;
3 | display: flex;
4 | flex-direction: column;
5 | align-items: center;
6 | background-color: white;
7 | padding: 15px;
8 | z-index: 1;
9 | justify-content: center;
10 | bottom: 0;
11 | width: 100%;
12 | height: 50px;
13 | border-top: 1px solid rgba(0, 0, 0, 0.1);
14 | }
15 |
16 | .footer-description {
17 | color: rgba(0, 0, 0, 0.5);
18 | }
19 |
20 | .footer-links {
21 | display: flex;
22 | }
23 |
24 | .linkedin-logo {
25 | margin-left: 10px;
26 | font-size: 24px;
27 | color: blue;
28 | }
29 |
30 | .github-logo {
31 | color: black;
32 | font-size: 24px;
33 | margin-right: 10px;
34 | }
--------------------------------------------------------------------------------
/backend/routes/api/index.js:
--------------------------------------------------------------------------------
1 | const router = require("express").Router();
2 | const sessionRouter = require("./session.js");
3 | const usersRouter = require("./users.js");
4 | const listingsRouter = require("./listings.js");
5 | const imageRouter = require("./image.js");
6 | const bookingRouter = require("./booking");
7 | const searchRouter = require("./search");
8 | const reviewRouter = require("./review");
9 |
10 | router.use('/session', sessionRouter);
11 | router.use('/users', usersRouter);
12 | router.use('/listings', listingsRouter);
13 | router.use('/images', imageRouter);
14 | router.use('/bookings', bookingRouter);
15 | router.use('/reviews', reviewRouter);
16 | router.use('/search', searchRouter);
17 |
18 | module.exports = router;
--------------------------------------------------------------------------------
/frontend/src/components/ListingPost/ListingPost.css:
--------------------------------------------------------------------------------
1 | .listing-post__container-outer {
2 | position: fixed;
3 | top: 75px;
4 | width: 100%;
5 | height: 100%;
6 | border-top: 1px solid rgba(0, 0, 0, 0.1);
7 | box-shadow: inset 0px 0px 60px rgba(0, 0, 0, 0.07);
8 | overflow-y: auto;
9 | background-image: url("../../assets/images/orange-office.jpg");
10 | }
11 |
12 | .listing-post__container {
13 | position: relative;
14 | background-color: white;
15 | width: 1000px;
16 | left: 51%;
17 | transform: translateX(-50%);
18 | padding: 40px;
19 | border-left: 1px solid lightgray;
20 | border-right: 1px solid lightgray;
21 | }
22 |
23 | .listing-post__header {
24 | margin-left: 300px;
25 | }
--------------------------------------------------------------------------------
/frontend/src/utils/date.js:
--------------------------------------------------------------------------------
1 | export const formatDate = (date) => {
2 | if(!date) {
3 | return "";
4 | }
5 | const dateFragments = date.split("-");
6 | const [year, month, dayTime] = [...dateFragments];
7 |
8 | const time = dayTime.split('T')[1];
9 | const day = dayTime.split('T')[0];
10 |
11 | return `${month}/${day}/${year} at ${time}`;
12 | };
13 |
14 | export const formatFromDb = (date) => {
15 | if(!date) return "";
16 |
17 | const partialFormat = formatDate(date);
18 | return partialFormat.split('.')[0];
19 | };
20 |
21 | export const formatForReview = (date) => {
22 | const splitDate = date.split(" ");
23 | const month = splitDate[1];
24 | const year = splitDate[3];
25 |
26 | return `${month} ${year}`;
27 | }
--------------------------------------------------------------------------------
/frontend/src/store/search.js:
--------------------------------------------------------------------------------
1 | const SEARCH_BUBBLE_SHOW = '/search/SEARCH_BUBBLE_SHOW';
2 | const SEARCH_BUBBLE_HIDE = '/search/SEARCH_BUBBLE_HIDE';
3 |
4 | export const showSearchBubble = () => {
5 | return {
6 | type: SEARCH_BUBBLE_SHOW,
7 | };
8 | };
9 |
10 | export const hideSearchBubble = () => {
11 | return {
12 | type: SEARCH_BUBBLE_HIDE,
13 | };
14 | };
15 |
16 | const initialState = {
17 | show: false,
18 | }
19 |
20 | export default function searchBubbleReducer(state=initialState, action) {
21 | switch (action.type) {
22 | case SEARCH_BUBBLE_SHOW:
23 | return { show: true };
24 | case SEARCH_BUBBLE_HIDE:
25 | return { show: false };
26 | default:
27 | return state;
28 | }
29 | }
--------------------------------------------------------------------------------
/frontend/src/components/SearchOverlay/index.js:
--------------------------------------------------------------------------------
1 | import { useLocation } from 'react-router';
2 | import { useSelector } from 'react-redux';
3 |
4 | export default function SearchOverlay() {
5 | const show = useSelector(state => state.search.show);
6 |
7 | const currentUrl = useLocation();
8 |
9 | if(!show) {
10 | return null;
11 | }
12 |
13 | const styles = {
14 | position: 'fixed',
15 | width: '100%',
16 | height: '100%',
17 | backgroundColor: 'rgba(0, 0, 0, 0.35)',
18 | backdropFilter: currentUrl.pathname === '/' ? 'blur(7px)' : 'none',
19 | top: currentUrl.pathname === '/' ? '0' : '75px',
20 | zIndex: '2',
21 | };
22 |
23 | return (
24 |
25 |
26 |
27 | )
28 | }
--------------------------------------------------------------------------------
/frontend/src/store/csrf.js:
--------------------------------------------------------------------------------
1 | import Cookies from 'js-cookie';
2 |
3 | export async function csrfFetch(url, options = {}) {
4 | options.method = options.method || 'GET';
5 | options.headers = options.headers || {};
6 |
7 | if(options.method.toUpperCase() !== 'GET') {
8 | if(options.headers["Content-Type"] === "multipart/form-data") {
9 | delete options.headers["Content-Type"];
10 | } else {
11 | options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
12 | }
13 | options.headers['XSRF-Token'] = Cookies.get('XSRF-TOKEN');
14 | }
15 |
16 | const res = await window.fetch(url, options);
17 |
18 | if(res.status >= 400) throw res;
19 |
20 | return res;
21 | }
22 |
23 | export function restoreCSRF() {
24 | return csrfFetch('/api/csrf/restore');
25 | }
--------------------------------------------------------------------------------
/backend/db/models/listing.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | module.exports = (sequelize, DataTypes) => {
3 | const Listing = sequelize.define('Listing', {
4 | userId: DataTypes.INTEGER,
5 | address: DataTypes.STRING,
6 | city: DataTypes.STRING,
7 | state: DataTypes.STRING,
8 | country: DataTypes.STRING,
9 | lat: DataTypes.DECIMAL,
10 | lng: DataTypes.DECIMAL,
11 | name: DataTypes.STRING,
12 | description: DataTypes.TEXT,
13 | price: DataTypes.DECIMAL
14 | }, {});
15 | Listing.associate = function(models) {
16 | // associations can be defined here
17 | Listing.hasMany(models.Image, { foreignKey: 'listingId', onDelete: 'CASCADE', hooks: true});
18 | Listing.belongsTo(models.User, { foreignKey: 'userId' });
19 | Listing.hasMany(models.Booking, { foreignKey: 'listingId', onDelete:'CASCADE', hooks: true});
20 | };
21 | return Listing;
22 | };
--------------------------------------------------------------------------------
/backend/routes/api/image.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const asyncHandler = require("express-async-handler");
3 |
4 | const { restoreUser } = require("../../utils/auth");
5 | const { User, Listing, Image } = require("../../db/models");
6 |
7 | const router = express.Router();
8 |
9 | router.delete("/:id", restoreUser, asyncHandler(async (req, res, next) => {
10 | const userId = req.user.id;
11 |
12 | const imageId = req.params.id;
13 |
14 | const image = await Image.findByPk(imageId);
15 | const listing = await Listing.findByPk(image.listingId);
16 | if(userId === listing.userId) {
17 | await image.destroy();
18 | res.json({ message: "Image deletion was successful." });
19 | } else {
20 | const err = new Error("You can only delete images that you own.");
21 | next(err);
22 | }
23 | }));
24 |
25 | module.exports = router;
--------------------------------------------------------------------------------
/backend/db/migrations/20210719194102-create-image.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | module.exports = {
3 | up: (queryInterface, Sequelize) => {
4 | return queryInterface.createTable('Images', {
5 | id: {
6 | allowNull: false,
7 | autoIncrement: true,
8 | primaryKey: true,
9 | type: Sequelize.INTEGER
10 | },
11 | listingId: {
12 | type: Sequelize.INTEGER,
13 | allowNull: false,
14 | references: { model: 'Listings' }
15 | },
16 | url: {
17 | type: Sequelize.TEXT,
18 | allowNull: false
19 | },
20 | createdAt: {
21 | allowNull: false,
22 | type: Sequelize.DATE
23 | },
24 | updatedAt: {
25 | allowNull: false,
26 | type: Sequelize.DATE
27 | }
28 | });
29 | },
30 | down: (queryInterface, Sequelize) => {
31 | return queryInterface.dropTable('Images');
32 | }
33 | };
--------------------------------------------------------------------------------
/frontend/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { BrowserRouter } from 'react-router-dom';
5 |
6 | import './index.css';
7 | import App from './App';
8 | import configureStore from './store';
9 | import { restoreCSRF, csrfFetch } from './store/csrf';
10 | import * as sessionActions from './store/session';
11 |
12 | const store = configureStore();
13 |
14 | if(process.env.NODE_ENV !== 'production') {
15 | restoreCSRF();
16 |
17 | window.csrfFetch = csrfFetch;
18 | window.store = store;
19 | window.sessionActions = sessionActions;
20 | }
21 |
22 | function Root() {
23 | return (
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | ReactDOM.render(
33 |
34 |
35 | ,
36 | document.getElementById('root')
37 | );
38 |
--------------------------------------------------------------------------------
/frontend/src/components/Footer/index.js:
--------------------------------------------------------------------------------
1 | import './Footer.css';
2 |
3 | export default function Footer({ show }) {
4 | if(!show) return null;
5 |
6 | return (
7 |
8 |
9 | Created by Kagen Hearn with React/Redux
10 |
11 |
23 |
24 | )
25 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "authenticate-me",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "heroku-postbuild": "npm run build --prefix frontend",
8 | "install": "npm --prefix backend install backend && npm --prefix frontend install frontend",
9 | "dev:backend": "npm install --prefix backend start",
10 | "dev:frontend": "npm install --prefix frontend start",
11 | "sequelize": "npm run --prefix backend sequelize",
12 | "sequelize-cli": "npm run --prefix backend sequelize-cli",
13 | "start": "npm start --prefix backend"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/KagenLH/authenticate-me-individual.git"
18 | },
19 | "keywords": [],
20 | "author": "",
21 | "license": "ISC",
22 | "bugs": {
23 | "url": "https://github.com/KagenLH/authenticate-me-individual/issues"
24 | },
25 | "homepage": "https://github.com/KagenLH/authenticate-me-individual#readme"
26 | }
27 |
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "backend",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "sequelize": "sequelize",
8 | "sequelize-cli": "sequelize-cli",
9 | "start": "per-env",
10 | "start:development": "nodemon -r dotenv/config ./bin/www",
11 | "start:production": "node ./bin/www"
12 | },
13 | "keywords": [],
14 | "author": "",
15 | "license": "ISC",
16 | "dependencies": {
17 | "aws-sdk": "^2.948.0",
18 | "bcryptjs": "^2.4.3",
19 | "cookie-parser": "^1.4.5",
20 | "cors": "^2.8.5",
21 | "csurf": "^1.11.0",
22 | "dotenv": "^10.0.0",
23 | "express": "^4.17.1",
24 | "express-async-handler": "^1.1.4",
25 | "express-validator": "^6.12.0",
26 | "faker": "^5.5.3",
27 | "helmet": "^4.6.0",
28 | "jsonwebtoken": "^8.5.1",
29 | "morgan": "^1.10.0",
30 | "multer": "^1.4.2",
31 | "per-env": "^1.0.2",
32 | "pg": "^8.6.0",
33 | "sequelize": "^5.22.4",
34 | "sequelize-cli": "^5.5.1"
35 | },
36 | "devDependencies": {
37 | "dotenv-cli": "^4.0.0",
38 | "nodemon": "^2.0.12"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/backend/db/models/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | const Sequelize = require('sequelize');
6 | const basename = path.basename(__filename);
7 | const env = process.env.NODE_ENV || 'development';
8 | const config = require(__dirname + '/../../config/database.js')[env];
9 | const db = {};
10 |
11 | let sequelize;
12 | if (config.use_env_variable) {
13 | sequelize = new Sequelize(process.env[config.use_env_variable], config);
14 | } else {
15 | sequelize = new Sequelize(config.database, config.username, config.password, config);
16 | }
17 |
18 | fs
19 | .readdirSync(__dirname)
20 | .filter(file => {
21 | return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
22 | })
23 | .forEach(file => {
24 | const model = sequelize['import'](path.join(__dirname, file));
25 | db[model.name] = model;
26 | });
27 |
28 | Object.keys(db).forEach(modelName => {
29 | if (db[modelName].associate) {
30 | db[modelName].associate(db);
31 | }
32 | });
33 |
34 | db.sequelize = sequelize;
35 | db.Sequelize = Sequelize;
36 |
37 | module.exports = db;
38 |
--------------------------------------------------------------------------------
/frontend/src/store/imageViewer.js:
--------------------------------------------------------------------------------
1 | const OPEN_IMAGE_VIEWER = '/images/SHOW_IMAGE_VIEWER';
2 | const CLOSE_IMAGE_VIEWER = '/images/CLOSE_IMAGE_VIEWER';
3 | const SET_CURRENT_IMAGE = '/images/SET_CURRENT_IMAGE';
4 |
5 | export const openImageViewer = () => {
6 | return {
7 | type: OPEN_IMAGE_VIEWER
8 | };
9 | };
10 |
11 | export const closeImageViewer = () => {
12 | return {
13 | type: CLOSE_IMAGE_VIEWER
14 | };
15 | };
16 |
17 | export const setCurrentImage = (payload) => {
18 | return {
19 | type: SET_CURRENT_IMAGE,
20 | payload
21 | };
22 | };
23 |
24 | const initialState = {
25 | showViewer: false,
26 | currentImage: null,
27 | };
28 |
29 | export const imageViewerReducer = (state = initialState, action) => {
30 | switch (action.type) {
31 | case OPEN_IMAGE_VIEWER:
32 | return { ...state, showViewer: true };
33 | case CLOSE_IMAGE_VIEWER:
34 | return { ...state, showViewer: false };
35 | case SET_CURRENT_IMAGE:
36 | return { ...state, currentImage: action.payload }
37 | default:
38 | return state;
39 | }
40 | }
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@appacademy/cra-template-react-v17": "1.0.2",
7 | "js-cookie": "^2.2.1",
8 | "moment": "^2.29.1",
9 | "react": "^17.0.2",
10 | "react-dom": "^17.0.2",
11 | "react-dropzone": "^11.3.4",
12 | "react-redux": "^7.2.4",
13 | "react-router-dom": "^5.2.0",
14 | "react-scripts": "4.0.3",
15 | "redux": "^4.1.0",
16 | "redux-thunk": "^2.3.0"
17 | },
18 | "scripts": {
19 | "start": "react-scripts start",
20 | "build": "react-scripts build",
21 | "test": "react-scripts test",
22 | "eject": "react-scripts eject"
23 | },
24 | "eslintConfig": {
25 | "extends": "react-app"
26 | },
27 | "browserslist": {
28 | "production": [
29 | ">0.2%",
30 | "not dead",
31 | "not op_mini all"
32 | ],
33 | "development": [
34 | "last 1 chrome version",
35 | "last 1 firefox version",
36 | "last 1 safari version"
37 | ]
38 | },
39 | "devDependencies": {
40 | "redux-logger": "^3.0.6"
41 | },
42 | "proxy": "http://localhost:5000"
43 | }
44 |
--------------------------------------------------------------------------------
/backend/db/migrations/20210722202834-create-booking.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | module.exports = {
3 | up: (queryInterface, Sequelize) => {
4 | return queryInterface.createTable('Bookings', {
5 | id: {
6 | allowNull: false,
7 | autoIncrement: true,
8 | primaryKey: true,
9 | type: Sequelize.INTEGER
10 | },
11 | listingId: {
12 | type: Sequelize.INTEGER,
13 | allowNull: false,
14 | references: { model: 'Listings' },
15 | },
16 | userId: {
17 | type: Sequelize.INTEGER,
18 | allowNull: false,
19 | references: { model: 'Users' },
20 | },
21 | startTime: {
22 | type: Sequelize.DATE,
23 | allowNull: false,
24 | },
25 | endTime: {
26 | type: Sequelize.DATE,
27 | allowNull: false,
28 | },
29 | createdAt: {
30 | allowNull: false,
31 | type: Sequelize.DATE
32 | },
33 | updatedAt: {
34 | allowNull: false,
35 | type: Sequelize.DATE
36 | }
37 | });
38 | },
39 | down: (queryInterface, Sequelize) => {
40 | return queryInterface.dropTable('Bookings');
41 | }
42 | };
--------------------------------------------------------------------------------
/backend/db/migrations/20210723223212-create-review.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | module.exports = {
3 | up: (queryInterface, Sequelize) => {
4 | return queryInterface.createTable('Reviews', {
5 | id: {
6 | allowNull: false,
7 | autoIncrement: true,
8 | primaryKey: true,
9 | type: Sequelize.INTEGER
10 | },
11 | userId: {
12 | type: Sequelize.INTEGER,
13 | allowNull: false,
14 | references: { model: 'Users' },
15 | },
16 | bookingId: {
17 | type: Sequelize.INTEGER,
18 | allowNull: false,
19 | references: { model: 'Bookings' },
20 | },
21 | numStars: {
22 | type: Sequelize.DECIMAL,
23 | allowNull: false,
24 | },
25 | content: {
26 | type: Sequelize.TEXT,
27 | allowNull: false,
28 | },
29 | createdAt: {
30 | allowNull: false,
31 | type: Sequelize.DATE
32 | },
33 | updatedAt: {
34 | allowNull: false,
35 | type: Sequelize.DATE
36 | }
37 | });
38 | },
39 | down: (queryInterface, Sequelize) => {
40 | return queryInterface.dropTable('Reviews');
41 | }
42 | };
--------------------------------------------------------------------------------
/backend/db/migrations/20210714233753-create-user.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | module.exports = {
3 | up: (queryInterface, Sequelize) => {
4 | return queryInterface.createTable('Users', {
5 | id: {
6 | allowNull: false,
7 | autoIncrement: true,
8 | primaryKey: true,
9 | type: Sequelize.INTEGER
10 | },
11 | username: {
12 | type: Sequelize.STRING(30),
13 | allowNull: false,
14 | unique: true
15 | },
16 | email: {
17 | type: Sequelize.STRING(256),
18 | allowNull: false,
19 | unique: true
20 | },
21 | hashedPassword: {
22 | type: Sequelize.STRING.BINARY,
23 | allowNull: false
24 | },
25 | avatarUrl: {
26 | type: Sequelize.STRING(1000),
27 | },
28 | createdAt: {
29 | allowNull: false,
30 | type: Sequelize.DATE,
31 | defaultValue: Sequelize.fn('now'),
32 | },
33 | updatedAt: {
34 | allowNull: false,
35 | type: Sequelize.DATE,
36 | defaultValue: Sequelize.fn('now'),
37 | }
38 | });
39 | },
40 | down: (queryInterface, Sequelize) => {
41 | return queryInterface.dropTable('Users');
42 | }
43 | };
--------------------------------------------------------------------------------
/backend/routes/index.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 |
3 | const apiRouter = require('./api');
4 |
5 | const router = express.Router();
6 |
7 | router.use('/api', apiRouter);
8 |
9 | if (process.env.NODE_ENV === 'production') {
10 | const path = require('path');
11 | // Serve the frontend's index.html file at the root route
12 | router.get('/', (req, res) => {
13 | res.cookie('XSRF-TOKEN', req.csrfToken());
14 | res.sendFile(
15 | path.resolve(__dirname, '../../frontend', 'build', 'index.html')
16 | );
17 | });
18 |
19 | // Serve the static assets in the frontend's build folder
20 | router.use(express.static(path.resolve("../frontend/build")));
21 |
22 | // Serve the frontend's index.html file at all other routes NOT starting with /api
23 | router.get(/^(?!\/?api).*/, (req, res) => {
24 | res.cookie('XSRF-TOKEN', req.csrfToken());
25 | res.sendFile(
26 | path.resolve(__dirname, '../../frontend', 'build', 'index.html')
27 | );
28 | });
29 | }
30 |
31 | if (process.env.NODE_ENV !== 'production') {
32 | router.get('/api/csrf/restore', (req, res) => {
33 | res.cookie('XSRF-TOKEN', req.csrfToken());
34 | res.status(201).json({});
35 | });
36 | }
37 |
38 | module.exports = router;
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Work-In - Find and Share Spaces
17 |
18 |
19 | You need to enable JavaScript to run this app.
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/frontend/src/components/AuthModal/AuthForms/AuthForm.css:
--------------------------------------------------------------------------------
1 | .auth-form {
2 | font-family: Arial, Helvetica, sans-serif;
3 | font-size: 22px;
4 | justify-content: center;
5 | display: flex;
6 | flex-direction: column;
7 | align-items: center;
8 | border-radius: 20px;
9 | }
10 |
11 | .auth-form__input {
12 | margin: 5px;
13 | width: 400px;
14 | height: 54px;
15 | font-size: 17px;
16 | align-self: center;
17 | padding: 7px;
18 | padding-top: 16px;
19 | padding-left: 12px;
20 | border-radius: 5px;
21 | border: 1px solid gray;
22 | }
23 |
24 | .auth-form__input:focus {
25 | outline: none;
26 | border: 2px solid salmon;
27 | }
28 |
29 | .auth-form__button {
30 | background-color: white;
31 | border: none;
32 | margin-top: 10px;
33 | color: white;
34 | font-size: 20px;
35 | cursor: pointer;
36 | width: 250px;
37 | align-self: center;
38 | background-color: salmon;
39 | padding: 7px;
40 | border-radius: 10px;
41 | }
42 |
43 | .auth-form__label {
44 | position: relative;
45 | }
46 |
47 | .auth-form__input:focus ~ .floating-label,
48 | .auth-form__input:not(:focus):valid ~ .floating-label {
49 | top: 12px;
50 | bottom: 10px;
51 | left: 20px;
52 | font-size: 11px;
53 | opacity: 0.6;
54 | }
55 |
56 | .floating-label {
57 | opacity: 0.4;
58 | font-size: 16px;
59 | position: absolute;
60 | pointer-events: none;
61 | left: 16px;
62 | top: 23px;
63 | transition: 0.2s ease all;
64 | }
65 |
66 |
--------------------------------------------------------------------------------
/frontend/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers, applyMiddleware, compose, createStore } from "redux";
2 | import thunk from 'redux-thunk';
3 |
4 | import sessionReducer from './session';
5 | import { loginModalReducer, signupModalReducer, deleteListingModalReducer, createBookingModalReducer } from "./modals";
6 | import { imageViewerReducer } from "./imageViewer";
7 | import bookingReducer from "./booking";
8 | import userBookingReducer from "./userBookings";
9 | import searchBubbleReducer from "./search";
10 | import reviewsReducer from "./reviews";
11 |
12 | const rootReducer = combineReducers({
13 | session: sessionReducer,
14 | loginModal: loginModalReducer,
15 | signupModal: signupModalReducer,
16 | imageViewer: imageViewerReducer,
17 | deleteListingModal: deleteListingModalReducer,
18 | createBookingModal: createBookingModalReducer,
19 | booking: bookingReducer,
20 | userBookings: userBookingReducer,
21 | search: searchBubbleReducer,
22 | reviews: reviewsReducer,
23 | });
24 |
25 | let enhancer;
26 |
27 | if(process.env.NODE_ENV === 'production') {
28 | enhancer = applyMiddleware(thunk);
29 | } else {
30 | const logger = require("redux-logger").default;
31 | const composeEnhancers =
32 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
33 | enhancer = composeEnhancers(applyMiddleware(thunk, logger));
34 | }
35 |
36 | const configureStore = (preloadedState) => {
37 | return createStore(rootReducer, preloadedState, enhancer);
38 | };
39 |
40 | export default configureStore;
--------------------------------------------------------------------------------
/backend/db/seeders/20210714234806-demo-user.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const faker = require("faker");
3 | const bcrypt = require("bcryptjs");
4 |
5 | module.exports = {
6 | up: (queryInterface, Sequelize) => {
7 | /*
8 | Add altering commands here.
9 | Return a promise to correctly handle asynchronicity.
10 |
11 | Example:
12 | return queryInterface.bulkInsert('People', [{
13 | name: 'John Doe',
14 | isBetaMember: false
15 | }], {});
16 | */
17 | return queryInterface.bulkInsert('Users', [
18 | {
19 | email: 'demo@user.io',
20 | username: 'Demo Host',
21 | hashedPassword: bcrypt.hashSync('password'),
22 | },
23 | {
24 | email: faker.internet.email(),
25 | username: 'Eager Engineer',
26 | hashedPassword: bcrypt.hashSync(faker.internet.password()),
27 | },
28 | {
29 | email: faker.internet.email(),
30 | username: 'WackyWebDev',
31 | hashedPassword: bcrypt.hashSync(faker.internet.password()),
32 | },
33 | ], {});
34 | },
35 |
36 | down: (queryInterface, Sequelize) => {
37 | /*
38 | Add reverting commands here.
39 | Return a promise to correctly handle asynchronicity.
40 |
41 | Example:
42 | return queryInterface.bulkDelete('People', null, {});
43 | */
44 | const Op = Sequelize.Op;
45 | return queryInterface.bulkDelete('Users', {
46 | username: { [Op.in]: ['Demo Host', 'Eager Engineer', 'WackyWebDev'] }
47 | }, {});
48 | }
49 | };
50 |
--------------------------------------------------------------------------------
/frontend/src/components/Navigation/ProfileButton.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { useDispatch } from 'react-redux';
3 | import { useLocation } from 'react-router';
4 |
5 | import { logoutUser } from "../../store/session";
6 |
7 | import './Navigation.css'
8 |
9 | function ProfileButton({ user }) {
10 | const dispatch = useDispatch();
11 | const [showMenu, setShowMenu] = useState(false);
12 |
13 | const location = useLocation();
14 |
15 | const openMenu = () => {
16 | if (showMenu) return;
17 | setShowMenu(true);
18 | };
19 |
20 | useEffect(() => {
21 | if (!showMenu) return;
22 |
23 | const closeMenu = () => {
24 | setShowMenu(false);
25 | };
26 |
27 | document.addEventListener('click', closeMenu);
28 |
29 | return () => document.removeEventListener("click", closeMenu);
30 | }, [showMenu]);
31 |
32 | const logout = (e) => {
33 | e.preventDefault();
34 | dispatch(logoutUser());
35 | };
36 |
37 | return (
38 | <>
39 |
40 |
41 |
42 | {showMenu && (
43 |
44 | {user.user.username}
45 | {user.user.email}
46 |
47 | Log Out
48 |
49 |
50 | )}
51 | >
52 | );
53 | }
54 |
55 | export default ProfileButton;
--------------------------------------------------------------------------------
/backend/routes/api/search.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const asyncHandler = require("express-async-handler");
3 | const { Op } = require("sequelize");
4 |
5 | const { Listing, Image, User } = require("../../db/models");
6 | const { parseSearchString } = require("../../utils/parse");
7 |
8 | const router = express.Router();
9 |
10 | router.get("/:keyword",
11 | parseSearchString,
12 | asyncHandler(async (req, res, next) => {
13 | const keyword = req.keyword
14 |
15 | const results = await Listing.findAll({
16 | where: {
17 | [Op.or]: {
18 | name: {
19 | [Op.iLike]: `%${keyword}%`,
20 | },
21 | address: {
22 | [Op.iLike]: `%${keyword}%`,
23 | },
24 | city: {
25 | [Op.iLike]: `%${keyword}%`,
26 | },
27 | state: {
28 | [Op.iLike]: `%${keyword}%`,
29 | },
30 | country: {
31 | [Op.iLike]: `%${keyword}%`,
32 | },
33 | },
34 | },
35 | limit: 5,
36 | include: [Image, User]
37 | });
38 |
39 | res.json(results);
40 | }));
41 |
42 | router.get("/", (req, res) => {
43 | res.json([]);
44 | });
45 |
46 | module.exports = router;
--------------------------------------------------------------------------------
/backend/db/migrations/20210718191204-create-listing.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | module.exports = {
3 | up: (queryInterface, Sequelize) => {
4 | return queryInterface.createTable('Listings', {
5 | id: {
6 | allowNull: false,
7 | autoIncrement: true,
8 | primaryKey: true,
9 | type: Sequelize.INTEGER
10 | },
11 | userId: {
12 | type: Sequelize.INTEGER,
13 | allowNull: false,
14 | references: { model: 'Users' }
15 | },
16 | address: {
17 | type: Sequelize.STRING,
18 | allowNull: false
19 | },
20 | city: {
21 | type: Sequelize.STRING(100),
22 | allowNull: false
23 | },
24 | state: {
25 | type: Sequelize.STRING(60),
26 | allowNull: false
27 | },
28 | country: {
29 | type: Sequelize.STRING(60),
30 | allowNull: false
31 | },
32 | lat: {
33 | type: Sequelize.DECIMAL,
34 | },
35 | lng: {
36 | type: Sequelize.DECIMAL,
37 | },
38 | name: {
39 | type: Sequelize.STRING(200),
40 | allowNull: false
41 | },
42 | description: {
43 | type: Sequelize.TEXT,
44 | allowNull: false
45 | },
46 | price: {
47 | type: Sequelize.DECIMAL,
48 | allowNull: false
49 | },
50 | createdAt: {
51 | allowNull: false,
52 | type: Sequelize.DATE
53 | },
54 | updatedAt: {
55 | allowNull: false,
56 | type: Sequelize.DATE
57 | }
58 | });
59 | },
60 | down: (queryInterface, Sequelize) => {
61 | return queryInterface.dropTable('Listings');
62 | }
63 | };
--------------------------------------------------------------------------------
/frontend/src/store/userBookings.js:
--------------------------------------------------------------------------------
1 | import { csrfFetch } from "./csrf";
2 |
3 |
4 | const ADD_USER_BOOKING = 'userBookings/ADD_USER_BOOKING';
5 | const LOAD_USER_BOOKINGS = 'userBookings/LOAD_USER_BOOKINGS';
6 | const REMOVE_USER_BOOKING = 'userBookings/REMOVE_USER_BOOKING';
7 |
8 | export const addUserBooking = (payload) => {
9 | return {
10 | type: ADD_USER_BOOKING,
11 | payload,
12 | }
13 | };
14 |
15 | export const loadUserBookings = (payload) => {
16 | return {
17 | type: LOAD_USER_BOOKINGS,
18 | payload,
19 | }
20 | };
21 |
22 | export const removeUserBooking = (payload) => {
23 | return {
24 | type: REMOVE_USER_BOOKING,
25 | payload,
26 | }
27 | };
28 |
29 | export const fetchUserBookings = (user) => async dispatch => {
30 | if(user) {
31 | const res = await csrfFetch('/api/bookings');
32 |
33 | if(res.ok) {
34 | const bookings = await res.json();
35 |
36 | return dispatch(loadUserBookings(bookings));
37 | }
38 | }
39 | };
40 |
41 | const initialState = [
42 |
43 | ];
44 |
45 | export default function userBookingReducer(state=initialState, action) {
46 | switch(action.type) {
47 | case ADD_USER_BOOKING:
48 | return [ ...state, action.payload ];
49 | case LOAD_USER_BOOKINGS:
50 | return [ ...action.payload ];
51 | case REMOVE_USER_BOOKING:
52 | const newState = [...state];
53 | const deletedBooking = newState.find(booking => booking.id === action.payload);
54 | delete newState[newState.indexOf(deletedBooking)];
55 | return newState;
56 | default:
57 | return state;
58 | }
59 | }
--------------------------------------------------------------------------------
/backend/utils/auth.js:
--------------------------------------------------------------------------------
1 | const jwt = require("jsonwebtoken");
2 | const { jwtConfig } = require("../config");
3 | const { User } = require("../db/models");
4 |
5 | const { secret, expiresIn } = jwtConfig;
6 |
7 | // Send a JWT Cookie
8 | const setTokenCookie = (res, user) => {
9 | const token = jwt.sign(
10 | { data: user.toSafeObject() },
11 | secret,
12 | { expiresIn: parseInt(expiresIn) }
13 | );
14 |
15 | const isProduction = process.env.NODE_ENV === 'production';
16 |
17 | res.cookie('token', token, {
18 | maxAge: expiresIn * 1000,
19 | httpOnly: true,
20 | secure: isProduction,
21 | sameSite: isProduction && "Lax"
22 | });
23 |
24 | return token;
25 | };
26 |
27 | const restoreUser = (req, res, next) => {
28 | const { token } = req.cookies;
29 |
30 | return jwt.verify(token, secret, null, async (err, jwtPayload) => {
31 | if(err) {
32 | return next();
33 | }
34 |
35 | try {
36 | const { id } = jwtPayload.data;
37 | req.user = await User.scope('currentUser').findByPk(id);
38 | } catch (e) {
39 | res.clearCookie('token');
40 | return next();
41 | }
42 |
43 | if(!req.user) res.clearCookie('token');
44 |
45 | return next();
46 | });
47 | };
48 |
49 | const requireAuth = [
50 | restoreUser,
51 | (req, res, next) => {
52 | if(req.user) return next();
53 |
54 | const err = new Error('Unauthorized');
55 | err.title = 'Unauthorized';
56 | err.errors = ['Unauthorized'];
57 | err.status = 401;
58 | return next(err);
59 | }
60 | ];
61 |
62 | module.exports = { setTokenCookie, restoreUser, requireAuth };
--------------------------------------------------------------------------------
/backend/routes/api/session.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const asyncHandler = require("express-async-handler");
3 | const { check } = require("express-validator");
4 | const { handleValidationErrors } = require("../../utils/validation");
5 |
6 | const { setTokenCookie, restoreUser } = require("../../utils/auth");
7 | const { User } = require("../../db/models");
8 |
9 | const router = express.Router();
10 |
11 | const validateLogin = [
12 | check('credential')
13 | .exists({ checkFalsy: true })
14 | .notEmpty()
15 | .withMessage('Please provide a valid email or username.'),
16 | check('password')
17 | .exists({ checkFalsy: true })
18 | .withMessage('Please provide a password.'),
19 | handleValidationErrors,
20 | ];
21 |
22 | // Log in route
23 | router.post('/', validateLogin, asyncHandler(async (req, res, next) => {
24 | const { credential, password } = req.body;
25 |
26 | const user = await User.login({ credential, password });
27 |
28 | if(!user) {
29 | const err = new Error('Login failed');
30 | err.status = 401;
31 | err.title = 'Login failed';
32 | err.errors = ['The provided credentials were invalid.'];
33 | return next(err);
34 | }
35 |
36 | await setTokenCookie(res, user);
37 |
38 | return res.json({
39 | user
40 | });
41 | }));
42 |
43 | // Log out
44 | router.delete('/', (req, res) => {
45 | res.clearCookie('token');
46 | return res.json({ message: 'success' });
47 | });
48 |
49 | router.get('/', restoreUser, (req, res) => {
50 | const { user } = req;
51 | if(user) {
52 | return res.json({
53 | user: user.toSafeObject()
54 | });
55 | } else return res.json({user: null});
56 | });
57 |
58 | module.exports = router;
--------------------------------------------------------------------------------
/frontend/src/components/AuthModal/Modal.css:
--------------------------------------------------------------------------------
1 | .auth-modal {
2 | position: fixed;
3 | left: 0;
4 | top: 0;
5 | right: 0;
6 | bottom: 0;
7 | background-color: rgba(0, 0, 0, 0.7);
8 | display: flex;
9 | align-items: center;
10 | justify-content: center;
11 | z-index: 99;
12 | }
13 |
14 | .auth-modal__content {
15 | width: 500px;
16 | border-radius: 10px;
17 | background-color: white;
18 | }
19 |
20 | .auth-modal__header {
21 | height: 60px;
22 | box-sizing: border-box;
23 | margin-top: 5px;
24 | padding: 10px;
25 | display: flex;
26 | align-items: center;
27 | border-bottom: 1px solid lightgray;
28 | }
29 |
30 | .auth-modal__title {
31 | margin-left: 110px;
32 | font-size: 18px;
33 | }
34 |
35 | .auth-modal__title-login {
36 | margin-left: 125px;
37 | }
38 |
39 | .auth-modal__body {
40 | padding: 25px;
41 | }
42 |
43 | .auth-modal__close {
44 | background-color: white;
45 | color: black;
46 | border: none;
47 | cursor: pointer;
48 | font-size: 20px;
49 | margin: 5px;
50 | border-radius: 50%;
51 | width: 30px;
52 | height: 30px;
53 | }
54 |
55 | .auth-modal__close:hover {
56 | background-color:rgba(0, 0, 0, 0.05)
57 | }
58 |
59 | .auth-modal__options-text {
60 | display: flex;
61 | margin-top: 10px;
62 | flex-direction: column;
63 | justify-content: center;
64 | align-items: center;
65 | }
66 |
67 | .auth-modal__options-button {
68 | background-color: white;
69 | margin: 5px;
70 | border: none;
71 | color: salmon;
72 | cursor: pointer;
73 | }
74 |
75 | .auth-modal__logo {
76 | position: relative;
77 | left: 120px;
78 | margin-top: -50px;
79 | margin-bottom: -40px;
80 | height: 200px;
81 | width: 200px;
82 | }
--------------------------------------------------------------------------------
/frontend/src/components/HomePage/HomePage.css:
--------------------------------------------------------------------------------
1 | .homepage {
2 | position: absolute;
3 | display: flex;
4 | top: 35px;
5 | width: 100%;
6 | height: 100%;
7 | justify-content: center;
8 | align-items: center;
9 | background-image: url('./office.jpg');
10 | }
11 |
12 | .homepage__search-input {
13 | padding: 9px;
14 | font-size: 21px;
15 | border: none;
16 | height: 60px;
17 | width: 100%;
18 | font-family: Arial, Helvetica, sans-serif;
19 | box-shadow: 0px 0px 50px 10px rgba(0, 0, 0, 0.2);
20 | }
21 |
22 | .homepage__search-input:focus {
23 | outline: none;
24 | border: 2px solid salmon;
25 | }
26 |
27 | .homepage__search {
28 | position: relative;
29 | width: 100%;
30 | display: flex;
31 | align-items: center;
32 | }
33 |
34 | .search-form-container {
35 | position: absolute;
36 | display: flex;
37 | flex-direction: column;
38 | width: 70%;
39 | top: 50%;
40 | left: 15%;
41 | z-index: 11;
42 | }
43 |
44 | .nav-search-form-container {
45 | position: relative;
46 | z-index: 11;
47 | }
48 |
49 | .homepage__search-submit {
50 | width: 40px;
51 | height: 40px;
52 | background-color: transparent;
53 | border: none;
54 | cursor: pointer;
55 | }
56 |
57 | .search-icon__wrapper-home {
58 | font-size: 32px;
59 | color: white;
60 | margin-right: 40px;
61 | }
62 |
63 | .home-search-logo {
64 | position: fixed;
65 | bottom: 33%;
66 | left: 33%;
67 | width: 550px;
68 | height: 550px;
69 | }
70 |
71 | .homepage__welcome-message {
72 | position: absolute;
73 | display: flex;
74 | flex-direction: column;
75 | align-items: center;
76 | font-size: 50px;
77 | font-weight: bold;
78 | top: 32%;
79 | color: rgba(255, 255, 255, 0.85);
80 | }
81 |
82 |
--------------------------------------------------------------------------------
/backend/routes/api/users.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const asyncHandler = require("express-async-handler");
3 | const { check } = require("express-validator");
4 |
5 | const { handleValidationErrors } = require("../../utils/validation");
6 | const { setTokenCookie, requireAuth } = require("../../utils/auth");
7 | const { User } = require("../../db/models");
8 |
9 | const validateSignup = [
10 | check('email')
11 | .exists({ checkFalsy: true })
12 | .isEmail()
13 | .withMessage('Please provide a valid email.')
14 | .trim()
15 | .normalizeEmail(),
16 | check('username')
17 | .exists({ checkFalsy: true })
18 | .isLength({ min: 4 })
19 | .withMessage('Please provide a username with at least 4 characters.')
20 | .isLength({ max: 50 })
21 | .withMessage('Usename cannot be longer than 50 characters')
22 | .trim(),
23 | check('username')
24 | .not()
25 | .isEmail()
26 | .withMessage('Username cannot be an email.'),
27 | check('password')
28 | .exists({ checkFalsy: true })
29 | .isLength({ min: 8 })
30 | .withMessage('Password must be at least 8 characters.')
31 | .isStrongPassword()
32 | .withMessage('Password must have at least one lowercase and one uppercase letter, one number, and one symbol.')
33 | .not()
34 | .contains(" ")
35 | .withMessage("Password cannot contain spaces."),
36 | handleValidationErrors,
37 | ];
38 |
39 | const sanitizeSignup = [
40 |
41 | ];
42 |
43 | const router = express.Router();
44 |
45 | router.post("/", validateSignup, asyncHandler(async (req, res, next) => {
46 | const { email, password, username, avatarUrl } = req.body;
47 | const user = await User.signup({ email, username, password, avatarUrl });
48 |
49 | await setTokenCookie(res, user);
50 |
51 | return res.json({
52 | user,
53 | });
54 | }));
55 |
56 | module.exports = router;
--------------------------------------------------------------------------------
/backend/app.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const morgan = require("morgan");
3 | const cors = require("cors");
4 | const csrf = require("csurf");
5 | const helmet = require("helmet");
6 | const cookieParser = require("cookie-parser");
7 | const { ValidationError } = require("sequelize");
8 |
9 | const routes = require("./routes");
10 |
11 | const { environment } = require("./config");
12 | const isProduction = environment === "production";
13 |
14 | const app = express();
15 |
16 | // Application middlewares, each is evaluated in the order that they are attached.
17 | app.use(morgan('dev'));
18 | app.use(cookieParser());
19 | app.use(express.urlencoded({ extended: false }));
20 | app.use(express.json());
21 |
22 | if(!isProduction) {
23 | app.use(cors());
24 | }
25 |
26 | app.use(helmet({
27 | contentSecurityPolicy: false
28 | }));
29 |
30 | app.use(
31 | csrf({
32 | cookie: {
33 | secure: isProduction,
34 | sameSite: isProduction && "Lax",
35 | httpOnly: true
36 | }
37 | })
38 | );
39 |
40 | // Application routers
41 | app.use(routes);
42 |
43 | app.use((req, res, next) => {
44 | const err = new Error("The requested resource couldn't be found.");
45 | err.title = "Resource Not Found";
46 | err.errors = ["The requested resource couldn't be found."];
47 | err.status = 404;
48 | next(err);
49 | });
50 |
51 | app.use((err, req, res, next) => {
52 | if(err instanceof ValidationError) {
53 | err.errors = err.errors.map((e) => e.message);
54 | err.title = 'Validation error';
55 | }
56 |
57 | next(err);
58 | });
59 |
60 | app.use((err, req, res, next) => {
61 | res.status(err.status || 500);
62 | console.error(err);
63 | res.json({
64 | title: err.title || 'Server Error',
65 | message: err.message,
66 | errors: err.errors,
67 | stack: isProduction ? null : err.stack
68 | });
69 | });
70 |
71 |
72 |
73 | module.exports = app;
--------------------------------------------------------------------------------
/frontend/src/components/Navigation/index.js:
--------------------------------------------------------------------------------
1 | import { useSelector, useDispatch } from 'react-redux';
2 | import { NavLink } from 'react-router-dom';
3 | import { useLocation } from 'react-router';
4 |
5 | import ProfileButton from './ProfileButton';
6 | import { showLoginModal, showSignupModal} from '../../store/modals';
7 | import SearchBar from '../SearchBar';
8 |
9 | import './Navigation.css';
10 | import logo from '../../assets/images/work-in-logo.png';
11 |
12 | export default function Navigation() {
13 | const currentUrl = useLocation();
14 | const dispatch = useDispatch();
15 | const sessionUser = useSelector(state => state.session.user);
16 |
17 | return (
18 |
19 | {currentUrl.pathname.includes("listings") &&
20 | <>
21 |
22 |
23 |
24 |
25 | >
26 | }
27 |
28 | Home
29 | Listings
30 | {sessionUser !== null ?
31 |
32 | :
33 | <>
34 | dispatch(showSignupModal())} className="nav-auth-button">Sign Up
35 | dispatch(showLoginModal())} className="nav-auth-button">Login
36 | >}
37 |
38 |
39 | );
40 | }
--------------------------------------------------------------------------------
/frontend/src/components/Navigation/Navigation.css:
--------------------------------------------------------------------------------
1 | .nav-bar {
2 | position: fixed;
3 | display: flex;
4 | justify-content: flex-end;
5 | align-items: center;
6 | top: 0;
7 | height: 35px;
8 | width: 100%;
9 | z-index: 3;
10 | }
11 |
12 | .nav-link {
13 | color: black;
14 | height: auto;
15 | width: 75px;
16 | font-size: 15px;
17 | text-decoration: none;
18 | text-align: center;
19 | line-height: 100%;
20 | font-family: Arial, Helvetica, sans-serif;
21 | padding-right: 0;
22 | }
23 |
24 | .nav-link:hover {
25 | font-weight: bold;
26 | }
27 |
28 | .nav-links {
29 | display: flex;
30 | height: 100%;
31 | align-items: center;
32 | margin-right: 10px;
33 | }
34 |
35 | .nav-auth-button {
36 | font-family: Arial, Helvetica, sans-serif;
37 | background-color: white;
38 | width: 75px;
39 | font-size: 15px;
40 | border: none;
41 | color: black;
42 | cursor: pointer;
43 | }
44 |
45 | .nav-auth-button:hover {
46 | font-weight: bold;
47 | }
48 |
49 | .open-menu-button {
50 | cursor: pointer;
51 | background-color: transparent;
52 | border: none;
53 | margin-bottom: 1px;
54 | width: 30px;
55 | height: 30px;
56 | border-radius: 50%;
57 | }
58 |
59 | .open-menu-button:hover {
60 | background-color: rgba(0, 0, 0, 0.1);
61 | }
62 |
63 | .profile-dropdown {
64 | position: absolute;
65 | list-style: none;
66 | background-color: rgb(241, 240, 240);
67 | right: 20px;
68 | top: 50px;
69 | padding: 10px;
70 | border: 1px solid rgba(0, 0, 0, 0.2);
71 | border-radius: 5px;
72 | z-index: 2
73 | }
74 |
75 | .logout-button {
76 | background-color: transparent;
77 | border: none;
78 | color: salmon;
79 | cursor: pointer;
80 | }
81 |
82 | .nav-bar-logo {
83 | height: 100%;
84 | width: 110px;
85 | overflow: clip;
86 | }
87 |
88 | .nav-bar-logo-image {
89 | position: relative;
90 | right: 20px;
91 | bottom: 39px;
92 | height: 150px;
93 | width: 150px;
94 | }
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/ImageViewer/ImageViewer.css:
--------------------------------------------------------------------------------
1 | .image-viewer__container {
2 | position: fixed;
3 | background: rgba(0, 0, 0, 0.9);
4 | width: 100%;
5 | height: 100%;
6 | z-index: 99;
7 | }
8 |
9 | .image-viewer__close-button {
10 | position: absolute;
11 | top: 30px;
12 | left: 75px;
13 | background-color: transparent;
14 | color: white;
15 | border: none;
16 | font-size: 18px;
17 | cursor: pointer;
18 | width: 80px;
19 | height: 30px;
20 | border-radius: 5px;
21 | }
22 |
23 | .image-viewer__close-button:hover {
24 | background-color: rgba(255, 255, 255, 0.4);
25 | }
26 |
27 | .image-viewer__close-span {
28 | font-size: 16px;
29 | margin-left: 10px;
30 | font-weight: normal;
31 | }
32 |
33 | .image-viewer__close-button-container {
34 | display: flex;
35 | align-items: center;
36 | justify-content: center;
37 | }
38 |
39 | .sliding-image__container {
40 | position: absolute;
41 | top: 50%;
42 | left: 50%;
43 | transform: translate(-50%, -50%);
44 | }
45 |
46 | .image-viewer-image {
47 | max-height: 700px;
48 | max-width: 1100px;
49 | }
50 |
51 | .image-viewer__current-position {
52 | position: absolute;
53 | top: 30px;
54 | left: 50%;
55 | transform: translateX(-50%);
56 | color: white;
57 | }
58 |
59 | .image-viewer__next-button {
60 | background-color: transparent;
61 | cursor: pointer;
62 | width: 40px;
63 | height: 40px;
64 | border: 2px solid rgba(255, 255, 255, 0.65);
65 | border-radius: 50%;
66 | color: white;
67 | position: absolute;
68 | top: 50%;
69 | right: 3%;
70 | }
71 |
72 | .image-viewer__next-button:hover {
73 | background-color:rgba(255, 255, 255, 0.2);
74 | }
75 |
76 | .image-viewer__previous-button {
77 | background-color: transparent;
78 | cursor: pointer;
79 | width: 40px;
80 | height: 40px;
81 | border: 2px solid rgba(255, 255, 255, 0.65);
82 | border-radius: 50%;
83 | color: white;
84 | position: absolute;
85 | top: 50%;
86 | left: 3%;
87 | }
88 |
89 | .image-viewer__previous-button:hover {
90 | background-color:rgba(255, 255, 255, 0.2);
91 | }
92 |
--------------------------------------------------------------------------------
/frontend/src/components/SearchBar/SearchBar.css:
--------------------------------------------------------------------------------
1 | .navbar__search {
2 | position: relative;
3 | left: 20px;
4 | display: flex;
5 | }
6 |
7 | .navbar__search-input {
8 | padding: 10px;
9 | border-radius: 50px;
10 | width: 300px;
11 | border: 1px solid lightgray;
12 | box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.2);
13 | transition: ease 0.2s;
14 | }
15 |
16 | .navbar__search-input:focus {
17 | outline: none;
18 | border: 1px solid salmon;
19 | /* width: 400px;
20 | height: 65px; */
21 | }
22 |
23 | /* .navbar__search-input:focus ~ .homepage__search-submit,
24 | .navbar__search-input:not(:focus):valid ~ .homepage__search-submit {
25 | transform: translateY(-20px);
26 | } */
27 |
28 | .searchbar__dropdown {
29 | width: 500px;
30 | border-radius: 30px;
31 | position: fixed;
32 | top: 85px;
33 | left: 50%;
34 | overflow: clip;
35 | transform: translateX(-50%);
36 | background-color: white;
37 | z-index: 11;
38 | }
39 |
40 |
41 | .homepage__dropdown {
42 | position: relative;
43 | margin-top: 20px;
44 | width: 95%;
45 | margin-left: 3%;
46 | border-radius: 40px;
47 | background-color: white;
48 | }
49 |
50 | .searchbar__results {
51 | position: relative;
52 | width: 100%;
53 | list-style: none;
54 | }
55 |
56 | .searchbar__result {
57 | position: relative;
58 | cursor: pointer;
59 | width: 100%;
60 | }
61 |
62 | .searchbar__result-text {
63 | position: relative;
64 | display: flex;
65 | width: 100%;
66 | align-items: center;
67 | height: 60px;
68 | padding: 20px;
69 | }
70 |
71 | .searchbar__result-text:hover {
72 | background-color: rgba(0, 0, 0, 0.05);
73 | }
74 |
75 | .searchbar__map-marker-wrapper {
76 | font-size: 22px;
77 | margin-right: 15px;
78 | }
79 |
80 | @media screen and (max-height: 800px) {
81 | .searchbar__result-text {
82 | height: 50px;
83 | }
84 | }
85 |
86 | @media screen and (min-height: 801px) and (max-height: 999) {
87 | .home-search-logo {
88 | bottom: 38%;
89 | left: 34%;
90 | }
91 | }
92 |
93 | @media screen and (min-height: 1000px) {
94 | .home-search-logo {
95 | bottom: 40%;
96 | left: 36%;
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/ConfirmBooking/ConfirmBooking.css:
--------------------------------------------------------------------------------
1 | .confirm-booking__container {
2 | position: fixed;
3 | width: 100%;
4 | height: 100%;
5 | background-color: rgba(0, 0, 0, 0.8);
6 | z-index: 99;
7 | }
8 |
9 | .confirm-booking__content {
10 | position: relative;
11 | top: 50%;
12 | left: 50%;
13 | width: 500px;
14 | border-radius: 20px;
15 | transform: translate(-50%, -50%);
16 | background-color: white;
17 | padding: 15px;
18 | }
19 |
20 | .confirm-booking__header {
21 | padding-top: 5px;
22 | display: flex;
23 | align-items: center;
24 | justify-content: center;
25 | border-bottom: 1px solid rgba(0, 0, 0, 0.2);
26 | padding-bottom: 20px;
27 | }
28 |
29 | .confirm-booking__close-button {
30 | position: absolute;
31 | background-color: transparent;
32 | left: 20px;
33 | top: 12px;
34 | border: none;
35 | cursor: pointer;
36 | width: 40px;
37 | height: 40px;
38 | border-radius: 50%;
39 | }
40 |
41 | .confirm-booking__close-button:hover {
42 | background-color: rgba(0, 0, 0, 0.1);
43 | }
44 |
45 | .confirm-booking__title {
46 | font-size: 20px;
47 | font-weight: bold;
48 | }
49 |
50 | .confirm-booking__body {
51 | display: flex;
52 | flex-direction: column;
53 | align-items: center;
54 | }
55 |
56 | .confirm-booking__listing-name {
57 | margin-top: 10px;
58 | font-weight: bold;
59 | }
60 |
61 | .confirm-booking__listing-image {
62 | height: 200px;
63 | width: 300px;
64 | border-radius: 20px;
65 | margin-top: 10px;
66 | }
67 |
68 | .confirm-booking__submit {
69 | margin-top: 10px;
70 | cursor: pointer;
71 | width: 300px;
72 | height: 50px;
73 | background-color: salmon;
74 | border: none;
75 | color: white;
76 | border-radius: 10px;
77 | font-weight: bold;
78 | font-size: 17px;
79 | }
80 |
81 | .confirm-booking__booking-dates {
82 | margin-top: 10px;
83 | color: rgba(0, 0, 0, 0.7);
84 | }
85 |
86 | .confirm-booking__days-hours {
87 | margin-top: 10px;
88 | font-size: 17px;
89 | }
90 |
91 | .confirm-booking__total {
92 | margin-top: 5px;
93 | display: flex;
94 | justify-content: space-between;
95 | font-weight: bold;
96 | font-size: 18px;
97 | }
98 |
99 |
100 |
--------------------------------------------------------------------------------
/frontend/src/store/booking.js:
--------------------------------------------------------------------------------
1 | const SET_BOOKING_HOURS = 'booking/SET_BOOKING_HOURS';
2 | const SET_BOOKING_DAYS = 'booking/SET_BOOKING_DAYS';
3 | const SET_BOOKING_TOTAL = 'booking/SET_BOOKING_TOTAL';
4 | const SET_BOOKING_START = 'booking/SET_BOOKING_START';
5 | const SET_BOOKING_END = 'booking/SET_BOOKING_END';
6 | const SET_BOOKING_ALL = 'booking/SET_BOOKING_ALL';
7 | const SET_BOOKING_ID = 'booking/SET_BOOKING_ID';
8 |
9 | export const setBookingInfo = (payload) => {
10 | return {
11 | type: SET_BOOKING_ALL,
12 | payload,
13 | };
14 | };
15 |
16 | export const setBookingHours = (payload) => {
17 | return {
18 | type: SET_BOOKING_HOURS,
19 | payload,
20 | };
21 | };
22 |
23 | export const setBookingDays = (payload) => {
24 | return {
25 | type: SET_BOOKING_DAYS,
26 | payload,
27 | };
28 | };
29 |
30 | export const setBookingTotal = (payload) => {
31 | return {
32 | type: SET_BOOKING_TOTAL,
33 | payload,
34 | };
35 | };
36 |
37 | export const setBookingStart = (payload) => {
38 | return {
39 | type: SET_BOOKING_START,
40 | payload,
41 | };
42 | };
43 |
44 | export const setBookingEnd = (payload) => {
45 | return {
46 | type: SET_BOOKING_END,
47 | payload,
48 | };
49 | };
50 |
51 | export const setBookingId = (payload) => {
52 | return {
53 | type: SET_BOOKING_ID,
54 | payload,
55 | };
56 | };
57 |
58 | const initialState = {
59 | id: null,
60 | hours: null,
61 | days: null,
62 | total: null,
63 | start: null,
64 | end: null,
65 | };
66 |
67 | export default function bookingReducer(state=initialState, action) {
68 | switch(action.type) {
69 | case SET_BOOKING_ALL:
70 | return { ...action.payload };
71 | case SET_BOOKING_HOURS:
72 | return { ...state, hours: action.payload };
73 | case SET_BOOKING_DAYS:
74 | return { ...state, days: action.payload };
75 | case SET_BOOKING_TOTAL:
76 | return { ...state, total: action.payload };
77 | case SET_BOOKING_START:
78 | return { ...state, start: action.payload };
79 | case SET_BOOKING_END:
80 | return { ...state, end: action.payload };
81 | case SET_BOOKING_ID:
82 | return { ...state, id: action.payload };
83 | default:
84 | return state;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/ImageViewer/index.js:
--------------------------------------------------------------------------------
1 | import { useDispatch, useSelector } from 'react-redux';
2 |
3 | import { closeImageViewer, setCurrentImage } from '../../../store/imageViewer';
4 | import ImageViewerImage from './ImageViewerImage';
5 | import "./ImageViewer.css";
6 |
7 | export default function ImageViewer({ images, show }) {
8 | const currentImage = useSelector(state => state.imageViewer.currentImage);
9 | const dispatch = useDispatch();
10 |
11 | let currentIndex = images.reduce((accum, curr, i) => curr === currentImage ? i : accum, 0);
12 |
13 | if(!show) {
14 | return null;
15 | }
16 |
17 | return (
18 |
21 |
24 |
25 |
26 |
dispatch(closeImageViewer())}
29 | >
30 |
31 |
32 |
33 | Close
34 |
35 |
36 |
37 |
38 | {currentIndex !== images.length - 1 &&
39 |
{
42 | currentIndex += 1;
43 | dispatch(setCurrentImage(images[currentIndex]))
44 | }}
45 | >
46 |
47 |
48 |
49 | }
50 | {currentIndex !== 0 &&
{
53 | currentIndex -= 1;
54 | dispatch(setCurrentImage(images[currentIndex]))
55 | }}
56 | >
57 |
58 |
59 |
60 | }
61 |
62 | {`${currentIndex + 1} / ${images.length}`}
63 |
64 |
65 | );
66 | }
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/ReviewSection/ReviewSection.css:
--------------------------------------------------------------------------------
1 | .review-section__container {
2 | position: relative;
3 | margin-top: 35px;
4 | }
5 |
6 | .review-section__header-title {
7 | display: flex;
8 | align-items: center;
9 | font-size: 21px;
10 | font-weight: bold;
11 | }
12 |
13 | .review-section__header-title-star {
14 | font-size: 20px;
15 | color: salmon;
16 | margin-right: 10px;
17 | }
18 |
19 | .review-section__body {
20 | margin-top: 30px;
21 | display: flex;
22 | flex-wrap: wrap;
23 | }
24 |
25 | .review-section__review {
26 | width: 400px;
27 | }
28 |
29 | .review-header {
30 | margin-bottom: 15px;
31 | }
32 |
33 | .review-content {
34 | padding-left: 10px;
35 | }
36 |
37 | .review-username {
38 | font-size: 17px;
39 | font-weight: bold;
40 | margin-bottom: 5px;
41 | }
42 |
43 | .review-date {
44 | font-size: 16px;
45 | color: rgba(0, 0, 0, 0.6);
46 | }
47 |
48 | .review-section__body-user-review {
49 | width: 100%;
50 | position: relative;
51 | padding: 15px;
52 | padding-bottom: 40px;
53 | border-bottom: 1px solid rgba(0, 0, 0, 0.2);
54 | }
55 |
56 | .review-user-review__title {
57 | position: relative;
58 | width: 100%;
59 | display: flex;
60 | justify-content: center;
61 | font-size: 18px;
62 | font-weight: bold;
63 | }
64 |
65 | .review-user-review__header {
66 | margin-top: 10px;
67 | margin-bottom: 15px;
68 | }
69 |
70 | .review-user-review-username {
71 | font-size: 17px;
72 | font-weight: bold;
73 | margin-bottom: 5px;
74 | }
75 |
76 | .review-user-review-date {
77 | font-size: 16px;
78 | color: rgba(0, 0, 0, 0.6);
79 | }
80 |
81 | .review-user-review__content {
82 | padding-left: 15px;
83 | }
84 |
85 | .review-user-review__buttons {
86 | display: flex;
87 | justify-content: space-between;
88 | margin-top: 15px;
89 | }
90 |
91 | .review-user-review__edit {
92 | background-color: transparent;
93 | border: none;
94 | color: rgba(100, 148, 237, 0.8);
95 | cursor: pointer;
96 | }
97 |
98 | .review-user-review__edit:hover {
99 | color: cornflowerblue;
100 | }
101 |
102 | .review-user-review__delete {
103 | background-color: transparent;
104 | border: none;
105 | color: rgba(250, 128, 114, 0.8);
106 | cursor: pointer;
107 | }
108 |
109 | .review-user-review__delete:hover {
110 | color: salmon;
111 | }
--------------------------------------------------------------------------------
/frontend/src/store/session.js:
--------------------------------------------------------------------------------
1 | import { csrfFetch } from "./csrf";
2 |
3 | const CREATE_SESSION = 'session/CREATE_SESSION';
4 | const DESTROY_SESSION = 'session/DESTROY_SESSION';
5 |
6 | const initialState = {
7 | user: null
8 | }
9 |
10 | const createSession = (payload) => {
11 | return {
12 | type: CREATE_SESSION,
13 | payload
14 | };
15 | };
16 |
17 | const destroySession = () => {
18 | return {
19 | type: DESTROY_SESSION
20 | }
21 | }
22 |
23 | export const loginUser = (credentials) => async dispatch => {
24 | const res = await csrfFetch('/api/session', {
25 | method: 'POST',
26 | headers: { 'Content-Type': 'application/json' },
27 | body: JSON.stringify({ credential: credentials.credential, password: credentials.password })
28 | });
29 |
30 | if(res.ok) {
31 | const user = await res.json();
32 | dispatch(createSession(user));
33 | return user;
34 | }
35 | };
36 |
37 | export const restoreLogin = () => async dispatch => {
38 | const res = await csrfFetch('/api/session');
39 |
40 | if(res.ok) {
41 | const user = await res.json();
42 | if(user.user !== null) {
43 | dispatch(createSession(user));
44 | }
45 | return user;
46 | }
47 | }
48 |
49 | export const signUp = (userData) => async dispatch => {
50 | const { username, password, email, avatarUrl } = userData;
51 | const res = await csrfFetch('/api/users', {
52 | method: "POST",
53 | headers: { "Content-Type": "application/json" },
54 | body: JSON.stringify({ username, password, email, avatarUrl })
55 | });
56 |
57 | if(res.ok) {
58 | const user = await res.json();
59 | dispatch(createSession(user));
60 | return user;
61 | }
62 | }
63 |
64 | export const logoutUser = () => async dispatch => {
65 | const res = await csrfFetch('/api/session', {
66 | method: 'DELETE',
67 | });
68 |
69 | if(res.ok) {
70 | const json = res.json();
71 | dispatch(destroySession());
72 | return json.message;
73 | }
74 | }
75 |
76 | const sessionReducer = (state = initialState, action) => {
77 | switch(action.type) {
78 | case CREATE_SESSION: {
79 | const newState = { user: { ...action.payload } };
80 | return newState;
81 | }
82 | case DESTROY_SESSION: {
83 | return { user: null };
84 | }
85 | default:
86 | return state;
87 | }
88 | };
89 |
90 | export default sessionReducer;
--------------------------------------------------------------------------------
/frontend/src/App.js:
--------------------------------------------------------------------------------
1 | import { Switch, Route } from "react-router";
2 | import { useState, useEffect } from "react";
3 | import { useDispatch, useSelector } from "react-redux";
4 | import { useLocation } from "react-router-dom";
5 |
6 | import { restoreLogin } from "./store/session";
7 | import { fetchUserBookings } from "./store/userBookings";
8 |
9 | import Navigation from "./components/Navigation";
10 | import HomePage from "./components/HomePage";
11 | import Modal from "./components/AuthModal";
12 | import ListingPage from "./components/ListingPage";
13 | import ListingSearch from "./components/ListingSearch";
14 | import ListingPost from "./components/ListingPost";
15 | import ListingEdit from "./components/ListingEdit";
16 | import SearchOverlay from "./components/SearchOverlay";
17 | import Footer from "./components/Footer";
18 |
19 | function App() {
20 | const dispatch = useDispatch();
21 | const loginModal = useSelector(state => state.loginModal.showModal);
22 | const signupModal = useSelector(state => state.signupModal.showModal);
23 |
24 | const [isLoaded, setIsLoaded] = useState(false);
25 |
26 | const location = useLocation()
27 |
28 | useEffect(() => {
29 | async function tryRestoreSession() {
30 | async function tryRestoreLogin() {
31 | try {
32 | const user = await dispatch(restoreLogin());
33 | if(user.user) {
34 | setIsLoaded(true);
35 | return user.user
36 | }
37 | } catch (e) {
38 |
39 | }
40 | }
41 | const user = await tryRestoreLogin();
42 |
43 | if(isLoaded) {
44 | try {
45 | await dispatch(fetchUserBookings(user));
46 | } catch(err) {
47 | }
48 | }
49 | }
50 |
51 | tryRestoreSession();
52 | })
53 |
54 | return (
55 | <>
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | >
79 | );
80 | }
81 |
82 | export default App;
83 |
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/DeleteListing/DeleteListing.css:
--------------------------------------------------------------------------------
1 | .delete-listing__container {
2 | position: fixed;
3 | width: 100%;
4 | height: 100%;
5 | z-index: 10000;
6 | background: rgba(0, 0, 0, 0.8);
7 | }
8 |
9 | .delete-listing__content {
10 | z-index: 99;
11 | position: relative;
12 | background-color: white;
13 | width: 500px;
14 | height: 600px;
15 | top: 50%;
16 | left: 50%;
17 | transform: translate(-50%, -50%);
18 | border-radius: 30px;
19 | }
20 |
21 | .delete-listing__button {
22 | position: absolute;
23 | bottom: 20px;
24 | left: 50%;
25 | transform: translateX(-50%);
26 | background-color: salmon;
27 | border: none;
28 | color: white;
29 | font-weight: bold;
30 | width: 200px;
31 | height: 60px;
32 | border-radius: 20px;
33 | cursor: pointer;
34 | }
35 |
36 | .delete-listing__button:hover {
37 | background-color: rgb(248, 120, 106)
38 | }
39 |
40 | .delete-listing__header {
41 | display: flex;
42 | justify-content: space-between;
43 | align-items: center;
44 | padding: 20px;
45 | border-bottom: 1px solid rgba(0, 0, 0, 0.2);
46 | }
47 |
48 | .delete-listing__header-text {
49 | font-size: 18px;
50 | font-weight: bold;
51 | margin-right: 85px;
52 | }
53 |
54 | .delete-listing__close {
55 | background-color: transparent;
56 | color: rgba(0, 0, 0, 0.8);
57 | width: 35px;
58 | height: 35px;
59 | border-radius: 50%;
60 | border: none;
61 | cursor: pointer;
62 | }
63 |
64 | .delete-listing__close:hover {
65 | background-color: rgba(0, 0, 0, 0.1);
66 | }
67 |
68 | .delete-listing__body-image {
69 | width: 300px;
70 | height: 300px;
71 | border-radius: 20px;
72 | }
73 |
74 | .delete-listing__body {
75 | position: relative;
76 | }
77 |
78 | .delete-listing__body-info {
79 | position: relative;
80 | margin-top: 20px;
81 | left: 50%;
82 | transform: translateX(-30%);
83 | }
84 |
85 | .delete-listing__body-name {
86 | display: flex;
87 | justify-content: center;
88 | margin-right: 200px;
89 | margin-bottom: 10px;
90 | font-weight: bold;
91 | }
92 |
93 | .delete-listing__body-price {
94 | margin-top: 5px;
95 | color:rgba(0, 0, 0, 0.7);
96 | }
97 |
98 | .delete-listing__confirmation-container {
99 | display: flex;
100 | justify-content: center;
101 | }
102 |
103 | .delete-listing__confirmation {
104 | font-weight: bold;
105 | margin-left: 25px;
106 | margin-top: 20px;
107 | width: 300px;
108 | }
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/DeleteListing/index.js:
--------------------------------------------------------------------------------
1 | import { useHistory } from 'react-router-dom';
2 | import { useDispatch } from 'react-redux';
3 |
4 | import { closeDeleteModal } from '../../../store/modals';
5 | import { csrfFetch } from '../../../store/csrf';
6 | import './DeleteListing.css';
7 |
8 | export default function DeleteListing({ show, coverImageUrl, name, price, listingId }) {
9 | const dispatch = useDispatch();
10 | const history = useHistory();
11 |
12 | const deleteListing = async () => {
13 | const res = await csrfFetch(`/api/listings/${listingId}`, {
14 | method: 'DELETE',
15 | });
16 |
17 | if(res.ok) {
18 | dispatch(closeDeleteModal());
19 | history.push("/listings")
20 | }
21 | }
22 |
23 | if(!show) {
24 | return null;
25 | }
26 |
27 | return (
28 |
29 |
30 |
31 |
dispatch(closeDeleteModal())}
34 | >
35 |
36 |
37 |
38 | Confirm deletion of this listing
39 |
40 |
41 |
42 |
43 |
44 | {name}
45 |
46 |
47 |
48 | {`$${price} / hour`}
49 |
50 |
51 |
52 |
53 | Are you sure you want to delete this listing? This cannot be reversed.
54 |
55 |
56 |
57 |
61 | Delete Listing
62 |
63 |
64 |
65 | );
66 | }
--------------------------------------------------------------------------------
/frontend/src/store/reviews.js:
--------------------------------------------------------------------------------
1 | import { csrfFetch } from "./csrf";
2 |
3 | const REVIEWS_SET_REVIEWS = '/reviews/REVIEWS_SET_REVIEWS';
4 | const REVIEWS_REMOVE_REVIEW = '/reviews/REVIEWS_REMOVE_REVIEW';
5 | const REVIEWS_ADD_REVIEW = '/reviews/REVIEWS_ADD_REVIEW';
6 | const REVIEWS_EDIT_REVIEW = '/reviews/REVIEWS_EDIT_REVIEW';
7 |
8 | export const setReviews = (payload) => {
9 | return {
10 | type: REVIEWS_SET_REVIEWS,
11 | payload,
12 | };
13 | };
14 |
15 | const addReview = (payload) => {
16 | return {
17 | type: REVIEWS_ADD_REVIEW,
18 | payload,
19 | };
20 | };
21 |
22 | const removeReview = (payload) => {
23 | return {
24 | type: REVIEWS_REMOVE_REVIEW,
25 | payload,
26 | };
27 | };
28 |
29 | // const updateReview = (payload) => {
30 | // return {
31 | // type: REVIEWS_EDIT_REVIEW,
32 | // payload,
33 | // };
34 | // };
35 |
36 | export const createReview = (payload) => async dispatch => {
37 | const { bookingId, numStars, content } = payload;
38 |
39 | const res = await csrfFetch('/api/reviews', {
40 | method: 'POST',
41 | headers: { 'Content-Type': 'application/json' },
42 | body: JSON.stringify({
43 | bookingId,
44 | numStars,
45 | content,
46 | }),
47 | });
48 |
49 | if(res.ok) {
50 | const review = await res.json();
51 | dispatch(addReview(review));
52 | } else {
53 |
54 | }
55 | };
56 |
57 | export const editReview = (payload) => async dispatch => {
58 |
59 | };
60 |
61 | export const deleteReview = (payload) => async dispatch => {
62 | const res = await csrfFetch(`/api/reviews/${payload}`, {
63 | method: 'DELETE',
64 | });
65 |
66 | if(res.ok) {
67 | dispatch(removeReview(payload));
68 | }
69 | };
70 |
71 | const initialState = [
72 |
73 | ];
74 |
75 | const reviewsReducer = (state=initialState, action) => {
76 | switch (action.type) {
77 | case REVIEWS_SET_REVIEWS: {
78 | return [ ...action.payload ];
79 | }
80 | case REVIEWS_ADD_REVIEW: {
81 | return [ ...state, action.payload ];
82 | }
83 | case REVIEWS_EDIT_REVIEW: {
84 | const newState = [ ...state ];
85 | const updateIndex = newState.findIndex(review => review.id === action.payload.id);
86 | newState[updateIndex] = action.payload;
87 | return newState;
88 | }
89 | case REVIEWS_REMOVE_REVIEW: {
90 | const newState = [ ...state ];
91 | delete newState[newState.findIndex(review => review.id === action.payload)];
92 | return newState;
93 | }
94 | default:
95 | return state;
96 | }
97 | };
98 |
99 | export default reviewsReducer;
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/ReviewCreate/ReviewCreate.css:
--------------------------------------------------------------------------------
1 | .review-create__container {
2 | margin-top: 55px;
3 | font-weight: bold;
4 | }
5 |
6 | .review-create__form {
7 | width: 500px;
8 | display: flex;
9 | flex-direction: column;
10 | align-items: center;
11 | }
12 |
13 | .review-create__form-clear {
14 | float: none;
15 | clear: both;
16 | }
17 |
18 | .hide {
19 | display: none;
20 | }
21 |
22 | .review-create__form-rating {
23 | width: 300px;
24 | right: 200px;
25 | margin-top: 5px;
26 | font-weight: normal;
27 | unicode-bidi: bidi-override;
28 | direction: rtl;
29 | align-self: center;
30 | text-align: center;
31 | position: relative;
32 | }
33 |
34 | .review-create__form-rating > label {
35 | float: right;
36 | display: inline;
37 | padding: 0;
38 | margin: 0;
39 | font-size: 36px;
40 | position: relative;
41 | width: 1.1em;
42 | cursor: pointer;
43 | color: black;
44 | }
45 |
46 | .review-create__form-rating > label:hover,
47 | .review-create__form-rating > label:hover ~ label,
48 | .review-create__form-rating > input.radio-btn:checked ~ label {
49 | color: transparent;
50 | }
51 |
52 | .review-create__form-rating > label:hover:before,
53 | .review-create__form-rating > label:hover ~ label:before,
54 | .review-create__form-rating > input.radio-btn:checked ~ label:before,
55 | .review-create__form-rating > input.radio-btn:checked ~ label:before {
56 | content: "\2605";
57 | position: absolute;
58 | left: 0;
59 | color: #FFD700;
60 | }
61 |
62 | .review-create__form-input {
63 | position: relative;
64 | border-radius: 10px;
65 | left: 50px;
66 | width: 625px;
67 | height: 200px;
68 | resize: none;
69 | padding: 20px;
70 | font-size: 20px;
71 | margin-top: 10px;
72 | }
73 |
74 | .review-create__form-input:focus {
75 | border: 2px solid cornflowerblue;
76 | outline: none;
77 | }
78 |
79 | .review-create__submit-button {
80 | background-color: cornflowerblue;
81 | border: none;
82 | color: white;
83 | cursor: pointer;
84 | width: 100px;
85 | height: 35px;
86 | position: relative;
87 | margin-top: 10px;
88 | border-radius: 5px;
89 | }
90 |
91 | .review-create__buttons-container {
92 | display: flex;
93 | position: relative;
94 | left: 50px;
95 | width: 625px;
96 | justify-content: space-between;
97 | }
98 |
99 | .review-create__clear-text {
100 | background-color: cornflowerblue;
101 | border: none;
102 | color: white;
103 | cursor: pointer;
104 | width: 100px;
105 | height: 35px;
106 | position: relative;
107 | margin-top: 10px;
108 | border-radius: 5px;
109 | }
--------------------------------------------------------------------------------
/frontend/src/components/ListingSearch/ListingSearch.css:
--------------------------------------------------------------------------------
1 | .listing-search__container {
2 | position: absolute;
3 | display: flex;
4 | flex-direction: column;
5 | align-items: center;
6 | height: 100%;
7 | width: 100%;
8 | top: 75px;
9 | padding: 40px;
10 | padding-left: 55px;
11 | border-top: 1px solid rgba(211, 211, 211, 0.205);
12 | box-shadow: inset 0px 0px 60px rgba(0, 0, 0, 0.07);
13 | overflow: auto;
14 | }
15 |
16 | .listing-search__listings-container {
17 | display: flex;
18 | flex-wrap: wrap;
19 | margin-bottom: 100px;
20 | }
21 |
22 | .listing-search__listing-box {
23 | display: flex;
24 | flex-direction: column;
25 | margin: 10px;
26 | }
27 |
28 | .listing-search__listing-image-wrapper {
29 | position: relative;
30 | }
31 |
32 | .listing-search__listing-image-link {
33 | position: relative;
34 | z-index: 1;
35 | text-decoration: none;
36 | color: black;
37 | }
38 |
39 | .listing-search__listing-image {
40 | position: relative;
41 | width: 100%;
42 | height: 100%;
43 | border-radius: 15px;
44 | }
45 |
46 | .listing-search__listing-name-price {
47 | display: flex;
48 | justify-content: space-between;
49 | }
50 |
51 | .listing-search__listing-name {
52 | font-size: 17px;
53 | font-weight: bold;
54 | }
55 |
56 | .listing-search__listing-general-location {
57 | color: rgba(0, 0, 0, 0.6);
58 | }
59 |
60 | .listing-search__listing-image-wrapper {
61 | position: relative;
62 | width: 300px;
63 | height: 300px;
64 | margin-bottom: 5px;
65 | }
66 |
67 | .listing-search__listing-image-wrapper:after {
68 | content: '\A';
69 | position: absolute;
70 | width: 100%;
71 | height: 100%;
72 | top: 0;
73 | left: 0;
74 | border-radius: 15px;
75 | background: rgba(0, 0, 0, 0.2);
76 | opacity: 0;
77 | transition: all 0.25s;
78 | -webkit-transition: all 0.25s;
79 | }
80 |
81 | .listing-search__listing-image-wrapper:hover:after {
82 | opacity: 1;
83 | }
84 |
85 | .listing-search__top-bar {
86 | display: flex;
87 | justify-content: flex-end;
88 | margin-bottom: 15px;
89 | }
90 |
91 | /* .listing-search__create-listing {
92 | margin-right: 50px;
93 | } */
94 |
95 | .listing-search__create-listing-button-label {
96 | font-size: 14px;
97 | margin-right: 5px;
98 | }
99 |
100 | .listing-search__create-listing-button {
101 | text-decoration: none;
102 | font-weight: bold;
103 | color: cornflowerblue;
104 | font-size: 13px;
105 | }
106 |
107 | .listing-search__heart-container {
108 | position: absolute;
109 | color: white;
110 | top: 20px;
111 | right: 20px;
112 | font-size: 24px;
113 | z-index: -1;
114 | }
115 |
116 | .listing-search__listing-image-wrapper:hover > .listing-search__heart-container {
117 | z-index: 1
118 | }
--------------------------------------------------------------------------------
/backend/db/models/user.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const { Validator } = require("sequelize");
3 | const bcrypt = require("bcryptjs");
4 |
5 | module.exports = (sequelize, DataTypes) => {
6 | const User = sequelize.define('User', {
7 | username: {
8 | type: DataTypes.STRING,
9 | allowNull: false,
10 | validate: {
11 | len: [4, 30],
12 | isNotEmail(value) {
13 | if(Validator.isEmail(value)) {
14 | throw new Error('Cannot be an email.');
15 | }
16 | },
17 | },
18 | },
19 | email: {
20 | type: DataTypes.STRING,
21 | allowNull: false,
22 | validate: {
23 | len: [3, 256]
24 | },
25 | },
26 | hashedPassword: {
27 | type: DataTypes.STRING,
28 | allowNull: false,
29 | validate: {
30 | len: [60, 60]
31 | },
32 | },
33 | avatarUrl: {
34 | type: DataTypes.STRING,
35 | },
36 | }, {
37 | defaultScope: {
38 | attributes: {
39 | exclude: ['hashedPassword', 'email', 'createdAt', 'updatedAt']
40 | },
41 | },
42 | scopes: {
43 | currentUser: {
44 | attributes: { exclude: ['hashedPassword'] },
45 | },
46 | loginUser: {
47 | attributes: {},
48 | },
49 | },
50 | });
51 |
52 | User.associate = function(models) {
53 | // associations can be defined here
54 | User.hasMany(models.Listing, { foreignKey: 'userId', onDelete: 'CASCADE', hooks: true});
55 | User.hasMany(models.Booking, { foreignKey: 'userId', onDelete: 'CASCADE', hooks: true });
56 | User.hasMany(models.Review, { foreignKey: "userId", onDelete: 'CASCADE', hooks: true});
57 | };
58 |
59 | User.prototype.toSafeObject = function () {
60 | const { id, username, email, avatarUrl } = this;
61 | return { id, username, email, avatarUrl };
62 | };
63 |
64 | User.prototype.validatePassword = function (password) {
65 | return bcrypt.compareSync(password, this.hashedPassword.toString());
66 | };
67 |
68 | User.getCurrentUserById = async (id) => {
69 | return await User.scope('currentUser').findByPk(id);
70 | };
71 |
72 | User.login = async ({ credential, password }) => {
73 | const { Op } = require("sequelize");
74 | const user = await User.scope("loginUser").findOne({
75 | where: {
76 | [Op.or]: {
77 | username: credential,
78 | email: credential
79 | }
80 | }
81 | });
82 | if(user && user.validatePassword(password)) {
83 | return await User.scope('currentUser').findByPk(user.id);
84 | }
85 | };
86 |
87 | User.signup = async ({ username, email, password, avatarUrl }) => {
88 | const hashedPassword = bcrypt.hashSync(password);
89 | const user = await User.create({
90 | username,
91 | email,
92 | hashedPassword,
93 | avatarUrl
94 | });
95 |
96 | return await User.scope('currentUser').findByPk(user.id);
97 | };
98 |
99 | return User;
100 | };
--------------------------------------------------------------------------------
/frontend/src/components/AuthModal/AuthForms/LoginForm.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { loginUser } from '../../../store/session';
3 | import { useDispatch } from 'react-redux';
4 |
5 | import './AuthForm.css';
6 | import FormErrors from '../../FormErrors';
7 | import { closeLoginModal } from '../../../store/modals';
8 |
9 |
10 | export default function LoginForm() {
11 | const [credential, setCredential] = useState('');
12 | const [password, setPassword] = useState('');
13 | const [validationErrors, setValidationErrors] = useState([]);
14 |
15 | const dispatch = useDispatch();
16 |
17 |
18 | const handleSubmit = async (e) => {
19 | e.preventDefault();
20 |
21 | if(credential.length && password.length) {
22 |
23 | try {
24 | await dispatch(loginUser({credential, password}));
25 | dispatch(closeLoginModal());
26 | } catch (e) {
27 | const err = await e.json();
28 | const errors = err.errors;
29 | setValidationErrors(errors);
30 | }
31 | }
32 | };
33 |
34 | return (
35 |
84 | );
85 | }
--------------------------------------------------------------------------------
/backend/awsS3.js:
--------------------------------------------------------------------------------
1 | const AWS = require("aws-sdk");
2 | // name of your bucket here
3 | const NAME_OF_BUCKET = "work-in-app";
4 |
5 | const multer = require("multer");
6 |
7 | // make sure to set environment variables in production for:
8 | // AWS_ACCESS_KEY_ID
9 | // AWS_SECRET_ACCESS_KEY
10 | // and aws will automatically use those environment variables
11 |
12 | const s3 = new AWS.S3({ apiVersion: "2006-03-01" });
13 |
14 | // --------------------------- Public UPLOAD ------------------------
15 |
16 | const singlePublicFileUpload = async (file) => {
17 | const { originalname, mimetype, buffer } = await file;
18 | const path = require("path");
19 | // name of the file in your S3 bucket will be the date in ms plus the extension name
20 | const Key = new Date().getTime().toString() + path.extname(originalname);
21 | const uploadParams = {
22 | Bucket: NAME_OF_BUCKET,
23 | Key,
24 | Body: buffer,
25 | ACL: "public-read",
26 | };
27 | const result = await s3.upload(uploadParams).promise();
28 |
29 | // save the name of the file in your bucket as the key in your database to retrieve for later
30 | return result.Location;
31 | };
32 |
33 | const multiplePublicFileUpload = async (files) => {
34 | return await Promise.all(
35 | files.map((file) => {
36 | return singlePublicFileUpload(file);
37 | })
38 | );
39 | };
40 |
41 | // --------------------------- Prviate UPLOAD ------------------------
42 |
43 | const singlePrivateFileUpload = async (file) => {
44 | const { originalname, mimetype, buffer } = await file;
45 | const path = require("path");
46 | // name of the file in your S3 bucket will be the date in ms plus the extension name
47 | const Key = new Date().getTime().toString() + path.extname(originalname);
48 | const uploadParams = {
49 | Bucket: NAME_OF_BUCKET,
50 | Key,
51 | Body: buffer,
52 | };
53 | const result = await s3.upload(uploadParams).promise();
54 |
55 | // save the name of the file in your bucket as the key in your database to retrieve for later
56 | return result.Key;
57 | };
58 |
59 | const multiplePrivateFileUpload = async (files) => {
60 | return await Promise.all(
61 | files.map((file) => {
62 | return singlePrivateFileUpload(file);
63 | })
64 | );
65 | };
66 |
67 | const retrievePrivateFile = (key) => {
68 | let fileUrl;
69 | if (key) {
70 | fileUrl = s3.getSignedUrl("getObject", {
71 | Bucket: NAME_OF_BUCKET,
72 | Key: key,
73 | });
74 | }
75 | return fileUrl || key;
76 | };
77 |
78 | // --------------------------- Storage ------------------------
79 |
80 | const storage = multer.memoryStorage({
81 | destination: function (req, file, callback) {
82 | callback(null, "");
83 | },
84 | });
85 |
86 | const singleMulterUpload = (nameOfKey) =>
87 | multer({ storage: storage }).single(nameOfKey);
88 | const multipleMulterUpload = (nameOfKey) =>
89 | multer({ storage: storage }).array(nameOfKey);
90 |
91 | module.exports = {
92 | s3,
93 | singlePublicFileUpload,
94 | multiplePublicFileUpload,
95 | singlePrivateFileUpload,
96 | multiplePrivateFileUpload,
97 | retrievePrivateFile,
98 | singleMulterUpload,
99 | multipleMulterUpload,
100 | };
--------------------------------------------------------------------------------
/frontend/src/components/ListingSearch/index.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { NavLink } from 'react-router-dom';
3 | import { useLocation } from 'react-router';
4 |
5 | import { csrfFetch } from '../../store/csrf';
6 |
7 | import './ListingSearch.css';
8 |
9 | const useQuery = () => {
10 | return new URLSearchParams(useLocation().search);
11 | };
12 |
13 | export default function ListingSearch() {
14 | const [listings, setListings] = useState([]);
15 |
16 | const location = useLocation();
17 | let query = useQuery();
18 |
19 | useEffect(() => {
20 | (async function() {
21 | if(query.get("search")) {
22 | const queryString = encodeURIComponent(query.get("search"));
23 | const res = await csrfFetch(`/api/search/${queryString}`);
24 |
25 | if(res.ok) {
26 | const newListings = await res.json();
27 | setListings(newListings);
28 | }
29 | } else {
30 | const res = await csrfFetch('/api/listings');
31 |
32 | if(res.ok) {
33 | const newListings = await res.json();
34 | setListings(newListings);
35 | }
36 | }
37 |
38 | })();
39 | }, [location]);
40 |
41 | return (
42 |
45 |
46 |
47 | Have a Workspace of your own?
48 | Host a Listing
49 |
50 |
51 |
52 | {listings.map(listing => (
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | {listing.name}
65 |
66 |
67 | {`$${listing.price} / hour`}
68 |
69 |
70 |
71 | {listing.city}, {listing.state}
72 |
73 |
74 |
75 |
76 | ))}
77 |
78 |
79 | );
80 | }
--------------------------------------------------------------------------------
/frontend/src/components/AuthModal/index.js:
--------------------------------------------------------------------------------
1 | import { useDispatch } from 'react-redux';
2 |
3 | import { closeLoginModal, closeSignupModal, showLoginModal, showSignupModal } from '../../store/modals';
4 | import { loginUser } from '../../store/session';
5 | import LoginForm from './AuthForms/LoginForm';
6 | import './Modal.css'
7 | import SignupFormPage from './AuthForms/SignupForm';
8 |
9 | import logo from '../../assets/images/work-in-logo.png';
10 |
11 | export default function Modal ({show, authType}) {
12 | const dispatch = useDispatch();
13 |
14 | if(!show) {
15 | return null;
16 | }
17 |
18 | return (
19 |
20 |
21 |
22 | dispatch(closeLoginModal()) : () => dispatch(closeSignupModal())}>X
23 |
24 | {authType === 'login' ?
25 | 'Welcome back.'
26 | :
27 | 'Welcome to Work-In.'}
28 |
29 |
30 |
31 |
32 | {authType === 'login' ?
33 |
34 | :
35 |
}
36 |
37 | {authType === 'login' ?
38 | 'First time on Work-In?'
39 | :
40 | 'Have an account on Work-In already?'}
41 | {
45 | dispatch(closeLoginModal());
46 | dispatch(showSignupModal());
47 | }
48 | :
49 | () => {
50 | dispatch(closeSignupModal());
51 | dispatch(showLoginModal());
52 | }}
53 | >
54 | {authType === 'login' ?
55 | 'Create an account'
56 | :
57 | 'Login'}
58 |
59 |
60 | or
61 | {
64 | const user = dispatch(loginUser({credential: "Demo Host", password: "password" }));
65 | if(user) {
66 | if(authType === 'login') dispatch(closeLoginModal());
67 | if(authType === 'signup') dispatch(closeSignupModal());
68 | }
69 | }}
70 | >
71 | Login as Demo user
72 |
73 |
74 |
75 |
76 |
77 |
78 | );
79 | }
--------------------------------------------------------------------------------
/frontend/src/store/modals.js:
--------------------------------------------------------------------------------
1 | const SHOW_LOGIN_MODAL = '/modals/SHOW_LOGIN_MODAL';
2 | const CLOSE_LOGIN_MODAL = '/modals/CLOSE_LOGIN_MODAL';
3 | const SHOW_SIGNUP_MODAL = '/modals/SHOW_SIGNUP_MODAL';
4 | const CLOSE_SIGNUP_MODAL = '/modals/CLOSE_SIGNUP_MODAL';
5 | const SHOW_LISTING_DELETE_MODAL = '/modals/SHOW_LISTING_DELETE_MODAL';
6 | const CLOSE_LISTING_DELETE_MODAL = '/modals/CLOSE_LISTING_DELETE_MODAL';
7 | const SHOW_CREATE_BOOKING_MODAL = '/modals/SHOW_CREATE_BOOKING_MODAL';
8 | const CLOSE_CREATE_BOOKING_MODAL = '/modals/CLOSE_CREATE_BOOKING_MODAL';
9 | const BOOKING_MODAL_POST = '/modals/BOOKING_MODAL_POST';
10 | const BOOKING_MODAL_DELETE = '/modals/BOOKING_MODAL_DELETE';
11 |
12 | export const showLoginModal = () => {
13 | return {
14 | type: SHOW_LOGIN_MODAL
15 | };
16 | };
17 |
18 | export const closeLoginModal = () => {
19 | return {
20 | type: CLOSE_LOGIN_MODAL
21 | };
22 | };
23 |
24 | export const showSignupModal = () => {
25 | return {
26 | type: SHOW_SIGNUP_MODAL
27 | };
28 | };
29 |
30 | export const closeSignupModal = () => {
31 | return {
32 | type: CLOSE_SIGNUP_MODAL
33 | }
34 | };
35 |
36 | export const showDeleteModal = () => {
37 | return {
38 | type: SHOW_LISTING_DELETE_MODAL
39 | };
40 | };
41 |
42 | export const closeDeleteModal = () => {
43 | return {
44 | type: CLOSE_LISTING_DELETE_MODAL
45 | };
46 | };
47 |
48 | export const showCreateBookingModal = () => {
49 | return {
50 | type: SHOW_CREATE_BOOKING_MODAL
51 | };
52 | };
53 |
54 | export const closeCreateBookingModal = () => {
55 | return {
56 | type: CLOSE_CREATE_BOOKING_MODAL
57 | };
58 | };
59 |
60 | export const setBookingModalPost = () => {
61 | return {
62 | type: BOOKING_MODAL_POST,
63 | };
64 | };
65 |
66 | export const setBookingModalDelete = () => {
67 | return {
68 | type: BOOKING_MODAL_DELETE,
69 | };
70 | };
71 |
72 | const initialState = {
73 | showModal: false
74 | };
75 |
76 | const initialBookingState = {
77 | showModal: false,
78 | context: "post",
79 | };
80 |
81 | export const loginModalReducer = (state=initialState, action) => {
82 | switch (action.type) {
83 | case SHOW_LOGIN_MODAL:
84 | return { showModal: true };
85 | case CLOSE_LOGIN_MODAL:
86 | return { showModal: false };
87 | default:
88 | return state;
89 | }
90 | };
91 |
92 | export const signupModalReducer = (state=initialState, action) => {
93 | switch (action.type) {
94 | case SHOW_SIGNUP_MODAL:
95 | return { showModal: true };
96 | case CLOSE_SIGNUP_MODAL:
97 | return { showModal: false };
98 | default:
99 | return state;
100 | }
101 | };
102 |
103 | export const deleteListingModalReducer = (state=initialState, action) => {
104 | switch(action.type) {
105 | case SHOW_LISTING_DELETE_MODAL:
106 | return { showModal: true };
107 | case CLOSE_LISTING_DELETE_MODAL:
108 | return { showModal: false };
109 | default:
110 | return state;
111 | }
112 | };
113 |
114 | export const createBookingModalReducer = (state=initialBookingState, action) => {
115 | switch(action.type) {
116 | case SHOW_CREATE_BOOKING_MODAL:
117 | return { ...state, showModal: true };
118 | case CLOSE_CREATE_BOOKING_MODAL:
119 | return { ...state, showModal: false };
120 | case BOOKING_MODAL_POST:
121 | return { ...state, context: "post" };
122 | case BOOKING_MODAL_DELETE:
123 | return { ...state, context: "delete" };
124 | default:
125 | return state;
126 | }
127 | };
--------------------------------------------------------------------------------
/backend/routes/api/booking.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const asyncHandler = require("express-async-handler");
3 | const { check } = require("express-validator");
4 |
5 | const { handleValidationErrors } = require("../../utils/validation");
6 | const { requireAuth } = require("../../utils/auth");
7 | const { Booking, Review, User } = require("../../db/models");
8 |
9 | const validateBooking = [
10 | check("startTime")
11 | .exists({ checkFalsy: true })
12 | .withMessage("Start date must be a valid date."),
13 | check("endTime")
14 | .exists({ checkFalsy: true })
15 | .withMessage("End date must be a valid date."),
16 | handleValidationErrors,
17 | ];
18 |
19 | const router = express.Router();
20 |
21 | const logRequest = (req, res, next) => {
22 | next();
23 | }
24 |
25 | router.get('/:id',
26 | requireAuth,
27 | asyncHandler( async (req, res, next) => {
28 | try {
29 | const id = parseInt(req.params.id);
30 | const booking = await Booking.findByPk(id, {
31 | include: [Review, User]
32 | });
33 |
34 | if(booking.userId === req.user.id) {
35 | res.json(booking);
36 | } else {
37 | const err = new Error("You can only access bookings that you own.");
38 | err.status = 403;
39 | next(err);
40 | }
41 | } catch (err) {
42 | next(err);
43 | }
44 | }));
45 |
46 | router.get('/',
47 | requireAuth,
48 | asyncHandler (async (req, res, next) => {
49 | try {
50 | const id = req.user.id;
51 |
52 | const bookings = await Booking.findAll({
53 | where: {
54 | userId: id
55 | },
56 | include: [Review, User]
57 | });
58 |
59 | res.json(bookings);
60 | } catch(err) {
61 | next(err);
62 | }
63 | }));
64 |
65 | router.post('/',
66 | logRequest,
67 | requireAuth,
68 | validateBooking,
69 | asyncHandler( async (req, res, next) => {
70 | try {
71 | const { startTime, endTime, listingId } = req.body;
72 | const userId = req.user.id;
73 | const booking = await Booking.create({
74 | userId,
75 | listingId,
76 | startTime,
77 | endTime
78 | });
79 |
80 | res.json(booking);
81 | } catch(err) {
82 | next(err);
83 | }
84 | }));
85 |
86 | router.delete('/:id',
87 | requireAuth,
88 | asyncHandler( async (req, res, next) => {
89 | const id = parseInt(req.params.id);
90 | try {
91 | const booking = await Booking.findByPk(id);
92 |
93 | if(booking.userId === req.user.id) {
94 | await booking.destroy();
95 | res.json({ message: "Booking was successfully deleted" });
96 | } else {
97 | const err = new Error("You can only delete bookings that you own.");
98 | err.status = 403;
99 | next(err);
100 | }
101 | } catch(err) {
102 | next(err);
103 | }
104 | }));
105 |
106 | module.exports = router;
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/BookingBox/BookingBox.css:
--------------------------------------------------------------------------------
1 | .booking-box__container {
2 | left: 650px;
3 | position: absolute;
4 | display: flex;
5 | flex-direction: column;
6 | border: 1px solid rgba(0, 0, 0, 0.2);
7 | width: 350px;
8 | margin-top: 25px;
9 | border-radius: 15px;
10 | padding: 25px;
11 | box-shadow:5px 5px 15px 2px rgba(0, 0, 0, 0.2);
12 | }
13 |
14 | .booking-box__choose-dates {
15 | position: relative;
16 | display: flex;
17 | margin-bottom: 25px;
18 | justify-content: center;
19 | }
20 |
21 | .booking-box__date-picker {
22 | margin-top: 20px;
23 | height: 50px;
24 | width: 150px;
25 | border-radius: 10px;
26 | border: 1px solid rgba(0, 0, 0, 0.3);
27 | margin-right: 2px;
28 | padding-top: 10px;
29 | padding-left: 7px;
30 | font-size: 14px;
31 | cursor: pointer;
32 | }
33 |
34 | .booking-box__date-picker:focus {
35 | outline: none;
36 | }
37 |
38 | .booking-box__price {
39 | color: rgba(0, 0, 0, 0.8)
40 | }
41 |
42 | .booking-box__price-number {
43 | font-size: 22px;
44 | font-weight: bold;
45 | }
46 |
47 | .booking-box__floating-label {
48 | position: absolute;
49 | left: 8px;
50 | top: 26px;
51 | font-size: 12px;
52 | font-weight: bold;
53 | }
54 |
55 | .booking-box__choose-dates-wrapper {
56 | position: relative;
57 | }
58 |
59 | .booking-box__book-button {
60 | position: relative;
61 | left: 50%;
62 | transform: translateX(-50%);
63 | border: none;
64 | background-color: salmon;
65 | width: 300px;
66 | height: 45px;
67 | color: white;
68 | border-radius: 10px;
69 | font-weight: bold;
70 | cursor: pointer;
71 | margin-top: 10px;
72 | }
73 |
74 | .booking-box__book-button:disabled {
75 | cursor: default;
76 | background-color: rgba(250, 128, 114, 0.808);
77 | }
78 |
79 | .booking-box__price-calculations {
80 | position: relative;
81 | color: black;
82 | }
83 |
84 | .total-price-display {
85 | display: flex;
86 | justify-content: space-between;
87 | font-weight: bold;
88 | font-size: 15px;
89 | border-top: 1px solid rgba(0, 0, 0, 0.2);
90 | padding-top: 10px;
91 | }
92 |
93 | .day-by-price-calcs {
94 | display: flex;
95 | justify-content: space-between;
96 | margin-bottom: 10px;
97 | }
98 |
99 | .day-by-price-calcs-title {
100 | color: rgba(0, 0, 0, 0.8);
101 | text-decoration: underline;
102 | }
103 |
104 | .booking-box__no-charge {
105 | font-size: 15px;
106 | color:rgba(0, 0, 0, 0.7);
107 | display: flex;
108 | justify-content: center;
109 | margin-top: 15px;
110 | }
111 |
112 | .booking-box__pre-existing-booking {
113 | margin-top: 10px;
114 | }
115 |
116 | .pre-existing__dates {
117 | display: flex;
118 | flex-direction: column;
119 | align-items: center;
120 | color:rgba(0, 0, 0, 0.7);
121 | margin-top: 10px;
122 | }
123 |
124 | .pre-existing__delete-label {
125 | margin-top: 10px;
126 | }
127 |
128 | .pre-existing__delete-button {
129 | position: relative;
130 | background-color: transparent;
131 | border: none;
132 | font-size: 16px;
133 | color: rgba(100, 148, 237, 0.801);
134 | cursor: pointer;
135 | bottom: 19px;
136 | left: 32px;
137 | }
138 |
139 | .pre-existing__delete-button:hover {
140 | color: cornflowerblue;
141 | }
142 |
143 | .booking-box__previous-stay {
144 | margin-top: 10px;
145 | display: flex;
146 | flex-direction: column;
147 | align-items: center;
148 | }
149 |
150 | .booking-box__header {
151 | display: flex;
152 | align-items: center;
153 | justify-content: space-between;
154 | }
155 |
156 | .booking-box__review-info {
157 | display: flex;
158 | font-size: 14px;
159 | }
160 |
161 | .booking-box__title-score-number {
162 | margin-left: 3px;
163 | font-weight: bold;
164 | }
165 |
166 | .booking-box__title-score-reviews {
167 | margin-left: 5px;
168 | }
169 |
170 | .booking-box__title-score-star {
171 | color: salmon;
172 | }
--------------------------------------------------------------------------------
/backend/routes/api/review.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const asyncHandler = require("express-async-handler");
3 | const { check } = require("express-validator");
4 | const { Op } = require("sequelize");
5 |
6 | const { handleValidationErrors } = require("../../utils/validation");
7 | const { requireAuth } = require("../../utils/auth");
8 | const { Listing, Booking, User, Review } = require("../../db/models");
9 |
10 | const router = express.Router();
11 |
12 | const validateReviews = [
13 | check("bookingId")
14 | .exists({ checkFalsy: true })
15 | .withMessage("Reviews must be associated with a booking!")
16 | .isInt()
17 | .withMessage("Invalid booking ID. Booking IDs must be integers."),
18 | check("numStars")
19 | .exists({ checkFalsy: true })
20 | .withMessage("Reviews must have a number of stars associated.")
21 | .isDecimal()
22 | .withMessage("The number of stars for a review must be a decimal."),
23 | check("content")
24 | .exists({ checkFalsy: true })
25 | .withMessage("Reviews must have some written content!"),
26 | handleValidationErrors,
27 | ];
28 |
29 | router.post('/',
30 | requireAuth,
31 | validateReviews,
32 | asyncHandler(async (req, res, next) => {
33 | const { bookingId, numStars, content } = req.body;
34 | const userId = req.user.id;
35 |
36 | try {
37 | const booking = await Booking.findByPk(bookingId);
38 |
39 | // Check that the user that is making this request owns the booking the review is being made for
40 | if(booking.userId === userId) {
41 | await Review.findOrCreate({
42 | where: {
43 | [Op.and]: {
44 | bookingId,
45 | userId,
46 | },
47 | },
48 | defaults: {
49 | userId,
50 | bookingId,
51 | numStars,
52 | content,
53 | },
54 | include: [User, Booking]
55 | });
56 |
57 | const review = await Review.findOne({
58 | where: {
59 | [Op.and]: {
60 | bookingId,
61 | userId,
62 | },
63 | },
64 | include: [User, Booking],
65 | });
66 |
67 | if(!review) {
68 | const err = new Error("You can only post one review per booking!");
69 | err.status = 401;
70 | next(err);
71 | }
72 |
73 | res.json(review);
74 | }
75 | } catch (err) {
76 | next(err);
77 | }
78 | }));
79 |
80 | router.delete('/:id',
81 | requireAuth,
82 | asyncHandler( async (req, res, next) => {
83 | const userId = req.user.id;
84 | const reviewId = parseInt(req.params.id);
85 |
86 | try {
87 | const review = await Review.findByPk(reviewId);
88 |
89 | if(review && review.userId === userId) {
90 | await review.destroy();
91 |
92 | res.json({ message: "Review deletion successful." });
93 | } else {
94 | const err = Error("You can only delete reviews that you own.");
95 | err.status = 401;
96 | next(err);
97 | }
98 | } catch (err) {
99 | next(err);
100 | }
101 | }));
102 |
103 |
104 |
105 | module.exports = router;
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/ReviewSection/index.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 |
4 | import { formatForReview } from '../../../utils/date';
5 | import { deleteReview } from '../../../store/reviews';
6 | import './ReviewSection.css';
7 | import { fetchUserBookings } from '../../../store/userBookings';
8 |
9 | export default function ReviewSection({ reviews, average }) {
10 |
11 | const user = useSelector(state => state.session.user?.user);
12 |
13 | const dispatch = useDispatch();
14 |
15 | const [userReview, setUserReview] = useState(reviews.find(review => review?.userId === user?.id));
16 | const otherReviews = reviews.filter(review => review.userId !== user?.id);
17 |
18 | const destroyReview = async () => {
19 | await dispatch(deleteReview(userReview.id));
20 | await fetchUserBookings();
21 | setUserReview(null);
22 | };
23 |
24 | if(!reviews.length) return null;
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 | {`${average.toFixed(2)} - ${reviews.length} reviews`}
34 |
35 |
36 |
37 | {!!userReview && (
38 |
39 |
40 | You left this review
41 |
42 |
43 |
44 | {userReview?.User.username}
45 |
46 |
47 | {formatForReview(new Date(userReview?.Booking.startTime).toLocaleDateString("en-US", {
48 | weekday: 'long',
49 | year: 'numeric',
50 | month: 'long',
51 | day: 'numeric',
52 | }))}
53 |
54 |
55 |
56 | {userReview.content}
57 |
58 |
59 |
62 | Edit Review
63 |
64 |
68 | Delete Review
69 |
70 |
71 |
72 | )}
73 | {otherReviews.map(review => (
74 |
75 |
76 |
77 | {review?.User.username}
78 |
79 |
80 | {formatForReview(new Date(review?.Booking.startTime).toLocaleDateString("en-US", {
81 | weekday: 'long',
82 | year: 'numeric',
83 | month: 'long',
84 | day: 'numeric',
85 | }))}
86 |
87 |
88 |
89 | {review?.content}
90 |
91 |
92 | ))}
93 |
94 |
95 | )
96 | }
--------------------------------------------------------------------------------
/frontend/src/components/ListingPost/ListingForm/ListingForm.css:
--------------------------------------------------------------------------------
1 | .listing-form {
2 | position: relative;
3 | margin: 25px;
4 | margin-bottom: 100px;
5 | }
6 |
7 | .listing-form__input-text {
8 | position: relative;
9 | width: 850px;
10 | height: 60px;
11 | border-radius: 25px;
12 | border: 1px solid rgba(0, 0, 0, 0.4);
13 | padding: 15px;
14 | padding-top: 30px;
15 | font-size: 16px;
16 | }
17 |
18 | .listing-form__input-wrapper {
19 | position: relative;
20 | display: flex;
21 | flex-direction: column;
22 | margin: 20px;
23 | }
24 |
25 | .listing-form__input-text:focus {
26 | outline: none;
27 | border: 1px solid rgba(250, 128, 114, 0.6);
28 | }
29 |
30 | .listing-form__input-text:focus ~ .listing-form__floating-label,
31 | .listing-form__input-text:not(:focus):valid ~ .listing-form__floating-label {
32 | top: 12px;
33 | bottom: 10px;
34 | left: 20px;
35 | font-size: 11px;
36 | opacity: 0.8;
37 | }
38 |
39 | .listing-form__floating-label {
40 | opacity: 0.4;
41 | font-size: 16px;
42 | position: absolute;
43 | pointer-events: none;
44 | left: 16px;
45 | top: 23px;
46 | transition: 0.2s ease all;
47 | }
48 |
49 | .listing-form__description-label {
50 | font-weight: normal;
51 | font-size: 18px;
52 | font-weight: bold;
53 | margin-left: 20px;
54 | }
55 |
56 | .listing-form__input-textarea {
57 | padding: 25px;
58 | margin-left: 10px;
59 | resize: none;
60 | overflow-y: auto;
61 | font-size: 20px;
62 | border-radius: 20px;
63 | margin-top: 15px;
64 | margin-bottom: 25px;
65 | height: 400px;
66 | }
67 |
68 | .listing-form__input-textarea:focus {
69 | outline: none;
70 | border: 1px solid salmon;
71 | }
72 |
73 | .listing-form__submit-button {
74 | background-color: salmon;
75 | cursor: pointer;
76 | border: none;
77 | color: white;
78 | font-size: 18px;
79 | font-weight: bold;
80 | width: 300px;
81 | height: 50px;
82 | border-radius: 30px;
83 | }
84 |
85 | .listing-form__uploaded-images {
86 | display: flex;
87 | margin: 25px;
88 | flex-wrap: wrap;
89 | }
90 |
91 | .listing-form__input-images-box {
92 | position: relative;
93 | margin: 15px;
94 | cursor: pointer;
95 | background-color: rgba(211, 211, 211, 0.411);
96 | width: 500px;
97 | height: 150px;
98 | box-shadow: inset 5px 5px 14px 30px rgba(0, 0, 0, 0.05);
99 | }
100 |
101 | .listing-form__input-images-drag-message {
102 | position: relative;
103 | top: 50%;
104 | left: 75%;
105 | transform: translate(-50%, -50%);
106 | color:rgba(100, 148, 237, 0.678)
107 | }
108 |
109 | .listing-form__images-label {
110 | font-weight: bold;
111 | margin-left: 20px;
112 | margin-top: 30px;
113 | }
114 |
115 | .listing-form__uploaded-image-wrapper {
116 | position: relative;
117 | }
118 |
119 | .listing-form__uploaded-image-label {
120 | display: flex;
121 | font-size: 15px;
122 | color:rgba(0, 0, 0, 0.7);
123 | justify-content: center;
124 | }
125 |
126 | .listing-form__submit-button-container {
127 | display: flex;
128 | justify-content: center;
129 | }
130 |
131 | .listing-form__uploaded-image-overlay {
132 | position: relative;
133 | width: 175px;
134 | height: 175px;
135 | margin: 10px;
136 | cursor: pointer;
137 | }
138 |
139 | .listing-form__uploaded-image-overlay:after {
140 | content: '\A';
141 | position: absolute;
142 | width: 100%;
143 | height: 100%;
144 | top: 0;
145 | left: 0;
146 | border-radius: 20px;
147 | margin: 5px;
148 | background: rgba(0, 0, 0, 0.7);
149 | opacity: 0;
150 | transition: all 0.1s;
151 | -webkit-transition: all 0.1s;
152 | }
153 |
154 | .listing-form__uploaded-image-overlay:hover:after {
155 | opacity: 1;
156 | }
157 |
158 | .listing-form__validation-errors {
159 | list-style: none;
160 | margin-left: 30px;
161 | }
162 |
163 | .listing-form__validation-error {
164 | color: red;
165 | font-size: 14px;
166 | }
167 |
168 | .listing-form__uploaded-images-label {
169 | position: relative;
170 | margin-left: 40px;
171 | margin-bottom: -20px;
172 | font-weight: bold;
173 | }
174 |
175 | .listing-form__uploaded-image-trash {
176 | position: absolute;
177 | color: white;
178 | font-size: 34px;
179 | left: 55%;
180 | top: 55%;
181 | transform: translate(-50%, -50%);
182 | }
183 |
184 | .listing-form__uploaded-image-overlay:hover > .listing-form__uploaded-image-trash {
185 | z-index: 99;
186 | }
--------------------------------------------------------------------------------
/frontend/src/components/SearchBar/index.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { useHistory } from 'react-router';
4 |
5 | import { showSearchBubble, hideSearchBubble } from '../../store/search';
6 | import '../HomePage/HomePage.css';
7 | import './SearchBar.css';
8 | import logo from '../../assets/images/work-in-logo.png';
9 |
10 | import { csrfFetch } from '../../store/csrf';
11 |
12 | export default function SearchBar({ context }) {
13 | const [searchVal, setSearchVal] = useState('');
14 | const [searchResults, setSearchResults] = useState([]);
15 |
16 | const showBubble = useSelector(state => state.search.show);
17 |
18 | const dispatch = useDispatch();
19 | const history = useHistory();
20 |
21 | const handleSubmit = async (e) => {
22 | e.preventDefault();
23 |
24 | history.push(`/listings/?search=${searchVal}`);
25 | };
26 |
27 | const fetchResults = async (e) => {
28 | const queryString = encodeURIComponent(searchVal);
29 |
30 | const res = await csrfFetch(`/api/search/${queryString}`);
31 |
32 | if(res.ok) {
33 | const listings = await res.json();
34 | setSearchResults(listings);
35 | dispatch(showSearchBubble());
36 | } else {
37 |
38 | }
39 | };
40 |
41 | useEffect(() => {
42 | if(!showBubble) return;
43 |
44 | const closeBubble = () => {
45 | dispatch(hideSearchBubble());
46 | }
47 |
48 | document.addEventListener('click', closeBubble);
49 |
50 | return () => document.removeEventListener('click', closeBubble);
51 | }, [showBubble, dispatch]);
52 |
53 | return (
54 | <>
55 |
56 | {showBubble && context === "home" && (
57 |
58 | )}
59 |
81 | {showBubble &&
82 | (
e.stopPropagation()}
85 | >
86 |
106 |
)}
107 |
108 | >
109 | )
110 | }
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/ConfirmBooking/index.js:
--------------------------------------------------------------------------------
1 | import { useDispatch, useSelector } from 'react-redux';
2 | import { useParams, useHistory } from 'react-router';
3 |
4 | import './ConfirmBooking.css';
5 | import { addUserBooking, removeUserBooking } from '../../../store/userBookings';
6 | import { csrfFetch } from '../../../store/csrf';
7 | import { closeCreateBookingModal } from '../../../store/modals';
8 | import { formatDate } from '../../../utils/date';
9 |
10 | export default function ConfirmBooking({ show, image, name, context, id }) {
11 | const dispatch = useDispatch();
12 | const history = useHistory();
13 |
14 | const listingId = useParams().id;
15 |
16 | const bookingInfo = useSelector(state => state.booking);
17 |
18 | if(!show) {
19 | return null;
20 | }
21 |
22 | const createBooking = async () => {
23 | const { start, end } = bookingInfo;
24 |
25 | const res = await csrfFetch('/api/bookings', {
26 | method: 'POST',
27 | headers: { 'Content-Type': 'application/json' },
28 | body: JSON.stringify({
29 | listingId,
30 | startTime: start,
31 | endTime: end,
32 | })
33 | });
34 |
35 | if(res.ok) {
36 | const booking = await res.json();
37 | dispatch(addUserBooking(booking));
38 | dispatch(closeCreateBookingModal());
39 | } else {
40 |
41 | }
42 | };
43 |
44 | const deleteBooking = async () => {
45 | const res = await csrfFetch(`/api/bookings/${id}`, {
46 | method: 'DELETE',
47 | });
48 |
49 | if(res.ok) {
50 | history.push('/listings');
51 | dispatch(removeUserBooking(id));
52 | dispatch(closeCreateBookingModal());
53 | }
54 | };
55 |
56 | return (
57 |
58 |
59 |
60 |
dispatch(closeCreateBookingModal())}
63 | >
64 |
65 |
66 |
67 | {context === "post" ? 'Confirm your booking' : 'Confirm booking cancelation'}
68 |
69 |
70 |
71 |
72 | {name}
73 |
74 |
79 |
80 |
81 |
82 | {`Start time: ${formatDate(bookingInfo.start)}`}
83 |
84 |
85 | {`End time: ${formatDate(bookingInfo.end)}`}
86 |
87 |
88 |
89 | {context === "post" && `${bookingInfo.days} days and ${bookingInfo.hours} hours`}
90 |
91 |
92 |
93 | {context === "post" && 'Total: '}
94 |
95 |
96 | {context === "post" && `$${bookingInfo.total}`}
97 |
98 |
99 |
100 |
104 | {context === "post" ? `Reserve Listing` : `Cancel Booking`}
105 |
106 |
107 |
108 |
109 | );
110 | }
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/ReviewCreate/index.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useDispatch } from 'react-redux';
3 |
4 | import { createReview } from '../../../store/reviews';
5 | import { fetchUserBookings } from '../../../store/userBookings';
6 |
7 | import './ReviewCreate.css';
8 |
9 | export default function ReviewCreate({ host, booking }) {
10 | const [numStars, setNumStars] = useState("3.0");
11 | const [content, setContent] = useState('');
12 |
13 | const dispatch = useDispatch();
14 |
15 | const handleSubmit = async (e) => {
16 | e.preventDefault();
17 | const reqNumStars = parseFloat(numStars);
18 |
19 | await dispatch(createReview({
20 | bookingId: booking.id,
21 | numStars: reqNumStars,
22 | content,
23 | }));
24 |
25 | await fetchUserBookings();
26 | };
27 |
28 | const clearInput = (e) => {
29 | e.preventDefault();
30 | setContent('');
31 | };
32 |
33 | return (
34 |
35 | {`Review your experience at ${host}'s workspace`}
36 |
108 |
109 | );
110 | }
--------------------------------------------------------------------------------
/frontend/src/components/AuthModal/AuthForms/SignupForm.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useDispatch } from 'react-redux';
3 | import { Redirect } from 'react-router-dom';
4 |
5 | import { signUp } from "../../../store/session";
6 | import { closeSignupModal } from "../../../store/modals";
7 | import FormErrors from "../../FormErrors";
8 |
9 | export default function SignupForm() {
10 | const [username, setUsername] = useState('');
11 | const [email, setEmail] = useState('');
12 | const [password, setPassword] = useState('');
13 | const [confirmPassword, setConfirmPassword] = useState('');
14 | const [validationErrors, setValidationErrors] = useState([]);
15 |
16 | const dispatch = useDispatch();
17 |
18 | // useEffect(() => {
19 | // const errors = [];
20 |
21 | // if(password !== confirmPassword) errors.push("Password fields must match.");
22 | // if(!username.length) errors.push("Username is required for signup.");
23 | // if(!email.length) errors.push("Email is required for signup.");
24 | // if(!password.length) errors.push("Password is required for signup.");
25 | // if(!confirmPassword.length) errors.push("You must confirm your password for signup.");
26 |
27 | // setValidationErrors(errors);
28 | // }, [username, email, password, confirmPassword])
29 |
30 | const handleSubmit = async (e) => {
31 | e.preventDefault();
32 | if(password === confirmPassword) {
33 | try {
34 | await dispatch(signUp({username, email, password}));
35 | dispatch(closeSignupModal());
36 | return (
37 |
38 | )
39 | } catch(e) {
40 | const err = await e.json();
41 | const errors = err.errors;
42 | setValidationErrors(errors);
43 | }
44 | } else {
45 | return setValidationErrors(['Confirm password field must match the Password field.']);
46 | }
47 | };
48 |
49 | return (
50 |
54 |
55 |
58 | setUsername(e.target.value)}
64 | required
65 | >
66 |
67 |
70 | Username
71 |
72 |
73 |
74 |
77 | setEmail(e.target.value)}
83 | required
84 | >
85 |
86 |
89 | E-mail address
90 |
91 |
92 |
93 |
96 | setPassword(e.target.value)}
102 | required
103 | >
104 |
105 |
108 | Password
109 |
110 |
111 |
112 |
115 | setConfirmPassword(e.target.value)}
121 | required
122 | >
123 |
124 |
127 | Confirm Password
128 |
129 |
130 |
133 | Sign Up
134 |
135 |
136 | )
137 | }
--------------------------------------------------------------------------------
/backend/routes/api/listings.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const asyncHandler = require("express-async-handler");
3 | const { check } = require("express-validator");
4 |
5 | const { handleValidationErrors } = require("../../utils/validation");
6 | const { restoreUser } = require("../../utils/auth");
7 | const { multipleMulterUpload, multiplePublicFileUpload } = require("../../awsS3");
8 |
9 | const { Listing, Image, User, Review, Booking } = require("../../db/models");
10 |
11 | const router = express.Router();
12 |
13 | const validateListing = [
14 | check("name")
15 | .trim()
16 | .exists({ checkFalsy: true })
17 | .withMessage("You must give a name to your listing.")
18 | .isLength({min: 10, max: 200})
19 | .withMessage("Your listing's name must be at least 10 and no more than 200 characters."),
20 | check("address")
21 | .trim()
22 | .exists({ checkFalsy: true })
23 | .withMessage("You must provide an address for your listing.")
24 | .isLength({min: 5, max: 255})
25 | .withMessage("A valid address is required."),
26 | check("city")
27 | .trim()
28 | .exists({ checkFalsy: true })
29 | .withMessage("You must provide a city for your listing.")
30 | .isLength({ max: 100 })
31 | .withMessage("City for the listing must be a valid city."),
32 | check("state")
33 | .trim()
34 | .exists({ checkFalsy: true })
35 | .withMessage("You must provide a state or province for your listing.")
36 | .isLength({ max: 60 })
37 | .withMessage("State/Province for listing must be valid."),
38 | check("country")
39 | .trim()
40 | .exists({ checkFalsy: true })
41 | .withMessage("You must provide a country for your listing.")
42 | .isLength({ max: 60 })
43 | .withMessage("Country for listing must be valid."),
44 | check("description")
45 | .trim()
46 | .exists({ checkFalsy: true })
47 | .withMessage("Your listing must have a description."),
48 | check("price")
49 | .trim()
50 | .exists({ checkFalsy: true })
51 | .withMessage("A price for the listing is required.")
52 | .isDecimal({ decimal_digits: 2})
53 | .withMessage("Price must be a valid price (XX.XX)"),
54 | handleValidationErrors,
55 | ];
56 |
57 | router.get('/:id(\\d+)', asyncHandler( async (req, res, next) => {
58 | const id = parseInt(req.params.id);
59 |
60 | try {
61 | const listing = await Listing.findByPk(id, {
62 | include: [Image, User, {
63 | model: Booking,
64 | include: {
65 | model: Review,
66 | include: [User, Booking]
67 | },
68 | }],
69 | });
70 |
71 | res.json(listing);
72 | } catch (err) {
73 | next(err);
74 | }
75 | }));
76 |
77 | router.get('/', asyncHandler( async (req, res, next) => {
78 | try {
79 | const listings = await Listing.findAll({
80 | include: [Image, User],
81 | });
82 | res.json(listings);
83 | } catch (err) {
84 | next(err);
85 | }
86 | }));
87 |
88 | router.post('/', multipleMulterUpload("images"), restoreUser, validateListing, asyncHandler( async (req, res, next) => {
89 | const { name, address, city, state, country, price, description } = req.body;
90 | const userId = req.user.id;
91 |
92 | try {
93 | const newListing = await Listing.create({
94 | userId,
95 | name,
96 | address,
97 | city,
98 | state,
99 | country,
100 | price,
101 | description
102 | });
103 |
104 | if(newListing) {
105 | const imageUrls = await multiplePublicFileUpload(req.files);
106 | for(let i = 0; i < imageUrls.length; i++) {
107 | await Image.create({ listingId: newListing.id, url: imageUrls[i] });
108 | }
109 | }
110 |
111 | res.json(newListing);
112 | } catch(err) {
113 | next(err);
114 | }
115 | }));
116 |
117 | router.put("/:id", multipleMulterUpload("images"), restoreUser, validateListing, asyncHandler(async (req, res, next) => {
118 | const { name, address, city, state, country, description, price } = req.body;
119 | const userId = req.user.id;
120 |
121 | const { id } = req.params;
122 |
123 | try {
124 | const listing = await Listing.findByPk(id);
125 | if(listing.userId === userId) {
126 | const newListing = await Listing.update({
127 | name,
128 | address,
129 | city,
130 | state,
131 | country,
132 | description,
133 | price,
134 | }, { where: { id }});
135 |
136 | const imageUrls = await multiplePublicFileUpload(req.files);
137 | for(let i = 0; i < imageUrls.length; i++) {
138 | await Image.create({ listingId: listing.id, url: imageUrls[i] });
139 | }
140 |
141 | res.json({ message: "Listing updated successfully." });
142 | }
143 | } catch (err) {
144 | next(err);
145 | }
146 | }));
147 |
148 | router.delete("/:id", restoreUser, asyncHandler(async (req, res, next) => {
149 | const { id } = req.params
150 |
151 | const listing = await Listing.findByPk(id);
152 | if(listing.userId === req.user.id) {
153 | await listing.destroy();
154 | res.json({ message: "Sucessfully deleted listing."});
155 | } else {
156 | const err = new Error("Cannot destroy a listing you do not own.");
157 | next(err);
158 | }
159 | }));
160 |
161 | module.exports = router;
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/ListingPage.css:
--------------------------------------------------------------------------------
1 | .listing-page-container-outer {
2 | position: fixed;
3 | top: 75px;
4 | width: 100%;
5 | height: calc(100vh - 4vh);
6 | overflow-y: auto;
7 | padding: 50px;
8 | border-top: 1px solid rgba(211, 211, 211, 0.589);
9 | box-shadow: inset 0px 0px 60px rgba(0, 0, 0, 0.07);
10 | padding-bottom: 200px;
11 | }
12 |
13 | .listing-page-container {
14 | position: relative;
15 | width: 1000px;
16 | left: 50%;
17 | transform: translateX(-50%);
18 | margin-bottom: 50px;
19 | }
20 |
21 | .listing-page__listing-image {
22 | width: 500px;
23 | height: 300px;
24 | }
25 |
26 | .listing-page__cover-image {
27 | position: relative;
28 | display: block;
29 | border-radius: 15px;
30 | width: 495px;
31 | height: 495px;
32 | margin: 5px;
33 | margin-left: 0;
34 | }
35 |
36 | .listing-page__images-block {
37 | position: relative;
38 | margin-top: 15px;
39 | width: 100%;
40 | }
41 |
42 | .listing-page__images-images {
43 | position: relative;
44 | display: grid;
45 | grid-template-rows: 2fr 1fr 1fr;
46 | grid-template-rows: 1fr 1fr;
47 | width: 100%;
48 | height: 500px;
49 | }
50 |
51 | .listing-page__cover-image-container {
52 | position: relative;
53 | cursor: pointer;
54 | grid-column: 1 / 2;
55 | grid-row: 1 / 3;
56 | }
57 |
58 | .listing-page__secondary-container-1 {
59 | position: relative;
60 | grid-column: 2 / 3;
61 | grid-row: 1 / 3;
62 | }
63 |
64 | .listing-page__secondary-container-2 {
65 | position: relative;
66 | grid-column: 3 / 4;
67 | grid-row: 1 / 3;
68 | }
69 |
70 | .listing-page__secondary-full-container {
71 | position: relative;
72 | cursor: pointer;
73 | grid-column: 2 / 4;
74 | grid-row: 1 / 3;
75 | }
76 |
77 | .listing-page__secondary-image {
78 | margin: 5px;
79 | border-radius: 15px;
80 | width: 240px;
81 | height: 240px;
82 | }
83 |
84 | .listing-page__hosting-info {
85 | position: relative;
86 | font-weight: bold;
87 | font-size: 22px;
88 | margin-top: 30px;
89 | margin-left: 5px;
90 | border-bottom: 1px solid lightgray;
91 | padding-bottom: 25px;
92 | height: 80px;
93 | width: 625px;
94 | }
95 |
96 | .listing-page__description {
97 | color:rgba(0, 0, 0, 0.8);
98 | width: 650px;
99 | padding: 25px;
100 | }
101 |
102 | .listing-page__title {
103 | font-weight: bold;
104 | font-size: 26px;
105 | }
106 |
107 | .listing-page__title-address {
108 | color:rgba(0, 0, 0, 0.5);
109 | }
110 |
111 | .listing-page__full-address {
112 | font-weight: normal;
113 | margin-top: 10px;
114 | color: rgba(0, 0, 0, 0.6);
115 | font-size: 16px;
116 | }
117 |
118 | .listing-page__cover-image-container:after {
119 | content: '\A';
120 | position: absolute;
121 | width: 495px;
122 | height: 495px;
123 | margin: 5px;
124 | margin-left: 0;
125 | top: 0;
126 | left: 0;
127 | border-radius: 15px;
128 | background: rgba(0, 0, 0, 0.15);
129 | opacity: 0;
130 | }
131 |
132 | .listing-page__cover-image-container:hover:after {
133 | opacity: 1;
134 | }
135 |
136 | .listing-page__cover-image-container:after {
137 | content: '\A';
138 | position: absolute;
139 | width: 495px;
140 | height: 495px;
141 | margin: 5px;
142 | margin-left: 0;
143 | top: 0;
144 | left: 0;
145 | border-radius: 15px;
146 | background: rgba(0, 0, 0, 0.15);
147 | opacity: 0;
148 | }
149 |
150 | .listing-page__cover-image-container:hover:after {
151 | opacity: 1;
152 | }
153 |
154 | .listing-page__secondary-image-overlay {
155 | position: relative;
156 | }
157 |
158 | .listing-page__secondary-image-overlay:after {
159 | content: '\A';
160 | position: absolute;
161 | width: 240px;
162 | height: 240px;
163 | margin: 5px;
164 | top: 0;
165 | left: 0;
166 | border-radius: 15px;
167 | background: rgba(0, 0, 0, 0.15);
168 | opacity: 0;
169 | }
170 |
171 | .listing-page__secondary-image-overlay:hover:after {
172 | opacity: 1;
173 | }
174 |
175 | .listing-page__secondary-full-container:after {
176 | content: '\A';
177 | position: absolute;
178 | width: 495px;
179 | height: 495px;
180 | margin: 5px;
181 | top: 0;
182 | left: 0;
183 | border-radius: 15px;
184 | background: rgba(0, 0, 0, 0.15);
185 | opacity: 0;
186 | }
187 |
188 | .listing-page__secondary-full-container:hover:after {
189 | opacity: 1;
190 | }
191 |
192 | .listing-page__images-showall {
193 | position: absolute;
194 | cursor: pointer;
195 | bottom: 10px;
196 | right: 10px;
197 | align-items: center;
198 | display: flex;
199 | padding: 5px;
200 | padding-left: 10px;
201 | padding-right: 10px;
202 | justify-content: space-between;
203 | background-color: rgba(255, 255, 255, 0.9);
204 | border: 1px solid black;
205 | border-radius: 5px;
206 | }
207 |
208 | .listing-page__images-showall:hover {
209 | background-color: rgba(243, 241, 241, 1);
210 | }
211 |
212 | .listing-page__images-showall-text {
213 | margin-left: 5px;
214 | font-weight: bold;
215 | }
216 |
217 | .listing-page__crud-links {
218 | display: flex;
219 | flex-direction: column;
220 | font-size: 13px;
221 | color:rgba(0, 0, 0, 0.5);
222 | margin-top: 40px;
223 | margin-bottom: 15px;
224 | justify-content: center;
225 | }
226 |
227 | .listing-page__crud-link {
228 | font-weight: bold;
229 | text-decoration: none;
230 | color: cornflowerblue;
231 | margin-left: 7px;
232 | }
233 |
234 | .listing-page__links-container {
235 | display: flex;
236 | flex-direction: column;
237 | margin-top: 10px;
238 | }
239 |
240 | .listing-page__crud-button {
241 | background-color: transparent;
242 | color: salmon;
243 | border: none;
244 | width: 90px;
245 | cursor: pointer;
246 | font-weight: bold;
247 | font-size: 13px;
248 | }
249 |
250 | .listing-page__crud-or {
251 | font-weight: bold;
252 | margin-left: 25px;
253 | }
254 |
255 | .listing-page__hosting-booking {
256 | display: flex;
257 | justify-content: space-between;
258 | }
259 |
260 | .listing-page__review-section {
261 | border-top: 1px solid rgba(0, 0, 0, 0.2);
262 | width: 625px;
263 | padding: 20px;
264 | }
265 |
266 | .listing-page__title-address-and-score {
267 | display: flex;
268 | align-items: center;
269 | margin-top: 5px;
270 | }
271 |
272 | .listing-page__title-score-number {
273 | font-weight: bolder;
274 | margin-left: 5px;
275 | }
276 |
277 | .listing-page__title-score-star {
278 | color: salmon;
279 | font-size: 13px;
280 | margin-bottom: 2px;
281 | }
282 |
283 | .listing-page__title-score {
284 | display: flex;
285 | align-items: center;
286 | }
287 |
288 | .listing-page__title-score-reviews {
289 | margin-left: 5px;
290 | color: rgba(0, 0, 0, 0.6);
291 | }
292 |
293 | .listing-page__title-address {
294 | margin-left: 10px;
295 | }
--------------------------------------------------------------------------------
/backend/db/seeders/20210719172258-listing-data.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | up: (queryInterface, Sequelize) => {
5 | /*
6 | Add altering commands here.
7 | Return a promise to correctly handle asynchronicity.
8 |
9 | Example:
10 | return queryInterface.bulkInsert('People', [{
11 | name: 'John Doe',
12 | isBetaMember: false
13 | }], {});
14 | */
15 | return queryInterface.bulkInsert('Listings', [
16 | {userId: 1, name: 'Salesforce Tower', address:'415 Mission St', city: 'San Francisco', state: 'CA', country: 'USA', price: 25, createdAt: new Date(), updatedAt: new Date(),
17 | description: `Thoughtfully designed coworking and office space across 3 floors in the tallest building on the West Coast, with full floor opportunities available.`},
18 | {userId: 1, name: 'Bayshore Business Center', address:'1485 Bay Shore Blvd', city: 'San Francisco', state: 'CA', country: 'USA', price: 15, createdAt: new Date(), updatedAt: new Date(),
19 | description: `Our business center is an asset to the San Francisco community and neighborhood. All offices are inclusive, private spaces with utilities and janitorial service include. We have an On-site Cafe with full restaurant service, catering, delivery, and to-go items. Receptionists are available Monday-Friday from 8 AM to 5 PM. 24-hour access to all tenants, full use of space, private and ample restrooms, conference rooms, door signage, etc. Contact us today for more details! Visit our website www.bayshorebusinesscenter.com/`},
20 | {userId: 1, name: '825 Third Ave', address:'845 Third Ave', city: 'New York', state: 'NY', country: 'USA', price: 32, createdAt: new Date(), updatedAt: new Date(),
21 | description: `825 Third Avenue is being thoroughly reimagined through a $150 million capital improvement program focusing on performance, tenant comfort, modern aesthetics and operational efficiency. MEP systems, windows, building infrastructure, and retail storefronts are being replaced or substantially upgraded. Significant renovations also include a new lobby, state-ofthe-art amenity center and wraparound terrace opportunity on the 12th floor.`},
22 | {userId: 2, name: 'Masonic Hall', address:'71 W 23rd St', city: 'New York', state: 'NY', country: 'USA', price: 38, createdAt: new Date(), updatedAt: new Date(),
23 | description: `Strategically situated at the corner of 6th and 23rd Streets, the Masonic Hall at 71 W 23rd Street presents the opportunity for space in one of Manhattan’s most popular neighborhoods. The 19-story, 453,600 square-foot property offers panoramic views and plenty of windows for abundant natural light. The renovated and well-kept lobby is attended around the clock and provides a welcoming environment for employees and guests alike. Tenants also enjoy access to on-site event space and conference center, which can be rented for business uses. This desirable location is just one block from Madison Square Park and is surrounded by an abundance of fine dining, quick-service restaurants, upscale hospitality options, and neighborhood amenities. Commuters enjoy immediate access to the 23rd Street Station, which serves the PATH train and the yellow, blue, F, and M lines. And for even more options, the iconic Penn Station is less than a mile’s walk away.`},
24 | {userId: 2, name: '1245 Broadway', address:'1245 Broadway', city: 'New York', state: 'NY', country: 'USA', price: 10, createdAt: new Date(), updatedAt: new Date(),
25 | description: `1245 Broadway is the NoMad neighborhood's first boutique trophy office property. Inspired by Manhattan's classical 19th- and 20th-century architecture, yet asserting a distinctly contemporary presence, the newly constructed building is both contextual and unique. Eco-conscious, Class A design and construction features enhance comfort, optimize energy efficiency, and contribute to a more sustainable corporate environment. Experience exterior and interior lighting that reaches museum-quality excellence, a marble-walled lobby, a luxurious double-height arcade with premier dining, and a private club-inspired tenant-exclusive lounge complete with a refreshment bar, private phone booths, a gas fireplace, and wood-paneled walls. Rising to 23 stories, 1245 Broadway offers a range of iconic Manhattan skyline views, including the Chrysler and Empire State buildings, from massive picture windows with triple-glazed glass. Flexible and open office floor plans with 11' to 13'6" finished ceiling heights can be configured to accommodate an efficient mix of shared workspaces, meeting rooms, conference rooms, and private offices.`},
26 | {userId: 1, name: 'Sergipe 1440', address:'Rua Sergipe, 1440', city: 'Belo Horizonte', state: 'MG', country: 'Brazil', price: 5, createdAt: new Date(), updatedAt: new Date(),
27 | description: `Located in the commercial birthplace of Belo Horizonte, This vibrant workspace at Savassi is bursting with innovation and opportunity. With six floors of attractive lounges, private offices and high-tech meeting rooms, this dog-friendly venue accommodates teams of all sizes. With a variety of bus lines serving the site, travel is made easy with lines 1170, 2151, 4106, SCO2A and SE02. In addition, we have an on-site bike rack and convenient parking options nearby. After working hours, welcome your customers or celebrate the team's achievements at world-renowned restaurants in the neighborhood. If you intend to rent an office near Praça da Savassi, this is the ideal place to be.`},
28 | {userId: 1, name: 'Boulevard Shopping', address:'Avenida dos Andradas, 3000 Boulevard Shopping', city: 'Belo Horizonte', state: 'MG', country: 'Brazil', price: 7, createdAt: new Date(), updatedAt: new Date(),
29 | description: `Perfectly positioned on one of the city's main avenues, this coworking space on Av. dos Andradas offers a thriving environment for your business. Spanning four floors of a historic tower, this space is designed to keep your staff productive and inspired. Enter through Boulevard Shopping, take the elevator to the ninth floor and join a world of workspaces, with refreshing common areas, stylish private offices and expansive meeting rooms. Make your commute to work easier using the nearby Santa Efigênia subway station and bus lines 4801A, 9032, 9205 and 9411 in front of the building. Additionally, there is an on-site bicycle rack for cyclists. Need a break between tasks? Is easy. Explore the various shops in the building or take a trip to Santa Tereza. It doesn't matter if your company has one employee or a thousand, you'll feel right at home in Boulevard Shopping's work and coworking spaces. Book a visit right now.`},
30 | {userId: 3, name: 'Nações Unidas 12901', address:'Av. das Nações Unidas, 12901 CENU Torre Norte, Brooklin Paulista', city: 'São Paulo', state: 'SP', country: 'Brazil', price: 12, createdAt: new Date(), updatedAt: new Date(),
31 | description: `With Views of the Pinheiros River, This Shared Office Space at the United Nations, 12901 features art-filled lounges and energizing communal spaces, allowing both teams and individuals to feel inspired and productive. This coworking space in Brooklyn is steps away from a bus stop for convenient public transportation, as well as the Tower Bridge business center. Teams can bond over a number of buffet-style restaurants within minutes of the United Nations at lunch, or catch a happy hour and live music with colleagues or clients at the nearby Caluma Buffet and Restaurant. Ready to see how this shared office space in Zona Sul can inspire your team? Schedule a visit to rent office space in Brooklin Sao Paulo at Nações Unidas, 12901 today.`},
32 | {userId: 2, name: 'Faria Lima 3729', address:'Praça senador Salgado Filho 1 - Centro', city: 'São Paulo', state: 'SP', country: 'Brazil', price: 13, createdAt: new Date(), updatedAt: new Date(),
33 | description: `A beautiful and practical workspace situated directly in the heart of Sao Paulo.`},
34 | {userId: 1, name: 'Helios Seelinger 155', address:'Rua Helios Seelinger, 155', city: 'Rio de Janeiro', state: 'RJ', country: 'Brazil', price: 6, createdAt: new Date(), updatedAt: new Date(),
35 | description: `Between sand, surf and fully stocked printing stations, our coworking space in Barra da Tijuca is a professional paradise. With four floors and exclusive terrace access, this modern venue is filled with amenities to help businesses of all sizes thrive. Your team has access to inviting lounge areas, high-tech meeting rooms and stocked kitchens throughout our space. Hosting a variety of businesses across multiple industries, this lively neighborhood is home to a wealth of dining, shopping and nightlife options that provide entertainment for every occasion. Transportation to the site is very convenient, with the Jardim Oceânico metro station just a few minutes' walk away and parking is available nearby. Are you ready to re-imagine your workday amidst the stunning natural beauty of the Jardim Oceânico? Schedule a visit right now.`},
36 | ]);
37 | },
38 |
39 | down: (queryInterface, Sequelize) => {
40 | /*
41 | Add reverting commands here.
42 | Return a promise to correctly handle asynchronicity.
43 |
44 | Example:
45 | return queryInterface.bulkDelete('People', null, {});
46 | */
47 | return queryInterface.bulkDelete('Listings', null, {});
48 | }
49 | };
50 |
--------------------------------------------------------------------------------
/frontend/src/components/ListingPage/BookingBox/index.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { useParams } from 'react-router';
4 | import moment from 'moment';
5 |
6 | import { useBookingPast } from '../../../utils/hooks';
7 | import { showCreateBookingModal, setBookingModalPost, setBookingModalDelete, showLoginModal } from '../../../store/modals';
8 | import { setBookingInfo, setBookingStart, setBookingEnd, setBookingId} from '../../../store/booking';
9 | import { formatDate, formatFromDb } from '../../../utils/date';
10 | import './BookingBox.css';
11 |
12 |
13 | export default function BookingBox({ price, reviews, average }) {
14 | const [startTime, setStartTime] = useState('');
15 | const [realStartTime, setRealStartTime] = useState('');
16 | const [endTime, setEndTime] = useState('');
17 | const [realEndTime, setRealEndTime] = useState('');
18 | const [finalPrice, setFinalPrice] = useState(null);
19 | const [displayHours, setDisplayHours] = useState(null);
20 | const [displayDays, setDisplayDays] = useState(null);
21 |
22 |
23 | const dispatch = useDispatch();
24 |
25 | const listingId = useParams().id;
26 |
27 | const user = useSelector(state => state.session.user);
28 |
29 | const userBooking = useSelector(state => state.userBookings.find(booking => booking?.listingId === parseInt(listingId)));
30 |
31 | const isBookingPast = useBookingPast(userBooking);
32 |
33 | const calcTotal = () => {
34 | const startDate = moment(realStartTime);
35 | const endDate = moment(realEndTime);
36 |
37 | let days = (parseInt(endDate.diff(startDate, 'days')));
38 | let hours = (parseInt(endDate.subtract(days, 'days').diff(startDate, 'hours')));
39 |
40 | if(hours >= 12) {
41 | hours -= 12;
42 | days += 1;
43 |
44 | if(hours >= 12) {
45 | hours -= 12;
46 | days += 1;
47 | }
48 | }
49 |
50 | setFinalPrice((days * 12 + hours) * price);
51 | setDisplayDays(days);
52 | setDisplayHours(hours);
53 | };
54 |
55 | useEffect(() => {
56 | if(realStartTime && realEndTime) {
57 | calcTotal();
58 | }
59 | }, [realStartTime, realEndTime]);
60 |
61 | return (
62 | <>
63 |
64 |
65 |
66 | {`$${price}`} / hour
67 |
68 | {reviews.length > 0 && (
69 |
70 |
71 |
72 |
73 |
74 | {average.toFixed(2)}
75 |
76 |
77 | {` (${reviews.length} reviews)`}
78 |
79 |
80 | )}
81 |
82 |
126 | {finalPrice &&
127 | (
128 |
129 |
130 | {`$${price} x ${displayDays} days (12 hours)`}
131 |
132 |
133 | {`$${displayDays * 12 * price}`}
134 |
135 |
136 |
137 |
138 | {`$${price} x ${displayHours} hours`}
139 |
140 |
141 | {`$${displayHours * price}`}
142 |
143 |
144 |
145 |
146 | Total
147 |
148 |
149 | {`$${finalPrice}`}
150 |
151 |
152 |
153 | You won't be charged yet.
154 |
155 |
)}
156 |
{
159 | if(!user) {
160 | dispatch(showLoginModal());
161 | } else {
162 | dispatch(setBookingInfo({
163 | hours: displayHours,
164 | days: displayDays,
165 | total: finalPrice,
166 | start: realStartTime,
167 | end: realEndTime,
168 | }));
169 |
170 | dispatch(setBookingModalPost());
171 | dispatch(showCreateBookingModal());
172 | }
173 | }}
174 | disabled={!finalPrice || (userBooking && !isBookingPast)}
175 | >
176 | Reserve This Listing
177 |
178 | {userBooking && !isBookingPast && (
179 |
180 | You have a reservation at this workspace
181 |
182 |
183 | {`From ${formatFromDb(userBooking.startTime)}`}
184 |
185 |
186 | {`To ${formatFromDb(userBooking.endTime)}`}
187 |
188 |
189 |
190 | If you wish to make a new reservation, first
191 |
192 |
{
195 | dispatch(setBookingStart(userBooking.startTime));
196 | dispatch(setBookingEnd(userBooking.endTime));
197 | dispatch(setBookingId(userBooking.id));
198 | dispatch(setBookingModalDelete());
199 | dispatch(showCreateBookingModal());
200 | }}
201 | >
202 | cancel your booking
203 |
204 |
205 | )}
206 | {isBookingPast && (
207 |
208 | You worked here recently
209 |
210 |
211 | {`From ${formatFromDb(userBooking.startTime)}`}
212 |
213 |
214 | {`To ${formatFromDb(userBooking.endTime)}`}
215 |
216 |
217 |
218 | )}
219 |
220 | >
221 | )
222 | }
--------------------------------------------------------------------------------
/frontend/src/components/ListingPost/ListingForm/index.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useEffect } from 'react';
2 | import { useDropzone } from 'react-dropzone';
3 | import { useParams, useHistory } from 'react-router';
4 |
5 | import UploadedImage from './UploadedImage';
6 | import FormErrors from '../../FormErrors';
7 | import { csrfFetch } from '../../../store/csrf';
8 | import './ListingForm.css';
9 |
10 | export default function ListingForm({ context }) {
11 | const [name, setName] = useState('');
12 | const [address, setAddress] = useState('');
13 | const [city, setCity] = useState('');
14 | const [state, setState] = useState('');
15 | const [country, setCountry] = useState('');
16 | const [description, setDescription] = useState('');
17 | const [images, setImages] = useState([]);
18 | const [newImages, setNewImages] = useState([]);
19 | const [validationErrors, setValidationErrors] = useState([]);
20 |
21 | const listingId = useParams().id;
22 |
23 | const history = useHistory();
24 |
25 | const onDrop = useCallback((acceptedFiles, rejectedFiles) => {
26 | const mappedAcc = acceptedFiles.map(file => ({file, errors: []}));
27 | if(context === "post") {
28 | setImages(current => [...current, ...mappedAcc, ...rejectedFiles]);
29 | } else {
30 | setNewImages(current => [...current, ...mappedAcc, ...rejectedFiles]);
31 | }
32 | }, [context]);
33 |
34 | const { getRootProps, getInputProps } = useDropzone({onDrop});
35 |
36 | const removeImage = (e) => {
37 | if(context === "post") {
38 | setImages(images.filter(image => image.file.path !== e.target.getAttribute("urlkey")));
39 | } else {
40 | setNewImages(newImages.filter(image => image.file.path !== e.target.getAttribute("urlkey")));
41 | }
42 | }
43 |
44 | const removeOldImage = async (e) => {
45 | try {
46 | const res = await csrfFetch(`/api/images/${e.target.getAttribute("urlkey")}`, {
47 | method: 'DELETE',
48 | });
49 | if(res.ok) {
50 | await res.json();
51 | const imageRes = await csrfFetch(`/api/listings/${listingId}`);
52 | if(imageRes.ok) {
53 | const listing = await imageRes.json();
54 | setImages(listing.Images);
55 | }
56 | }
57 | } catch(e) {
58 |
59 | }
60 |
61 | };
62 |
63 | const handleSubmit = async (e) => {
64 | e.preventDefault();
65 | try {
66 | const formData = new FormData();
67 | formData.append("name", name);
68 | formData.append("address", address);
69 | formData.append("city", city);
70 | formData.append("state", state);
71 | formData.append("country", country);
72 | formData.append("price", 20.00);
73 | formData.append("description", description);
74 |
75 | if(context === "edit") {
76 | if(newImages && newImages.length !== 0) {
77 | for(let i = 0; i < newImages.length; i++) {
78 | formData.append("images", newImages[i].file);
79 | }
80 | }
81 | } else {
82 | if(images && images.length !== 0) {
83 | for(let i = 0; i < images.length; i++) {
84 | formData.append("images", images[i].file);
85 | }
86 | }
87 | }
88 |
89 | const res = await csrfFetch(context === "edit" ? `/api/listings/${listingId}` : '/api/listings', {
90 | method: context === "edit" ? "PUT" : "POST",
91 | headers: { 'Content-Type': 'multipart/form-data'},
92 | body: formData
93 | });
94 |
95 | if(res.ok) {
96 | history.push('/listings');
97 | }
98 |
99 | } catch(err) {
100 | const errors = await err.json();
101 | setValidationErrors(errors.errors);
102 | }
103 | }
104 |
105 | useEffect(() => {
106 | if(context === "edit") {
107 | (async function () {
108 | try {
109 | const res = await csrfFetch(`/api/listings/${listingId}`);
110 |
111 | if(res.ok) {
112 | const listing = await res.json();
113 |
114 | setName(listing.name);
115 | setAddress(listing.address);
116 | setCity(listing.city);
117 | setState(listing.state);
118 | setCountry(listing.country);
119 | setDescription(listing.description);
120 | setImages(listing.Images);
121 | }
122 | } catch (err) {
123 | }
124 | })();
125 | }
126 | }, [context, listingId]);
127 |
128 | return (
129 |
132 |
133 |
134 | setName(e.target.value)}
141 | />
142 |
143 | Name your listing
144 |
145 |
146 |
147 |
148 | setAddress(e.target.value)}
155 | />
156 |
157 | What's the street address?
158 |
159 |
160 |
161 |
162 | setCity(e.target.value)}
169 | />
170 |
171 | Workspace City
172 |
173 |
174 |
175 |
176 | setState(e.target.value)}
183 | />
184 |
185 | Workspace State/Province
186 |
187 |
188 |
189 |
190 | setCountry(e.target.value)}
197 | />
198 |
199 | Workspace Country
200 |
201 |
202 |
203 |
204 | Upload some images of your place (the first will be the cover image).
205 |
206 |
207 |
212 |
Drag and drop your images here.
213 |
214 |
215 | {context === "edit" && New images to add to this listing (click to remove):
}
216 |
217 | {context === "post" && images.map((imageWrapper, i) => (
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 | {`Image #${i + 1}` + (i === 0 ? ` (Cover Image)` : '')}
227 |
228 |
229 | ))}
230 | {context === "edit" && newImages.map((imageWrapper, i) => (
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 | {`Image #${i + images.length + 1}` + (i - images.length === 0 ? ` (Cover Image)` : '')}
240 |
241 |
242 | ))}
243 |
244 | {context === "edit" && Images for this listing (click to remove):
}
245 |
246 | {context === "edit" && images.map((image, i) => (
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 | {`Image #${i + 1}` + (i === 0 ? ` (Cover Image)` : '')}
256 |
257 |
258 | ))}
259 |
260 |
261 |
262 |
263 | Describe the details of your listing:
264 |
265 | setDescription(e.target.value)}
270 | required
271 | />
272 |
273 |
274 | {context === "post" ? 'Create Listing' : 'Update Listing'}
275 |
276 |
277 | );
278 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Table of Contents
2 | 1. [Welcome to Work-In!](https://github.com/KagenLH/Work-In#welcome-to-work-in)
3 | 2. [Technologies](https://github.com/KagenLH/Work-In#technologies)
4 | 3. [Relevant Documents](https://github.com/KagenLH/Work-In#relevant-documents)
5 | 4. [Navigating and using Work-In](https://github.com/KagenLH/Work-In#navigating-and-using-work-in)
6 | * [Home](https://github.com/KagenLH/Work-In#home)
7 | * [Listings Search](https://github.com/KagenLH/Work-In#listings-search)
8 | * [Individual Listing Page](https://github.com/KagenLH/Work-In#individual-listing-page)
9 | 5. [Running Work-In on your local machine](https://github.com/KagenLH/Work-In#running-work-in-on-your-local-machine)
10 | 6. [Technical Challenges and Implementation Details](https://github.com/KagenLH/Work-In#technical-challenges-and-implementation-details)
11 | * [Image Viewer](https://github.com/KagenLH/Work-In#image-viewer)
12 | - [Displaying the image viewer properly](https://github.com/KagenLH/Work-In#1-displaying-the-image-viewer-properly)
13 | - [Ability to scroll images and show the correct image](https://github.com/KagenLH/Work-In#2-ability-to-scroll-images-and-show-the-correct-image)
14 | * [Adding/Removing Images from a listing with the edit listing form](https://github.com/KagenLH/Work-In#addingremoving-images-from-a-listing-with-the-edit-listing-form)
15 | - [Separating already existing images from new user uploads](https://github.com/KagenLH/Work-In#1-separating-already-existing-images-from-new-user-uploads)
16 | - [Cleanly removing existing images from listings](https://github.com/KagenLH/Work-In#2-cleanly-removing-existing-images-from-listings)
17 | 7. [Future Features and Plans](https://github.com/KagenLH/Work-In#future-features-and-plans)
18 |
19 | ## Welcome to Work-In!
20 | Work-In is a web application inspired by the online travel booking site Airbnb. This is a clone that aims to use the model of the online reservation system for workspaces and short-term/one-time rental of establishments such as offices, rooms, and clinics.
21 |
22 | You can try this app at the following link: [https://work-in-live.herokuapp.com/](https://work-in-live.herokuapp.com/)
23 |
24 | ## Technologies
25 | - Express.js
26 | - Sequelize
27 | - PostgreSQL
28 | - React.js
29 | - Redux
30 | - Node.js
31 | - AWS S3
32 | - Pure CSS (no libraries or extensions)
33 |
34 | ## Relevant Documents
35 | - [Feature List](https://github.com/KagenLH/Work-In/wiki/Feature-List)
36 | - [Database Schema](https://github.com/KagenLH/Work-In/wiki/Database-Schema)
37 | - [React Component List](https://github.com/KagenLH/Work-In/wiki/React-Components-List)
38 | - [Frontend Routes](https://github.com/KagenLH/Work-In/wiki/Frontend-Routes)
39 | - [API Routes](https://github.com/KagenLH/Work-In/wiki/API-Routes)
40 |
41 | ## Navigating and using Work-In
42 | ### Home
43 | When you navigate to the homepage of the application you will be presented with this splash screen:
44 | 
45 |
46 | From here you can click on the login or signup links to bring up a modal where you can login if you have an account, signup if you don't, or otherwise just login with the Demo Host account.
47 |
48 | 
49 |
50 | You can also begin searching immediately for a location or a listing by name, and suggested results will begin to appear for you.
51 |
52 | 
53 |
54 | Clicking on one of those suggestions will direct you to the page for that listing:
55 |
56 | 
57 |
58 | ### Listings Search
59 | Clicking on the "Listings" navigation link will take you to a page that shows all of the available listings on the platform.
60 |
61 | 
62 |
63 | Alternatively, you can search for more specific listings in the search bar:
64 |
65 | 
66 |
67 | Pressing enter during your search input or pressing the search icon will direct you to a page with listings that match only your search criterion:
68 |
69 | 
70 |
71 | ### Individual Listing Page
72 | Clicking on the card for any of the listings on the listing search page will navigate to the specific page for that individual listing.
73 |
74 | 
75 |
76 | From here you can click on the "Show All Photos" button to bring up an image viewer where you can look at images in better proportions and more detail. You can click the left or right arrows to navigate between the various images available for the listing.
77 |
78 | 
79 |
80 | If you decide that you like the listing and would like to reserve it for some time, you can check availability and set your dates with the date pickers in the booking box.
81 |
82 | 
83 |
84 | You will be presented with the details of the booking you have selected, the total for that reservation, and the math used to calculate that total. If you wish to proceed with booking, you can click the "Reserve This Listing" button. This will bring up a confirmation popup modal:
85 |
86 | 
87 |
88 | If you then click on the "Reserve Listing" button, your booking will be created! You have now reserved a workspace!
89 |
90 |
91 | ## Running Work-In on your local machine
92 | Before running Work-In in the development environment you'll need an up to date installation of Node.js and PostgreSQL. These instructions will assume that you already have both of those already installed and configured. Also assure ahead of time that no other processes are running on port 5000.
93 |
94 | From wherever you wish to place your development installation:
95 | ```bash
96 | git clone https://github.com/KagenLH/Work-In.git
97 | ```
98 | Once the repository is pulled from GitHub, you're ready to start setup.
99 | First install dependencies:
100 | ```bash
101 | cd Work-In
102 | npm install
103 | ```
104 | Create a .env file in the root of the backend folder. Copy the values from the .env.example file. You can either copy all of the values verbatim or change the values to values of your liking. Keep in mind that you won't be able to add new images/listings with your local application because it requires the secret AWS access key. You can change the JWT_SECRET if you like. Your .env should look like this:
105 | ```
106 | PORT=5000
107 | DB_USERNAME=workin_app
108 | DB_DATABASE=workin_development
109 | DB_PASSWORD=password
110 | DB_HOST=localhost
111 | JWT_SECRET=secret
112 | JWT_EXPIRES_IN=608000
113 | ```
114 | Now you can create, migrate, and seed the database
115 | ```bash
116 | npm run sequelize db:create
117 | npm run sequelize db:migrate
118 | npm run sequelize db:seed:all
119 | ```
120 | Now you're ready to start the API server:
121 | ```bash
122 | cd backend
123 | npm start
124 | ```
125 | Open another terminal window, and from the root project directory navigate to the front-end and start the React server:
126 | ```bash
127 | cd frontend
128 | npm start
129 | ```
130 | ## Technical Challenges and Implementation Details
131 | ### Image Viewer
132 | When creating the image viewing overlay there was no pre-existing template available for me to work from, so I approached this task with a mind for designing my own from the ground up. This presented a number of challenges.
133 |
134 | #### 1. Displaying the image viewer properly
135 | The image viewer should appear on the page and cover every other element on the page. When it is on it should be the only feature on the page. I decided to approach this by creating a new component [ImageViewer](https://github.com/KagenLH/Work-In/wiki/React-Components-List) to hold the image viewer in that would be of fixed position, cover the entire page, and obscure everything behind it:
136 | ```css
137 | .image-viewer__container {
138 | position: fixed;
139 | background: rgba(0, 0, 0, 0.9);
140 | width: 100%;
141 | height: 100%;
142 | z-index: 5;
143 | }
144 | ```
145 | with a proper overlay in place, now the image viewer applet could be added over top of it.
146 |
147 | #### 2. Ability to scroll images and show the correct image
148 | I decided that the image viewer should only display one image at a time with the ability to switch between them with side arrow buttons. In order to manage the image that should be shown at any given time, a new `imageViewer` piece of global state was created and configured in the Redux store. The `ImageViewer` component would read that slice of state to determine the `currentImage`, and display that image accordingly:
149 | ```jsx
150 |
153 |
154 |
155 | ```
156 | The images for the current listing page would be passed as a prop to the ImageViewer component, and anytime that one of the side arrow buttons is clicked, the `imageViewer.currentImage` slice of state is updated to the value of the next index in the images array.
157 |
158 | ### Adding/Removing Images from a Listing with the Edit Listing Form
159 | Users editing their listings should be able to add new images to their listing, or remove existing ones. This should be facilitated in a pleasant, graphical, and user friendly manner. This required a significantly more sophisticated approach than editing the existing text fields for the listing.
160 |
161 | #### 1. Separating already existing images from new user uploads
162 | Images that are already attached to the listing already have rows in the database. They are displayed and added into the form by their access URL on AWS, and thus sending in their raw data is not possible using the same methods that the Listing Post uses without doing extra work. Furthermore, there is simply no need to send these images in again with the edit request since they already exist. Therefore separate containers and management are needed for images the user has already created and images that the user is currently adding. I decided to implement this by separating the two form fields into two separate slices of local component state:
163 |
164 | ```jsx
165 | const [images, setImages] = useState([]);
166 | const [newImages, setNewImages] = useState([]);
167 | ```
168 | Each is rendered in a separate portion of the DOM using their respective piece of state. When the time comes to submit the form, only the newImages array is appended into the FormData object to be sent to the server.
169 |
170 | #### 2. Cleanly removing existing images from listings
171 | When the user clicks on one of the images they have in the form, it will be removed. If its an image they have just added, it will simply be removed from the form. If its an image that already existed on their listing, it will be destroyed in the database as well. Because each image has a row in the table and in the database the listing owns the image via a foreign key on the image table, simply deleting the image in the database is sufficient to detach it from the listing.
172 |
173 | First, send the appropriate request to the API server:
174 | ```jsx
175 | const res = await csrfFetch(`/api/images/${e.target.getAttribute("urlkey")}`, {
176 | method: 'DELETE',
177 | });
178 | ```
179 | Where `urlkey` is a custom attribute given to each image indicating the unique ID for that image.
180 |
181 | Next, handle the request and destroy the image on the backend:
182 | ```js
183 | const image = await Image.findByPk(imageId);
184 | const listing = await Listing.findByPk(image.listingId);
185 | if(userId === listing.userId) {
186 | await image.destroy();
187 | res.json({ message: "Image deletion was successful." });
188 | } else {
189 | const err = new Error("You can only delete images that you own.");
190 | next(err);
191 | }
192 | ```
193 | ## Future features and plans
194 |
195 | * User Profiles and uploaded avatars
196 |
197 | * User Inbox and Messages
198 |
199 | * Ability to share listings with other platforms such as messaging, social media, etc.
200 |
201 | * Google Geolocation and Google Maps API integration
202 |
--------------------------------------------------------------------------------