├── .gitignore ├── api ├── index.js ├── models │ ├── Booking.js │ ├── Place.js │ └── User.js ├── package.json └── yarn.lock ├── client ├── .env ├── .gitignore ├── index.html ├── package.json ├── postcss.config.cjs ├── public │ └── vite.svg ├── src │ ├── AccountNav.jsx │ ├── AddressLink.jsx │ ├── App.css │ ├── App.jsx │ ├── BookingDates.jsx │ ├── BookingWidget.jsx │ ├── Header.jsx │ ├── Image.jsx │ ├── Layout.jsx │ ├── Perks.jsx │ ├── PhotosUploader.jsx │ ├── PlaceGallery.jsx │ ├── PlaceImg.jsx │ ├── UserContext.jsx │ ├── assets │ │ └── react.svg │ ├── index.css │ ├── main.jsx │ └── pages │ │ ├── BookingPage.jsx │ │ ├── BookingsPage.jsx │ │ ├── IndexPage.jsx │ │ ├── LoginPage.jsx │ │ ├── PlacePage.jsx │ │ ├── PlacesFormPage.jsx │ │ ├── PlacesPage.jsx │ │ ├── ProfilePage.jsx │ │ └── RegisterPage.jsx ├── tailwind.config.cjs ├── vite.config.js └── yarn.lock └── vercel.json /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | api/node_modules 3 | api/.env 4 | api/uploads/* 5 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const cors = require('cors'); 3 | const mongoose = require("mongoose"); 4 | const bcrypt = require('bcryptjs'); 5 | const jwt = require('jsonwebtoken'); 6 | const User = require('./models/User.js'); 7 | const Place = require('./models/Place.js'); 8 | const Booking = require('./models/Booking.js'); 9 | const cookieParser = require('cookie-parser'); 10 | const imageDownloader = require('image-downloader'); 11 | const {S3Client, PutObjectCommand} = require('@aws-sdk/client-s3'); 12 | const multer = require('multer'); 13 | const fs = require('fs'); 14 | const mime = require('mime-types'); 15 | 16 | require('dotenv').config(); 17 | const app = express(); 18 | 19 | const bcryptSalt = bcrypt.genSaltSync(10); 20 | const jwtSecret = 'fasefraw4r5r3wq45wdfgw34twdfg'; 21 | const bucket = 'dawid-booking-app'; 22 | 23 | app.use(express.json()); 24 | app.use(cookieParser()); 25 | app.use('/uploads', express.static(__dirname+'/uploads')); 26 | app.use(cors({ 27 | credentials: true, 28 | origin: 'http://127.0.0.1:5173', 29 | })); 30 | 31 | async function uploadToS3(path, originalFilename, mimetype) { 32 | const client = new S3Client({ 33 | region: 'us-east-1', 34 | credentials: { 35 | accessKeyId: process.env.S3_ACCESS_KEY, 36 | secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, 37 | }, 38 | }); 39 | const parts = originalFilename.split('.'); 40 | const ext = parts[parts.length - 1]; 41 | const newFilename = Date.now() + '.' + ext; 42 | await client.send(new PutObjectCommand({ 43 | Bucket: bucket, 44 | Body: fs.readFileSync(path), 45 | Key: newFilename, 46 | ContentType: mimetype, 47 | ACL: 'public-read', 48 | })); 49 | return `https://${bucket}.s3.amazonaws.com/${newFilename}`; 50 | } 51 | 52 | function getUserDataFromReq(req) { 53 | return new Promise((resolve, reject) => { 54 | jwt.verify(req.cookies.token, jwtSecret, {}, async (err, userData) => { 55 | if (err) throw err; 56 | resolve(userData); 57 | }); 58 | }); 59 | } 60 | 61 | app.get('/api/test', (req,res) => { 62 | mongoose.connect(process.env.MONGO_URL); 63 | res.json('test ok'); 64 | }); 65 | 66 | app.post('/api/register', async (req,res) => { 67 | mongoose.connect(process.env.MONGO_URL); 68 | const {name,email,password} = req.body; 69 | 70 | try { 71 | const userDoc = await User.create({ 72 | name, 73 | email, 74 | password:bcrypt.hashSync(password, bcryptSalt), 75 | }); 76 | res.json(userDoc); 77 | } catch (e) { 78 | res.status(422).json(e); 79 | } 80 | 81 | }); 82 | 83 | app.post('/api/login', async (req,res) => { 84 | mongoose.connect(process.env.MONGO_URL); 85 | const {email,password} = req.body; 86 | const userDoc = await User.findOne({email}); 87 | if (userDoc) { 88 | const passOk = bcrypt.compareSync(password, userDoc.password); 89 | if (passOk) { 90 | jwt.sign({ 91 | email:userDoc.email, 92 | id:userDoc._id 93 | }, jwtSecret, {}, (err,token) => { 94 | if (err) throw err; 95 | res.cookie('token', token).json(userDoc); 96 | }); 97 | } else { 98 | res.status(422).json('pass not ok'); 99 | } 100 | } else { 101 | res.json('not found'); 102 | } 103 | }); 104 | 105 | app.get('/api/profile', (req,res) => { 106 | mongoose.connect(process.env.MONGO_URL); 107 | const {token} = req.cookies; 108 | if (token) { 109 | jwt.verify(token, jwtSecret, {}, async (err, userData) => { 110 | if (err) throw err; 111 | const {name,email,_id} = await User.findById(userData.id); 112 | res.json({name,email,_id}); 113 | }); 114 | } else { 115 | res.json(null); 116 | } 117 | }); 118 | 119 | app.post('/api/logout', (req,res) => { 120 | res.cookie('token', '').json(true); 121 | }); 122 | 123 | 124 | app.post('/api/upload-by-link', async (req,res) => { 125 | const {link} = req.body; 126 | const newName = 'photo' + Date.now() + '.jpg'; 127 | await imageDownloader.image({ 128 | url: link, 129 | dest: '/tmp/' +newName, 130 | }); 131 | const url = await uploadToS3('/tmp/' +newName, newName, mime.lookup('/tmp/' +newName)); 132 | res.json(url); 133 | }); 134 | 135 | const photosMiddleware = multer({dest:'/tmp'}); 136 | app.post('/api/upload', photosMiddleware.array('photos', 100), async (req,res) => { 137 | const uploadedFiles = []; 138 | for (let i = 0; i < req.files.length; i++) { 139 | const {path,originalname,mimetype} = req.files[i]; 140 | const url = await uploadToS3(path, originalname, mimetype); 141 | uploadedFiles.push(url); 142 | } 143 | res.json(uploadedFiles); 144 | }); 145 | 146 | app.post('/api/places', (req,res) => { 147 | mongoose.connect(process.env.MONGO_URL); 148 | const {token} = req.cookies; 149 | const { 150 | title,address,addedPhotos,description,price, 151 | perks,extraInfo,checkIn,checkOut,maxGuests, 152 | } = req.body; 153 | jwt.verify(token, jwtSecret, {}, async (err, userData) => { 154 | if (err) throw err; 155 | const placeDoc = await Place.create({ 156 | owner:userData.id,price, 157 | title,address,photos:addedPhotos,description, 158 | perks,extraInfo,checkIn,checkOut,maxGuests, 159 | }); 160 | res.json(placeDoc); 161 | }); 162 | }); 163 | 164 | app.get('/api/user-places', (req,res) => { 165 | mongoose.connect(process.env.MONGO_URL); 166 | const {token} = req.cookies; 167 | jwt.verify(token, jwtSecret, {}, async (err, userData) => { 168 | const {id} = userData; 169 | res.json( await Place.find({owner:id}) ); 170 | }); 171 | }); 172 | 173 | app.get('/api/places/:id', async (req,res) => { 174 | mongoose.connect(process.env.MONGO_URL); 175 | const {id} = req.params; 176 | res.json(await Place.findById(id)); 177 | }); 178 | 179 | app.put('/api/places', async (req,res) => { 180 | mongoose.connect(process.env.MONGO_URL); 181 | const {token} = req.cookies; 182 | const { 183 | id, title,address,addedPhotos,description, 184 | perks,extraInfo,checkIn,checkOut,maxGuests,price, 185 | } = req.body; 186 | jwt.verify(token, jwtSecret, {}, async (err, userData) => { 187 | if (err) throw err; 188 | const placeDoc = await Place.findById(id); 189 | if (userData.id === placeDoc.owner.toString()) { 190 | placeDoc.set({ 191 | title,address,photos:addedPhotos,description, 192 | perks,extraInfo,checkIn,checkOut,maxGuests,price, 193 | }); 194 | await placeDoc.save(); 195 | res.json('ok'); 196 | } 197 | }); 198 | }); 199 | 200 | app.get('/api/places', async (req,res) => { 201 | mongoose.connect(process.env.MONGO_URL); 202 | res.json( await Place.find() ); 203 | }); 204 | 205 | app.post('/api/bookings', async (req, res) => { 206 | mongoose.connect(process.env.MONGO_URL); 207 | const userData = await getUserDataFromReq(req); 208 | const { 209 | place,checkIn,checkOut,numberOfGuests,name,phone,price, 210 | } = req.body; 211 | Booking.create({ 212 | place,checkIn,checkOut,numberOfGuests,name,phone,price, 213 | user:userData.id, 214 | }).then((doc) => { 215 | res.json(doc); 216 | }).catch((err) => { 217 | throw err; 218 | }); 219 | }); 220 | 221 | 222 | 223 | app.get('/api/bookings', async (req,res) => { 224 | mongoose.connect(process.env.MONGO_URL); 225 | const userData = await getUserDataFromReq(req); 226 | res.json( await Booking.find({user:userData.id}).populate('place') ); 227 | }); 228 | 229 | app.listen(4000); -------------------------------------------------------------------------------- /api/models/Booking.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const bookingSchema = new mongoose.Schema({ 4 | place: {type:mongoose.Schema.Types.ObjectId, required:true, ref:'Place'}, 5 | user: {type:mongoose.Schema.Types.ObjectId, required:true}, 6 | checkIn: {type:Date, required:true}, 7 | checkOut: {type:Date, required:true}, 8 | name: {type:String, required:true}, 9 | phone: {type:String, required:true}, 10 | price: Number, 11 | }); 12 | 13 | const BookingModel = mongoose.model('Booking', bookingSchema); 14 | 15 | module.exports = BookingModel; -------------------------------------------------------------------------------- /api/models/Place.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const placeSchema = new mongoose.Schema({ 4 | owner: {type:mongoose.Schema.Types.ObjectId, ref:'User'}, 5 | title: String, 6 | address: String, 7 | photos: [String], 8 | description: String, 9 | perks: [String], 10 | extraInfo: String, 11 | checkIn: Number, 12 | checkOut: Number, 13 | maxGuests: Number, 14 | price: Number, 15 | }); 16 | 17 | const PlaceModel = mongoose.model('Place', placeSchema); 18 | 19 | module.exports = PlaceModel; -------------------------------------------------------------------------------- /api/models/User.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const {Schema} = mongoose; 3 | 4 | const UserSchema = new Schema({ 5 | name: String, 6 | email: {type:String, unique:true}, 7 | password: String, 8 | }); 9 | 10 | const UserModel = mongoose.model('User', UserSchema); 11 | 12 | module.exports = UserModel; -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@aws-sdk/client-s3": "^3.279.0", 4 | "bcryptjs": "^2.4.3", 5 | "cookie-parser": "^1.4.6", 6 | "cors": "^2.8.5", 7 | "dotenv": "^16.0.3", 8 | "express": "^4.18.2", 9 | "image-downloader": "^4.3.0", 10 | "jsonwebtoken": "^9.0.0", 11 | "mime-types": "^2.1.35", 12 | "mongoose": "^6.8.3", 13 | "multer": "^1.4.5-lts.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dejwid/airbnb-clone/5fda1b2a18c55b0facb961b3fe38570e090ba4a1/client/.env -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "autoprefixer": "^10.4.13", 13 | "axios": "^1.2.2", 14 | "date-fns": "^2.29.3", 15 | "postcss": "^8.4.21", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "react-router-dom": "^6.6.2", 19 | "tailwindcss": "^3.2.4" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.0.26", 23 | "@types/react-dom": "^18.0.9", 24 | "@vitejs/plugin-react": "^3.0.0", 25 | "vite": "^4.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/AccountNav.jsx: -------------------------------------------------------------------------------- 1 | import {Link, useLocation} from "react-router-dom"; 2 | 3 | export default function AccountNav() { 4 | const {pathname} = useLocation(); 5 | let subpage = pathname.split('/')?.[2]; 6 | if (subpage === undefined) { 7 | subpage = 'profile'; 8 | } 9 | function linkClasses (type=null) { 10 | let classes = 'inline-flex gap-1 py-2 px-6 rounded-full'; 11 | if (type === subpage) { 12 | classes += ' bg-primary text-white'; 13 | } else { 14 | classes += ' bg-gray-200'; 15 | } 16 | return classes; 17 | } 18 | return ( 19 | 39 | ); 40 | } -------------------------------------------------------------------------------- /client/src/AddressLink.jsx: -------------------------------------------------------------------------------- 1 | export default function AddressLink({children,className=null}) { 2 | if (!className) { 3 | className = 'my-3 block'; 4 | } 5 | className += ' flex gap-1 font-semibold underline'; 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | {children} 13 | 14 | ); 15 | } -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dejwid/airbnb-clone/5fda1b2a18c55b0facb961b3fe38570e090ba4a1/client/src/App.css -------------------------------------------------------------------------------- /client/src/App.jsx: -------------------------------------------------------------------------------- 1 | import './App.css' 2 | import {Route, Routes} from "react-router-dom"; 3 | import IndexPage from "./pages/IndexPage.jsx"; 4 | import LoginPage from "./pages/LoginPage"; 5 | import Layout from "./Layout"; 6 | import RegisterPage from "./pages/RegisterPage"; 7 | import axios from "axios"; 8 | import {UserContextProvider} from "./UserContext"; 9 | import ProfilePage from "./pages/ProfilePage.jsx"; 10 | import PlacesPage from "./pages/PlacesPage"; 11 | import PlacesFormPage from "./pages/PlacesFormPage"; 12 | import PlacePage from "./pages/PlacePage"; 13 | import BookingsPage from "./pages/BookingsPage"; 14 | import BookingPage from "./pages/BookingPage"; 15 | 16 | axios.defaults.baseURL = import.meta.env.VITE_API_BASE_URL; 17 | axios.defaults.withCredentials = true; 18 | 19 | function App() { 20 | return ( 21 | 22 | 23 | }> 24 | } /> 25 | } /> 26 | } /> 27 | } /> 28 | } /> 29 | } /> 30 | } /> 31 | } /> 32 | } /> 33 | } /> 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | export default App 41 | -------------------------------------------------------------------------------- /client/src/BookingDates.jsx: -------------------------------------------------------------------------------- 1 | import {differenceInCalendarDays, format} from "date-fns"; 2 | 3 | export default function BookingDates({booking,className}) { 4 | return ( 5 |
6 | 7 | 8 | 9 | {differenceInCalendarDays(new Date(booking.checkOut), new Date(booking.checkIn))} nights: 10 |
11 | 12 | 13 | 14 | {format(new Date(booking.checkIn), 'yyyy-MM-dd')} 15 |
16 | → 17 |
18 | 19 | 20 | 21 | {format(new Date(booking.checkOut), 'yyyy-MM-dd')} 22 |
23 |
24 | ); 25 | } -------------------------------------------------------------------------------- /client/src/BookingWidget.jsx: -------------------------------------------------------------------------------- 1 | import {useContext, useEffect, useState} from "react"; 2 | import {differenceInCalendarDays} from "date-fns"; 3 | import axios from "axios"; 4 | import {Navigate} from "react-router-dom"; 5 | import {UserContext} from "./UserContext.jsx"; 6 | 7 | export default function BookingWidget({place}) { 8 | const [checkIn,setCheckIn] = useState(''); 9 | const [checkOut,setCheckOut] = useState(''); 10 | const [numberOfGuests,setNumberOfGuests] = useState(1); 11 | const [name,setName] = useState(''); 12 | const [phone,setPhone] = useState(''); 13 | const [redirect,setRedirect] = useState(''); 14 | const {user} = useContext(UserContext); 15 | 16 | useEffect(() => { 17 | if (user) { 18 | setName(user.name); 19 | } 20 | }, [user]); 21 | 22 | let numberOfNights = 0; 23 | if (checkIn && checkOut) { 24 | numberOfNights = differenceInCalendarDays(new Date(checkOut), new Date(checkIn)); 25 | } 26 | 27 | async function bookThisPlace() { 28 | const response = await axios.post('/bookings', { 29 | checkIn,checkOut,numberOfGuests,name,phone, 30 | place:place._id, 31 | price:numberOfNights * place.price, 32 | }); 33 | const bookingId = response.data._id; 34 | setRedirect(`/account/bookings/${bookingId}`); 35 | } 36 | 37 | if (redirect) { 38 | return 39 | } 40 | 41 | return ( 42 |
43 |
44 | Price: ${place.price} / per night 45 |
46 |
47 |
48 |
49 | 50 | setCheckIn(ev.target.value)}/> 53 |
54 |
55 | 56 | setCheckOut(ev.target.value)}/> 58 |
59 |
60 |
61 | 62 | setNumberOfGuests(ev.target.value)}/> 65 |
66 | {numberOfNights > 0 && ( 67 |
68 | 69 | setName(ev.target.value)}/> 72 | 73 | setPhone(ev.target.value)}/> 76 |
77 | )} 78 |
79 | 85 |
86 | ); 87 | } -------------------------------------------------------------------------------- /client/src/Header.jsx: -------------------------------------------------------------------------------- 1 | import {Link} from "react-router-dom"; 2 | import {useContext} from "react"; 3 | import {UserContext} from "./UserContext.jsx"; 4 | 5 | export default function Header() { 6 | const {user} = useContext(UserContext); 7 | return ( 8 |
9 | 10 | 11 | 12 | 13 | airbnb 14 | 15 |
16 |
Anywhere
17 |
18 |
Any week
19 |
20 |
Add guests
21 | 26 |
27 | 28 | 29 | 30 | 31 |
32 | 33 | 34 | 35 |
36 | {!!user && ( 37 |
38 | {user.name} 39 |
40 | )} 41 | 42 |
43 | ); 44 | } -------------------------------------------------------------------------------- /client/src/Image.jsx: -------------------------------------------------------------------------------- 1 | export default function Image({src,...rest}) { 2 | src = src && src.includes('https://') 3 | ? src 4 | : 'http://localhost:4000/uploads/'+src; 5 | return ( 6 | {''} 7 | ); 8 | } -------------------------------------------------------------------------------- /client/src/Layout.jsx: -------------------------------------------------------------------------------- 1 | import Header from "./Header"; 2 | import {Outlet} from "react-router-dom"; 3 | 4 | export default function Layout() { 5 | return ( 6 | 7 |
8 |
9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /client/src/Perks.jsx: -------------------------------------------------------------------------------- 1 | export default function Perks({selected,onChange}) { 2 | function handleCbClick(ev) { 3 | const {checked,name} = ev.target; 4 | if (checked) { 5 | onChange([...selected,name]); 6 | } else { 7 | onChange([...selected.filter(selectedName => selectedName !== name)]); 8 | } 9 | } 10 | return ( 11 | <> 12 | 19 | 26 | 33 | 40 | 49 | 56 | 57 | ); 58 | } -------------------------------------------------------------------------------- /client/src/PhotosUploader.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import {useState} from "react"; 3 | import Image from "./Image.jsx"; 4 | 5 | export default function PhotosUploader({addedPhotos,onChange}) { 6 | const [photoLink,setPhotoLink] = useState(''); 7 | async function addPhotoByLink(ev) { 8 | ev.preventDefault(); 9 | const {data:filename} = await axios.post('/upload-by-link', {link: photoLink}); 10 | onChange(prev => { 11 | return [...prev, filename]; 12 | }); 13 | setPhotoLink(''); 14 | } 15 | function uploadPhoto(ev) { 16 | const files = ev.target.files; 17 | const data = new FormData(); 18 | for (let i = 0; i < files.length; i++) { 19 | data.append('photos', files[i]); 20 | } 21 | axios.post('/upload', data, { 22 | headers: {'Content-type':'multipart/form-data'} 23 | }).then(response => { 24 | const {data:filenames} = response; 25 | onChange(prev => { 26 | return [...prev, ...filenames]; 27 | }); 28 | }) 29 | } 30 | function removePhoto(ev,filename) { 31 | ev.preventDefault(); 32 | onChange([...addedPhotos.filter(photo => photo !== filename)]); 33 | } 34 | function selectAsMainPhoto(ev,filename) { 35 | ev.preventDefault(); 36 | onChange([filename,...addedPhotos.filter(photo => photo !== filename)]); 37 | } 38 | return ( 39 | <> 40 |
41 | setPhotoLink(ev.target.value)} 43 | type="text" placeholder={'Add using a link ....jpg'}/> 44 | 45 |
46 |
47 | {addedPhotos.length > 0 && addedPhotos.map(link => ( 48 |
49 | 50 | 55 | 67 |
68 | ))} 69 | 76 |
77 | 78 | ); 79 | } -------------------------------------------------------------------------------- /client/src/PlaceGallery.jsx: -------------------------------------------------------------------------------- 1 | import {useState} from "react"; 2 | import Image from "./Image.jsx"; 3 | 4 | export default function PlaceGallery({place}) { 5 | 6 | const [showAllPhotos,setShowAllPhotos] = useState(false); 7 | 8 | if (showAllPhotos) { 9 | return ( 10 |
11 |
12 |
13 |

Photos of {place.title}

14 | 20 |
21 | {place?.photos?.length > 0 && place.photos.map(photo => ( 22 |
23 | 24 |
25 | ))} 26 |
27 |
28 | ); 29 | } 30 | 31 | return ( 32 |
33 |
34 |
35 | {place.photos?.[0] && ( 36 |
37 | setShowAllPhotos(true)} className="aspect-square cursor-pointer object-cover" src={place.photos[0]} alt=""/> 38 |
39 | )} 40 |
41 |
42 | {place.photos?.[1] && ( 43 | setShowAllPhotos(true)} className="aspect-square cursor-pointer object-cover" src={place.photos[1]} alt=""/> 44 | )} 45 |
46 | {place.photos?.[2] && ( 47 | setShowAllPhotos(true)} className="aspect-square cursor-pointer object-cover relative top-2" src={place.photos[2]} alt=""/> 48 | )} 49 |
50 |
51 |
52 | 58 |
59 | ); 60 | } -------------------------------------------------------------------------------- /client/src/PlaceImg.jsx: -------------------------------------------------------------------------------- 1 | import Image from "./Image.jsx"; 2 | 3 | export default function PlaceImg({place,index=0,className=null}) { 4 | if (!place.photos?.length) { 5 | return ''; 6 | } 7 | if (!className) { 8 | className = 'object-cover'; 9 | } 10 | return ( 11 | 12 | ); 13 | } -------------------------------------------------------------------------------- /client/src/UserContext.jsx: -------------------------------------------------------------------------------- 1 | import {createContext, useEffect, useState} from "react"; 2 | import axios from "axios"; 3 | import {data} from "autoprefixer"; 4 | 5 | export const UserContext = createContext({}); 6 | 7 | export function UserContextProvider({children}) { 8 | const [user,setUser] = useState(null); 9 | const [ready,setReady] = useState(false); 10 | useEffect(() => { 11 | if (!user) { 12 | axios.get('/profile').then(({data}) => { 13 | setUser(data); 14 | setReady(true); 15 | }); 16 | } 17 | }, []); 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } -------------------------------------------------------------------------------- /client/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | input[type="text"],input[type="password"], 6 | input[type="email"],input[type="number"], 7 | input[type="tel"], 8 | textarea{ 9 | @apply w-full border my-1 py-2 px-3 rounded-2xl; 10 | } 11 | textarea{ 12 | height: 140px; 13 | } 14 | button{ 15 | @apply bg-gray-300; 16 | } 17 | button.primary{ 18 | background-color: #F5385D; 19 | @apply bg-primary p-2 w-full text-white rounded-2xl; 20 | } -------------------------------------------------------------------------------- /client/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import './index.css' 5 | import {BrowserRouter} from "react-router-dom"; 6 | 7 | ReactDOM.createRoot(document.getElementById('root')).render( 8 | 9 | 10 | 11 | 12 | , 13 | ) 14 | -------------------------------------------------------------------------------- /client/src/pages/BookingPage.jsx: -------------------------------------------------------------------------------- 1 | import {useParams} from "react-router-dom"; 2 | import {useEffect, useState} from "react"; 3 | import axios from "axios"; 4 | import AddressLink from "../AddressLink"; 5 | import PlaceGallery from "../PlaceGallery"; 6 | import BookingDates from "../BookingDates"; 7 | 8 | export default function BookingPage() { 9 | const {id} = useParams(); 10 | const [booking,setBooking] = useState(null); 11 | useEffect(() => { 12 | if (id) { 13 | axios.get('/bookings').then(response => { 14 | const foundBooking = response.data.find(({_id}) => _id === id); 15 | if (foundBooking) { 16 | setBooking(foundBooking); 17 | } 18 | }); 19 | } 20 | }, [id]); 21 | 22 | if (!booking) { 23 | return ''; 24 | } 25 | 26 | return ( 27 |
28 |

{booking.place.title}

29 | {booking.place.address} 30 |
31 |
32 |

Your booking information:

33 | 34 |
35 |
36 |
Total price
37 |
${booking.price}
38 |
39 |
40 | 41 |
42 | ); 43 | } -------------------------------------------------------------------------------- /client/src/pages/BookingsPage.jsx: -------------------------------------------------------------------------------- 1 | import AccountNav from "../AccountNav"; 2 | import {useEffect, useState} from "react"; 3 | import axios from "axios"; 4 | import PlaceImg from "../PlaceImg"; 5 | import {differenceInCalendarDays, format} from "date-fns"; 6 | import {Link} from "react-router-dom"; 7 | import BookingDates from "../BookingDates"; 8 | 9 | export default function BookingsPage() { 10 | const [bookings,setBookings] = useState([]); 11 | useEffect(() => { 12 | axios.get('/bookings').then(response => { 13 | setBookings(response.data); 14 | }); 15 | }, []); 16 | return ( 17 |
18 | 19 |
20 | {bookings?.length > 0 && bookings.map(booking => ( 21 | 22 |
23 | 24 |
25 |
26 |

{booking.place.title}

27 |
28 | 29 |
30 | 31 | 32 | 33 | 34 | Total price: ${booking.price} 35 | 36 |
37 |
38 |
39 | 40 | ))} 41 |
42 |
43 | ); 44 | } -------------------------------------------------------------------------------- /client/src/pages/IndexPage.jsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from "react"; 2 | import axios from "axios"; 3 | import {Link} from "react-router-dom"; 4 | import Image from "../Image.jsx"; 5 | 6 | export default function IndexPage() { 7 | const [places,setPlaces] = useState([]); 8 | useEffect(() => { 9 | axios.get('/places').then(response => { 10 | setPlaces(response.data); 11 | }); 12 | }, []); 13 | return ( 14 |
15 | {places.length > 0 && places.map(place => ( 16 | 17 |
18 | {place.photos?.[0] && ( 19 | 20 | )} 21 |
22 |

{place.address}

23 |

{place.title}

24 |
25 | ${place.price} per night 26 |
27 | 28 | ))} 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /client/src/pages/LoginPage.jsx: -------------------------------------------------------------------------------- 1 | import {Link, Navigate} from "react-router-dom"; 2 | import {useContext, useState} from "react"; 3 | import axios from "axios"; 4 | import {UserContext} from "../UserContext.jsx"; 5 | 6 | export default function LoginPage() { 7 | const [email, setEmail] = useState(''); 8 | const [password, setPassword] = useState(''); 9 | const [redirect, setRedirect] = useState(false); 10 | const {setUser} = useContext(UserContext); 11 | async function handleLoginSubmit(ev) { 12 | ev.preventDefault(); 13 | try { 14 | const {data} = await axios.post('/login', {email,password}); 15 | setUser(data); 16 | alert('Login successful'); 17 | setRedirect(true); 18 | } catch (e) { 19 | alert('Login failed'); 20 | } 21 | } 22 | 23 | if (redirect) { 24 | return 25 | } 26 | 27 | return ( 28 |
29 |
30 |

Login

31 |
32 | setEmail(ev.target.value)} /> 36 | setPassword(ev.target.value)} /> 40 | 41 |
42 | Don't have an account yet? Register now 43 |
44 |
45 |
46 |
47 | ); 48 | } -------------------------------------------------------------------------------- /client/src/pages/PlacePage.jsx: -------------------------------------------------------------------------------- 1 | import {Link, useParams} from "react-router-dom"; 2 | import {useEffect, useState} from "react"; 3 | import axios from "axios"; 4 | import BookingWidget from "../BookingWidget"; 5 | import PlaceGallery from "../PlaceGallery"; 6 | import AddressLink from "../AddressLink"; 7 | 8 | export default function PlacePage() { 9 | const {id} = useParams(); 10 | const [place,setPlace] = useState(null); 11 | useEffect(() => { 12 | if (!id) { 13 | return; 14 | } 15 | axios.get(`/places/${id}`).then(response => { 16 | setPlace(response.data); 17 | }); 18 | }, [id]); 19 | 20 | if (!place) return ''; 21 | 22 | 23 | 24 | return ( 25 |
26 |

{place.title}

27 | {place.address} 28 | 29 |
30 |
31 |
32 |

Description

33 | {place.description} 34 |
35 | Check-in: {place.checkIn}
36 | Check-out: {place.checkOut}
37 | Max number of guests: {place.maxGuests} 38 |
39 |
40 | 41 |
42 |
43 |
44 |
45 |

Extra info

46 |
47 |
{place.extraInfo}
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /client/src/pages/PlacesFormPage.jsx: -------------------------------------------------------------------------------- 1 | import PhotosUploader from "../PhotosUploader.jsx"; 2 | import Perks from "../Perks.jsx"; 3 | import {useEffect, useState} from "react"; 4 | import axios from "axios"; 5 | import AccountNav from "../AccountNav"; 6 | import {Navigate, useParams} from "react-router-dom"; 7 | 8 | export default function PlacesFormPage() { 9 | const {id} = useParams(); 10 | const [title,setTitle] = useState(''); 11 | const [address,setAddress] = useState(''); 12 | const [addedPhotos,setAddedPhotos] = useState([]); 13 | const [description,setDescription] = useState(''); 14 | const [perks,setPerks] = useState([]); 15 | const [extraInfo,setExtraInfo] = useState(''); 16 | const [checkIn,setCheckIn] = useState(''); 17 | const [checkOut,setCheckOut] = useState(''); 18 | const [maxGuests,setMaxGuests] = useState(1); 19 | const [price,setPrice] = useState(100); 20 | const [redirect,setRedirect] = useState(false); 21 | useEffect(() => { 22 | if (!id) { 23 | return; 24 | } 25 | axios.get('/places/'+id).then(response => { 26 | const {data} = response; 27 | setTitle(data.title); 28 | setAddress(data.address); 29 | setAddedPhotos(data.photos); 30 | setDescription(data.description); 31 | setPerks(data.perks); 32 | setExtraInfo(data.extraInfo); 33 | setCheckIn(data.checkIn); 34 | setCheckOut(data.checkOut); 35 | setMaxGuests(data.maxGuests); 36 | setPrice(data.price); 37 | }); 38 | }, [id]); 39 | function inputHeader(text) { 40 | return ( 41 |

{text}

42 | ); 43 | } 44 | function inputDescription(text) { 45 | return ( 46 |

{text}

47 | ); 48 | } 49 | function preInput(header,description) { 50 | return ( 51 | <> 52 | {inputHeader(header)} 53 | {inputDescription(description)} 54 | 55 | ); 56 | } 57 | 58 | async function savePlace(ev) { 59 | ev.preventDefault(); 60 | const placeData = { 61 | title, address, addedPhotos, 62 | description, perks, extraInfo, 63 | checkIn, checkOut, maxGuests, price, 64 | }; 65 | if (id) { 66 | // update 67 | await axios.put('/places', { 68 | id, ...placeData 69 | }); 70 | setRedirect(true); 71 | } else { 72 | // new place 73 | await axios.post('/places', placeData); 74 | setRedirect(true); 75 | } 76 | 77 | } 78 | 79 | if (redirect) { 80 | return 81 | } 82 | 83 | return ( 84 |
85 | 86 |
87 | {preInput('Title', 'Title for your place. should be short and catchy as in advertisement')} 88 | setTitle(ev.target.value)} placeholder="title, for example: My lovely apt"/> 89 | {preInput('Address', 'Address to this place')} 90 | setAddress(ev.target.value)}placeholder="address"/> 91 | {preInput('Photos','more = better')} 92 | 93 | {preInput('Description','description of the place')} 94 |