├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt └── src ├── App.js ├── assets ├── jpg │ ├── rentCategoryImage.jpg │ └── sellCategoryImage.jpg └── svg │ ├── badgeIcon.svg │ ├── bathtubIcon.svg │ ├── bedIcon.svg │ ├── checkIcon.svg │ ├── deleteIcon.svg │ ├── editIcon.svg │ ├── exploreIcon.svg │ ├── googleIcon.svg │ ├── homeIcon.svg │ ├── keyboardArrowRightIcon.svg │ ├── localOfferIcon.svg │ ├── lockIcon.svg │ ├── personIcon.svg │ ├── personOutlineIcon.svg │ ├── shareIcon.svg │ ├── uploadIcon.svg │ └── visibilityIcon.svg ├── components ├── ListingItem.jsx ├── Navbar.jsx ├── OAuth.jsx ├── PrivateRoute.jsx ├── Slider.jsx └── Spinner.jsx ├── firebase.config.js ├── hooks └── useAuthStatus.js ├── index.css ├── index.js ├── pages ├── Category.jsx ├── Contact.jsx ├── CreateListing.jsx ├── EditListing.jsx ├── Explore.jsx ├── ForgotPassword.jsx ├── Listing.jsx ├── Offers.jsx ├── Profile.jsx ├── SignIn.jsx └── SignUp.jsx └── reportWebVitals.js /.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 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # House Marketplace 2 | 3 | Find and list houses for sale or for rent. This is a React / Firebase v9 project from the React Front To Back 2022 course. 4 | 5 | ## Usage 6 | 7 | ### Geolocation 8 | 9 | The listings use Google geocoding to get the coords from the address field. You need to either rename .env.example to .env and add your Google Geocode API key OR in the **CreateListing.jsx** file you can set **geolocationEnabled** to "false" and it will add a lat/lng field to the form. 10 | 11 | ### Run 12 | 13 | ```bash 14 | npm start 15 | ``` 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "house-marketplace", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.15.0", 7 | "@testing-library/react": "^11.2.7", 8 | "@testing-library/user-event": "^12.8.3", 9 | "firebase": "^9.4.1", 10 | "leaflet": "^1.7.1", 11 | "react": "^17.0.2", 12 | "react-dom": "^17.0.2", 13 | "react-helmet": "^6.1.0", 14 | "react-leaflet": "^3.2.2", 15 | "react-router-dom": "^6.0.2", 16 | "react-scripts": "4.0.3", 17 | "react-toastify": "^8.1.0", 18 | "swiper": "^6.8.1", 19 | "uuid": "^8.3.2", 20 | "web-vitals": "^1.1.2" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test", 26 | "eject": "react-scripts eject" 27 | }, 28 | "eslintConfig": { 29 | "extends": [ 30 | "react-app", 31 | "react-app/jest" 32 | ] 33 | }, 34 | "browserslist": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradtraversy/house-marketplace/3c01914ed909d26a01ef034c81d62199b97498d2/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 33 | House Marketplace 34 | 35 | 36 | 37 |
38 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradtraversy/house-marketplace/3c01914ed909d26a01ef034c81d62199b97498d2/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradtraversy/house-marketplace/3c01914ed909d26a01ef034c81d62199b97498d2/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' 2 | import { ToastContainer } from 'react-toastify' 3 | import 'react-toastify/dist/ReactToastify.css' 4 | import Navbar from './components/Navbar' 5 | import PrivateRoute from './components/PrivateRoute' 6 | import Explore from './pages/Explore' 7 | import Offers from './pages/Offers' 8 | import Category from './pages/Category' 9 | import Profile from './pages/Profile' 10 | import SignIn from './pages/SignIn' 11 | import SignUp from './pages/SignUp' 12 | import ForgotPassword from './pages/ForgotPassword' 13 | import CreateListing from './pages/CreateListing' 14 | import EditListing from './pages/EditListing' 15 | import Listing from './pages/Listing' 16 | import Contact from './pages/Contact' 17 | 18 | function App() { 19 | return ( 20 | <> 21 | 22 | 23 | } /> 24 | } /> 25 | } /> 26 | }> 27 | } /> 28 | 29 | } /> 30 | } /> 31 | } /> 32 | } /> 33 | } /> 34 | } 37 | /> 38 | } /> 39 | 40 | 41 | 42 | 43 | 44 | 45 | ) 46 | } 47 | 48 | export default App 49 | -------------------------------------------------------------------------------- /src/assets/jpg/rentCategoryImage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradtraversy/house-marketplace/3c01914ed909d26a01ef034c81d62199b97498d2/src/assets/jpg/rentCategoryImage.jpg -------------------------------------------------------------------------------- /src/assets/jpg/sellCategoryImage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradtraversy/house-marketplace/3c01914ed909d26a01ef034c81d62199b97498d2/src/assets/jpg/sellCategoryImage.jpg -------------------------------------------------------------------------------- /src/assets/svg/badgeIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/bathtubIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/bedIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/checkIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/deleteIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/editIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/exploreIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/googleIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/assets/svg/homeIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/keyboardArrowRightIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/localOfferIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/lockIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/personIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/personOutlineIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/shareIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/uploadIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/svg/visibilityIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ListingItem.jsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom' 2 | import { ReactComponent as DeleteIcon } from '../assets/svg/deleteIcon.svg' 3 | import { ReactComponent as EditIcon } from '../assets/svg/editIcon.svg' 4 | import bedIcon from '../assets/svg/bedIcon.svg' 5 | import bathtubIcon from '../assets/svg/bathtubIcon.svg' 6 | 7 | function ListingItem({ listing, id, onEdit, onDelete }) { 8 | return ( 9 |
  • 10 | 14 | {listing.name} 19 |
    20 |

    {listing.location}

    21 |

    {listing.name}

    22 | 23 |

    24 | $ 25 | {listing.offer 26 | ? listing.discountedPrice 27 | .toString() 28 | .replace(/\B(?=(\d{3})+(?!\d))/g, ',') 29 | : listing.regularPrice 30 | .toString() 31 | .replace(/\B(?=(\d{3})+(?!\d))/g, ',')} 32 | {listing.type === 'rent' && ' / Month'} 33 |

    34 |
    35 | bed 36 |

    37 | {listing.bedrooms > 1 38 | ? `${listing.bedrooms} Bedrooms` 39 | : '1 Bedroom'} 40 |

    41 | bath 42 |

    43 | {listing.bathrooms > 1 44 | ? `${listing.bathrooms} Bathrooms` 45 | : '1 Bathroom'} 46 |

    47 |
    48 |
    49 | 50 | 51 | {onDelete && ( 52 | onDelete(listing.id, listing.name)} 56 | /> 57 | )} 58 | 59 | {onEdit && onEdit(id)} />} 60 |
  • 61 | ) 62 | } 63 | 64 | export default ListingItem 65 | -------------------------------------------------------------------------------- /src/components/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useLocation } from 'react-router-dom' 2 | import { ReactComponent as OfferIcon } from '../assets/svg/localOfferIcon.svg' 3 | import { ReactComponent as ExploreIcon } from '../assets/svg/exploreIcon.svg' 4 | import { ReactComponent as PersonOutlineIcon } from '../assets/svg/personOutlineIcon.svg' 5 | 6 | function Navbar() { 7 | const navigate = useNavigate() 8 | const location = useLocation() 9 | 10 | const pathMatchRoute = (route) => { 11 | if (route === location.pathname) { 12 | return true 13 | } 14 | } 15 | 16 | return ( 17 | 71 | ) 72 | } 73 | 74 | export default Navbar 75 | -------------------------------------------------------------------------------- /src/components/OAuth.jsx: -------------------------------------------------------------------------------- 1 | import { useLocation, useNavigate } from 'react-router-dom' 2 | import { getAuth, signInWithPopup, GoogleAuthProvider } from 'firebase/auth' 3 | import { doc, setDoc, getDoc, serverTimestamp } from 'firebase/firestore' 4 | import { db } from '../firebase.config' 5 | import { toast } from 'react-toastify' 6 | import googleIcon from '../assets/svg/googleIcon.svg' 7 | 8 | function OAuth() { 9 | const navigate = useNavigate() 10 | const location = useLocation() 11 | 12 | const onGoogleClick = async () => { 13 | try { 14 | const auth = getAuth() 15 | const provider = new GoogleAuthProvider() 16 | const result = await signInWithPopup(auth, provider) 17 | const user = result.user 18 | 19 | // Check for user 20 | const docRef = doc(db, 'users', user.uid) 21 | const docSnap = await getDoc(docRef) 22 | 23 | // If user, doesn't exist, create user 24 | if (!docSnap.exists()) { 25 | await setDoc(doc(db, 'users', user.uid), { 26 | name: user.displayName, 27 | email: user.email, 28 | timestamp: serverTimestamp(), 29 | }) 30 | } 31 | navigate('/') 32 | } catch (error) { 33 | toast.error('Could not authorize with Google') 34 | } 35 | } 36 | 37 | return ( 38 |
    39 |

    Sign {location.pathname === '/sign-up' ? 'up' : 'in'} with

    40 | 43 |
    44 | ) 45 | } 46 | 47 | export default OAuth 48 | -------------------------------------------------------------------------------- /src/components/PrivateRoute.jsx: -------------------------------------------------------------------------------- 1 | import { Navigate, Outlet } from 'react-router-dom' 2 | import { useAuthStatus } from '../hooks/useAuthStatus' 3 | import Spinner from './Spinner' 4 | 5 | const PrivateRoute = () => { 6 | const { loggedIn, checkingStatus } = useAuthStatus() 7 | 8 | if (checkingStatus) { 9 | return 10 | } 11 | 12 | return loggedIn ? : 13 | } 14 | 15 | export default PrivateRoute 16 | -------------------------------------------------------------------------------- /src/components/Slider.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { useNavigate } from 'react-router-dom' 3 | import { collection, getDocs, query, orderBy, limit } from 'firebase/firestore' 4 | import { db } from '../firebase.config' 5 | import SwiperCore, { Navigation, Pagination, Scrollbar, A11y } from 'swiper' 6 | import { Swiper, SwiperSlide } from 'swiper/react' 7 | import 'swiper/swiper-bundle.css' 8 | import Spinner from './Spinner' 9 | SwiperCore.use([Navigation, Pagination, Scrollbar, A11y]) 10 | 11 | function Slider() { 12 | const [loading, setLoading] = useState(true) 13 | const [listings, setListings] = useState(null) 14 | 15 | const navigate = useNavigate() 16 | 17 | useEffect(() => { 18 | const fetchListings = async () => { 19 | const listingsRef = collection(db, 'listings') 20 | const q = query(listingsRef, orderBy('timestamp', 'desc'), limit(5)) 21 | const querySnap = await getDocs(q) 22 | 23 | let listings = [] 24 | 25 | querySnap.forEach((doc) => { 26 | return listings.push({ 27 | id: doc.id, 28 | data: doc.data(), 29 | }) 30 | }) 31 | 32 | setListings(listings) 33 | setLoading(false) 34 | } 35 | 36 | fetchListings() 37 | }, []) 38 | 39 | if (loading) { 40 | return 41 | } 42 | 43 | if (listings.length === 0) { 44 | return <> 45 | } 46 | 47 | return ( 48 | listings && ( 49 | <> 50 |

    Recommended

    51 | 52 | 53 | {listings.map(({ data, id }) => ( 54 | navigate(`/category/${data.type}/${id}`)} 57 | > 58 |
    65 |

    {data.name}

    66 |

    67 | ${data.discountedPrice ?? data.regularPrice}{' '} 68 | {data.type === 'rent' && '/ month'} 69 |

    70 |
    71 |
    72 | ))} 73 |
    74 | 75 | ) 76 | ) 77 | } 78 | 79 | export default Slider 80 | -------------------------------------------------------------------------------- /src/components/Spinner.jsx: -------------------------------------------------------------------------------- 1 | function Spinner() { 2 | return ( 3 |
    4 |
    5 |
    6 | ) 7 | } 8 | 9 | export default Spinner 10 | -------------------------------------------------------------------------------- /src/firebase.config.js: -------------------------------------------------------------------------------- 1 | import { initializeApp } from 'firebase/app' 2 | import { getFirestore } from 'firebase/firestore' 3 | 4 | // Your web app's Firebase configuration 5 | const firebaseConfig = { 6 | apiKey: 'AIzaSyDA8LVcBB6ZuFMGtZZLEh_veJ44WGrNRdE', 7 | authDomain: 'house-marketplace-app-fb1d0.firebaseapp.com', 8 | projectId: 'house-marketplace-app-fb1d0', 9 | storageBucket: 'house-marketplace-app-fb1d0.appspot.com', 10 | messagingSenderId: '832068369979', 11 | appId: '1:832068369979:web:dce177da9bfc60a4b4e61e', 12 | } 13 | 14 | // Initialize Firebase 15 | initializeApp(firebaseConfig) 16 | export const db = getFirestore() 17 | -------------------------------------------------------------------------------- /src/hooks/useAuthStatus.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from 'react' 2 | import { getAuth, onAuthStateChanged } from 'firebase/auth' 3 | 4 | export const useAuthStatus = () => { 5 | const [loggedIn, setLoggedIn] = useState(false) 6 | const [checkingStatus, setCheckingStatus] = useState(true) 7 | const isMounted = useRef(true) 8 | 9 | useEffect(() => { 10 | if (isMounted) { 11 | const auth = getAuth() 12 | onAuthStateChanged(auth, (user) => { 13 | if (user) { 14 | setLoggedIn(true) 15 | } 16 | setCheckingStatus(false) 17 | }) 18 | } 19 | 20 | return () => { 21 | isMounted.current = false 22 | } 23 | }, [isMounted]) 24 | 25 | return { loggedIn, checkingStatus } 26 | } 27 | 28 | // Protected routes in v6 29 | // https://stackoverflow.com/questions/65505665/protected-route-with-firebase 30 | 31 | // Fix memory leak warning 32 | // https://stackoverflow.com/questions/59780268/cleanup-memory-leaks-on-an-unmounted-component-in-react-hooks 33 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;300;400;500;600;700;800;900&display=swap'); 2 | * { 3 | box-sizing: border-box; 4 | } 5 | 6 | html::-webkit-scrollbar { 7 | display: none; 8 | } 9 | 10 | body { 11 | font-family: 'Montserrat', sans-serif; 12 | background-color: #f2f4f8; 13 | margin: 0; 14 | box-sizing: border-box; 15 | } 16 | 17 | a { 18 | text-decoration: none; 19 | display: block; 20 | color: #000000; 21 | } 22 | 23 | button { 24 | outline: none; 25 | border: none; 26 | } 27 | 28 | .input, 29 | .passwordInput, 30 | .emailInput, 31 | .nameInput, 32 | .textarea { 33 | box-shadow: rgba(0, 0, 0, 0.11); 34 | border: none; 35 | background: #ffffff; 36 | border-radius: 3rem; 37 | height: 3rem; 38 | width: 100%; 39 | outline: none; 40 | font-family: 'Montserrat', sans-serif; 41 | padding: 0 3rem; 42 | font-size: 1rem; 43 | } 44 | @media (min-width: 1100px) { 45 | .input, 46 | .passwordInput, 47 | .emailInput, 48 | .nameInput, 49 | .textarea { 50 | padding: 0 5rem; 51 | } 52 | } 53 | 54 | .textarea { 55 | padding: 1rem 1.5rem; 56 | height: 300px; 57 | border-radius: 1rem; 58 | } 59 | 60 | .primaryButton { 61 | cursor: pointer; 62 | background: #00cc66; 63 | border-radius: 1rem; 64 | padding: 0.85rem 2rem; 65 | color: #ffffff; 66 | font-weight: 600; 67 | font-size: 1.25rem; 68 | width: 80%; 69 | margin: 0 auto; 70 | display: flex; 71 | align-items: center; 72 | justify-content: center; 73 | } 74 | 75 | .removeIcon { 76 | cursor: pointer; 77 | position: absolute; 78 | top: -3%; 79 | right: -2%; 80 | } 81 | 82 | .editIcon { 83 | cursor: pointer; 84 | position: absolute; 85 | top: -3.4%; 86 | right: 20px; 87 | } 88 | 89 | .pageContainer, 90 | .offers, 91 | .profile, 92 | .listingDetails, 93 | .category, 94 | .explore { 95 | margin: 1rem; 96 | } 97 | @media (min-width: 1024px) { 98 | .pageContainer, 99 | .offers, 100 | .profile, 101 | .listingDetails, 102 | .category, 103 | .explore { 104 | margin: 3rem; 105 | } 106 | } 107 | 108 | .loadingSpinnerContainer { 109 | position: fixed; 110 | top: 0; 111 | right: 0; 112 | bottom: 0; 113 | left: 0; 114 | background-color: rgba(0, 0, 0, 0.5); 115 | z-index: 5000; 116 | display: flex; 117 | justify-content: center; 118 | align-items: center; 119 | } 120 | 121 | .pageContainer{ 122 | margin-bottom: 10rem; 123 | } 124 | 125 | .loadingSpinner { 126 | width: 64px; 127 | height: 64px; 128 | border: 8px solid; 129 | border-color: #00cc66 transparent #00cc66 transparent; 130 | border-radius: 50%; 131 | animation: spin 1.2s linear infinite; 132 | } 133 | @keyframes spin { 134 | 0% { 135 | transform: rotate(0deg); 136 | } 137 | 100% { 138 | transform: rotate(360deg); 139 | } 140 | } 141 | 142 | .pageHeader { 143 | font-size: 2rem; 144 | font-weight: 800; 145 | } 146 | 147 | .navbar { 148 | position: fixed; 149 | left: 0; 150 | bottom: 0; 151 | right: 0; 152 | height: 85px; 153 | background-color: #ffffff; 154 | z-index: 1000; 155 | display: flex; 156 | justify-content: center; 157 | align-items: center; 158 | } 159 | 160 | .navbarNav { 161 | width: 100%; 162 | margin-top: 0.75rem; 163 | overflow-y: hidden; 164 | } 165 | 166 | .navbarListItems { 167 | margin: 0; 168 | padding: 0; 169 | display: flex; 170 | justify-content: space-evenly; 171 | align-items: center; 172 | } 173 | 174 | .navbarListItem { 175 | cursor: pointer; 176 | display: flex; 177 | flex-direction: column; 178 | align-items: center; 179 | } 180 | 181 | .navbarListItemName, 182 | .navbarListItemNameActive { 183 | margin-top: 0.25rem; 184 | font-size: 14px; 185 | font-weight: 600; 186 | color: #8f8f8f; 187 | } 188 | .navbarListItemNameActive { 189 | color: #2c2c2c; 190 | } 191 | 192 | .nameInput { 193 | margin-bottom: 2rem; 194 | background: url('./assets/svg/badgeIcon.svg') #ffffff 2.5% center no-repeat; 195 | } 196 | 197 | .emailInput { 198 | margin-bottom: 2rem; 199 | background: url('./assets/svg/personIcon.svg') #ffffff 2.5% center no-repeat; 200 | } 201 | 202 | .passwordInputDiv { 203 | position: relative; 204 | } 205 | 206 | .passwordInput { 207 | margin-bottom: 2rem; 208 | background: url('./assets/svg/lockIcon.svg') #ffffff 2.5% center no-repeat; 209 | } 210 | 211 | .showPassword { 212 | cursor: pointer; 213 | position: absolute; 214 | top: -4%; 215 | right: 1%; 216 | padding: 1rem; 217 | } 218 | 219 | .forgotPasswordLink { 220 | cursor: pointer; 221 | color: #00cc66; 222 | font-weight: 600; 223 | text-align: right; 224 | } 225 | 226 | .signInBar, 227 | .signUpBar { 228 | margin-top: 3rem; 229 | display: flex; 230 | justify-content: space-between; 231 | align-items: center; 232 | position: inherit; 233 | } 234 | 235 | .signInButton, 236 | .signUpButton, 237 | .signInText, 238 | .signUpText { 239 | cursor: pointer; 240 | } 241 | @media (min-width: 1024px) { 242 | .signInBar, 243 | .signUpBar { 244 | justify-content: start; 245 | } 246 | } 247 | 248 | .signInText, 249 | .signUpText { 250 | font-size: 1.5rem; 251 | font-weight: 700; 252 | } 253 | 254 | .signInButton, 255 | .signUpButton { 256 | display: flex; 257 | justify-content: center; 258 | align-items: center; 259 | width: 3rem; 260 | height: 3rem; 261 | background-color: #00cc66; 262 | border-radius: 50%; 263 | } 264 | @media (min-width: 1024px) { 265 | .signInButton, 266 | .signUpButton { 267 | margin-left: 3rem; 268 | } 269 | } 270 | 271 | .socialLogin { 272 | margin-top: 4rem; 273 | display: flex; 274 | flex-direction: column; 275 | align-items: center; 276 | } 277 | 278 | .socialIconDiv { 279 | cursor: pointer; 280 | display: flex; 281 | justify-content: center; 282 | align-items: center; 283 | padding: 0.75rem; 284 | margin: 1.5rem; 285 | width: 3rem; 286 | height: 3rem; 287 | background-color: #ffffff; 288 | border-radius: 50%; 289 | box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.1); 290 | } 291 | 292 | .socialIconImg { 293 | width: 100%; 294 | } 295 | 296 | .registerLink { 297 | margin-top: 4rem; 298 | color: #00cc66; 299 | font-weight: 600; 300 | text-align: center; 301 | margin-bottom: 3rem; 302 | } 303 | 304 | @media (min-width: 1217px) { 305 | .explore { 306 | margin-bottom: 10rem; 307 | } 308 | } 309 | @media (max-height: 536px) { 310 | .explore { 311 | margin-bottom: 10rem; 312 | } 313 | } 314 | 315 | .exploreHeading, 316 | .exploreCategoryHeading { 317 | font-weight: 700; 318 | } 319 | 320 | .exploreCategoryHeading { 321 | margin-top: 3rem; 322 | } 323 | 324 | .swiper-container { 325 | min-height: 225px; 326 | height: 23vw; 327 | } 328 | 329 | .swiper-pagination-bullet-active { 330 | background-color: #ffffff !important; 331 | } 332 | 333 | .swiperSlideDiv { 334 | position: relative; 335 | width: 100%; 336 | height: 100%; 337 | } 338 | 339 | .swiperSlideImg { 340 | width: 100%; 341 | object-fit: cover; 342 | } 343 | 344 | .swiperSlideText { 345 | color: #ffffff; 346 | position: absolute; 347 | top: 70px; 348 | left: 0; 349 | font-weight: 600; 350 | max-width: 90%; 351 | font-size: 1.25rem; 352 | background-color: rgba(0, 0, 0, 0.8); 353 | padding: 0.5rem; 354 | } 355 | @media (min-width: 1024px) { 356 | .swiperSlideText { 357 | font-size: 1.75rem; 358 | } 359 | } 360 | 361 | .swiperSlidePrice { 362 | color: #000000; 363 | position: absolute; 364 | top: 143px; 365 | left: 11px; 366 | font-weight: 600; 367 | max-width: 90%; 368 | background-color: #ffffff; 369 | padding: 0.25rem 0.5rem; 370 | border-radius: 1rem; 371 | } 372 | @media (min-width: 1024px) { 373 | .swiperSlidePrice { 374 | font-size: 1.25rem; 375 | } 376 | } 377 | 378 | .exploreCategories { 379 | display: flex; 380 | justify-content: space-between; 381 | } 382 | .exploreCategories a { 383 | width: 48%; 384 | } 385 | 386 | .exploreCategoryImg { 387 | min-height: 115px; 388 | height: 15vw; 389 | width: 100%; 390 | border-radius: 1.5rem; 391 | object-fit: cover; 392 | margin: 0 auto; 393 | } 394 | 395 | .exploreCategoryName { 396 | font-weight: 500; 397 | text-align: left; 398 | } 399 | 400 | .category { 401 | margin-bottom: 10rem; 402 | } 403 | 404 | .categoryListings { 405 | padding: 0; 406 | } 407 | 408 | .categoryListing { 409 | display: flex; 410 | justify-content: space-between; 411 | align-items: center; 412 | margin-bottom: 1rem; 413 | position: relative; 414 | } 415 | 416 | .categoryListingLink { 417 | display: contents; 418 | } 419 | 420 | .categoryListingImg { 421 | width: 30%; 422 | height: 100px; 423 | border-radius: 1.5rem; 424 | object-fit: cover; 425 | } 426 | @media (min-width: 1024px) { 427 | .categoryListingImg { 428 | width: 19%; 429 | height: 217px; 430 | } 431 | } 432 | 433 | .categoryListingDetails { 434 | width: 65%; 435 | } 436 | @media (min-width: 1024px) { 437 | .categoryListingDetails { 438 | width: 79%; 439 | } 440 | } 441 | 442 | .categoryListingLocation { 443 | font-weight: 600; 444 | font-size: 0.7rem; 445 | opacity: 0.8; 446 | margin-bottom: 0; 447 | } 448 | 449 | .categoryListingName { 450 | font-weight: 600; 451 | font-size: 1.25rem; 452 | margin: 0; 453 | } 454 | 455 | .categoryListingPrice { 456 | margin-top: 0.5rem; 457 | font-weight: 600; 458 | font-size: 1.1rem; 459 | color: #00cc66; 460 | margin-bottom: 0; 461 | display: flex; 462 | align-items: center; 463 | } 464 | 465 | .categoryListingInfoDiv { 466 | display: flex; 467 | justify-content: space-between; 468 | max-width: 275px; 469 | } 470 | 471 | .categoryListingInfoText { 472 | font-weight: 500; 473 | font-size: 0.7rem; 474 | } 475 | 476 | .loadMore { 477 | cursor: pointer; 478 | width: 8rem; 479 | margin: 0 auto; 480 | text-align: center; 481 | padding: 0.25rem 0.5rem; 482 | background-color: #000000; 483 | color: #ffffff; 484 | font-weight: 600; 485 | border-radius: 1rem; 486 | opacity: 0.7; 487 | margin-top: 2rem; 488 | } 489 | 490 | .listingDetails { 491 | margin-bottom: 10rem; 492 | } 493 | 494 | .shareIconDiv { 495 | cursor: pointer; 496 | position: fixed; 497 | top: 3%; 498 | right: 5%; 499 | z-index: 2; 500 | background-color: #ffffff; 501 | border-radius: 50%; 502 | width: 3rem; 503 | height: 3rem; 504 | display: flex; 505 | justify-content: center; 506 | align-items: center; 507 | } 508 | 509 | .listingName { 510 | font-weight: 600; 511 | font-size: 1.5rem; 512 | margin-bottom: 0.5rem; 513 | } 514 | 515 | .listingLocation { 516 | margin-top: 0; 517 | font-weight: 600; 518 | } 519 | 520 | .discountPrice { 521 | padding: 0.25rem 0.5rem; 522 | background-color: #000000; 523 | color: #ffffff; 524 | border-radius: 1rem; 525 | font-size: 0.8rem; 526 | font-weight: 600; 527 | display: inline; 528 | } 529 | 530 | .listingType { 531 | padding: 0.25rem 0.5rem; 532 | background-color: #00cc66; 533 | color: #ffffff; 534 | border-radius: 2rem; 535 | display: inline; 536 | font-weight: 600; 537 | font-size: 0.8rem; 538 | margin-right: 1rem; 539 | } 540 | 541 | .listingDetailsList { 542 | padding: 0; 543 | list-style-type: none; 544 | } 545 | .listingDetailsList li { 546 | margin: 0.3rem 0; 547 | font-weight: 500; 548 | opacity: 0.8; 549 | } 550 | 551 | .listingLocationTitle { 552 | margin-top: 2rem; 553 | font-weight: 600; 554 | font-size: 1.25rem; 555 | } 556 | 557 | .leafletContainer { 558 | width: 100%; 559 | height: 200px; 560 | overflow-x: hidden; 561 | margin-bottom: 3rem; 562 | } 563 | @media (min-width: 1024px) { 564 | .leafletContainer { 565 | height: 400px; 566 | } 567 | } 568 | 569 | .linkCopied { 570 | position: fixed; 571 | top: 9%; 572 | right: 5%; 573 | z-index: 2; 574 | background-color: #ffffff; 575 | border-radius: 1rem; 576 | padding: 0.5rem 1rem; 577 | font-weight: 600; 578 | } 579 | 580 | .contactListingName { 581 | margin-top: -1rem; 582 | margin-bottom: 0; 583 | font-weight: 600; 584 | } 585 | 586 | .contactListingLocation { 587 | margin-top: 0.25rem; 588 | font-weight: 600; 589 | } 590 | 591 | .contactLandlord { 592 | margin-top: 2rem; 593 | display: flex; 594 | align-items: center; 595 | } 596 | 597 | .landlordName { 598 | font-weight: 600; 599 | font-size: 1.2rem; 600 | } 601 | 602 | .messageForm { 603 | margin-top: 0.5rem; 604 | } 605 | 606 | .messageDiv { 607 | margin-top: 2rem; 608 | display: flex; 609 | flex-direction: column; 610 | margin-bottom: 4rem; 611 | } 612 | 613 | .messageLabel { 614 | margin-bottom: 0.5rem; 615 | } 616 | 617 | .profile { 618 | margin-bottom: 10rem; 619 | } 620 | 621 | .profileHeader { 622 | display: flex; 623 | justify-content: space-between; 624 | align-items: center; 625 | } 626 | 627 | .logOut { 628 | cursor: pointer; 629 | font-family: 'Montserrat', sans-serif; 630 | font-size: 1rem; 631 | font-weight: 600; 632 | color: #ffffff; 633 | background-color: #00cc66; 634 | padding: 0.25rem 0.75rem; 635 | border-radius: 1rem; 636 | } 637 | 638 | .profileDetailsHeader { 639 | display: flex; 640 | justify-content: space-between; 641 | max-width: 500px; 642 | } 643 | 644 | .personalDetailsText { 645 | font-weight: 600; 646 | } 647 | 648 | .changePersonalDetails { 649 | cursor: pointer; 650 | font-weight: 600; 651 | color: #00cc66; 652 | } 653 | 654 | .profileCard { 655 | background-color: #ffffff; 656 | border-radius: 1rem; 657 | padding: 1rem; 658 | box-shadow: rgba(0, 0, 0, 0.2); 659 | max-width: 500px; 660 | } 661 | 662 | .profileDetails { 663 | display: flex; 664 | flex-direction: column; 665 | } 666 | 667 | .profileName, 668 | .profileEmail, 669 | .profileAddress, 670 | .profileAddressActive, 671 | .profileEmailActive, 672 | .profileNameActive { 673 | all: unset; 674 | margin: 0.3rem 0; 675 | font-weight: 600; 676 | width: 100%; 677 | } 678 | .profileNameActive { 679 | background-color: rgba(44, 44, 44, 0.1); 680 | } 681 | 682 | .profileEmail, 683 | .profileAddress, 684 | .profileAddressActive, 685 | .profileEmailActive { 686 | font-weight: 500; 687 | } 688 | .profileEmailActive { 689 | background-color: rgba(44, 44, 44, 0.1); 690 | } 691 | 692 | .profileAddressActive { 693 | background-color: rgba(44, 44, 44, 0.1); 694 | } 695 | 696 | .createListing { 697 | background-color: #ffffff; 698 | border-radius: 1rem; 699 | padding: 0.25rem 1rem; 700 | box-shadow: rgba(0, 0, 0, 0.2); 701 | margin-top: 2rem; 702 | font-weight: 600; 703 | max-width: 500px; 704 | display: flex; 705 | justify-content: space-between; 706 | align-items: center; 707 | } 708 | 709 | .listingText { 710 | margin-top: 3rem; 711 | font-weight: 600; 712 | } 713 | 714 | .lisitingsList { 715 | padding: 0; 716 | } 717 | 718 | .formLabel { 719 | font-weight: 600; 720 | margin-top: 1rem; 721 | display: block; 722 | } 723 | 724 | .formButtons { 725 | display: flex; 726 | } 727 | 728 | .formButton, 729 | .formInput, 730 | .formInputAddress, 731 | .formInputName, 732 | .formInputSmall, 733 | .formInputFile, 734 | .formButtonActive { 735 | padding: 0.9rem 3rem; 736 | background-color: #ffffff; 737 | font-weight: 600; 738 | border-radius: 1rem; 739 | font-size: 1rem; 740 | margin: 0.5rem 0.5rem 0 0; 741 | display: flex; 742 | justify-content: center; 743 | align-items: center; 744 | } 745 | .formButtonActive { 746 | background-color: #00cc66; 747 | color: #ffffff; 748 | } 749 | 750 | .flex { 751 | display: flex; 752 | } 753 | 754 | .formInput, 755 | .formInputAddress, 756 | .formInputName, 757 | .formInputSmall, 758 | .formInputFile { 759 | border: none; 760 | outline: none; 761 | font-family: 'Montserrat', sans-serif; 762 | } 763 | .formInputSmall, 764 | .formInputFile { 765 | margin-right: 3rem; 766 | padding: 0.9rem 0.7rem; 767 | text-align: center; 768 | } 769 | 770 | .formInputName { 771 | padding: 0.9rem 1rem; 772 | width: 90%; 773 | max-width: 326px; 774 | } 775 | 776 | .formInputAddress { 777 | padding: 0.9rem 1rem; 778 | width: 90%; 779 | max-width: 326px; 780 | } 781 | 782 | .formPriceDiv { 783 | display: flex; 784 | align-items: center; 785 | } 786 | 787 | .formPriceText { 788 | margin-left: -1.5rem; 789 | font-weight: 600; 790 | } 791 | 792 | .imagesInfo { 793 | font-size: 0.9rem; 794 | opacity: 0.75; 795 | } 796 | 797 | .formInputFile { 798 | width: 100%; 799 | } 800 | .formInputFile::-webkit-file-upload-button { 801 | background-color: #00cc66; 802 | border: none; 803 | color: #ffffff; 804 | font-weight: 600; 805 | padding: 0.5rem 0.75rem; 806 | border-radius: 1rem; 807 | margin-right: 1rem; 808 | } 809 | 810 | .createListingButton { 811 | margin-top: 5rem; 812 | } 813 | 814 | .offers { 815 | margin-bottom: 10rem; 816 | } 817 | 818 | .offerBadge { 819 | padding: 0.25rem 0.5rem; 820 | background-color: #000000; 821 | color: #ffffff; 822 | border-radius: 1rem; 823 | margin-left: 1rem; 824 | font-size: 0.8rem; 825 | opacity: 0.75; 826 | } 827 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /src/pages/Category.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useParams } from 'react-router-dom' 3 | import { 4 | collection, 5 | getDocs, 6 | query, 7 | where, 8 | orderBy, 9 | limit, 10 | startAfter, 11 | } from 'firebase/firestore' 12 | import { db } from '../firebase.config' 13 | import { toast } from 'react-toastify' 14 | import Spinner from '../components/Spinner' 15 | import ListingItem from '../components/ListingItem' 16 | 17 | function Category() { 18 | const [listings, setListings] = useState(null) 19 | const [loading, setLoading] = useState(true) 20 | const [lastFetchedListing, setLastFetchedListing] = useState(null) 21 | 22 | const params = useParams() 23 | 24 | useEffect(() => { 25 | const fetchListings = async () => { 26 | try { 27 | // Get reference 28 | const listingsRef = collection(db, 'listings') 29 | 30 | // Create a query 31 | const q = query( 32 | listingsRef, 33 | where('type', '==', params.categoryName), 34 | orderBy('timestamp', 'desc'), 35 | limit(10) 36 | ) 37 | 38 | // Execute query 39 | const querySnap = await getDocs(q) 40 | 41 | const lastVisible = querySnap.docs[querySnap.docs.length - 1] 42 | setLastFetchedListing(lastVisible) 43 | 44 | const listings = [] 45 | 46 | querySnap.forEach((doc) => { 47 | return listings.push({ 48 | id: doc.id, 49 | data: doc.data(), 50 | }) 51 | }) 52 | 53 | setListings(listings) 54 | setLoading(false) 55 | } catch (error) { 56 | toast.error('Could not fetch listings') 57 | } 58 | } 59 | 60 | fetchListings() 61 | }, [params.categoryName]) 62 | 63 | // Pagination / Load More 64 | const onFetchMoreListings = async () => { 65 | try { 66 | // Get reference 67 | const listingsRef = collection(db, 'listings') 68 | 69 | // Create a query 70 | const q = query( 71 | listingsRef, 72 | where('type', '==', params.categoryName), 73 | orderBy('timestamp', 'desc'), 74 | startAfter(lastFetchedListing), 75 | limit(10) 76 | ) 77 | 78 | // Execute query 79 | const querySnap = await getDocs(q) 80 | 81 | const lastVisible = querySnap.docs[querySnap.docs.length - 1] 82 | setLastFetchedListing(lastVisible) 83 | 84 | const listings = [] 85 | 86 | querySnap.forEach((doc) => { 87 | return listings.push({ 88 | id: doc.id, 89 | data: doc.data(), 90 | }) 91 | }) 92 | 93 | setListings((prevState) => [...prevState, ...listings]) 94 | setLoading(false) 95 | } catch (error) { 96 | toast.error('Could not fetch listings') 97 | } 98 | } 99 | 100 | return ( 101 |
    102 |
    103 |

    104 | {params.categoryName === 'rent' 105 | ? 'Places for rent' 106 | : 'Places for sale'} 107 |

    108 |
    109 | 110 | {loading ? ( 111 | 112 | ) : listings && listings.length > 0 ? ( 113 | <> 114 |
    115 |
      116 | {listings.map((listing) => ( 117 | 122 | ))} 123 |
    124 |
    125 | 126 |
    127 |
    128 | {lastFetchedListing && ( 129 |

    130 | Load More 131 |

    132 | )} 133 | 134 | ) : ( 135 |

    No listings for {params.categoryName}

    136 | )} 137 |
    138 | ) 139 | } 140 | 141 | export default Category 142 | -------------------------------------------------------------------------------- /src/pages/Contact.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { useParams, useSearchParams } from 'react-router-dom' 3 | import { doc, getDoc } from 'firebase/firestore' 4 | import { db } from '../firebase.config' 5 | import { toast } from 'react-toastify' 6 | 7 | function Contact() { 8 | const [message, setMessage] = useState('') 9 | const [landlord, setLandlord] = useState(null) 10 | // eslint-disable-next-line 11 | const [searchParams, setSearchParams] = useSearchParams() 12 | 13 | const params = useParams() 14 | 15 | useEffect(() => { 16 | const getLandlord = async () => { 17 | const docRef = doc(db, 'users', params.landlordId) 18 | const docSnap = await getDoc(docRef) 19 | 20 | if (docSnap.exists()) { 21 | setLandlord(docSnap.data()) 22 | } else { 23 | toast.error('Could not get landlord data') 24 | } 25 | } 26 | 27 | getLandlord() 28 | }, [params.landlordId]) 29 | 30 | const onChange = (e) => setMessage(e.target.value) 31 | 32 | return ( 33 |
    34 |
    35 |

    Contact Landlord

    36 |
    37 | 38 | {landlord !== null && ( 39 |
    40 |
    41 |

    Contact {landlord?.name}

    42 |
    43 | 44 |
    45 |
    46 | 49 | 56 |
    57 | 58 | 63 | 66 | 67 |
    68 |
    69 | )} 70 |
    71 | ) 72 | } 73 | 74 | export default Contact 75 | -------------------------------------------------------------------------------- /src/pages/CreateListing.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react' 2 | import { getAuth, onAuthStateChanged } from 'firebase/auth' 3 | import { 4 | getStorage, 5 | ref, 6 | uploadBytesResumable, 7 | getDownloadURL, 8 | } from 'firebase/storage' 9 | import { addDoc, collection, serverTimestamp } from 'firebase/firestore' 10 | import { db } from '../firebase.config' 11 | import { useNavigate } from 'react-router-dom' 12 | import { toast } from 'react-toastify' 13 | import { v4 as uuidv4 } from 'uuid' 14 | import Spinner from '../components/Spinner' 15 | 16 | function CreateListing() { 17 | // eslint-disable-next-line 18 | const [geolocationEnabled, setGeolocationEnabled] = useState(true) 19 | const [loading, setLoading] = useState(false) 20 | const [formData, setFormData] = useState({ 21 | type: 'rent', 22 | name: '', 23 | bedrooms: 1, 24 | bathrooms: 1, 25 | parking: false, 26 | furnished: false, 27 | address: '', 28 | offer: false, 29 | regularPrice: 0, 30 | discountedPrice: 0, 31 | images: {}, 32 | latitude: 0, 33 | longitude: 0, 34 | }) 35 | 36 | const { 37 | type, 38 | name, 39 | bedrooms, 40 | bathrooms, 41 | parking, 42 | furnished, 43 | address, 44 | offer, 45 | regularPrice, 46 | discountedPrice, 47 | images, 48 | latitude, 49 | longitude, 50 | } = formData 51 | 52 | const auth = getAuth() 53 | const navigate = useNavigate() 54 | const isMounted = useRef(true) 55 | 56 | useEffect(() => { 57 | if (isMounted) { 58 | onAuthStateChanged(auth, (user) => { 59 | if (user) { 60 | setFormData({ ...formData, userRef: user.uid }) 61 | } else { 62 | navigate('/sign-in') 63 | } 64 | }) 65 | } 66 | 67 | return () => { 68 | isMounted.current = false 69 | } 70 | // eslint-disable-next-line react-hooks/exhaustive-deps 71 | }, [isMounted]) 72 | 73 | const onSubmit = async (e) => { 74 | e.preventDefault() 75 | 76 | setLoading(true) 77 | 78 | if (discountedPrice >= regularPrice) { 79 | setLoading(false) 80 | toast.error('Discounted price needs to be less than regular price') 81 | return 82 | } 83 | 84 | if (images.length > 6) { 85 | setLoading(false) 86 | toast.error('Max 6 images') 87 | return 88 | } 89 | 90 | let geolocation = {} 91 | let location 92 | 93 | if (geolocationEnabled) { 94 | const response = await fetch( 95 | `https://maps.googleapis.com/maps/api/geocode/json?address=${address}&key=${process.env.REACT_APP_GEOCODE_API_KEY}` 96 | ) 97 | 98 | const data = await response.json() 99 | 100 | geolocation.lat = data.results[0]?.geometry.location.lat ?? 0 101 | geolocation.lng = data.results[0]?.geometry.location.lng ?? 0 102 | 103 | location = 104 | data.status === 'ZERO_RESULTS' 105 | ? undefined 106 | : data.results[0]?.formatted_address 107 | 108 | if (location === undefined || location.includes('undefined')) { 109 | setLoading(false) 110 | toast.error('Please enter a correct address') 111 | return 112 | } 113 | } else { 114 | geolocation.lat = latitude 115 | geolocation.lng = longitude 116 | } 117 | 118 | // Store image in firebase 119 | const storeImage = async (image) => { 120 | return new Promise((resolve, reject) => { 121 | const storage = getStorage() 122 | const fileName = `${auth.currentUser.uid}-${image.name}-${uuidv4()}` 123 | 124 | const storageRef = ref(storage, 'images/' + fileName) 125 | 126 | const uploadTask = uploadBytesResumable(storageRef, image) 127 | 128 | uploadTask.on( 129 | 'state_changed', 130 | (snapshot) => { 131 | const progress = 132 | (snapshot.bytesTransferred / snapshot.totalBytes) * 100 133 | console.log('Upload is ' + progress + '% done') 134 | switch (snapshot.state) { 135 | case 'paused': 136 | console.log('Upload is paused') 137 | break 138 | case 'running': 139 | console.log('Upload is running') 140 | break 141 | default: 142 | break 143 | } 144 | }, 145 | (error) => { 146 | reject(error) 147 | }, 148 | () => { 149 | // Handle successful uploads on complete 150 | // For instance, get the download URL: https://firebasestorage.googleapis.com/... 151 | getDownloadURL(uploadTask.snapshot.ref).then((downloadURL) => { 152 | resolve(downloadURL) 153 | }) 154 | } 155 | ) 156 | }) 157 | } 158 | 159 | const imgUrls = await Promise.all( 160 | [...images].map((image) => storeImage(image)) 161 | ).catch(() => { 162 | setLoading(false) 163 | toast.error('Images not uploaded') 164 | return 165 | }) 166 | 167 | const formDataCopy = { 168 | ...formData, 169 | imgUrls, 170 | geolocation, 171 | timestamp: serverTimestamp(), 172 | } 173 | 174 | formDataCopy.location = address 175 | delete formDataCopy.images 176 | delete formDataCopy.address 177 | !formDataCopy.offer && delete formDataCopy.discountedPrice 178 | 179 | const docRef = await addDoc(collection(db, 'listings'), formDataCopy) 180 | setLoading(false) 181 | toast.success('Listing saved') 182 | navigate(`/category/${formDataCopy.type}/${docRef.id}`) 183 | } 184 | 185 | const onMutate = (e) => { 186 | let boolean = null 187 | 188 | if (e.target.value === 'true') { 189 | boolean = true 190 | } 191 | if (e.target.value === 'false') { 192 | boolean = false 193 | } 194 | 195 | // Files 196 | if (e.target.files) { 197 | setFormData((prevState) => ({ 198 | ...prevState, 199 | images: e.target.files, 200 | })) 201 | } 202 | 203 | // Text/Booleans/Numbers 204 | if (!e.target.files) { 205 | setFormData((prevState) => ({ 206 | ...prevState, 207 | [e.target.id]: boolean ?? e.target.value, 208 | })) 209 | } 210 | } 211 | 212 | if (loading) { 213 | return 214 | } 215 | 216 | return ( 217 |
    218 |
    219 |

    Create a Listing

    220 |
    221 | 222 |
    223 |
    224 | 225 |
    226 | 235 | 244 |
    245 | 246 | 247 | 257 | 258 |
    259 |
    260 | 261 | 271 |
    272 |
    273 | 274 | 284 |
    285 |
    286 | 287 | 288 |
    289 | 300 | 311 |
    312 | 313 | 314 |
    315 | 324 | 337 |
    338 | 339 | 340 |