├── public ├── images │ ├── logo.png │ ├── favicon.png │ └── placeholder.jpg ├── vercel.svg └── next.svg ├── postcss.config.js ├── app ├── loading.tsx ├── providers │ └── ToasterProvider.tsx ├── libs │ └── prismadb.ts ├── components │ ├── Loader.tsx │ ├── Container.tsx │ ├── navbar │ │ ├── MenuItem.tsx │ │ ├── Logo.tsx │ │ ├── index.tsx │ │ ├── Search.tsx │ │ ├── UserMenu.tsx │ │ └── Categories.tsx │ ├── Avatar.tsx │ ├── Heading.tsx │ ├── ClientOnly.tsx │ ├── inputs │ │ ├── CategoryInput.tsx │ │ ├── Calendar.tsx │ │ ├── index.tsx │ │ ├── CountrySelect.tsx │ │ ├── Counter.tsx │ │ └── ImageUpload.tsx │ ├── listings │ │ ├── ListingCategory.tsx │ │ ├── ListingHead.tsx │ │ ├── ListingReservation.tsx │ │ ├── ListingInfo.tsx │ │ └── ListingCard.tsx │ ├── HeartButton.tsx │ ├── EmptyState.tsx │ ├── Map.tsx │ ├── Button.tsx │ ├── CategoryBox.tsx │ └── modals │ │ ├── LoginModal.tsx │ │ ├── index.tsx │ │ ├── RegisterModal.tsx │ │ ├── SearchModal.tsx │ │ └── RentModal.tsx ├── hooks │ ├── useRentModal.ts │ ├── useLoginModal.ts │ ├── useSearchModal.ts │ ├── useRegisterModal.ts │ ├── useCountries.ts │ └── useFavorite.ts ├── globals.css ├── error.tsx ├── types │ └── index.ts ├── api │ ├── register │ │ └── route.ts │ ├── listings │ │ ├── [listingId] │ │ │ └── route.ts │ │ └── route.ts │ ├── reservations │ │ ├── [reservationId] │ │ │ └── route.ts │ │ └── route.ts │ └── favorites │ │ └── [listingId] │ │ └── route.ts ├── actions │ ├── getFavoriteListings.ts │ ├── getListingById.ts │ ├── getCurrentUser.ts │ ├── getReservations.ts │ └── getListings.ts ├── favorites │ ├── page.tsx │ └── FavoritesClient.tsx ├── listings │ └── [listingId] │ │ ├── page.tsx │ │ └── ListingClient.tsx ├── trips │ ├── page.tsx │ └── TripsClient.tsx ├── properties │ ├── page.tsx │ └── PropertiesClient.tsx ├── reservations │ ├── page.tsx │ └── ReservationsClient.tsx ├── layout.tsx └── page.tsx ├── .vscode └── settings.json ├── middleware.ts ├── tailwind.config.js ├── next.config.js ├── .gitignore ├── tsconfig.json ├── package.json ├── README.md ├── prisma └── schema.prisma └── pages └── api └── auth └── [...nextauth].ts /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mucahittasan/Airbnb-Clone-NextJs/HEAD/public/images/logo.png -------------------------------------------------------------------------------- /public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mucahittasan/Airbnb-Clone-NextJs/HEAD/public/images/favicon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/images/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mucahittasan/Airbnb-Clone-NextJs/HEAD/public/images/placeholder.jpg -------------------------------------------------------------------------------- /app/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from "./components/Loader" 2 | 3 | const Loading = () => { 4 | return ( 5 | 6 | ) 7 | } 8 | 9 | export default Loading -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\.pnpm\\typescript@5.0.4\\node_modules\\typescript\\lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /app/providers/ToasterProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Toaster } from "react-hot-toast" 4 | 5 | const ToastProvider = () => { 6 | return ( 7 | 8 | ) 9 | } 10 | 11 | export default ToastProvider -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 11 | -------------------------------------------------------------------------------- /app/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { PuffLoader } from 'react-spinners' 4 | 5 | const Loader = () => { 6 | return ( 7 |
8 | 9 |
10 | ) 11 | } 12 | 13 | export default Loader; -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | images: { 7 | domains: [ 8 | 'res.cloudinary.com', 9 | 'avatars.githubusercontent.com', 10 | 'lh3.googleusercontent.com' 11 | ] 12 | } 13 | } 14 | 15 | module.exports = nextConfig -------------------------------------------------------------------------------- /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/components/Container.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react" 3 | 4 | interface ContainerProps { 5 | children: React.ReactNode 6 | } 7 | 8 | const Container: React.FC = ({ children }) => { 9 | return ( 10 |
11 | {children} 12 |
13 | ) 14 | } 15 | 16 | export default Container -------------------------------------------------------------------------------- /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/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-botttom, 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 | font-size: 16px !important; 24 | width: 100% !important; 25 | } -------------------------------------------------------------------------------- /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 = ({ onClick, label }) => { 9 | return ( 10 |
14 | {label} 15 |
16 | ) 17 | } 18 | 19 | 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 = ({ error }) => { 11 | 12 | useEffect(() => { 13 | console.log(error); 14 | 15 | }, [error]) 16 | 17 | return ( 18 | 22 | ) 23 | } 24 | 25 | export default ErrorState -------------------------------------------------------------------------------- /app/components/navbar/Logo.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | // Next Libraries 3 | import Image from 'next/image' 4 | import { useRouter } from 'next/navigation' 5 | 6 | const Logo = () => { 7 | 8 | const router = useRouter(); 9 | 10 | return ( 11 | router.push("/")} 13 | alt='Logo' 14 | className='hidden md:block cursor-pointer' 15 | height={100} 16 | width={100} 17 | src="/images/logo.png" 18 | /> 19 | ) 20 | } 21 | 22 | export default Logo -------------------------------------------------------------------------------- /.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*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts -------------------------------------------------------------------------------- /app/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Image from "next/image" 4 | import { FC } from "react"; 5 | 6 | interface AvatarProps { 7 | src?: string | null | undefined 8 | } 9 | 10 | const Avatar: FC = ({ src }) => { 11 | return ( 12 |
13 | Avatar 20 |
21 | ) 22 | } 23 | 24 | export default Avatar -------------------------------------------------------------------------------- /app/components/Heading.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | interface HeadingProps { 4 | title: string; 5 | subtitle?: string; 6 | center?: boolean 7 | } 8 | 9 | const Heading: React.FC = ({ title, subtitle, center }) => { 10 | return ( 11 |
12 |
13 | {title} 14 |
15 |
16 | {subtitle} 17 |
18 |
19 | ) 20 | } 21 | 22 | export default Heading -------------------------------------------------------------------------------- /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 [hasMounted, setHasMounted] = useState(false); 13 | 14 | useEffect(() => { 15 | setHasMounted(true); 16 | }, []) 17 | 18 | if (!hasMounted) { 19 | return null; 20 | } 21 | 22 | return ( 23 |
24 | {children} 25 |
26 | ) 27 | } 28 | 29 | export default ClientOnly; -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/hooks/useCountries.ts: -------------------------------------------------------------------------------- 1 | import countries from "world-countries"; 2 | 3 | const formattedCountries = countries.map((country) => ({ 4 | value: country.cca2, 5 | label: country.name.common, 6 | flag: country.flag, 7 | latlng: country.latlng, 8 | region: country.region 9 | })); 10 | 11 | const useCountries = () => { 12 | const getAll = () => formattedCountries; 13 | 14 | const getByValue = (value: string) => { 15 | return formattedCountries.find((item) => item.value === value) 16 | } 17 | 18 | return { 19 | getAll, 20 | getByValue 21 | } 22 | } 23 | 24 | export default useCountries; -------------------------------------------------------------------------------- /app/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Listing, Reservation, User } from "@prisma/client"; 2 | 3 | export type SafeListing = Omit & { 4 | createdAt: string; 5 | }; 6 | 7 | export type SafeReservation = Omit< 8 | Reservation, 9 | "createdAt" | "startDate" | "endDate" | "listing" 10 | > & { 11 | createdAt: string; 12 | startDate: string; 13 | endDate: string; 14 | listing: SafeListing; 15 | }; 16 | 17 | export type SafeUser = Omit< 18 | User, 19 | "createdAt" | "updatedAt" | "emailVerified" 20 | > & { 21 | createdAt: string; 22 | updatedAt: string; 23 | emailVerified: string | null; 24 | }; 25 | -------------------------------------------------------------------------------- /app/api/register/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import bcrypt from "bcrypt"; 3 | 4 | import prisma from "@/app/libs/prismadb"; 5 | 6 | export async function POST( 7 | request: Request, 8 | ) { 9 | const body = await request.json(); 10 | const { 11 | email, 12 | name, 13 | password, 14 | } = body; 15 | 16 | const salt = await bcrypt.genSalt(10); 17 | 18 | const hashedPassword = await bcrypt.hash(password, salt); 19 | 20 | const user = await prisma.user.create({ 21 | data: { 22 | email, 23 | name, 24 | hashedPassword, 25 | } 26 | }); 27 | 28 | return NextResponse.json(user); 29 | } -------------------------------------------------------------------------------- /app/actions/getFavoriteListings.ts: -------------------------------------------------------------------------------- 1 | import prisma from '@/app/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) return []; 10 | 11 | const favorites = await prisma.listing.findMany({ 12 | where: { 13 | id: { 14 | in: [...(currentUser.favoriteIds || [])] 15 | } 16 | } 17 | }); 18 | 19 | const safeFavorites = favorites.map((favorite) => ({ 20 | ...favorite, 21 | createdAt: favorite.createdAt.toISOString() 22 | })); 23 | 24 | return safeFavorites; 25 | 26 | } catch (error: any) { 27 | throw new Error(error) 28 | } 29 | } -------------------------------------------------------------------------------- /app/components/inputs/CategoryInput.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { FC } from "react" 4 | import { IconType } from "react-icons" 5 | 6 | interface CategoryInputProps { 7 | onClick: (value: string) => void 8 | selected?: boolean 9 | label: string 10 | icon: IconType 11 | } 12 | 13 | const CategoryInput: FC = ({ onClick, selected, label, icon: Icon }) => { 14 | return ( 15 |
onClick(label)} 17 | className={`rounded-xl border-2 p-4 flex flex-col gap-3 hover:border-black transition cursor-pointer 18 | ${selected ? "border-black" : "border-neutral-200"}`} 19 | > 20 | 21 |
22 | {label} 23 |
24 |
25 | ) 26 | } 27 | 28 | export default CategoryInput; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ], 26 | "paths": { 27 | "@/*": [ 28 | "./*" 29 | ] 30 | } 31 | }, 32 | "include": [ 33 | "next-env.d.ts", 34 | "**/*.ts", 35 | "**/*.tsx", 36 | ".next/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } -------------------------------------------------------------------------------- /app/api/listings/[listingId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import getCurrentUser from "@/app/actions/getCurrentUser"; 4 | import prisma from "@/app/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 | } 35 | -------------------------------------------------------------------------------- /app/components/inputs/Calendar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | DateRange, 5 | Range, 6 | RangeKeyDict 7 | } from 'react-date-range'; 8 | 9 | import 'react-date-range/dist/styles.css'; 10 | import 'react-date-range/dist/theme/default.css'; 11 | 12 | interface DatePickerProps { 13 | value: Range, 14 | onChange: (value: RangeKeyDict) => void; 15 | disabledDates?: Date[]; 16 | } 17 | 18 | const DatePicker: React.FC = ({ 19 | value, 20 | onChange, 21 | disabledDates 22 | }) => { 23 | return ( 24 | 34 | ); 35 | } 36 | 37 | export default DatePicker; 38 | -------------------------------------------------------------------------------- /app/components/listings/ListingCategory.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FC } from "react"; 4 | import { IconType } from "react-icons"; 5 | 6 | interface ListingCategoryProps { 7 | icon: IconType; 8 | label: string; 9 | description: string; 10 | } 11 | 12 | const ListingCategory: FC = ({ icon: Icon, label, description }) => { 13 | return ( 14 |
15 |
16 | 17 |
18 |
19 | {label} 20 |
21 |
22 | {description} 23 |
24 |
25 |
26 |
27 | ) 28 | } 29 | 30 | export default ListingCategory -------------------------------------------------------------------------------- /app/api/reservations/[reservationId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import getCurrentUser from "@/app/actions/getCurrentUser"; 4 | import prisma from "@/app/libs/prismadb"; 5 | 6 | interface IParams { 7 | reservationId?: 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 { reservationId } = params; 21 | 22 | if (!reservationId || typeof reservationId !== 'string') { 23 | throw new Error('Invalid ID'); 24 | } 25 | 26 | const reservation = await prisma.reservation.deleteMany({ 27 | where: { 28 | id: reservationId, 29 | OR: [ 30 | { userId: currentUser.id }, 31 | { listing: { userId: currentUser.id } } 32 | ] 33 | } 34 | }); 35 | 36 | return NextResponse.json(reservation); 37 | } 38 | -------------------------------------------------------------------------------- /app/components/navbar/index.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | // Components 4 | import Container from "../Container"; 5 | import Logo from "./Logo"; 6 | import Search from "./Search"; 7 | import UserMenu from "./UserMenu"; 8 | import { FC } from "react"; 9 | import { SafeUser } from "@/app/types"; 10 | import Categories from "./Categories"; 11 | 12 | interface NavbarProps { 13 | currentUser?: SafeUser | null 14 | } 15 | 16 | const Navbar: FC = ({ currentUser }) => { 17 | 18 | return ( 19 |
20 |
21 | 22 |
23 | 24 | 25 | 26 |
27 |
28 |
29 | 30 |
31 | ); 32 | } 33 | 34 | export default Navbar; -------------------------------------------------------------------------------- /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 | 35 | } catch (error: any) { 36 | throw new Error(error) 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 "@/app/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 | 14 | if (!session?.user?.email) { 15 | return null; 16 | } 17 | 18 | const currentUser = await prisma.user.findUnique({ 19 | where: { 20 | email: session.user.email as string, 21 | } 22 | }); 23 | 24 | if (!currentUser) { 25 | return null; 26 | } 27 | 28 | return { 29 | ...currentUser, 30 | createdAt: currentUser.createdAt.toISOString(), 31 | updatedAt: currentUser.updatedAt.toISOString(), 32 | emailVerified: currentUser.emailVerified?.toISOString() || null, 33 | }; 34 | } catch (error: any) { 35 | return null; 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /app/favorites/page.tsx: -------------------------------------------------------------------------------- 1 | import EmptyState from "../components/EmptyState" 2 | import ClientOnly from "../components/ClientOnly" 3 | import FavoritesClient from "./FavoritesClient" 4 | 5 | import getCurrentUser from "../actions/getCurrentUser" 6 | import getFavoriteListings from "../actions/getFavoriteListings" 7 | 8 | export const metadata = { 9 | title: 'Airbnb | Favorites', 10 | } 11 | 12 | const ListingPage = async () => { 13 | 14 | const listings = await getFavoriteListings(); 15 | const currentUser = await getCurrentUser(); 16 | 17 | if (listings.length === 0) { 18 | 19 | return ( 20 | 21 | 25 | 26 | ) 27 | } 28 | 29 | return ( 30 | 31 | 35 | 36 | ) 37 | } 38 | 39 | export default ListingPage; -------------------------------------------------------------------------------- /app/components/HeartButton.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { FC } from "react" 4 | import { SafeUser } from "../types"; 5 | import { AiFillHeart, AiOutlineHeart } from "react-icons/ai"; 6 | import useFavorite from "../hooks/useFavorite"; 7 | 8 | interface HeartButtonProps { 9 | listingId: string; 10 | currentUser?: SafeUser | null 11 | } 12 | 13 | const HeartButton: FC = ({ listingId, currentUser }) => { 14 | 15 | const { hasFavorited, toggleFavorite } = useFavorite({ 16 | listingId, 17 | currentUser 18 | }); 19 | 20 | 21 | return ( 22 |
26 | 30 | 36 |
37 | ) 38 | } 39 | 40 | export default HeartButton -------------------------------------------------------------------------------- /app/components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { FC } from "react"; 5 | import Heading from "./Heading"; 6 | import Button from "./Button"; 7 | 8 | interface EmptyStateProps { 9 | title?: string; 10 | subtitle?: string; 11 | showReset?: boolean; 12 | } 13 | 14 | const EmptyState: FC = ({ title = "No exact matches", subtitle = "Try changing or removing some of your filters", showReset }) => { 15 | 16 | const router = useRouter(); 17 | 18 | return ( 19 |
20 | 25 |
26 | {showReset && ( 27 |
34 |
35 | ) 36 | } 37 | 38 | export default EmptyState -------------------------------------------------------------------------------- /app/favorites/FavoritesClient.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react" 2 | import { SafeListing, SafeUser } from "../types" 3 | import Container from "../components/Container"; 4 | import Heading from "../components/Heading"; 5 | import ListingCard from "../components/listings/ListingCard"; 6 | 7 | interface FavoritesClientProps { 8 | listings: SafeListing[]; 9 | currentUser?: SafeUser | null; 10 | } 11 | 12 | const FavoritesClient: FC = ({ listings, currentUser }) => { 13 | return ( 14 | 15 | 19 |
20 | {listings.map((listing) => ( 21 | 26 | ))} 27 |
28 |
29 | ) 30 | } 31 | 32 | export default FavoritesClient -------------------------------------------------------------------------------- /app/api/reservations/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import prisma from "@/app/libs/prismadb"; 4 | import getCurrentUser from "@/app/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 | const { 17 | listingId, 18 | startDate, 19 | endDate, 20 | totalPrice 21 | } = body; 22 | 23 | if (!listingId || !startDate || !endDate || !totalPrice) { 24 | return NextResponse.error(); 25 | } 26 | 27 | const listingAndReservation = await prisma.listing.update({ 28 | where: { 29 | id: listingId 30 | }, 31 | data: { 32 | reservations: { 33 | create: { 34 | userId: currentUser.id, 35 | startDate, 36 | endDate, 37 | totalPrice, 38 | } 39 | } 40 | } 41 | }); 42 | 43 | return NextResponse.json(listingAndReservation); 44 | } 45 | -------------------------------------------------------------------------------- /app/listings/[listingId]/page.tsx: -------------------------------------------------------------------------------- 1 | import getCurrentUser from '@/app/actions/getCurrentUser'; 2 | import getListingById from '@/app/actions/getListingById' 3 | import ClientOnly from '@/app/components/ClientOnly'; 4 | import EmptyState from '@/app/components/EmptyState'; 5 | import React from 'react' 6 | import ListingClient from './ListingClient'; 7 | import getReservations from '@/app/actions/getReservations'; 8 | 9 | export const metadata = { 10 | title: 'Airbnb | Listings', 11 | } 12 | 13 | interface IParams { 14 | listingId?: string; 15 | } 16 | 17 | const ListingPage = async ({ params }: { params: IParams }) => { 18 | 19 | const listing = await getListingById(params); 20 | const reservations = await getReservations(params); 21 | const currentUser = await getCurrentUser(); 22 | 23 | if (!listing) { 24 | return ( 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | return ( 32 | 33 | 38 | 39 | ) 40 | } 41 | 42 | export default ListingPage -------------------------------------------------------------------------------- /app/api/listings/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import prisma from '@/app/libs/prismadb'; 4 | import getCurrentUser from "@/app/actions/getCurrentUser"; 5 | 6 | export async function POST(request: Request) { 7 | const currentUser = await getCurrentUser(); 8 | 9 | if (!currentUser) { 10 | return NextResponse.error(); 11 | } 12 | 13 | const body = await request.json(); 14 | const { 15 | title, 16 | description, 17 | imageSrc, 18 | category, 19 | roomCount, 20 | bathroomCount, 21 | guestCount, 22 | location, 23 | price 24 | } = body; 25 | 26 | // if one of the body item is empty -> 27 | Object.keys(body).forEach((value: any) => { 28 | if (!body[value]) { 29 | NextResponse.error() 30 | } 31 | }); 32 | 33 | const listing = await prisma.listing.create({ 34 | data: { 35 | title, 36 | description, 37 | imageSrc, 38 | category, 39 | roomCount, 40 | bathroomCount, 41 | guestCount, 42 | locationValue: location.value, 43 | price: parseInt(price, 10), 44 | userId: currentUser.id 45 | } 46 | }); 47 | 48 | return NextResponse.json(listing); 49 | } -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/actions/getReservations.ts: -------------------------------------------------------------------------------- 1 | import prisma from '@/app/libs/prismadb' 2 | 3 | interface IParams { 4 | listingId?: string; 5 | userId?: string; 6 | authorId?: string; 7 | } 8 | 9 | export default async function getReservations(params: IParams) { 10 | 11 | try { 12 | const { listingId, userId, authorId } = params; 13 | 14 | const query: any = {}; 15 | 16 | if (listingId) query.listingId = listingId; 17 | 18 | if (userId) query.userId = userId 19 | 20 | if (authorId) query.listing = { userId: authorId } 21 | 22 | const reservations = await prisma.reservation.findMany({ 23 | where: query, 24 | include: { 25 | listing: true 26 | }, 27 | orderBy: { 28 | createdAt: "desc" 29 | } 30 | }); 31 | 32 | const safeReservations = reservations.map((reservation) => ({ 33 | ...reservation, 34 | createdAt: reservation.createdAt.toISOString(), 35 | startDate: reservation.startDate.toISOString(), 36 | endDate: reservation.endDate.toISOString(), 37 | listing: { 38 | ...reservation.listing, 39 | createdAt: reservation.listing.createdAt.toISOString() 40 | } 41 | })); 42 | 43 | return safeReservations; 44 | 45 | } catch (error: any) { 46 | throw new Error(error) 47 | } 48 | } -------------------------------------------------------------------------------- /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 | export const metadata = { 9 | title: 'Airbnb | Trips', 10 | } 11 | 12 | const TripsPage = async () => { 13 | const currentUser = await getCurrentUser(); 14 | 15 | if (!currentUser) { 16 | return ( 17 | 18 | 22 | 23 | ) 24 | 25 | } 26 | 27 | const reservations = await getReservations({ 28 | userId: currentUser?.id 29 | }); 30 | 31 | if (reservations.length === 0) { 32 | return ( 33 | 34 | 38 | 39 | ) 40 | } 41 | 42 | return ( 43 | 44 | 48 | 49 | ) 50 | 51 | } 52 | 53 | export default TripsPage; -------------------------------------------------------------------------------- /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 | export const metadata = { 9 | title: 'Airbnb | Properties', 10 | } 11 | 12 | const PropertiesPage = async () => { 13 | const currentUser = await getCurrentUser(); 14 | 15 | if (!currentUser) { 16 | return ( 17 | 18 | 22 | 23 | ) 24 | 25 | } 26 | 27 | const listings = await getListings({ 28 | userId: currentUser?.id 29 | }); 30 | 31 | if (listings.length === 0) { 32 | return ( 33 | 34 | 38 | 39 | ) 40 | } 41 | 42 | return ( 43 | 44 | 48 | 49 | ) 50 | 51 | } 52 | 53 | export default PropertiesPage; -------------------------------------------------------------------------------- /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 | import { FC } from "react"; 11 | 12 | // @ts-ignore 13 | delete L.Icon.Default.prototype._getIconUrl; 14 | L.Icon.Default.mergeOptions({ 15 | iconUrl: markerIcon.src, 16 | iconRetinaUrl: markerIcon2x.src, 17 | shadowUrl: markerShadow.src 18 | }); 19 | 20 | interface MapProps { 21 | center?: number[] 22 | } 23 | 24 | 25 | const Map: FC = ({ center }) => { 26 | return ( 27 | 33 | 37 | {center && ( 38 | 41 | )} 42 | 43 | ) 44 | } 45 | 46 | export default Map -------------------------------------------------------------------------------- /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 ReservationsClient from "./ReservationsClient" 7 | 8 | export const metadata = { 9 | title: 'Airbnb | Reservations', 10 | } 11 | 12 | const Reservations = async () => { 13 | 14 | const currentUser = await getCurrentUser(); 15 | 16 | if (!currentUser) { 17 | return ( 18 | 19 | 23 | 24 | ); 25 | } 26 | 27 | const reservations = await getReservations({ 28 | authorId: currentUser.id 29 | }) 30 | 31 | if (reservations.length === 0) { 32 | return ( 33 | 34 | 38 | 39 | ) 40 | } 41 | 42 | 43 | return ( 44 | 45 | 49 | 50 | ) 51 | } 52 | 53 | export default Reservations -------------------------------------------------------------------------------- /app/components/listings/ListingHead.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import useCountries from "@/app/hooks/useCountries"; 4 | import { SafeUser } from "@/app/types"; 5 | import { FC } from "react"; 6 | import Heading from "../Heading"; 7 | import Image from "next/image"; 8 | import HeartButton from "../HeartButton"; 9 | 10 | interface ListingHeadProps { 11 | title: string; 12 | locationValue: string; 13 | imageSrc: string; 14 | id: string; 15 | currentUser?: SafeUser | null; 16 | } 17 | 18 | const ListingHead: FC = ({ title, locationValue, imageSrc, id, currentUser }) => { 19 | 20 | const { getByValue } = useCountries(); 21 | 22 | const location = getByValue(locationValue); 23 | 24 | return ( 25 | <> 26 | 30 |
31 | Image 37 |
38 | 42 |
43 |
44 | 45 | ) 46 | } 47 | 48 | export default ListingHead -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Nunito } from 'next/font/google'; 2 | 3 | import './globals.css' 4 | 5 | import ToastProvider from './providers/ToasterProvider'; 6 | import getCurrentUser from './actions/getCurrentUser'; 7 | // Components 8 | import Navbar from './components/navbar'; 9 | import ClientOnly from './components/ClientOnly'; 10 | // Modals 11 | import RegisterModal from './components/modals/RegisterModal'; 12 | import LoginModal from './components/modals/LoginModal'; 13 | import RentModal from './components/modals/RentModal'; 14 | import SearchModal from './components/modals/SearchModal'; 15 | 16 | export const metadata = { 17 | title: 'Airbnb | Home', 18 | description: 'Airbnb clone', 19 | icon: { 20 | url: "/favicon.png", 21 | type: "image/png", 22 | }, 23 | shortcut: { url: "/favicon.png", type: "image/png" }, 24 | } 25 | 26 | const font = Nunito({ 27 | subsets: ["latin"] 28 | }) 29 | 30 | export default async function RootLayout({ children, }: { children: React.ReactNode }) { 31 | 32 | const currentUser = await getCurrentUser(); 33 | 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
46 | {children} 47 |
48 | 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /app/components/Button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { IconType } from "react-icons"; 4 | 5 | interface ButtonProps { 6 | label: string; 7 | onClick: (e: React.MouseEvent) => void; 8 | disabled?: boolean; 9 | outline?: boolean; 10 | small?: boolean; 11 | icon?: IconType; 12 | mtAuto?: boolean; 13 | } 14 | 15 | const Button: React.FC = ({ 16 | label, 17 | onClick, 18 | disabled, 19 | outline, 20 | small, 21 | icon: Icon, 22 | mtAuto 23 | }) => { 24 | return ( 25 | 53 | ); 54 | } 55 | 56 | export default Button; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airbnb-clone", 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 | "postinstall": "prisma generate" 11 | }, 12 | "dependencies": { 13 | "@next-auth/prisma-adapter": "^1.0.5", 14 | "@prisma/client": "^4.11.0", 15 | "@types/node": "18.15.5", 16 | "@types/react": "18.0.28", 17 | "@types/react-dom": "18.0.11", 18 | "axios": "^1.3.4", 19 | "bcrypt": "^5.1.0", 20 | "date-fns": "^2.29.3", 21 | "eslint": "8.36.0", 22 | "eslint-config-next": "13.2.4", 23 | "leaflet": "^1.9.3", 24 | "next": "13.2.4", 25 | "next-auth": "^4.20.1", 26 | "next-cloudinary": "^4.0.1", 27 | "next-superjson-plugin": "^0.5.6", 28 | "query-string": "^8.1.0", 29 | "react": "18.2.0", 30 | "react-date-range": "^1.4.0", 31 | "react-dom": "18.2.0", 32 | "react-hook-form": "^7.43.7", 33 | "react-hot-toast": "^2.4.0", 34 | "react-icons": "^4.8.0", 35 | "react-leaflet": "^4.2.1", 36 | "react-select": "^5.7.2", 37 | "react-spinners": "^0.13.8", 38 | "swr": "^2.1.1", 39 | "typescript": "5.0.2", 40 | "world-countries": "^4.0.0", 41 | "zustand": "^4.3.6" 42 | }, 43 | "devDependencies": { 44 | "@types/bcrypt": "^5.0.0", 45 | "@types/leaflet": "^1.9.3", 46 | "@types/react-date-range": "^1.4.4", 47 | "autoprefixer": "^10.4.14", 48 | "postcss": "^8.4.21", 49 | "prisma": "^4.11.0", 50 | "tailwindcss": "^3.2.7" 51 | } 52 | } -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ClientOnly from './components/ClientOnly' 3 | import Container from './components/Container' 4 | import EmptyState from './components/EmptyState'; 5 | import getListings, { IListingsParams } from './actions/getListings'; 6 | import ListingCard from './components/listings/ListingCard'; 7 | import getCurrentUser from './actions/getCurrentUser'; 8 | 9 | interface PageProps { 10 | searchParams: IListingsParams 11 | } 12 | 13 | const Page = async ({ searchParams }: PageProps) => { 14 | 15 | 16 | const listings = await getListings(searchParams); 17 | const currentUser = await getCurrentUser(); 18 | 19 | if (listings.length === 0) { 20 | return ( 21 | 22 | 23 | 24 | ) 25 | } 26 | 27 | 28 | return ( 29 | 30 | 31 |
32 | {listings.map((listing) => { 33 | return ( 34 | 39 | ) 40 | })} 41 |
42 |
43 |
44 | ) 45 | } 46 | 47 | export default Page -------------------------------------------------------------------------------- /app/hooks/useFavorite.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { useCallback, useMemo } from "react"; 5 | import useLoginModal from "./useLoginModal"; 6 | 7 | import { toast } from "react-hot-toast"; 8 | 9 | import { SafeUser } from "../types"; 10 | 11 | 12 | interface IUseFavorite { 13 | listingId: string; 14 | currentUser?: SafeUser | null 15 | } 16 | 17 | const useFavorite = ({ listingId, currentUser }: IUseFavorite) => { 18 | 19 | const router = useRouter(); 20 | const loginModal = useLoginModal(); 21 | 22 | const hasFavorited = useMemo(() => { 23 | const list = currentUser?.favoriteIds || []; 24 | 25 | return list.includes(listingId) 26 | }, [currentUser, listingId]); 27 | 28 | const toggleFavorite = useCallback(async (e: React.MouseEvent) => { 29 | e.stopPropagation(); 30 | 31 | if (!currentUser) return loginModal.onOpen(); 32 | 33 | try { 34 | let request; 35 | 36 | if (hasFavorited) { 37 | request = () => axios.delete(`/api/favorites/${listingId}`) 38 | } else { 39 | request = () => axios.post(`/api/favorites/${listingId}`) 40 | } 41 | 42 | await request(); 43 | router.refresh(); 44 | toast.success("Success"); 45 | 46 | } catch (error) { 47 | toast.error("Something went wrong!"); 48 | } 49 | 50 | }, [currentUser, hasFavorited, listingId, loginModal, router]); 51 | 52 | return { 53 | hasFavorited, 54 | toggleFavorite 55 | } 56 | } 57 | 58 | export default useFavorite; -------------------------------------------------------------------------------- /app/components/inputs/index.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { FieldErrors, FieldValues, UseFormRegister } from "react-hook-form"; 4 | import { BiDollar } from "react-icons/bi"; 5 | 6 | interface InputProps { 7 | id: string; 8 | label: string; 9 | type?: string; 10 | disabled?: boolean; 11 | formatPrice?: boolean; 12 | required?: boolean; 13 | register: UseFormRegister; 14 | errors: FieldErrors 15 | } 16 | 17 | const Input: React.FC = ({ 18 | id, 19 | label, 20 | type = "text", 21 | disabled, 22 | formatPrice, 23 | register, 24 | required, 25 | errors 26 | }) => { 27 | return ( 28 |
29 | {formatPrice && ( 30 | 31 | )} 32 | 37 | 47 | 48 |
49 | ) 50 | } 51 | 52 | export default Input -------------------------------------------------------------------------------- /app/api/favorites/[listingId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import getCurrentUser from "@/app/actions/getCurrentUser"; 4 | import prisma from '@/app/libs/prismadb'; 5 | 6 | interface IParams { 7 | listingId?: string; 8 | } 9 | 10 | export async function POST( 11 | request: Request, 12 | { params }: { params: IParams } 13 | ) { 14 | const currentUser = await getCurrentUser(); 15 | 16 | if (!currentUser) return NextResponse.error(); 17 | 18 | const { listingId } = params; 19 | 20 | if (!listingId || typeof listingId !== "string") throw new Error("Invalid ID"); 21 | 22 | let favoriteIds = [...(currentUser.favoriteIds || [])] 23 | 24 | favoriteIds.push(listingId); 25 | 26 | const user = await prisma.user.update({ 27 | where: { 28 | id: currentUser.id 29 | }, 30 | data: { 31 | favoriteIds 32 | } 33 | }); 34 | 35 | return NextResponse.json(user) 36 | } 37 | 38 | export async function DELETE( 39 | request: Request, 40 | { params }: { params: IParams } 41 | ) { 42 | const currentUser = await getCurrentUser(); 43 | 44 | if (!currentUser) return NextResponse.error(); 45 | 46 | const { listingId } = params; 47 | 48 | if (!listingId || typeof listingId !== "string") throw new Error("Invalid ID"); 49 | 50 | let favoriteIds = [...(currentUser.favoriteIds || [])] 51 | 52 | favoriteIds = favoriteIds.filter((id) => id !== listingId); 53 | 54 | const user = await prisma.user.update({ 55 | where: { 56 | id: currentUser.id 57 | }, 58 | data: { 59 | favoriteIds 60 | } 61 | }); 62 | 63 | return NextResponse.json(user); 64 | } 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Airbnb Clone

2 | 3 |
4 | 5 | ↪️ Demo 6 |
7 |
8 | 9 |
10 | 11 |
12 | 13 |
14 | 15 |

✨ About The Project

16 | 17 | - In this project, I made an airbnb project with "Code with antonio". How to use modals in general in my project, how to use nextjs and prisma together, how to use nextAuth package of nextjs, how to signIn with github or google, how to use nextjs together with typescript, how to optimize operations such as map and calendar and integrate them into the project. I've worked with things and I think it's a pretty cool project. 18 | 19 |

📌 Build With

20 | 21 | - [NextJs](https://nextjs.org/) 22 | - [Prisma](https://www.prisma.io/) 23 | - [Typescript](https://www.typescriptlang.org/) 24 | - [Tailwindcss](https://tailwindcss.com/) 25 | - [NextAuth](https://next-auth.js.org/) 26 | - [React Hook Form](https://react-hook-form.com/) 27 | - [Zustand](https://github.com/pmndrs/zustand) 28 | 29 |

🔍 Setup

30 | 31 | - Clone the project with **"git clone"** 32 | 33 | - After cloning the project, by following these steps, you will fulfill the project requirements. 34 | 35 | - install with npm: 36 | 37 | ```npm 38 | npm i 39 | ``` 40 | 41 | 42 | - After downloading the requirements, run below codes: 43 | - Run with npm: 44 | ```npm 45 | npm run dev 46 | ``` 47 | - Run with yarn: 48 | ```yarn 49 | yarn dev 50 | ``` 51 | 52 | 53 |

📧 Contact

54 | 55 | Mucahit Tasan - [Linkedin](https://www.linkedin.com/in/mucahittasan) - [E-mail](mailto:mucahittasan0@gmail.com) 56 | -------------------------------------------------------------------------------- /app/components/CategoryBox.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSearchParams, useRouter } from "next/navigation"; 4 | import { FC, useCallback } from "react"; 5 | import { IconType } from "react-icons"; 6 | import qs from 'query-string' 7 | 8 | interface CategoryBoxProps { 9 | icon: IconType, 10 | label: string; 11 | selected?: boolean; 12 | } 13 | 14 | 15 | const CategoryBox: FC = ({ icon: Icon, label, selected }) => { 16 | 17 | const router = useRouter(); 18 | const params = useSearchParams(); 19 | 20 | const handleClick = useCallback(() => { 21 | let currentQuery = {}; 22 | 23 | if (params) { 24 | currentQuery = qs.parse(params.toString()); 25 | } 26 | 27 | const udpatedQuery: any = { 28 | ...currentQuery, 29 | category: label 30 | } 31 | 32 | // If click same category, that category will remove 33 | if (params?.get("category") === label) { 34 | delete udpatedQuery.category; 35 | } 36 | 37 | const url = qs.stringifyUrl({ 38 | url: "/", 39 | query: udpatedQuery 40 | }, { skipNull: true }); 41 | 42 | router.push(url); 43 | 44 | }, [label, params, router]); 45 | 46 | return ( 47 |
63 | 64 |
65 | {label} 66 |
67 |
68 | ) 69 | } 70 | 71 | export default CategoryBox -------------------------------------------------------------------------------- /app/components/inputs/CountrySelect.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import useCountries from "@/app/hooks/useCountries"; 4 | import { FC } from "react"; 5 | 6 | import Select from 'react-select'; 7 | 8 | export type CountrySelectValue = { 9 | flag: string; 10 | label: string; 11 | latlng: number[]; 12 | region: string; 13 | value: string; 14 | } 15 | 16 | interface CountrySelectProps { 17 | value?: CountrySelectValue; 18 | onChange: (value: CountrySelectValue) => void; 19 | } 20 | 21 | const CountrySelect: FC = ({ value, onChange }) => { 22 | 23 | const { getAll } = useCountries(); 24 | 25 | return ( 26 |
27 | 77 | 78 |
79 | ) 80 | 81 | const footerContent = ( 82 |
83 |
84 |
110 | ) 111 | 112 | return ( 113 | 123 | ) 124 | } 125 | 126 | export default LoginModal -------------------------------------------------------------------------------- /app/components/modals/index.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { IoMdClose } from 'react-icons/io' 4 | import Button from "../Button"; 5 | 6 | interface ModalProps { 7 | isOpen?: boolean; 8 | onClose: () => void; 9 | onSubmit: () => void; 10 | title?: string; 11 | body?: React.ReactElement; 12 | footer?: React.ReactElement; 13 | actionLabel: string; 14 | disabled?: boolean; 15 | secondaryAction?: () => void; 16 | secondaryActionLabel?: string; 17 | } 18 | 19 | const Modal: React.FC = ({ 20 | isOpen, 21 | onClose, 22 | onSubmit, 23 | title, 24 | body, 25 | footer, 26 | actionLabel, 27 | disabled, 28 | secondaryAction, 29 | secondaryActionLabel 30 | }) => { 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 | }, [onSubmit, disabled]); 56 | 57 | const handleSecondaryAction = useCallback(() => { 58 | if (disabled || !secondaryAction) { 59 | return; 60 | } 61 | 62 | secondaryAction() 63 | }, [disabled, secondaryAction]); 64 | 65 | if (!isOpen) { 66 | return null; 67 | } 68 | 69 | return ( 70 | <> 71 |
72 |
73 | {/* CONTENT */} 74 |
75 |
76 | {/* HEADER */} 77 |
78 | 83 |
84 | {title} 85 |
86 |
87 | {/* BODY */} 88 |
89 | {body} 90 |
91 | {/* FOOTER */} 92 |
93 |
94 | {secondaryAction && secondaryActionLabel && ( 95 |
109 | {footer} 110 |
111 |
112 |
113 |
114 |
115 | 116 | ) 117 | } 118 | 119 | export default Modal -------------------------------------------------------------------------------- /app/components/modals/RegisterModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import axios from "axios"; 4 | import { AiFillGithub } from "react-icons/ai"; 5 | import { signIn } from "next-auth/react"; 6 | import { FcGoogle } from "react-icons/fc"; 7 | import { useCallback, useState } from "react"; 8 | import { toast } from "react-hot-toast"; 9 | import { 10 | FieldValues, 11 | SubmitHandler, 12 | useForm 13 | } from "react-hook-form"; 14 | 15 | import useLoginModal from "@/app/hooks/useLoginModal"; 16 | import useRegisterModal from "@/app/hooks/useRegisterModal"; 17 | 18 | import Modal from "./"; 19 | import Input from "../inputs"; 20 | import Heading from "../Heading"; 21 | import Button from "../Button"; 22 | 23 | const RegisterModal = () => { 24 | const registerModal = useRegisterModal(); 25 | const loginModal = useLoginModal(); 26 | const [isLoading, setIsLoading] = useState(false); 27 | 28 | const { 29 | register, 30 | handleSubmit, 31 | formState: { 32 | errors, 33 | }, 34 | } = useForm({ 35 | defaultValues: { 36 | name: '', 37 | email: '', 38 | password: '' 39 | }, 40 | }); 41 | 42 | const onSubmit: SubmitHandler = (data) => { 43 | setIsLoading(true); 44 | 45 | axios.post('/api/register', data) 46 | .then(() => { 47 | toast.success('Registered!'); 48 | registerModal.onClose(); 49 | loginModal.onOpen(); 50 | }) 51 | .catch((error) => { 52 | toast.error(error); 53 | }) 54 | .finally(() => { 55 | setIsLoading(false); 56 | }) 57 | } 58 | 59 | const onToggle = useCallback(() => { 60 | registerModal.onClose(); 61 | loginModal.onOpen(); 62 | }, [registerModal, loginModal]) 63 | 64 | const bodyContent = ( 65 |
66 | 70 | 78 | 86 | 95 |
96 | ) 97 | 98 | const footerContent = ( 99 |
100 |
101 |
133 | ) 134 | 135 | return ( 136 | 146 | ); 147 | } 148 | 149 | export default RegisterModal; 150 | -------------------------------------------------------------------------------- /app/components/modals/SearchModal.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import qs from 'query-string' 4 | import useSearchModal from "@/app/hooks/useSearchModal" 5 | import Modal from "." 6 | import { useRouter, useSearchParams } from "next/navigation" 7 | import { useCallback, useMemo, useState } from "react" 8 | import { Range } from "react-date-range" 9 | import dynamic from "next/dynamic" 10 | import CountrySelect, { CountrySelectValue } from "../inputs/CountrySelect" 11 | import { formatISO } from 'date-fns' 12 | import Heading from '../Heading' 13 | import Calendar from "../inputs/Calendar"; 14 | import Counter from '../inputs/Counter' 15 | 16 | enum STEPS { 17 | LOCATION = 0, 18 | DATE = 1, 19 | INFO = 2 20 | } 21 | 22 | const SearchModal = () => { 23 | 24 | const router = useRouter(); 25 | const params = useSearchParams(); 26 | const searchModal = useSearchModal(); 27 | 28 | const [location, setLocation] = useState(); 29 | const [step, setStep] = useState(STEPS.LOCATION); 30 | const [guestCount, setGuestCount] = useState(1); 31 | const [roomCount, setRoomCount] = useState(1); 32 | const [bathroomCount, setBathroomCount] = useState(1); 33 | const [dateRange, setDateRange] = useState({ 34 | startDate: new Date(), 35 | endDate: new Date(), 36 | key: "selection" 37 | }) 38 | 39 | const Map = useMemo(() => dynamic(() => import("../Map"), { 40 | ssr: false 41 | }), [location]); 42 | 43 | const onBack = useCallback(() => { 44 | setStep((value) => value - 1); 45 | }, []) 46 | 47 | const onNext = useCallback(() => { 48 | setStep((value) => value + 1); 49 | }, []) 50 | 51 | const onSubmit = useCallback(async () => { 52 | if (step !== STEPS.INFO) { 53 | return onNext(); 54 | } 55 | 56 | let currentQuery = {}; 57 | 58 | if (params) { 59 | currentQuery = qs.parse(params.toString()); 60 | 61 | } 62 | 63 | const updatedQuery: any = { 64 | ...currentQuery, 65 | locationValue: location?.value, 66 | guestCount, 67 | roomCount, 68 | bathroomCount 69 | }; 70 | 71 | if (dateRange.startDate) { 72 | updatedQuery.startDate = formatISO(dateRange.startDate); 73 | } 74 | 75 | if (dateRange.endDate) { 76 | updatedQuery.endDate = formatISO(dateRange.endDate) 77 | } 78 | 79 | const url = qs.stringifyUrl({ 80 | url: '', 81 | query: updatedQuery 82 | }, { skipNull: true }); 83 | 84 | setStep(STEPS.LOCATION); 85 | searchModal.onClose(); 86 | 87 | router.push(url); 88 | 89 | }, [step, searchModal, location, router, guestCount, roomCount, bathroomCount, dateRange, onNext, params]); 90 | 91 | const actionLabel = useMemo(() => { 92 | if (step === STEPS.INFO) { 93 | return "Search"; 94 | } 95 | 96 | return "Next"; 97 | }, [step]) 98 | 99 | const secondaryActionLabel = useMemo(() => { 100 | if (step === STEPS.LOCATION) { 101 | return undefined; 102 | } 103 | 104 | return "Back"; 105 | }, [step]) 106 | 107 | let bodyContent = ( 108 |
109 | 113 | { 116 | setLocation(value as CountrySelectValue) 117 | }} 118 | /> 119 |
120 | 121 |
122 | ) 123 | 124 | if (step === STEPS.DATE) { 125 | bodyContent = ( 126 |
127 | 131 | setDateRange(value.selection)} 134 | 135 | /> 136 |
137 | ) 138 | } 139 | 140 | if (step === STEPS.INFO) { 141 | bodyContent = ( 142 |
143 | 147 | setGuestCount(value)} 152 | /> 153 | setRoomCount(value)} 158 | /> 159 | setBathroomCount(value)} 164 | /> 165 |
166 | ) 167 | } 168 | 169 | return ( 170 | 180 | ) 181 | } 182 | 183 | export default SearchModal -------------------------------------------------------------------------------- /app/listings/[listingId]/ListingClient.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import axios from "axios"; 4 | import { useCallback, useEffect, useMemo, useState } from "react"; 5 | import { toast } from "react-hot-toast"; 6 | import { Range } from "react-date-range"; 7 | import { useRouter } from "next/navigation"; 8 | import { differenceInDays, eachDayOfInterval } from 'date-fns'; 9 | 10 | import useLoginModal from "@/app/hooks/useLoginModal"; 11 | import { SafeListing, SafeReservation, SafeUser } from "@/app/types"; 12 | 13 | import Container from "@/app/components/Container"; 14 | import { categories } from "@/app/components/navbar/Categories"; 15 | import ListingHead from "@/app/components/listings/ListingHead"; 16 | import ListingInfo from "@/app/components/listings/ListingInfo"; 17 | import ListingReservation from "@/app/components/listings/ListingReservation"; 18 | 19 | const initialDateRange = { 20 | startDate: new Date(), 21 | endDate: new Date(), 22 | key: 'selection' 23 | }; 24 | 25 | interface ListingClientProps { 26 | reservations?: SafeReservation[]; 27 | listing: SafeListing & { 28 | user: SafeUser; 29 | }; 30 | currentUser?: SafeUser | null; 31 | } 32 | 33 | const ListingClient: React.FC = ({ 34 | listing, 35 | reservations = [], 36 | currentUser 37 | }) => { 38 | const loginModal = useLoginModal(); 39 | const router = useRouter(); 40 | 41 | const disabledDates = useMemo(() => { 42 | let dates: Date[] = []; 43 | 44 | reservations.forEach((reservation: any) => { 45 | const range = eachDayOfInterval({ 46 | start: new Date(reservation.startDate), 47 | end: new Date(reservation.endDate) 48 | }); 49 | 50 | dates = [...dates, ...range]; 51 | }); 52 | 53 | return dates; 54 | }, [reservations]); 55 | 56 | const category = useMemo(() => { 57 | return categories.find((items) => 58 | items.label === listing.category); 59 | }, [listing.category]); 60 | 61 | const [isLoading, setIsLoading] = useState(false); 62 | const [totalPrice, setTotalPrice] = useState(listing.price); 63 | const [dateRange, setDateRange] = useState(initialDateRange); 64 | 65 | const onCreateReservation = useCallback(() => { 66 | if (!currentUser) { 67 | return loginModal.onOpen(); 68 | } 69 | setIsLoading(true); 70 | 71 | axios.post('/api/reservations', { 72 | totalPrice, 73 | startDate: dateRange.startDate, 74 | endDate: dateRange.endDate, 75 | listingId: listing?.id 76 | }) 77 | .then(() => { 78 | toast.success('Listing reserved!'); 79 | setDateRange(initialDateRange); 80 | router.push("/trips"); 81 | }) 82 | .catch(() => { 83 | toast.error('Something went wrong.'); 84 | }) 85 | .finally(() => { 86 | setIsLoading(false); 87 | }) 88 | }, 89 | [ 90 | totalPrice, 91 | dateRange, 92 | listing?.id, 93 | router, 94 | currentUser, 95 | loginModal 96 | ]); 97 | 98 | useEffect(() => { 99 | if (dateRange.startDate && dateRange.endDate) { 100 | const dayCount = differenceInDays( 101 | dateRange.endDate, 102 | dateRange.startDate 103 | ); 104 | 105 | if (dayCount && listing.price) { 106 | setTotalPrice(dayCount * listing.price); 107 | } else { 108 | setTotalPrice(listing.price); 109 | } 110 | } 111 | }, [dateRange, listing.price]); 112 | 113 | return ( 114 | 115 |
121 |
122 | 129 |
138 | 147 |
155 | setDateRange(value)} 159 | dateRange={dateRange} 160 | onSubmit={onCreateReservation} 161 | disabled={isLoading} 162 | disabledDates={disabledDates} 163 | /> 164 |
165 |
166 |
167 |
168 |
169 | ); 170 | } 171 | 172 | export default ListingClient; 173 | -------------------------------------------------------------------------------- /app/components/modals/RentModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | 4 | import { useMemo, useState } from "react"; 5 | import { useRouter } from "next/navigation"; 6 | import useRentModal from "@/app/hooks/useRentModal" 7 | 8 | import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; 9 | 10 | import Modal from "." 11 | import Heading from "../Heading"; 12 | import { categories } from "../navbar/Categories"; 13 | import CategoryInput from "../inputs/CategoryInput"; 14 | import CountrySelect from "../inputs/CountrySelect"; 15 | import dynamic from "next/dynamic"; 16 | import Counter from "../inputs/Counter"; 17 | import ImageUpload from "../inputs/ImageUpload"; 18 | import Input from "../inputs"; 19 | import axios from "axios"; 20 | import { toast } from "react-hot-toast"; 21 | 22 | 23 | enum STEPS { 24 | CATEGORY = 0, 25 | LOCATION = 1, 26 | INFO = 2, 27 | IMAGES = 3, 28 | DESCRIPTION = 4, 29 | PRICE = 5 30 | } 31 | 32 | const RentModal = () => { 33 | 34 | const router = useRouter() 35 | const rentModal = useRentModal(); 36 | 37 | const [step, setStep] = useState(STEPS.CATEGORY); 38 | const [isLoading, setIsLoading] = useState(false); 39 | 40 | const { 41 | register, 42 | handleSubmit, 43 | setValue, 44 | watch, 45 | formState: { errors }, 46 | reset 47 | } = useForm({ 48 | defaultValues: { 49 | category: "", 50 | location: null, 51 | guestCount: 1, 52 | roomCount: 1, 53 | bathroomCount: 1, 54 | imageSrc: "", 55 | price: 1, 56 | title: '', 57 | description: "" 58 | } 59 | }); 60 | 61 | const category = watch("category"); 62 | const location = watch("location"); 63 | const guestCount = watch("guestCount"); 64 | const roomCount = watch("roomCount"); 65 | const bathroomCount = watch("bathroomCount"); 66 | const imageSrc = watch("imageSrc"); 67 | 68 | const Map = useMemo(() => dynamic(() => import("../Map"), { 69 | ssr: false 70 | }), [location]) 71 | 72 | const setCustomValue = (id: string, value: any) => { 73 | setValue(id, value, { 74 | shouldValidate: true, 75 | shouldDirty: true, 76 | shouldTouch: true, 77 | }); 78 | } 79 | 80 | const onBack = () => { 81 | setStep((value) => value - 1); 82 | } 83 | 84 | const onNext = () => { 85 | setStep((value) => value + 1); 86 | } 87 | 88 | const onSubmit: SubmitHandler = (data) => { 89 | if (step !== STEPS.PRICE) { 90 | return onNext(); 91 | } 92 | 93 | setIsLoading(true); 94 | 95 | axios.post("/api/listings", data) 96 | .then(() => { 97 | 98 | toast.success("Listing Created!") 99 | router.refresh(); 100 | reset(); 101 | setStep(STEPS.CATEGORY); 102 | rentModal.onClose(); 103 | 104 | }) 105 | .catch(() => { 106 | 107 | toast.error("Something went wrong!"); 108 | 109 | }) 110 | .finally(() => { 111 | setIsLoading(false); 112 | }) 113 | } 114 | 115 | const actionLabel = useMemo(() => { 116 | if (step === STEPS.PRICE) { 117 | return "Create" 118 | } 119 | 120 | return "Next" 121 | }, [step]); 122 | 123 | const secondaryActionLabel = useMemo(() => { 124 | if (step === STEPS.CATEGORY) return undefined; 125 | 126 | return "Back"; 127 | }, [step]); 128 | 129 | // Step 1 130 | let bodyContent = ( 131 |
132 | 136 |
137 | {categories.map((item) => ( 138 |
139 | setCustomValue('category', category)} 141 | selected={category === item.label} 142 | label={item.label} 143 | icon={item.icon} 144 | /> 145 |
146 | ))} 147 |
148 |
149 | ) 150 | 151 | // Step 2 152 | if (step === STEPS.LOCATION) { 153 | bodyContent = ( 154 |
155 | 159 | setCustomValue("location", value)} 162 | /> 163 | 164 |
165 | ) 166 | } 167 | 168 | // Step 3 169 | if (step === STEPS.INFO) { 170 | bodyContent = ( 171 |
172 | 176 | setCustomValue("guestCount", value)} 181 | /> 182 |
183 | setCustomValue("roomCount", value)} 188 | /> 189 |
190 | setCustomValue("bathroomCount", value)} 195 | /> 196 |
197 | ) 198 | } 199 | 200 | // Step 4 201 | if (step === STEPS.IMAGES) { 202 | bodyContent = ( 203 |
204 | 208 | setCustomValue("imageSrc", value)} 211 | 212 | /> 213 |
214 | ) 215 | } 216 | 217 | // Step 5 218 | if (step === STEPS.DESCRIPTION) { 219 | bodyContent = ( 220 |
221 | 225 | 233 |
234 | 242 |
243 | ) 244 | } 245 | 246 | // Step 6 247 | if (step === STEPS.PRICE) { 248 | bodyContent = ( 249 |
250 | 254 | 264 |
265 | ) 266 | } 267 | 268 | return ( 269 | 279 | ) 280 | } 281 | 282 | export default RentModal --------------------------------------------------------------------------------