├── .env-example
├── .gitignore
├── README.md
├── _theme_files
├── add-property.html
├── css
│ └── styles.css
├── favicon.ico
├── images
│ ├── logo-white.png
│ ├── logo.png
│ ├── pin.svg
│ ├── profile.png
│ └── properties
│ │ ├── a1.jpg
│ │ ├── a2.jpg
│ │ ├── a3.jpg
│ │ ├── a4.jpg
│ │ ├── b1.jpg
│ │ ├── b2.jpg
│ │ ├── b3.jpg
│ │ ├── c1.jpg
│ │ ├── c2.jpg
│ │ ├── c3.jpg
│ │ ├── c4.jpg
│ │ ├── d1.jpg
│ │ ├── d2.jpg
│ │ ├── d3.jpg
│ │ ├── e1.jpg
│ │ ├── e2.jpg
│ │ ├── e3.jpg
│ │ ├── e4.jpg
│ │ ├── f1.jpg
│ │ ├── f2.jpg
│ │ ├── f3.jpg
│ │ ├── g1.jpg
│ │ ├── g2.jpg
│ │ ├── g3.jpg
│ │ ├── g4.jpg
│ │ ├── h1.jpg
│ │ ├── h2.jpg
│ │ ├── h3.jpg
│ │ ├── i1.jpg
│ │ ├── i2.jpg
│ │ ├── i3.jpg
│ │ ├── j1.jpg
│ │ ├── j2.jpg
│ │ └── j3.jpg
├── index.html
├── js
│ └── main.js
├── messages.html
├── not-found.html
├── profile.html
├── properties.html
├── property.html
└── saved-properties.html
├── app
├── actions
│ ├── addMessage.js
│ ├── addProperty.js
│ ├── bookmarkProperty.js
│ ├── checkBookmarkStatus.js
│ ├── deleteMessage.js
│ ├── deleteProperty.js
│ ├── getUnreadMessageCount.js
│ ├── markMessageAsRead.js
│ └── updateProperty.js
├── api
│ └── auth
│ │ └── [...nextauth]
│ │ └── route.js
├── error.jsx
├── favicon.ico
├── layout.jsx
├── loading.jsx
├── messages
│ └── page.jsx
├── not-found.jsx
├── page.jsx
├── profile
│ └── page.jsx
└── properties
│ ├── [id]
│ ├── edit
│ │ └── page.jsx
│ └── page.jsx
│ ├── add
│ └── page.jsx
│ ├── page.jsx
│ ├── saved
│ └── page.jsx
│ └── search-results
│ └── page.jsx
├── assets
├── images
│ ├── logo-white.png
│ ├── logo.png
│ ├── pin.svg
│ └── profile.png
└── styles
│ └── globals.css
├── components
├── AuthProvider.jsx
├── BookmarkButton.jsx
├── FeaturedProperties.jsx
├── FeaturedPropertyCard.jsx
├── Footer.jsx
├── Hero.jsx
├── HomeProperties.jsx
├── InfoBox.jsx
├── InfoBoxes.jsx
├── MessageCard.jsx
├── Navbar.jsx
├── Pagination.jsx
├── ProfileProperties.jsx
├── PropertyAddForm.jsx
├── PropertyCard.jsx
├── PropertyContactForm.jsx
├── PropertyDetails.jsx
├── PropertyEditForm.jsx
├── PropertyHeaderImage.jsx
├── PropertyImages.jsx
├── PropertyMap.jsx
├── PropertySearchForm.jsx
├── ShareButtons.jsx
├── Spinner.jsx
├── SubmitMessageButton.jsx
└── UnreadMessageCount.jsx
├── config
├── cloudinary.js
└── database.js
├── context
└── GlobalContext.js
├── jsconfig.json
├── middleware.js
├── models
├── Message.js
├── Property.js
└── User.js
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── properties.json
├── properties2.json
├── public
├── images
│ └── screen.jpg
├── next.svg
└── vercel.svg
├── tailwind.config.js
└── utils
├── authOptions.js
├── convertToObject.js
└── getSessionUser.js
/.env-example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_DOMAIN=http://localhost:3000
2 | NEXT_PUBLIC_API_DOMAIN=http://localhost:3000/api
3 | MONGODB_URI=ADD_YOUR_OWN
4 | GOOGLE_CLIENT_ID=ADD_YOUR_OWN
5 | GOOGLE_CLIENT_SECRET=ADD_YOUR_OWN
6 | NEXTAUTH_URL=http://localhost:3000
7 | NEXTAUTH_URL_INTERNAL=http://localhost:3000
8 | NEXTAUTH_SECRET=ADD_YOUR_OWN
9 | CLOUDINARY_CLOUD_NAME=ADD_YOUR_OWN
10 | CLOUDINARY_API_KEY=ADD_YOUR_OWN
11 | CLOUDINARY_API_SECRET=ADD_YOUR_OWN
12 | NEXT_PUBLIC_MAPBOX_TOKEN=ADD_YOUR_OWN
13 | NEXT_PUBLIC_GOOGLE_GEOCODING_API_KEY=ADD_YOUR_OWN
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 | .env
31 | .DS_Store
32 |
33 | # vercel
34 | .vercel
35 |
36 | # typescript
37 | *.tsbuildinfo
38 | next-env.d.ts
39 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Property Pulse
2 |
3 | > A web application to help you find your next rental property.
4 |
5 | This is the main project from my [Next 14 From Scratch Course](https://www.traversymedia.com/nextjs-from-scratch)
6 |
7 | This is the second iteration of the app/course that uses server actions instead of API routes.
8 |
9 | The `_theme_files` folder contains the pure HTML files with Tailwind classes.
10 |
11 |
12 |
13 | ## Features
14 |
15 | Here are some of the current features that Property Pulse has:
16 |
17 | - [x] User authentication with Google & Next Auth
18 | - [x] User authorization
19 | - [x] Route protection
20 | - [x] User profile with user listings
21 | - [x] Property Listing CRUD
22 | - [x] Property image upload (Multiple)
23 | - [x] Property search
24 | - [x] Internal messages with 'unread' notifications
25 | - [x] Photoswipe image gallery
26 | - [x] Mapbox maps
27 | - [x] Toast notifications
28 | - [x] Property bookmarking / saved properties
29 | - [x] Property sharing to social media
30 | - [x] Loading spinners
31 | - [x] Responsive design (Tailwind)
32 | - [x] Custom 404 page
33 | - [x] Next.js Actions
34 |
35 | Property Pulse uses the following technologies:
36 |
37 | - [Next.js](https://nextjs.org/)
38 | - [React](https://reactjs.org/)
39 | - [Tailwind CSS](https://tailwindcss.com/)
40 | - [MongoDB](https://www.mongodb.com/)
41 | - [Mongoose](https://mongoosejs.com/)
42 | - [NextAuth.js](https://next-auth.js.org/)
43 | - [React Icons](https://react-icons.github.io/react-icons/)
44 | - [Photoswipe](https://photoswipe.com/)
45 | - [Cloudinary](https://cloudinary.com/)
46 | - [Mapbox](https://www.mapbox.com/)
47 | - [React Map GL](https://visgl.github.io/react-map-gl/)
48 | - [React Geocode](https://www.npmjs.com/package/react-geocode)
49 | - [React Spinners](https://www.npmjs.com/package/react-spinners)
50 | - [React Toastify](https://fkhadra.github.io/react-toastify/)
51 | - [React Share](https://www.npmjs.com/package/react-share)
52 |
53 | ## Getting Started
54 |
55 | ### Prerequisites
56 |
57 | - Node.js version 18 or higher
58 | - MongoDB Atlas account and a cluster. Sign up and create a cluster at [MongoDB](https://www.mongodb.com/)
59 | - Cloudinary account. Sign up at [Cloudinary](https://cloudinary.com/)
60 | - Google console account. Sign up at [Google Cloud](https://console.cloud.google.com/)
61 | - Mapbox account. Sign up at [Mapbox](https://www.mapbox.com/)
62 |
63 | ### `.env` File
64 |
65 | Rename the `env.example` file to `.env` and fill in the following environment variables:
66 |
67 | - Get your MongoDB connection string from your MongoDB Atlas cluster and add it to `MONGODB_URI`.
68 | - Get your Google client ID and secret from your Google console account and add them to `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`.
69 | - Add a secret to `NEXTAUTH_SECRET`. You can generate with the following command:
70 | ```bash
71 | openssl rand -base64 32
72 | ```
73 | - Get your Cloudinary cloud name, API key, and API secret from your Cloudinary account and add them to `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, and `CLOUDINARY_API_SECRET`.
74 | - Get your Mapbox token from your Mapbox account and add it to `NEXT_PUBLIC_MAPBOX_TOKEN`.
75 | - Get your Google Geocoding API key from your Google console account and add it to `NEXT_PUBLIC_GOOGLE_GEOCODING_API_KEY`.
76 |
77 | ### Install Dependencies
78 |
79 | ```bash
80 | npm install
81 | ```
82 |
83 | ### Run the Development Server
84 |
85 | ```bash
86 | npm run dev
87 | ```
88 |
89 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
90 |
91 | ## License
92 |
93 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details
94 |
--------------------------------------------------------------------------------
/_theme_files/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/favicon.ico
--------------------------------------------------------------------------------
/_theme_files/images/logo-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/logo-white.png
--------------------------------------------------------------------------------
/_theme_files/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/logo.png
--------------------------------------------------------------------------------
/_theme_files/images/pin.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/_theme_files/images/profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/profile.png
--------------------------------------------------------------------------------
/_theme_files/images/properties/a1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/a1.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/a2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/a2.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/a3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/a3.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/a4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/a4.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/b1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/b1.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/b2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/b2.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/b3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/b3.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/c1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/c1.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/c2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/c2.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/c3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/c3.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/c4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/c4.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/d1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/d1.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/d2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/d2.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/d3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/d3.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/e1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/e1.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/e2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/e2.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/e3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/e3.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/e4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/e4.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/f1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/f1.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/f2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/f2.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/f3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/f3.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/g1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/g1.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/g2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/g2.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/g3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/g3.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/g4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/g4.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/h1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/h1.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/h2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/h2.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/h3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/h3.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/i1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/i1.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/i2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/i2.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/i3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/i3.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/j1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/j1.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/j2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/j2.jpg
--------------------------------------------------------------------------------
/_theme_files/images/properties/j3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/_theme_files/images/properties/j3.jpg
--------------------------------------------------------------------------------
/_theme_files/js/main.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', () => {
2 | // Mobile menu dropdown
3 | const mobileMenuButton = document.querySelector('#mobile-dropdown-button');
4 | const mobileMenu = document.querySelector('#mobile-menu');
5 |
6 | mobileMenuButton.addEventListener('click', () =>
7 | mobileMenu.classList.toggle('hidden')
8 | );
9 |
10 | // Profile dropdown
11 | const profileButton = document.querySelector('#user-menu-button');
12 | const profileDropdown = document.querySelector('#user-menu');
13 |
14 | profileButton.addEventListener('click', () =>
15 | profileDropdown.classList.toggle('hidden')
16 | );
17 | });
18 |
--------------------------------------------------------------------------------
/_theme_files/messages.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
13 |
14 |
15 | Property Pulse | Find local rental properties
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
30 |
31 | Open main menu
32 |
40 |
45 |
46 |
47 |
48 |
49 |
85 |
86 |
87 |
88 |
89 |
92 |
93 | Login or Register
94 |
95 |
96 |
97 |
98 |
99 |
187 |
188 |
189 |
190 |
191 |
216 |
217 |
218 |
219 |
220 |
223 |
Your Messages
224 |
225 |
226 |
229 |
230 | Property Inquiry:
231 | Boston Commons Retreat
232 |
233 |
234 | Lorem ipsum dolor sit amet, consectetur adipisicing elit.
235 | Obcaecati libero nobis vero quos aspernatur nemo alias nam, odit
236 | dolores sed quaerat illum impedit quibusdam officia ad
237 | voluptatibus molestias sequi? Repudiandae!
238 |
239 |
240 |
241 | Name: John Doe
242 |
243 |
244 | Reply Email:
245 | recipient@example.com
248 |
249 |
250 | Reply Phone:
251 | 123-456-7890
254 |
255 | Received: 1/1/2024 12:00 PM
256 |
257 |
260 | Mark As Read
261 |
262 |
263 | Delete
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
276 |
277 |
278 |
279 |
287 |
288 |
289 | © 2024 PropertyPulse. All rights reserved.
290 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 |
--------------------------------------------------------------------------------
/_theme_files/not-found.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
13 |
14 |
15 | Property Pulse | Find local rental properties
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
30 |
31 | Open main menu
32 |
40 |
45 |
46 |
47 |
48 |
49 |
85 |
86 |
87 |
88 |
89 |
92 |
93 | Login or Register
94 |
95 |
96 |
97 |
98 |
99 |
187 |
188 |
189 |
190 |
191 |
216 |
217 |
218 |
219 |
220 |
223 |
224 |
227 |
228 |
229 |
Page Not Found
230 |
231 | The page you are looking for does not exist.
232 |
233 |
Go Home
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
249 |
250 |
251 |
252 |
260 |
261 |
262 | © 2024 PropertyPulse. All rights reserved.
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
--------------------------------------------------------------------------------
/_theme_files/profile.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
13 |
14 |
15 | Property Pulse | Find local rental properties
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
30 |
31 | Open main menu
32 |
40 |
45 |
46 |
47 |
48 |
49 |
85 |
86 |
87 |
88 |
89 |
92 |
93 | Login or Register
94 |
95 |
96 |
97 |
98 |
99 |
187 |
188 |
189 |
190 |
191 |
216 |
217 |
218 |
219 |
220 |
223 |
Your Profile
224 |
225 |
226 |
227 |
232 |
233 |
234 |
235 | Name: John Doe
236 |
237 |
238 | Email: john@gmail.com
239 |
240 |
241 |
242 |
243 |
Your Listings
244 |
245 |
246 |
251 |
252 |
253 |
Property Title 1
254 |
Address: 123 Main St
255 |
256 |
270 |
271 |
272 |
273 |
278 |
279 |
280 |
Property Title 2
281 |
Address: 456 Elm St
282 |
283 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 |
309 |
310 |
311 |
312 |
320 |
321 |
322 | © 2024 PropertyPulse. All rights reserved.
323 |
324 |
325 |
326 |
327 |
328 |
329 |
330 |
331 |
--------------------------------------------------------------------------------
/app/actions/addMessage.js:
--------------------------------------------------------------------------------
1 | 'use server';
2 | import connectDB from '@/config/database';
3 | import Message from '@/models/Message';
4 | import { getSessionUser } from '@/utils/getSessionUser';
5 | import { revalidatePath } from 'next/cache';
6 |
7 | async function addMessage(previousState, formData) {
8 | await connectDB();
9 |
10 | const sessionUser = await getSessionUser();
11 |
12 | if (!sessionUser || !sessionUser.user) {
13 | return { error: 'You must be logged in to send a message' };
14 | }
15 |
16 | const { user } = sessionUser;
17 |
18 | const recipient = formData.get('recipient');
19 |
20 | if (user.id === recipient) {
21 | return { error: 'You can not send a message to yourself' };
22 | }
23 |
24 | const newMessage = new Message({
25 | sender: user.id,
26 | recipient,
27 | property: formData.get('property'),
28 | name: formData.get('name'),
29 | email: formData.get('email'),
30 | phone: formData.get('phone'),
31 | body: formData.get('message'),
32 | });
33 |
34 | await newMessage.save();
35 |
36 | return { submitted: true };
37 | }
38 |
39 | export default addMessage;
40 |
--------------------------------------------------------------------------------
/app/actions/addProperty.js:
--------------------------------------------------------------------------------
1 | 'use server';
2 | import connectDB from '@/config/database';
3 | import Property from '@/models/Property';
4 | import { getSessionUser } from '@/utils/getSessionUser';
5 | import { revalidatePath } from 'next/cache';
6 | import { redirect } from 'next/navigation';
7 | import cloudinary from '@/config/cloudinary';
8 |
9 | async function addProperty(formData) {
10 | await connectDB();
11 |
12 | const sessionUser = await getSessionUser();
13 |
14 | if (!sessionUser || !sessionUser.userId) {
15 | throw new Error('User ID is required');
16 | }
17 |
18 | const { userId } = sessionUser;
19 |
20 | // Access all values for amenities and images
21 | const amenities = formData.getAll('amenities');
22 | const images = formData.getAll('images').filter((image) => image.name !== '');
23 |
24 | // Create the propertyData object with embedded seller_info
25 | const propertyData = {
26 | type: formData.get('type'),
27 | name: formData.get('name'),
28 | description: formData.get('description'),
29 | location: {
30 | street: formData.get('location.street'),
31 | city: formData.get('location.city'),
32 | state: formData.get('location.state'),
33 | zipcode: formData.get('location.zipcode'),
34 | },
35 | beds: formData.get('beds'),
36 | baths: formData.get('baths'),
37 | square_feet: formData.get('square_feet'),
38 | amenities,
39 | rates: {
40 | weekly: formData.get('rates.weekly'),
41 | monthly: formData.get('rates.monthly'),
42 | nightly: formData.get('rates.nightly.'),
43 | },
44 | seller_info: {
45 | name: formData.get('seller_info.name'),
46 | email: formData.get('seller_info.email'),
47 | phone: formData.get('seller_info.phone'),
48 | },
49 | owner: userId,
50 | };
51 |
52 | const imageUrls = [];
53 |
54 | for (const imageFile of images) {
55 | const imageBuffer = await imageFile.arrayBuffer();
56 | const imageArray = Array.from(new Uint8Array(imageBuffer));
57 | const imageData = Buffer.from(imageArray);
58 |
59 | // Convert the image data to base64
60 | const imageBase64 = imageData.toString('base64');
61 |
62 | // Make request to upload to Cloudinary
63 | const result = await cloudinary.uploader.upload(
64 | `data:image/png;base64,${imageBase64}`,
65 | {
66 | folder: 'propertypulse',
67 | }
68 | );
69 |
70 | imageUrls.push(result.secure_url);
71 | }
72 |
73 | propertyData.images = imageUrls;
74 |
75 | const newProperty = new Property(propertyData);
76 | await newProperty.save();
77 |
78 | revalidatePath('/', 'layout');
79 |
80 | redirect(`/properties/${newProperty._id}`);
81 | }
82 |
83 | export default addProperty;
84 |
--------------------------------------------------------------------------------
/app/actions/bookmarkProperty.js:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import connectDB from '@/config/database';
4 | import User from '@/models/User';
5 | import { getSessionUser } from '@/utils/getSessionUser';
6 | import { revalidatePath } from 'next/cache';
7 |
8 | async function bookmarkProperty(propertyId) {
9 | await connectDB();
10 |
11 | const sessionUser = await getSessionUser();
12 |
13 | if (!sessionUser || !sessionUser.userId) {
14 | return { error: 'User ID is required' };
15 | }
16 |
17 | const { userId } = sessionUser;
18 |
19 | // Find user in database
20 | const user = await User.findById(userId);
21 |
22 | // Check if property is bookmarked
23 | let isBookmarked = user.bookmarks.includes(propertyId);
24 | console.log(isBookmarked);
25 |
26 | let message;
27 |
28 | if (isBookmarked) {
29 | // If already bookmarked, remove it
30 | user.bookmarks.pull(propertyId);
31 | message = 'Bookmark removed successfully';
32 | isBookmarked = false;
33 | } else {
34 | // If not bookmarked, add it
35 | user.bookmarks.push(propertyId);
36 | message = 'Bookmark added successfully';
37 | isBookmarked = true;
38 | }
39 |
40 | console.log(message);
41 |
42 | await user.save();
43 | revalidatePath('/properties/saved', 'page');
44 |
45 | return { message, isBookmarked };
46 | }
47 |
48 | export default bookmarkProperty;
49 |
--------------------------------------------------------------------------------
/app/actions/checkBookmarkStatus.js:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | const { default: connectDB } = require('@/config/database');
4 | const { default: User } = require('@/models/User');
5 | const { getSessionUser } = require('@/utils/getSessionUser');
6 |
7 | async function checkBookmarkStatus(propertyId) {
8 | await connectDB();
9 |
10 | const sessionUser = await getSessionUser();
11 |
12 | if (!sessionUser || !sessionUser.userId) {
13 | return { error: 'User ID is required' };
14 | }
15 |
16 | const { userId } = sessionUser;
17 |
18 | // Find user in database
19 | const user = await User.findById(userId);
20 |
21 | // Check if property is bookmarked
22 | let isBookmarked = user.bookmarks.includes(propertyId);
23 |
24 | return { isBookmarked };
25 | }
26 |
27 | export default checkBookmarkStatus;
28 |
--------------------------------------------------------------------------------
/app/actions/deleteMessage.js:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import connectDB from '@/config/database';
4 | import Message from '@/models/Message';
5 | import { getSessionUser } from '@/utils/getSessionUser';
6 | import { revalidatePath } from 'next/cache';
7 |
8 | async function deleteMessage(messageId) {
9 | await connectDB();
10 |
11 | const sessionUser = await getSessionUser();
12 |
13 | if (!sessionUser || !sessionUser.user) {
14 | throw new Error('User ID is required');
15 | }
16 |
17 | const { userId } = sessionUser;
18 |
19 | const message = await Message.findById(messageId);
20 |
21 | if (!message) throw new Error('Message Not Found');
22 |
23 | // Verify ownership
24 | if (message.recipient.toString() !== userId) {
25 | throw new Error('Unauthorized');
26 | }
27 |
28 | // revalidate cache
29 | revalidatePath('/messages', 'page');
30 |
31 | await message.deleteOne();
32 | }
33 |
34 | export default deleteMessage;
35 |
--------------------------------------------------------------------------------
/app/actions/deleteProperty.js:
--------------------------------------------------------------------------------
1 | 'use server';
2 | import cloudinary from '@/config/cloudinary';
3 | import connectDB from '@/config/database';
4 | import Property from '@/models/Property';
5 | import { getSessionUser } from '@/utils/getSessionUser';
6 | import { revalidatePath } from 'next/cache';
7 |
8 | async function deleteProperty(propertyId) {
9 | const sessionUser = await getSessionUser();
10 |
11 | // Check for session
12 | if (!sessionUser || !sessionUser.userId) {
13 | throw new Error('User ID is required');
14 | }
15 |
16 | const { userId } = sessionUser;
17 |
18 | await connectDB();
19 |
20 | const property = await Property.findById(propertyId);
21 |
22 | if (!property) throw new Error('Property Not Found');
23 |
24 | // Verify ownership
25 | if (property.owner.toString() !== userId) {
26 | throw new Error('Unauthorized');
27 | }
28 |
29 | // extract public id's from image url in DB
30 | const publicIds = property.images.map((imageUrl) => {
31 | const parts = imageUrl.split('/');
32 | return parts.at(-1).split('.').at(0);
33 | });
34 |
35 | // Delete images from Cloudinary
36 | if (publicIds.length > 0) {
37 | for (let publicId of publicIds) {
38 | await cloudinary.uploader.destroy('propertypulse/' + publicId);
39 | }
40 | }
41 |
42 | // Proceed with property deletion
43 | await property.deleteOne();
44 |
45 | revalidatePath('/', 'layout');
46 | }
47 |
48 | export default deleteProperty;
49 |
--------------------------------------------------------------------------------
/app/actions/getUnreadMessageCount.js:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import connectDB from '@/config/database';
4 | import Message from '@/models/Message';
5 | import { getSessionUser } from '@/utils/getSessionUser';
6 |
7 | async function getUnreadMessageCount() {
8 | await connectDB();
9 |
10 | const sessionUser = await getSessionUser();
11 |
12 | if (!sessionUser || !sessionUser.user) {
13 | return { error: 'User ID is required' };
14 | }
15 |
16 | const { userId } = sessionUser;
17 |
18 | const count = await Message.countDocuments({
19 | recipient: userId,
20 | read: false,
21 | });
22 |
23 | return { count };
24 | }
25 |
26 | export default getUnreadMessageCount;
27 |
--------------------------------------------------------------------------------
/app/actions/markMessageAsRead.js:
--------------------------------------------------------------------------------
1 | 'use server';
2 | import connectDB from '@/config/database';
3 | import Message from '@/models/Message';
4 | import { getSessionUser } from '@/utils/getSessionUser';
5 | import { revalidatePath } from 'next/cache';
6 |
7 | async function markMessageAsRead(messageId) {
8 | await connectDB();
9 |
10 | const sessionUser = await getSessionUser();
11 |
12 | if (!sessionUser || !sessionUser.user) {
13 | throw new Error('User ID is required');
14 | }
15 |
16 | const { userId } = sessionUser;
17 |
18 | const message = await Message.findById(messageId);
19 |
20 | if (!message) throw new Error('Message not found');
21 |
22 | // Verify ownership
23 | if (message.recipient.toString() !== userId) {
24 | return new Response('Unauthorized', { status: 401 });
25 | }
26 |
27 | message.read = !message.read;
28 |
29 | revalidatePath('/messages', 'page');
30 |
31 | await message.save();
32 |
33 | return message.read;
34 | }
35 |
36 | export default markMessageAsRead;
37 |
--------------------------------------------------------------------------------
/app/actions/updateProperty.js:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import connectDB from '@/config/database';
4 | import Property from '@/models/Property';
5 | import { getSessionUser } from '@/utils/getSessionUser';
6 | import { revalidatePath } from 'next/cache';
7 | import { redirect } from 'next/navigation';
8 |
9 | async function updateProperty(propertyId, formData) {
10 | await connectDB();
11 |
12 | const sessionUser = await getSessionUser();
13 |
14 | const { userId } = sessionUser;
15 |
16 | const existingProperty = await Property.findById(propertyId);
17 |
18 | // Verify ownership
19 | if (existingProperty.owner.toString() !== userId) {
20 | throw new Error('Current user does not own this property.');
21 | }
22 |
23 | const propertyData = {
24 | type: formData.get('type'),
25 | name: formData.get('name'),
26 | description: formData.get('description'),
27 | location: {
28 | street: formData.get('location.street'),
29 | city: formData.get('location.city'),
30 | state: formData.get('location.state'),
31 | zipcode: formData.get('location.zipcode'),
32 | },
33 | beds: formData.get('beds'),
34 | baths: formData.get('baths'),
35 | square_feet: formData.get('square_feet'),
36 | amenities: formData.getAll('amenities'),
37 | rates: {
38 | weekly: formData.get('rates.weekly'),
39 | monthly: formData.get('rates.monthly'),
40 | nightly: formData.get('rates.nightly.'),
41 | },
42 | seller_info: {
43 | name: formData.get('seller_info.name'),
44 | email: formData.get('seller_info.email'),
45 | phone: formData.get('seller_info.phone'),
46 | },
47 | owner: userId,
48 | };
49 |
50 | const updatedProperty = await Property.findByIdAndUpdate(
51 | propertyId,
52 | propertyData
53 | );
54 |
55 | revalidatePath('/', 'layout');
56 |
57 | redirect(`/properties/${updatedProperty._id}`);
58 | }
59 |
60 | export default updateProperty;
61 |
--------------------------------------------------------------------------------
/app/api/auth/[...nextauth]/route.js:
--------------------------------------------------------------------------------
1 | import { authOptions } from '@/utils/authOptions';
2 | import NextAuth from 'next-auth/next';
3 |
4 | const handler = NextAuth(authOptions);
5 |
6 | export { handler as GET, handler as POST };
7 |
--------------------------------------------------------------------------------
/app/error.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { FaExclamationCircle } from 'react-icons/fa';
3 | import Link from 'next/link';
4 |
5 | const ErrorPage = ({ error, reset }) => {
6 | console.log(error);
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Something Went Wrong
17 |
18 |
{error.toString()}
19 |
23 | Go Home
24 |
25 |
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default ErrorPage;
34 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/app/favicon.ico
--------------------------------------------------------------------------------
/app/layout.jsx:
--------------------------------------------------------------------------------
1 | import Navbar from '@/components/Navbar';
2 | import Footer from '@/components/Footer';
3 | import AuthProvider from '@/components/AuthProvider';
4 | import { GlobalProvider } from '@/context/GlobalContext';
5 | import { ToastContainer } from 'react-toastify';
6 | import 'react-toastify/dist/ReactToastify.css';
7 | import '@/assets/styles/globals.css';
8 | import 'photoswipe/dist/photoswipe.css';
9 |
10 | export const metadata = {
11 | title: 'PropertyPulse',
12 | description: 'Find The Perfect Rental Property',
13 | keywords: 'rental, property, real estate',
14 | };
15 |
16 | const MainLayout = ({ children }) => {
17 | return (
18 |
19 |
20 |
21 |
22 |
23 | {children}
24 |
25 |
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default MainLayout;
34 |
--------------------------------------------------------------------------------
/app/loading.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import ClipLoader from 'react-spinners/ClipLoader';
3 |
4 | const override = {
5 | display: 'block',
6 | margin: '100px auto',
7 | };
8 |
9 | const LoadingPage = () => {
10 | return (
11 |
17 | );
18 | };
19 | export default LoadingPage;
20 |
--------------------------------------------------------------------------------
/app/messages/page.jsx:
--------------------------------------------------------------------------------
1 | import connectDB from '@/config/database';
2 | import Message from '@/models/Message';
3 | import MessageCard from '@/components/MessageCard';
4 | import '@/models/Property';
5 | import { convertToSerializeableObject } from '@/utils/convertToObject';
6 | import { getSessionUser } from '@/utils/getSessionUser';
7 |
8 | const MessagePage = async () => {
9 | await connectDB();
10 |
11 | const sessionUser = await getSessionUser();
12 |
13 | const { userId } = sessionUser;
14 | console.log(userId);
15 |
16 | const readMessages = await Message.find({ recipient: userId, read: true })
17 | .sort({ createdAt: -1 }) // Sort read messages in asc order
18 | .populate('sender', 'username')
19 | .populate('property', 'name')
20 | .lean();
21 |
22 | const unreadMessages = await Message.find({
23 | recipient: userId,
24 | read: false,
25 | })
26 | .sort({ createdAt: -1 }) // Sort read messages in asc order
27 | .populate('sender', 'username')
28 | .populate('property', 'name')
29 | .lean();
30 |
31 | // Convert to serializable object so we can pass to client component.
32 | const messages = [...unreadMessages, ...readMessages].map((messageDoc) => {
33 | const message = convertToSerializeableObject(messageDoc);
34 | message.sender = convertToSerializeableObject(messageDoc.sender);
35 | message.property = convertToSerializeableObject(messageDoc.property);
36 | return message;
37 | });
38 |
39 | return (
40 |
41 |
42 |
43 |
Your Messages
44 |
45 |
46 | {messages.length === 0 ? (
47 |
You have no messages
48 | ) : (
49 | messages.map((message) => (
50 |
51 | ))
52 | )}
53 |
54 |
55 |
56 |
57 | );
58 | };
59 | export default MessagePage;
60 |
--------------------------------------------------------------------------------
/app/not-found.jsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { FaExclamationTriangle } from 'react-icons/fa';
3 |
4 | const NotFoundPage = () => {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
Page Not Found
14 |
15 | The page you are looking for does not exist.
16 |
17 |
21 | Go Home
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 | export default NotFoundPage;
31 |
--------------------------------------------------------------------------------
/app/page.jsx:
--------------------------------------------------------------------------------
1 | import Hero from '../components/Hero';
2 | import InfoBoxes from '@/components/InfoBoxes';
3 | import HomeProperties from '@/components/HomeProperties';
4 | import FeaturedProperties from '@/components/FeaturedProperties';
5 |
6 | const HomePage = () => {
7 | return (
8 | <>
9 |
10 |
11 |
12 |
13 | >
14 | );
15 | };
16 | export default HomePage;
17 |
--------------------------------------------------------------------------------
/app/profile/page.jsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import connectDB from '@/config/database';
3 | import Property from '@/models/Property';
4 | import { getSessionUser } from '@/utils/getSessionUser';
5 | import profileDefault from '@/assets/images/profile.png';
6 | import ProfileProperties from '@/components/ProfileProperties';
7 | import { convertToSerializeableObject } from '@/utils/convertToObject';
8 |
9 | const ProfilePage = async () => {
10 | await connectDB();
11 |
12 | const sessionUser = await getSessionUser();
13 |
14 | const { userId } = sessionUser;
15 |
16 | if (!userId) {
17 | throw new Error('User ID is required');
18 | }
19 |
20 | const propertiesDocs = await Property.find({ owner: userId }).lean();
21 | const properties = propertiesDocs.map(convertToSerializeableObject);
22 |
23 | return (
24 |
25 |
26 |
27 |
Your Profile
28 |
29 |
30 |
31 |
38 |
39 |
40 |
41 | Name: {' '}
42 | {sessionUser.user.name}
43 |
44 |
45 | Email: {' '}
46 | {sessionUser.user.email}
47 |
48 |
49 |
50 |
51 |
Your Listings
52 | {properties.length === 0 ? (
53 |
You have no property listings
54 | ) : (
55 |
56 | )}
57 |
58 |
59 |
60 |
61 |
62 | );
63 | };
64 |
65 | export default ProfilePage;
66 |
--------------------------------------------------------------------------------
/app/properties/[id]/edit/page.jsx:
--------------------------------------------------------------------------------
1 | import PropertyEditForm from '@/components/PropertyEditForm';
2 | import connectDB from '@/config/database';
3 | import Property from '@/models/Property';
4 | import { convertToSerializeableObject } from '@/utils/convertToObject';
5 |
6 | const PropertyEditPage = async ({ params }) => {
7 | await connectDB();
8 |
9 | const propertyDoc = await Property.findById(params.id).lean();
10 | const property = convertToSerializeableObject(propertyDoc);
11 |
12 | if (!property) {
13 | return (
14 |
15 | Property Not Found
16 |
17 | );
18 | }
19 |
20 | return (
21 |
28 | );
29 | };
30 |
31 | export default PropertyEditPage;
32 |
--------------------------------------------------------------------------------
/app/properties/[id]/page.jsx:
--------------------------------------------------------------------------------
1 | import PropertyHeaderImage from '@/components/PropertyHeaderImage';
2 | import PropertyDetails from '@/components/PropertyDetails';
3 | import connectDB from '@/config/database';
4 | import Property from '@/models/Property';
5 | import PropertyImages from '@/components/PropertyImages';
6 | import BookmarkButton from '@/components/BookmarkButton';
7 | import ShareButtons from '@/components/ShareButtons';
8 | import PropertyContactForm from '@/components/PropertyContactForm';
9 | import { convertToSerializeableObject } from '@/utils/convertToObject';
10 | import Link from 'next/link';
11 | import { FaArrowLeft } from 'react-icons/fa';
12 |
13 | const PropertyPage = async ({ params }) => {
14 | await connectDB();
15 | const propertyDoc = await Property.findById(params.id).lean();
16 | const property = convertToSerializeableObject(propertyDoc);
17 |
18 | if (!property) {
19 | return (
20 |
21 | Property Not Found
22 |
23 | );
24 | }
25 |
26 | return (
27 | <>
28 |
29 |
30 |
31 |
35 | Back to Properties
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | {/* */}
45 |
50 |
51 |
52 |
53 |
54 | >
55 | );
56 | };
57 | export default PropertyPage;
58 |
--------------------------------------------------------------------------------
/app/properties/add/page.jsx:
--------------------------------------------------------------------------------
1 | import PropertyAddForm from '@/components/PropertyAddForm';
2 |
3 | const PropertyAddPage = () => {
4 | return (
5 |
12 | );
13 | };
14 | export default PropertyAddPage;
15 |
--------------------------------------------------------------------------------
/app/properties/page.jsx:
--------------------------------------------------------------------------------
1 | import PropertyCard from '@/components/PropertyCard';
2 | import PropertySearchForm from '@/components/PropertySearchForm';
3 | import Pagination from '@/components/Pagination';
4 | import Property from '@/models/Property';
5 | import connectDB from '@/config/database';
6 |
7 | const PropertiesPage = async ({ searchParams: { pageSize = 9, page = 1 } }) => {
8 | await connectDB();
9 | const skip = (page - 1) * pageSize;
10 |
11 | const total = await Property.countDocuments({});
12 | const properties = await Property.find({}).skip(skip).limit(pageSize);
13 |
14 | // Calculate if pagination is needed
15 | const showPagination = total > pageSize;
16 |
17 | return (
18 | <>
19 |
24 |
25 |
26 |
Browse Properties
27 | {properties.length === 0 ? (
28 |
No properties found
29 | ) : (
30 |
31 | {properties.map((property, index) => (
32 |
33 | ))}
34 |
35 | )}
36 | {showPagination && (
37 |
42 | )}
43 |
44 |
45 | >
46 | );
47 | };
48 |
49 | export default PropertiesPage;
50 |
--------------------------------------------------------------------------------
/app/properties/saved/page.jsx:
--------------------------------------------------------------------------------
1 | import PropertyCard from '@/components/PropertyCard';
2 | import connectDB from '@/config/database';
3 | import User from '@/models/User';
4 | import { getSessionUser } from '@/utils/getSessionUser';
5 |
6 | const SavedPropertiesPage = async () => {
7 | await connectDB();
8 |
9 | const sessionUser = await getSessionUser();
10 |
11 | const { userId } = sessionUser;
12 |
13 | // NOTE: here we can make one database query by using Model.populate
14 | const { bookmarks } = await User.findById(userId)
15 | .populate('bookmarks')
16 | .lean();
17 |
18 | return (
19 |
20 |
21 |
Saved Properties
22 | {bookmarks.length === 0 ? (
23 |
No saved properties
24 | ) : (
25 |
26 | {bookmarks.map((property) => (
27 |
28 | ))}
29 |
30 | )}
31 |
32 |
33 | );
34 | };
35 | export default SavedPropertiesPage;
36 |
--------------------------------------------------------------------------------
/app/properties/search-results/page.jsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { FaArrowAltCircleLeft } from 'react-icons/fa';
3 | import PropertyCard from '@/components/PropertyCard';
4 | import PropertySearchForm from '@/components/PropertySearchForm';
5 | import connectDB from '@/config/database';
6 | import Property from '@/models/Property';
7 | import { convertToSerializeableObject } from '@/utils/convertToObject';
8 |
9 | const SearchResultsPage = async ({
10 | searchParams: { location, propertyType },
11 | }) => {
12 | await connectDB();
13 |
14 | const locationPattern = new RegExp(location, 'i');
15 |
16 | // Match location pattern against database fields
17 | let query = {
18 | $or: [
19 | { name: locationPattern },
20 | { description: locationPattern },
21 | { 'location.street': locationPattern },
22 | { 'location.city': locationPattern },
23 | { 'location.state': locationPattern },
24 | { 'location.zipcode': locationPattern },
25 | ],
26 | };
27 |
28 | // Only check for property if its not 'All'
29 | if (propertyType && propertyType !== 'All') {
30 | const typePattern = new RegExp(propertyType, 'i');
31 | query.type = typePattern;
32 | }
33 |
34 | const propertiesQueryResults = await Property.find(query).lean();
35 | const properties = propertiesQueryResults.map(convertToSerializeableObject);
36 |
37 | return (
38 | <>
39 |
44 |
45 |
46 |
50 |
Back To Properties
51 |
52 |
Search Results
53 | {properties.length === 0 ? (
54 |
No search results found
55 | ) : (
56 |
57 | {properties.map((property) => (
58 |
59 | ))}
60 |
61 | )}
62 |
63 |
64 | >
65 | );
66 | };
67 | export default SearchResultsPage;
68 |
--------------------------------------------------------------------------------
/assets/images/logo-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/assets/images/logo-white.png
--------------------------------------------------------------------------------
/assets/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/assets/images/logo.png
--------------------------------------------------------------------------------
/assets/images/pin.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/assets/images/profile.png
--------------------------------------------------------------------------------
/assets/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/components/AuthProvider.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { SessionProvider } from 'next-auth/react';
3 |
4 | const AuthProvider = ({ children }) => {
5 | return {children} ;
6 | };
7 | export default AuthProvider;
8 |
--------------------------------------------------------------------------------
/components/BookmarkButton.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useState, useEffect } from 'react';
3 | import { FaBookmark } from 'react-icons/fa';
4 | import { useSession } from 'next-auth/react';
5 | import bookmarkProperty from '@/app/actions/bookmarkProperty';
6 | import checkBookmarkStatus from '@/app/actions/checkBookmarkStatus';
7 | import { toast } from 'react-toastify';
8 |
9 | const BookmarkButton = ({ property }) => {
10 | const { data: session } = useSession();
11 | const userId = session?.user?.id;
12 |
13 | const [isBookmarked, setIsBookmarked] = useState(false);
14 | const [loading, setLoading] = useState(true);
15 |
16 | useEffect(() => {
17 | if (!userId) {
18 | setLoading(false);
19 | return;
20 | }
21 |
22 | checkBookmarkStatus(property._id).then((res) => {
23 | if (res.error) toast.error(res.error);
24 | if (res.isBookmarked) setIsBookmarked(res.isBookmarked);
25 | setLoading(false);
26 | });
27 | }, [property._id, userId, checkBookmarkStatus]);
28 |
29 | const handleClick = async () => {
30 | if (!userId) {
31 | toast.error('You need to sign in to bookmark a property');
32 | return;
33 | }
34 |
35 | bookmarkProperty(property._id).then((res) => {
36 | if (res.error) return toast.error(res.error);
37 | setIsBookmarked(res.isBookmarked);
38 | toast.success(res.message);
39 | });
40 | };
41 |
42 | if (loading) return Loading...
;
43 |
44 | return isBookmarked ? (
45 |
49 | Remove Bookmark
50 |
51 | ) : (
52 |
56 | Bookmark Property
57 |
58 | );
59 | };
60 | export default BookmarkButton;
61 |
--------------------------------------------------------------------------------
/components/FeaturedProperties.jsx:
--------------------------------------------------------------------------------
1 | import FeaturedPropertyCard from '@/components/FeaturedPropertyCard';
2 | import connectDB from '@/config/database';
3 | import Property from '@/models/Property';
4 |
5 | const FeaturedProperties = async () => {
6 | await connectDB();
7 |
8 | const properties = await Property.find({
9 | is_featured: true,
10 | }).lean();
11 |
12 | return properties.length > 0 ? (
13 |
14 |
15 |
16 | Featured Properties
17 |
18 |
19 | {properties.map((property) => (
20 |
21 | ))}
22 |
23 |
24 |
25 | ) : null;
26 | };
27 | export default FeaturedProperties;
28 |
--------------------------------------------------------------------------------
/components/FeaturedPropertyCard.jsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import Image from 'next/image';
3 | import {
4 | FaBed,
5 | FaBath,
6 | FaRulerCombined,
7 | FaMoneyBill,
8 | FaMapMarker,
9 | } from 'react-icons/fa';
10 |
11 | const FeaturedPropertyCard = ({ property }) => {
12 | const getRateDisplay = () => {
13 | const { rates } = property;
14 |
15 | if (rates.monthly) {
16 | return `${rates.monthly.toLocaleString()}/mo`;
17 | } else if (rates.weekly) {
18 | return `${rates.weekly.toLocaleString()}/wk`;
19 | } else if (rates.nightly) {
20 | return `${rates.nightly.toLocaleString()}/night`;
21 | }
22 | };
23 |
24 | return (
25 |
26 |
34 |
35 |
{property.name}
36 |
{property.type}
37 |
38 | ${getRateDisplay()}
39 |
40 |
41 |
42 | {property.beds}{' '}
43 | Beds
44 |
45 |
46 | {property.baths}{' '}
47 | Baths
48 |
49 |
50 |
51 | {property.square_feet}{' '}
52 | sqft
53 |
54 |
55 |
56 |
57 | {property.rates.nightly && (
58 |
59 | Nightly
60 |
61 | )}
62 |
63 | {property.rates.weekly && (
64 |
65 | Weekly
66 |
67 | )}
68 |
69 | {property.rates.monthly && (
70 |
71 | Monthly
72 |
73 | )}
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | {' '}
83 | {property.location.city} {property.location.state}
84 |
85 |
86 |
90 | Details
91 |
92 |
93 |
94 |
95 | );
96 | };
97 | export default FeaturedPropertyCard;
98 |
--------------------------------------------------------------------------------
/components/Footer.jsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import logo from '@/assets/images/logo.png';
3 |
4 | const Footer = () => {
5 | const currentYear = new Date().getFullYear();
6 |
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
23 |
24 |
25 | © {currentYear} PropertyPulse. All rights reserved.
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default Footer;
34 |
--------------------------------------------------------------------------------
/components/Hero.jsx:
--------------------------------------------------------------------------------
1 | import PropertySearchForm from './PropertySearchForm';
2 |
3 | const Hero = () => {
4 | return (
5 |
6 |
7 |
8 |
9 | Find The Perfect Rental
10 |
11 |
12 | Discover the perfect property that suits your needs.
13 |
14 |
15 |
16 |
17 |
18 | );
19 | };
20 | export default Hero;
21 |
--------------------------------------------------------------------------------
/components/HomeProperties.jsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import PropertyCard from './PropertyCard';
3 | import connectDB from '@/config/database';
4 | import Property from '@/models/Property';
5 |
6 | const HomeProperties = async () => {
7 | await connectDB();
8 |
9 | // Get the 3 latest properties
10 | const recentProperties = await Property.find({})
11 | .sort({ createdAt: -1 })
12 | .limit(3)
13 | .lean();
14 |
15 | return (
16 | <>
17 |
18 |
19 |
20 | Recent Properties
21 |
22 |
23 | {recentProperties.length === 0 ? (
24 |
No Properties Found
25 | ) : (
26 | recentProperties.map((property) => (
27 |
28 | ))
29 | )}
30 |
31 |
32 |
33 |
34 |
35 |
39 | View All Properties
40 |
41 |
42 | >
43 | );
44 | };
45 |
46 | export default HomeProperties;
47 |
--------------------------------------------------------------------------------
/components/InfoBox.jsx:
--------------------------------------------------------------------------------
1 | const InfoBox = ({
2 | heading,
3 | backgroundColor = 'bg-gray-100',
4 | textColor = 'text-gray-800',
5 | buttonInfo,
6 | children,
7 | }) => {
8 | return (
9 |
19 | );
20 | };
21 | export default InfoBox;
22 |
--------------------------------------------------------------------------------
/components/InfoBoxes.jsx:
--------------------------------------------------------------------------------
1 | import InfoBox from './InfoBox';
2 |
3 | const InfoBoxes = () => {
4 | return (
5 |
6 |
7 |
8 |
17 | Find your dream rental property. Bookmark properties and contact
18 | owners.
19 |
20 |
29 | List your properties and reach potential tenants. Rent as an Airbnb
30 | or long term.
31 |
32 |
33 |
34 |
35 | );
36 | };
37 | export default InfoBoxes;
38 |
--------------------------------------------------------------------------------
/components/MessageCard.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useState } from 'react';
3 | import { toast } from 'react-toastify';
4 | import markMessageAsRead from '@/app/actions/markMessageAsRead';
5 | import deleteMessage from '@/app/actions/deleteMessage';
6 | import { useGlobalContext } from '@/context/GlobalContext';
7 |
8 | const MessageCard = ({ message }) => {
9 | const [isRead, setIsRead] = useState(message.read);
10 | const [isDeleted, setIsDeleted] = useState(false);
11 |
12 | const { setUnreadCount } = useGlobalContext();
13 |
14 | const handleReadClick = async () => {
15 | const read = await markMessageAsRead(message._id);
16 | setIsRead(read);
17 | setUnreadCount((prevCount) => (read ? prevCount - 1 : prevCount + 1));
18 | toast.success(`Marked as ${read ? 'read' : 'new'}`);
19 | };
20 |
21 | const handleDeleteClick = async () => {
22 | await deleteMessage(message._id);
23 | setIsDeleted(true);
24 | setUnreadCount((prevCount) => (isRead ? prevCount : prevCount - 1));
25 | toast.success('Message Deleted');
26 | };
27 |
28 | if (isDeleted) {
29 | return Deleted message
;
30 | }
31 |
32 | return (
33 |
34 | {!isRead && (
35 |
36 | New
37 |
38 | )}
39 |
40 | Property Inquiry: {' '}
41 | {message.property.name}
42 |
43 |
{message.body}
44 |
45 |
63 |
69 | {isRead ? 'Mark As New' : 'Mark As Read'}
70 |
71 |
75 | Delete
76 |
77 |
78 | );
79 | };
80 |
81 | export default MessageCard;
82 |
--------------------------------------------------------------------------------
/components/Navbar.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useState, useEffect } from 'react';
3 | import Link from 'next/link';
4 | import Image from 'next/image';
5 | import { usePathname } from 'next/navigation';
6 | import { FaGoogle } from 'react-icons/fa';
7 | import logo from '@/assets/images/logo-white.png';
8 | import profileDefault from '@/assets/images/profile.png';
9 | import { signIn, signOut, useSession, getProviders } from 'next-auth/react';
10 | import UnreadMessageCount from './UnreadMessageCount';
11 |
12 | const Navbar = () => {
13 | const { data: session } = useSession();
14 | const profileImage = session?.user?.image;
15 |
16 | const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
17 | const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
18 | const [providers, setProviders] = useState(null);
19 |
20 | const pathname = usePathname();
21 |
22 | useEffect(() => {
23 | const setAuthProviders = async () => {
24 | const res = await getProviders();
25 | setProviders(res);
26 | };
27 |
28 | setAuthProviders();
29 |
30 | // NOTE: close mobile menu if the viewport size is changed
31 | window.addEventListener('resize', () => {
32 | setIsMobileMenuOpen(false);
33 | });
34 | }, []);
35 |
36 | return (
37 |
38 |
39 |
40 |
41 | {/* */}
42 |
setIsMobileMenuOpen(!isMobileMenuOpen)}
49 | >
50 |
51 | Open main menu
52 |
60 |
65 |
66 |
67 |
68 |
69 |
70 | {/* */}
71 |
72 |
73 |
74 |
75 | PropertyPulse
76 |
77 |
78 | {/* */}
79 |
80 |
81 |
87 | Home
88 |
89 |
95 | Properties
96 |
97 | {session && (
98 |
104 | Add Property
105 |
106 | )}
107 |
108 |
109 |
110 |
111 | {/* */}
112 | {!session && (
113 |
114 |
115 | {providers &&
116 | Object.values(providers).map((provider) => (
117 | signIn(provider.id)}
120 | className='flex items-center text-white bg-gray-700 hover:bg-gray-900 hover:text-white rounded-md px-3 py-2 my-3'
121 | >
122 |
123 | Login or Register
124 |
125 | ))}
126 |
127 |
128 | )}
129 |
130 | {/* */}
131 | {session && (
132 |
133 |
134 |
138 |
139 | View notifications
140 |
148 |
153 |
154 |
155 |
156 |
157 | {/* */}
158 |
159 |
160 |
178 |
179 |
180 | {/* */}
181 | {isProfileMenuOpen && (
182 |
227 | )}
228 |
229 |
230 | )}
231 |
232 |
233 | {/* */}
234 | {isMobileMenuOpen && (
235 |
282 | )}
283 |
284 | );
285 | };
286 | export default Navbar;
287 |
--------------------------------------------------------------------------------
/components/Pagination.jsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | const Pagination = ({ page, pageSize, totalItems }) => {
4 | const totalPages = Math.ceil(totalItems / pageSize);
5 | return (
6 |
7 | {page > 1 ? (
8 |
12 | Previous
13 |
14 | ) : null}
15 |
16 |
17 | {' '}
18 | Page {page} of {totalPages}
19 |
20 |
21 | {page < totalPages ? (
22 |
26 | Next
27 |
28 | ) : null}
29 |
30 | );
31 | };
32 | export default Pagination;
33 |
--------------------------------------------------------------------------------
/components/ProfileProperties.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useState } from 'react';
3 | import Image from 'next/image';
4 | import Link from 'next/link';
5 | import { toast } from 'react-toastify';
6 | import deleteProperty from '@/app/actions/deleteProperty';
7 |
8 | const ProfileProperties = ({ properties: initialProperties }) => {
9 | const [properties, setProperties] = useState(initialProperties);
10 |
11 | const handleDeleteProperty = async (propertyId) => {
12 | const confirmed = window.confirm(
13 | 'Are you sure you want to delete this property?'
14 | );
15 |
16 | if (!confirmed) return;
17 |
18 | const deletePropertyById = deleteProperty.bind(null, propertyId);
19 |
20 | await deletePropertyById();
21 |
22 | toast.success('Property Deleted');
23 |
24 | const updatedProperties = properties.filter(
25 | (property) => property._id !== propertyId
26 | );
27 |
28 | setProperties(updatedProperties);
29 | };
30 |
31 | return properties.map((property) => (
32 |
33 |
34 |
42 |
43 |
44 |
{property.name}
45 |
46 | Address: {property.location.street} {property.location.city}{' '}
47 | {property.location.state}
48 |
49 |
50 |
51 |
55 | Edit
56 |
57 | handleDeleteProperty(property._id)}
59 | className='bg-red-500 text-white px-3 py-2 rounded-md hover:bg-red-600'
60 | type='button'
61 | >
62 | Delete
63 |
64 |
65 |
66 | ));
67 | };
68 |
69 | export default ProfileProperties;
70 |
--------------------------------------------------------------------------------
/components/PropertyAddForm.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import addProperty from '@/app/actions/addProperty';
3 |
4 | const PropertyAddForm = () => {
5 | return (
6 |
405 | );
406 | };
407 |
408 | export default PropertyAddForm;
409 |
--------------------------------------------------------------------------------
/components/PropertyCard.jsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import Link from 'next/link';
3 | import {
4 | FaBed,
5 | FaBath,
6 | FaRulerCombined,
7 | FaMoneyBill,
8 | FaMapMarker,
9 | } from 'react-icons/fa';
10 |
11 | const PropertyCard = ({ property }) => {
12 | const getRateDisplay = () => {
13 | const { rates } = property;
14 | if (rates.monthly) {
15 | return `$${rates.monthly.toLocaleString()}/mo`;
16 | } else if (rates.weekly) {
17 | return `$${rates.weekly.toLocaleString()}/wk`;
18 | } else if (rates.nightly) {
19 | return `$${rates.nightly.toLocaleString()}/night`;
20 | }
21 | };
22 |
23 | return (
24 |
25 |
34 |
35 |
36 |
{property.type}
37 |
{property.name}
38 |
39 |
40 | {getRateDisplay()}
41 |
42 |
43 |
44 |
45 | {property.beds}
46 | Beds
47 |
48 |
49 | {property.baths}
50 | Baths
51 |
52 |
53 | {' '}
54 | {property.square_feet}
55 | sqft
56 |
57 |
58 |
59 |
60 |
61 | Weekly
62 |
63 |
64 | Monthly
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | {' '}
75 | {property.location.city}, {property.location.state}
76 |
77 |
78 |
82 | Details
83 |
84 |
85 |
86 |
87 | );
88 | };
89 | export default PropertyCard;
90 |
--------------------------------------------------------------------------------
/components/PropertyContactForm.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useEffect } from 'react';
3 | import { useFormState } from 'react-dom';
4 | import { useSession } from 'next-auth/react';
5 | import { toast } from 'react-toastify';
6 | import addMessage from '@/app/actions/addMessage';
7 | import SubmitMessageButton from './SubmitMessageButton';
8 |
9 | const PropertyContactForm = ({ property }) => {
10 | const { data: session } = useSession();
11 |
12 | const [state, formAction] = useFormState(addMessage, {});
13 |
14 | useEffect(() => {
15 | if (state.error) toast.error(state.error);
16 | if (state.submitted) toast.success('Message sent successfully');
17 | }, [state]);
18 |
19 | if (state.submitted) {
20 | return (
21 |
22 | Your message has been sent successfully
23 |
24 | );
25 | }
26 |
27 | return (
28 | session && (
29 |
110 | )
111 | );
112 | };
113 | export default PropertyContactForm;
114 |
--------------------------------------------------------------------------------
/components/PropertyDetails.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | FaBed,
3 | FaBath,
4 | FaRulerCombined,
5 | FaTimes,
6 | FaCheck,
7 | FaMapMarker,
8 | } from 'react-icons/fa';
9 | import PropertyMap from '@/components/PropertyMap';
10 |
11 | const PropertyDetails = ({ property }) => {
12 | return (
13 |
14 |
15 |
{property.type}
16 |
{property.name}
17 |
18 |
19 |
20 | {property.location.street}, {property.location.city}{' '}
21 | {property.location.state}
22 |
23 |
24 |
25 |
26 | Rates & Options
27 |
28 |
29 |
30 |
Nightly
31 |
32 | {property.rates.nightly ? (
33 | `$${property.rates.nightly.toLocaleString()}`
34 | ) : (
35 |
36 | )}
37 |
38 |
39 |
40 |
Weekly
41 |
42 | {property.rates.weekly ? (
43 | `$${property.rates.weekly.toLocaleString()}`
44 | ) : (
45 |
46 | )}
47 |
48 |
49 |
50 |
Monthly
51 |
52 | {property.rates.monthly ? (
53 | `$${property.rates.monthly.toLocaleString()}`
54 | ) : (
55 |
56 | )}
57 |
58 |
59 |
60 |
61 |
62 |
63 |
Description & Details
64 |
65 |
66 | {property.beds}{' '}
67 | Beds
68 |
69 |
70 | {property.baths}{' '}
71 | Baths
72 |
73 |
74 |
75 | {property.square_feet}{' '}
76 | sqft
77 |
78 |
79 |
{property.description}
80 |
81 |
82 |
83 |
Amenities
84 |
85 |
86 | {property.amenities.map((amenity, index) => (
87 |
88 | {amenity}
89 |
90 | ))}
91 |
92 |
93 |
96 |
97 | );
98 | };
99 |
100 | export default PropertyDetails;
101 |
--------------------------------------------------------------------------------
/components/PropertyEditForm.jsx:
--------------------------------------------------------------------------------
1 | import updateProperty from '@/app/actions/updateProperty';
2 |
3 | const PropertyEditForm = ({ property }) => {
4 | const updatePropertyById = updateProperty.bind(null, property._id);
5 |
6 | return (
7 |
8 | Edit Property
9 |
10 |
11 |
12 | Property Type
13 |
14 |
21 | Apartment
22 | Condo
23 | House
24 | Cabin or Cottage
25 | Room
26 | Studio
27 | Other
28 |
29 |
30 |
31 |
32 | Listing Name
33 |
34 |
43 |
44 |
45 |
49 | Description
50 |
51 |
59 |
60 |
61 |
62 | Location
63 |
71 |
80 |
89 |
97 |
98 |
99 |
143 |
144 |
145 |
Amenities
146 |
319 |
320 |
321 |
322 |
323 | Rates (Leave blank if not applicable)
324 |
325 |
363 |
364 |
365 |
366 |
370 | Seller Name
371 |
372 |
380 |
381 |
382 |
386 | Seller Email
387 |
388 |
397 |
398 |
399 |
403 | Seller Phone
404 |
405 |
413 |
414 |
415 |
419 | Update Property
420 |
421 |
422 |
423 | );
424 | };
425 | export default PropertyEditForm;
426 |
--------------------------------------------------------------------------------
/components/PropertyHeaderImage.jsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | const PropertyHeaderImage = ({ image }) => {
4 | return (
5 |
20 | );
21 | };
22 | export default PropertyHeaderImage;
23 |
--------------------------------------------------------------------------------
/components/PropertyImages.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import Image from 'next/image';
3 | import { Gallery, Item } from 'react-photoswipe-gallery';
4 |
5 | const PropertyImages = ({ images }) => {
6 | return (
7 |
8 |
9 |
10 | {images.length === 1 ? (
11 |
-
17 | {({ ref, open }) => (
18 |
28 | )}
29 |
30 | ) : (
31 |
32 | {images.map((image, index) => (
33 |
43 | -
49 | {({ ref, open }) => (
50 |
61 | )}
62 |
63 |
64 | ))}
65 |
66 | )}
67 |
68 |
69 |
70 | );
71 | };
72 | export default PropertyImages;
73 |
--------------------------------------------------------------------------------
/components/PropertyMap.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useEffect, useState } from 'react';
3 | import 'mapbox-gl/dist/mapbox-gl.css';
4 | import Map, { Marker } from 'react-map-gl';
5 | import { setDefaults, fromAddress } from 'react-geocode';
6 | import Image from 'next/image';
7 | import pin from '@/assets/images/pin.svg';
8 | import Spinner from './Spinner';
9 |
10 | const PropertyMap = ({ property }) => {
11 | const [lat, setLat] = useState(null);
12 | const [lng, setLng] = useState(null);
13 | const [viewport, setViewport] = useState({
14 | latitude: 0,
15 | longitude: 0,
16 | zoom: 12,
17 | width: '100%',
18 | height: '500px',
19 | });
20 | const [loading, setLoading] = useState(true);
21 | const [geocodeError, setGeocodeError] = useState(false);
22 |
23 | setDefaults({
24 | key: process.env.NEXT_PUBLIC_GOOGLE_GEOCODING_API_KEY,
25 | language: 'en',
26 | region: 'us',
27 | });
28 |
29 | useEffect(() => {
30 | const fetchCoords = async () => {
31 | try {
32 | const res = await fromAddress(
33 | `${property.location.street} ${property.location.city} ${property.location.state} ${property.location.zipcode}`
34 | );
35 |
36 | // Check for results
37 | if (res.results.length === 0) {
38 | // No results found
39 | setGeocodeError(true);
40 | setLoading(false);
41 | return;
42 | }
43 |
44 | const { lat, lng } = res.results[0].geometry.location;
45 |
46 | setLat(lat);
47 | setLng(lng);
48 | setViewport({
49 | ...viewport,
50 | latitude: lat,
51 | longitude: lng,
52 | });
53 |
54 | setLoading(false);
55 | } catch (error) {
56 | console.log(error);
57 | setGeocodeError(true);
58 | setLoading(false);
59 | }
60 | };
61 |
62 | fetchCoords();
63 | }, []);
64 |
65 | if (loading) return ;
66 |
67 | if (geocodeError) {
68 | return No location data found
;
69 | }
70 |
71 | return (
72 | !loading && (
73 |
84 |
85 |
86 |
87 |
88 | )
89 | );
90 | };
91 |
92 | export default PropertyMap;
93 |
--------------------------------------------------------------------------------
/components/PropertySearchForm.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useState } from 'react';
3 | import { useRouter } from 'next/navigation';
4 |
5 | const PropertySearchForm = () => {
6 | const [location, setLocation] = useState('');
7 | const [propertyType, setPropertyType] = useState('All');
8 |
9 | const router = useRouter();
10 |
11 | const handleSubmit = (e) => {
12 | e.preventDefault();
13 |
14 | if (location === '' && propertyType === 'All') {
15 | router.push('/properties');
16 | } else {
17 | const query = `?location=${location}&propertyType=${propertyType}`;
18 |
19 | router.push(`/properties/search-results${query}`);
20 | }
21 | };
22 |
23 | return (
24 |
28 |
29 |
30 | Location
31 |
32 | setLocation(e.target.value)}
39 | />
40 |
41 |
42 |
43 | Property Type
44 |
45 | setPropertyType(e.target.value)}
50 | >
51 | All
52 | Apartment
53 | Studio
54 | Condo
55 | House
56 | Cabin or Cottage
57 | Loft
58 | Room
59 | Other
60 |
61 |
62 |
66 | Search
67 |
68 |
69 | );
70 | };
71 |
72 | export default PropertySearchForm;
73 |
--------------------------------------------------------------------------------
/components/ShareButtons.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import {
3 | FacebookShareButton,
4 | TwitterShareButton,
5 | WhatsappShareButton,
6 | EmailShareButton,
7 | FacebookIcon,
8 | TwitterIcon,
9 | WhatsappIcon,
10 | EmailIcon,
11 | } from 'react-share';
12 |
13 | const ShareButtons = ({ property }) => {
14 | const shareUrl = `${process.env.NEXT_PUBLIC_DOMAIN}/properties/${property._id}`;
15 |
16 | return (
17 | <>
18 |
19 | Share This Property:
20 |
21 |
22 |
27 |
28 |
29 |
30 |
35 |
36 |
37 |
38 |
43 |
44 |
45 |
46 |
51 |
52 |
53 |
54 | >
55 | );
56 | };
57 | export default ShareButtons;
58 |
--------------------------------------------------------------------------------
/components/Spinner.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import ClipLoader from 'react-spinners/ClipLoader';
3 |
4 | const override = {
5 | display: 'block',
6 | margin: '100px auto',
7 | };
8 |
9 | const Spinner = ({ loading }) => {
10 | return (
11 |
18 | );
19 | };
20 | export default Spinner;
21 |
--------------------------------------------------------------------------------
/components/SubmitMessageButton.jsx:
--------------------------------------------------------------------------------
1 | import { useFormStatus } from 'react-dom';
2 | import { FaPaperPlane } from 'react-icons/fa';
3 |
4 | const SubmitMessageButton = () => {
5 | const { pending } = useFormStatus();
6 | return (
7 |
12 | {' '}
13 | {pending ? 'Sending...' : 'Send Message'}
14 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/components/UnreadMessageCount.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useGlobalContext } from '@/context/GlobalContext';
3 |
4 | const UnreadMessageCount = () => {
5 | const { unreadCount } = useGlobalContext();
6 |
7 | return unreadCount > 0 ? (
8 |
9 | {unreadCount}
10 |
11 | ) : null;
12 | };
13 | export default UnreadMessageCount;
14 |
--------------------------------------------------------------------------------
/config/cloudinary.js:
--------------------------------------------------------------------------------
1 | import { v2 as cloudinary } from 'cloudinary';
2 |
3 | cloudinary.config({
4 | cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
5 | api_key: process.env.CLOUDINARY_API_KEY,
6 | api_secret: process.env.CLOUDINARY_API_SECRET,
7 | });
8 |
9 | export default cloudinary;
10 |
--------------------------------------------------------------------------------
/config/database.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 |
3 | let connected = false;
4 |
5 | const connectDB = async () => {
6 | mongoose.set('strictQuery', true);
7 |
8 | // If the database is already connected, don't connect again
9 | if (connected) {
10 | console.log('MongoDB is already connected...');
11 | return;
12 | }
13 |
14 | // Connect to MongoDB
15 | try {
16 | await mongoose.connect(process.env.MONGODB_URI);
17 | connected = true;
18 | console.log('MongoDB connected...');
19 | } catch (error) {
20 | console.log(error);
21 | }
22 | };
23 |
24 | export default connectDB;
25 |
--------------------------------------------------------------------------------
/context/GlobalContext.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import getUnreadMessageCount from '@/app/actions/getUnreadMessageCount';
3 | import { useSession } from 'next-auth/react';
4 | import { createContext, useContext, useState, useEffect } from 'react';
5 |
6 | // Create context
7 | const GlobalContext = createContext();
8 |
9 | // Create a provider
10 | export function GlobalProvider({ children }) {
11 | const [unreadCount, setUnreadCount] = useState(0);
12 |
13 | const { data: session } = useSession();
14 |
15 | useEffect(() => {
16 | if (session && session.user) {
17 | getUnreadMessageCount().then((res) => {
18 | if (res.count) setUnreadCount(res.count);
19 | });
20 | }
21 | }, [getUnreadMessageCount, session]);
22 |
23 | return (
24 |
30 | {children}
31 |
32 | );
33 | }
34 |
35 | // Create a custom hook to access context
36 | export function useGlobalContext() {
37 | return useContext(GlobalContext);
38 | }
39 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/middleware.js:
--------------------------------------------------------------------------------
1 | export { default } from 'next-auth/middleware';
2 |
3 | export const config = {
4 | matcher: ['/properties/add', '/profile', '/properties/saved', '/messages'],
5 | };
6 |
--------------------------------------------------------------------------------
/models/Message.js:
--------------------------------------------------------------------------------
1 | import { Schema, model, models } from 'mongoose';
2 |
3 | const MessageSchema = new Schema(
4 | {
5 | sender: {
6 | type: Schema.Types.ObjectId,
7 | ref: 'User',
8 | required: true,
9 | },
10 | recipient: {
11 | type: Schema.Types.ObjectId,
12 | ref: 'User',
13 | required: true,
14 | },
15 | property: {
16 | type: Schema.Types.ObjectId,
17 | ref: 'Property',
18 | required: true,
19 | },
20 | name: {
21 | type: String,
22 | required: [true, 'Name is required'],
23 | },
24 | email: {
25 | type: String,
26 | required: [true, 'Email is required'],
27 | },
28 | phone: {
29 | type: String,
30 | },
31 | body: {
32 | type: String,
33 | },
34 | read: {
35 | type: Boolean,
36 | default: false,
37 | },
38 | },
39 | {
40 | timestamps: true,
41 | }
42 | );
43 |
44 | const Message = models.Message || model('Message', MessageSchema);
45 |
46 | export default Message;
47 |
--------------------------------------------------------------------------------
/models/Property.js:
--------------------------------------------------------------------------------
1 | import { Schema, model, models } from 'mongoose';
2 |
3 | const PropertySchema = new Schema(
4 | {
5 | owner: {
6 | type: Schema.Types.ObjectId,
7 | ref: 'User',
8 | required: true,
9 | },
10 | name: {
11 | type: String,
12 | required: true,
13 | },
14 | type: {
15 | type: String,
16 | required: true,
17 | },
18 | description: {
19 | type: String,
20 | },
21 | location: {
22 | street: {
23 | type: String,
24 | },
25 | city: {
26 | type: String,
27 | },
28 | state: {
29 | type: String,
30 | },
31 | zipcode: {
32 | type: String,
33 | },
34 | },
35 | beds: {
36 | type: Number,
37 | required: true,
38 | },
39 | baths: {
40 | type: Number,
41 | required: true,
42 | },
43 | square_feet: {
44 | type: Number,
45 | required: true,
46 | },
47 | amenities: [
48 | {
49 | type: String,
50 | },
51 | ],
52 | rates: {
53 | nightly: {
54 | type: Number,
55 | },
56 | weekly: {
57 | type: Number,
58 | },
59 | monthly: {
60 | type: Number,
61 | },
62 | },
63 | seller_info: {
64 | name: {
65 | type: String,
66 | },
67 | email: {
68 | type: String,
69 | },
70 | phone: {
71 | type: String,
72 | },
73 | },
74 | images: [
75 | {
76 | type: String,
77 | },
78 | ],
79 | is_featured: {
80 | type: Boolean,
81 | default: false,
82 | },
83 | },
84 | {
85 | timestamps: true,
86 | }
87 | );
88 |
89 | const Property = models.Property || model('Property', PropertySchema);
90 |
91 | export default Property;
92 |
--------------------------------------------------------------------------------
/models/User.js:
--------------------------------------------------------------------------------
1 | import { Schema, model, models } from 'mongoose';
2 |
3 | const UserSchema = new Schema(
4 | {
5 | email: {
6 | type: String,
7 | unique: [true, 'Email already exists'],
8 | required: [true, 'Email is required'],
9 | },
10 | username: {
11 | type: String,
12 | required: [true, 'Username is required'],
13 | },
14 | image: {
15 | type: String,
16 | },
17 | bookmarks: [
18 | {
19 | type: Schema.Types.ObjectId,
20 | ref: 'Property',
21 | },
22 | ],
23 | },
24 | {
25 | timestamps: true,
26 | }
27 | );
28 |
29 | const User = models.User || model('User', UserSchema);
30 |
31 | export default User;
32 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: 'https',
7 | hostname: 'lh3.googleusercontent.com',
8 | pathname: '**',
9 | },
10 | {
11 | protocol: 'https',
12 | hostname: 'res.cloudinary.com',
13 | pathname: '**',
14 | },
15 | ],
16 | },
17 | };
18 |
19 | export default nextConfig;
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "property-pulse-2.0",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "cloudinary": "^2.2.0",
13 | "mapbox-gl": "^3.5.1",
14 | "mongodb": "^6.8.0",
15 | "mongoose": "^8.5.0",
16 | "next": "14.2.4",
17 | "next-auth": "^4.24.7",
18 | "photoswipe": "^5.4.4",
19 | "react": "^18",
20 | "react-dom": "^18",
21 | "react-geocode": "^1.0.0-alpha.1",
22 | "react-icons": "^5.2.1",
23 | "react-map-gl": "^7.1.7",
24 | "react-photoswipe-gallery": "^3.0.1",
25 | "react-share": "^5.1.0",
26 | "react-spinners": "^0.14.1",
27 | "react-toastify": "^10.0.5"
28 | },
29 | "devDependencies": {
30 | "@types/node": "20.14.10",
31 | "@types/react": "18.3.3",
32 | "postcss": "^8",
33 | "tailwindcss": "^3.4.1",
34 | "typescript": "5.5.3"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/properties.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "_id": "1",
4 | "owner": "1",
5 | "name": "Boston Commons Retreat",
6 | "type": "Apartment",
7 | "description": "This is a beautiful apartment located near the commons. It is a 2 bedroom apartment with a full kitchen and bathroom. It is available for weekly or monthly rentals.",
8 | "location": {
9 | "street": "120 Tremont Street",
10 | "city": "Boston",
11 | "state": "MA",
12 | "zipcode": "02108"
13 | },
14 | "beds": 2,
15 | "baths": 1,
16 | "square_feet": 1500,
17 | "amenities": [
18 | "Wifi",
19 | "Full kitchen",
20 | "Washer & Dryer",
21 | "Free Parking",
22 | "Hot Tub",
23 | "24/7 Security",
24 | "Wheelchair Accessible",
25 | "Elevator Access",
26 | "Dishwasher",
27 | "Gym/Fitness Center",
28 | "Air Conditioning",
29 | "Balcony/Patio",
30 | "Smart TV",
31 | "Coffee Maker"
32 | ],
33 | "rates": {
34 | "weekly": 1100,
35 | "monthly": 4200
36 | },
37 | "seller_info": {
38 | "name": "John Doe",
39 | "email": "john@gmail.com",
40 | "phone": "617-555-5555"
41 | },
42 | "images": ["a1.jpg", "a2.jpg", "a3.jpg"],
43 | "is_featured": false,
44 | "createdAt": "2024-01-01T00:00:00.000Z",
45 | "updatedAt": "2024-01-01T00:00:00.000Z"
46 | },
47 | {
48 | "_id": "2",
49 | "owner": "1",
50 | "name": "Cozy Downtown Loft",
51 | "type": "Apartment",
52 | "description": "A cozy downtown loft with great city views.",
53 | "location": {
54 | "street": "45 Main Street",
55 | "city": "New York",
56 | "state": "NY",
57 | "zipcode": "10001"
58 | },
59 | "beds": 1,
60 | "baths": 1,
61 | "square_feet": 1800,
62 | "amenities": [
63 | "Wifi",
64 | "Full kitchen",
65 | "Washer & Dryer",
66 | "Free Parking",
67 | "Hot Tub",
68 | "24/7 Security",
69 | "Wheelchair Accessible",
70 | "Elevator Access",
71 | "Dishwasher",
72 | "High-Speed Internet",
73 | "Air Conditioning",
74 | "Smart TV",
75 | "Outdoor Grill/BBQ"
76 | ],
77 | "rates": {
78 | "weekly": 1000,
79 | "monthly": 4000
80 | },
81 | "seller_info": {
82 | "name": "Jane Smith",
83 | "email": "jane@gmail.com",
84 | "phone": "212-555-5555"
85 | },
86 | "images": ["b1.jpg", "b2.jpg", "b3.jpg"],
87 | "is_featured": false,
88 | "createdAt": "2024-01-02T00:00:00.000Z",
89 | "updatedAt": "2024-01-02T00:00:00.000Z"
90 | },
91 | {
92 | "_id": "3",
93 | "owner": "2",
94 | "name": "Luxury Condo with a View",
95 | "type": "Condo",
96 | "description": "Experience luxury in this stunning condo with breathtaking views.",
97 | "location": {
98 | "street": "500 Lux Lane",
99 | "city": "Los Angeles",
100 | "state": "CA",
101 | "zipcode": "90001"
102 | },
103 | "beds": 3,
104 | "baths": 2,
105 | "square_feet": 2200,
106 | "amenities": [
107 | "Wifi",
108 | "Full kitchen",
109 | "Washer & Dryer",
110 | "Free Parking",
111 | "Hot Tub",
112 | "24/7 Security",
113 | "Wheelchair Accessible",
114 | "Elevator Access",
115 | "Dishwasher",
116 | "Swimming Pool",
117 | "Gym/Fitness Center",
118 | "Air Conditioning",
119 | "Smart TV",
120 | "Coffee Maker"
121 | ],
122 | "rates": {
123 | "nightly": 200,
124 | "weekly": 750,
125 | "monthly": 3300
126 | },
127 | "seller_info": {
128 | "name": "David Johnson",
129 | "email": "david@gmail.com",
130 | "phone": "213-555-5555"
131 | },
132 | "images": ["c1.jpg", "c2.jpg", "c3.jpg"],
133 | "is_featured": false,
134 | "createdAt": "2024-01-03T00:00:00.000Z",
135 | "updatedAt": "2024-01-03T00:00:00.000Z"
136 | },
137 | {
138 | "_id": "4",
139 | "owner": "2",
140 | "name": "Charming Cottage Getaway",
141 | "type": "Cottage Or Cabin",
142 | "description": "Escape to this charming cottage for a peaceful retreat.",
143 | "location": {
144 | "street": "123 Countryside Lane",
145 | "city": "Austin",
146 | "state": "TX",
147 | "zipcode": "78701"
148 | },
149 | "beds": 1,
150 | "baths": 1,
151 | "square_feet": 900,
152 | "amenities": [
153 | "Fireplace",
154 | "Outdoor Grill/BBQ",
155 | "Balcony/Patio",
156 | "Coffee Maker"
157 | ],
158 | "rates": {
159 | "weekly": 2000
160 | },
161 | "seller_info": {
162 | "name": "Emily Davis",
163 | "email": "emily@gmail.com",
164 | "phone": "512-555-5555"
165 | },
166 | "images": ["d1.jpg", "d2.jpg", "d3.jpg"],
167 | "is_featured": false,
168 | "createdAt": "2024-01-04T00:00:00.000Z",
169 | "updatedAt": "2024-01-04T00:00:00.000Z"
170 | },
171 | {
172 | "_id": "5",
173 | "owner": "3",
174 | "name": "Modern Downtown Studio",
175 | "type": "Studio",
176 | "description": "Stay in style in this modern downtown studio apartment.",
177 | "location": {
178 | "street": "75 Urban Avenue",
179 | "city": "Chicago",
180 | "state": "IL",
181 | "zipcode": "60601"
182 | },
183 | "beds": 1,
184 | "baths": 1,
185 | "square_feet": 900,
186 | "amenities": [
187 | "High-Speed Internet",
188 | "Smart TV",
189 | "Air Conditioning",
190 | "Gym/Fitness Center",
191 | "Outdoor Grill/BBQ"
192 | ],
193 | "rates": {
194 | "weekly": 1100,
195 | "monthly": 4200
196 | },
197 | "seller_info": {
198 | "name": "Michael Brown",
199 | "email": "michael@gmail.com",
200 | "phone": "312-555-5555"
201 | },
202 | "images": ["e1.jpg", "e2.jpg", "e3.jpg"],
203 | "is_featured": true,
204 | "createdAt": "2024-01-05T00:00:00.000Z",
205 | "updatedAt": "2024-01-05T00:00:00.000Z"
206 | },
207 | {
208 | "_id": "6",
209 | "owner": "3",
210 | "name": "Seaside Retreat",
211 | "type": "House",
212 | "description": "Escape to this seaside house for a relaxing getaway.",
213 | "location": {
214 | "street": "456 Oceanfront Drive",
215 | "city": "Miami",
216 | "state": "FL",
217 | "zipcode": "33101"
218 | },
219 | "beds": 4,
220 | "baths": 3,
221 | "square_feet": 2800,
222 | "amenities": [
223 | "Beach Access",
224 | "Swimming Pool",
225 | "Balcony/Patio",
226 | "Smart TV",
227 | "Outdoor Grill/BBQ"
228 | ],
229 | "rates": {
230 | "nightly": 500,
231 | "weekly": 2500
232 | },
233 | "seller_info": {
234 | "name": "Sarah Wilson",
235 | "email": "sarah@gmail.com",
236 | "phone": "305-555-5555"
237 | },
238 | "images": ["f1.jpg", "f2.jpg", "f3.jpg"],
239 | "is_featured": true,
240 | "createdAt": "2024-01-06T00:00:00.000Z",
241 | "updatedAt": "2024-01-06T00:00:00.000Z"
242 | },
243 | {
244 | "_id": "7",
245 | "owner": "4",
246 | "name": "Rustic Cabin in the Woods",
247 | "type": "Cottage Or Cabin",
248 | "description": "Experience nature in this cozy rustic cabin.",
249 | "location": {
250 | "street": "789 Forest Lane",
251 | "city": "Denver",
252 | "state": "CO",
253 | "zipcode": "80201"
254 | },
255 | "beds": 2,
256 | "baths": 1,
257 | "square_feet": 1100,
258 | "amenities": [
259 | "Fireplace",
260 | "Outdoor Grill/BBQ",
261 | "Hiking Trails Access",
262 | "Pet-Friendly"
263 | ],
264 | "rates": {
265 | "nightly": 475,
266 | "weekly": 2000
267 | },
268 | "seller_info": {
269 | "name": "Robert Anderson",
270 | "email": "robert@gmail.com",
271 | "phone": "303-555-5555"
272 | },
273 | "images": ["g1.jpg", "g2.jpg", "g3.jpg"],
274 | "is_featured": false,
275 | "createdAt": "2024-01-07T00:00:00.000Z",
276 | "updatedAt": "2024-01-07T00:00:00.000Z"
277 | },
278 | {
279 | "_id": "8",
280 | "owner": "5",
281 | "name": "Ski-In/Ski-Out Chalet",
282 | "type": "Chalet",
283 | "description": "Hit the slopes from this cozy ski-in/ski-out chalet.",
284 | "location": {
285 | "street": "321 Mountain Road",
286 | "city": "Aspen",
287 | "state": "CO",
288 | "zipcode": "81611"
289 | },
290 | "beds": 3,
291 | "baths": 2,
292 | "square_feet": 1800,
293 | "amenities": [
294 | "Ski Equipment Storage",
295 | "Fireplace",
296 | "Balcony/Patio",
297 | "Smart TV"
298 | ],
299 | "rates": {
300 | "nightly": 300,
301 | "weekly": 1100
302 | },
303 | "seller_info": {
304 | "name": "Jennifer Martin",
305 | "email": "jennifer@gmail.com",
306 | "phone": "970-555-5555"
307 | },
308 | "images": ["h1.jpg", "h2.jpg", "h3.jpg"],
309 | "is_featured": false,
310 | "createdAt": "2024-01-08T00:00:00.000Z",
311 | "updatedAt": "2024-01-08T00:00:00.000Z"
312 | },
313 | {
314 | "_id": "9",
315 | "owner": "6",
316 | "name": "Mountain View Retreat",
317 | "type": "House",
318 | "description": "Enjoy stunning mountain views from this spacious retreat.",
319 | "location": {
320 | "street": "600 Summit Drive",
321 | "city": "Boulder",
322 | "state": "CO",
323 | "zipcode": "80301"
324 | },
325 | "beds": 4,
326 | "baths": 3,
327 | "square_feet": 2400,
328 | "amenities": [
329 | "Mountain View",
330 | "Hiking Trails Access",
331 | "Air Conditioning",
332 | "Smart TV",
333 | "Outdoor Grill/BBQ"
334 | ],
335 | "rates": {
336 | "weekly": 1000,
337 | "monthly": 3800
338 | },
339 | "seller_info": {
340 | "name": "Lisa Taylor",
341 | "email": "lisa@gmail.com",
342 | "phone": "303-555-5555"
343 | },
344 | "images": ["i1.jpg", "i2.jpg", "i3.jpg"],
345 | "is_featured": false,
346 | "createdAt": "2024-01-09T00:00:00.000Z",
347 | "updatedAt": "2024-01-09T00:00:00.000Z"
348 | },
349 | {
350 | "_id": "10",
351 | "owner": "7",
352 | "name": "Historic Downtown Loft",
353 | "type": "Apartment",
354 | "description": "Step back in time with a stay in this historic downtown loft.",
355 | "location": {
356 | "street": "123 History Lane",
357 | "city": "Philadelphia",
358 | "state": "PA",
359 | "zipcode": "19101"
360 | },
361 | "beds": 2,
362 | "baths": 1,
363 | "square_feet": 1200,
364 | "amenities": [
365 | "High-Speed Internet",
366 | "Air Conditioning",
367 | "Smart TV",
368 | "Coffee Maker"
369 | ],
370 | "rates": {
371 | "weekly": 550,
372 | "monthly": 2100
373 | },
374 | "seller_info": {
375 | "name": "Matthew Harris",
376 | "email": "matthew@gmail.com",
377 | "phone": "215-555-5555"
378 | },
379 | "images": ["j1.jpg", "j2.jpg", "j3.jpg"],
380 | "is_featured": false,
381 | "createdAt": "2024-01-10T00:00:00.000Z",
382 | "updatedAt": "2024-01-10T00:00:00.000Z"
383 | }
384 | ]
385 |
--------------------------------------------------------------------------------
/properties2.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "owner": "1",
4 | "name": "Boston Commons Retreat",
5 | "type": "Apartment",
6 | "description": "This is a beautiful apartment located near the commons. It is a 2 bedroom apartment with a full kitchen and bathroom. It is available for weekly or monthly rentals.",
7 | "location": {
8 | "street": "120 Tremont Street",
9 | "city": "Boston",
10 | "state": "MA",
11 | "zipcode": "02108"
12 | },
13 | "beds": 2,
14 | "baths": 1,
15 | "square_feet": 1500,
16 | "amenities": [
17 | "Wifi",
18 | "Full kitchen",
19 | "Washer & Dryer",
20 | "Free Parking",
21 | "Hot Tub",
22 | "24/7 Security",
23 | "Wheelchair Accessible",
24 | "Elevator Access",
25 | "Dishwasher",
26 | "Gym/Fitness Center",
27 | "Air Conditioning",
28 | "Balcony/Patio",
29 | "Smart TV",
30 | "Coffee Maker"
31 | ],
32 | "rates": {
33 | "weekly": 1100,
34 | "monthly": 4200
35 | },
36 | "seller_info": {
37 | "name": "John Doe",
38 | "email": "john@gmail.com",
39 | "phone": "617-555-5555"
40 | },
41 | "images": ["a1.jpg", "a2.jpg", "a3.jpg"],
42 | "is_featured": false,
43 | "createdAt": "2024-01-01T00:00:00.000Z",
44 | "updatedAt": "2024-01-01T00:00:00.000Z"
45 | },
46 | {
47 | "owner": "1",
48 | "name": "Cozy Downtown Loft",
49 | "type": "Apartment",
50 | "description": "A cozy downtown loft with great city views.",
51 | "location": {
52 | "street": "45 Main Street",
53 | "city": "New York",
54 | "state": "NY",
55 | "zipcode": "10001"
56 | },
57 | "beds": 1,
58 | "baths": 1,
59 | "square_feet": 1800,
60 | "amenities": [
61 | "Wifi",
62 | "Full kitchen",
63 | "Washer & Dryer",
64 | "Free Parking",
65 | "Hot Tub",
66 | "24/7 Security",
67 | "Wheelchair Accessible",
68 | "Elevator Access",
69 | "Dishwasher",
70 | "High-Speed Internet",
71 | "Air Conditioning",
72 | "Smart TV",
73 | "Outdoor Grill/BBQ"
74 | ],
75 | "rates": {
76 | "weekly": 1000,
77 | "monthly": 4000
78 | },
79 | "seller_info": {
80 | "name": "Jane Smith",
81 | "email": "jane@gmail.com",
82 | "phone": "212-555-5555"
83 | },
84 | "images": ["b1.jpg", "b2.jpg", "b3.jpg"],
85 | "is_featured": false,
86 | "createdAt": "2024-01-02T00:00:00.000Z",
87 | "updatedAt": "2024-01-02T00:00:00.000Z"
88 | },
89 | {
90 | "owner": "2",
91 | "name": "Luxury Condo with a View",
92 | "type": "Condo",
93 | "description": "Experience luxury in this stunning condo with breathtaking views.",
94 | "location": {
95 | "street": "500 Lux Lane",
96 | "city": "Los Angeles",
97 | "state": "CA",
98 | "zipcode": "90001"
99 | },
100 | "beds": 3,
101 | "baths": 2,
102 | "square_feet": 2200,
103 | "amenities": [
104 | "Wifi",
105 | "Full kitchen",
106 | "Washer & Dryer",
107 | "Free Parking",
108 | "Hot Tub",
109 | "24/7 Security",
110 | "Wheelchair Accessible",
111 | "Elevator Access",
112 | "Dishwasher",
113 | "Swimming Pool",
114 | "Gym/Fitness Center",
115 | "Air Conditioning",
116 | "Smart TV",
117 | "Coffee Maker"
118 | ],
119 | "rates": {
120 | "nightly": 200,
121 | "weekly": 750,
122 | "monthly": 3300
123 | },
124 | "seller_info": {
125 | "name": "David Johnson",
126 | "email": "david@gmail.com",
127 | "phone": "213-555-5555"
128 | },
129 | "images": ["c1.jpg", "c2.jpg", "c3.jpg"],
130 | "is_featured": false,
131 | "createdAt": "2024-01-03T00:00:00.000Z",
132 | "updatedAt": "2024-01-03T00:00:00.000Z"
133 | },
134 | {
135 | "owner": "2",
136 | "name": "Charming Cottage Getaway",
137 | "type": "Cottage Or Cabin",
138 | "description": "Escape to this charming cottage for a peaceful retreat.",
139 | "location": {
140 | "street": "123 Countryside Lane",
141 | "city": "Austin",
142 | "state": "TX",
143 | "zipcode": "78701"
144 | },
145 | "beds": 1,
146 | "baths": 1,
147 | "square_feet": 900,
148 | "amenities": [
149 | "Fireplace",
150 | "Outdoor Grill/BBQ",
151 | "Balcony/Patio",
152 | "Coffee Maker"
153 | ],
154 | "rates": {
155 | "weekly": 2000
156 | },
157 | "seller_info": {
158 | "name": "Emily Davis",
159 | "email": "emily@gmail.com",
160 | "phone": "512-555-5555"
161 | },
162 | "images": ["d1.jpg", "d2.jpg", "d3.jpg"],
163 | "is_featured": false,
164 | "createdAt": "2024-01-04T00:00:00.000Z",
165 | "updatedAt": "2024-01-04T00:00:00.000Z"
166 | },
167 | {
168 | "owner": "3",
169 | "name": "Modern Downtown Studio",
170 | "type": "Studio",
171 | "description": "Stay in style in this modern downtown studio apartment.",
172 | "location": {
173 | "street": "75 Urban Avenue",
174 | "city": "Chicago",
175 | "state": "IL",
176 | "zipcode": "60601"
177 | },
178 | "beds": 1,
179 | "baths": 1,
180 | "square_feet": 900,
181 | "amenities": [
182 | "High-Speed Internet",
183 | "Smart TV",
184 | "Air Conditioning",
185 | "Gym/Fitness Center",
186 | "Outdoor Grill/BBQ"
187 | ],
188 | "rates": {
189 | "weekly": 1100,
190 | "monthly": 4200
191 | },
192 | "seller_info": {
193 | "name": "Michael Brown",
194 | "email": "michael@gmail.com",
195 | "phone": "312-555-5555"
196 | },
197 | "images": ["e1.jpg", "e2.jpg", "e3.jpg"],
198 | "is_featured": true,
199 | "createdAt": "2024-01-05T00:00:00.000Z",
200 | "updatedAt": "2024-01-05T00:00:00.000Z"
201 | },
202 | {
203 | "owner": "3",
204 | "name": "Seaside Retreat",
205 | "type": "House",
206 | "description": "Escape to this seaside house for a relaxing getaway.",
207 | "location": {
208 | "street": "456 Oceanfront Drive",
209 | "city": "Miami",
210 | "state": "FL",
211 | "zipcode": "33101"
212 | },
213 | "beds": 4,
214 | "baths": 3,
215 | "square_feet": 2800,
216 | "amenities": [
217 | "Beach Access",
218 | "Swimming Pool",
219 | "Balcony/Patio",
220 | "Smart TV",
221 | "Outdoor Grill/BBQ"
222 | ],
223 | "rates": {
224 | "nightly": 500,
225 | "weekly": 2500
226 | },
227 | "seller_info": {
228 | "name": "Sarah Wilson",
229 | "email": "sarah@gmail.com",
230 | "phone": "305-555-5555"
231 | },
232 | "images": ["f1.jpg", "f2.jpg", "f3.jpg"],
233 | "is_featured": true,
234 | "createdAt": "2024-01-06T00:00:00.000Z",
235 | "updatedAt": "2024-01-06T00:00:00.000Z"
236 | },
237 | {
238 | "owner": "4",
239 | "name": "Rustic Cabin in the Woods",
240 | "type": "Cottage Or Cabin",
241 | "description": "Experience nature in this cozy rustic cabin.",
242 | "location": {
243 | "street": "789 Forest Lane",
244 | "city": "Denver",
245 | "state": "CO",
246 | "zipcode": "80201"
247 | },
248 | "beds": 2,
249 | "baths": 1,
250 | "square_feet": 1100,
251 | "amenities": [
252 | "Fireplace",
253 | "Outdoor Grill/BBQ",
254 | "Hiking Trails Access",
255 | "Pet-Friendly"
256 | ],
257 | "rates": {
258 | "nightly": 475,
259 | "weekly": 2000
260 | },
261 | "seller_info": {
262 | "name": "Robert Anderson",
263 | "email": "robert@gmail.com",
264 | "phone": "303-555-5555"
265 | },
266 | "images": ["g1.jpg", "g2.jpg", "g3.jpg"],
267 | "is_featured": false,
268 | "createdAt": "2024-01-07T00:00:00.000Z",
269 | "updatedAt": "2024-01-07T00:00:00.000Z"
270 | },
271 | {
272 | "owner": "5",
273 | "name": "Ski-In/Ski-Out Chalet",
274 | "type": "Chalet",
275 | "description": "Hit the slopes from this cozy ski-in/ski-out chalet.",
276 | "location": {
277 | "street": "321 Mountain Road",
278 | "city": "Aspen",
279 | "state": "CO",
280 | "zipcode": "81611"
281 | },
282 | "beds": 3,
283 | "baths": 2,
284 | "square_feet": 1800,
285 | "amenities": [
286 | "Ski Equipment Storage",
287 | "Fireplace",
288 | "Balcony/Patio",
289 | "Smart TV"
290 | ],
291 | "rates": {
292 | "nightly": 300,
293 | "weekly": 1100
294 | },
295 | "seller_info": {
296 | "name": "Jennifer Martin",
297 | "email": "jennifer@gmail.com",
298 | "phone": "970-555-5555"
299 | },
300 | "images": ["h1.jpg", "h2.jpg", "h3.jpg"],
301 | "is_featured": false,
302 | "createdAt": "2024-01-08T00:00:00.000Z",
303 | "updatedAt": "2024-01-08T00:00:00.000Z"
304 | },
305 | {
306 | "owner": "6",
307 | "name": "Mountain View Retreat",
308 | "type": "House",
309 | "description": "Enjoy stunning mountain views from this spacious retreat.",
310 | "location": {
311 | "street": "600 Summit Drive",
312 | "city": "Boulder",
313 | "state": "CO",
314 | "zipcode": "80301"
315 | },
316 | "beds": 4,
317 | "baths": 3,
318 | "square_feet": 2400,
319 | "amenities": [
320 | "Mountain View",
321 | "Hiking Trails Access",
322 | "Air Conditioning",
323 | "Smart TV",
324 | "Outdoor Grill/BBQ"
325 | ],
326 | "rates": {
327 | "weekly": 1000,
328 | "monthly": 3800
329 | },
330 | "seller_info": {
331 | "name": "Lisa Taylor",
332 | "email": "lisa@gmail.com",
333 | "phone": "303-555-5555"
334 | },
335 | "images": ["i1.jpg", "i2.jpg", "i3.jpg"],
336 | "is_featured": false,
337 | "createdAt": "2024-01-09T00:00:00.000Z",
338 | "updatedAt": "2024-01-09T00:00:00.000Z"
339 | },
340 | {
341 | "owner": "7",
342 | "name": "Historic Downtown Loft",
343 | "type": "Apartment",
344 | "description": "Step back in time with a stay in this historic downtown loft.",
345 | "location": {
346 | "street": "123 History Lane",
347 | "city": "Philadelphia",
348 | "state": "PA",
349 | "zipcode": "19101"
350 | },
351 | "beds": 2,
352 | "baths": 1,
353 | "square_feet": 1200,
354 | "amenities": [
355 | "High-Speed Internet",
356 | "Air Conditioning",
357 | "Smart TV",
358 | "Coffee Maker"
359 | ],
360 | "rates": {
361 | "weekly": 550,
362 | "monthly": 2100
363 | },
364 | "seller_info": {
365 | "name": "Matthew Harris",
366 | "email": "matthew@gmail.com",
367 | "phone": "215-555-5555"
368 | },
369 | "images": ["j1.jpg", "j2.jpg", "j3.jpg"],
370 | "is_featured": false,
371 | "createdAt": "2024-01-10T00:00:00.000Z",
372 | "updatedAt": "2024-01-10T00:00:00.000Z"
373 | }
374 | ]
375 |
--------------------------------------------------------------------------------
/public/images/screen.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syedrizvinet/nextjs-property-pulse/64cd9ab879525c7d55ca7de838d0cee96930c400/public/images/screen.jpg
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
5 | './components/**/*.{js,ts,jsx,tsx,mdx}',
6 | './app/**/*.{js,ts,jsx,tsx,mdx}',
7 | ],
8 | theme: {
9 | extend: {
10 | fontFamily: {
11 | sans: ['Poppins', 'sans-serif'],
12 | },
13 | gridTemplateColumns: {
14 | '70/30': '70% 28%',
15 | },
16 | },
17 | },
18 | plugins: [],
19 | };
20 |
--------------------------------------------------------------------------------
/utils/authOptions.js:
--------------------------------------------------------------------------------
1 | import connectDB from '@/config/database';
2 | import User from '@/models/User';
3 |
4 | import GoogleProvider from 'next-auth/providers/google';
5 |
6 | export const authOptions = {
7 | providers: [
8 | GoogleProvider({
9 | clientId: process.env.GOOGLE_CLIENT_ID,
10 | clientSecret: process.env.GOOGLE_CLIENT_SECRET,
11 | authorization: {
12 | params: {
13 | prompt: 'consent',
14 | access_type: 'offline',
15 | response_type: 'code',
16 | },
17 | },
18 | }),
19 | ],
20 | callbacks: {
21 | // Invoked on successful signin
22 | async signIn({ profile }) {
23 | // 1. Connect to database
24 | await connectDB();
25 | // 2. Check if user exists
26 | const userExists = await User.findOne({ email: profile.email });
27 | // 3. If not, then add user to database
28 | if (!userExists) {
29 | // Truncate user name if too long
30 | const username = profile.name.slice(0, 20);
31 |
32 | await User.create({
33 | email: profile.email,
34 | username,
35 | image: profile.picture,
36 | });
37 | }
38 | // 4. Return true to allow sign in
39 | return true;
40 | },
41 | // Modifies the session object
42 | async session({ session }) {
43 | // 1. Get user from database
44 | const user = await User.findOne({ email: session.user.email });
45 | // 2. Assign the user id to the session
46 | session.user.id = user._id.toString();
47 | // 3. return session
48 | return session;
49 | },
50 | },
51 | };
52 |
--------------------------------------------------------------------------------
/utils/convertToObject.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Converts a Mongoose lean document into a serializable plain JavaScript object.
3 | *
4 | * @param {Object} leanDocument - The Mongoose lean document to be converted.
5 | * @returns {Object} A plain JavaScript object that is a serializable representation of the input document.
6 | */
7 |
8 | export function convertToSerializeableObject(leanDocument) {
9 | for (const key of Object.keys(leanDocument)) {
10 | if (leanDocument[key].toJSON && leanDocument[key].toString)
11 | leanDocument[key] = leanDocument[key].toString();
12 | }
13 | return leanDocument;
14 | }
15 |
--------------------------------------------------------------------------------
/utils/getSessionUser.js:
--------------------------------------------------------------------------------
1 | import { getServerSession } from 'next-auth/next';
2 | import { authOptions } from '@/utils/authOptions';
3 |
4 | export const getSessionUser = async () => {
5 | const session = await getServerSession(authOptions);
6 |
7 | if (!session || !session.user) {
8 | return null;
9 | }
10 |
11 | return {
12 | user: session.user,
13 | userId: session.user.id,
14 | };
15 | };
16 |
--------------------------------------------------------------------------------