├── client
├── postcss.config.js
├── src
│ ├── index.css
│ ├── components
│ │ ├── PrivateRoute.jsx
│ │ ├── OAuth.jsx
│ │ ├── Contact.jsx
│ │ ├── ListingItem.jsx
│ │ └── Header.jsx
│ ├── main.jsx
│ ├── firebase.js
│ ├── redux
│ │ ├── store.js
│ │ └── user
│ │ │ └── userSlice.js
│ ├── pages
│ │ ├── About.jsx
│ │ ├── SignIn.jsx
│ │ ├── SignUp.jsx
│ │ ├── Home.jsx
│ │ ├── Listing.jsx
│ │ ├── Search.jsx
│ │ ├── Profile.jsx
│ │ ├── CreateListing.jsx
│ │ └── UpdateListing.jsx
│ └── App.jsx
├── tailwind.config.js
├── index.html
├── vite.config.js
├── README.md
├── .eslintrc.cjs
└── package.json
├── api
├── utils
│ ├── error.js
│ └── verifyUser.js
├── routes
│ ├── auth.route.js
│ ├── user.route.js
│ └── listing.route.js
├── models
│ ├── user.model.js
│ └── listing.model.js
├── index.js
└── controllers
│ ├── user.controller.js
│ ├── auth.controller.js
│ └── listing.controller.js
├── .gitignore
└── package.json
/client/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body{
6 | background-color: rgb(241, 245, 241);
7 | }
--------------------------------------------------------------------------------
/api/utils/error.js:
--------------------------------------------------------------------------------
1 | export const errorHandler = (statusCode, message) => {
2 | const error = new Error();
3 | error.statusCode = statusCode;
4 | error.message = message;
5 | return error;
6 | };
7 |
--------------------------------------------------------------------------------
/client/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [
8 | require('@tailwindcss/line-clamp'),
9 | // ...
10 | ],
11 | };
12 |
--------------------------------------------------------------------------------
/client/src/components/PrivateRoute.jsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { Outlet, Navigate } from 'react-router-dom';
3 |
4 | export default function PrivateRoute() {
5 | const { currentUser } = useSelector((state) => state.user);
6 | return currentUser ? : ;
7 | }
8 |
--------------------------------------------------------------------------------
/api/routes/auth.route.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { google, signOut, signin, signup } from '../controllers/auth.controller.js';
3 |
4 | const router = express.Router();
5 |
6 | router.post("/signup", signup);
7 | router.post("/signin", signin);
8 | router.post('/google', google);
9 | router.get('/signout', signOut)
10 |
11 | export default router;
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Sahand Estate
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 | .env
--------------------------------------------------------------------------------
/client/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react-swc';
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | server: {
7 | proxy: {
8 | '/api': {
9 | target: 'http://localhost:3000',
10 | secure: false,
11 | },
12 | },
13 | },
14 |
15 | plugins: [react()],
16 | });
17 |
--------------------------------------------------------------------------------
/api/utils/verifyUser.js:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken';
2 | import { errorHandler } from './error.js';
3 |
4 | export const verifyToken = (req, res, next) => {
5 | const token = req.cookies.access_token;
6 |
7 | if (!token) return next(errorHandler(401, 'Unauthorized'));
8 |
9 | jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
10 | if (err) return next(errorHandler(403, 'Forbidden'));
11 |
12 | req.user = user;
13 | next();
14 | });
15 | };
16 |
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # React + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
--------------------------------------------------------------------------------
/client/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import App from './App.jsx';
4 | import './index.css';
5 | import { persistor, store } from './redux/store.js';
6 | import { Provider } from 'react-redux';
7 | import { PersistGate } from 'redux-persist/integration/react';
8 |
9 | ReactDOM.createRoot(document.getElementById('root')).render(
10 |
11 |
12 |
13 |
14 |
15 | );
16 |
--------------------------------------------------------------------------------
/api/routes/user.route.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { deleteUser, test, updateUser, getUserListings, getUser} from '../controllers/user.controller.js';
3 | import { verifyToken } from '../utils/verifyUser.js';
4 |
5 |
6 | const router = express.Router();
7 |
8 | router.get('/test', test);
9 | router.post('/update/:id', verifyToken, updateUser)
10 | router.delete('/delete/:id', verifyToken, deleteUser)
11 | router.get('/listings/:id', verifyToken, getUserListings)
12 | router.get('/:id', verifyToken, getUser)
13 |
14 | export default router;
--------------------------------------------------------------------------------
/api/routes/listing.route.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { createListing, deleteListing, updateListing, getListing, getListings } from '../controllers/listing.controller.js';
3 | import { verifyToken } from '../utils/verifyUser.js';
4 |
5 | const router = express.Router();
6 |
7 | router.post('/create', verifyToken, createListing);
8 | router.delete('/delete/:id', verifyToken, deleteListing);
9 | router.post('/update/:id', verifyToken, updateListing);
10 | router.get('/get/:id', getListing);
11 | router.get('/get', getListings);
12 |
13 | export default router;
14 |
--------------------------------------------------------------------------------
/client/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:react/recommended',
7 | 'plugin:react/jsx-runtime',
8 | 'plugin:react-hooks/recommended',
9 | ],
10 | ignorePatterns: ['dist', '.eslintrc.cjs'],
11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
12 | settings: { react: { version: '18.2' } },
13 | plugins: ['react-refresh'],
14 | rules: {
15 | 'react-refresh/only-export-components': [
16 | 'warn',
17 | { allowConstantExport: true },
18 | ],
19 | },
20 | }
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mern-estate",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "nodemon api/index.js",
9 | "start": "node api/index.js",
10 | "build": "npm install && npm install --prefix client && npm run build --prefix client"
11 | },
12 | "keywords": [],
13 | "author": "",
14 | "license": "ISC",
15 | "dependencies": {
16 | "bcryptjs": "^2.4.3",
17 | "cookie-parser": "^1.4.6",
18 | "dotenv": "^16.3.1",
19 | "express": "^4.18.2",
20 | "jsonwebtoken": "^9.0.2",
21 | "mongoose": "^7.5.0",
22 | "nodemon": "^3.0.1"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/api/models/user.model.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 |
3 | const userSchema = new mongoose.Schema(
4 | {
5 | username: {
6 | type: String,
7 | required: true,
8 | unique: true,
9 | },
10 | email: {
11 | type: String,
12 | required: true,
13 | unique: true,
14 | },
15 | password: {
16 | type: String,
17 | required: true,
18 | },
19 | avatar:{
20 | type: String,
21 | default: "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png"
22 | },
23 | },
24 | { timestamps: true }
25 | );
26 |
27 | const User = mongoose.model('User', userSchema);
28 |
29 | export default User;
30 |
--------------------------------------------------------------------------------
/client/src/firebase.js:
--------------------------------------------------------------------------------
1 | // Import the functions you need from the SDKs you need
2 | import { initializeApp } from 'firebase/app';
3 | // TODO: Add SDKs for Firebase products that you want to use
4 | // https://firebase.google.com/docs/web/setup#available-libraries
5 |
6 | // Your web app's Firebase configuration
7 | const firebaseConfig = {
8 | apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
9 | authDomain: 'mern-estate.firebaseapp.com',
10 | projectId: 'mern-estate',
11 | storageBucket: 'mern-estate.appspot.com',
12 | messagingSenderId: '1078482850952',
13 | appId: '1:1078482850952:web:28f19139ab77246602fb3d',
14 | };
15 |
16 | // Initialize Firebase
17 | export const app = initializeApp(firebaseConfig);
18 |
--------------------------------------------------------------------------------
/client/src/redux/store.js:
--------------------------------------------------------------------------------
1 | import { combineReducers, configureStore } from '@reduxjs/toolkit';
2 | import userReducer from './user/userSlice';
3 | import { persistReducer, persistStore } from 'redux-persist';
4 | import storage from 'redux-persist/lib/storage';
5 |
6 | const rootReducer = combineReducers({ user: userReducer });
7 |
8 | const persistConfig = {
9 | key: 'root',
10 | storage,
11 | version: 1,
12 | };
13 |
14 | const persistedReducer = persistReducer(persistConfig, rootReducer);
15 |
16 | export const store = configureStore({
17 | reducer: persistedReducer,
18 | middleware: (getDefaultMiddleware) =>
19 | getDefaultMiddleware({
20 | serializableCheck: false,
21 | }),
22 | });
23 |
24 | export const persistor = persistStore(store);
25 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@reduxjs/toolkit": "^1.9.5",
14 | "axios": "^1.9.0",
15 | "firebase": "^10.3.1",
16 | "react": "^18.2.0",
17 | "react-dom": "^18.2.0",
18 | "react-icons": "^4.10.1",
19 | "react-redux": "^8.1.2",
20 | "react-router-dom": "^6.15.0",
21 | "redux-persist": "^6.0.0",
22 | "swiper": "^10.2.0"
23 | },
24 | "devDependencies": {
25 | "@tailwindcss/line-clamp": "^0.4.4",
26 | "@types/react": "^18.2.15",
27 | "@types/react-dom": "^18.2.7",
28 | "@vitejs/plugin-react-swc": "^3.3.2",
29 | "autoprefixer": "^10.4.15",
30 | "eslint": "^8.45.0",
31 | "eslint-plugin-react": "^7.32.2",
32 | "eslint-plugin-react-hooks": "^4.6.0",
33 | "eslint-plugin-react-refresh": "^0.4.3",
34 | "postcss": "^8.4.29",
35 | "tailwindcss": "^3.3.3",
36 | "vite": "^4.4.5"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/client/src/pages/About.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function About() {
4 | return (
5 |
6 |
About Sahand Estate
7 |
Sahand Estate is a leading real estate agency that specializes in helping clients buy, sell, and rent properties in the most desirable neighborhoods. Our team of experienced agents is dedicated to providing exceptional service and making the buying and selling process as smooth as possible.
8 |
9 | Our mission is to help our clients achieve their real estate goals by providing expert advice, personalized service, and a deep understanding of the local market. Whether you are looking to buy, sell, or rent a property, we are here to help you every step of the way.
10 |
11 |
Our team of agents has a wealth of experience and knowledge in the real estate industry, and we are committed to providing the highest level of service to our clients. We believe that buying or selling a property should be an exciting and rewarding experience, and we are dedicated to making that a reality for each and every one of our clients.
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/api/models/listing.model.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 |
3 | const listingSchema = new mongoose.Schema(
4 | {
5 | name: {
6 | type: String,
7 | required: true,
8 | },
9 | description: {
10 | type: String,
11 | required: true,
12 | },
13 | address: {
14 | type: String,
15 | required: true,
16 | },
17 | regularPrice: {
18 | type: Number,
19 | required: true,
20 | },
21 | discountPrice: {
22 | type: Number,
23 | required: true,
24 | },
25 | bathrooms: {
26 | type: Number,
27 | required: true,
28 | },
29 | bedrooms: {
30 | type: Number,
31 | required: true,
32 | },
33 | furnished: {
34 | type: Boolean,
35 | required: true,
36 | },
37 | parking: {
38 | type: Boolean,
39 | required: true,
40 | },
41 | type: {
42 | type: String,
43 | required: true,
44 | },
45 | offer: {
46 | type: Boolean,
47 | required: true,
48 | },
49 | imageUrls: {
50 | type: Array,
51 | required: true,
52 | },
53 | userRef: {
54 | type: String,
55 | required: true,
56 | },
57 | },
58 | { timestamps: true }
59 | );
60 |
61 | const Listing = mongoose.model('Listing', listingSchema);
62 |
63 | export default Listing;
64 |
--------------------------------------------------------------------------------
/api/index.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import mongoose from 'mongoose';
3 | import dotenv from 'dotenv';
4 | import userRouter from './routes/user.route.js';
5 | import authRouter from './routes/auth.route.js';
6 | import listingRouter from './routes/listing.route.js';
7 | import cookieParser from 'cookie-parser';
8 | import path from 'path';
9 | dotenv.config();
10 |
11 | mongoose
12 | .connect(process.env.MONGO)
13 | .then(() => {
14 | console.log('Connected to MongoDB!');
15 | })
16 | .catch((err) => {
17 | console.log(err);
18 | });
19 |
20 | const __dirname = path.resolve();
21 |
22 | const app = express();
23 |
24 | app.use(express.json());
25 |
26 | app.use(cookieParser());
27 |
28 | app.listen(3000, () => {
29 | console.log('Server is running on port 3000!');
30 | });
31 |
32 | app.use('/api/user', userRouter);
33 | app.use('/api/auth', authRouter);
34 | app.use('/api/listing', listingRouter);
35 |
36 |
37 | app.use(express.static(path.join(__dirname, '/client/dist')));
38 |
39 | app.get('*', (req, res) => {
40 | res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html'));
41 | })
42 |
43 | app.use((err, req, res, next) => {
44 | const statusCode = err.statusCode || 500;
45 | const message = err.message || 'Internal Server Error';
46 | return res.status(statusCode).json({
47 | success: false,
48 | statusCode,
49 | message,
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/client/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { BrowserRouter, Routes, Route } from 'react-router-dom';
2 | import Home from './pages/Home';
3 | import SignIn from './pages/SignIn';
4 | import SignUp from './pages/SignUp';
5 | import About from './pages/About';
6 | import Profile from './pages/Profile';
7 | import Header from './components/Header';
8 | import PrivateRoute from './components/PrivateRoute';
9 | import CreateListing from './pages/CreateListing';
10 | import UpdateListing from './pages/UpdateListing';
11 | import Listing from './pages/Listing';
12 | import Search from './pages/Search';
13 |
14 | export default function App() {
15 | return (
16 |
17 |
18 |
19 | } />
20 | } />
21 | } />
22 | } />
23 | } />
24 | } />
25 |
26 | }>
27 | } />
28 | } />
29 | }
32 | />
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/client/src/components/OAuth.jsx:
--------------------------------------------------------------------------------
1 | import { GoogleAuthProvider, getAuth, signInWithPopup } from 'firebase/auth';
2 | import { app } from '../firebase';
3 | import { useDispatch } from 'react-redux';
4 | import { signInSuccess } from '../redux/user/userSlice';
5 | import { useNavigate } from 'react-router-dom';
6 |
7 | export default function OAuth() {
8 | const dispatch = useDispatch();
9 | const navigate = useNavigate();
10 | const handleGoogleClick = async () => {
11 | try {
12 | const provider = new GoogleAuthProvider();
13 | const auth = getAuth(app);
14 |
15 | const result = await signInWithPopup(auth, provider);
16 |
17 | const res = await fetch('/api/auth/google', {
18 | method: 'POST',
19 | headers: {
20 | 'Content-Type': 'application/json',
21 | },
22 | body: JSON.stringify({
23 | name: result.user.displayName,
24 | email: result.user.email,
25 | photo: result.user.photoURL,
26 | }),
27 | });
28 | const data = await res.json();
29 | dispatch(signInSuccess(data));
30 | navigate('/');
31 | } catch (error) {
32 | console.log('could not sign in with google', error);
33 | }
34 | };
35 | return (
36 |
41 | Continue with google
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/client/src/components/Contact.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | export default function Contact({ listing }) {
5 | const [landlord, setLandlord] = useState(null);
6 | const [message, setMessage] = useState('');
7 | const onChange = (e) => {
8 | setMessage(e.target.value);
9 | };
10 |
11 | useEffect(() => {
12 | const fetchLandlord = async () => {
13 | try {
14 | const res = await fetch(`/api/user/${listing.userRef}`);
15 | const data = await res.json();
16 | setLandlord(data);
17 | } catch (error) {
18 | console.log(error);
19 | }
20 | };
21 | fetchLandlord();
22 | }, [listing.userRef]);
23 | return (
24 | <>
25 | {landlord && (
26 |
27 |
28 | Contact {landlord.username} {' '}
29 | for{' '}
30 | {listing.name.toLowerCase()}
31 |
32 |
41 |
42 |
46 | Send Message
47 |
48 |
49 | )}
50 | >
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/client/src/redux/user/userSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 |
3 | const initialState = {
4 | currentUser: null,
5 | error: null,
6 | loading: false,
7 | };
8 |
9 | const userSlice = createSlice({
10 | name: 'user',
11 | initialState,
12 | reducers: {
13 | signInStart: (state) => {
14 | state.loading = true;
15 | },
16 | signInSuccess: (state, action) => {
17 | state.currentUser = action.payload;
18 | state.loading = false;
19 | state.error = null;
20 | },
21 | signInFailure: (state, action) => {
22 | state.error = action.payload;
23 | state.loading = false;
24 | },
25 | updateUserStart: (state) => {
26 | state.loading = true;
27 | },
28 | updateUserSuccess: (state, action) => {
29 | state.currentUser = action.payload;
30 | state.loading = false;
31 | state.error = null;
32 | },
33 | updateUserFailure: (state, action) => {
34 | state.error = action.payload;
35 | state.loading = false;
36 | },
37 | deleteUserStart: (state) => {
38 | state.loading = true;
39 | },
40 | deleteUserSuccess: (state) => {
41 | state.currentUser = null;
42 | state.loading = false;
43 | state.error = null;
44 | },
45 | deleteUserFailure: (state, action) => {
46 | state.error = action.payload;
47 | state.loading = false;
48 | },
49 | signOutUserStart: (state) => {
50 | state.loading = true;
51 | },
52 | signOutUserSuccess: (state) => {
53 | state.currentUser = null;
54 | state.loading = false;
55 | state.error = null;
56 | },
57 | signOutUserFailure: (state, action) => {
58 | state.error = action.payload;
59 | state.loading = false;
60 | },
61 | },
62 | });
63 |
64 | export const {
65 | signInStart,
66 | signInSuccess,
67 | signInFailure,
68 | updateUserFailure,
69 | updateUserSuccess,
70 | updateUserStart,
71 | deleteUserFailure,
72 | deleteUserSuccess,
73 | deleteUserStart,
74 | signOutUserFailure,
75 | signOutUserSuccess,
76 | signOutUserStart,
77 | } = userSlice.actions;
78 |
79 | export default userSlice.reducer;
80 |
--------------------------------------------------------------------------------
/client/src/components/ListingItem.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import { MdLocationOn } from 'react-icons/md';
3 |
4 | export default function ListingItem({ listing }) {
5 | return (
6 |
7 |
8 |
16 |
17 |
18 | {listing.name}
19 |
20 |
21 |
22 |
23 | {listing.address}
24 |
25 |
26 |
27 | {listing.description}
28 |
29 |
30 | $
31 | {listing.offer
32 | ? listing.discountPrice.toLocaleString('en-US')
33 | : listing.regularPrice.toLocaleString('en-US')}
34 | {listing.type === 'rent' && ' / month'}
35 |
36 |
37 |
38 | {listing.bedrooms > 1
39 | ? `${listing.bedrooms} beds `
40 | : `${listing.bedrooms} bed `}
41 |
42 |
43 | {listing.bathrooms > 1
44 | ? `${listing.bathrooms} baths `
45 | : `${listing.bathrooms} bath `}
46 |
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/api/controllers/user.controller.js:
--------------------------------------------------------------------------------
1 | import bcryptjs from 'bcryptjs';
2 | import User from '../models/user.model.js';
3 | import { errorHandler } from '../utils/error.js';
4 | import Listing from '../models/listing.model.js';
5 |
6 | export const test = (req, res) => {
7 | res.json({
8 | message: 'Api route is working!',
9 | });
10 | };
11 |
12 | export const updateUser = async (req, res, next) => {
13 | if (req.user.id !== req.params.id)
14 | return next(errorHandler(401, 'You can only update your own account!'));
15 | try {
16 | if (req.body.password) {
17 | req.body.password = bcryptjs.hashSync(req.body.password, 10);
18 | }
19 |
20 | const updatedUser = await User.findByIdAndUpdate(
21 | req.params.id,
22 | {
23 | $set: {
24 | username: req.body.username,
25 | email: req.body.email,
26 | password: req.body.password,
27 | avatar: req.body.avatar,
28 | },
29 | },
30 | { new: true }
31 | );
32 |
33 | const { password, ...rest } = updatedUser._doc;
34 |
35 | res.status(200).json(rest);
36 | } catch (error) {
37 | next(error);
38 | }
39 | };
40 |
41 | export const deleteUser = async (req, res, next) => {
42 | if (req.user.id !== req.params.id)
43 | return next(errorHandler(401, 'You can only delete your own account!'));
44 | try {
45 | await User.findByIdAndDelete(req.params.id);
46 | res.clearCookie('access_token');
47 | res.status(200).json('User has been deleted!');
48 | } catch (error) {
49 | next(error);
50 | }
51 | };
52 |
53 | export const getUserListings = async (req, res, next) => {
54 | if (req.user.id === req.params.id) {
55 | try {
56 | const listings = await Listing.find({ userRef: req.params.id });
57 | res.status(200).json(listings);
58 | } catch (error) {
59 | next(error);
60 | }
61 | } else {
62 | return next(errorHandler(401, 'You can only view your own listings!'));
63 | }
64 | };
65 |
66 | export const getUser = async (req, res, next) => {
67 | try {
68 |
69 | const user = await User.findById(req.params.id);
70 |
71 | if (!user) return next(errorHandler(404, 'User not found!'));
72 |
73 | const { password: pass, ...rest } = user._doc;
74 |
75 | res.status(200).json(rest);
76 | } catch (error) {
77 | next(error);
78 | }
79 | };
80 |
--------------------------------------------------------------------------------
/client/src/pages/SignIn.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Link, useNavigate } from 'react-router-dom';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import {
5 | signInStart,
6 | signInSuccess,
7 | signInFailure,
8 | } from '../redux/user/userSlice';
9 | import OAuth from '../components/OAuth';
10 |
11 | export default function SignIn() {
12 | const [formData, setFormData] = useState({});
13 | const { loading, error } = useSelector((state) => state.user);
14 | const navigate = useNavigate();
15 | const dispatch = useDispatch();
16 | const handleChange = (e) => {
17 | setFormData({
18 | ...formData,
19 | [e.target.id]: e.target.value,
20 | });
21 | };
22 | const handleSubmit = async (e) => {
23 | e.preventDefault();
24 | try {
25 | dispatch(signInStart());
26 | const res = await fetch('/api/auth/signin', {
27 | method: 'POST',
28 | headers: {
29 | 'Content-Type': 'application/json',
30 | },
31 | body: JSON.stringify(formData),
32 | });
33 | const data = await res.json();
34 | console.log(data);
35 | if (data.success === false) {
36 | dispatch(signInFailure(data.message));
37 | return;
38 | }
39 | dispatch(signInSuccess(data));
40 | navigate('/');
41 | } catch (error) {
42 | dispatch(signInFailure(error.message));
43 | }
44 | };
45 | return (
46 |
47 |
Sign In
48 |
72 |
73 |
Dont have an account?
74 |
75 |
Sign up
76 |
77 |
78 | {error &&
{error}
}
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/client/src/pages/SignUp.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Link, useNavigate } from 'react-router-dom';
3 | import OAuth from '../components/OAuth';
4 |
5 | export default function SignUp() {
6 | const [formData, setFormData] = useState({});
7 | const [error, setError] = useState(null);
8 | const [loading, setLoading] = useState(false);
9 | const navigate = useNavigate();
10 | const handleChange = (e) => {
11 | setFormData({
12 | ...formData,
13 | [e.target.id]: e.target.value,
14 | });
15 | };
16 | const handleSubmit = async (e) => {
17 | e.preventDefault();
18 | try {
19 | setLoading(true);
20 | const res = await fetch('/api/auth/signup', {
21 | method: 'POST',
22 | headers: {
23 | 'Content-Type': 'application/json',
24 | },
25 | body: JSON.stringify(formData),
26 | });
27 | const data = await res.json();
28 | console.log(data);
29 | if (data.success === false) {
30 | setLoading(false);
31 | setError(data.message);
32 | return;
33 | }
34 | setLoading(false);
35 | setError(null);
36 | navigate('/sign-in');
37 | } catch (error) {
38 | setLoading(false);
39 | setError(error.message);
40 | }
41 | };
42 | return (
43 |
44 |
Sign Up
45 |
76 |
77 |
Have an account?
78 |
79 |
Sign in
80 |
81 |
82 | {error &&
{error}
}
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/client/src/components/Header.jsx:
--------------------------------------------------------------------------------
1 | import { FaSearch } from 'react-icons/fa';
2 | import { Link, useNavigate } from 'react-router-dom';
3 | import { useSelector } from 'react-redux';
4 | import { useEffect, useState } from 'react';
5 |
6 | export default function Header() {
7 | const { currentUser } = useSelector((state) => state.user);
8 | const [searchTerm, setSearchTerm] = useState('');
9 | const navigate = useNavigate();
10 | const handleSubmit = (e) => {
11 | e.preventDefault();
12 | const urlParams = new URLSearchParams(window.location.search);
13 | urlParams.set('searchTerm', searchTerm);
14 | const searchQuery = urlParams.toString();
15 | navigate(`/search?${searchQuery}`);
16 | };
17 |
18 | useEffect(() => {
19 | const urlParams = new URLSearchParams(location.search);
20 | const searchTermFromUrl = urlParams.get('searchTerm');
21 | if (searchTermFromUrl) {
22 | setSearchTerm(searchTermFromUrl);
23 | }
24 | }, [location.search]);
25 | return (
26 |
27 |
28 |
29 |
30 | Sahand
31 | Estate
32 |
33 |
34 |
49 |
50 |
51 |
52 | Home
53 |
54 |
55 |
56 |
57 | About
58 |
59 |
60 |
61 | {currentUser ? (
62 |
67 | ) : (
68 | Sign in
69 | )}
70 |
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/api/controllers/auth.controller.js:
--------------------------------------------------------------------------------
1 | import User from '../models/user.model.js';
2 | import bcryptjs from 'bcryptjs';
3 | import { errorHandler } from '../utils/error.js';
4 | import jwt from 'jsonwebtoken';
5 |
6 | export const signup = async (req, res, next) => {
7 | const { username, email, password } = req.body;
8 | const hashedPassword = bcryptjs.hashSync(password, 10);
9 | const newUser = new User({ username, email, password: hashedPassword });
10 | try {
11 | await newUser.save();
12 | res.status(201).json('User created successfully!');
13 | } catch (error) {
14 | next(error);
15 | }
16 | };
17 |
18 | export const signin = async (req, res, next) => {
19 | const { email, password } = req.body;
20 | try {
21 | const validUser = await User.findOne({ email });
22 | if (!validUser) return next(errorHandler(404, 'User not found!'));
23 | const validPassword = bcryptjs.compareSync(password, validUser.password);
24 | if (!validPassword) return next(errorHandler(401, 'Wrong credentials!'));
25 | const token = jwt.sign({ id: validUser._id }, process.env.JWT_SECRET);
26 | const { password: pass, ...rest } = validUser._doc;
27 | res
28 | .cookie('access_token', token, { httpOnly: true })
29 | .status(200)
30 | .json(rest);
31 | } catch (error) {
32 | next(error);
33 | }
34 | };
35 |
36 | export const google = async (req, res, next) => {
37 | try {
38 | const user = await User.findOne({ email: req.body.email });
39 | if (user) {
40 | const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET);
41 | const { password: pass, ...rest } = user._doc;
42 | res
43 | .cookie('access_token', token, { httpOnly: true })
44 | .status(200)
45 | .json(rest);
46 | } else {
47 | const generatedPassword =
48 | Math.random().toString(36).slice(-8) +
49 | Math.random().toString(36).slice(-8);
50 | const hashedPassword = bcryptjs.hashSync(generatedPassword, 10);
51 | const newUser = new User({
52 | username:
53 | req.body.name.split(' ').join('').toLowerCase() +
54 | Math.random().toString(36).slice(-4),
55 | email: req.body.email,
56 | password: hashedPassword,
57 | avatar: req.body.photo,
58 | });
59 | await newUser.save();
60 | const token = jwt.sign({ id: newUser._id }, process.env.JWT_SECRET);
61 | const { password: pass, ...rest } = newUser._doc;
62 | res
63 | .cookie('access_token', token, { httpOnly: true })
64 | .status(200)
65 | .json(rest);
66 | }
67 | } catch (error) {
68 | next(error);
69 | }
70 | };
71 |
72 | export const signOut = async (req, res, next) => {
73 | try {
74 | res.clearCookie('access_token');
75 | res.status(200).json('User has been logged out!');
76 | } catch (error) {
77 | next(error);
78 | }
79 | };
80 |
--------------------------------------------------------------------------------
/api/controllers/listing.controller.js:
--------------------------------------------------------------------------------
1 | import Listing from '../models/listing.model.js';
2 | import { errorHandler } from '../utils/error.js';
3 |
4 | export const createListing = async (req, res, next) => {
5 | try {
6 | const listing = await Listing.create(req.body);
7 | return res.status(201).json(listing);
8 | } catch (error) {
9 | next(error);
10 | }
11 | };
12 |
13 | export const deleteListing = async (req, res, next) => {
14 | const listing = await Listing.findById(req.params.id);
15 |
16 | if (!listing) {
17 | return next(errorHandler(404, 'Listing not found!'));
18 | }
19 |
20 | if (req.user.id !== listing.userRef) {
21 | return next(errorHandler(401, 'You can only delete your own listings!'));
22 | }
23 |
24 | try {
25 | await Listing.findByIdAndDelete(req.params.id);
26 | res.status(200).json('Listing has been deleted!');
27 | } catch (error) {
28 | next(error);
29 | }
30 | };
31 |
32 | export const updateListing = async (req, res, next) => {
33 | const listing = await Listing.findById(req.params.id);
34 | if (!listing) {
35 | return next(errorHandler(404, 'Listing not found!'));
36 | }
37 | if (req.user.id !== listing.userRef) {
38 | return next(errorHandler(401, 'You can only update your own listings!'));
39 | }
40 |
41 | try {
42 | const updatedListing = await Listing.findByIdAndUpdate(
43 | req.params.id,
44 | req.body,
45 | { new: true }
46 | );
47 | res.status(200).json(updatedListing);
48 | } catch (error) {
49 | next(error);
50 | }
51 | };
52 |
53 | export const getListing = async (req, res, next) => {
54 | try {
55 | const listing = await Listing.findById(req.params.id);
56 | if (!listing) {
57 | return next(errorHandler(404, 'Listing not found!'));
58 | }
59 | res.status(200).json(listing);
60 | } catch (error) {
61 | next(error);
62 | }
63 | };
64 |
65 | export const getListings = async (req, res, next) => {
66 | try {
67 | const limit = parseInt(req.query.limit) || 9;
68 | const startIndex = parseInt(req.query.startIndex) || 0;
69 | let offer = req.query.offer;
70 |
71 | if (offer === undefined || offer === 'false') {
72 | offer = { $in: [false, true] };
73 | }
74 |
75 | let furnished = req.query.furnished;
76 |
77 | if (furnished === undefined || furnished === 'false') {
78 | furnished = { $in: [false, true] };
79 | }
80 |
81 | let parking = req.query.parking;
82 |
83 | if (parking === undefined || parking === 'false') {
84 | parking = { $in: [false, true] };
85 | }
86 |
87 | let type = req.query.type;
88 |
89 | if (type === undefined || type === 'all') {
90 | type = { $in: ['sale', 'rent'] };
91 | }
92 |
93 | const searchTerm = req.query.searchTerm || '';
94 |
95 | const sort = req.query.sort || 'createdAt';
96 |
97 | const order = req.query.order || 'desc';
98 |
99 | const listings = await Listing.find({
100 | name: { $regex: searchTerm, $options: 'i' },
101 | offer,
102 | furnished,
103 | parking,
104 | type,
105 | })
106 | .sort({ [sort]: order })
107 | .limit(limit)
108 | .skip(startIndex);
109 |
110 | return res.status(200).json(listings);
111 | } catch (error) {
112 | next(error);
113 | }
114 | };
115 |
--------------------------------------------------------------------------------
/client/src/pages/Home.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { Swiper, SwiperSlide } from 'swiper/react';
4 | import { Navigation } from 'swiper/modules';
5 | import SwiperCore from 'swiper';
6 | import 'swiper/css/bundle';
7 | import ListingItem from '../components/ListingItem';
8 |
9 | export default function Home() {
10 | const [offerListings, setOfferListings] = useState([]);
11 | const [saleListings, setSaleListings] = useState([]);
12 | const [rentListings, setRentListings] = useState([]);
13 | SwiperCore.use([Navigation]);
14 | console.log(offerListings);
15 | useEffect(() => {
16 | const fetchOfferListings = async () => {
17 | try {
18 | const res = await fetch('/api/listing/get?offer=true&limit=4');
19 | const data = await res.json();
20 | setOfferListings(data);
21 | fetchRentListings();
22 | } catch (error) {
23 | console.log(error);
24 | }
25 | };
26 | const fetchRentListings = async () => {
27 | try {
28 | const res = await fetch('/api/listing/get?type=rent&limit=4');
29 | const data = await res.json();
30 | setRentListings(data);
31 | fetchSaleListings();
32 | } catch (error) {
33 | console.log(error);
34 | }
35 | };
36 |
37 | const fetchSaleListings = async () => {
38 | try {
39 | const res = await fetch('/api/listing/get?type=sale&limit=4');
40 | const data = await res.json();
41 | setSaleListings(data);
42 | } catch (error) {
43 | log(error);
44 | }
45 | };
46 | fetchOfferListings();
47 | }, []);
48 | return (
49 |
50 | {/* top */}
51 |
52 |
53 | Find your next perfect
54 |
55 | place with ease
56 |
57 |
58 | Sahand Estate is the best place to find your next perfect place to
59 | live.
60 |
61 | We have a wide range of properties for you to choose from.
62 |
63 |
67 | Let's get started...
68 |
69 |
70 |
71 | {/* swiper */}
72 |
73 | {offerListings &&
74 | offerListings.length > 0 &&
75 | offerListings.map((listing) => (
76 |
77 |
85 |
86 | ))}
87 |
88 |
89 | {/* listing results for offer, sale and rent */}
90 |
91 |
92 | {offerListings && offerListings.length > 0 && (
93 |
94 |
95 |
Recent offers
96 | Show more offers
97 |
98 |
99 | {offerListings.map((listing) => (
100 |
101 | ))}
102 |
103 |
104 | )}
105 | {rentListings && rentListings.length > 0 && (
106 |
107 |
108 |
Recent places for rent
109 | Show more places for rent
110 |
111 |
112 | {rentListings.map((listing) => (
113 |
114 | ))}
115 |
116 |
117 | )}
118 | {saleListings && saleListings.length > 0 && (
119 |
120 |
121 |
Recent places for sale
122 | Show more places for sale
123 |
124 |
125 | {saleListings.map((listing) => (
126 |
127 | ))}
128 |
129 |
130 | )}
131 |
132 |
133 | );
134 | }
135 |
--------------------------------------------------------------------------------
/client/src/pages/Listing.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useParams } from 'react-router-dom';
3 | import { Swiper, SwiperSlide } from 'swiper/react';
4 | import SwiperCore from 'swiper';
5 | import { useSelector } from 'react-redux';
6 | import { Navigation } from 'swiper/modules';
7 | import 'swiper/css/bundle';
8 | import {
9 | FaBath,
10 | FaBed,
11 | FaChair,
12 | FaMapMarkedAlt,
13 | FaMapMarkerAlt,
14 | FaParking,
15 | FaShare,
16 | } from 'react-icons/fa';
17 | import Contact from '../components/Contact';
18 |
19 | // https://sabe.io/blog/javascript-format-numbers-commas#:~:text=The%20best%20way%20to%20format,format%20the%20number%20with%20commas.
20 |
21 | export default function Listing() {
22 | SwiperCore.use([Navigation]);
23 | const [listing, setListing] = useState(null);
24 | const [loading, setLoading] = useState(false);
25 | const [error, setError] = useState(false);
26 | const [copied, setCopied] = useState(false);
27 | const [contact, setContact] = useState(false);
28 | const params = useParams();
29 | const { currentUser } = useSelector((state) => state.user);
30 |
31 | useEffect(() => {
32 | const fetchListing = async () => {
33 | try {
34 | setLoading(true);
35 | const res = await fetch(`/api/listing/get/${params.listingId}`);
36 | const data = await res.json();
37 | if (data.success === false) {
38 | setError(true);
39 | setLoading(false);
40 | return;
41 | }
42 | setListing(data);
43 | setLoading(false);
44 | setError(false);
45 | } catch (error) {
46 | setError(true);
47 | setLoading(false);
48 | }
49 | };
50 | fetchListing();
51 | }, [params.listingId]);
52 |
53 | return (
54 |
55 | {loading && Loading...
}
56 | {error && (
57 | Something went wrong!
58 | )}
59 | {listing && !loading && !error && (
60 |
61 |
62 | {listing.imageUrls.map((url) => (
63 |
64 |
71 |
72 | ))}
73 |
74 |
75 | {
78 | navigator.clipboard.writeText(window.location.href);
79 | setCopied(true);
80 | setTimeout(() => {
81 | setCopied(false);
82 | }, 2000);
83 | }}
84 | />
85 |
86 | {copied && (
87 |
88 | Link copied!
89 |
90 | )}
91 |
92 |
93 | {listing.name} - ${' '}
94 | {listing.offer
95 | ? listing.discountPrice.toLocaleString('en-US')
96 | : listing.regularPrice.toLocaleString('en-US')}
97 | {listing.type === 'rent' && ' / month'}
98 |
99 |
100 |
101 | {listing.address}
102 |
103 |
104 |
105 | {listing.type === 'rent' ? 'For Rent' : 'For Sale'}
106 |
107 | {listing.offer && (
108 |
109 | ${+listing.regularPrice - +listing.discountPrice} OFF
110 |
111 | )}
112 |
113 |
114 | Description -
115 | {listing.description}
116 |
117 |
118 |
119 |
120 | {listing.bedrooms > 1
121 | ? `${listing.bedrooms} beds `
122 | : `${listing.bedrooms} bed `}
123 |
124 |
125 |
126 | {listing.bathrooms > 1
127 | ? `${listing.bathrooms} baths `
128 | : `${listing.bathrooms} bath `}
129 |
130 |
131 |
132 | {listing.parking ? 'Parking spot' : 'No Parking'}
133 |
134 |
135 |
136 | {listing.furnished ? 'Furnished' : 'Unfurnished'}
137 |
138 |
139 | {currentUser && listing.userRef !== currentUser._id && !contact && (
140 |
setContact(true)}
142 | className='bg-slate-700 text-white rounded-lg uppercase hover:opacity-95 p-3'
143 | >
144 | Contact landlord
145 |
146 | )}
147 | {contact &&
}
148 |
149 |
150 | )}
151 |
152 | );
153 | }
154 |
--------------------------------------------------------------------------------
/client/src/pages/Search.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import ListingItem from '../components/ListingItem';
4 |
5 | export default function Search() {
6 | const navigate = useNavigate();
7 | const [sidebardata, setSidebardata] = useState({
8 | searchTerm: '',
9 | type: 'all',
10 | parking: false,
11 | furnished: false,
12 | offer: false,
13 | sort: 'created_at',
14 | order: 'desc',
15 | });
16 |
17 | const [loading, setLoading] = useState(false);
18 | const [listings, setListings] = useState([]);
19 | const [showMore, setShowMore] = useState(false);
20 |
21 | useEffect(() => {
22 | const urlParams = new URLSearchParams(location.search);
23 | const searchTermFromUrl = urlParams.get('searchTerm');
24 | const typeFromUrl = urlParams.get('type');
25 | const parkingFromUrl = urlParams.get('parking');
26 | const furnishedFromUrl = urlParams.get('furnished');
27 | const offerFromUrl = urlParams.get('offer');
28 | const sortFromUrl = urlParams.get('sort');
29 | const orderFromUrl = urlParams.get('order');
30 |
31 | if (
32 | searchTermFromUrl ||
33 | typeFromUrl ||
34 | parkingFromUrl ||
35 | furnishedFromUrl ||
36 | offerFromUrl ||
37 | sortFromUrl ||
38 | orderFromUrl
39 | ) {
40 | setSidebardata({
41 | searchTerm: searchTermFromUrl || '',
42 | type: typeFromUrl || 'all',
43 | parking: parkingFromUrl === 'true' ? true : false,
44 | furnished: furnishedFromUrl === 'true' ? true : false,
45 | offer: offerFromUrl === 'true' ? true : false,
46 | sort: sortFromUrl || 'created_at',
47 | order: orderFromUrl || 'desc',
48 | });
49 | }
50 |
51 | const fetchListings = async () => {
52 | setLoading(true);
53 | setShowMore(false);
54 | const searchQuery = urlParams.toString();
55 | const res = await fetch(`/api/listing/get?${searchQuery}`);
56 | const data = await res.json();
57 | if (data.length > 8) {
58 | setShowMore(true);
59 | } else {
60 | setShowMore(false);
61 | }
62 | setListings(data);
63 | setLoading(false);
64 | };
65 |
66 | fetchListings();
67 | }, [location.search]);
68 |
69 | const handleChange = (e) => {
70 | if (
71 | e.target.id === 'all' ||
72 | e.target.id === 'rent' ||
73 | e.target.id === 'sale'
74 | ) {
75 | setSidebardata({ ...sidebardata, type: e.target.id });
76 | }
77 |
78 | if (e.target.id === 'searchTerm') {
79 | setSidebardata({ ...sidebardata, searchTerm: e.target.value });
80 | }
81 |
82 | if (
83 | e.target.id === 'parking' ||
84 | e.target.id === 'furnished' ||
85 | e.target.id === 'offer'
86 | ) {
87 | setSidebardata({
88 | ...sidebardata,
89 | [e.target.id]:
90 | e.target.checked || e.target.checked === 'true' ? true : false,
91 | });
92 | }
93 |
94 | if (e.target.id === 'sort_order') {
95 | const sort = e.target.value.split('_')[0] || 'created_at';
96 |
97 | const order = e.target.value.split('_')[1] || 'desc';
98 |
99 | setSidebardata({ ...sidebardata, sort, order });
100 | }
101 | };
102 |
103 | const handleSubmit = (e) => {
104 | e.preventDefault();
105 | const urlParams = new URLSearchParams();
106 | urlParams.set('searchTerm', sidebardata.searchTerm);
107 | urlParams.set('type', sidebardata.type);
108 | urlParams.set('parking', sidebardata.parking);
109 | urlParams.set('furnished', sidebardata.furnished);
110 | urlParams.set('offer', sidebardata.offer);
111 | urlParams.set('sort', sidebardata.sort);
112 | urlParams.set('order', sidebardata.order);
113 | const searchQuery = urlParams.toString();
114 | navigate(`/search?${searchQuery}`);
115 | };
116 |
117 | const onShowMoreClick = async () => {
118 | const numberOfListings = listings.length;
119 | const startIndex = numberOfListings;
120 | const urlParams = new URLSearchParams(location.search);
121 | urlParams.set('startIndex', startIndex);
122 | const searchQuery = urlParams.toString();
123 | const res = await fetch(`/api/listing/get?${searchQuery}`);
124 | const data = await res.json();
125 | if (data.length < 9) {
126 | setShowMore(false);
127 | }
128 | setListings([...listings, ...data]);
129 | };
130 | return (
131 |
132 |
232 |
233 |
234 | Listing results:
235 |
236 |
237 | {!loading && listings.length === 0 && (
238 |
No listing found!
239 | )}
240 | {loading && (
241 |
242 | Loading...
243 |
244 | )}
245 |
246 | {!loading &&
247 | listings &&
248 | listings.map((listing) => (
249 |
250 | ))}
251 |
252 | {showMore && (
253 |
257 | Show more
258 |
259 | )}
260 |
261 |
262 |
263 | );
264 | }
265 |
--------------------------------------------------------------------------------
/client/src/pages/Profile.jsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { useRef, useState, useEffect } from 'react';
3 | import {
4 | getDownloadURL,
5 | getStorage,
6 | ref,
7 | uploadBytesResumable,
8 | } from 'firebase/storage';
9 | import { app } from '../firebase';
10 | import {
11 | updateUserStart,
12 | updateUserSuccess,
13 | updateUserFailure,
14 | deleteUserFailure,
15 | deleteUserStart,
16 | deleteUserSuccess,
17 | signOutUserStart,
18 | } from '../redux/user/userSlice';
19 | import { useDispatch } from 'react-redux';
20 | import { Link } from 'react-router-dom';
21 | export default function Profile() {
22 | const fileRef = useRef(null);
23 | const { currentUser, loading, error } = useSelector((state) => state.user);
24 | const [file, setFile] = useState(undefined);
25 | const [filePerc, setFilePerc] = useState(0);
26 | const [fileUploadError, setFileUploadError] = useState(false);
27 | const [formData, setFormData] = useState({});
28 | const [updateSuccess, setUpdateSuccess] = useState(false);
29 | const [showListingsError, setShowListingsError] = useState(false);
30 | const [userListings, setUserListings] = useState([]);
31 | const dispatch = useDispatch();
32 |
33 | // firebase storage
34 | // allow read;
35 | // allow write: if
36 | // request.resource.size < 2 * 1024 * 1024 &&
37 | // request.resource.contentType.matches('image/.*')
38 |
39 | useEffect(() => {
40 | if (file) {
41 | handleFileUpload(file);
42 | }
43 | }, [file]);
44 |
45 | const handleFileUpload = (file) => {
46 | const storage = getStorage(app);
47 | const fileName = new Date().getTime() + file.name;
48 | const storageRef = ref(storage, fileName);
49 | const uploadTask = uploadBytesResumable(storageRef, file);
50 |
51 | uploadTask.on(
52 | 'state_changed',
53 | (snapshot) => {
54 | const progress =
55 | (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
56 | setFilePerc(Math.round(progress));
57 | },
58 | (error) => {
59 | setFileUploadError(true);
60 | },
61 | () => {
62 | getDownloadURL(uploadTask.snapshot.ref).then((downloadURL) =>
63 | setFormData({ ...formData, avatar: downloadURL })
64 | );
65 | }
66 | );
67 | };
68 |
69 | const handleChange = (e) => {
70 | setFormData({ ...formData, [e.target.id]: e.target.value });
71 | };
72 |
73 | const handleSubmit = async (e) => {
74 | e.preventDefault();
75 | try {
76 | dispatch(updateUserStart());
77 | const res = await fetch(`/api/user/update/${currentUser._id}`, {
78 | method: 'POST',
79 | headers: {
80 | 'Content-Type': 'application/json',
81 | },
82 | body: JSON.stringify(formData),
83 | });
84 | const data = await res.json();
85 | if (data.success === false) {
86 | dispatch(updateUserFailure(data.message));
87 | return;
88 | }
89 |
90 | dispatch(updateUserSuccess(data));
91 | setUpdateSuccess(true);
92 | } catch (error) {
93 | dispatch(updateUserFailure(error.message));
94 | }
95 | };
96 |
97 | const handleDeleteUser = async () => {
98 | try {
99 | dispatch(deleteUserStart());
100 | const res = await fetch(`/api/user/delete/${currentUser._id}`, {
101 | method: 'DELETE',
102 | });
103 | const data = await res.json();
104 | if (data.success === false) {
105 | dispatch(deleteUserFailure(data.message));
106 | return;
107 | }
108 | dispatch(deleteUserSuccess(data));
109 | } catch (error) {
110 | dispatch(deleteUserFailure(error.message));
111 | }
112 | };
113 |
114 | const handleSignOut = async () => {
115 | try {
116 | dispatch(signOutUserStart());
117 | const res = await fetch('/api/auth/signout');
118 | const data = await res.json();
119 | if (data.success === false) {
120 | dispatch(deleteUserFailure(data.message));
121 | return;
122 | }
123 | dispatch(deleteUserSuccess(data));
124 | } catch (error) {
125 | dispatch(deleteUserFailure(data.message));
126 | }
127 | };
128 |
129 | const handleShowListings = async () => {
130 | try {
131 | setShowListingsError(false);
132 | const res = await fetch(`/api/user/listings/${currentUser._id}`);
133 | const data = await res.json();
134 | if (data.success === false) {
135 | setShowListingsError(true);
136 | return;
137 | }
138 |
139 | setUserListings(data);
140 | } catch (error) {
141 | setShowListingsError(true);
142 | }
143 | };
144 |
145 | const handleListingDelete = async (listingId) => {
146 | try {
147 | const res = await fetch(`/api/listing/delete/${listingId}`, {
148 | method: 'DELETE',
149 | });
150 | const data = await res.json();
151 | if (data.success === false) {
152 | console.log(data.message);
153 | return;
154 | }
155 |
156 | setUserListings((prev) =>
157 | prev.filter((listing) => listing._id !== listingId)
158 | );
159 | } catch (error) {
160 | console.log(error.message);
161 | }
162 | };
163 | return (
164 |
165 |
Profile
166 |
229 |
230 |
234 | Delete account
235 |
236 |
237 | Sign out
238 |
239 |
240 |
241 |
{error ? error : ''}
242 |
243 | {updateSuccess ? 'User is updated successfully!' : ''}
244 |
245 |
246 | Show Listings
247 |
248 |
249 | {showListingsError ? 'Error showing listings' : ''}
250 |
251 |
252 | {userListings && userListings.length > 0 && (
253 |
254 |
255 | Your Listings
256 |
257 | {userListings.map((listing) => (
258 |
262 |
263 |
268 |
269 |
273 |
{listing.name}
274 |
275 |
276 |
277 | handleListingDelete(listing._id)}
279 | className='text-red-700 uppercase'
280 | >
281 | Delete
282 |
283 |
284 | Edit
285 |
286 |
287 |
288 | ))}
289 |
290 | )}
291 |
292 | );
293 | }
294 |
--------------------------------------------------------------------------------
/client/src/pages/CreateListing.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { app } from '../firebase';
3 | import { useSelector } from 'react-redux';
4 | import { useNavigate } from 'react-router-dom';
5 | import axios from 'axios';
6 |
7 | export default function CreateListing() {
8 | const { currentUser } = useSelector((state) => state.user);
9 | const navigate = useNavigate();
10 | const [files, setFiles] = useState([]);
11 | const [formData, setFormData] = useState({
12 | imageUrls: [],
13 | name: '',
14 | description: '',
15 | address: '',
16 | type: 'rent',
17 | bedrooms: 1,
18 | bathrooms: 1,
19 | regularPrice: 50,
20 | discountPrice: 0,
21 | offer: false,
22 | parking: false,
23 | furnished: false,
24 | });
25 | const [imageUploadError, setImageUploadError] = useState(false);
26 | const [uploading, setUploading] = useState(false);
27 | const [error, setError] = useState(false);
28 | const [loading, setLoading] = useState(false);
29 | console.log(formData);
30 | const handleImageSubmit = (e) => {
31 | if (files.length > 0 && files.length + formData.imageUrls.length < 7) {
32 | setUploading(true);
33 | setImageUploadError(false);
34 | const promises = [];
35 |
36 | for (let i = 0; i < files.length; i++) {
37 | promises.push(storeImage(files[i]));
38 | }
39 | Promise.all(promises)
40 | .then((urls) => {
41 | setFormData({
42 | ...formData,
43 | imageUrls: formData.imageUrls.concat(urls),
44 | });
45 | setImageUploadError(false);
46 | setUploading(false);
47 | })
48 | .catch((err) => {
49 | setImageUploadError('Image upload failed (2 mb max per image)');
50 | setUploading(false);
51 | });
52 | } else {
53 | setImageUploadError('You can only upload 6 images per listing');
54 | setUploading(false);
55 | }
56 | };
57 |
58 |
59 | const storeImage = async (file) => {
60 | return new Promise(async (resolve, reject) => {
61 | const formData = new FormData();
62 | formData.append('file', file);
63 | formData.append('upload_preset', 'UPLOAD_PRESET'); // Replace this
64 | formData.append('cloud_name', 'CLOUD_NAME'); // Replace this
65 |
66 | try {
67 | const res = await axios.post(
68 | `https://api.cloudinary.com/v1_1/dflansvri/image/upload`,
69 | formData
70 | );
71 | resolve(res.data.secure_url); // Cloudinary returns secure_url
72 | } catch (error) {
73 | reject(error);
74 | }
75 | });
76 | };
77 |
78 |
79 | const handleRemoveImage = (index) => {
80 | setFormData({
81 | ...formData,
82 | imageUrls: formData.imageUrls.filter((_, i) => i !== index),
83 | });
84 | };
85 |
86 | const handleChange = (e) => {
87 | if (e.target.id === 'sale' || e.target.id === 'rent') {
88 | setFormData({
89 | ...formData,
90 | type: e.target.id,
91 | });
92 | }
93 |
94 | if (
95 | e.target.id === 'parking' ||
96 | e.target.id === 'furnished' ||
97 | e.target.id === 'offer'
98 | ) {
99 | setFormData({
100 | ...formData,
101 | [e.target.id]: e.target.checked,
102 | });
103 | }
104 |
105 | if (
106 | e.target.type === 'number' ||
107 | e.target.type === 'text' ||
108 | e.target.type === 'textarea'
109 | ) {
110 | setFormData({
111 | ...formData,
112 | [e.target.id]: e.target.value,
113 | });
114 | }
115 | };
116 |
117 | const handleSubmit = async (e) => {
118 | e.preventDefault();
119 | try {
120 | if (formData.imageUrls.length < 1)
121 | return setError('You must upload at least one image');
122 | if (+formData.regularPrice < +formData.discountPrice)
123 | return setError('Discount price must be lower than regular price');
124 | setLoading(true);
125 | setError(false);
126 | const res = await fetch('/api/listing/create', {
127 | method: 'POST',
128 | headers: {
129 | 'Content-Type': 'application/json',
130 | },
131 | body: JSON.stringify({
132 | ...formData,
133 | userRef: currentUser._id,
134 | }),
135 | });
136 | const data = await res.json();
137 | setLoading(false);
138 | if (data.success === false) {
139 | setError(data.message);
140 | }
141 | navigate(`/listing/${data._id}`);
142 | } catch (error) {
143 | setError(error.message);
144 | setLoading(false);
145 | }
146 | };
147 | return (
148 |
149 |
150 | Create a Listing
151 |
152 |
360 |
361 | );
362 | }
363 |
--------------------------------------------------------------------------------
/client/src/pages/UpdateListing.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import {
3 | getDownloadURL,
4 | getStorage,
5 | ref,
6 | uploadBytesResumable,
7 | } from 'firebase/storage';
8 | import { app } from '../firebase';
9 | import { useSelector } from 'react-redux';
10 | import { useNavigate, useParams } from 'react-router-dom';
11 |
12 | export default function CreateListing() {
13 | const { currentUser } = useSelector((state) => state.user);
14 | const navigate = useNavigate();
15 | const params = useParams();
16 | const [files, setFiles] = useState([]);
17 | const [formData, setFormData] = useState({
18 | imageUrls: [],
19 | name: '',
20 | description: '',
21 | address: '',
22 | type: 'rent',
23 | bedrooms: 1,
24 | bathrooms: 1,
25 | regularPrice: 50,
26 | discountPrice: 0,
27 | offer: false,
28 | parking: false,
29 | furnished: false,
30 | });
31 | const [imageUploadError, setImageUploadError] = useState(false);
32 | const [uploading, setUploading] = useState(false);
33 | const [error, setError] = useState(false);
34 | const [loading, setLoading] = useState(false);
35 |
36 | useEffect(() => {
37 | const fetchListing = async () => {
38 | const listingId = params.listingId;
39 | const res = await fetch(`/api/listing/get/${listingId}`);
40 | const data = await res.json();
41 | if (data.success === false) {
42 | console.log(data.message);
43 | return;
44 | }
45 | setFormData(data);
46 | };
47 |
48 | fetchListing();
49 | }, []);
50 |
51 | const handleImageSubmit = (e) => {
52 | if (files.length > 0 && files.length + formData.imageUrls.length < 7) {
53 | setUploading(true);
54 | setImageUploadError(false);
55 | const promises = [];
56 |
57 | for (let i = 0; i < files.length; i++) {
58 | promises.push(storeImage(files[i]));
59 | }
60 | Promise.all(promises)
61 | .then((urls) => {
62 | setFormData({
63 | ...formData,
64 | imageUrls: formData.imageUrls.concat(urls),
65 | });
66 | setImageUploadError(false);
67 | setUploading(false);
68 | })
69 | .catch((err) => {
70 | setImageUploadError('Image upload failed (2 mb max per image)');
71 | setUploading(false);
72 | });
73 | } else {
74 | setImageUploadError('You can only upload 6 images per listing');
75 | setUploading(false);
76 | }
77 | };
78 |
79 | const storeImage = async (file) => {
80 | return new Promise((resolve, reject) => {
81 | const storage = getStorage(app);
82 | const fileName = new Date().getTime() + file.name;
83 | const storageRef = ref(storage, fileName);
84 | const uploadTask = uploadBytesResumable(storageRef, file);
85 | uploadTask.on(
86 | 'state_changed',
87 | (snapshot) => {
88 | const progress =
89 | (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
90 | console.log(`Upload is ${progress}% done`);
91 | },
92 | (error) => {
93 | reject(error);
94 | },
95 | () => {
96 | getDownloadURL(uploadTask.snapshot.ref).then((downloadURL) => {
97 | resolve(downloadURL);
98 | });
99 | }
100 | );
101 | });
102 | };
103 |
104 | const handleRemoveImage = (index) => {
105 | setFormData({
106 | ...formData,
107 | imageUrls: formData.imageUrls.filter((_, i) => i !== index),
108 | });
109 | };
110 |
111 | const handleChange = (e) => {
112 | if (e.target.id === 'sale' || e.target.id === 'rent') {
113 | setFormData({
114 | ...formData,
115 | type: e.target.id,
116 | });
117 | }
118 |
119 | if (
120 | e.target.id === 'parking' ||
121 | e.target.id === 'furnished' ||
122 | e.target.id === 'offer'
123 | ) {
124 | setFormData({
125 | ...formData,
126 | [e.target.id]: e.target.checked,
127 | });
128 | }
129 |
130 | if (
131 | e.target.type === 'number' ||
132 | e.target.type === 'text' ||
133 | e.target.type === 'textarea'
134 | ) {
135 | setFormData({
136 | ...formData,
137 | [e.target.id]: e.target.value,
138 | });
139 | }
140 | };
141 |
142 | const handleSubmit = async (e) => {
143 | e.preventDefault();
144 | try {
145 | if (formData.imageUrls.length < 1)
146 | return setError('You must upload at least one image');
147 | if (+formData.regularPrice < +formData.discountPrice)
148 | return setError('Discount price must be lower than regular price');
149 | setLoading(true);
150 | setError(false);
151 | const res = await fetch(`/api/listing/update/${params.listingId}`, {
152 | method: 'POST',
153 | headers: {
154 | 'Content-Type': 'application/json',
155 | },
156 | body: JSON.stringify({
157 | ...formData,
158 | userRef: currentUser._id,
159 | }),
160 | });
161 | const data = await res.json();
162 | setLoading(false);
163 | if (data.success === false) {
164 | setError(data.message);
165 | }
166 | navigate(`/listing/${data._id}`);
167 | } catch (error) {
168 | setError(error.message);
169 | setLoading(false);
170 | }
171 | };
172 | return (
173 |
174 |
175 | Update a Listing
176 |
177 |
178 |
179 |
190 |
199 |
208 |
260 |
261 |
274 |
287 |
288 |
298 |
299 |
Regular price
300 | {formData.type === 'rent' && (
301 |
($ / month)
302 | )}
303 |
304 |
305 | {formData.offer && (
306 |
307 |
317 |
318 |
Discounted price
319 | {formData.type === 'rent' && (
320 |
($ / month)
321 | )}
322 |
323 |
324 | )}
325 |
326 |
327 |
328 |
329 | Images:
330 |
331 | The first image will be the cover (max 6)
332 |
333 |
334 |
335 | setFiles(e.target.files)}
337 | className='p-3 border border-gray-300 rounded w-full'
338 | type='file'
339 | id='images'
340 | accept='image/*'
341 | multiple
342 | />
343 |
349 | {uploading ? 'Uploading...' : 'Upload'}
350 |
351 |
352 |
353 | {imageUploadError && imageUploadError}
354 |
355 | {formData.imageUrls.length > 0 &&
356 | formData.imageUrls.map((url, index) => (
357 |
361 |
366 |
handleRemoveImage(index)}
369 | className='p-3 text-red-700 rounded-lg uppercase hover:opacity-75'
370 | >
371 | Delete
372 |
373 |
374 | ))}
375 |
379 | {loading ? 'Updating...' : 'Update listing'}
380 |
381 | {error &&
{error}
}
382 |
383 |
384 |
385 | );
386 | }
387 |
--------------------------------------------------------------------------------