├── 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 |
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 |
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 |
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 | router.push("/")}
31 | />
32 | )}
33 |
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 |
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 |
41 | {Icon && (
42 |
50 | )}
51 | {label}
52 |
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 |
35 | {label}
36 |
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 |
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 |
onChange(value as CountrySelectValue)}
33 | formatOptionLabel={(option: any) => (
34 |
35 |
{option.flag}
36 |
37 | {option.label},
38 | {option.region}
39 |
40 |
41 | )}
42 | classNames={{
43 | control: () => "p-3 border-2",
44 | input: () => "text-lg",
45 | option: () => 'text-lg'
46 | }}
47 | theme={(theme) => ({
48 | ...theme,
49 | borderRadius: 6,
50 | colors: {
51 | ...theme.colors,
52 | primary: "black",
53 | primary25: "#ffe4e6"
54 | }
55 | })}
56 | />
57 |
58 | )
59 | }
60 |
61 | export default CountrySelect
--------------------------------------------------------------------------------
/app/components/inputs/Counter.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { FC, useCallback } from "react";
4 | import { AiOutlineMinus, AiOutlinePlus } from "react-icons/ai";
5 |
6 | interface CounterProps {
7 | title: string;
8 | subtitle: string;
9 | value: number;
10 | onChange: (value: number) => void;
11 | }
12 |
13 | const Counter: FC = ({ title, subtitle, value, onChange }) => {
14 |
15 | const onAdd = useCallback(() => {
16 | onChange(value + 1);
17 | }, [onChange, value]);
18 |
19 | const onReduce = useCallback(() => {
20 | if (value === 1) {
21 | return;
22 | }
23 |
24 | onChange(value - 1);
25 | }, [value, onChange]);
26 |
27 | return (
28 |
29 |
30 |
31 | {title}
32 |
33 |
34 | {subtitle}
35 |
36 |
37 |
38 |
44 |
45 | {value}
46 |
47 |
53 |
54 |
55 | )
56 | }
57 |
58 | export default Counter
--------------------------------------------------------------------------------
/app/components/inputs/ImageUpload.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { CldUploadWidget } from "next-cloudinary";
4 | import Image from 'next/image';
5 | import { FC, useCallback } from "react";
6 | import { TbPhotoPlus } from "react-icons/tb";
7 |
8 | declare global {
9 | var cloudinary: any;
10 | }
11 |
12 | interface ImageUploadProps {
13 | onChange: (value: string) => void;
14 | value: string;
15 | }
16 |
17 | const ImageUpload: FC = ({ onChange, value }) => {
18 |
19 | const handleUpload = useCallback((result: any) => {
20 | onChange(result.info.secure_url)
21 | }, [onChange])
22 |
23 | return (
24 |
31 | {({ open }) => {
32 | return (
33 | open?.()}
35 | className="relative cursor-pointer hover:opacity-70 transition border-dashed border-2 p-20 border-neutral-200 flex flex-col justify-center items-center gap-4 text-neutral-600"
36 | >
37 |
38 |
39 | Click to upload
40 |
41 | {value && (
42 |
43 |
49 |
50 | )}
51 |
52 | )
53 | }}
54 |
55 |
56 | )
57 | }
58 |
59 | export default ImageUpload
--------------------------------------------------------------------------------
/app/reservations/ReservationsClient.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { toast } from "react-hot-toast"
4 | import axios from 'axios';
5 | import { useCallback, useState } from "react";
6 | import { useRouter } from "next/navigation";
7 |
8 | import { SafeReservation, SafeUser } from "../types";
9 |
10 | import Heading from "../components/Heading";
11 | import Container from "../components/Container";
12 | import ListingCard from "../components/listings/ListingCard";
13 |
14 | interface ReservationClientProps {
15 | reservations: SafeReservation[];
16 | currentUser?: SafeUser | null;
17 | }
18 |
19 | const ReservationsClient: React.FC = ({ reservations, currentUser }) => {
20 |
21 | const router = useRouter();
22 | const [deletingId, setDeletingId] = useState('');
23 |
24 | const onCancel = useCallback((id: string) => {
25 | setDeletingId(id);
26 |
27 | axios.delete(`/api/reservations/${id}`)
28 | .then(() => {
29 | toast.success("Reservation cancelled");
30 | router.refresh();
31 | })
32 | .catch(() => {
33 | toast.error("Something went wrong.");
34 | })
35 | .finally(() => {
36 | setDeletingId("");
37 | })
38 | }, [router])
39 |
40 | return (
41 |
42 |
46 |
47 | {reservations.map((reservation) => (
48 |
58 | ))}
59 |
60 |
61 | )
62 | }
63 |
64 | export default ReservationsClient
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "mongodb"
7 | url = env("DATABASE_URL")
8 | }
9 |
10 | model User {
11 | id String @id @default(auto()) @map("_id") @db.ObjectId
12 | name String?
13 | email String? @unique
14 | emailVerified DateTime?
15 | image String?
16 | hashedPassword String?
17 | createdAt DateTime @default(now())
18 | updatedAt DateTime @updatedAt
19 | favoriteIds String[] @db.ObjectId
20 |
21 | accounts Account[]
22 | listings Listing[]
23 | reservations Reservation[]
24 | }
25 |
26 | model Account {
27 | id String @id @default(auto()) @map("_id") @db.ObjectId
28 | userId String @db.ObjectId
29 | type String
30 | provider String
31 | providerAccountId String
32 | refresh_token String? @db.String
33 | access_token String? @db.String
34 | expires_at Int?
35 | token_type String?
36 | scope String?
37 | id_token String? @db.String
38 | session_state String?
39 |
40 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
41 |
42 | @@unique([provider, providerAccountId])
43 | }
44 |
45 | model Listing {
46 | id String @id @default(auto()) @map("_id") @db.ObjectId
47 | title String
48 | description String
49 | imageSrc String
50 | createdAt DateTime @default(now())
51 | category String
52 | roomCount Int
53 | bathroomCount Int
54 | guestCount Int
55 | locationValue String
56 | userId String @db.ObjectId
57 | price Int
58 |
59 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
60 | reservations Reservation[]
61 | }
62 |
63 | model Reservation {
64 | id String @id @default(auto()) @map("_id") @db.ObjectId
65 | userId String @db.ObjectId
66 | listingId String @db.ObjectId
67 | startDate DateTime
68 | endDate DateTime
69 | totalPrice Int
70 | createdAt DateTime @default(now())
71 |
72 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
73 | listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
74 | }
75 |
--------------------------------------------------------------------------------
/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import bcrypt from "bcrypt"
2 | import NextAuth, { AuthOptions } from "next-auth"
3 | import CredentialsProvider from "next-auth/providers/credentials"
4 | import GithubProvider from "next-auth/providers/github"
5 | import GoogleProvider from "next-auth/providers/google"
6 | import { PrismaAdapter } from "@next-auth/prisma-adapter"
7 |
8 | import prisma from "@/app/libs/prismadb"
9 |
10 | export const authOptions: AuthOptions = {
11 | adapter: PrismaAdapter(prisma),
12 | providers: [
13 |
14 | GithubProvider({
15 | clientId: process.env.GITHUB_ID as string,
16 | clientSecret: process.env.GITHUB_SECRET as string
17 | }),
18 | GoogleProvider({
19 | clientId: process.env.GOOGLE_CLIENT_ID as string,
20 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string
21 | }),
22 | CredentialsProvider({
23 | name: 'credentials',
24 | credentials: {
25 | email: { label: 'email', type: 'text' },
26 | password: { label: 'password', type: 'password' }
27 | },
28 | async authorize(credentials) {
29 | if (!credentials?.email || !credentials?.password) {
30 | throw new Error('Invalid credentials');
31 | }
32 |
33 | const user = await prisma.user.findUnique({
34 | where: {
35 | email: credentials.email
36 | }
37 | });
38 |
39 | if (!user || !user?.hashedPassword) {
40 | throw new Error('Invalid credentials');
41 | }
42 |
43 | const isCorrectPassword = await bcrypt.compare(
44 | credentials.password,
45 | user.hashedPassword
46 | );
47 |
48 | if (!isCorrectPassword) {
49 | throw new Error('Invalid credentials');
50 | }
51 |
52 | return user;
53 | }
54 | })
55 | ],
56 | pages: {
57 | signIn: '/',
58 | },
59 | debug: process.env.NODE_ENV === 'development',
60 | session: {
61 | strategy: "jwt",
62 | },
63 | secret: process.env.NEXTAUTH_SECRET,
64 | }
65 |
66 | export default NextAuth(authOptions);
--------------------------------------------------------------------------------
/app/properties/PropertiesClient.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { toast } from "react-hot-toast";
4 | import axios from "axios";
5 | import { useCallback, useState } from "react";
6 | import { useRouter } from "next/navigation";
7 |
8 | import { SafeListing, SafeUser } from "@/app/types";
9 |
10 | import Heading from "@/app/components/Heading";
11 | import Container from "@/app/components/Container";
12 | import ListingCard from "@/app/components/listings/ListingCard";
13 |
14 | interface PropertiesClientProps {
15 | listings: SafeListing[],
16 | currentUser?: SafeUser | null,
17 | }
18 |
19 | const PropertiesClient: React.FC = ({
20 | listings,
21 | currentUser
22 | }) => {
23 | const router = useRouter();
24 | const [deletingId, setDeletingId] = useState('');
25 |
26 | const onCancel = useCallback((id: string) => {
27 | setDeletingId(id);
28 |
29 | axios.delete(`/api/listings/${id}`)
30 | .then(() => {
31 | toast.success('Listing deleted');
32 | router.refresh();
33 | })
34 | .catch((error) => {
35 | toast.error(error?.response?.data?.error)
36 | })
37 | .finally(() => {
38 | setDeletingId('');
39 | })
40 | }, [router]);
41 |
42 | return (
43 |
44 |
48 |
61 | {listings.map((listing: any) => (
62 |
71 | ))}
72 |
73 |
74 | );
75 | }
76 |
77 | export default PropertiesClient;
--------------------------------------------------------------------------------
/app/components/listings/ListingReservation.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Range } from "react-date-range";
4 |
5 | import Button from "../Button";
6 | import Calendar from "../inputs/Calendar";
7 |
8 | interface ListingReservationProps {
9 | price: number;
10 | dateRange: Range,
11 | totalPrice: number;
12 | onChangeDate: (value: Range) => void;
13 | onSubmit: () => void;
14 | disabled?: boolean;
15 | disabledDates: Date[];
16 | }
17 |
18 | const ListingReservation: React.FC<
19 | ListingReservationProps
20 | > = ({
21 | price,
22 | dateRange,
23 | totalPrice,
24 | onChangeDate,
25 | onSubmit,
26 | disabled,
27 | disabledDates
28 | }) => {
29 | return (
30 |
39 |
41 |
42 | $ {price}
43 |
44 |
45 | night
46 |
47 |
48 |
49 |
53 | onChangeDate(value.selection)}
54 | />
55 |
56 |
57 |
62 |
63 |
64 |
75 |
76 | Total
77 |
78 |
79 | $ {totalPrice}
80 |
81 |
82 |
83 | );
84 | }
85 |
86 | export default ListingReservation;
--------------------------------------------------------------------------------
/app/components/listings/ListingInfo.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 { IconType } from "react-icons";
7 | import Avatar from "../Avatar";
8 | import ListingCategory from "./ListingCategory";
9 | import dynamic from "next/dynamic";
10 |
11 | const Map = dynamic(() => import("../Map"), {
12 | ssr: false
13 | });
14 |
15 | interface ListingInfoProps {
16 | user: SafeUser;
17 | description: string;
18 | guestCount: number;
19 | roomCount: number;
20 | bathroomCount: number;
21 | category: {
22 | icon: IconType;
23 | label: string;
24 | description: string;
25 | } | undefined
26 |
27 | locationValue: string;
28 | }
29 |
30 | const ListingInfo: FC = ({
31 | user,
32 | description,
33 | guestCount,
34 | roomCount,
35 | bathroomCount,
36 | category,
37 | locationValue
38 | }) => {
39 |
40 | const { getByValue } = useCountries();
41 |
42 | const coordinate = getByValue(locationValue)?.latlng;
43 |
44 |
45 |
46 | return (
47 |
48 |
49 |
50 |
Hosted by {user?.name}
51 |
52 |
53 |
54 |
55 | {guestCount} geusts
56 |
57 |
58 | {roomCount} rooms
59 |
60 |
61 | {bathroomCount} bathrooms
62 |
63 |
64 |
65 |
66 | {category && (
67 |
72 | )}
73 |
74 |
75 | {description}
76 |
77 |
78 |
79 |
80 | )
81 | }
82 |
83 | export default ListingInfo
--------------------------------------------------------------------------------
/app/trips/TripsClient.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { toast } from "react-hot-toast";
4 | import axios from "axios";
5 | import { useCallback, useState } from "react";
6 | import { useRouter } from "next/navigation";
7 |
8 | import { SafeReservation, SafeUser } from "@/app/types";
9 |
10 | import Heading from "@/app/components/Heading";
11 | import Container from "@/app/components/Container";
12 | import ListingCard from "@/app/components/listings/ListingCard";
13 |
14 | interface TripsClientProps {
15 | reservations: SafeReservation[],
16 | currentUser?: SafeUser | null,
17 | }
18 |
19 | const TripsClient: React.FC = ({
20 | reservations,
21 | currentUser
22 | }) => {
23 | const router = useRouter();
24 | const [deletingId, setDeletingId] = useState('');
25 |
26 | const onCancel = useCallback((id: string) => {
27 | setDeletingId(id);
28 |
29 | axios.delete(`/api/reservations/${id}`)
30 | .then(() => {
31 | toast.success('Reservation cancelled');
32 | router.refresh();
33 | })
34 | .catch((error) => {
35 | toast.error(error?.response?.data?.error)
36 | })
37 | .finally(() => {
38 | setDeletingId('');
39 | })
40 | }, [router]);
41 |
42 | return (
43 |
44 |
48 |
61 | {reservations.map((reservation: any) => (
62 |
72 | ))}
73 |
74 |
75 | );
76 | }
77 |
78 | export default TripsClient;
--------------------------------------------------------------------------------
/app/actions/getListings.ts:
--------------------------------------------------------------------------------
1 | import prisma from "@/app/libs/prismadb";
2 |
3 | export interface IListingsParams {
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: IListingsParams
16 | ) {
17 | try {
18 | const {
19 | userId,
20 | roomCount,
21 | guestCount,
22 | bathroomCount,
23 | locationValue,
24 | startDate,
25 | endDate,
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 | const listings = await prisma.listing.findMany({
81 | where: query,
82 | orderBy: {
83 | createdAt: 'desc'
84 | }
85 | });
86 |
87 | const safeListings = listings.map((listing) => ({
88 | ...listing,
89 | createdAt: listing.createdAt.toISOString(),
90 | }));
91 |
92 | return safeListings;
93 | } catch (error: any) {
94 | throw new Error(error);
95 | }
96 | }
--------------------------------------------------------------------------------
/app/components/navbar/Search.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 | import useCountries from '@/app/hooks/useCountries';
3 | import useSearchModal from '@/app/hooks/useSearchModal'
4 | import { differenceInDays } from 'date-fns';
5 | import { useSearchParams } from 'next/navigation';
6 | import { useMemo } from 'react';
7 | // React icons
8 | import { BiSearch } from 'react-icons/bi'
9 |
10 | const Search = () => {
11 |
12 | const searchModal = useSearchModal();
13 | const params = useSearchParams();
14 | const { getByValue } = useCountries();
15 |
16 | const locationValue = params?.get("locationValue")
17 | const startDate = params?.get("startDate")
18 | const endDate = params?.get("endDate")
19 | const guestCount = params?.get("guestCount");
20 |
21 | const locationLabel = useMemo(() => {
22 | if (locationValue) {
23 | return getByValue(locationValue as string)?.label
24 | }
25 |
26 | return "Anywhere"
27 | }, [getByValue, locationValue]);
28 |
29 | const durationLabel = useMemo(() => {
30 | if (startDate && endDate) {
31 | const start = new Date(startDate as string);
32 | const end = new Date(endDate as string);
33 | let diff = differenceInDays(end, start);
34 |
35 | if (diff === 0) {
36 | diff = 1
37 | }
38 |
39 | return `${diff} Days`
40 |
41 | }
42 |
43 | return "Any Week"
44 |
45 | }, [startDate, endDate]);
46 |
47 | const guestLabel = useMemo(() => {
48 | if (guestCount) {
49 | return `${guestCount} Guests`
50 | }
51 |
52 | return "Add Guests";
53 | }, [guestCount])
54 |
55 | return (
56 |
59 |
60 |
61 | {locationLabel}
62 |
63 |
64 | {durationLabel}
65 |
66 |
67 |
{guestLabel}
68 |
69 |
70 |
71 |
72 |
73 |
74 | )
75 | }
76 |
77 | export default Search
--------------------------------------------------------------------------------
/app/components/navbar/UserMenu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 | // React libraries
3 | import React, { FC, useCallback, useState } from 'react'
4 | // Icons
5 | import { AiOutlineMenu } from 'react-icons/ai'
6 |
7 | import { signOut } from 'next-auth/react'
8 | import { SafeUser } from '@/app/types'
9 |
10 | import Avatar from '../Avatar'
11 | import MenuItem from './MenuItem'
12 | // Modals
13 | import useRegisterModal from '@/app/hooks/useRegisterModal'
14 | import useLoginModal from '@/app/hooks/useLoginModal'
15 | import useRentModal from '@/app/hooks/useRentModal'
16 | import { useRouter } from 'next/navigation'
17 |
18 | interface UserMenuProps {
19 | currentUser?: SafeUser | null
20 | }
21 |
22 |
23 | const UserMenu: FC = ({ currentUser }) => {
24 |
25 | const registerModal = useRegisterModal();
26 | const loginModal = useLoginModal();
27 | const rentModal = useRentModal();
28 |
29 | const router = useRouter();
30 |
31 | const [isOpen, setIsOpen] = useState(false);
32 |
33 | const toggleOpen = useCallback(() => {
34 | setIsOpen((value) => !value)
35 | }, []);
36 |
37 | const onRent = useCallback(() => {
38 | if (!currentUser) {
39 | return loginModal.onOpen();
40 | }
41 |
42 | rentModal.onOpen();
43 | }, [currentUser, loginModal])
44 |
45 | return (
46 |
47 |
48 |
52 | Airbnb you home
53 |
54 |
63 |
64 | {isOpen && (
65 |
66 |
67 | {!currentUser ? (
68 | <>
69 |
70 |
71 | >
72 | ) : (
73 | <>
74 | router.push("/trips")} label='My trips' />
75 | router.push("/favorites")} label='My favorites' />
76 | router.push("/reservations")} label='My reservations' />
77 | router.push("/properties")} label='My properties' />
78 | onRent()} label='Airbnb my home' />
79 |
80 | signOut()} label='Logout' />
81 |
82 | >
83 | )}
84 |
85 |
86 |
87 | )}
88 |
89 | )
90 | }
91 |
92 | export default UserMenu
--------------------------------------------------------------------------------
/app/components/listings/ListingCard.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { FC, useCallback, useMemo } from "react"
4 |
5 | import { useRouter } from "next/navigation";
6 | import useCountries from "@/app/hooks/useCountries";
7 |
8 | import { SafeListing, SafeReservation, SafeUser } from "@/app/types";
9 | import { format } from "date-fns"
10 | import Image from "next/image";
11 | import HeartButton from "../HeartButton";
12 | import Button from "../Button";
13 |
14 | interface ListingCardProps {
15 | data: SafeListing;
16 | reservation?: SafeReservation;
17 | onAction?: (id: string) => void
18 | disabled?: boolean;
19 | actionLabel?: string;
20 | actionId?: string;
21 | currentUser?: SafeUser | null
22 | }
23 |
24 | const ListingCard: FC = ({ data, reservation, onAction, disabled, actionLabel, actionId = "", currentUser }) => {
25 |
26 | const router = useRouter();
27 |
28 | const { getByValue } = useCountries();
29 |
30 | const location = getByValue(data.locationValue);
31 |
32 | const handleCancel = useCallback((e: React.MouseEvent) => {
33 | e.stopPropagation();
34 |
35 | if (disabled) return;
36 |
37 | onAction?.(actionId);
38 |
39 | }, [onAction, actionId, disabled]);
40 |
41 |
42 | const price = useMemo(() => {
43 | if (reservation) {
44 | return reservation.totalPrice;
45 | }
46 |
47 | return data.price
48 | }, [reservation, data.price]);
49 |
50 | const reservationDate = useMemo(() => {
51 | if (!reservation) return null;
52 |
53 | const start = new Date(reservation.startDate);
54 | const end = new Date(reservation.endDate)
55 |
56 | return `${format(start, 'PP')} - ${format(end, "PP")}`
57 |
58 | }, [reservation]);
59 |
60 | return (
61 | router.push(`/listings/${data.id}`)}
63 | className="col-span-1 cursor-pointer group"
64 | >
65 |
66 |
80 |
81 | {location?.region}, {location?.label}
82 |
83 |
84 | {reservationDate || data.category}
85 |
86 |
87 |
88 | $ {price}
89 |
90 | {!reservation && (
91 |
night
92 | )}
93 |
94 | {onAction && actionLabel && (
95 |
102 | )}
103 |
104 |
105 | )
106 | }
107 |
108 | export default ListingCard
--------------------------------------------------------------------------------
/app/components/navbar/Categories.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { usePathname, useSearchParams } from 'next/navigation';
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
14 | } from 'react-icons/gi';
15 | import { FaSkiing } from 'react-icons/fa';
16 | import { BsSnow } from 'react-icons/bs';
17 | import { IoDiamond } from 'react-icons/io5';
18 | import { MdOutlineVilla } from 'react-icons/md';
19 |
20 | import Container from '../Container';
21 | import CategoryBox from '../CategoryBox';
22 |
23 | export const categories = [
24 | {
25 | label: 'Beach',
26 | icon: TbBeach,
27 | description: 'This property is close to the beach!',
28 | },
29 | {
30 | label: 'Windmills',
31 | icon: GiWindmill,
32 | description: 'This property is has windmills!',
33 | },
34 | {
35 | label: 'Modern',
36 | icon: MdOutlineVilla,
37 | description: 'This property is modern!'
38 | },
39 | {
40 | label: 'Countryside',
41 | icon: TbMountain,
42 | description: 'This property is in the countryside!'
43 | },
44 | {
45 | label: 'Pools',
46 | icon: TbPool,
47 | description: 'This is property has a beautiful pool!'
48 | },
49 | {
50 | label: 'Islands',
51 | icon: GiIsland,
52 | description: 'This property is on an island!'
53 | },
54 | {
55 | label: 'Lake',
56 | icon: GiBoatFishing,
57 | description: 'This property is near a lake!'
58 | },
59 | {
60 | label: 'Skiing',
61 | icon: FaSkiing,
62 | description: 'This property has skiing activies!'
63 | },
64 | {
65 | label: 'Castles',
66 | icon: GiCastle,
67 | description: 'This property is an ancient castle!'
68 | },
69 | {
70 | label: 'Caves',
71 | icon: GiCaveEntrance,
72 | description: 'This property is in a spooky cave!'
73 | },
74 | {
75 | label: 'Camping',
76 | icon: GiForestCamp,
77 | description: 'This property offers camping activities!'
78 | },
79 | {
80 | label: 'Arctic',
81 | icon: BsSnow,
82 | description: 'This property is in arctic environment!'
83 | },
84 | {
85 | label: 'Desert',
86 | icon: GiCactus,
87 | description: 'This property is in the desert!'
88 | },
89 | {
90 | label: 'Barns',
91 | icon: GiBarn,
92 | description: 'This property is in a barn!'
93 | },
94 | {
95 | label: 'Lux',
96 | icon: IoDiamond,
97 | description: 'This property is brand new and luxurious!'
98 | }
99 | ]
100 |
101 | const Categories = () => {
102 | const params = useSearchParams();
103 | const category = params?.get('category');
104 | const pathname = usePathname();
105 | const isMainPage = pathname === '/';
106 |
107 | if (!isMainPage) {
108 | return null;
109 | }
110 |
111 | return (
112 |
113 |
123 | {categories.map((item) => (
124 |
130 | ))}
131 |
132 |
133 | );
134 | }
135 |
136 | export default Categories;
--------------------------------------------------------------------------------
/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 {
9 | FieldValues,
10 | SubmitHandler,
11 | useForm
12 | } from 'react-hook-form';
13 |
14 | import useLoginModal from '@/app/hooks/useLoginModal'
15 | import Modal from '.'
16 | import Heading from '../Heading'
17 | import Input from '../inputs'
18 | import { toast } from 'react-hot-toast'
19 | import Button from '../Button'
20 | import { useRouter } from "next/navigation"
21 | import useRegisterModal from "@/app/hooks/useRegisterModal"
22 |
23 | const LoginModal = () => {
24 |
25 | const router = useRouter();
26 |
27 | const loginModal = useLoginModal();
28 | const registerModal = useRegisterModal();
29 |
30 | const [isLoading, setIsLoading] = useState(false);
31 |
32 | const {
33 | register,
34 | handleSubmit,
35 | formState: {
36 | errors,
37 | }
38 | } = useForm({
39 | defaultValues: {
40 | email: "",
41 | password: ""
42 | }
43 | });
44 |
45 | const onSubmit: SubmitHandler = (data) => {
46 | setIsLoading(true);
47 |
48 | signIn("credentials", {
49 | ...data,
50 | redirect: false
51 | })
52 | .then((callback) => {
53 | setIsLoading(false);
54 |
55 | if (callback?.ok) {
56 | toast.success("Logged in");
57 | router.refresh();
58 | loginModal.onClose();
59 | }
60 |
61 | if (callback?.error) {
62 | toast.error(callback.error);
63 | }
64 | })
65 | }
66 |
67 | const onToggle = useCallback(() => {
68 | loginModal.onClose();
69 | registerModal.onOpen();
70 |
71 | }, [loginModal, registerModal]);
72 |
73 | const bodyContent = (
74 |
75 |
76 |
77 |
78 |
79 | )
80 |
81 | const footerContent = (
82 |
83 |
84 |
signIn('google')}
89 | />
90 | signIn('github')}
95 | />
96 |
97 |
98 |
99 | First time using Airbnb?
100 |
101 |
105 | Create an account
106 |
107 |
108 |
109 |
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 |
81 |
82 |
83 |
84 | {title}
85 |
86 |
87 | {/* BODY */}
88 |
89 | {body}
90 |
91 | {/* FOOTER */}
92 |
93 |
94 | {secondaryAction && secondaryActionLabel && (
95 |
101 | )}
102 |
107 |
108 |
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 |
signIn('google')}
106 | />
107 | signIn('github')}
112 | />
113 |
121 |
Already have an account?
122 | Log in
130 |
131 |
132 |
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
--------------------------------------------------------------------------------