├── .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 | \A 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 | \A 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 | 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 |
12 |
13 | 14 | 15 | 16 |
17 |
18 | 19 | 20 | 21 |
22 |
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 | 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 | 42 | {showMenu && ( 43 | 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 | \A 23 | 24 | 25 | 26 | } 27 |
    28 | Home 29 | Listings 30 | {sessionUser !== null ? 31 | 32 | : 33 | <> 34 | 35 | 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 | 38 | {currentIndex !== images.length - 1 && 39 | } 50 | {currentIndex !== 0 && } 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 |