Compare commits

...

10 Commits

Author SHA1 Message Date
QkoSad 199899d371 README updated 2024-09-30 15:41:01 +03:00
QkoSad 85f56e2f80 finished 2022-12-04 16:36:39 +02:00
QkoSad 7841afe8de added swiper to explore and singular offers, and capability to remove offers 2022-12-03 17:46:43 +02:00
QkoSad 09b6b0ad86 added map to listings 2022-12-02 22:21:43 +02:00
QkoSad d79737a497 added option for geolocation, that requires an api and currently does not work, and completed the add listing functionality 2022-12-02 18:33:38 +02:00
QkoSad 5c96af8b6c add listings added 2022-12-02 08:14:14 +02:00
QkoSad 8cc369bfe6 added reset password and google auth 2022-11-30 22:09:51 +02:00
QkoSad e31b90d9ab up to 92 2022-11-30 21:25:57 +02:00
QkoSad 19fb5256cf added toastify to signin and signup 2022-11-30 16:58:41 +02:00
QkoSad 98d2d372e8 working before adding toastify 2022-11-30 16:31:09 +02:00
51 changed files with 4757 additions and 196 deletions
+6 -70
View File
@@ -1,70 +1,6 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
# House marketplace
---
>React app for buying and selling houses. It uses firebase as a backend.
---
## Usage:
`npm start`
+1800 -6
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -6,9 +6,16 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"firebase": "^9.14.0",
"leaflet": "^1.9.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.0",
"react-router-dom": "^6.4.3",
"react-scripts": "5.0.1",
"react-toastify": "^9.1.1",
"swiper": "^8.4.5",
"uuid": "^9.0.0",
"web-vitals": "^2.1.4"
},
"scripts": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+5 -23
View File
@@ -10,34 +10,16 @@
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.3/dist/leaflet.css"
integrity="sha256-kLaT2GOSpHechhsozzB+flnD+zUyjE2LlfWPgU04xyI="
crossorigin=""/>
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
-38
View File
@@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+41 -18
View File
@@ -1,24 +1,47 @@
import logo from './logo.svg';
import './App.css';
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import PrivateRoute from "./components/PrivateRoute";
import Navbar from "./components/Navbar";
import Explore from "./pages/Explore";
import Profile from "./pages/Profile";
import ForgotPassword from "./pages/ForgotPassword";
import Offers from "./pages/Offers";
import Signin from "./pages/Signin";
import Signup from "./pages/Signup";
import Category from "./pages/Category";
import CreateLising from "./pages/CreateListing";
import Listing from "./pages/Listing";
import Contact from "./pages/Contact";
import EditListing from "./pages/EditListing";
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
<>
<Router>
<Routes>
<Route path="/" element={<Explore />} />
<Route path="/offers" element={<Offers />} />
<Route path="/category/:categoryName" element={<Category />} />
<Route path="/profile" element={<PrivateRoute />}>
<Route path="/profile" element={<Profile />} />
</Route>
<Route path="/sign-in" element={<Signin />} />
<Route path="/sign-up" element={<Signup />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/create-listing" element={<CreateLising />} />
<Route path="/edit-listing/:listingId" element={<EditListing />} />
<Route
path="/category/:categoryName/:listingId"
element={<Listing />}
/>
<Route path="/contact/:landlordId" element={<Contact />}/>
</Routes>
<Navbar />
</Router>
<ToastContainer />
</>
);
}
-8
View File
@@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><path d="M20,7h-5V4c0-1.1-0.9-2-2-2h-2C9.9,2,9,2.9,9,4v3H4C2.9,7,2,7.9,2,9v11c0,1.1,0.9,2,2,2h16c1.1,0,2-0.9,2-2V9 C22,7.9,21.1,7,20,7z M9,12c0.83,0,1.5,0.67,1.5,1.5S9.83,15,9,15s-1.5-0.67-1.5-1.5S8.17,12,9,12z M12,18H6v-0.75c0-1,2-1.5,3-1.5 s3,0.5,3,1.5V18z M13,9h-2V4h2V9z M18,16.5h-4V15h4V16.5z M18,13.5h-4V12h4V13.5z"/></g></svg>

After

Width:  |  Height:  |  Size: 521 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><g><g><circle cx="7" cy="7" r="2"/></g><g><path d="M20,13V4.83C20,3.27,18.73,2,17.17,2c-0.75,0-1.47,0.3-2,0.83l-1.25,1.25C13.76,4.03,13.59,4,13.41,4 c-0.4,0-0.77,0.12-1.08,0.32l2.76,2.76c0.2-0.31,0.32-0.68,0.32-1.08c0-0.18-0.03-0.34-0.07-0.51l1.25-1.25 C16.74,4.09,16.95,4,17.17,4C17.63,4,18,4.37,18,4.83V13h-6.85c-0.3-0.21-0.57-0.45-0.82-0.72l-1.4-1.55 c-0.19-0.21-0.43-0.38-0.69-0.5C7.93,10.08,7.59,10,7.24,10C6,10.01,5,11.01,5,12.25V13H2v6c0,1.1,0.9,2,2,2c0,0.55,0.45,1,1,1 h14c0.55,0,1-0.45,1-1c1.1,0,2-0.9,2-2v-6H20z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 730 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><g><rect fill="none" height="3" width="5" x="6" y="7"/><rect fill="none" height="3" width="5" x="13" y="7"/><path d="M20,10V7c0-1.1-0.9-2-2-2H6C4.9,5,4,5.9,4,7v3c-1.1,0-2,0.9-2,2v5h1.33L4,19h1l0.67-2h12.67L19,19h1l0.67-2H22v-5 C22,10.9,21.1,10,20,10z M11,10H6V7h5V10z M18,10h-5V7h5V10z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 490 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>

After

Width:  |  Height:  |  Size: 206 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>

After

Width:  |  Height:  |  Size: 234 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>

After

Width:  |  Height:  |  Size: 306 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z"/></svg>

After

Width:  |  Height:  |  Size: 409 B

+15
View File
@@ -0,0 +1,15 @@
<svg width="124" height="124" viewBox="0 0 124 124" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path d="M119.081 51.0933L69.0114 51.0909C66.8004 51.0909 65.0083 52.8828 65.0083 55.0938V71.0887C65.0083 73.2992 66.8004 75.0916 69.0112 75.0916H97.2073C94.1198 83.1043 88.3572 89.8147 81.005 94.0785L93.0277 114.891C112.314 103.737 123.716 84.1664 123.716 62.2585C123.716 59.1391 123.486 56.9092 123.026 54.3982C122.677 52.4905 121.02 51.0933 119.081 51.0933Z" fill="#167EE6"/>
<path d="M62.3391 99.1246C48.5404 99.1246 36.4944 91.5853 30.0247 80.429L9.21289 92.4247C19.8039 110.781 39.6442 123.141 62.3391 123.141C73.4724 123.141 83.9775 120.144 93.0272 114.92V114.891L81.0044 94.0785C75.505 97.2682 69.141 99.1246 62.3391 99.1246Z" fill="#12B347"/>
<path d="M93.0275 114.919V114.891L81.0047 94.0781C75.5053 97.2675 69.1418 99.1242 62.3394 99.1242V123.141C73.4727 123.141 83.9783 120.143 93.0275 114.919Z" fill="#0F993E"/>
<path d="M24.9802 61.7646C24.9802 54.9631 26.8363 48.5999 30.0253 43.1007L9.21345 31.105C3.96074 40.1262 0.963379 50.6028 0.963379 61.7646C0.963379 72.9265 3.96074 83.4031 9.21345 92.4242L30.0253 80.4285C26.8363 74.9294 24.9802 68.5661 24.9802 61.7646Z" fill="#FFD500"/>
<path d="M62.3391 24.4057C71.3372 24.4057 79.6023 27.603 86.0581 32.9214C87.6508 34.2334 89.9656 34.1387 91.4244 32.6798L102.757 21.3467C104.413 19.6915 104.295 16.9821 102.527 15.4482C91.7102 6.06454 77.6368 0.388916 62.3391 0.388916C39.6442 0.388916 19.8039 12.7498 9.21289 31.1056L30.0247 43.1013C36.4944 31.9449 48.5404 24.4057 62.3391 24.4057Z" fill="#FF4B26"/>
<path d="M86.0584 32.9214C87.6511 34.2334 89.9661 34.1387 91.4248 32.6798L102.758 21.3467C104.413 19.6915 104.295 16.9821 102.527 15.4482C91.7106 6.0643 77.6372 0.388916 62.3394 0.388916V24.4057C71.3372 24.4057 79.6026 27.603 86.0584 32.9214Z" fill="#D93F21"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="122.752" height="122.752" fill="white" transform="translate(0.963379 0.388794)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>

