├── app ├── components │ ├── README.md │ ├── Loader.tsx │ ├── Avatar.tsx │ ├── Container.tsx │ ├── Navbar │ │ ├── MenuItem.tsx │ │ ├── Logo.tsx │ │ ├── Navbar.tsx │ │ ├── Search.tsx │ │ ├── Categories.tsx │ │ └── UserMenu.tsx │ ├── ClientOnly.tsx │ ├── Heading.tsx │ ├── inputs │ │ ├── Calendar.tsx │ │ ├── CategoryInput.tsx │ │ ├── CountrySelect.tsx │ │ ├── ImageUpload.tsx │ │ ├── Counter.tsx │ │ └── input.tsx │ ├── listings │ │ ├── ListingCategory.tsx │ │ ├── ListingHead.tsx │ │ ├── ListingReservation.tsx │ │ ├── ListingInfo.tsx │ │ └── ListingCard.tsx │ ├── EmptyState.tsx │ ├── HeartButton.tsx │ ├── Button.tsx │ ├── Map.tsx │ ├── CategoryBox.tsx │ └── modals │ │ ├── RegisterModal.tsx │ │ ├── LoginModal.tsx │ │ ├── Modal.tsx │ │ ├── SearchModal.tsx │ │ └── RentModal.tsx ├── loading.tsx ├── providers │ └── ToasterProvider.tsx ├── libs │ └── prismadb.ts ├── hooks │ ├── useRentModal.ts │ ├── useLoginModal.ts │ ├── useSearchModal.ts │ ├── useRegisterModal.ts │ ├── useCountries.ts │ └── useFavorite.ts ├── globals.css ├── error.tsx ├── api │ ├── register │ │ └── route.ts │ ├── listings │ │ ├── [listingId] │ │ │ └── route.ts │ │ └── route.ts │ ├── reservations │ │ ├── [reservationId] │ │ │ └── route.ts │ │ └── route.ts │ └── favorites │ │ └── [listingId] │ │ └── route.ts ├── types │ └── index.ts ├── actions │ ├── getFavoriteListings.ts │ ├── getListingById.ts │ ├── getCurrentUser.ts │ ├── getReservations.ts │ └── getListings.ts ├── favorites │ ├── page.tsx │ └── FavoritesClient.tsx ├── listings │ └── [listingId] │ │ ├── page.tsx │ │ └── ListingClient.tsx ├── properties │ ├── page.tsx │ └── PropertiesClient.tsx ├── trips │ ├── page.tsx │ └── TripsClient.tsx ├── reservations │ ├── page.tsx │ └── ReservationClient.tsx ├── layout.tsx ├── page.tsx └── favicon.ico ├── .eslintrc.json ├── public ├── images │ ├── logo.png │ └── placeholder.jpg ├── vercel.svg ├── thirteen.svg └── next.svg ├── postcss.config.js ├── .vscode └── settings.json ├── middleware.ts ├── tailwind.config.js ├── next.config.js ├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json ├── pages └── api │ └── auth │ └── [...nextauth].ts ├── README.md └── prisma └── schema.prisma /app/components/README.md: -------------------------------------------------------------------------------- 1 | Components Folder 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stanwukong/AirBnb_FullStack/HEAD/public/images/logo.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/images/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stanwukong/AirBnb_FullStack/HEAD/public/images/placeholder.jpg -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /app/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from "./components/Loader"; 2 | 3 | const Loading = () => { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | export default Loading; -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'next-auth/middleware'; 2 | 3 | export const config = { 4 | matcher: [ 5 | '/trips', 6 | '/reservations', 7 | '/properties', 8 | '/favorites' 9 | ] 10 | } -------------------------------------------------------------------------------- /app/providers/ToasterProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Toaster } from 'react-hot-toast' 4 | 5 | const ToasterProvider = () => { 6 | return ( 7 | 11 | ) 12 | } 13 | 14 | export default ToasterProvider; -------------------------------------------------------------------------------- /app/libs/prismadb.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client" 2 | 3 | declare global { 4 | var prisma: PrismaClient | undefined 5 | } 6 | 7 | const client = globalThis.prisma || new PrismaClient() 8 | if (process.env.NODE_ENV !== "production") globalThis.prisma = client 9 | 10 | export default client; -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./app/**/*.{js,ts,jsx,tsx}", 5 | "./pages/**/*.{js,ts,jsx,tsx}", 6 | "./components/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | } 13 | 14 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | images: { 7 | domains: [ 8 | "avatars.githubusercontent.com", 9 | "lh3.googleusercontent.com", 10 | "res.cloudinary.com", 11 | ] 12 | } 13 | } 14 | 15 | module.exports = nextConfig 16 | -------------------------------------------------------------------------------- /app/hooks/useRentModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface RentModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | const useRentModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })); 14 | 15 | export default useRentModal; -------------------------------------------------------------------------------- /app/hooks/useLoginModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface LoginModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | const useLoginModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })); 14 | 15 | export default useLoginModal; -------------------------------------------------------------------------------- /app/hooks/useSearchModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface SearchModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | const useSearchModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })); 14 | 15 | export default useSearchModal; -------------------------------------------------------------------------------- /app/hooks/useRegisterModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface RegisterModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | const useRegisterModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })); 14 | 15 | export default useRegisterModal; -------------------------------------------------------------------------------- /app/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { PuffLoader } from "react-spinners"; 4 | 5 | const Loader = () => { 6 | return ( 7 |
16 | 20 |
21 | ); 22 | } 23 | 24 | export default Loader; -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | height: 100%; 9 | } 10 | 11 | .leaflet-bottom, 12 | .leaflet-control, 13 | .leaflet-pane, 14 | .leaflet-top { 15 | z-index: 0 !important; 16 | } 17 | 18 | .rdrMonth { 19 | width: 100% !important; 20 | } 21 | 22 | .rdrCalendarWrapper { 23 | width: 100% !important; 24 | font-size: 16px !important; 25 | } -------------------------------------------------------------------------------- /app/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image from "next/image"; 4 | 5 | 6 | interface AvatarProps { 7 | src?: string | null | undefined; 8 | 9 | } 10 | const Avatar: React.FC = ({ 11 | src 12 | }) => { 13 | return ( 14 | Avatar 21 | ); 22 | } 23 | 24 | export default Avatar; -------------------------------------------------------------------------------- /app/components/Container.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | 4 | interface ContainerProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | 9 | const Container: React.FC = ({ 10 | children 11 | }) => { 12 | return ( 13 |
23 | {children} 24 |
25 | ); 26 | } 27 | 28 | export default Container; -------------------------------------------------------------------------------- /app/components/Navbar/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | interface MenuItemProps { 4 | onClick: () => void, 5 | label: string 6 | } 7 | 8 | const MenuItem: React.FC = ({ 9 | onClick, 10 | label 11 | }) => { 12 | return ( 13 |
23 | {label} 24 |
25 | ); 26 | } 27 | 28 | export default MenuItem; -------------------------------------------------------------------------------- /app/error.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect } from "react" 4 | import EmptyState from "./components/EmptyState" 5 | 6 | interface ErrorStateProps { 7 | error: Error 8 | } 9 | 10 | const ErrorState: React.FC = ({ 11 | error 12 | }) => { 13 | useEffect(() => { 14 | console.log(error) 15 | }, [error]) 16 | 17 | return ( 18 | 22 | ) 23 | } 24 | 25 | export default ErrorState; -------------------------------------------------------------------------------- /app/api/register/route.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt" 2 | import prisma from "../../libs/prismadb" 3 | import { NextResponse } from "next/server" 4 | 5 | export async function POST(request: Request) { 6 | const body = await request.json() 7 | const { email, name, password } = body 8 | 9 | const hashedPassword = await bcrypt.hash(password, 12) 10 | 11 | const user = await prisma.user.create({ 12 | data: { 13 | email, 14 | name, 15 | hashedPassword, 16 | }, 17 | }) 18 | 19 | return NextResponse.json(user) 20 | } 21 | -------------------------------------------------------------------------------- /app/components/ClientOnly.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect, useState } from "react"; 3 | 4 | interface ClientOnlyProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | const ClientOnly: React.FC = ({ 9 | children 10 | }) => { 11 | 12 | const [isMounted, setIsMounted] = useState(false); 13 | 14 | useEffect(() => { 15 | setIsMounted(true); 16 | }, []); 17 | 18 | if (!isMounted) { 19 | return null; 20 | } 21 | 22 | return ( 23 | <> 24 | {children} 25 | 26 | ); 27 | } 28 | 29 | export default ClientOnly; -------------------------------------------------------------------------------- /app/components/Heading.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | interface HeadingProps { 4 | title: string; 5 | subtitle?: string; 6 | center?: boolean; 7 | } 8 | const Heading: React.FC = ({ 9 | title, 10 | subtitle, 11 | center 12 | }) => { 13 | return ( 14 |
15 |
16 | {title} 17 |
18 |
19 | {subtitle} 20 |
21 |
22 | ); 23 | } 24 | 25 | export default Heading; -------------------------------------------------------------------------------- /app/components/Navbar/Logo.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Image from "next/image" 4 | import { useRouter } from "next/navigation" 5 | 6 | const Logo = () => { 7 | const router = useRouter() 8 | 9 | return ( 10 |
11 | router.push("/")} 13 | alt="Logo" 14 | className="md:block cursor-pointer" 15 | height="100" 16 | width="100" 17 | src="/images/logo.png" 18 | /> 19 |
20 | ) 21 | } 22 | 23 | export default Logo 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /app/hooks/useCountries.ts: -------------------------------------------------------------------------------- 1 | import countries from 'world-countries'; 2 | 3 | 4 | const formattedCountries = countries.map((country) => ({ 5 | value: country.cca2, 6 | label: country.name.common, 7 | flag: country.flag, 8 | latlng: country.latlng, 9 | region: country.region, 10 | })); 11 | 12 | const useCountries = () => { 13 | const getAll = () => formattedCountries; 14 | 15 | const getByValue = (value: string) => { 16 | return formattedCountries.find((item) => item.value === value); 17 | } 18 | 19 | return { 20 | getAll, 21 | getByValue, 22 | } 23 | } 24 | 25 | export default useCountries; -------------------------------------------------------------------------------- /app/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Listing, Reservation, User } from "@prisma/client"; 2 | export type SafeListing = Omit< 3 | Listing, 4 | 'createdAt' 5 | > & { 6 | createdAt: string; 7 | } 8 | 9 | export type SafeReservation = Omit< 10 | Reservation, 11 | 'createdAt' | 'startDate' | 'endDate' | 'listing' 12 | > & { 13 | createdAt: string; 14 | startDate: string; 15 | endDate: string; 16 | listing: SafeListing 17 | } 18 | 19 | export type SafeUser = Omit< 20 | User, 21 | 'createdAt' | 'updatedAt' | 'emailVerified' 22 | > & { 23 | createdAt: string; 24 | updatedAt: string; 25 | emailVerified: string | null; 26 | }; -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/actions/getFavoriteListings.ts: -------------------------------------------------------------------------------- 1 | import prisma from '../libs/prismadb'; 2 | 3 | import getCurrentUser from './getCurrentUser'; 4 | 5 | export default async function getFavoriteListings() { 6 | try { 7 | const currentUser = await getCurrentUser(); 8 | 9 | if (!currentUser) { 10 | return []; 11 | } 12 | 13 | const favorites = await prisma.listing.findMany({ 14 | where: { 15 | id: { 16 | in: [...(currentUser.favoriteIds || [])] 17 | } 18 | } 19 | }); 20 | 21 | const safefavorites = favorites.map( 22 | (favorite) => ({ 23 | ...favorite, 24 | createdAt: favorite.createdAt.toISOString() 25 | }) 26 | ); 27 | 28 | return safefavorites; 29 | 30 | } catch (error: any) { 31 | throw new Error(error) 32 | } 33 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "./*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /app/components/inputs/Calendar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { DateRange, Range, RangeKeyDict } from "react-date-range"; 4 | 5 | import 'react-date-range/dist/styles.css'; 6 | import 'react-date-range/dist/theme/default.css'; 7 | 8 | interface CalendarProps { 9 | value: Range; 10 | onChange: (value: RangeKeyDict) => void; 11 | disabledDates?: Date[] 12 | } 13 | 14 | const Calendar: React.FC = ({ 15 | value, 16 | onChange, 17 | disabledDates 18 | }) => { 19 | return ( 20 | 30 | ); 31 | } 32 | 33 | export default Calendar; -------------------------------------------------------------------------------- /app/api/listings/[listingId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import getCurrentUser from "../../../actions/getCurrentUser"; 4 | import prisma from "../../../libs/prismadb"; 5 | 6 | interface IParams { 7 | listingId?: string; 8 | } 9 | 10 | export async function DELETE( 11 | request: Request, 12 | { params }: { params: IParams } 13 | ) { 14 | const currentUser = await getCurrentUser(); 15 | 16 | if (!currentUser) { 17 | return NextResponse.error(); 18 | } 19 | 20 | const { listingId } = params; 21 | 22 | if (!listingId || typeof listingId !== "string") { 23 | throw new Error('Invalid ID'); 24 | } 25 | 26 | const listing = await prisma.listing.deleteMany({ 27 | where: { 28 | id: listingId, 29 | userId: currentUser.id 30 | } 31 | }); 32 | 33 | return NextResponse.json(listing); 34 | } -------------------------------------------------------------------------------- /app/components/listings/ListingCategory.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { IconType } from "react-icons"; 4 | 5 | interface ListingCategoryProps { 6 | icon: IconType; 7 | label: string; 8 | description: string; 9 | } 10 | const ListingCategory: React.FC = ({ 11 | icon: Icon, 12 | label, 13 | description, 14 | }) => { 15 | return ( 16 |
17 |
18 | 19 |
20 |
21 | {label} 22 |
23 |
24 | {description} 25 |
26 |
27 |
28 |
29 | ); 30 | } 31 | 32 | export default ListingCategory; -------------------------------------------------------------------------------- /app/components/inputs/CategoryInput.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { IconType } from "react-icons"; 4 | 5 | interface CategoryInputProps { 6 | icon: IconType; 7 | label: string; 8 | selected?: boolean; 9 | onClick: (value: string) => void; 10 | } 11 | const CategoryInput: React.FC = ({ 12 | icon: Icon, 13 | label, 14 | selected, 15 | onClick, 16 | }) => { 17 | return ( 18 |
onClick(label)} 20 | className={` 21 | rounded-xl 22 | border-2 23 | p-4 24 | flex 25 | flex-col 26 | gap-3 27 | hover:border-black 28 | transition 29 | cursor-pointer 30 | ${selected ? 'border-black' : 'border-neutral-200'} 31 | `} 32 | 33 | > 34 | 35 |
{label}
36 |
37 | ); 38 | } 39 | 40 | export default CategoryInput; -------------------------------------------------------------------------------- /app/favorites/page.tsx: -------------------------------------------------------------------------------- 1 | import EmptyState from "../components/EmptyState"; 2 | import ClientOnly from "../components/ClientOnly"; 3 | 4 | import getCurrentUser from "../actions/getCurrentUser"; 5 | import getFavoriteListings from "../actions/getFavoriteListings"; 6 | import FavoritesClient from "./FavoritesClient"; 7 | 8 | const ListingPage = async () => { 9 | const listings = await getFavoriteListings(); 10 | const currentUser = await getCurrentUser(); 11 | 12 | if (listings.length === 0) { 13 | return ( 14 | 15 | 19 | 20 | ); 21 | } 22 | 23 | return ( 24 | 25 | 29 | 30 | ) 31 | } 32 | 33 | export default ListingPage; -------------------------------------------------------------------------------- /app/api/reservations/[reservationId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import getCurrentUser from "../../../actions/getCurrentUser"; 3 | import prisma from "../../../libs/prismadb"; 4 | 5 | interface IParams { 6 | reservationId?: string; 7 | } 8 | 9 | export async function DELETE( 10 | request: Request, 11 | { params }: { params: IParams } 12 | ) { 13 | const currentUser = await getCurrentUser(); 14 | 15 | if (!currentUser) { 16 | return NextResponse.error(); 17 | } 18 | 19 | const { reservationId } = params; 20 | 21 | if (!reservationId || typeof reservationId !== 'string') { 22 | throw new Error('Invalid ID'); 23 | } 24 | 25 | const reservation = await prisma.reservation.deleteMany({ 26 | where: { 27 | id: reservationId, 28 | OR: [ 29 | { userId: currentUser.id }, 30 | { listing: { userId: currentUser.id }} 31 | ] 32 | } 33 | }); 34 | 35 | return NextResponse.json(reservation); 36 | } -------------------------------------------------------------------------------- /app/actions/getListingById.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../../app/libs/prismadb" 2 | 3 | interface Iparams { 4 | listingId?: string 5 | } 6 | 7 | export default async function getListingById(params: Iparams) { 8 | try { 9 | const { listingId } = params 10 | 11 | const listing = await prisma.listing.findUnique({ 12 | where: { 13 | id: listingId, 14 | }, 15 | include: { 16 | user: true, 17 | }, 18 | }) 19 | 20 | if (!listing) { 21 | return null 22 | } 23 | 24 | return { 25 | ...listing, 26 | createdAt: listing.createdAt.toISOString(), 27 | user: { 28 | ...listing.user, 29 | createdAt: listing.user.createdAt.toISOString(), 30 | updatedAt: listing.user.updatedAt.toISOString(), 31 | emailVerified: listing.user.emailVerified?.toISOString() || null, 32 | }, 33 | } 34 | } catch (error: any) { 35 | throw new Error(error) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/actions/getCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "next-auth/next" 2 | 3 | import { authOptions } from "../../pages/api/auth/[...nextauth]" 4 | import prisma from "../libs/prismadb" 5 | 6 | export async function getSession() { 7 | return await getServerSession(authOptions) 8 | } 9 | 10 | export default async function getCurrentUser() { 11 | try { 12 | const session = await getSession() 13 | if (!session?.user?.email) { 14 | return null 15 | } 16 | const currentUser = await prisma.user.findUnique({ 17 | where: { 18 | email: session.user.email as string, 19 | }, 20 | }) 21 | if (!currentUser) { 22 | return null 23 | } 24 | return { 25 | ...currentUser, 26 | createdAt: currentUser.createdAt.toISOString(), 27 | updatedAt: currentUser.updatedAt.toISOString(), 28 | emailVerified: currentUser.emailVerified?.toISOString() || null, 29 | } 30 | } catch (error: any) { 31 | return null 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/api/reservations/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import prisma from "../../libs/prismadb"; 4 | import getCurrentUser from "../../actions/getCurrentUser"; 5 | 6 | export async function POST( 7 | request: Request 8 | ) { 9 | const currentUser = await getCurrentUser(); 10 | 11 | if (!currentUser) { 12 | return NextResponse.error(); 13 | } 14 | 15 | const body = await request.json(); 16 | 17 | const { 18 | listingId, 19 | startDate, 20 | endDate, 21 | totalPrice 22 | } = body; 23 | 24 | if (!listingId || !startDate || !endDate || !totalPrice) { 25 | return NextResponse.error(); 26 | } 27 | 28 | const listingAndReservation = await prisma.listing.update({ 29 | where: { 30 | id: listingId 31 | }, 32 | data: { 33 | reservations: { 34 | create: { 35 | userId: currentUser.id, 36 | startDate, 37 | endDate, 38 | totalPrice 39 | } 40 | } 41 | } 42 | }); 43 | 44 | return NextResponse.json(listingAndReservation) 45 | } -------------------------------------------------------------------------------- /app/components/Navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Container from '../Container' 4 | import Logo from './Logo' 5 | import Search from './Search' 6 | import UserMenu from './UserMenu' 7 | import { SafeUser } from '../../../app/types'; 8 | import Categories from './Categories'; 9 | 10 | interface NavbarProps { 11 | currentUser?: SafeUser | null; 12 | } 13 | 14 | 15 | const Navbar: React.FC = ({ 16 | currentUser 17 | }) => { 18 | return ( 19 |
20 |
27 | 28 |
37 | 38 | 39 | 40 |
41 |
42 |
43 | 44 |
45 | ) 46 | } 47 | 48 | export default Navbar -------------------------------------------------------------------------------- /app/listings/[listingId]/page.tsx: -------------------------------------------------------------------------------- 1 | import getListingById from "../../actions/getListingById" 2 | import ClientOnly from "../../components/ClientOnly" 3 | import EmptyState from "../../components/EmptyState" 4 | import getCurrentUser from "../../actions/getCurrentUser" 5 | import ListingClient from "./ListingClient" 6 | import getReservations from "../../actions/getReservations" 7 | 8 | interface IParams { 9 | listingId: string 10 | } 11 | 12 | const ListingPage = async ({ params }: { params: IParams }) => { 13 | const listing = await getListingById(params) 14 | const reservations = await getReservations(params) 15 | const currentUser = await getCurrentUser() 16 | 17 | if (!listing) { 18 | return ( 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | return ( 26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | export default ListingPage 33 | -------------------------------------------------------------------------------- /app/components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import Heading from "./Heading"; 5 | import Button from "./Button"; 6 | 7 | interface EmptyStateProps { 8 | title?: string; 9 | subtitle?: string; 10 | showReset?: boolean; 11 | } 12 | 13 | const EmptyState: React.FC = ({ 14 | title = "No exact matches", 15 | subtitle = "Try adjusting your filter or browse all rentals", 16 | showReset 17 | }) => { 18 | const router = useRouter(); 19 | return ( 20 |
30 | 35 |
36 | { showReset && ( 37 |
44 |
45 | ); 46 | } 47 | 48 | export default EmptyState; -------------------------------------------------------------------------------- /app/components/HeartButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AiFillHeart, AiOutlineHeart } from "react-icons/ai"; 4 | import { SafeUser } from "../../app/types"; 5 | import useFavorite from "../../app/hooks/useFavorite"; 6 | 7 | interface HeartButtonProps { 8 | listingId: string; 9 | currentUser?: SafeUser | null; 10 | } 11 | 12 | const HeartButton: React.FC = ({ 13 | listingId, 14 | currentUser 15 | }) => { 16 | const { isFavorite, toggleFavorite } = useFavorite({listingId, currentUser}); 17 | 18 | return ( 19 |
28 | 37 | 43 |
44 | ); 45 | } 46 | 47 | export default HeartButton; -------------------------------------------------------------------------------- /app/properties/page.tsx: -------------------------------------------------------------------------------- 1 | import EmptyState from "../components/EmptyState"; 2 | import ClientOnly from "../components/ClientOnly"; 3 | 4 | import getCurrentUser from "../actions/getCurrentUser"; 5 | import PropertiesClient from "./PropertiesClient"; 6 | import getListings from "../actions/getListings"; 7 | 8 | const PropertiesPage = async () => { 9 | const currentUser = await getCurrentUser(); 10 | 11 | if(!currentUser) { 12 | return ( 13 | 14 | 18 | 19 | ); 20 | } 21 | 22 | const listings = await getListings({ 23 | userId: currentUser.id 24 | }); 25 | 26 | if (listings.length === 0) { 27 | return ( 28 | 29 | 33 | 34 | ) 35 | } 36 | 37 | return ( 38 | 39 | 43 | 44 | ) 45 | } 46 | 47 | export default PropertiesPage; -------------------------------------------------------------------------------- /app/trips/page.tsx: -------------------------------------------------------------------------------- 1 | import EmptyState from "../components/EmptyState"; 2 | import ClientOnly from "../components/ClientOnly"; 3 | 4 | import getCurrentUser from "../actions/getCurrentUser"; 5 | import getReservations from "../actions/getReservations"; 6 | import TripsClient from "./TripsClient"; 7 | 8 | const TripsPage = async () => { 9 | const currentUser = await getCurrentUser(); 10 | 11 | if(!currentUser) { 12 | return ( 13 | 14 | 18 | 19 | ); 20 | } 21 | 22 | const reservations = await getReservations({ 23 | userId: currentUser.id 24 | }); 25 | 26 | if (reservations.length === 0) { 27 | return ( 28 | 29 | 33 | 34 | ) 35 | } 36 | 37 | return ( 38 | 39 | 43 | 44 | ) 45 | } 46 | 47 | export default TripsPage; -------------------------------------------------------------------------------- /public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Oluwafemi Ajanaku 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/favorites/FavoritesClient.tsx: -------------------------------------------------------------------------------- 1 | import { SafeListing, SafeUser } from "../types"; 2 | import Container from "../components/Container"; 3 | import Heading from "../components/Heading"; 4 | import ListingCard from "../components/listings/ListingCard"; 5 | 6 | 7 | interface FavoritesClientProps { 8 | listings: SafeListing[]; 9 | currentUser: SafeUser | null; 10 | } 11 | 12 | const FavoritesClient: React.FC = ({ 13 | listings, 14 | currentUser 15 | }) => { 16 | return ( 17 | 18 | 22 |
35 | {listings.map( 36 | (listing) => ( 37 | 42 | ) 43 | )} 44 |
45 |
46 | ); 47 | } 48 | 49 | export default FavoritesClient; -------------------------------------------------------------------------------- /app/api/listings/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | import prisma from "../../libs/prismadb" 3 | import getCurrentUser from "../../actions/getCurrentUser" 4 | 5 | export async function POST(request: Request) { 6 | const currentUser = await getCurrentUser() 7 | 8 | if (!currentUser) { 9 | return NextResponse.redirect("/login") 10 | } 11 | 12 | const body = await request.json() 13 | const { 14 | title, 15 | description, 16 | imageSrc, 17 | category, 18 | roomCount, 19 | bathroomCount, 20 | guestCount, 21 | location, 22 | price, 23 | } = body 24 | 25 | Object.keys(body).forEach((value: any) => { 26 | if (!body[value]) { 27 | NextResponse.error() 28 | } 29 | }) 30 | 31 | const listing = await prisma.listing.create({ 32 | data: { 33 | title, 34 | description, 35 | imageSrc, 36 | category, 37 | roomCount, 38 | bathroomCount, 39 | guestCount, 40 | locationValue: location.value, 41 | price: parseInt(price, 10), 42 | userId: currentUser.id, 43 | }, 44 | }) 45 | 46 | return NextResponse.json(listing) 47 | } 48 | -------------------------------------------------------------------------------- /app/reservations/page.tsx: -------------------------------------------------------------------------------- 1 | import EmptyState from "../components/EmptyState"; 2 | import ClientOnly from "../components/ClientOnly"; 3 | 4 | import getCurrentUser from "../actions/getCurrentUser"; 5 | import getReservations from "../actions/getReservations"; 6 | import ReservationClient from "./ReservationClient"; 7 | 8 | const ReservationsPage = async () => { 9 | const currentUser = await getCurrentUser(); 10 | 11 | if (!currentUser) { 12 | return ( 13 | 14 | 18 | 19 | ); 20 | } 21 | 22 | const reservations = await getReservations({ 23 | authorId: currentUser.id 24 | }); 25 | 26 | if (reservations.length == 0) { 27 | return ( 28 | 29 | 33 | 34 | ); 35 | } 36 | 37 | return ( 38 | 39 | 43 | 44 | ); 45 | } 46 | 47 | export default ReservationsPage; -------------------------------------------------------------------------------- /app/components/Button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { IconType } from 'react-icons'; 3 | 4 | interface ButtonProps { 5 | label: string; 6 | onClick: (e: React.MouseEvent) => void; 7 | disabled?: boolean; 8 | outline?: boolean; 9 | small?: boolean; 10 | icon?: IconType; 11 | } 12 | const Button: React.FC = ({ 13 | label, 14 | onClick, 15 | disabled, 16 | outline, 17 | small, 18 | icon: Icon 19 | }) => { 20 | return ( 21 | 53 | ); 54 | } 55 | 56 | export default Button; -------------------------------------------------------------------------------- /app/actions/getReservations.ts: -------------------------------------------------------------------------------- 1 | import prisma from '../libs/prismadb'; 2 | 3 | interface IParams { 4 | listingId?: string; 5 | userId?: string; 6 | authorId?: string; 7 | } 8 | 9 | export default async function getReservations( 10 | params: IParams 11 | ) { 12 | try { 13 | const { listingId, userId, authorId } = params; 14 | 15 | const query: any = {}; 16 | 17 | if (listingId) { 18 | query.listingId = listingId; 19 | } 20 | 21 | if (userId) { 22 | query.userId = userId; 23 | } 24 | 25 | if (authorId) { 26 | query.listing = { userId: authorId }; 27 | } 28 | 29 | const reservations = await prisma.reservation.findMany({ 30 | where: query, 31 | include: { 32 | listing: true 33 | }, 34 | orderBy: { 35 | createdAt: 'desc' 36 | } 37 | }); 38 | 39 | const safeReservations = reservations.map( 40 | (reservation) => ({ 41 | ...reservation, 42 | createdAt: reservation.createdAt.toISOString(), 43 | startDate: reservation.startDate.toISOString(), 44 | endDate: reservation.endDate.toISOString(), 45 | listing: { 46 | ...reservation.listing, 47 | createdAt: reservation.listing.createdAt.toISOString() 48 | } 49 | }) 50 | ); 51 | 52 | return safeReservations; 53 | } catch (error: any) { 54 | throw new Error(error); 55 | } 56 | } -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Nunito } from "next/font/google" 2 | 3 | import "./globals.css" 4 | import Navbar from "./components/Navbar/Navbar" 5 | import ClientOnly from "./components/ClientOnly" 6 | import RegisterModal from "./components/modals/RegisterModal" 7 | import ToasterProvider from "./providers/ToasterProvider" 8 | import LoginModal from "./components/modals/LoginModal" 9 | import getCurrentUser from "./actions/getCurrentUser" 10 | import RentModal from "./components/modals/RentModal" 11 | import SearchModal from "./components/modals/SearchModal" 12 | 13 | export const metadata = { 14 | title: "Airbnb", 15 | description: "Airbnb clone", 16 | } 17 | 18 | const font = Nunito({ subsets: ["latin"] }) 19 | 20 | export default async function RootLayout({ 21 | children, 22 | }: { 23 | children: React.ReactNode 24 | }) { 25 | const currentUser = await getCurrentUser(); 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 | {children} 39 |
40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /app/components/listings/ListingHead.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SafeUser } from "../../types"; 4 | import useCountries from "../../hooks/useCountries"; 5 | import Heading from "../Heading"; 6 | import Image from "next/image"; 7 | import HeartButton from "../HeartButton"; 8 | 9 | interface ListingHeadProps { 10 | title: string; 11 | locationValue: string; 12 | imageSrc: string; 13 | id: string; 14 | currentUser?: SafeUser | null; 15 | } 16 | 17 | const ListingHead: React.FC = ({ 18 | title, 19 | locationValue, 20 | imageSrc, 21 | id, 22 | currentUser, 23 | }) => { 24 | const { getByValue } = useCountries(); 25 | 26 | const location = getByValue(locationValue); 27 | return ( 28 | <> 29 | 33 |
42 | {title} 48 |
51 | 55 |
56 |
57 | 58 | ); 59 | } 60 | 61 | export default ListingHead; -------------------------------------------------------------------------------- /app/components/Map.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import L from "leaflet" 4 | import { MapContainer, Marker, TileLayer } from "react-leaflet" 5 | 6 | import "leaflet/dist/leaflet.css" 7 | import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png" 8 | import markerIcon from "leaflet/dist/images/marker-icon.png" 9 | import markerShadow from "leaflet/dist/images/marker-shadow.png" 10 | 11 | // @ts-ignore 12 | delete L.Icon.Default.prototype._getIconUrl 13 | L.Icon.Default.mergeOptions({ 14 | iconUrl: markerIcon.src, 15 | iconRetinaUrl: markerIcon2x.src, 16 | shadowUrl: markerShadow.src, 17 | }) 18 | 19 | interface MapProps { 20 | center?: number[]; // [latitude, longitude] 21 | } 22 | 23 | const url = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; 24 | const attribution='© OpenStreetMap contributors'; 25 | const Map: React.FC = ({ center }) => { 26 | return ( 27 |
28 | 34 | 38 | {center && ( 39 | 40 | )} 41 | 42 |
43 | ) 44 | } 45 | 46 | export default Map 47 | -------------------------------------------------------------------------------- /app/components/inputs/CountrySelect.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Select from 'react-select'; 4 | import useCountries from '../../hooks/useCountries'; 5 | 6 | export type CountrySelectValue = { 7 | flag: string; 8 | label: string; 9 | latlng: number[]; 10 | value: string; 11 | region: string; 12 | } 13 | 14 | interface CountrySelectProps { 15 | value?: CountrySelectValue; 16 | onChange: (value: CountrySelectValue) => void; 17 | } 18 | 19 | const CountrySelect: React.FC = ({ 20 | value, 21 | onChange 22 | }) => { 23 | const { getAll } = useCountries(); 24 | return ( 25 |
26 | 64 | 84 |
85 | ) 86 | } 87 | 88 | export default Input 89 | -------------------------------------------------------------------------------- /app/actions/getListings.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../libs/prismadb" 2 | 3 | export interface IListingParams { 4 | userId?: string; 5 | guestCount?: number; 6 | roomCount?: number; 7 | bathroomCount?: number; 8 | startDate?: string; 9 | endDate?: string; 10 | locationValue?: string; 11 | category?: string; 12 | } 13 | 14 | export default async function getListings( 15 | params: IListingParams 16 | ) { 17 | try { 18 | const { 19 | userId, 20 | roomCount, 21 | guestCount, 22 | bathroomCount, 23 | startDate, 24 | endDate, 25 | locationValue, 26 | category 27 | } = params; 28 | 29 | let query: any ={}; 30 | 31 | if (userId) { 32 | query.userId = userId; 33 | } 34 | 35 | if (category ) { 36 | query.category = category; 37 | } 38 | 39 | if (roomCount) { 40 | query.roomCount = { 41 | gte: +roomCount 42 | }; 43 | } 44 | 45 | if (guestCount) { 46 | query.guestCount = { 47 | gte: +guestCount 48 | }; 49 | } 50 | 51 | if (bathroomCount) { 52 | query.bathroomCount = { 53 | gte: +bathroomCount 54 | }; 55 | } 56 | 57 | if (locationValue) { 58 | query.locationValue = locationValue; 59 | } 60 | 61 | if (startDate && endDate) { 62 | query.NOT = { 63 | reservations: { 64 | some: { 65 | OR: [ 66 | { 67 | endDate: { gte: startDate }, 68 | startDate: { lte: startDate} 69 | }, 70 | { 71 | startDate: { lte: endDate }, 72 | endDate: { gte: endDate } 73 | } 74 | ] 75 | } 76 | } 77 | } 78 | } 79 | 80 | 81 | 82 | 83 | const listings = await prisma.listing.findMany({ 84 | where: query, 85 | orderBy: { 86 | createdAt: "desc", 87 | }, 88 | }); 89 | 90 | const safeListings = listings.map((listing) => ({ 91 | ...listing, 92 | createdAt: listing.createdAt.toISOString(), 93 | })) 94 | return safeListings; 95 | } catch (error: any) { 96 | throw new Error(error) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/components/listings/ListingInfo.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { IconType } from "react-icons"; 4 | import { SafeUser } from "../../types"; 5 | import useCountries from "../../hooks/useCountries"; 6 | import Avatar from "../Avatar"; 7 | import ListingCategory from "./ListingCategory"; 8 | import dynamic from "next/dynamic"; 9 | 10 | const Map = dynamic(() => import("../Map"), { 11 | ssr: false, 12 | }); 13 | 14 | interface ListingInfoProps { 15 | user: SafeUser; 16 | description: string; 17 | guestCount: number; 18 | roomCount: number; 19 | bathroomCount: number; 20 | category: { 21 | icon: IconType; 22 | label: string; 23 | description: string; 24 | } | undefined 25 | locationValue: string; 26 | 27 | } 28 | 29 | const ListingInfo: React.FC = ({ 30 | user, 31 | description, 32 | guestCount, 33 | roomCount, 34 | bathroomCount, 35 | category, 36 | locationValue, 37 | }) => { 38 | const { getByValue } = useCountries(); 39 | 40 | const coordinates = getByValue(locationValue)?.latlng; 41 | 42 | return ( 43 |
46 |
47 |
57 |
Hosted by {user?.name}
58 | 59 |
60 |
68 |
69 | {guestCount} guests 70 |
71 |
72 | {roomCount} rooms 73 |
74 |
75 | {bathroomCount} bathrooms 76 |
77 |
78 |
79 |
80 | {category && ( 81 | 86 | )} 87 |
88 |
89 | {description} 90 |
91 |
92 | 93 |
94 | ); 95 | } 96 | 97 | export default ListingInfo; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Airbnb Clone 2 | 3 | This is a full stack Airbnb clone built using Next.js 13, App router, React, Tailwind CSS, Prisma, MongoDB, and NextAuth. It provides a platform for users to browse and book vacation rentals as well as list their own rental properties. 4 | 5 | ## Screenshots 6 | ![Home Page](https://i.ibb.co/rkXdF5V/Airbnb-Home.jpg) 7 | 8 | ## Features 9 | * User authentication and authorization using NextAuth 10 | * User profile management 11 | * Property listing management 12 | * Search functionality to filter rental properties based on location, date range, and number of guests 13 | * Responsive design Using Tailwind CSS 14 | 15 | ## Technologies Used 16 | * [Next.js](https://next.js.org) - A React framework for server-side rendering and static site generation 17 | * [React](https://reactjs.org) - A JavaScript library for building user interfaces 18 | * [Tailwind CSS](https://tailwindcss.com) - A utility-first CSS framework 19 | * [Zustand](https://zustand.surge.sh) - A state management library for React 20 | * [Axios](https://axios-http.com) - A promise-based HTTP client 21 | * [React-hot-toast](https://react-hot-toast.com) - A React library for toast notifications 22 | * [Prisma](https://prisma.io) - An open-source ORM for Node.js 23 | * [MongoDB](https://mongodb.com) - A document-based, NoSQL database 24 | * [NextAuth](https://next-auth.js.org) - Authentication for Next.js 25 | * [Next-Cloudinary](https://next-cloudinary.spacejelly.dev) - A Next.js library for image and video upload and storage 26 | 27 | ## Getting Started 28 | 1. Clone the repository 29 | ``` 30 | git clone https://github.com/your-username/full-stack-airbnb-clone.git 31 | ``` 32 | 33 | 2. Install dependencies 34 | ``` 35 | cd airbnb_fullstack 36 | npm install 37 | ``` 38 | 39 | 3. Create a `.env` file in the root directory and add the following environment variables: 40 | ``` 41 | NEXTAUTH_URL=http://localhost:3000 42 | DATABASE_URL=<...> // To be included 43 | STRIPE_SECRET_KEY=<...> // To be included 44 | ``` 45 | 46 | 4. Start the development server 47 | ``` 48 | npm run dev 49 | ``` 50 | 51 | 5. Open [http://localhost:3000](http://localhost:3000) with your browser to view the app. 52 | 53 | ## Contributing 54 | Contributions are welcome! Please open an issue or pull request for any changes. 55 | 56 | ## License 57 | This project is licensed under the [MIT License](License.txt) -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "mongodb" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id String @id @default(auto()) @map("_id") @db.ObjectId 15 | name String? 16 | email String? @unique 17 | emailVerified DateTime? 18 | image String? 19 | hashedPassword String? 20 | createdAt DateTime @default(now()) 21 | updatedAt DateTime @updatedAt 22 | favoriteIds String[] @db.ObjectId 23 | 24 | accounts Account[] 25 | listings Listing[] 26 | reservations Reservation[] 27 | } 28 | 29 | model Account { 30 | id String @id @default(auto()) @map("_id") @db.ObjectId 31 | userId String @db.ObjectId 32 | type String 33 | provider String 34 | providerAccountId String 35 | refresh_token String? @db.String 36 | access_token String? @db.String 37 | expires_at Int? 38 | token_type String? 39 | scope String? 40 | id_token String? @db.String 41 | session_state String? 42 | 43 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 44 | 45 | @@unique([provider, providerAccountId]) 46 | } 47 | 48 | model Listing { 49 | id String @id @default(auto()) @map("_id") @db.ObjectId 50 | title String 51 | description String 52 | imageSrc String 53 | createdAt DateTime @default(now()) 54 | category String 55 | roomCount Int 56 | bathroomCount Int 57 | guestCount Int 58 | locationValue String 59 | userId String @db.ObjectId 60 | price Int 61 | 62 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 63 | reservations Reservation[] 64 | } 65 | 66 | model Reservation { 67 | id String @id @default(auto()) @map("_id") @db.ObjectId 68 | userId String @db.ObjectId 69 | listingId String @db.ObjectId 70 | startDate DateTime 71 | endDate DateTime 72 | totalPrice Int 73 | createdAt DateTime @default(now()) 74 | 75 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 76 | listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade) 77 | } 78 | -------------------------------------------------------------------------------- /app/components/Navbar/Search.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { BiSearch } from "react-icons/bi" 4 | import useSearchModal from "../../hooks/useSearchModal" 5 | import { useSearchParams } from "next/navigation"; 6 | import useCountries from "../../hooks/useCountries"; 7 | import { useMemo } from "react"; 8 | import { differenceInDays } from "date-fns"; 9 | 10 | const Search = () => { 11 | const searchModal = useSearchModal(); 12 | const params = useSearchParams(); 13 | const { getByValue } = useCountries(); 14 | 15 | const locationValue = params?.get('locationValue'); 16 | const startDate = params?.get('startDate'); 17 | const endDate = params?.get('endDate'); 18 | const guestCount = params?.get('guestCount'); 19 | 20 | const locationLabel = useMemo(() => { 21 | if (locationValue) { 22 | return getByValue(locationValue as string)?.label; 23 | } 24 | 25 | return 'Anywhere' 26 | }, [getByValue, locationValue]) 27 | 28 | const durationLabel = useMemo(() => { 29 | if (startDate && endDate) { 30 | const start = new Date(startDate as string); 31 | const end = new Date(endDate as string); 32 | let diff = differenceInDays(end, start) 33 | 34 | if (diff ==0) { 35 | diff = 1; 36 | } 37 | 38 | return `${diff} Days` 39 | } 40 | 41 | return 'Any Week' 42 | }, [startDate, endDate]) 43 | 44 | const guestLabel = useMemo(() => { 45 | if (guestCount) { 46 | return `${guestCount} Guests` 47 | } 48 | 49 | return 'Add Guests' 50 | }, [guestCount]) 51 | return ( 52 |
67 |
75 |
82 | {locationLabel} 83 |
84 |
96 | {durationLabel} 97 |
98 |
110 |
{guestLabel}
111 |
119 | 120 |
121 |
122 |
123 |
124 | ) 125 | } 126 | 127 | export default Search 128 | -------------------------------------------------------------------------------- /app/components/Navbar/Categories.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Container from "../Container"; 4 | import { TbBeach, TbMountain, TbPool } from "react-icons/tb"; 5 | import { 6 | GiBarn, 7 | GiBoatFishing, 8 | GiCactus, 9 | GiCastle, 10 | GiCaveEntrance, 11 | GiForestCamp, 12 | GiIsland, 13 | GiWindmill } from "react-icons/gi"; 14 | import { MdOutlineVilla } from "react-icons/md"; 15 | import { FaSkiing } from "react-icons/fa"; 16 | import { BsSnow } from "react-icons/bs"; 17 | import { IoDiamond } from "react-icons/io5"; 18 | import CategoryBox from "../CategoryBox"; 19 | import { usePathname, useSearchParams } from "next/navigation"; 20 | 21 | export const categories = [ 22 | { 23 | label: 'Beach', 24 | icon: TbBeach, 25 | description: 'This property is close to the beach!' 26 | }, 27 | { 28 | label: 'Windmills', 29 | icon: GiWindmill, 30 | description: 'This property has windmills!' 31 | }, 32 | { 33 | label: 'Modern', 34 | icon: MdOutlineVilla, 35 | description: 'This property is modern!' 36 | }, 37 | { 38 | label: 'Countryside', 39 | icon: TbMountain, 40 | description: 'This property is in the countryside!' 41 | }, 42 | { 43 | label: 'Pools', 44 | icon: TbPool, 45 | description: 'This property has a pool' 46 | }, 47 | { 48 | label: 'Islands', 49 | icon: GiIsland, 50 | description: 'This property is on an island' 51 | }, 52 | { 53 | label: 'Lake', 54 | icon: GiBoatFishing, 55 | description: 'This property is close to a lake' 56 | }, 57 | { 58 | label: 'Skiing', 59 | icon: FaSkiing, 60 | description: 'This property is has skiing activites' 61 | }, 62 | { 63 | label: 'Castles', 64 | icon: GiCastle, 65 | description: 'This property is in a castle' 66 | }, 67 | { 68 | label: 'Camping', 69 | icon: GiForestCamp, 70 | description: 'This property has camping activities' 71 | }, 72 | { 73 | label: 'Arctic', 74 | icon: BsSnow, 75 | description: 'This property has camping activities!' 76 | }, 77 | { 78 | label: 'Cave', 79 | icon: GiCaveEntrance, 80 | description: 'This property is in a cave!' 81 | }, 82 | { 83 | label: 'Desert', 84 | icon: GiCactus, 85 | description: 'This property is in the desert!' 86 | }, 87 | { 88 | label: 'Barn', 89 | icon: GiBarn, 90 | description: 'This property is in the farm!' 91 | }, 92 | { 93 | label: 'Lux', 94 | icon: IoDiamond, 95 | description: 'This property is luxurious!' 96 | }, 97 | ] 98 | 99 | const Categories = () => { 100 | const params = useSearchParams(); 101 | const category = params?.get('category'); 102 | const pathname = usePathname(); 103 | 104 | const isMainPage = pathname === '/'; 105 | 106 | if (!isMainPage) { 107 | return null; 108 | } 109 | return ( 110 | 111 |
122 | {categories.map((item) => ( 123 | 129 | ))} 130 |
131 |
132 | ); 133 | } 134 | 135 | export default Categories; -------------------------------------------------------------------------------- /app/components/listings/ListingCard.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { SafeListing, SafeReservation, SafeUser } from "../../types" 4 | import { useRouter } from "next/navigation" 5 | import useCountries from "../../hooks/useCountries" 6 | import { useCallback, useMemo } from "react" 7 | import { format } from "date-fns" 8 | import Image from "next/image" 9 | import HeartButton from "../HeartButton" 10 | import Button from "../Button" 11 | 12 | interface ListingCardProps { 13 | data: SafeListing 14 | reservation?: SafeReservation 15 | onAction?: (id: string) => void 16 | disabled?: boolean 17 | actionLabel?: string 18 | actionId?: string 19 | currentUser: SafeUser | null 20 | } 21 | 22 | const ListingCard: React.FC = ({ 23 | data, 24 | reservation, 25 | onAction, 26 | disabled, 27 | actionLabel, 28 | actionId = "", 29 | currentUser, 30 | }) => { 31 | const router = useRouter() 32 | const { getByValue } = useCountries() 33 | 34 | const location = getByValue(data.locationValue) 35 | 36 | const handleCancel = useCallback( 37 | (e: React.MouseEvent) => { 38 | e.stopPropagation() 39 | 40 | if (disabled) { 41 | return 42 | } 43 | 44 | onAction?.(actionId) 45 | }, 46 | [onAction, actionId, disabled] 47 | ) 48 | 49 | const price = useMemo(() => { 50 | if (reservation) { 51 | return reservation.totalPrice 52 | } 53 | 54 | return data.price 55 | }, [reservation, data.price]) 56 | 57 | const reservationDate = useMemo(() => { 58 | if (!reservation) { 59 | return null 60 | } 61 | 62 | const start = new Date(reservation.startDate) 63 | const end = new Date(reservation.endDate) 64 | 65 | return `${format(start, "PP")} - ${format(end, "PP")}` 66 | }, [reservation]) 67 | 68 | return ( 69 |
router.push(`/listings/${data.id}`)} 71 | className=" 72 | col-span-1 73 | cursor-pointer 74 | group 75 | focus:outline-none 76 | " 77 | > 78 |
79 |
88 | Listing 100 |
107 | 108 |
109 |
110 |
111 | {location?.region}, {location?.label} 112 |
113 |
114 | {reservationDate || data.category} 115 |
116 |
117 |
$ {price}
118 | {!reservation &&
night
} 119 |
120 | {onAction && actionLabel && ( 121 |
129 |
130 | ) 131 | } 132 | 133 | export default ListingCard 134 | -------------------------------------------------------------------------------- /app/components/modals/RegisterModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import axios from 'axios'; 4 | import { AiFillGithub } from 'react-icons/ai'; 5 | import { FcGoogle } from 'react-icons/fc'; 6 | import { useCallback, useState } from 'react'; 7 | import { 8 | FieldValues, 9 | SubmitHandler, 10 | useForm 11 | } from 'react-hook-form'; 12 | 13 | import useRegisterModal from '../../hooks/useRegisterModal'; 14 | import Modal from './Modal'; 15 | import Heading from '../Heading'; 16 | import Input from '../inputs/input'; 17 | import { toast } from 'react-hot-toast'; 18 | import Button from '../Button'; 19 | import { signIn } from "next-auth/react" 20 | import useLoginModal from '../../hooks/useLoginModal'; 21 | 22 | const RegisterModal = () => { 23 | const registerModal = useRegisterModal(); 24 | const loginModal = useLoginModal(); 25 | const [isLoading, setIsLoading] = useState(false); 26 | 27 | const { 28 | register, 29 | handleSubmit, 30 | formState: { 31 | errors 32 | } 33 | } = useForm({ 34 | defaultValues: { 35 | name: '', 36 | email: '', 37 | password: '', 38 | } 39 | }); 40 | 41 | const onSubmit: SubmitHandler = (data) => { 42 | setIsLoading(true); 43 | 44 | axios.post('/api/register', data) 45 | .then(() => { 46 | toast.success("Account created successfully"); 47 | registerModal.onClose(); 48 | loginModal.onOpen(); 49 | }) 50 | .catch((err) => { 51 | toast.error("Something went wrong") 52 | }) 53 | .finally(() => { 54 | setIsLoading(false); 55 | }) 56 | } 57 | 58 | const toggle = useCallback(() => { 59 | registerModal.onClose(); 60 | loginModal.onOpen(); 61 | }, [loginModal, registerModal]) 62 | 63 | const bodyContent = ( 64 |
65 | 69 | 77 | 85 | 94 |
95 | ) 96 | 97 | const footerContent = ( 98 |
99 |
100 |
139 | ) 140 | return ( 141 | 151 | ); 152 | } 153 | 154 | export default RegisterModal; -------------------------------------------------------------------------------- /app/components/Navbar/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { AiOutlineMenu } from "react-icons/ai" 4 | import Avatar from "../Avatar" 5 | import { useCallback, useState } from "react" 6 | import MenuItem from "./MenuItem" 7 | import useRegisterModal from "../../hooks/useRegisterModal" 8 | import useLoginModal from "../../hooks/useLoginModal" 9 | import { signOut } from "next-auth/react" 10 | import { SafeUser } from "../../../app/types" 11 | import useRentModal from "../../hooks/useRentModal" 12 | import { useRouter } from "next/navigation" 13 | 14 | interface UserMenuProps { 15 | currentUser?: SafeUser | null 16 | } 17 | 18 | const UserMenu: React.FC = ({ currentUser }) => { 19 | const router = useRouter(); 20 | const registerModal = useRegisterModal(); 21 | const loginModal = useLoginModal(); 22 | const rentModal = useRentModal(); 23 | const [isOpen, setIsOpen] = useState(false); 24 | 25 | const toggleOpen = useCallback(() => { 26 | setIsOpen((value) => !value) 27 | }, []) 28 | 29 | const onRent = useCallback(() => { 30 | if (!currentUser) { 31 | return loginModal.onOpen() 32 | } 33 | // open rent model 34 | rentModal.onOpen() 35 | }, [currentUser, loginModal, rentModal]) 36 | return ( 37 |
38 |
39 |
54 | Airbnb your home 55 |
56 |
74 | 75 |
76 | 77 |
78 |
79 |
80 | {isOpen && ( 81 |
95 |
103 | {currentUser ? ( 104 | <> 105 | router.push("/trips")} label="My Trips" /> 106 | router.push('/favorites')} label="My Favorites" /> 107 | router.push('/reservations')} label="My Reservations" /> 108 | router.push('/properties')} label="My Properties" /> 109 | 110 |
111 | signOut()} label="Log Out" /> 112 | 113 | ) : ( 114 | <> 115 | 116 | 117 | 118 | )} 119 |
120 |
121 | )} 122 |
123 | ) 124 | } 125 | 126 | export default UserMenu 127 | -------------------------------------------------------------------------------- /app/components/modals/LoginModal.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { signIn } from "next-auth/react" 4 | import axios from "axios" 5 | import { AiFillGithub } from "react-icons/ai" 6 | import { FcGoogle } from "react-icons/fc" 7 | import { useCallback, useState } from "react" 8 | import { FieldValues, SubmitHandler, useForm } from "react-hook-form" 9 | 10 | import useRegisterModal from "../../hooks/useRegisterModal" 11 | import Modal from "./Modal" 12 | import Heading from "../Heading" 13 | import Input from "../inputs/input" 14 | import { toast } from "react-hot-toast" 15 | import Button from "../Button" 16 | import { useRouter } from "next/navigation" 17 | import useLoginModal from "../../hooks/useLoginModal" 18 | 19 | const LoginModal = () => { 20 | const router = useRouter() 21 | 22 | const registerModal = useRegisterModal(); 23 | const loginModal = useLoginModal(); 24 | 25 | const [isLoading, setIsLoading] = useState(false) 26 | 27 | const { 28 | register, 29 | handleSubmit, 30 | formState: { errors }, 31 | } = useForm({ 32 | defaultValues: { 33 | email: "", 34 | password: "", 35 | }, 36 | }) 37 | 38 | const onSubmit: SubmitHandler = (data) => { 39 | setIsLoading(true) 40 | 41 | signIn("credentials", { 42 | ...data, 43 | redirect: false, 44 | }) 45 | .then((callback) => { 46 | setIsLoading(false) 47 | 48 | if (callback?.ok) { 49 | toast.success("Logged in"); 50 | router.refresh(); 51 | loginModal.onClose(); 52 | } 53 | 54 | if (callback?.error) { 55 | toast.error(callback.error); 56 | } 57 | }); 58 | } 59 | 60 | const toggle = useCallback(() => { 61 | loginModal.onClose(); 62 | registerModal.onOpen(); 63 | }, [loginModal, registerModal]) 64 | 65 | const bodyContent = ( 66 |
67 | 68 | 76 | 85 |
86 | ) 87 | 88 | const footerContent = ( 89 |
90 |
91 |
126 | ) 127 | return ( 128 | 138 | ) 139 | } 140 | 141 | export default LoginModal; 142 | -------------------------------------------------------------------------------- /app/components/modals/Modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useEffect, useState } from "react"; 4 | import { IoMdClose } from "react-icons/io"; 5 | import Button from "../Button"; 6 | 7 | interface ModalProps { 8 | isOpen?: boolean 9 | onClose: () => void 10 | onSubmit: () => void 11 | title?: string 12 | body?: React.ReactElement 13 | footer?: React.ReactElement 14 | actionLabel: string 15 | disabled?: boolean 16 | secondaryAction?: () => void 17 | secondaryActionLabel?: string 18 | } 19 | 20 | const Modal: React.FC = ({ 21 | isOpen, 22 | onClose, 23 | onSubmit, 24 | title, 25 | body, 26 | footer, 27 | actionLabel, 28 | disabled, 29 | secondaryAction, 30 | secondaryActionLabel, 31 | }) => { 32 | const [showModal, setShowModal] = useState(isOpen); 33 | 34 | useEffect(() => { 35 | setShowModal(isOpen); 36 | }, [isOpen]) 37 | 38 | const handleClose = useCallback(() => { 39 | if (disabled) { 40 | return; 41 | } 42 | 43 | setShowModal(false) 44 | setTimeout(() => { 45 | onClose() 46 | }, 300) 47 | }, [disabled, onClose]) 48 | 49 | const handleSubmit = useCallback(() => { 50 | if (disabled) { 51 | return; 52 | } 53 | 54 | onSubmit(); 55 | }, [disabled, onSubmit]) 56 | 57 | const handleSecondaryAction = useCallback(() => { 58 | if (disabled || !secondaryAction) { 59 | return; 60 | } 61 | secondaryAction(); 62 | }, [disabled, secondaryAction]) 63 | 64 | if (!isOpen) { 65 | return null; 66 | } 67 | return ( 68 | <> 69 |
84 |
98 | {/* CONTENT */} 99 |
108 |
126 | {/* HEADER */} 127 |
138 | 152 |
{title}
153 |
154 | {/* BODY */} 155 |
{body}
156 | {/* FOOTER */} 157 |
158 |
166 | {secondaryAction && secondaryActionLabel && ( 167 |
181 | {footer} 182 |
183 |
184 |
185 |
186 |
187 | 188 | ) 189 | } 190 | 191 | export default Modal 192 | -------------------------------------------------------------------------------- /app/listings/[listingId]/ListingClient.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SafeListing, SafeUser, SafeReservation } from "../../types"; 4 | import { useCallback, useEffect, useMemo, useState } from "react"; 5 | import { categories } from "../../components/Navbar/Categories"; 6 | import Container from "../../components/Container"; 7 | import ListingHead from "../../components/listings/ListingHead"; 8 | import ListingInfo from "../../components/listings/ListingInfo"; 9 | import useLoginModal from "../../hooks/useLoginModal"; 10 | import { useRouter } from "next/navigation"; 11 | import { differenceInCalendarDays, eachDayOfInterval } from "date-fns"; 12 | import axios from "axios"; 13 | import { toast } from "react-hot-toast"; 14 | import ListingReservation from "../../components/listings/ListingReservation"; 15 | import { Range } from "react-date-range"; 16 | 17 | const initialDateRange = { 18 | startDate: new Date(), 19 | endDate: new Date(), 20 | key: 'selection' 21 | } 22 | 23 | interface ListingClientProps { 24 | reservations?: SafeReservation[]; 25 | listing: SafeListing & { 26 | user: SafeUser 27 | } 28 | currentUser: SafeUser | null; 29 | } 30 | 31 | const ListingClient: React.FC = ({ 32 | listing, 33 | currentUser, 34 | reservations = [] 35 | }) => { 36 | 37 | const loginModal = useLoginModal(); 38 | const router = useRouter(); 39 | 40 | const disabledDates = useMemo(() => { 41 | let dates: Date[] = []; 42 | 43 | reservations.forEach((reservation) => { 44 | const range = eachDayOfInterval({ 45 | start: new Date(reservation.startDate), 46 | end: new Date(reservation.endDate) 47 | }); 48 | 49 | dates = [...dates, ...range]; 50 | }); 51 | 52 | return dates; 53 | }, [reservations]); 54 | 55 | const [isLoading, setIsLoading] = useState(false); 56 | const [totalPrice, setTotalPrice] = useState(listing.price); 57 | const [dateRange, setDateRange] = useState(initialDateRange); 58 | 59 | const onCreateReservation = useCallback(() => { 60 | if (!currentUser) { 61 | return loginModal.onOpen(); 62 | } 63 | setIsLoading(true); 64 | 65 | axios.post('/api/reservations', { 66 | totalPrice, 67 | startDate: dateRange.startDate, 68 | endDate: dateRange.endDate, 69 | listingId: listing.id 70 | }) 71 | .then(() => { 72 | toast.success("Success! Listing reserved"); 73 | setDateRange(initialDateRange); 74 | // redirect to /trips 75 | router.push('/trips'); 76 | 77 | }) 78 | .catch(() => { 79 | toast.error("Something went wrong") 80 | }) 81 | .finally(() => { 82 | setIsLoading(false) 83 | }) 84 | 85 | }, [ 86 | totalPrice, 87 | dateRange, 88 | listing?.id, 89 | router, 90 | currentUser, 91 | loginModal 92 | ]); 93 | 94 | useEffect(() => { 95 | if (dateRange.startDate && dateRange.endDate) { 96 | const dayCount = differenceInCalendarDays( 97 | dateRange.endDate, 98 | dateRange.startDate 99 | ); 100 | 101 | if (dayCount && listing.price) { 102 | setTotalPrice(dayCount * listing.price) 103 | } else { 104 | setTotalPrice(listing.price) 105 | } 106 | } 107 | }, [dateRange, listing.price]) 108 | 109 | const category = useMemo(() => { 110 | return categories.find((item) => item.label === listing.category) 111 | }, [listing.category]) 112 | return ( 113 | 114 |
117 |
120 | 128 |
137 | 146 |
152 | setDateRange(value)} 156 | dateRange={dateRange} 157 | onSubmit={onCreateReservation} 158 | disabled={isLoading} 159 | disabledDates={disabledDates} 160 | 161 | /> 162 |
163 |
164 |
165 |
166 |
167 | ); 168 | } 169 | 170 | export default ListingClient; -------------------------------------------------------------------------------- /app/components/modals/SearchModal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Modal from "./Modal"; 4 | 5 | import qs from 'query-string' 6 | import useSearchModal from "../../hooks/useSearchModal"; 7 | import { useRouter, useSearchParams } from "next/navigation"; 8 | import { useCallback, useMemo, useState } from "react"; 9 | import { Range } from "react-date-range"; 10 | import dynamic from "next/dynamic"; 11 | import CountrySelect, { CountrySelectValue } from "../inputs/CountrySelect"; 12 | import { formatISO } from "date-fns"; 13 | import Heading from "../Heading"; 14 | import Calendar from "../inputs/Calendar"; 15 | import Counter from "../inputs/Counter"; 16 | 17 | enum STEPS { 18 | LOCATION = 0, 19 | DATE = 1, 20 | INFO = 2 21 | } 22 | const SearchModal = () => { 23 | const router = useRouter(); 24 | const params = useSearchParams(); 25 | const searchModal = useSearchModal(); 26 | 27 | const [location, setLocation] = useState() 28 | const [step, setStep] = useState(STEPS.LOCATION); 29 | const [guestCount, setGuestCount] = useState(1); 30 | const [roomCount, setRoomCount] = useState(1); 31 | const [bathroomCount, setBathroomCount] = useState(1); 32 | const [dateRange, setDateRange] = useState({ 33 | startDate: new Date(), 34 | endDate: new Date(), 35 | key: 'selection' 36 | }) 37 | 38 | const Map = useMemo(() => dynamic(() => import('../Map'), { 39 | ssr: false 40 | }), [location]); 41 | 42 | const onBack = useCallback(() => { 43 | setStep((value) => value - 1); 44 | }, []); 45 | 46 | const onNext = useCallback(() => { 47 | setStep((value) => value + 1); 48 | }, []) 49 | 50 | const onSubmit = useCallback(async () => { 51 | if (step !== STEPS.INFO) { 52 | return onNext(); 53 | } 54 | 55 | let currentQuery = {}; 56 | 57 | if (params) { 58 | currentQuery = qs.parse(params.toString()); 59 | } 60 | 61 | const updatedQuery: any = { 62 | ...currentQuery, 63 | locationValue: location?.value, 64 | guestCount, 65 | roomCount, 66 | bathroomCount 67 | }; 68 | 69 | if (dateRange.startDate) { 70 | updatedQuery.startDate = formatISO(dateRange.startDate) 71 | } 72 | 73 | if (dateRange.endDate) { 74 | updatedQuery.startDate = formatISO(dateRange.endDate) 75 | } 76 | 77 | const url = qs.stringifyUrl({ 78 | url: '/', 79 | query: updatedQuery, 80 | }, { skipNull: true }); 81 | 82 | setStep(STEPS.LOCATION); 83 | searchModal.onClose(); 84 | 85 | router.push(url); 86 | }, [ 87 | step, 88 | searchModal, 89 | location, 90 | router, 91 | guestCount, 92 | bathroomCount, 93 | dateRange, 94 | onNext, 95 | params 96 | ]) 97 | 98 | const actionLabel = useMemo(() => { 99 | if (step === STEPS.INFO) { 100 | return 'Search'; 101 | } 102 | 103 | return 'Next' 104 | }, [step]) 105 | 106 | const secondaryActionLabel = useMemo(() => { 107 | if (step === STEPS.LOCATION) { 108 | return undefined; 109 | } 110 | 111 | return 'Back' 112 | }, [step]) 113 | 114 | let bodyContent = ( 115 |
116 | 120 | setLocation(value as CountrySelectValue) 123 | } 124 | /> 125 |
126 | 127 |
128 | ); 129 | 130 | if (step === STEPS.DATE) { 131 | bodyContent = ( 132 |
133 | 137 | setDateRange(value.selection)} 140 | /> 141 |
142 | ) 143 | } 144 | 145 | if (step === STEPS.INFO) { 146 | bodyContent = ( 147 |
148 | 152 | setGuestCount(value)} 157 | /> 158 | setRoomCount(value)} 163 | /> 164 | setBathroomCount(value)} 169 | /> 170 |
171 | ) 172 | } 173 | return ( 174 | 184 | ); 185 | } 186 | 187 | export default SearchModal; -------------------------------------------------------------------------------- /app/components/modals/RentModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Modal from "./Modal"; 4 | import useRentModal from "../../hooks/useRentModal"; 5 | import { useMemo, useState } from "react"; 6 | import Heading from "../Heading"; 7 | import { categories } from "../Navbar/Categories"; 8 | import CategoryInput from "../inputs/CategoryInput"; 9 | import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; 10 | import CountrySelect from "../inputs/CountrySelect"; 11 | import dynamic from "next/dynamic"; 12 | import Counter from "../inputs/Counter"; 13 | import ImageUpload from "../inputs/ImageUpload"; 14 | import Input from "../inputs/input"; 15 | import axios from "axios"; 16 | import { toast } from "react-hot-toast"; 17 | import { useRouter } from "next/navigation"; 18 | 19 | 20 | 21 | enum STEPS { 22 | CATEGORY = 0, 23 | LOCATION = 1, 24 | INFO = 2, 25 | IMAGES = 3, 26 | DESCRIPTION = 4, 27 | PRICE = 5 28 | } 29 | 30 | const RentModal = () => { 31 | const rentModal = useRentModal(); 32 | const router = useRouter(); 33 | 34 | const [step, setStep] = useState(STEPS.CATEGORY); 35 | const [isLoading, setIsLoading] = useState(false); 36 | 37 | const { 38 | register, 39 | handleSubmit, 40 | setValue, 41 | watch, 42 | formState: { errors }, 43 | reset 44 | } = useForm({ 45 | defaultValues: { 46 | category: "", 47 | location: null, 48 | guestCount: 1, 49 | roomCount: 1, 50 | bathroomCount: 1, 51 | imageSrc: '', 52 | price: 1, 53 | title: '', 54 | description: '' 55 | } 56 | }); 57 | 58 | const category = watch('category'); 59 | const location = watch('location'); 60 | const guestCount = watch('guestCount'); 61 | const roomCount = watch('roomCount'); 62 | const bathroomCount = watch('bathroomCount'); 63 | const imageSrc = watch('imageSrc'); 64 | 65 | const Map = useMemo(() => dynamic(() => import('../Map'), { 66 | ssr: false 67 | }), [location]) 68 | 69 | const setCustomValue = (id: string, value: any) => { 70 | setValue(id, value, { 71 | shouldDirty: true, 72 | shouldTouch: true, 73 | shouldValidate: true 74 | }); 75 | } 76 | 77 | const onBack = () => { 78 | setStep((value) => value - 1); 79 | } 80 | 81 | const onNext = () => { 82 | setStep((value) => value + 1); 83 | } 84 | 85 | const onSubmit: SubmitHandler = (data) => { 86 | if (step !== STEPS.PRICE) { 87 | return onNext(); 88 | } 89 | 90 | setIsLoading(true); 91 | 92 | axios.post('/api/listings', data) 93 | .then(() => { 94 | toast.success("Listing Created"); 95 | router.refresh(); 96 | reset(); 97 | setStep(STEPS.CATEGORY) 98 | rentModal.onClose(); 99 | }) 100 | .catch(() => { 101 | toast.error("Something went wrong"); 102 | }).finally(() => { 103 | setIsLoading(false); 104 | }) 105 | } 106 | 107 | const actionLabel = useMemo(() => { 108 | if (step === STEPS.PRICE) { 109 | return "Create" 110 | } 111 | 112 | return "Next" 113 | }, [step]); 114 | 115 | const secondaryActionLabel = useMemo(() => { 116 | if (step === STEPS.CATEGORY) { 117 | return undefined; 118 | } 119 | 120 | return "Back"; 121 | }, [step]); 122 | 123 | let bodyContent = ( 124 |
125 | 129 |
139 | {categories.map((item) => ( 140 |
142 | setCustomValue('category', category)} 144 | selected={category === item.label} 145 | label={item.label} 146 | icon={item.icon} 147 | /> 148 |
149 | ))} 150 |
151 |
152 | ) 153 | 154 | if (step === STEPS.LOCATION) { 155 | bodyContent = ( 156 |
157 | 161 | setCustomValue('location', value)} 164 | /> 165 | 168 |
169 | ) 170 | } 171 | 172 | if (step === STEPS.INFO) { 173 | bodyContent = ( 174 |
175 | 179 | setCustomValue('guestCount', value)} 184 | /> 185 |
186 | setCustomValue('roomCount', value)} 191 | /> 192 |
193 | setCustomValue('bathroomCount', value)} 198 | /> 199 |
200 | ); 201 | } 202 | 203 | if (step === STEPS.IMAGES) { 204 | bodyContent = ( 205 |
206 | 210 | setCustomValue('imageSrc', value)} 213 | /> 214 |
215 | ) 216 | } 217 | 218 | if (step === STEPS.DESCRIPTION) { 219 | bodyContent = ( 220 |
221 | 225 | 233 |
234 | 242 |
243 | ) 244 | } 245 | 246 | if (step === STEPS.PRICE) { 247 | bodyContent = ( 248 |
249 | 253 | 263 |
264 | ); 265 | } 266 | return ( 267 | 277 | ); 278 | } 279 | 280 | export default RentModal; -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- 1 |  (F  (n00 (-� ��F(  $]��]$ �������� 8����������8��������������������������#���OOO�������������������������ggg����#Y����������������������������555����Y�����kkk������������������������������� �����������������������Y�����JJJ���������kkk������Y#�������������� ������#������111�DDD�������������������8����������8 �������� $]��]$( @ ,U����U,*������������*����������������Q������������������Qr��������������������rr����������������������rO������������������������O������������������������������������������������������(����������������������������'�������888���������������������������������������������������������___������������������������������������������������������������������������SSS��������+��������hhh�������������������������������������������������������������+T���������������������������������������������������������,,,���������T����������GGG��������������������������������������������������������������������������������������������������������������������������������+++���������������������������������jjj��������������������������������������������������������������������T������������������������������������III������������T+������������hhh���������������������������������+�����������������������������,,,��������������������������GGG��������������������������'����������������������������������(�������������333�___����������������������������������������O������������������������Or����������������������rr��������������������rQ������������������Q����������������*������������*,U����U,(0` - (Lj����jK( V��������������U%��������������������&������������������������Q��������������������������R��������������������������������������������������������������������������������������������������������������������������������������������������������������������������P��������������������������������������O����������������������������������������������������������������������������������#������������������������������������������#������������������������������������������������������$$$�hhh�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�PPP�����������U���������������������������������������������������������������������������������������������������sss�����������U������������eee������������������������������������������������������������������������������������������������� ���������������������������������������������������������������������������������������������HHH������������� (�������������EEE������������������������������������������������������������������������������������������(K��������������������������������������������������������������������������������������,,,��������������Lj��������������)))�����������������������������������������������������������������������������������j�������������������������������������������������������������������������������������������������������������������������������������������������������������������������iii����������������������������������eee����������������������������������������������������������������������������������������������������������������������������������������HHH������������������j�����������������EEE��������������������������������������������������������������jL����������������������������������������������������������,,,������������������K(������������������)))�������������������������������������������������������( ���������������������������������������������������������������������� ��������������������������������������������iii��������������������U�������������������eee����������������������������������������U������������������������������������HHH����������������������������������������EEE���������������������������������#����������������������������,,,��������������������#��������������������222�}}}�������������������������������������������������������������O��������������������������������������P��������������������������������������������������������������������������������������������������������������������������������������������������������������������������R��������������������������Q������������������������&��������������������%U��������������V (Kj����jL( �PNG 2 |  3 | IHDR\r�fsRGB���8eXIfMM*�i��D"8sIDATx�] �ՙn�]<QVA���h$ �N��13*�q��d�č�I���D�L2��(�(Ԙ2�ę�G ��q_@屈���xț�Џ��{o�������U�{}�O��;������9�‘d���(Dg��8 ��N�]��@ �hx�?v �N�3�=`;�6�.�&� �u�� ��6� P��н��@�àR�P�iZq�^DN���wp� � ��X�hИH g@�� 4 | :��|�5` p"@�'�ɲ�s{ �p�*�2����� dү���|(0� 5 | 0��>K� 6 | �xX�6IJ ��C|?$KE N�}ϓ|������h $ 2 � �|/�.Nz�# ���W�e� 7 | �5�� ����ܶ�� �;�y�� �g�s�h^I� �DL(�;�8�� Hjg�cH|x�1��R"�a���Ӂ�G��@��9`/`%0� H�@j �~,���K 8 | �,t).��I���D�T�O�)~��V�u$b 誛 �U%�7������_�$b 8A������J�3` 510wQ�?��vr���:�2�K�@ ��v*{%#��A�Z�咁^(��=�g\��W�����!:��,`�6��643�:@�c.Fٟ����u?�<��'������_܏vp: �8Q�� 9 | I�Ł� p{3���kHȢ�G�����c�Ѽ <�62&� 10 | ��2uC�����敭��T�3� 11 | �� ���;���d�/~m��.��X�@{�w.��d]G��{lK��Eb���(P�RuM�T�C���� �d��])��_Lm�=��=@b���K��GUk�^�U�������)1����g�T�Š��m`9�\����Q��@����Ⱆ6�:ڞ�^�w�����E�D ��� �5����F�,�� 12 | �X"�d�m�<�nB~�� @����t�t�x�� �;�f�>����I8����8��C1۪$B���e���+��jl��EZ��& ��S:�:�6�m����\G1��`���!�nl�l�Ɗ�^�Q`��@Oc�S��@e�ͷ���qb�p���S��@u p���F�D@�Г������2@#����L3�A��$H2�_h��FH #rq(��O�D�򤬈���runGOWa�b�&�SgD�3�ED�to�*Ǥ����9k��~)���,$�x�R�1�v�K ��9�D 䍁U(�w�&LE��ꩻ� S)��3�Y8x8$.i�(��K�ŀY ����a�]��� �4��ǀ c����@3�f����4�Ƣ�� �/*b������$!I�~� �7�B*-1` o �� �$��ǡD�����L�������J"���OQ��)��2@#�x4�"$e���I�8��Oi��8�"��G��8[x�t<�.��7&�m&؎R�^��tq�ؕ�.���Y�-2��d���*_��&d|j\�W�b � �G����*g����釁�F4�"I�؃�/b1q�N����Y�D ��p ���9���p�}w\��Ԥ���1 j`��O���xK=��H���A��1 �#� 13 | D:U8j���t���$b b�A||�U�Q��26%��)1 ��_ �ꢳ!~D�����+b >A��:]�E$��50��GDhR�t����ݻwR�)�� P���n$� 3���@bS�Nu�,Y�j�ʲ��:����;�����@�`�|�-[)�'OV��Ն�sFxڮ��ۥ�n}͛7�����~��ƺ�:���Q��J_��UKj8�q0x���;v4̞=[�hW=� �� �&�!e5�8hѢE��w�]�����6���_�iW}�SZ�? �/`�;vl�}��2<�h�"� ���A�܁�X,�m۶�+V�(��<�w���#F�^���;���aH�c� ��)S�*�{a���p ��c89(�^����4�&E��oÆ ��W�/��u�=�^���*?{k^�_E�����z���g��UI-���{WU* 14 | �:p�9 .tڷo(/ݺus>��3�'�^�Rg���ڞG��I_D���� ���~~���{ ���?N0�7�S��.ƍ׸�~?}/y]nA;�أ���2]�FOB2C?�_I����[�:�:�=#�OzK�-� ��ϣ�%����?j��I���P�ۯ��{N�-hU��t�:�������,���G�K�-hU���c�hP7 �� �˜�@�n?�\�-�k�.���2�:�� �`��F��=�-�V�_�G��܂V���}�0WI����F��ʭ���sM�r Z�8pJ�Q�*@OK8��� 15 | r Z� �ݖa,��w��S�W^y����.��5�at7��ݏ���Tv#�~7n ��A"�����+��W��pM��/�hK8����g��F/^������M{e��R�|�)q��7�t��?8'���K��P~���瞰�\��r ��>�ǷUk�eP��|�^x���� 16 | �/V/��v������ ���*�p�v������ʟ]J��}��k8(������ĉ�ѣGǗ�O�mڴq,X�o ���e. �^ �Qx���p�t����4^_�N�{�����y�2�s������-عsg�s���i�v��Z 8 17 | !~PJ?�c�������|�]�ܽ{��z�긓R��1pn���z�����tlp�9�f�r�v�jT殿�z�4*O�L�~����ԕ3��4�~~�r �;�m�xY�+��� ������3r �;�m�x�4���:7]Ձq L�4)U��!r �1��u�6���$� �7����8�w��̙3Ǹ|5�>?�\z��O� ��͆���,�E����3�����2���[����2Wu:E�����^p. H1cJ�t�]}��B�u��SOu�����I c�O�����%� �AZ������k����D?�5�@Q�� ���3�w�+��"��T��S��Uޥ�13��?� �5M'݋��>p��Z�j�~fj� ׈�סԐ�n�����>���i5D�[bf ��~a�'�`Xc���-�1�k����āI�������k��Q�ů|�k�M��(92�@�t�����݂X-�Lדa��N4��qܞ'$f0@� @V�nA�ܘY�L9:�|/^s��� ��)0`�j��T\w�uZ-����¨\� @�:��c�t���{�-��Rb��1%��I,Y%T���~ ��r �1����C��,�$��*ˀ���f<��0z����h�F��������| ���8Z-�CR����Tg��HRf��glY����s��-��p��'+����m�_ؒg������C�{� ����Ȫ�ϏΙ3g�-�GR|׹7`G��񥡘�0�U��_ٵZЏ�د�D�)���\>����ʗ������zN���@��~~��-��P��{rs���@�<����|.]�Ը|��m|g����_��y�W�KD1�b�M���%�s\����r �1��n�\ �ƒ�"-��` .4��~%3��I}[0A��$��=-�>BH"G�ۏ�^r��<�EBG�i�%���9�@^�~~ @�����1�� ��@�t�-[����{%@C�$�mAg���Κ5kʆх����/双O��l��ӿ��B�@.X���u�p�O��6��x�9MPn�`߷o_���^n�`t� 18 | ��(�����\r��s�A�y���ۂ�T��@h 19 | �E0l�0��;�tڵӘkƸN����Y�jU�� 20 | S#�|^㽺- |��p� N�.���ޥ`�^{�zL�6��4�ě�b��e�]&"�d�sΜ9Uޥ�U0�! ��*nP�*`���o֨v����i8G�����hh��m������ɓ�s�=�{J�U0�Ղ���wZ������������8bEz���,Y�D��![C�>}��7:k׮ �no��f�>jvR?#b��X �(��F�AT�F��i��[�{��zv��>��C���a+�[0B2�D��=��G~�( 21 | �ĺ������LO�\s�܂>"8|�`[) 22 | &Lp8�'��������4oGe�#�ۏ�lْ_\�D̀܂�2Z�l��i�9�� t�ȑ9f ޢ�-����=���Y�y��n?uQ�}Xͬ �sA�i>=��1�=R��+� + �܂��.2� �K������CƢۃ20h� �˫%53�5@�MA�%���̣������j[��9�;��_(�����0��~r���\�{�m�P����x#TT9��n?����N#��ץ&� }���) 23 | �T�VL�!���j���` �p �8@Rr�UAV�A����=��-����pLH�`@n�*Ȋ1�܂U���?}w ]�H2@�ߴi��V���[�˯%�������5�8�)Э 24 | T`��|rZbZ-�.�!da+@����ߞ�Z�gf�[0p������ I��gr �$��o%P�_rCy �V�|߽����"m�Y���-�[ l��kxA���ۯ9]�[pҤI�Ȩ�pP���k��Feِ���gHE�d�nAm"Z�$��5} ���z�8����2r�X�|� ��Sܻw��r�J�s�J�~�T�f�z{�ͫ��x�j?j��Q�E�n��js���|G�xз���󕾤�rzr�� ��`���V{���u��4448�V��ra��p���QRZ�<{�dK.F9��#~T���s.����N%*� ���Ýu�8G&����/W:*x%�{�}@� ��l���Nc#�AI�������i����*?�د�0}�g���C"Ā pۯ������4薒ҏ(b�8�_Q�Y� ���r7'��� `��� �j�6�� *��3�W�g��"��l� �ˆ1�:�Sg}%� � ��P?����1`�����Y���"��D�0b@ �������9������[t��F1���p`k�\U�`��R��A#W81 e`)R�ZM�����[ u��F0� rq.�����#^�=C"Ā9 P'�R~f��� 27 | pn�zdC"�e���?�\K����@&$b }jz�3۵�x/{ ��1 Ra�#�|��ƟUK�=&�^��TM�n�2�9�5)?s���{O'�D ��D���o[kM�oK0�x���Td�_@]b r� �G�����;����D ��D���1�gaR �`��'`0�  �> \��/���f��������ŀ����!fn�Z�|b����U�.t���ट���r�9�+�������� �b rnE�Dk�=��8�����!b R�Cl�P�E�`�܌�K�'~�@���}*�!`�@��6L� �;�� $b@ D��?#��g�F� 28 | �� V��1�v��;�Es��Q����=ɮ�4���b@ T��n��!��3q�0^�V��c��1�ܶ��[����M�=8I����1@�څ@Cu��`N�o��WJĀ�W����e��I��n��N�mீ��ܴ�_ d��(�4`E܅I�� ��"̵�1 *3�+\�E� �\M���)g r��� 29 | ���8�>��p�?vI��0�ǀ~�!b������$'�%"I����R��i�1 �0� �?S~&���r��� ��{ n�_ �����L�?��T�e��Ǝ�7�C"r��OQ~"qI���O 8�?$b � ܋r�#@�_�v�J̙��/��3�'d�/����W[����o'N� �l� �-2����@j�O~��0���2`H�@�؄��+����p OB��uO��(l�S�ԕ���9����~�c�:x/�X d�.���Ɣ�d��V�y@F$H2�����+M*�i��l8O@F$H2����2�4&r� PO��֢��€��7N�YS ����Y�1`��;�JS3n� g[�'��@W@"la`32�n?'�HB2p 30 | �hām�mu �����j@F@��V����Z!��xI���H�y�ѱ) ��>��Z!6���a�`�����dDV$9f��� pM�6�I�!LG:\LdrwPy�~�P�%��L3��7�TK��Am�mo|�6�� 3��-�hJ3��?�67 �yr���"�� ��g��4.$�1���_�[*��&���S/�dq������� C��h�3��>�6Ŷ%������\�#�RZq� � =lK|ŔX��X�WS�ej5/����$���:��v@������8�� �d��1(�z2~F�)���3��͋���l��C�������#����=�.\Lt? %�N$9b�%�:���2��u �1|-� ld�����t$b��@?���@� �F�c��ρ^�D �d�[9�ࠐz�����: 31 | H�@ ��P2v)~���@����z5��|����R�ֵ���|`#�W39؂��<�"-�0��\<�d ��u�oGLz1��Gp����e�倯d�.�j H�@j �F�3��@ c{s<��J& �@�����b���w�� �� ��n���v��< �����,M;��*p>p!0hH��{=�����x�]I� �DLh����<'��h8�@V �#��J���f�I� ��Hn����W�} �N�t[u�$��������� @� 2 �]&)� �#�3���, =%�T���k�&� I�����I��ӳ��[8 � �L�]�]t�T�g���6�-@b2U�OV��: A?�� } .i�| �xC���rv�w;��#�>�i8_b82�WP�������{'n���8�z;�Ƥy��s���@���P��o|�S�ih$3��@߹j��IEND�B`� --------------------------------------------------------------------------------