After

Width:  |  Height:  |  Size: 192 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg>

After

Width:  |  Height:  |  Size: 213 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM13 20.01L4 11V4h7v-.01l9 9-7 7.02z"/><circle cx="6.5" cy="6.5" r="1.5"/></svg>

After

Width:  |  Height:  |  Size: 402 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>

After

Width:  |  Height:  |  Size: 382 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>

After

Width:  |  Height:  |  Size: 266 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M12 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2m0 10c2.7 0 5.8 1.29 6 2H6c.23-.72 3.31-2 6-2m0-12C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>

After

Width:  |  Height:  |  Size: 360 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/></svg>

After

Width:  |  Height:  |  Size: 516 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/></svg>

After

Width:  |  Height:  |  Size: 194 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>

After

Width:  |  Height:  |  Size: 366 B

+60
View File
@@ -0,0 +1,60 @@
import { Link } from "react-router-dom";
import { ReactComponent as DeleteIcon } from "../assets/svg/deleteIcon.svg";
import { ReactComponent as EditIcon } from "../assets/svg/editIcon.svg";
import bedIcon from "../assets/svg/bedIcon.svg";
import bathtubIcon from "../assets/svg/bathtubIcon.svg";
function ListingItem({ listing, id, onDelete, onEdit }) {
return (
<li className="categoryListing">
<Link
to={`/category/${listing.type}/${id}`}
className="categoryListingLink"
>
<img
src={listing.imgUrls[0]}
alt={listing.name}
className="categoryListingImg"
/>
<div className="categoryListingDetails">
<p className="categoryListingLocation">{listing.location}</p>
<p className="categoryListingName">{listing.name}</p>
<p className="categoryListingPrice">
$
{listing.offer
? listing.discountedPrice
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
: listing.regularPrice
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
{listing.type === "rent" && " / Month"}
</p>
<div className="categoryListingInfoDiv">
<img src={bedIcon} alt="bed" />
<p className="categoryListingInfoText">
{listing.bedrooms > 1
? `${listing.bedrooms} Bedrooms`
: "1 Bedrooms"}
</p>
<img src={bathtubIcon} alt="bath" />
<p className="categoryListingInfoText">
{listing.bathrooms > 1
? `${listing.bathrooms} Bedrooms`
: "1 Bathrooms"}
</p>
</div>
</div>
</Link>
{onDelete && (
<DeleteIcon
className="removeIcon"
fill="rgb(231,76,60)"
onClick={() => onDelete(listing.id, listing.name)}
/>
)}
{onEdit && <EditIcon className="editIcon" onClick={() => onEdit(id)} />}
</li>
);
}
export default ListingItem;
+72
View File
@@ -0,0 +1,72 @@
import { useNavigate, useLocation } from "react-router-dom";
import { ReactComponent as OfferIcon } from "../assets/svg/localOfferIcon.svg";
import { ReactComponent as ExploreIcon } from "../assets/svg/exploreIcon.svg";
import { ReactComponent as PersonIcon } from "../assets/svg/personOutlineIcon.svg";
function Navbar() {
const navigate = useNavigate();
const location = useLocation();
const pathMatchRoute = (route) => {
if (route === location.pathname) {
return true;
}
};
return (
<footer className="navbar">
<nav className="navbarNan">
<ul className="navbarListItems">
<li className="navbarListItems" onClick={() => navigate("/")}>
<ExploreIcon
fill={pathMatchRoute("/") ? "#2c2c2c" : "#8f8f8f"}
width="36px"
height="36px"
/>
<p
className={
pathMatchRoute("/")
? "navbarListItemNameActive"
: "navbarListItemName"
}
>
Explore
</p>
</li>
<li className="navbarListItems" onClick={() => navigate("/offers")}>
<OfferIcon
fill={pathMatchRoute("/offers") ? "#2c2c2c" : "#8f8f8f"}
width="36px"
height="36px"
/>
<p
className={
pathMatchRoute("/offers")
? "navbarListItemNameActive"
: "navbarListItemName"
}
>
Offers
</p>
</li>
<li className="navbarListItems" onClick={() => navigate("/profile")}>
<PersonIcon
fill={pathMatchRoute("/profile") ? "#2c2c2c" : "#8f8f8f"}
width="36px"
height="36px"
/>
<p
className={
pathMatchRoute("/profile")
? "navbarListItemNameActive"
: "navbarListItemName"
}
>
Profile
</p>
</li>
</ul>
</nav>
</footer>
);
};
export default Navbar
+42
View File
@@ -0,0 +1,42 @@
import { useLocation, useNavigate } from "react-router-dom";
import { getAuth, signInWithPopup, GoogleAuthProvider } from "firebase/auth";
import { doc, setDoc, getDoc, serverTimestamp } from "firebase/firestore";
import { db } from "../firebase.config";
import { toast } from "react-toastify";
import googleIcon from "../assets/svg/googleIcon.svg";
function OAuth() {
const navigate = useNavigate();
const location = useLocation();
const onGoogleClick = async () => {
try {
const auth = getAuth();
const provider = new GoogleAuthProvider();
const result = await signInWithPopup(auth, provider);
const user = result.user;
const docRef = doc(db, "users", user.uid);
const docSnap = await getDoc(docRef);
if (!docSnap.exists()) {
await setDoc(doc(db, "users", user.uid), {
name: user.displayName,
email: user.email,
timestamp: serverTimestamp(),
});
}
navigate('/')
} catch (error) {
toast.error('Could not authorize with google')
}
};
return (
<div className="socialLogin">
<p>Sign {location.pathname === "/sign-up" ? "up" : "in"} with</p>
<button className="socialIconDiv" onClick={onGoogleClick}>
<img className="socialIconImg" src={googleIcon} alt="google" />
</button>
</div>
);
}
export default OAuth;
+13
View File
@@ -0,0 +1,13 @@
import { useAuthStatus } from "../hooks/useAuthStatus";
import { Navigate, Outlet } from "react-router-dom";
import Spinner from "./Spinner";
const PrivateRoute = () => {
const { loggedIn, checkingStatus } = useAuthStatus();
if (checkingStatus) {
return <Spinner/>
}
return loggedIn ? <Outlet /> : <Navigate to="/sign-in" />;
};
export default PrivateRoute;
+70
View File
@@ -0,0 +1,70 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { getDocs, query, orderBy, limit, collection } from "firebase/firestore";
import { db } from "../firebase.config";
import SwiperCore, { Navigation, Pagination, Scrollbar, A11y } from "swiper";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/swiper-bundle.css";
import Spinner from "./Spinner";
SwiperCore.use([Navigation, Pagination, Scrollbar, A11y]);
function Slider() {
const [loading, setLoading] = useState(true);
const [listings, setListings] = useState(null);
const navigate = useNavigate();
useEffect(() => {
const fetchListings = async () => {
const listingsRef = collection(db, "listings");
const q = query(listingsRef, orderBy("timestamp", "desc"), limit(5));
const querySnap = await getDocs(q);
let listings = [];
querySnap.forEach((doc) => {
return listings.push({
id: doc.id,
data: doc.data(),
});
});
setListings(listings);
setLoading(false);
};
fetchListings();
}, []);
if (loading) return <Spinner />;
if (listings.length === 0) return <></>
return (
listings && (
<>
<p className="exploreHeading">Recommended</p>
<Swiper slidesPerView={1} pagination={{ clickable: true }}>
{listings.map(({ data, id }) => (
<SwiperSlide
key={id}
onClick={() => navigate(`/category/${data.type}/${id}`)}
>
<div
style={{
background: `url(${data.imgUrls[0]}) center no-repeat`,
backgroundSize: "cover",
minHeight: "20rem",
}}
className="swiperSlideDiv"
>
<p className="swiperSlideText">{data.name}</p>
<p className="swiperSlidePrice">
${data.discountedPrice ?? data.regularPrice}{" "}
{data.type === "rent" && "/ month"}
</p>
</div>
</SwiperSlide>
))}
</Swiper>
</>
)
);
}
export default Slider;
+7
View File
@@ -0,0 +1,7 @@
function Spinner(){
return (
<div className="loadingSpinerContainer">
<div className="loadingSpinner"></div>
</div>)
}
export default Spinner
+15
View File
@@ -0,0 +1,15 @@
import { initializeApp } from "firebase/app";
import {getFirestore} from 'firebase/firestore'
// Your web app's Firebase configuration
const firebaseConfig = {
apiKey: "AIzaSyDkQS2GoBx-IR_BR4dD_4bPUNGaPyMQugI",
authDomain: "house-marketplace-app-7e89f.firebaseapp.com",
projectId: "house-marketplace-app-7e89f",
storageBucket: "house-marketplace-app-7e89f.appspot.com",
messagingSenderId: "66680163160",
appId: "1:66680163160:web:c1874be8bb6d9e273e732d"
};
// Initialize Firebase
initializeApp(firebaseConfig);
export const db = getFirestore()
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useState, useRef } from "react";
import { getAuth, onAuthStateChanged } from "firebase/auth";
export const useAuthStatus = () => {
const [loggedIn, setLoggedIn] = useState(false);
const [checkingStatus, setCheckingStatus] = useState(true);
const isMouted = useRef(true);
useEffect(() => {
if (isMouted) {
const auth = getAuth();
onAuthStateChanged(auth, (user) => {
if (user) {
setLoggedIn(true);
}
setCheckingStatus(false);
});
}
return () => {
isMouted.current = false;
};
},[isMouted]);
return { loggedIn, checkingStatus };
};
+818 -10
View File
@@ -1,13 +1,821 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;300;400;500;600;700;800;900&display=swap');
* {
box-sizing: border-box;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
html::-webkit-scrollbar {
display: none;
}
body {
font-family: 'Montserrat', sans-serif;
background-color: #f2f4f8;
margin: 0;
box-sizing: border-box;
}
a {
text-decoration: none;
display: block;
color: #000000;
}
button {
outline: none;
border: none;
}
.input,
.passwordInput,
.emailInput,
.nameInput,
.textarea {
box-shadow: rgba(0, 0, 0, 0.11);
border: none;
background: #ffffff;
border-radius: 3rem;
height: 3rem;
width: 100%;
outline: none;
font-family: 'Montserrat', sans-serif;
padding: 0 3rem;
font-size: 1rem;
}
@media (min-width: 1100px) {
.input,
.passwordInput,
.emailInput,
.nameInput,
.textarea {
padding: 0 5rem;
}
}
.textarea {
padding: 1rem 1.5rem;
height: 300px;
border-radius: 1rem;
}
.primaryButton {
cursor: pointer;
background: #00cc66;
border-radius: 1rem;
padding: 0.85rem 2rem;
color: #ffffff;
font-weight: 600;
font-size: 1.25rem;
width: 80%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.removeIcon {
cursor: pointer;
position: absolute;
top: -3%;
right: -2%;
}
.editIcon {
cursor: pointer;
position: absolute;
top: -3.4%;
right: 20px;
}
.pageContainer,
.offers,
.profile,
.listingDetails,
.category,
.explore {
margin: 1rem;
}
@media (min-width: 1024px) {
.pageContainer,
.offers,
.profile,
.listingDetails,
.category,
.explore {
margin: 3rem;
}
}
.loadingSpinnerContainer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 5000;
display: flex;
justify-content: center;
align-items: center;
}
.loadingSpinner {
width: 64px;
height: 64px;
border: 8px solid;
border-color: #00cc66 transparent #00cc66 transparent;
border-radius: 50%;
animation: spin 1.2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.pageHeader {
font-size: 2rem;
font-weight: 800;
}
.navbar {
position: fixed;
left: 0;
bottom: 0;
right: 0;
height: 85px;
background-color: #ffffff;
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
}
.navbarNav {
width: 100%;
margin-top: 0.75rem;
overflow-y: hidden;
}
.navbarListItems {
margin: 0;
padding: 0;
display: flex;
justify-content: space-evenly;
align-items: center;
}
.navbarListItem {
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
}
.navbarListItemName,
.navbarListItemNameActive {
margin-top: 0.25rem;
font-size: 14px;
font-weight: 600;
color: #8f8f8f;
}
.navbarListItemNameActive {
color: #2c2c2c;
}
.nameInput {
margin-bottom: 2rem;
background: url('./assets/svg/badgeIcon.svg') #ffffff 2.5% center no-repeat;
}
.emailInput {
margin-bottom: 2rem;
background: url('./assets/svg/personIcon.svg') #ffffff 2.5% center no-repeat;
}
.passwordInputDiv {
position: relative;
}
.passwordInput {
margin-bottom: 2rem;
background: url('./assets/svg/lockIcon.svg') #ffffff 2.5% center no-repeat;
}
.showPassword {
cursor: pointer;
position: absolute;
top: -4%;
right: 1%;
padding: 1rem;
}
.forgotPasswordLink {
cursor: pointer;
color: #00cc66;
font-weight: 600;
text-align: right;
}
.signInBar,
.signUpBar {
margin-top: 3rem;
display: flex;
justify-content: space-between;
align-items: center;
position: inherit;
}
.signInButton,
.signUpButton,
.signInText,
.signUpText {
cursor: pointer;
}
@media (min-width: 1024px) {
.signInBar,
.signUpBar {
justify-content: start;
}
}
.signInText,
.signUpText {
font-size: 1.5rem;
font-weight: 700;
}
.signInButton,
.signUpButton {
display: flex;
justify-content: center;
align-items: center;
width: 3rem;
height: 3rem;
background-color: #00cc66;
border-radius: 50%;
}
@media (min-width: 1024px) {
.signInButton,
.signUpButton {
margin-left: 3rem;
}
}
.socialLogin {
margin-top: 4rem;
display: flex;
flex-direction: column;
align-items: center;
}
.socialIconDiv {
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
padding: 0.75rem;
margin: 1.5rem;
width: 3rem;
height: 3rem;
background-color: #ffffff;
border-radius: 50%;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.1);
}
.socialIconImg {
width: 100%;
}
.registerLink {
margin-top: 4rem;
color: #00cc66;
font-weight: 600;
text-align: center;
margin-bottom: 3rem;
}
@media (min-width: 1217px) {
.explore {
margin-bottom: 10rem;
}
}
@media (max-height: 536) {
.explore {
margin-bottom: 10rem;
}
}
.exploreHeading,
.exploreCategoryHeading {
font-weight: 700;
}
.exploreCategoryHeading {
margin-top: 3rem;
}
.swiper-container {
min-height: 225px;
height: 23vw;
}
.swiper-pagination-bullet-active {
background-color: #ffffff !important;
}
.swiperSlideDiv {
position: relative;
width: 100%;
height: 100%;
}
.swiperSlideImg {
width: 100%;
object-fit: cover;
}
.swiperSlideText {
color: #ffffff;
position: absolute;
top: 70px;
left: 0;
font-weight: 600;
max-width: 90%;
font-size: 1.25rem;
background-color: rgba(0, 0, 0, 0.8);
padding: 0.5rem;
}
@media (min-width: 1024px) {
.swiperSlideText {
font-size: 1.75rem;
}
}
.swiperSlidePrice {
color: #000000;
position: absolute;
top: 143px;
left: 11px;
font-weight: 600;
max-width: 90%;
background-color: #ffffff;
padding: 0.25rem 0.5rem;
border-radius: 1rem;
}
@media (min-width: 1024px) {
.swiperSlidePrice {
font-size: 1.25rem;
}
}
.exploreCategories {
display: flex;
justify-content: space-between;
}
.exploreCategories a {
width: 48%;
}
.exploreCategoryImg {
min-height: 115px;
height: 15vw;
width: 100%;
border-radius: 1.5rem;
object-fit: cover;
margin: 0 auto;
}
.exploreCategoryName {
font-weight: 500;
text-align: left;
}
.category {
margin-bottom: 10rem;
}
.categoryListings {
padding: 0;
}
.categoryListing {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
position: relative;
}
.categoryListingLink {
display: contents;
}
.categoryListingImg {
width: 30%;
height: 100px;
border-radius: 1.5rem;
object-fit: cover;
}
@media (min-width: 1024px) {
.categoryListingImg {
width: 19%;
height: 217px;
}
}
.categoryListingDetails {
width: 65%;
}
@media (min-width: 1024px) {
.categoryListingDetails {
width: 79%;
}
}
.categoryListingLocation {
font-weight: 600;
font-size: 0.7rem;
opacity: 0.8;
margin-bottom: 0;
}
.categoryListingName {
font-weight: 600;
font-size: 1.25rem;
margin: 0;
}
.categoryListingPrice {
margin-top: 0.5rem;
font-weight: 600;
font-size: 1.1rem;
color: #00cc66;
margin-bottom: 0;
display: flex;
align-items: center;
}
.categoryListingInfoDiv {
display: flex;
justify-content: space-between;
max-width: 275px;
}
.categoryListingInfoText {
font-weight: 500;
font-size: 0.7rem;
}
.loadMore {
cursor: pointer;
width: 8rem;
margin: 0 auto;
text-align: center;
padding: 0.25rem 0.5rem;
background-color: #000000;
color: #ffffff;
font-weight: 600;
border-radius: 1rem;
opacity: 0.7;
margin-top: 2rem;
}
.listingDetails {
margin-bottom: 10rem;
}
.shareIconDiv {
cursor: pointer;
position: fixed;
top: 3%;
right: 5%;
z-index: 2;
background-color: #ffffff;
border-radius: 50%;
width: 3rem;
height: 3rem;
display: flex;
justify-content: center;
align-items: center;
}
.listingName {
font-weight: 600;
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.listingLocation {
margin-top: 0;
font-weight: 600;
}
.discountPrice {
padding: 0.25rem 0.5rem;
background-color: #000000;
color: #ffffff;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 600;
display: inline;
}
.listingType {
padding: 0.25rem 0.5rem;
background-color: #00cc66;
color: #ffffff;
border-radius: 2rem;
display: inline;
font-weight: 600;
font-size: 0.8rem;
margin-right: 1rem;
}
.listingDetailsList {
padding: 0;
list-style-type: none;
}
.listingDetailsList li {
margin: 0.3rem 0;
font-weight: 500;
opacity: 0.8;
}
.listingLocationTitle {
margin-top: 2rem;
font-weight: 600;
font-size: 1.25rem;
}
.leafletContainer {
width: 100%;
height: 200px;
overflow-x: hidden;
margin-bottom: 3rem;
}
@media (min-width: 1024px) {
.leafletContainer {
height: 400px;
}
}
.linkCopied {
position: fixed;
top: 9%;
right: 5%;
z-index: 2;
background-color: #ffffff;
border-radius: 1rem;
padding: 0.5rem 1rem;
font-weight: 600;
}
.contactListingName {
margin-top: -1rem;
margin-bottom: 0;
font-weight: 600;
}
.contactListingLocation {
margin-top: 0.25rem;
font-weight: 600;
}
.contactLandlord {
margin-top: 2rem;
display: flex;
align-items: center;
}
.landlordName {
font-weight: 600;
font-size: 1.2rem;
}
.messageForm {
margin-top: 0.5rem;
}
.messageDiv {
margin-top: 2rem;
display: flex;
flex-direction: column;
margin-bottom: 4rem;
}
.messageLabel {
margin-bottom: 0.5rem;
}
.profile {
margin-bottom: 10rem;
}
.profileHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.logOut {
cursor: pointer;
font-family: 'Montserrat', sans-serif;
font-size: 1rem;
font-weight: 600;
color: #ffffff;
background-color: #00cc66;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
}
.profileDetailsHeader {
display: flex;
justify-content: space-between;
max-width: 500px;
}
.personalDetailsText {
font-weight: 600;
}
.changePersonalDetails {
cursor: pointer;
font-weight: 600;
color: #00cc66;
}
.profileCard {
background-color: #ffffff;
border-radius: 1rem;
padding: 1rem;
box-shadow: rgba(0, 0, 0, 0.2);
max-width: 500px;
}
.profileDetails {
display: flex;
flex-direction: column;
}
.profileName,
.profileEmail,
.profileAddress,
.profileAddressActive,
.profileEmailActive,
.profileNameActive {
all: unset;
margin: 0.3rem 0;
font-weight: 600;
width: 100%;
}
.profileNameActive {
background-color: rgba(44, 44, 44, 0.1);
}
.profileEmail,
.profileAddress,
.profileAddressActive,
.profileEmailActive {
font-weight: 500;
}
.profileEmailActive {
background-color: rgba(44, 44, 44, 0.1);
}
.profileAddressActive {
background-color: rgba(44, 44, 44, 0.1);
}
.createListing {
background-color: #ffffff;
border-radius: 1rem;
padding: 0.25rem 1rem;
box-shadow: rgba(0, 0, 0, 0.2);
margin-top: 2rem;
font-weight: 600;
max-width: 500px;
display: flex;
justify-content: space-between;
align-items: center;
}
.listingText {
margin-top: 3rem;
font-weight: 600;
}
.lisitingsList {
padding: 0;
}
.formLabel {
font-weight: 600;
margin-top: 1rem;
display: block;
}
.formButtons {
display: flex;
}
.formButton,
.formInput,
.formInputAddress,
.formInputName,
.formInputSmall,
.formInputFile,
.formButtonActive {
padding: 0.9rem 3rem;
background-color: #ffffff;
font-weight: 600;
border-radius: 1rem;
font-size: 1rem;
margin: 0.5rem 0.5rem 0 0;
display: flex;
justify-content: center;
align-items: center;
}
.formButtonActive {
background-color: #00cc66;
color: #ffffff;
}
.flex {
display: flex;
}
.formInput,
.formInputAddress,
.formInputName,
.formInputSmall,
.formInputFile {
border: none;
outline: none;
font-family: 'Montserrat', sans-serif;
}
.formInputSmall,
.formInputFile {
margin-right: 3rem;
padding: 0.9rem 0.7rem;
text-align: center;
}
.formInputName {
padding: 0.9rem 1rem;
width: 90%;
max-width: 326px;
}
.formInputAddress {
padding: 0.9rem 1rem;
width: 90%;
max-width: 326px;
}
.formPriceDiv {
display: flex;
align-items: center;
}
.formPriceText {
margin-left: -1.5rem;
font-weight: 600;
}
.imagesInfo {
font-size: 0.9rem;
opacity: 0.75;
}
.formInputFile {
width: 100%;
}
.formInputFile::-webkit-file-upload-button {
background-color: #00cc66;
border: none;
color: #ffffff;
font-weight: 600;
padding: 0.5rem 0.75rem;
border-radius: 1rem;
margin-right: 1rem;
}
.createListingButton {
margin-top: 5rem;
}
.offers {
margin-bottom: 10rem;
}
.offerBadge {
padding: 0.25rem 0.5rem;
background-color: #000000;
color: #ffffff;
border-radius: 1rem;
margin-left: 1rem;
font-size: 0.8rem;
opacity: 0.75;
}
-5
View File
@@ -2,7 +2,6 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
@@ -11,7 +10,3 @@ root.render(
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import {
collection,
getDocs,
query,
where,
orderBy,
limit,
startAfter,
} from "firebase/firestore";
import { db } from "../firebase.config";
import { toast } from "react-toastify";
import Spinner from "../components/Spinner";
import ListingItem from "../components/ListingItem";
function Category() {
const [listings, setListings] = useState(null);
const [loading, setLoading] = useState(true);
const [lastFetchedlisting, setLastFetchedlisting] = useState(null);
const params = useParams();
useEffect(() => {
const fetchListings = async () => {
try {
const listingsRef = collection(db, "listings");
const q = query(
listingsRef,
where("type", "==", params.categoryName),
orderBy("timestamp", "desc"),
limit(10)
);
const querySnap = await getDocs(q);
const lastVisible = querySnap.docs[querySnap.docs.length - 1];
setLastFetchedlisting(lastVisible);
let listings = [];
querySnap.forEach((doc) => {
return listings.push({
id: doc.id,
data: doc.data(),
});
});
setListings(listings);
setLoading(false);
} catch (error) {
toast.error("could not fetch listings");
}
};
fetchListings();
}, [params.categoryName]);
const onFetchMoreListings = async () => {
try {
const listingsRef = collection(db, "listings");
const q = query(
listingsRef,
where("type", "==", params.categoryName),
orderBy("timestamp", "desc"),
startAfter(lastFetchedlisting),
limit(10)
);
const querySnap = await getDocs(q);
const lastVisible = querySnap.docs[querySnap.docs.length - 1];
setLastFetchedlisting(lastVisible);
let listings = [];
querySnap.forEach((doc) => {
return listings.push({
id: doc.id,
data: doc.data(),
});
});
setListings((prevState) => [...prevState, ...listings]);
setLoading(false);
} catch (error) {
console.log(error);
toast.error("could not fetch listings");
}
};
return (
<div className="category">
<header>
<p className="pageHeader">
{params.categoryName === "rent"
? "Places for rent"
: "Places for sale"}
</p>
</header>
{loading ? (
<Spinner />
) : listings && listings.length > 0 ? (
<>
<main>
<ul className="categoryListings">
{listings.map((listing) => (
<ListingItem
listing={listing.data}
id={listing.id}
key={listing.id}
/>
))}
</ul>
</main>
<br />
<br />
{lastFetchedlisting && (
<p className="loadMore" onClick={onFetchMoreListings}>
Load More
</p>
)}
</>
) : (
<p>No listings for {params.categoryName}</p>
)}
</div>
);
}
export default Category;
+67
View File
@@ -0,0 +1,67 @@
import { useState, useEffect } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { doc, getDoc } from "firebase/firestore";
import { db } from "../firebase.config";
import { toast } from "react-toastify";
function Contact() {
const [message, setMassage] = useState("");
const [landlord, setLandlord] = useState(null);
const [searchParams, setSearchParams] = useSearchParams();
const params = useParams();
useEffect(() => {
const getLandlord = async () => {
const docRef = doc(db, "users", params.landlordId);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
setLandlord(docSnap.data());
} else {
toast.error("Landlord does not exist");
}
};
getLandlord();
}, [params.landlordId]);
const onChange = (e) => setMassage(e.target.value);
return (
<div className="pageContainer">
<header>
<p className="pageHeader">Contact Landlord</p>
</header>
{landlord !== null && (
<main>
<div className="contactLandlord">
<p className="landlordName">Contact {landlord?.name}</p>
</div>
<form className="messageForm">
<div className="messageDiv">
<label htmlFor="message" className="messageLable">
Message
</label>
<textarea
onChange={onChange}
name="message"
id="message"
className="textarea"
value={message}
/>
</div>
<a
href={`mailto:${landlord.email}?Subject=${searchParams.get(
"listingName"
)}&body=${message}`}
>
<button className="primaryButton" type="button">
Send Message
</button>
</a>
</form>
</main>
)}
</div>
);
}
export default Contact;
+418
View File
@@ -0,0 +1,418 @@
import { useState, useRef, useEffect } from "react";
import { getAuth, onAuthStateChanged } from "firebase/auth";
import {
getStorage,
ref,
uploadBytesResumable,
getDownloadURL,
} from "firebase/storage";
import { db } from "../firebase.config";
import { addDoc, collection, serverTimestamp } from "firebase/firestore";
import { v4 as uuidv4 } from "uuid";
import { useNavigate } from "react-router-dom";
import Spinner from "../components/Spinner";
import { toast } from "react-toastify";
function CreateLising() {
const [geolocationEnabled, _] = useState(false);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
type: "rent",
name: "",
bedrooms: 1,
bathrooms: 1,
parking: false,
furnished: false,
address: "",
offer: false,
regularPrice: 0,
discountedPrice: 0,
images: {},
latittude: 0,
longitude: 0,
});
const {
type,
name,
bedrooms,
bathrooms,
parking,
furnished,
address,
offer,
regularPrice,
discountedPrice,
images,
latittude,
longitude,
} = formData;
const auth = getAuth();
const navigate = useNavigate();
const isMounted = useRef(true);
useEffect(() => {
if (isMounted) {
onAuthStateChanged(auth, (user) => {
if (user) {
setFormData({ ...formData, userRef: user.uid });
} else {
navigate("/sign-in");
}
});
}
return () => {
isMounted.current = false;
};
}, [isMounted]);
const onSubmit = async (e) => {
e.preventDefault();
setLoading(false);
if (discountedPrice >= regularPrice) {
setLoading(false);
toast.error("Discounted price needs to be less than regular price");
return;
}
if (images.length > 6) {
setLoading(false);
toast.error("Max 6 images");
return;
}
let geolocation = {};
let location;
if (geolocationEnabled) {
//Don't want to register for a geolocation
/* const response = await fetch(
`http://api.positionstack.com/v1/forward?access_key=${APIKEY}&query=${address}`
);
const data = await response.json();
setFormData((prevState) => ({
...prevState,
latitude: data.data[0].latitude,
longitude: data.data[0].longitude,
}));
*/
} else {
geolocation.lat = latittude;
geolocation.lng = longitude;
location = address;
}
const storeImage = async (image) => {
return new Promise((resolve, reject) => {
const storage = getStorage();
const fileName = `${auth.currentUser.uid}-${image.name}-${uuidv4()}`;
const storageRef = ref(storage, "images/" + fileName);
const uploadTask = uploadBytesResumable(storageRef, image);
uploadTask.on(
"state_changed",
(snapshot) => {
const progress =
(snapshot.bytesTransferred / snapshot.totalBytes) * 100;
console.log("Upload is " + progress + "% done");
switch (snapshot.state) {
case "paused":
console.log("Upload is paused");
break;
case "running":
console.log("Upload is running");
break;
default:
break;
}
},
(error) => {
reject(error);
},
() => {
getDownloadURL(uploadTask.snapshot.ref).then((downloadURL) => {
resolve(downloadURL);
});
}
);
});
};
const imgUrls = await Promise.all(
[...images].map((image) => storeImage(image))
).catch(() => {
setLoading(false);
toast.error("Image is not uploaded");
return;
});
const formDataCopy = {
...formData,
imgUrls,
geolocation,
timestamp: serverTimestamp(),
};
formDataCopy.location = address;
delete formDataCopy.images;
delete formDataCopy.address;
location && (formDataCopy.location = location);
!formDataCopy.offer && delete formDataCopy.discountedPrice;
const docRef = await addDoc(collection(db, "listings"), formDataCopy);
setLoading(false);
toast.success("Listing saved");
navigate(`/category/${formDataCopy.type}/${docRef.id}`);
};
const onMutate = (e) => {
let boolean = null;
if (e.target.value === "false") {
boolean = false;
}
if (e.target.value === "true") {
boolean = true;
}
if (e.target.files) {
setFormData((prevState) => ({
...prevState,
images: e.target.files,
}));
}
if (!e.target.files) {
setFormData((prevState) => ({
...prevState,
[e.target.id]: boolean ?? e.target.value,
}));
}
if (loading) {
return <Spinner />;
}
};
return (
<div className="profile">
<header>
<p className="pageHeader">Create a listing</p>
</header>
<main>
<form onSubmit={onSubmit}>
<label className="formLabel">Sell / Rent</label>
<div className="formButtons">
<button
type="button"
className={type === "sale" ? "formButtonActive" : "formButton"}
id="type"
value="sale"
onClick={onMutate}
>
Sell
</button>
<button
type="button"
className={type === "rent" ? "formButtonActive" : "formButton"}
id="type"
value="rent"
onClick={onMutate}
>
Rent
</button>
</div>
<label className="formLabel">Name</label>
<input
className="formInputName"
type="text"
id="name"
value={name}
onChange={onMutate}
maxLength="32"
minLength="10"
required
/>
<div className="formRooms fles">
<div>
<label className="formLabel">Bedrooms</label>
<input
className="formInputSmall"
type="number"
id="bedrooms"
value={bedrooms}
onChange={onMutate}
min="1"
max="50"
required
/>
</div>
<div>
<label className="formLabel">Bathrooms</label>
<input
className="formInputSmall"
type="number"
id="bathrooms"
value={bathrooms}
onChange={onMutate}
min="1"
max="50"
required
/>
</div>
</div>
<label className="formLabel">Parking spot</label>
<div className="formButtons">
<button
className={parking ? "formButtonActive" : "formButton"}
type="button"
id="parking"
value={true}
onClick={onMutate}
min="1"
max="50"
>
Yes
</button>
<button
className={
!parking && parking !== null ? "formButtonActive" : "formButton"
}
type="button"
id="parking"
value={false}
onClick={onMutate}
>
No
</button>
</div>
<label className="formLabel">Furnished</label>
<div className="formButtons">
<button
className={furnished ? "formButtonActive" : "formButton"}
type="button"
id="furnished"
value={true}
onClick={onMutate}
>
Yes
</button>
<button
className={
!furnished && furnished !== null
? "formButtonActive"
: "formButton"
}
type="button"
id="furnished"
value={false}
onClick={onMutate}
>
No
</button>
</div>
<label className="formLabel">Address</label>
<textarea
className="formInputAddress"
type="text"
id="address"
value={address}
onChange={onMutate}
required
/>
{!geolocationEnabled && (
<div className="formLatLng fles">
<div>
<label className="formLabel">Latitude</label>
<input
className="formInputSmall"
type="number"
id="latittude"
value={latittude}
onChange={onMutate}
required
/>
</div>
<div>
<label className="formLabel">Longitude</label>
<input
className="formInputSmall"
type="number"
id="longitude"
value={longitude}
onChange={onMutate}
required
/>
</div>
</div>
)}
<label className="formLabel">Offer</label>
<div className="formButtons">
<button
className={offer ? "formButtonActive" : "formButton"}
type="button"
id="offer"
value={true}
onClick={onMutate}
>
Yes
</button>
<button
className={
!offer && offer !== null ? "formButtonActive" : "formButton"
}
type="button"
id="offer"
value={false}
onClick={onMutate}
>
No
</button>
</div>
<label className="formLabel"> Regular Price</label>
<div className="formPriceDiv">
<input
className="formInputSmall"
type="number"
id="regularPrice"
value={regularPrice}
onChange={onMutate}
min="50"
max="750000000"
required
/>
{formData.type === "rent" && (
<p className="formPriceText">$ / Month</p>
)}
</div>
{offer && (
<>
<label className="formLabel">Discounted Price</label>
<input
className="formInputName"
type="number"
id="discountedPrice"
value={discountedPrice}
onChange={onMutate}
min="50"
max="750000000"
required={offer}
/>
</>
)}
<label className="formLabel">Images</label>
<p className="imagesInfo">
The first image will be the cover (max 6).
</p>
<input
className="formInputFile"
type="file"
id="images"
onChange={onMutate}
max="6"
accept=".jpg,.png,.jpeg"
multiple
required
/>
<button className="primaryButton createListingButton">
Create Listing
</button>
</form>
</main>
</div>
);
}
export default CreateLising;
+447
View File
@@ -0,0 +1,447 @@
import { useState, useRef, useEffect } from "react";
import { getAuth, onAuthStateChanged } from "firebase/auth";
import {
getStorage,
ref,
uploadBytesResumable,
getDownloadURL,
} from "firebase/storage";
import { db } from "../firebase.config";
import { doc, updateDoc, getDoc, serverTimestamp } from "firebase/firestore";
import { v4 as uuidv4 } from "uuid";
import { useNavigate, useParams } from "react-router-dom";
import Spinner from "../components/Spinner";
import { toast } from "react-toastify";
function EditListing() {
const [geolocationEnabled, setGeolocationEnabled] = useState(false);
const [loading, setLoading] = useState(false);
const [listing, setListing] = useState(false);
const [formData, setFormData] = useState({
type: "rent",
name: "",
bedrooms: 1,
bathrooms: 1,
parking: false,
furnished: false,
address: "",
offer: false,
regularPrice: 0,
discountedPrice: 0,
images: {},
latittude: 0,
longitude: 0,
});
const {
type,
name,
bedrooms,
bathrooms,
parking,
furnished,
address,
offer,
regularPrice,
discountedPrice,
images,
latittude,
longitude,
} = formData;
const auth = getAuth();
const params = useParams();
const navigate = useNavigate();
const isMounted = useRef(true);
useEffect(() => {
if (listing && listing.userRef !== auth.currentUser.uid) {
toast.error("You cannot edit that listing");
navigate("/");
}
});
useEffect(() => {
if (isMounted) {
onAuthStateChanged(auth, (user) => {
if (user) {
setFormData({ ...formData, userRef: user.uid });
} else {
navigate("/sign-in");
}
});
}
return () => {
isMounted.current = false;
};
}, [isMounted]);
useEffect(() => {
setLoading(true);
const fetchListing = async () => {
const docRef = doc(db, "listings", params.listingId);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
setListing(docSnap.data());
setFormData({ ...docSnap.data(), address: docSnap.data().location });
setListing(false);
} else {
navigate("/");
toast.error("Listing does not exist");
}
};
fetchListing();
}, []);
const onSubmit = async (e) => {
e.preventDefault();
setLoading(false);
if (discountedPrice >= regularPrice) {
setLoading(false);
toast.error("Discounted price needs to be less than regular price");
return;
}
if (images.length > 6) {
setLoading(false);
toast.error("Max 6 images");
return;
}
let geolocation = {};
let location;
if (geolocationEnabled) {
//Don't want to register for a geolocation
/* const response = await fetch(
`http://api.positionstack.com/v1/forward?access_key=${APIKEY}&query=${address}`
);
const data = await response.json();
setFormData((prevState) => ({
...prevState,
latitude: data.data[0].latitude,
longitude: data.data[0].longitude,
}));
*/
} else {
geolocation.lat = latittude;
geolocation.lng = longitude;
location = address;
}
const storeImage = async (image) => {
return new Promise((resolve, reject) => {
const storage = getStorage();
const fileName = `${auth.currentUser.uid}-${image.name}-${uuidv4()}`;
const storageRef = ref(storage, "images/" + fileName);
const uploadTask = uploadBytesResumable(storageRef, image);
uploadTask.on(
"state_changed",
(snapshot) => {
const progress =
(snapshot.bytesTransferred / snapshot.totalBytes) * 100;
console.log("Upload is " + progress + "% done");
switch (snapshot.state) {
case "paused":
console.log("Upload is paused");
break;
case "running":
console.log("Upload is running");
break;
default:
break;
}
},
(error) => {
reject(error);
},
() => {
getDownloadURL(uploadTask.snapshot.ref).then((downloadURL) => {
resolve(downloadURL);
});
}
);
});
};
const imgUrls = await Promise.all(
[...images].map((image) => storeImage(image))
).catch(() => {
setLoading(false);
toast.error("Image is not uploaded");
return;
});
const formDataCopy = {
...formData,
imgUrls,
geolocation,
timestamp: serverTimestamp(),
};
formDataCopy.location = address;
delete formDataCopy.images;
delete formDataCopy.address;
location && (formDataCopy.location = location);
!formDataCopy.offer && delete formDataCopy.discountedPrice;
const docRef = doc(db, "listings", params.listingId);
await updateDoc(docRef, formDataCopy);
setLoading(false);
toast.success("Listing saved");
navigate(`/category/${formDataCopy.type}/${docRef.id}`);
};
const onMutate = (e) => {
let boolean = null;
if (e.target.value === "false") {
boolean = false;
}
if (e.target.value === "true") {
boolean = true;
}
if (e.target.files) {
setFormData((prevState) => ({
...prevState,
images: e.target.files,
}));
}
if (!e.target.files) {
setFormData((prevState) => ({
...prevState,
[e.target.id]: boolean ?? e.target.value,
}));
}
if (loading) {
return <Spinner />;
}
};
return (
<div className="profile">
<header>
<p className="pageHeader">Edit a listing</p>
</header>
<main>
<form onSubmit={onSubmit}>
<label className="formLabel">Sell / Rent</label>
<div className="formButtons">
<button
type="button"
className={type === "sale" ? "formButtonActive" : "formButton"}
id="type"
value="sale"
onClick={onMutate}
>
Sell
</button>
<button
type="button"
className={type === "rent" ? "formButtonActive" : "formButton"}
id="type"
value="rent"
onClick={onMutate}
>
Rent
</button>
</div>
<label className="formLabel">Name</label>
<input
className="formInputName"
type="text"
id="name"
value={name}
onChange={onMutate}
maxLength="32"
minLength="10"
required
/>
<div className="formRooms fles">
<div>
<label className="formLabel">Bedrooms</label>
<input
className="formInputSmall"
type="number"
id="bedrooms"
value={bedrooms}
onChange={onMutate}
min="1"
max="50"
required
/>
</div>
<div>
<label className="formLabel">Bathrooms</label>
<input
className="formInputSmall"
type="number"
id="bathrooms"
value={bathrooms}
onChange={onMutate}
min="1"
max="50"
required
/>
</div>
</div>
<label className="formLabel">Parking spot</label>
<div className="formButtons">
<button
className={parking ? "formButtonActive" : "formButton"}
type="button"
id="parking"
value={true}
onClick={onMutate}
min="1"
max="50"
>
Yes
</button>
<button
className={
!parking && parking !== null ? "formButtonActive" : "formButton"
}
type="button"
id="parking"
value={false}
onClick={onMutate}
>
No
</button>
</div>
<label className="formLabel">Furnished</label>
<div className="formButtons">
<button
className={furnished ? "formButtonActive" : "formButton"}
type="button"
id="furnished"
value={true}
onClick={onMutate}
>
Yes
</button>
<button
className={
!furnished && furnished !== null
? "formButtonActive"
: "formButton"
}
type="button"
id="furnished"
value={false}
onClick={onMutate}
>
No
</button>
</div>
<label className="formLabel">Address</label>
<textarea
className="formInputAddress"
type="text"
id="address"
value={address}
onChange={onMutate}
required
/>
{!geolocationEnabled && (
<div className="formLatLng fles">
<div>
<label className="formLabel">Latitude</label>
<input
className="formInputSmall"
type="number"
id="latittude"
value={latittude}
onChange={onMutate}
required
/>
</div>
<div>
<label className="formLabel">Longitude</label>
<input
className="formInputSmall"
type="number"
id="longitude"
value={longitude}
onChange={onMutate}
required
/>
</div>
</div>
)}
<label className="formLabel">Offer</label>
<div className="formButtons">
<button
className={offer ? "formButtonActive" : "formButton"}
type="button"
id="offer"
value={true}
onClick={onMutate}
>
Yes
</button>
<button
className={
!offer && offer !== null ? "formButtonActive" : "formButton"
}
type="button"
id="offer"
value={false}
onClick={onMutate}
>
No
</button>
</div>
<label className="formLabel"> Regular Price</label>
<div className="formPriceDiv">
<input
className="formInputSmall"
type="number"
id="regularPrice"
value={regularPrice}
onChange={onMutate}
min="50"
max="750000000"
required
/>
{formData.type === "rent" && (
<p className="formPriceText">$ / Month</p>
)}
</div>
{offer && (
<>
<label className="formLabel">Discounted Price</label>
<input
className="formInputName"
type="number"
id="discountedPrice"
value={discountedPrice}
onChange={onMutate}
min="50"
max="750000000"
required={offer}
/>
</>
)}
<label className="formLabel">Images</label>
<p className="imagesInfo">
The first image will be the cover (max 6).
</p>
<input
className="formInputFile"
type="file"
id="images"
onChange={onMutate}
max="6"
accept=".jpg,.png,.jpeg"
multiple
required
/>
<button className="primaryButton createListingButton">
Edit Listing
</button>
</form>
</main>
</div>
);
}
export default EditListing;
+30
View File
@@ -0,0 +1,30 @@
import Slider from '../components/Slider';
import {Link} from 'react-router-dom'
import rentCategoryImage from '../assets/jpg/rentCategoryImage.jpg'
import sellCategoryImage from '../assets/jpg/sellCategoryImage.jpg'
function Explore (){
return (
<div className='export'>
<header>
<p className='pageHeader'>Explore</p>
</header>
<main>
<Slider/>
<p className='exploreCategoryHeading'>Categories</p>
<div className='exploreCategories'>
<Link to='/category/rent'>
<img src={rentCategoryImage} alt='rent' className='exploreCategoryImg'/>
</Link>
<p className='exploreCategoryName'>Places for rent</p>
<Link to='/category/sale'>
<img src={sellCategoryImage} alt='sell' className='exploreCategoryImg'/>
</Link>
<p className='exploreCategoryName'>Places for sale</p>
</div>
</main>
</div>
);
}
export default Explore
+42
View File
@@ -0,0 +1,42 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { getAuth,sendPasswordResetEmail } from "firebase/auth";
import { toast } from "react-toastify";
import {ReactComponent as ArrowRightIcon} from '../assets/svg/keyboardArrowRightIcon.svg'
function ForgotPassword (){
const [email, setEmail] = useState('')
const onChange = e =>{setEmail(e.target.value)}
const onSubmit =async (e) =>{
e.preventDefault()
try{
const auth=getAuth()
await sendPasswordResetEmail(auth, email)
toast.success('Email was sent')
}
catch(error){
toast.error("Could not send reset email")
}
}
return (
<div className="pageContainer">
<header>
<p className="pageHeader">Forgot Password</p>
</header>
<main>
<form onSubmit={onSubmit}
>
<input type='email' className='emailInput'placeholder="Email" id='email' value={email} onChange={onChange} />
<Link className="forgotPasswordLink" to='/sign-in'>Sign In</Link>
<div className="signInBar">
<div className="signInText">Send Reset Link</div>
<button className="signInButton"><ArrowRightIcon fill='#ffffff' width='34px' height='34px'/></button>
</div>
</form>
</main>
</div>
);
}
export default ForgotPassword
+138
View File
@@ -0,0 +1,138 @@
import SwiperCore, { Navigation, Pagination, Scrollbar, A11y } from "swiper";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/swiper-bundle.css";
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
import { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { getDoc, doc } from "firebase/firestore";
import { getAuth } from "firebase/auth";
import { db } from "../firebase.config";
import Spinner from "../components/Spinner";
import shareIcon from "../assets/svg/shareIcon.svg";
SwiperCore.use([Navigation, Pagination,Scrollbar,A11y])
function Listing() {
const [listing, setListing] = useState(null);
const [loading, setLoading] = useState(true);
const [shareLinkCopied, setShareLinkCopied] = useState(false);
const navigate = useNavigate();
const params = useParams();
const auth = getAuth();
useEffect(() => {
const fetchListings = async () => {
const docRef = doc(db, "listings", params.listingId);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
setListing(docSnap.data());
setLoading(false);
}
};
fetchListings();
}, [navigate, params.listingId]);
if (loading) return <Spinner />;
return (
<main>
<Swiper slidesPerView={1} pagination={{ clickable: true }}>
{listing.imgUrls.map((url, index) => (
<SwiperSlide key={index}>
<div
className="swiperSlideDiv"
style={{
background: `url(${listing.imgUrls[index]}) center no-repeat`,
backgroundSize: "cover",
minHeight:'20rem'
}}
></div>
</SwiperSlide>
))}
</Swiper>
<div
className="shareIconDiv"
onClick={() => {
navigator.clipboard.writeText(window.location.href);
setShareLinkCopied(true);
setTimeout(() => {
setShareLinkCopied(false);
}, 2000);
}}
>
<img src={shareIcon} alt="" />
</div>
{shareLinkCopied && <p className="linkCopied">Link Copied!</p>}
<div className="listingDetails">
<p className="listingName">
{listing.name} -{" "}
{listing.offer
? listing.discountedPrice
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
: listing.regularPrice
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
</p>
<p className="listingLocation">{listing.location}</p>
<p className="listingType">
For {listing.type === "rent" ? "Rent" : "Sale"}
</p>
{listing.offer && (
<p className="discountPrice">
${listing.regularPrice - listing.discountedPrice} discount
</p>
)}
<ul className="listingDetailsList">
<li>
{listing.bedrooms > 1
? `${listing.bedrooms} Bedrooms`
: "1 Bedroom"}
</li>
<li>
{listing.bathrooms > 1
? `${listing.bathrooms} Bathrooms`
: "1 Bathroom"}
</li>
<li>{listing.parking && "Parking Spot"}</li>
<li>{listing.furnished && "Furnished"}</li>
</ul>
<p className="listingLocationTitle">Location</p>
<div className="leafletContainer">
<MapContainer
style={{ height: "100%", width: "100%" }}
center={[listing.geolocation.lat, listing.geolocation.lng]}
zoom={13}
scrollWheelZoom={false}
>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png"
/>
<Marker
position={[listing.geolocation.lat, listing.geolocation.lng]}
>
<Popup>{listing.location}</Popup>
</Marker>
</MapContainer>
</div>
{auth.currentUser?.uid !== listing.userRef && (
<Link
to={`/contact/${listing.userRef}?listingName=${listing.name}`}
className="primaryButton"
>
Contact Landlord
</Link>
)}
</div>
</main>
);
}
export default Listing;
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import {
collection,
getDocs,
query,
where,
orderBy,
limit,
startAfter,
} from "firebase/firestore";
import { db } from "../firebase.config";
import { toast } from "react-toastify";
import Spinner from "../components/Spinner";
import ListingItem from "../components/ListingItem";
function Offers() {
const [listings, setListings] = useState(null);
const [loading, setLoading] = useState(true);
const [lastFetchedlisting, setLastFetchedlisting] = useState(null);
useEffect(() => {
const fetchListings = async () => {
try {
const listingsRef = collection(db, "listings");
const q = query(
listingsRef,
where("offer", "==", true),
orderBy("timestamp", "desc"),
limit(2)
);
const querySnap = await getDocs(q);
const lastVisible = querySnap.docs[querySnap.docs.length - 1];
setLastFetchedlisting(lastVisible);
let listings = [];
querySnap.forEach((doc) => {
return listings.push({
id: doc.id,
data: doc.data(),
});
});
setListings(listings);
setLoading(false);
} catch (error) {
console.log(error);
toast.error("could not fetch listings");
}
};
fetchListings();
}, []);
const onFetchMoreListings = async () => {
try {
const listingsRef = collection(db, "listings");
const q = query(
listingsRef,
where("offer", "==", true),
orderBy("timestamp", "desc"),
startAfter(lastFetchedlisting),
limit(10)
);
const querySnap = await getDocs(q);
const lastVisible = querySnap.docs[querySnap.docs.length - 1];
setLastFetchedlisting(lastVisible);
let listings = [];
querySnap.forEach((doc) => {
return listings.push({
id: doc.id,
data: doc.data(),
});
});
setListings((prevState) => [...prevState, ...listings]);
setLoading(false);
} catch (error) {
console.log(error);
toast.error("could not fetch listings");
}
};
return (
<div className="category">
<header>
<p className="pageHeader">Offers</p>
</header>
{loading ? (
<Spinner />
) : listings && listings.length > 0 ? (
<>
<main>
<ul className="categoryListings">
{listings.map((listing) => (
<ListingItem
listing={listing.data}
id={listing.id}
key={listing.id}
/>
))}
</ul>
</main>
<br />
<br />
{lastFetchedlisting && (
<p className="loadMore" onClick={onFetchMoreListings}>
Load More
</p>
)}
</>
) : (
<p>There are no current Offers</p>
)}
</div>
);
}
export default Offers;
+159
View File
@@ -0,0 +1,159 @@
import ListingItem from "../components/ListingItem";
import arrowRight from "../assets/svg/keyboardArrowRightIcon.svg";
import homeIcon from "../assets/svg/homeIcon.svg";
import { toast } from "react-toastify";
import { useEffect, useState } from "react";
import { getAuth, updateProfile } from "firebase/auth";
import { useNavigate, Link } from "react-router-dom";
import { db } from "../firebase.config";
import {
updateDoc,
doc,
collection,
getDocs,
query,
where,
orderBy,
deleteDoc,
} from "firebase/firestore";
function Profile() {
const [changeDetails, setChangeDetails] = useState(false);
const auth = getAuth();
const [loading, setLoading] = useState(false);
const [listings, setListings] = useState(null);
const [formData, setFormData] = useState({
name: auth.currentUser.displayName,
email: auth.currentUser.email,
});
const navigate = useNavigate();
const { name, email } = formData;
useEffect(() => {
const fetchUserListingts = async () => {
const listingsRef = collection(db, "listings");
const q = query(
listingsRef,
where("userRef", "==", auth.currentUser.uid),
orderBy("timestamp", "desc")
);
const querySnap = await getDocs(q);
const listings = [];
querySnap.forEach((doc) => {
return listings.push({ id: doc.id, data: doc.data() });
});
setListings(listings);
setLoading(false);
};
fetchUserListingts();
}, [auth.currentUser.uid]);
const onLogout = () => {
auth.signOut();
navigate("/");
};
const onSubmit = async () => {
try {
if (auth.currentUser.displayName !== name) {
await updateProfile(auth.currentUser, {
displayName: name,
});
const userRef = doc(db, "users", auth.currentUser.uid);
await updateDoc(userRef, {
name,
});
}
} catch (error) {
toast.error("could no update profile details");
}
};
const onChange = (e) => {
setFormData((prevState) => ({
...prevState,
[e.target.id]: e.target.value,
}));
};
const onDelete = async (listingId) => {
if (window.confirm("Are you sure you want to delte?")) {
await deleteDoc(doc(db, "listings", listingId));
const updatedListings = listings.filter(
(listing) => listing.id !== listingId
);
setListings(updatedListings);
toast.success("Successfully deleted listing ");
}
};
const onEdit = (listingId)=> navigate(`/edit-listing/${listingId}`)
return (
<div className="profile">
<header className="profileHeader">
<p className="pageHeader">My Profile</p>
<button type="button" className="logOut" onClick={onLogout}>
Logout
</button>
</header>
<main>
<div className="profileDetailsHeader">
<p className="profileDetailsText">Personal Details</p>
<p
className="changePersonalDetails"
onClick={() => {
changeDetails && onSubmit();
setChangeDetails((prevState) => !prevState);
}}
>
{changeDetails ? "done" : "change"}
</p>
</div>
<div className="profileCard">
<form>
<input
type="text"
id="email"
className={!changeDetails ? "profileEmail" : "profileEmailActive"}
disabled={!changeDetails}
value={email}
onChange={onChange}
/>
<input
type="text"
id="name"
className={!changeDetails ? "profileName" : "profileNameActive"}
disabled={!changeDetails}
value={name}
onChange={onChange}
/>
</form>
</div>
<Link to="/create-listing" className="createListing">
<img src={homeIcon} alt="home" />
<p>Sell or rent your home</p>
<img src={arrowRight} alt="arrowRight" />
</Link>
{!loading && listings?.length > 0 && (
<>
<p className="listingText">Your Listings</p>
<ul className="listingsList">
{listings.map((listing) => (
<ListingItem
key={listing.id}
listing={listing.data}
id={listing.id}
onDelete={() => onDelete(listing.id)}
onEdit={() => onEdit(listing.id)}
/>
))}
</ul>
</>
)}
</main>
</div>
);
}
export default Profile;
+85
View File
@@ -0,0 +1,85 @@
import OAuth from "../components/OAuth";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
import { ReactComponent as ArrowRightIcon } from "../assets/svg/keyboardArrowRightIcon.svg";
import visibilityIcon from "../assets/svg/visibilityIcon.svg";
function Signin() {
const [showPassword, setShowPassword] = useState(false);
const [formData, setFormData] = useState({ email: "", password: "" });
const { email, password } = formData;
const navigate = useNavigate();
const onChange = (e) => {
setFormData((prevState) => ({
...prevState,
[e.target.id]: e.target.value,
}));
};
const onSubmit = async (e) => {
e.preventDefault();
const auth = getAuth();
try {
const userCredential = await signInWithEmailAndPassword(
auth,
email,
password
);
if (userCredential.user) {
navigate("/");
}
} catch (error) {
toast.error("Bad user credentials")
}
};
return (
<>
<div className="pageContainer">
<header>
<p className="pageHeader">Welcome Back!</p>
</header>
<form onSubmit={onSubmit}>
<input
className="emailInput"
type="email"
placeholder="Email"
id="email"
value={email}
onChange={onChange}
/>
<div className="passwordInputDiv">
<input
type={showPassword ? "text" : "password"}
className="passwordInput"
placeholder="Password"
id="password"
value={password}
onChange={onChange}
/>
<img
className="showPassword"
src={visibilityIcon}
onClick={() => setShowPassword((prevState) => !prevState)}
/>
</div>
<Link to="/forgot-password" className="forgotPasswordLink">
Forgot Password
</Link>
<div className="signInBar">
<p className="sigInText">Sign In</p>
<button className="signInButton">
<ArrowRightIcon fill="#ffffff" width="34px" height="34px" />
</button>
</div>
</form>
<OAuth />
<Link to="/sign-up" className="registerLink">
Sign Up Instead
</Link>
</div>
</>
);
}
export default Signin;
+113
View File
@@ -0,0 +1,113 @@
import "react-toastify/dist/ReactToastify.css";
import { toast } from "react-toastify";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ReactComponent as ArrowRightIcon } from "../assets/svg/keyboardArrowRightIcon.svg";
import visibilityIcon from "../assets/svg/visibilityIcon.svg";
import {
getAuth,
createUserWithEmailAndPassword,
updateProfile,
} from "firebase/auth";
import { db } from "../firebase.config";
import { setDoc, doc, serverTimestamp } from "firebase/firestore";
import OAuth from "../components/OAuth";
function Signup() {
const [showPassword, setShowPassword] = useState(false);
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
});
const { name, email, password } = formData;
const navigate = useNavigate();
const onChange = (e) => {
setFormData((prevState) => ({
...prevState,
[e.target.id]: e.target.value,
}));
};
const onSubmit = async (e) => {
e.preventDefault();
try {
const auth = getAuth();
const userCredential = await createUserWithEmailAndPassword(
auth,
email,
password
);
const user = userCredential.user;
updateProfile(auth.currentUser, { displayName: name });
const fromDataCopy = { ...formData };
delete fromDataCopy.password;
fromDataCopy.timestamp = serverTimestamp();
await setDoc(doc(db, "users", user.uid), fromDataCopy);
navigate("/");
} catch (error) {
console.log(error);
toast.error("something went wrong with the registration");
}
};
return (
<>
<div className="pageContainer">
<header>
<p className="pageHeader">Welcome Back!</p>
</header>
<form onSubmit={onSubmit}>
<input
className="nameInput"
type="text"
placeholder="Name"
id="name"
value={name}
onChange={onChange}
/>
<input
className="emailInput"
type="email"
placeholder="Email"
id="email"
value={email}
onChange={onChange}
/>
<div className="passwordInputDiv">
<input
type={showPassword ? "text" : "password"}
className="passwordInput"
placeholder="Password"
id="password"
value={password}
onChange={onChange}
/>
<img
className="showPassword"
src={visibilityIcon}
onClick={() => setShowPassword((prevState) => !prevState)}
/>
</div>
<Link to="/forgot-password" className="forgotPasswordLink">
Forgot Password
</Link>
<div className="signUpBar">
<p className="sigUpText">Sign In</p>
<button className="signUpButton">
<ArrowRightIcon fill="#ffffff" width="34px" height="34px" />
</button>
</div>
</form>
<OAuth />
<Link to="/sign-in" className="registerLink">
Sign In Instead
</Link>
</div>
</>
);
}
export default Signup;
-13
View File
@@ -1,13 +0,0 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
-5
View File
@@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';