├── .eslintrc.json ├── app ├── favicon.ico ├── api │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── edgestore │ │ └── [...edgestore] │ │ │ └── route.ts │ └── webhooks │ │ └── stripe │ │ └── route.ts ├── listings │ └── [listingId] │ │ ├── _components │ │ ├── ListingCategory.tsx │ │ ├── ListingHead.tsx │ │ ├── ListingInfo.tsx │ │ ├── ListingReservation.tsx │ │ └── ListingClient.tsx │ │ ├── loading.tsx │ │ └── page.tsx ├── layout.tsx ├── favorites │ └── page.tsx ├── globals.css ├── page.tsx ├── properties │ └── page.tsx ├── reservations │ └── page.tsx └── trips │ └── page.tsx ├── public ├── images │ ├── airbnb.png │ ├── placeholder.jpg │ └── vacationhub.png ├── vercel.svg └── next.svg ├── postcss.config.js ├── types ├── index.ts └── next-auth.d.ts ├── middleware.ts ├── lib ├── stripe.ts ├── db.ts ├── edgestore.ts └── auth.ts ├── hooks ├── useMoveBack.ts ├── useIsClient.ts ├── useKeyPress.ts ├── useLoadMore.ts └── useOutsideClick.ts ├── services ├── user.ts ├── auth.ts ├── properties.ts ├── favorite.ts ├── listing.ts └── reservation.ts ├── utils ├── helper.ts ├── motion.ts └── constants.ts ├── components ├── Loader.tsx ├── navbar │ ├── Logo.tsx │ ├── MenuItem.tsx │ ├── index.tsx │ ├── CategoryBox.tsx │ ├── Categories.tsx │ ├── Search.tsx │ └── UserMenu.tsx ├── Avatar.tsx ├── BackButton.tsx ├── Heading.tsx ├── Provider.tsx ├── Calender.tsx ├── EmptyState.tsx ├── Image.tsx ├── Button.tsx ├── Map.tsx ├── ConfirmDelete.tsx ├── inputs │ ├── CategoryButton.tsx │ ├── CountrySelect.tsx │ ├── Counter.tsx │ └── Input.tsx ├── HeartButton.tsx ├── LoadMore.tsx ├── ListingMenu.tsx ├── ImageUpload.tsx ├── ListingCard.tsx ├── Menu.tsx └── modals │ ├── Modal.tsx │ ├── AuthModal.tsx │ ├── SearchModal.tsx │ └── RentModal.tsx ├── .gitignore ├── tailwind.config.ts ├── tsconfig.json ├── next.config.js ├── LICENSE ├── package.json ├── prisma └── schema.prisma ├── README.md └── data └── countries.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudeepmahato16/airbnb-clone/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/images/airbnb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudeepmahato16/airbnb-clone/HEAD/public/images/airbnb.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/images/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudeepmahato16/airbnb-clone/HEAD/public/images/placeholder.jpg -------------------------------------------------------------------------------- /public/images/vacationhub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sudeepmahato16/airbnb-clone/HEAD/public/images/vacationhub.png -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | import { IconType } from "react-icons"; 2 | 3 | export interface Category { 4 | label: string; 5 | icon: IconType; 6 | description?: string; 7 | } -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | export { default } from "next-auth/middleware"; 2 | 3 | export const config = { 4 | matcher: ["/favorites", "/properties", "/reservations", "/trips"], 5 | }; 6 | -------------------------------------------------------------------------------- /lib/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe' 2 | 3 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', { 4 | apiVersion: "2024-09-30.acacia", 5 | typescript: true, 6 | }) -------------------------------------------------------------------------------- /hooks/useMoveBack.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/navigation"; 2 | 3 | export const useMoveBack = () => { 4 | const router = useRouter(); 5 | 6 | return () => router.back(); 7 | }; 8 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { authOptions } from "@/lib/auth"; 3 | 4 | const handler = NextAuth(authOptions); 5 | 6 | export { handler as GET, handler as POST }; 7 | -------------------------------------------------------------------------------- /services/user.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "next-auth"; 2 | import { authOptions } from "@/lib/auth"; 3 | 4 | export const getCurrentUser = async () => { 5 | const session = await getServerSession(authOptions); 6 | return session?.user; 7 | }; 8 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | declare global { 4 | var prisma: PrismaClient | undefined; 5 | } 6 | 7 | export const db = globalThis.prisma || new PrismaClient(); 8 | if (process.env.NODE_ENV !== "production") globalThis.prisma = db; 9 | -------------------------------------------------------------------------------- /hooks/useIsClient.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useIsClient = () => { 4 | const [isClient, setIsClient] = useState(false); 5 | 6 | useEffect(() => { 7 | setIsClient(true); 8 | }, []); 9 | 10 | return isClient; 11 | }; 12 | -------------------------------------------------------------------------------- /lib/edgestore.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { type EdgeStoreRouter } from "@/app/api/edgestore/[...edgestore]/route"; 4 | import { createEdgeStoreProvider } from "@edgestore/react"; 5 | 6 | const { EdgeStoreProvider, useEdgeStore } = 7 | createEdgeStoreProvider(); 8 | 9 | export { EdgeStoreProvider, useEdgeStore }; 10 | -------------------------------------------------------------------------------- /utils/helper.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export const formatPrice = (price: number): string => { 5 | return new Intl.NumberFormat("en-US").format(price); 6 | }; 7 | 8 | export function cn(...inputs: ClassValue[]) { 9 | return twMerge(clsx(inputs)); 10 | } 11 | -------------------------------------------------------------------------------- /types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import type { Session, User } from "next-auth"; 2 | import type { JWT } from "next-auth/jwt"; 3 | 4 | 5 | declare module "next-auth/jwt" { 6 | interface JWT { 7 | id: string; 8 | } 9 | } 10 | 11 | declare module "next-auth" { 12 | interface Session { 13 | user: User & { 14 | id: string; 15 | }; 16 | } 17 | } -------------------------------------------------------------------------------- /components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { BiLoaderAlt } from "react-icons/bi"; 2 | 3 | import React, { FC } from "react"; 4 | 5 | interface SpinnerMiniProps { 6 | className?: string; 7 | } 8 | 9 | export const SpinnerMini: React.FC = ({ className }) => { 10 | return ; 11 | }; 12 | 13 | export default SpinnerMini; 14 | -------------------------------------------------------------------------------- /components/navbar/Logo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | 5 | const Logo = () => { 6 | return ( 7 | 8 | logo 16 | 17 | ); 18 | }; 19 | 20 | export default Logo; -------------------------------------------------------------------------------- /components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Image from "next/image"; 3 | 4 | interface AvatarProps { 5 | src: string | null | undefined; 6 | } 7 | 8 | const Avatar: React.FC = ({ src }) => { 9 | return ( 10 | Avatar 18 | ); 19 | }; 20 | 21 | export default Avatar; 22 | -------------------------------------------------------------------------------- /app/api/edgestore/[...edgestore]/route.ts: -------------------------------------------------------------------------------- 1 | import { initEdgeStore } from "@edgestore/server"; 2 | import { createEdgeStoreNextHandler } from "@edgestore/server/adapters/next/app"; 3 | 4 | const es = initEdgeStore.create(); 5 | 6 | const edgeStoreRouter = es.router({ 7 | publicFiles: es.fileBucket(), 8 | }); 9 | 10 | const handler = createEdgeStoreNextHandler({ 11 | router: edgeStoreRouter, 12 | }); 13 | 14 | export { handler as GET, handler as POST }; 15 | 16 | export type EdgeStoreRouter = typeof edgeStoreRouter; 17 | -------------------------------------------------------------------------------- /.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 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /components/navbar/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { FC } from "react"; 3 | import Menu from "../Menu"; 4 | 5 | interface MenuItemProps { 6 | onClick?: () => void; 7 | label: string; 8 | } 9 | 10 | export const MenuItemStyle = 11 | " hover:bg-neutral-100 transition font-semibold select-none"; 12 | 13 | const MenuItem: FC = ({ label, onClick }) => { 14 | return ( 15 | 16 | {label} 17 | 18 | ); 19 | }; 20 | 21 | export default MenuItem; 22 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | } 20 | export default config 21 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/BackButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { MdKeyboardBackspace } from "react-icons/md"; 4 | import { useMoveBack } from "@/hooks/useMoveBack"; 5 | 6 | const BackButton = () => { 7 | const back = useMoveBack(); 8 | return ( 9 | 19 | ); 20 | }; 21 | 22 | export default BackButton; 23 | -------------------------------------------------------------------------------- /hooks/useKeyPress.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | interface IUseKeyPress { 4 | key: string; 5 | action: (e: KeyboardEvent) => void; 6 | enable?: boolean; 7 | } 8 | 9 | export const useKeyPress = ({ key, action, enable = true }: IUseKeyPress) => { 10 | useEffect(() => { 11 | const onKeyDown = (e: KeyboardEvent) => { 12 | if (e.key === key) action(e); 13 | }; 14 | 15 | if (enable) { 16 | window.addEventListener("keydown", onKeyDown); 17 | } else { 18 | window.removeEventListener("keydown", onKeyDown); 19 | } 20 | 21 | return () => window.removeEventListener("keydown", onKeyDown); 22 | }, [action, key, enable]); 23 | }; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /components/Heading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import BackButton from "./BackButton"; 3 | 4 | interface HeadingProps { 5 | title: string; 6 | subtitle?: string; 7 | center?: boolean; 8 | backBtn?: boolean; 9 | } 10 | 11 | const Heading: React.FC = ({ 12 | title, 13 | subtitle, 14 | center, 15 | backBtn = false, 16 | }) => { 17 | return ( 18 |
19 |
20 |

{title}

21 |

{subtitle}

22 |
23 | {backBtn ? : null} 24 |
25 | ); 26 | }; 27 | 28 | export default Heading; 29 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: "https", 7 | hostname: "res.cloudinary.com", 8 | pathname: "**", 9 | port: "", 10 | }, 11 | { 12 | protocol: "https", 13 | hostname: "lh3.googleusercontent.com", 14 | pathname: "**", 15 | port: "", 16 | }, 17 | { 18 | protocol: "https", 19 | hostname: "avatars.githubusercontent.com", 20 | pathname: "**", 21 | port: "", 22 | }, 23 | { 24 | protocol: "https", 25 | hostname: "files.edgestore.dev", 26 | pathname: "**", 27 | port: "" 28 | } 29 | ], 30 | }, 31 | }; 32 | 33 | module.exports = nextConfig; 34 | -------------------------------------------------------------------------------- /services/auth.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { db } from "@/lib/db"; 3 | import bcrypt from "bcrypt"; 4 | 5 | export const registerUser = async ({ 6 | name, 7 | email, 8 | password: inputPassword, 9 | }: { 10 | name: string; 11 | email: string; 12 | password: string; 13 | }) => { 14 | try { 15 | if (!name || !email || !inputPassword) 16 | throw new Error("Please provide all credentials"); 17 | const hashedPassword = await bcrypt.hash(inputPassword, 12); 18 | 19 | const user = await db.user.create({ 20 | data: { 21 | email, 22 | name, 23 | password: hashedPassword, 24 | }, 25 | }); 26 | 27 | return { 28 | id: user.id, 29 | email: user.email, 30 | name: user.name, 31 | }; 32 | } catch (error: any) { 33 | throw new Error(error.message); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /app/listings/[listingId]/_components/ListingCategory.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IconType } from "react-icons"; 3 | 4 | interface ListingCategoryProps { 5 | icon: IconType; 6 | label: string; 7 | description: string; 8 | } 9 | 10 | const ListingCategory: React.FC = ({ 11 | icon: Icon, 12 | label, 13 | description, 14 | }) => { 15 | return ( 16 |
17 |
18 | 19 |
20 | {label} 21 |

{description}

22 |
23 |
24 |
25 | ); 26 | }; 27 | 28 | export default ListingCategory; 29 | -------------------------------------------------------------------------------- /components/Provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { PropsWithChildren } from "react"; 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { Toaster } from "react-hot-toast"; 5 | import { EdgeStoreProvider } from "@/lib/edgestore"; 6 | import {SessionProvider} from 'next-auth/react' 7 | 8 | const queryClient = new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | staleTime: 60 * 1000, 12 | }, 13 | }, 14 | }); 15 | 16 | const Providers = ({ children }: PropsWithChildren) => { 17 | return ( 18 | 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default Providers; 30 | -------------------------------------------------------------------------------- /components/navbar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from "react"; 2 | import Logo from "./Logo"; 3 | import Search from "./Search"; 4 | import Categories from "./Categories"; 5 | import UserMenu from "./UserMenu"; 6 | import { getCurrentUser } from "@/services/user"; 7 | 8 | interface NavbarProps {} 9 | 10 | const Navbar: React.FC = async () => { 11 | const user = await getCurrentUser(); 12 | 13 | return ( 14 |
15 | 24 | 25 |
26 | ); 27 | }; 28 | 29 | export default Navbar; 30 | -------------------------------------------------------------------------------- /hooks/useLoadMore.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export const useLoadMore = ( 4 | loadMoreData: () => void, 5 | hasMoreData: boolean | undefined, 6 | isLoading: boolean, 7 | isError: boolean 8 | ) => { 9 | const ref = useRef(null); 10 | 11 | useEffect(() => { 12 | const callbackFn = (entries: IntersectionObserverEntry[]) => { 13 | const entry = entries[0]; 14 | 15 | if (!entry.isIntersecting) return; 16 | 17 | if (!isLoading && hasMoreData && !isError) { 18 | loadMoreData(); 19 | } 20 | }; 21 | const observer = new IntersectionObserver(callbackFn, { 22 | root: null, 23 | rootMargin: "240px", 24 | threshold: 0.1, 25 | }); 26 | 27 | if (ref.current) { 28 | observer.observe(ref.current); 29 | } 30 | 31 | return () => observer.disconnect(); 32 | }, [hasMoreData, isError, isLoading, loadMoreData]); 33 | 34 | return { ref }; 35 | }; 36 | -------------------------------------------------------------------------------- /hooks/useOutsideClick.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | interface IUseOutsideClick { 4 | action: (e: MouseEvent) => void; 5 | listenCapturing?: boolean; 6 | enable?: boolean; 7 | } 8 | 9 | export const useOutsideClick = ({ 10 | action, 11 | listenCapturing = true, 12 | enable = true, 13 | }: IUseOutsideClick) => { 14 | const ref = useRef(null); 15 | 16 | useEffect(() => { 17 | const handleClick = (e: MouseEvent) => { 18 | if (ref.current && !ref.current.contains(e.target as Node)) { 19 | action(e); 20 | } 21 | }; 22 | 23 | if (enable) { 24 | document.addEventListener("click", handleClick, listenCapturing); 25 | } else { 26 | document.removeEventListener("click", handleClick, listenCapturing); 27 | } 28 | 29 | return () => 30 | document.removeEventListener("click", handleClick, listenCapturing); 31 | }, [action, listenCapturing, enable]); 32 | 33 | return { ref }; 34 | }; 35 | -------------------------------------------------------------------------------- /components/Calender.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React from "react"; 3 | import { DateRange, Range, RangeKeyDict } from "react-date-range"; 4 | 5 | import "react-date-range/dist/styles.css"; 6 | import "react-date-range/dist/theme/default.css"; 7 | 8 | interface CalendarProps { 9 | value: Range; 10 | onChange: (fieldName: string, value: Range) => void; 11 | disabledDates?: Date[]; 12 | } 13 | 14 | const Calendar: React.FC = ({ 15 | value, 16 | onChange, 17 | disabledDates, 18 | }) => { 19 | const handleChange = (value: RangeKeyDict) => { 20 | onChange("dateRange", value.selection) 21 | } 22 | return ( 23 | 33 | ); 34 | }; 35 | 36 | export default Calendar; 37 | -------------------------------------------------------------------------------- /components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Heading from "./Heading"; 3 | import Link from "next/link"; 4 | 5 | interface EmptyProps { 6 | title?: string; 7 | subtitle?: string; 8 | showReset?: boolean; 9 | } 10 | 11 | const EmptyState: React.FC = ({ 12 | title = "No exact matches", 13 | subtitle = "Try changing or removing some of your filters.", 14 | showReset, 15 | }) => { 16 | return ( 17 |
18 | 19 |
20 | {showReset && ( 21 | 25 | Remove all filters 26 | 27 | )} 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default EmptyState; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2024 Sudeep Mahato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /components/Image.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | import Image from "next/image"; 4 | import { cn } from "@/utils/helper"; 5 | 6 | const CustomImage = ({ 7 | imageSrc, 8 | fill = false, 9 | alt, 10 | className, 11 | priority = false, 12 | effect, 13 | sizes 14 | }: { 15 | imageSrc: string; 16 | fill?: boolean; 17 | alt: string; 18 | className?: string; 19 | priority?: boolean; 20 | effect?: "zoom"; 21 | sizes?: string; 22 | }) => { 23 | const [isImageLoaded, setIsImageLoaded] = useState(false); 24 | 25 | return ( 26 | {alt} setIsImageLoaded(true)} 37 | priority={priority} 38 | sizes={sizes} 39 | unoptimized 40 | /> 41 | ); 42 | }; 43 | 44 | export default CustomImage; 45 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Nunito } from "next/font/google"; 3 | import { GoogleAnalytics } from "@next/third-parties/google"; 4 | 5 | import "./globals.css"; 6 | import "react-loading-skeleton/dist/skeleton.css"; 7 | import Navbar from "@/components/navbar"; 8 | import Providers from "@/components/Provider"; 9 | 10 | const nunito = Nunito({ subsets: ["latin"] }); 11 | 12 | export const metadata: Metadata = { 13 | title: "VacationHub", 14 | description: 15 | "Your Ultimate Destination Connection. Discover a world of endless possibilities and seamless vacation planning at VacationHub.", 16 | }; 17 | 18 | export default function RootLayout({ 19 | children, 20 | }: { 21 | children: React.ReactNode; 22 | }) { 23 | return ( 24 | 25 | 26 | 27 | 28 |
{children}
29 |
30 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import { cn } from "@/utils/helper"; 3 | 4 | interface ButtonProps extends React.ButtonHTMLAttributes { 5 | size?: "small" | "large"; 6 | className?: string; 7 | children?: ReactNode; 8 | outline?: boolean; 9 | } 10 | 11 | const Button: React.FC = ({ 12 | children, 13 | className, 14 | size = "small", 15 | outline = false, 16 | ...props 17 | }) => { 18 | return ( 19 | 34 | ); 35 | }; 36 | 37 | export default Button; 38 | -------------------------------------------------------------------------------- /app/favorites/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import EmptyState from "@/components/EmptyState"; 4 | import ListingCard from "@/components/ListingCard"; 5 | import Heading from "@/components/Heading"; 6 | 7 | import { getCurrentUser } from "@/services/user"; 8 | import { getFavoriteListings } from "@/services/favorite"; 9 | 10 | const FavoritesPage = async () => { 11 | const user = await getCurrentUser(); 12 | 13 | if (!user) { 14 | return ; 15 | } 16 | 17 | const favorites = await getFavoriteListings(); 18 | 19 | if (favorites.length === 0) { 20 | return ( 21 | 25 | ); 26 | } 27 | 28 | return ( 29 |
30 | 31 |
32 | {favorites.map((listing) => { 33 | return ; 34 | })} 35 |
36 |
37 | ); 38 | }; 39 | 40 | export default FavoritesPage; 41 | -------------------------------------------------------------------------------- /app/listings/[listingId]/_components/ListingHead.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Image from "@/components/Image"; 3 | 4 | import Heading from "@/components/Heading"; 5 | import HeartButton from "@/components/HeartButton"; 6 | import { getFavorites } from "@/services/favorite"; 7 | 8 | interface ListingHeadProps { 9 | title: string; 10 | country: string | null; 11 | region: string | null; 12 | image: string; 13 | id: string; 14 | } 15 | 16 | const ListingHead: React.FC = async ({ 17 | title, 18 | country = "", 19 | region = "", 20 | image, 21 | id, 22 | }) => { 23 | const favorites = await getFavorites(); 24 | const hasFavorited = favorites.includes(id); 25 | 26 | return ( 27 | <> 28 | 29 |
32 | {title} 33 |
34 | 35 |
36 |
37 | 38 | ); 39 | }; 40 | 41 | export default ListingHead; 42 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/Map.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React from "react"; 4 | import L from "leaflet"; 5 | import "leaflet/dist/leaflet.css"; 6 | import { MapContainer, Marker, TileLayer } from "react-leaflet"; 7 | import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png"; 8 | import marketIcon from "leaflet/dist/images/marker-icon.png"; 9 | import markerShadow from "leaflet/dist/images/marker-shadow.png"; 10 | 11 | // @ts-ignore 12 | delete L.Icon.Default.prototype._getIconUrl; 13 | L.Icon.Default.mergeOptions({ 14 | iconUrl: marketIcon.src, 15 | iconRetinaUrl: markerIcon2x.src, 16 | shadowUrl: markerShadow.src, 17 | }); 18 | 19 | interface MapProps { 20 | center?: number[]; 21 | } 22 | 23 | const Map: React.FC = ({ center }) => { 24 | return ( 25 | 31 | 35 | {center && } 36 | 37 | ); 38 | }; 39 | 40 | export default Map; -------------------------------------------------------------------------------- /utils/motion.ts: -------------------------------------------------------------------------------- 1 | export const zoomIn = (scale: number, duration: number) => ({ 2 | hidden: { 3 | opacity: 0, 4 | scale, 5 | transition: { 6 | duration, 7 | ease: "easeOut", 8 | }, 9 | }, 10 | show: { 11 | opacity: 1, 12 | scale: 1, 13 | transition: { 14 | duration, 15 | ease: "easeOut", 16 | }, 17 | }, 18 | }); 19 | 20 | export const fadeIn = { 21 | hidden: { 22 | opacity: 0, 23 | transition: { 24 | duration: 0.15, 25 | type: "tween", 26 | ease: "easeIn", 27 | }, 28 | }, 29 | show: { 30 | opacity: 1, 31 | transition: { 32 | duration: 0.15, 33 | type: "tween", 34 | ease: "easeIn" 35 | }, 36 | }, 37 | }; 38 | 39 | export const slideIn = ( 40 | direction: "up" | "down" | "left" | "right", 41 | type: "tween" | "spring", 42 | duration: number 43 | ) => ({ 44 | hidden: { 45 | x: direction === "left" ? "-100%" : direction === "right" ? "100%" : 0, 46 | y: direction === "up" ? "100%" : direction === "down" ? "-100%" : 0, 47 | opacity: 0, 48 | transition: { 49 | duration, 50 | type, 51 | ease: "easeOut" 52 | }, 53 | }, 54 | show: { 55 | x: 0, 56 | y: 0, 57 | opacity: 1, 58 | transition: { 59 | type, 60 | duration, 61 | ease: "easeInOut" 62 | }, 63 | }, 64 | }); 65 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | overflow-x: hidden; 21 | overflow-y: scroll; 22 | scroll-behavior: smooth; 23 | } 24 | 25 | .no-scroll { 26 | position: fixed; 27 | inline-size: 100%; 28 | } 29 | 30 | .main-container { 31 | @apply max-w-[1140px] 2xl:max-w-[1200px] mx-auto xl:px-0 px-4; 32 | } 33 | 34 | .swiper-wrapper { 35 | @apply md:gap-2 gap-1 justify-between; 36 | } 37 | 38 | .leaflet-bottom, 39 | .leaflet-control, 40 | .leaflet-pane, 41 | .leaflet-top { 42 | z-index: 0 !important; 43 | } 44 | 45 | .rdrMonth { 46 | width: 100% !important; 47 | } 48 | 49 | .rdrCalendarWrapper { 50 | width: 100% !important; 51 | font-size: 16px !important; 52 | } 53 | 54 | 55 | ::-webkit-scrollbar { 56 | @apply w-2; 57 | } 58 | 59 | ::-webkit-scrollbar-thumb { 60 | @apply bg-rose-400 transition-all duration-300 rounded-[25px]; 61 | } 62 | 63 | ::-webkit-scrollbar-thumb:hover { 64 | @apply bg-rose-500; 65 | } 66 | 67 | ::-webkit-scrollbar-track { 68 | @apply bg-rose-100 rounded-[25px]; 69 | } 70 | 71 | ::-webkit-scrollbar-corner { 72 | @apply rounded-[25px]; 73 | } 74 | 75 | .hide-scrollbar::-webkit-scrollbar { 76 | width: 0; 77 | } -------------------------------------------------------------------------------- /components/ConfirmDelete.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IoMdClose } from "react-icons/io"; 3 | import Button from "./Button"; 4 | import SpinnerMini from "./Loader"; 5 | 6 | interface ConfirmDeleteProps { 7 | title: string; 8 | onConfirm: (fn?: () => void) => void; 9 | onCloseModal?: () => void; 10 | isLoading?: boolean; 11 | } 12 | 13 | const ConfirmDelete: React.FC = ({ 14 | title, 15 | onConfirm, 16 | onCloseModal, 17 | isLoading = false, 18 | }) => { 19 | const onAction = () => { 20 | onConfirm(onCloseModal); 21 | }; 22 | 23 | return ( 24 |
25 | 32 |

{title}

33 |

34 | Are you sure you want to do this? It can't be undone. 35 |

36 | 37 |
38 | 41 | 44 |
45 |
46 | ); 47 | }; 48 | 49 | export default ConfirmDelete; 50 | -------------------------------------------------------------------------------- /app/api/webhooks/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers' 2 | import { NextResponse } from 'next/server' 3 | import Stripe from 'stripe' 4 | import { stripe } from '@/lib/stripe' 5 | import { createReservation } from '@/services/reservation' 6 | 7 | 8 | export async function POST(req: Request) { 9 | try { 10 | const body = await req.text() 11 | const signature = headers().get('stripe-signature'); 12 | 13 | 14 | if (!signature) { 15 | return new Response('Invalid signature', { status: 400 }) 16 | } 17 | 18 | const event = stripe.webhooks.constructEvent( 19 | body, 20 | signature, 21 | process.env.STRIPE_WEBHOOK_SECRET! 22 | ) 23 | 24 | if (event.type === 'checkout.session.completed') { 25 | if (!event.data.object.customer_details?.email) { 26 | throw new Error('Missing user email') 27 | } 28 | 29 | const session = event.data.object as Stripe.Checkout.Session 30 | 31 | const { listingId, 32 | startDate, 33 | endDate, 34 | totalPrice, userId} = session.metadata || {}; 35 | 36 | if (!listingId || !startDate || !endDate || !totalPrice || !userId) { 37 | throw new Error('Invalid request metadata') 38 | } 39 | 40 | await createReservation({listingId, startDate: new Date(startDate), endDate: new Date(endDate), totalPrice: Number(totalPrice), userId}) 41 | 42 | } 43 | 44 | return NextResponse.json({ result: event, ok: true }) 45 | } catch (err) { 46 | console.error(err) 47 | 48 | return NextResponse.json( 49 | { message: 'Something went wrong', ok: false }, 50 | { status: 500 } 51 | ) 52 | } 53 | } -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, Suspense } from "react"; 2 | 3 | import ListingCard from "@/components/ListingCard"; 4 | import LoadMore from "@/components/LoadMore"; 5 | import EmptyState from "@/components/EmptyState"; 6 | 7 | import { getListings } from "@/services/listing"; 8 | import { getFavorites } from "@/services/favorite"; 9 | 10 | export const dynamic = "force-dynamic"; 11 | 12 | interface HomeProps { 13 | searchParams?: { [key: string]: string | undefined }; 14 | } 15 | 16 | const Home: FC = async ({ searchParams }) => { 17 | const { listings, nextCursor } = await getListings(searchParams); 18 | const favorites = await getFavorites(); 19 | 20 | if (!listings || listings.length === 0) { 21 | return ( 22 | 26 | ); 27 | } 28 | 29 | return ( 30 |
31 | {listings.map((listing) => { 32 | const hasFavorited = favorites.includes(listing.id); 33 | return ( 34 | 39 | ); 40 | })} 41 | {nextCursor ? ( 42 | }> 43 | 50 | 51 | ) : null} 52 |
53 | ); 54 | }; 55 | 56 | export default Home; 57 | -------------------------------------------------------------------------------- /components/navbar/CategoryBox.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import React from "react"; 3 | import { useRouter, useSearchParams } from "next/navigation"; 4 | import queryString from "query-string"; 5 | 6 | import { Category } from "@/types"; 7 | 8 | interface CategoryBoxProps extends Category { 9 | selected?: boolean; 10 | } 11 | 12 | const CategoryBox: React.FC = ({ 13 | icon: Icon, 14 | label, 15 | selected, 16 | }) => { 17 | const router = useRouter(); 18 | const params = useSearchParams(); 19 | 20 | const handleClick = () => { 21 | let currentQuery = {}; 22 | if (params) { 23 | currentQuery = queryString.parse(params.toString()); 24 | } 25 | 26 | const updatedQuery: any = { 27 | ...currentQuery, 28 | category: label, 29 | }; 30 | 31 | if (params?.get("category") === label) { 32 | delete updatedQuery.category; 33 | } 34 | 35 | const url = queryString.stringifyUrl( 36 | { 37 | url: "/", 38 | query: updatedQuery, 39 | }, 40 | { skipNull: true } 41 | ); 42 | router.push(url); 43 | } 44 | 45 | return ( 46 | 57 | ); 58 | }; 59 | 60 | export default CategoryBox; -------------------------------------------------------------------------------- /components/inputs/CategoryButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useRef, useEffect } from "react"; 3 | import { FieldValues, UseFormWatch } from "react-hook-form"; 4 | 5 | import { cn } from "@/utils/helper"; 6 | import { Category } from "@/types"; 7 | 8 | interface CategoryButtonProps extends Category { 9 | onClick: (fieldName: string, value: string) => void; 10 | watch: UseFormWatch; 11 | } 12 | 13 | const CategoryButton: React.FC = ({ 14 | icon: Icon, 15 | label, 16 | onClick, 17 | watch, 18 | }) => { 19 | const isSelected = watch("category") === label; 20 | const buttonRef = useRef(null); 21 | 22 | useEffect(() => { 23 | if (!buttonRef.current) return; 24 | const timer = setTimeout(() => { 25 | if(isSelected){ 26 | buttonRef.current?.focus(); 27 | } 28 | }, 300); 29 | 30 | return () => clearTimeout(timer); 31 | }, [isSelected]); 32 | 33 | const handleChange = () => { 34 | if(isSelected) return; 35 | onClick("category", label); 36 | }; 37 | 38 | return ( 39 |
40 | 53 |
54 | ); 55 | }; 56 | 57 | export default CategoryButton; 58 | -------------------------------------------------------------------------------- /app/listings/[listingId]/loading.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React, { useLayoutEffect } from "react"; 3 | import Skeleton from "react-loading-skeleton"; 4 | 5 | const Loading = () => { 6 | useLayoutEffect(() => { 7 | if(typeof window === undefined) return; 8 | window.scrollTo(0,0); 9 | }, []) 10 | 11 | return ( 12 |
13 |
14 |
15 | 16 | 17 |
18 | 19 | 23 |
24 | 25 |
26 |
27 |
28 |
29 | 30 | 31 | 32 |
33 | 34 |
35 | 36 | 37 | 38 |
39 |
40 |
41 | 42 |
43 | 44 |
45 |
46 |
47 | ); 48 | }; 49 | 50 | export default Loading; 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airbnb", 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 | "@edgestore/react": "^0.1.6", 14 | "@edgestore/server": "^0.1.6", 15 | "@next-auth/prisma-adapter": "^1.0.7", 16 | "@next/third-parties": "^15.2.1", 17 | "@prisma/client": "^5.8.0", 18 | "@tanstack/react-query": "^4.35.3", 19 | "bcrypt": "^5.1.1", 20 | "clsx": "^2.1.0", 21 | "date-fns": "^3.2.0", 22 | "framer-motion": "^10.17.6", 23 | "leaflet": "^1.9.4", 24 | "lodash.debounce": "^4.0.8", 25 | "lodash.throttle": "^4.1.1", 26 | "next": "14.2.24", 27 | "next-auth": "^4.24.5", 28 | "query-string": "^8.1.0", 29 | "react": "^18", 30 | "react-date-range": "^2.0.0-alpha.4", 31 | "react-dom": "^18", 32 | "react-hook-form": "^7.49.3", 33 | "react-hot-toast": "^2.4.1", 34 | "react-icons": "^4.12.0", 35 | "react-intersection-observer": "^9.5.3", 36 | "react-leaflet": "^4.2.1", 37 | "react-loading-skeleton": "^3.3.1", 38 | "react-select": "^5.8.0", 39 | "sharp": "^0.33.1", 40 | "stripe": "^17.2.1", 41 | "swiper": "^11.0.5", 42 | "tailwind-merge": "^2.2.0", 43 | "zod": "^3.22.4" 44 | }, 45 | "devDependencies": { 46 | "@types/bcrypt": "^5.0.2", 47 | "@types/leaflet": "^1.9.8", 48 | "@types/lodash.debounce": "^4.0.9", 49 | "@types/lodash.throttle": "^4.1.9", 50 | "@types/node": "^20", 51 | "@types/react": "^18", 52 | "@types/react-date-range": "^1.4.9", 53 | "@types/react-dom": "^18", 54 | "autoprefixer": "^10.0.1", 55 | "eslint": "^8", 56 | "eslint-config-next": "14.0.4", 57 | "postcss": "^8", 58 | "prisma": "^5.8.0", 59 | "tailwindcss": "^3.3.0", 60 | "typescript": "^5" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /components/inputs/CountrySelect.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect, useRef } from "react"; 3 | import Select from "react-select"; 4 | import countries from "@/data/countries.json"; 5 | 6 | export type CountrySelectValue = { 7 | flag: string; 8 | label: string; 9 | latlng: number[]; 10 | region: string; 11 | value: string; 12 | }; 13 | 14 | const CountrySelect = ({ 15 | value, 16 | onChange, 17 | }: { 18 | value?: CountrySelectValue; 19 | onChange: (name: string, val: CountrySelectValue) => void; 20 | }) => { 21 | const ref = useRef(null); 22 | 23 | useEffect(() => { 24 | const timer = setTimeout(() => { 25 | ref.current?.focus(); 26 | }, 300); 27 | 28 | return () => clearTimeout(timer); 29 | }, []); 30 | 31 | const handleChange = (value: CountrySelectValue) => { 32 | onChange("location", value); 33 | }; 34 | 35 | return ( 36 | 53 | 64 | 65 | ); 66 | }; 67 | 68 | export default Input; 69 | -------------------------------------------------------------------------------- /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 | password String? 17 | favoriteIds String[] @db.ObjectId 18 | 19 | createdAt DateTime @default(now()) 20 | updatedAt DateTime @updatedAt 21 | 22 | accounts Account[] 23 | listings Listing[] 24 | reservations Reservation[] 25 | } 26 | 27 | model Account { 28 | id String @id @default(auto()) @map("_id") @db.ObjectId 29 | userId String @db.ObjectId 30 | type String 31 | provider String 32 | providerAccountId String 33 | refresh_token String? @db.String 34 | access_token String? @db.String 35 | expires_at Int? 36 | token_type String? 37 | scope String? 38 | id_token String? @db.String 39 | session_state String? 40 | 41 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 42 | 43 | @@unique([provider, providerAccountId]) 44 | } 45 | 46 | model Listing { 47 | id String @id @default(auto()) @map("_id") @db.ObjectId 48 | title String 49 | description String 50 | imageSrc String 51 | createdAt DateTime @default(now()) 52 | category String 53 | roomCount Int 54 | bathroomCount Int 55 | guestCount Int 56 | userId String @db.ObjectId 57 | price Int 58 | country String? 59 | latlng Int[] 60 | region String? 61 | 62 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 63 | 64 | reservations Reservation[] 65 | } 66 | 67 | model Reservation { 68 | id String @id @default(auto()) @map("_id") @db.ObjectId 69 | userId String @db.ObjectId 70 | listingId String @db.ObjectId 71 | startDate DateTime 72 | endDate DateTime 73 | totalPrice Int 74 | createdAt DateTime @default(now()) 75 | 76 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 77 | listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade) 78 | } 79 | -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import { AuthOptions } from "next-auth"; 3 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 4 | import CredentialsProvider from "next-auth/providers/credentials"; 5 | import GoogleProvider from "next-auth/providers/google"; 6 | import GitHubProvider from "next-auth/providers/github"; 7 | import { db } from "./db"; 8 | 9 | export const authOptions: AuthOptions = { 10 | adapter: PrismaAdapter(db), 11 | providers: [ 12 | GitHubProvider({ 13 | clientId: process.env.GITHUB_CLIENT_ID as string, 14 | clientSecret: process.env.GITHUB_CLIENT_SECRET as string, 15 | }), 16 | GoogleProvider({ 17 | clientId: process.env.GOOGLE_CLIENT_ID as string, 18 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, 19 | }), 20 | CredentialsProvider({ 21 | name: "credentials", 22 | credentials: { 23 | email: { label: "email", type: "text" }, 24 | password: { 25 | label: "password", 26 | type: "password", 27 | }, 28 | }, 29 | 30 | async authorize(credentials) { 31 | if (!credentials?.email || !credentials?.password) { 32 | throw new Error("Invalid credentials"); 33 | } 34 | 35 | const user = await db.user.findUnique({ 36 | where: { 37 | email: credentials.email, 38 | }, 39 | }); 40 | 41 | if (!user || !user?.password) throw new Error("Invalid credentials"); 42 | 43 | const isCorrectPassword = await bcrypt.compare( 44 | credentials.password, 45 | user.password 46 | ); 47 | 48 | if (!isCorrectPassword) throw new Error("Invalid credentials"); 49 | 50 | return user; 51 | }, 52 | }), 53 | ], 54 | callbacks: { 55 | async session({ token, session }) { 56 | if (token) { 57 | session.user.id = token.id; 58 | session.user.name = token.name; 59 | session.user.email = token.email; 60 | } 61 | 62 | return session; 63 | }, 64 | 65 | async jwt({ token, user }) { 66 | if (user) { 67 | return { ...token, ...user }; 68 | } 69 | return token; 70 | }, 71 | }, 72 | pages: { 73 | signIn: "/", 74 | }, 75 | session: { 76 | strategy: "jwt", 77 | }, 78 | secret: process.env.NEXTAUTH_SECRET, 79 | }; 80 | -------------------------------------------------------------------------------- /services/favorite.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/lib/db"; 4 | import { getCurrentUser } from "./user"; 5 | import { revalidatePath } from "next/cache"; 6 | 7 | export const getFavorites = async () => { 8 | try { 9 | const user = await getCurrentUser(); 10 | 11 | if (!user) return []; 12 | const data = await db.user.findUnique({ 13 | where: { 14 | id: user.id, 15 | }, 16 | select: { 17 | favoriteIds: true, 18 | }, 19 | }); 20 | 21 | return data?.favoriteIds ?? []; 22 | } catch (error) { 23 | return []; 24 | } 25 | }; 26 | 27 | export const updateFavorite = async ({ 28 | listingId, 29 | favorite, 30 | }: { 31 | listingId: string; 32 | favorite: boolean; 33 | }) => { 34 | try { 35 | if (!listingId || typeof listingId !== "string") { 36 | throw new Error("Invalid ID"); 37 | } 38 | 39 | const favorites = await getFavorites(); 40 | const currentUser = await getCurrentUser(); 41 | 42 | if (!currentUser) { 43 | throw new Error("Please sign in to favorite the listing!"); 44 | } 45 | 46 | let newFavorites; 47 | let hasFavorited; 48 | 49 | if (!favorite) { 50 | newFavorites = favorites.filter((id) => id !== listingId); 51 | hasFavorited = false; 52 | } else { 53 | if (favorites.includes(listingId)) { 54 | newFavorites = [...favorites]; 55 | } else { 56 | newFavorites = [listingId, ...favorites]; 57 | } 58 | hasFavorited = true; 59 | } 60 | 61 | await db.user.update({ 62 | where: { 63 | id: currentUser.id, 64 | }, 65 | data: { 66 | favoriteIds: newFavorites, 67 | }, 68 | }); 69 | 70 | revalidatePath("/"); 71 | revalidatePath(`/listings/${listingId}`); 72 | revalidatePath("/favorites"); 73 | 74 | return { 75 | hasFavorited, 76 | }; 77 | } catch (error: any) { 78 | throw new Error(error.message); 79 | } 80 | }; 81 | 82 | export const getFavoriteListings = async () => { 83 | try { 84 | const favoriteIds = await getFavorites(); 85 | const favorites = await db.listing.findMany({ 86 | where: { 87 | id: { 88 | in: [...(favoriteIds || [])], 89 | }, 90 | }, 91 | }); 92 | 93 | return favorites; 94 | } catch (error: any) { 95 | throw new Error(error.message); 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /components/HeartButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState, useCallback, useRef } from "react"; 3 | import { AiFillHeart, AiOutlineHeart } from "react-icons/ai"; 4 | import { toast } from "react-hot-toast"; 5 | import { useMutation } from "@tanstack/react-query"; 6 | import debounce from "lodash.debounce"; 7 | import { useSession } from "next-auth/react"; 8 | 9 | import { cn } from "@/utils/helper"; 10 | import { updateFavorite } from "@/services/favorite"; 11 | 12 | interface HeartButtonProps { 13 | listingId: string; 14 | hasFavorited: boolean; 15 | } 16 | 17 | const HeartButton: React.FC = ({ 18 | listingId, 19 | hasFavorited: initialValue, 20 | }) => { 21 | const { status } = useSession(); 22 | const [hasFavorited, setHasFavorited] = useState(initialValue); 23 | const hasFavoritedRef = useRef(initialValue); 24 | const { mutate } = useMutation({ 25 | mutationFn: updateFavorite, 26 | onError: () => { 27 | hasFavoritedRef.current = !hasFavoritedRef.current; 28 | setHasFavorited(hasFavoritedRef.current); 29 | toast.error("Failed to favorite"); 30 | } 31 | }); 32 | 33 | const debouncedUpdateFavorite = debounce(() => { 34 | mutate({ 35 | listingId, 36 | favorite: hasFavoritedRef.current, 37 | }); 38 | }, 300); 39 | 40 | const handleUpdate = useCallback(() => { 41 | debouncedUpdateFavorite(); 42 | // eslint-disable-next-line react-hooks/exhaustive-deps 43 | }, []); 44 | 45 | const handleClick = (e: React.MouseEvent) => { 46 | e.stopPropagation(); 47 | e.preventDefault(); 48 | 49 | if (status !== "authenticated") { 50 | toast.error("Please sign in to favorite the listing!"); 51 | return; 52 | } 53 | 54 | handleUpdate(); 55 | setHasFavorited((prev) => !prev); 56 | hasFavoritedRef.current = !hasFavoritedRef.current; 57 | }; 58 | 59 | return ( 60 | 79 | ); 80 | }; 81 | 82 | export default HeartButton; 83 | -------------------------------------------------------------------------------- /components/navbar/Search.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useMemo } from "react"; 3 | import dynamic from "next/dynamic"; 4 | import { differenceInDays } from "date-fns"; 5 | import { useSearchParams } from "next/navigation"; 6 | import { FaSearch } from "react-icons/fa"; 7 | 8 | import Modal from "../modals/Modal"; 9 | 10 | const SearchModal = dynamic(() => import("@/components/modals/SearchModal"), { 11 | ssr: false 12 | }); 13 | 14 | const Search = () => { 15 | const searchParams = useSearchParams(); 16 | 17 | const country = searchParams?.get("country"); 18 | 19 | const startDate = searchParams?.get("startDate"); 20 | const endDate = searchParams?.get("endDate"); 21 | const guestCount = searchParams?.get("guestCount"); 22 | 23 | const durationLabel = useMemo(() => { 24 | if (startDate && endDate) { 25 | const start = new Date(startDate); 26 | const end = new Date(endDate); 27 | let diff = differenceInDays(end, start); 28 | 29 | if (diff === 0) { 30 | diff = 1; 31 | } 32 | 33 | return `${diff} Days`; 34 | } 35 | 36 | return "Any week"; 37 | }, [endDate, startDate]); 38 | 39 | const guestLabel = guestCount ? `${guestCount} Guests` : "Add Guests"; 40 | 41 | return ( 42 | 43 | 44 | 67 | 68 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | 75 | export default Search; 76 | -------------------------------------------------------------------------------- /components/LoadMore.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { FC } from "react"; 3 | import { Listing } from "@prisma/client"; 4 | import { useInfiniteQuery } from "@tanstack/react-query"; 5 | 6 | import ListingCard, { ListingSkeleton } from "./ListingCard"; 7 | import { useLoadMore } from "@/hooks/useLoadMore"; 8 | 9 | interface LoadMoreProps { 10 | nextCursor: string; 11 | fnArgs?: { [key: string]: string | undefined }; 12 | queryFn: (args: Record) => Promise<{ 13 | listings: Listing[]; 14 | nextCursor: null | string; 15 | }>; 16 | queryKey: any[]; 17 | favorites: string[]; 18 | } 19 | 20 | const LoadMore: FC = ({ 21 | nextCursor, 22 | fnArgs, 23 | queryFn, 24 | queryKey, 25 | favorites, 26 | }) => { 27 | const { data, isFetchingNextPage, hasNextPage, status, fetchNextPage } = 28 | useInfiniteQuery({ 29 | queryFn: ({ pageParam = nextCursor }) => 30 | queryFn({ ...fnArgs, cursor: pageParam }), 31 | queryKey, 32 | getNextPageParam: (lastPage) => lastPage?.nextCursor, 33 | }); 34 | 35 | const { ref } = useLoadMore( 36 | fetchNextPage, 37 | hasNextPage, 38 | status === "loading" || isFetchingNextPage, 39 | status === "error" 40 | ); 41 | 42 | return ( 43 | <> 44 | {data?.pages.map((group, i) => ( 45 | 46 | {group?.listings?.map( 47 | ( 48 | listing: Listing & { 49 | reservation?: { 50 | id: string; 51 | startDate: Date; 52 | endDate: Date; 53 | totalPrice: number; 54 | }; 55 | } 56 | ) => { 57 | const hasFavorited = favorites.includes(listing.id); 58 | return ( 59 | 65 | ); 66 | } 67 | )} 68 | 69 | ))} 70 | {(status === "loading" || isFetchingNextPage) && ( 71 | <> 72 | {Array.from({ length: 4 }).map( 73 | (_item: any, i: number) => ( 74 | 75 | ) 76 | )} 77 | 78 | )} 79 | {status === "error" && ( 80 |

81 | Something went wrong! 82 |

83 | )} 84 |
85 | 86 | ); 87 | }; 88 | 89 | export default LoadMore; 90 | -------------------------------------------------------------------------------- /utils/constants.ts: -------------------------------------------------------------------------------- 1 | import { TbBeach, TbMountain, TbPool } from "react-icons/tb"; 2 | import { 3 | GiBarn, 4 | GiBoatFishing, 5 | GiCactus, 6 | GiCastle, 7 | GiCaveEntrance, 8 | GiForestCamp, 9 | GiIsland, 10 | GiWindmill, 11 | } from "react-icons/gi"; 12 | import { FaSkiing } from "react-icons/fa"; 13 | import { BsSnow } from "react-icons/bs"; 14 | import { IoDiamond } from "react-icons/io5"; 15 | import { MdOutlineVilla } from "react-icons/md"; 16 | 17 | export const categories = [ 18 | { 19 | label: "Beach", 20 | icon: TbBeach, 21 | description: "This property is close to the beach!", 22 | }, 23 | { 24 | label: "Windmills", 25 | icon: GiWindmill, 26 | description: "This property has a windmills!", 27 | }, 28 | { 29 | label: "Modern", 30 | icon: MdOutlineVilla, 31 | description: "This property is modern!", 32 | }, 33 | { 34 | label: "Countryside", 35 | icon: TbMountain, 36 | description: "This property is in the countryside!", 37 | }, 38 | { 39 | label: "Pools", 40 | icon: TbPool, 41 | description: "This is property has a beautiful pool!", 42 | }, 43 | { 44 | label: "Islands", 45 | icon: GiIsland, 46 | description: "This property is on an island!", 47 | }, 48 | { 49 | label: "Lake", 50 | icon: GiBoatFishing, 51 | description: "This property is near a lake!", 52 | }, 53 | { 54 | label: "Skiing", 55 | icon: FaSkiing, 56 | description: "This property has skiing activies!", 57 | }, 58 | { 59 | label: "Castles", 60 | icon: GiCastle, 61 | description: "This property is an ancient castle!", 62 | }, 63 | { 64 | label: "Caves", 65 | icon: GiCaveEntrance, 66 | description: "This property is in a spooky cave!", 67 | }, 68 | { 69 | label: "Camping", 70 | icon: GiForestCamp, 71 | description: "This property offers camping activities!", 72 | }, 73 | { 74 | label: "Arctic", 75 | icon: BsSnow, 76 | description: "This property is in arctic environment!", 77 | }, 78 | { 79 | label: "Desert", 80 | icon: GiCactus, 81 | description: "This property is in the desert!", 82 | }, 83 | { 84 | label: "Barns", 85 | icon: GiBarn, 86 | description: "This property is in a barn!", 87 | }, 88 | { 89 | label: "Lux", 90 | icon: IoDiamond, 91 | description: "This property is brand new and luxurious!", 92 | }, 93 | ]; 94 | 95 | export const LISTINGS_BATCH = 16; 96 | 97 | export const menuItems = [ 98 | { 99 | label: "My trips", 100 | path: "/trips", 101 | }, 102 | { 103 | label: "My favorites", 104 | path: "/favorites", 105 | }, 106 | { 107 | label: "My reservations", 108 | path: "/reservations", 109 | }, 110 | { 111 | label: "My properties", 112 | path: "/properties", 113 | }, 114 | ]; 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Airbnb Clone 2 | 3 | This is an Airbnb clone built with Next.js, TypeScript, Tailwind CSS, MongoDB, Prisma, Next auth, Leaflet and many other technologies. 4 | 5 | ## Features 6 | 7 | - User registration and authentication 8 | - Property listing and browsing 9 | - Property booking and reservations 10 | - Search and filtering of properties 11 | - Interactive map using Leaflet to display property locations 12 | 13 | ## Demo 14 | 15 | You can check out a live demo of the Airbnb clone project [here](https://airbnb-clone-phi-green.vercel.app/). 16 | 17 | ## Screenshots 18 | 19 | vacationhub 20 | 21 | login-modal 22 | 23 | listing 24 | 25 | ## Prerequisites 26 | 27 | Make sure you have the following software installed on your system: 28 | 29 | - git If you want to clone the project from GitHub and work with it locally, you will need to have Git installed on your system. You can download and install Git from the official website (https://git-scm.com/). 30 | 31 | - Node.js Application requires Node.js to be installed on your system in order to run. You can download and install the latest version of Node.js from the official website (https://nodejs.org/). 32 | 33 | ## Installation 34 | 35 | - Clone the repository: 36 | 37 | ``` 38 | git clone https://github.com/sudeepmahato16/airbnb_clone.git 39 | ``` 40 | 41 | - Navigate to the project directory: 42 | 43 | ``` 44 | cd Airbnb 45 | ``` 46 | 47 | - Install the dependencies: 48 | 49 | ``` 50 | npm install 51 | ``` 52 | 53 | - Set up the environment variables: 54 | 55 | 1. Create a `.env.local` file in the root directory. 56 | 57 | 2. Add the following variables to the .env file, replacing the placeholder values with your own: 58 | 59 | ``` 60 | DATABASE_URL= 61 | GITHUB_CLIENT_ID= 62 | GITHUB_CLIENT_SECRET= 63 | GOOGLE_CLIENT_ID= 64 | GOOGLE_CLIENT_SECRET= 65 | NEXTAUTH_SECRET= 66 | EDGE_STORE_ACCESS_KEY= 67 | EDGE_STORE_SECRET_KEY= 68 | ``` 69 | 70 | ``` 71 | 72 | ``` 73 | 74 | ## Usage 75 | 76 | - Start the development server: 77 | 78 | ``` 79 | npm run dev 80 | ``` 81 | 82 | - Open your browser and visit `http://localhost:3000` to access the application. 83 | 84 | ## Contributing 85 | 86 | Contributions are welcome! If you want to contribute to this project, please follow these steps: 87 | 88 | - Fork the repository. 89 | - Create a new branch for your feature or bug fix. 90 | - Commit your changes to the new branch. 91 | - Open a pull request back to the main repository, including a description of your changes. 92 | -------------------------------------------------------------------------------- /components/ListingMenu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { FC, useTransition } from "react"; 3 | import { BsThreeDots } from "react-icons/bs"; 4 | import { usePathname } from "next/navigation"; 5 | import { useMutation } from "@tanstack/react-query"; 6 | import toast from "react-hot-toast"; 7 | 8 | import Menu from "./Menu"; 9 | import Modal from "./modals/Modal"; 10 | import ConfirmDelete from "./ConfirmDelete"; 11 | 12 | import { deleteProperty } from "@/services/properties"; 13 | import { deleteReservation } from "@/services/reservation"; 14 | 15 | const pathNameDict: { [x: string]: string } = { 16 | "/properties": "Delete property", 17 | "/trips": "Cancel reservation", 18 | "/reservations": "Cancel guest reservation", 19 | }; 20 | 21 | interface ListingMenuProps { 22 | id: string; 23 | } 24 | 25 | const ListingMenu: FC = ({ id }) => { 26 | const pathname = usePathname(); 27 | const { mutate: deleteListing } = useMutation({ 28 | mutationFn: deleteProperty, 29 | }); 30 | const { mutate: cancelReservation } = useMutation({ 31 | mutationFn: deleteReservation, 32 | }); 33 | const [isLoading, startTransition] = useTransition(); 34 | 35 | if (pathname === "/" || pathname === "/favorites") return null; 36 | 37 | const onConfirm = (onModalClose?: () => void) => { 38 | startTransition(() => { 39 | try { 40 | if (pathname === "/properties") { 41 | deleteListing(id, { 42 | onSuccess: () => { 43 | onModalClose?.(); 44 | toast.success("Listing successfully deleted!"); 45 | }, 46 | }); 47 | } else if (pathname === "/trips" || pathname === "/reservations") { 48 | cancelReservation(id, { 49 | onSuccess: () => { 50 | onModalClose?.(); 51 | toast.success("Reservation successfully cancelled!"); 52 | }, 53 | }); 54 | } 55 | } catch (error) { 56 | toast.error("Oops! Something went wrong. Please try again later."); 57 | onModalClose?.() 58 | } 59 | }); 60 | }; 61 | 62 | return ( 63 | 64 | 65 | 69 | 75 | 76 | 77 | 78 | 79 | {pathNameDict[pathname]} 80 | 81 | 82 | 83 | 84 | 85 | 90 | 91 | 92 | ); 93 | }; 94 | 95 | export default ListingMenu; 96 | -------------------------------------------------------------------------------- /components/navbar/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { AiOutlineMenu } from "react-icons/ai"; 4 | import { useRouter } from "next/navigation"; 5 | import { signOut } from "next-auth/react"; 6 | import { User } from "next-auth"; 7 | 8 | import Avatar from "../Avatar"; 9 | import MenuItem from "./MenuItem"; 10 | import Menu from "@/components/Menu"; 11 | import RentModal from "../modals/RentModal"; 12 | import Modal from "../modals/Modal"; 13 | import AuthModal from "../modals/AuthModal"; 14 | import { menuItems } from "@/utils/constants"; 15 | 16 | interface UserMenuProps { 17 | user?: User; 18 | } 19 | 20 | const UserMenu: React.FC = ({ user }) => { 21 | const router = useRouter(); 22 | 23 | const redirect = (url: string) => { 24 | router.push(url); 25 | }; 26 | 27 | return ( 28 |
29 |
30 | 31 | 32 | 38 | 39 | 40 | 41 | 50 | 51 | 52 | {user ? ( 53 | <> 54 | {menuItems.map((item) => ( 55 | redirect(item.path)} 58 | key={item.label} 59 | /> 60 | ))} 61 | 62 | 63 | 64 | 65 |
66 | 67 | 68 | ) : ( 69 | <> 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | )} 79 |
80 |
81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 |
91 |
92 |
93 | ); 94 | }; 95 | 96 | export default UserMenu; 97 | -------------------------------------------------------------------------------- /components/ImageUpload.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, FC, useState, useTransition } from "react"; 2 | import Image from "next/image"; 3 | import { TbPhotoPlus } from "react-icons/tb"; 4 | 5 | import SpinnerMini from "./Loader"; 6 | import { useEdgeStore } from "@/lib/edgestore"; 7 | import { cn } from "@/utils/helper"; 8 | 9 | interface ImageUploadProps { 10 | onChange: (fieldName: string, imgSrc: string) => void; 11 | initialImage?: string; 12 | } 13 | 14 | const ImageUpload: FC = ({ onChange, initialImage = "" }) => { 15 | const [image, setImage] = useState(initialImage); 16 | const [isLoading, startTransition] = useTransition(); 17 | const [isDragging, setIsDragging] = useState(false); 18 | const { edgestore } = useEdgeStore(); 19 | 20 | const uploadImage = (e: any, file: File) => { 21 | if(!file.type.startsWith("image")) return; 22 | setImage(URL.createObjectURL(file)); 23 | startTransition(async () => { 24 | const res = await edgestore.publicFiles.upload({ 25 | file, 26 | options: { 27 | replaceTargetUrl: initialImage, 28 | }, 29 | }); 30 | 31 | onChange("image", res.url); 32 | setTimeout(() => { 33 | e.target.form?.requestSubmit(); 34 | }, 1000); 35 | }); 36 | }; 37 | 38 | const handleChange = (e: ChangeEvent) => { 39 | if (!e.target.files) return; 40 | 41 | const file = e.target.files[0]; 42 | uploadImage(e, file); 43 | }; 44 | 45 | const onDragOver = (e: React.DragEvent) => { 46 | e.preventDefault() 47 | setIsDragging(true); 48 | }; 49 | 50 | const onDragLeave = () => { 51 | setIsDragging(false); 52 | }; 53 | 54 | const onDrop = (e: React.DragEvent) => { 55 | e.preventDefault() 56 | setIsDragging(false) 57 | uploadImage(e, e.dataTransfer.files[0]) 58 | } 59 | 60 | return ( 61 | 105 | ); 106 | }; 107 | 108 | export default ImageUpload; 109 | -------------------------------------------------------------------------------- /app/listings/[listingId]/_components/ListingClient.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { 3 | ReactNode, 4 | useEffect, 5 | useMemo, 6 | useState, 7 | useTransition, 8 | } from "react"; 9 | import { differenceInCalendarDays, eachDayOfInterval } from "date-fns"; 10 | import { Range } from "react-date-range"; 11 | import { User } from "next-auth"; 12 | import toast from "react-hot-toast"; 13 | import { useRouter } from "next/navigation"; 14 | 15 | import ListingReservation from "./ListingReservation"; 16 | import { createPaymentSession, createReservation } from "@/services/reservation"; 17 | 18 | const initialDateRange = { 19 | startDate: new Date(), 20 | endDate: new Date(), 21 | key: "selection", 22 | }; 23 | 24 | interface ListingClientProps { 25 | reservations?: { 26 | startDate: Date; 27 | endDate: Date; 28 | }[]; 29 | children: ReactNode; 30 | id: string; 31 | title: string; 32 | price: number; 33 | user: 34 | | (User & { 35 | id: string; 36 | }) 37 | | undefined; 38 | } 39 | 40 | const ListingClient: React.FC = ({ 41 | price, 42 | reservations = [], 43 | children, 44 | user, 45 | id, 46 | title, 47 | }) => { 48 | const [totalPrice, setTotalPrice] = useState(price); 49 | const [dateRange, setDateRange] = useState(initialDateRange); 50 | const [isLoading, startTransition] = useTransition(); 51 | const router = useRouter(); 52 | const disabledDates = useMemo(() => { 53 | let dates: Date[] = []; 54 | reservations.forEach((reservation) => { 55 | const range = eachDayOfInterval({ 56 | start: new Date(reservation.startDate), 57 | end: new Date(reservation.endDate), 58 | }); 59 | 60 | dates = [...dates, ...range]; 61 | }); 62 | return dates; 63 | }, [reservations]); 64 | 65 | useEffect(() => { 66 | if (dateRange.startDate && dateRange.endDate) { 67 | const dayCount = differenceInCalendarDays( 68 | dateRange.endDate, 69 | dateRange.startDate 70 | ); 71 | 72 | if (dayCount && price) { 73 | setTotalPrice((dayCount + 1) * price); 74 | } else { 75 | setTotalPrice(price); 76 | } 77 | } 78 | }, [dateRange.endDate, dateRange.startDate, price]); 79 | 80 | const onCreateReservation = () => { 81 | if (!user) return toast.error("Please log in to reserve listing."); 82 | startTransition(async () => { 83 | try { 84 | const { endDate, startDate } = dateRange; 85 | const res = await createPaymentSession({ 86 | listingId: id, 87 | endDate, 88 | startDate, 89 | totalPrice, 90 | }); 91 | 92 | if(res?.url){ 93 | router.push(res.url); 94 | } 95 | } catch (error: any) { 96 | toast.error(error?.message); 97 | } 98 | }); 99 | }; 100 | 101 | return ( 102 |
103 | {children} 104 | 105 |
106 | setDateRange(value)} 110 | dateRange={dateRange} 111 | onSubmit={onCreateReservation} 112 | isLoading={isLoading} 113 | disabledDates={disabledDates} 114 | /> 115 |
116 |
117 | ); 118 | }; 119 | 120 | export default ListingClient; 121 | -------------------------------------------------------------------------------- /components/ListingCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Link from "next/link"; 3 | import { format } from "date-fns"; 4 | import { Listing } from "@prisma/client"; 5 | import Skeleton from "react-loading-skeleton"; 6 | 7 | import HeartButton from "./HeartButton"; 8 | import Image from "./Image"; 9 | import { formatPrice } from "@/utils/helper"; 10 | import ListingMenu from "./ListingMenu"; 11 | 12 | interface ListingCardProps { 13 | data: Listing; 14 | reservation?: { 15 | id: string; 16 | startDate: Date; 17 | endDate: Date; 18 | totalPrice: number; 19 | }; 20 | hasFavorited: boolean; 21 | } 22 | 23 | const ListingCard: React.FC = ({ 24 | data, 25 | reservation, 26 | hasFavorited, 27 | }) => { 28 | const price = reservation ? reservation.totalPrice : data?.price; 29 | 30 | let reservationDate; 31 | if (reservation) { 32 | const start = new Date(reservation.startDate); 33 | const end = new Date(reservation.endDate); 34 | reservationDate = `${format(start, "PP")} - ${format(end, "PP")}`; 35 | } 36 | 37 | return ( 38 |
39 |
40 |
41 | 42 |
43 | 44 |
45 | 50 |
51 |
52 | 53 |
54 |
55 |
56 | {data.title} 64 |
65 |
66 | 67 | {data?.region}, {data?.country} 68 | 69 | 70 | {reservationDate || data.category} 71 | 72 | 73 |
74 | 75 | $ {formatPrice(price)} 76 | 77 | {!reservation && night} 78 |
79 |
80 | 81 |
82 | ); 83 | }; 84 | 85 | export default ListingCard; 86 | 87 | export const ListingSkeleton = () => { 88 | return ( 89 |
90 |
91 | 97 | 98 |
99 | 100 | 101 |
102 | 103 | 104 |
105 |
106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /services/listing.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { db } from "@/lib/db"; 3 | import { LISTINGS_BATCH } from "@/utils/constants"; 4 | import { getCurrentUser } from "./user"; 5 | 6 | export const getListings = async (query?: { 7 | [key: string]: string | string[] | undefined | null; 8 | }) => { 9 | try { 10 | const { 11 | userId, 12 | roomCount, 13 | guestCount, 14 | bathroomCount, 15 | country, 16 | startDate, 17 | endDate, 18 | category, 19 | cursor, 20 | } = query || {}; 21 | 22 | let where: any = {}; 23 | 24 | if (userId) { 25 | where.userId = userId; 26 | } 27 | 28 | if (category) { 29 | where.category = category; 30 | } 31 | 32 | if (roomCount) { 33 | where.roomCount = { 34 | gte: +roomCount, 35 | }; 36 | } 37 | 38 | if (guestCount) { 39 | where.guestCount = { 40 | gte: +guestCount, 41 | }; 42 | } 43 | 44 | if (bathroomCount) { 45 | where.bathroomCount = { 46 | gte: +bathroomCount, 47 | }; 48 | } 49 | 50 | if (country) { 51 | where.country = country; 52 | } 53 | 54 | if (startDate && endDate) { 55 | where.NOT = { 56 | reservations: { 57 | some: { 58 | OR: [ 59 | { 60 | endDate: { gte: startDate }, 61 | startDate: { lte: startDate }, 62 | }, 63 | { 64 | startDate: { lte: endDate }, 65 | endDate: { gte: endDate }, 66 | }, 67 | ], 68 | }, 69 | }, 70 | }; 71 | } 72 | 73 | const filterQuery: any = { 74 | where, 75 | take: LISTINGS_BATCH, 76 | orderBy: { createdAt: "desc" }, 77 | }; 78 | 79 | if (cursor) { 80 | filterQuery.cursor = { id: cursor }; 81 | filterQuery.skip = 1; 82 | } 83 | 84 | const listings = await db.listing.findMany(filterQuery); 85 | 86 | const nextCursor = 87 | listings.length === LISTINGS_BATCH 88 | ? listings[LISTINGS_BATCH - 1].id 89 | : null; 90 | 91 | return { 92 | listings, 93 | nextCursor, 94 | }; 95 | } catch (error) { 96 | return { 97 | listings: [], 98 | nextCursor: null, 99 | }; 100 | } 101 | }; 102 | 103 | export const getListingById = async (id: string) => { 104 | const listing = await db.listing.findUnique({ 105 | where: { 106 | id, 107 | }, 108 | include: { 109 | user: { 110 | select: { 111 | name: true, 112 | image: true, 113 | }, 114 | }, 115 | reservations: { 116 | select: { 117 | startDate: true, 118 | endDate: true, 119 | }, 120 | }, 121 | }, 122 | }); 123 | 124 | return listing; 125 | }; 126 | 127 | export const createListing = async (data: { [x: string]: any }) => { 128 | const { 129 | category, 130 | location: { region, label: country, latlng }, 131 | guestCount, 132 | bathroomCount, 133 | roomCount, 134 | image: imageSrc, 135 | price, 136 | title, 137 | description, 138 | } = data; 139 | 140 | Object.keys(data).forEach((value: any) => { 141 | if (!data[value]) { 142 | throw new Error("Invalid data"); 143 | } 144 | }); 145 | 146 | const user = await getCurrentUser(); 147 | if (!user) throw new Error("Unauthorized!"); 148 | 149 | const listing = await db.listing.create({ 150 | data: { 151 | title, 152 | description, 153 | imageSrc, 154 | category, 155 | roomCount, 156 | bathroomCount, 157 | guestCount, 158 | country, 159 | region, 160 | latlng, 161 | price: parseInt(price, 10), 162 | userId: user.id, 163 | }, 164 | }); 165 | 166 | return listing; 167 | }; 168 | -------------------------------------------------------------------------------- /components/Menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { 3 | FC, 4 | ReactElement, 5 | ReactNode, 6 | cloneElement, 7 | createContext, 8 | useContext, 9 | useState, 10 | } from "react"; 11 | import { AnimatePresence, motion } from "framer-motion"; 12 | import { useOutsideClick } from "@/hooks/useOutsideClick"; 13 | import { zoomIn } from "@/utils/motion"; 14 | import { cn } from "@/utils/helper"; 15 | import { useKeyPress } from "@/hooks/useKeyPress"; 16 | 17 | const MenuContext = createContext({ 18 | openId: "", 19 | setOpenId: (val: string) => { }, 20 | close: () => { }, 21 | }); 22 | 23 | interface ToggleProps { 24 | children: ReactElement; 25 | id: string; 26 | className?: string; 27 | } 28 | 29 | const Toggle: FC = ({ children, id, className }) => { 30 | const { setOpenId, close, openId } = useContext(MenuContext); 31 | 32 | const handleClick = (e: React.MouseEvent) => { 33 | e.preventDefault(); 34 | e.stopPropagation(); 35 | 36 | (openId === "" || openId !== id) ? setOpenId(id) : close(); 37 | }; 38 | 39 | return <>{cloneElement(children, { onClick: handleClick })}; 40 | }; 41 | 42 | interface ListProps { 43 | children: ReactNode; 44 | className?: string; 45 | position?: "bottom-left" | "bottom-right"; 46 | } 47 | 48 | const List: FC = ({ 49 | children, 50 | className, 51 | position = "bottom-right", 52 | }) => { 53 | const { openId, close } = useContext(MenuContext); 54 | const { ref } = useOutsideClick({ 55 | action: close, 56 | listenCapturing: false, 57 | enable: !!openId 58 | }); 59 | 60 | useKeyPress({ 61 | key: "Escape", 62 | action: close, 63 | enable: !!openId 64 | }); 65 | 66 | return ( 67 | 68 | {!openId ? null : ( 69 | 84 | {children} 85 | 86 | )} 87 | 88 | ); 89 | }; 90 | 91 | interface ButtonProps { 92 | children: ReactNode; 93 | onClick?: (e: React.MouseEvent) => void; 94 | className?: string; 95 | } 96 | 97 | const Button: FC = ({ children, onClick, className }) => { 98 | const { close } = useContext(MenuContext); 99 | const handleClick = (e: React.MouseEvent) => { 100 | e.stopPropagation(); 101 | e.preventDefault(); 102 | close(); 103 | onClick?.(e); 104 | }; 105 | 106 | return ( 107 |
  • 108 | 115 |
  • 116 | ); 117 | }; 118 | 119 | interface MenuProps { 120 | children: ReactNode; 121 | } 122 | 123 | const Menu: FC & { 124 | Toggle: typeof Toggle; 125 | List: typeof List; 126 | Button: typeof Button; 127 | } = ({ children }) => { 128 | const [openId, setOpenId] = useState(""); 129 | 130 | const close = () => setOpenId(""); 131 | 132 | return ( 133 | 140 |
    141 | {children} 142 |
    143 |
    144 | ); 145 | }; 146 | 147 | Menu.Toggle = Toggle; 148 | Menu.Button = Button; 149 | Menu.List = List; 150 | 151 | export default Menu; 152 | -------------------------------------------------------------------------------- /components/modals/Modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { 3 | FC, 4 | ReactElement, 5 | ReactNode, 6 | cloneElement, 7 | createContext, 8 | useCallback, 9 | useContext, 10 | useEffect, 11 | useState, 12 | } from "react"; 13 | import { motion, AnimatePresence } from "framer-motion"; 14 | import { IoMdClose } from "react-icons/io"; 15 | import { createPortal } from "react-dom"; 16 | 17 | import { useOutsideClick } from "@/hooks/useOutsideClick"; 18 | import { useIsClient } from "@/hooks/useIsClient"; 19 | import { useKeyPress } from "@/hooks/useKeyPress"; 20 | import { fadeIn, slideIn } from "@/utils/motion"; 21 | 22 | interface ModalProps { 23 | children: ReactNode; 24 | } 25 | 26 | interface TriggerProps { 27 | name: string; 28 | children: ReactElement; 29 | } 30 | 31 | interface WindowProps extends TriggerProps { } 32 | 33 | interface WindowHeaderProps { 34 | title: string; 35 | } 36 | 37 | const ModalContext = createContext({ 38 | open: (val: string) => { }, 39 | close: () => { }, 40 | openName: "", 41 | }); 42 | 43 | const Modal: FC & { 44 | Trigger: typeof Trigger; 45 | Window: typeof Window; 46 | WindowHeader: typeof WindowHeader; 47 | } = ({ children }) => { 48 | const [openName, setOpenName] = useState(""); 49 | 50 | const close = useCallback(() => { 51 | setOpenName(""); 52 | }, []); 53 | 54 | const open = setOpenName; 55 | return ( 56 | 63 | {children} 64 | 65 | ); 66 | }; 67 | 68 | const Trigger: FC = ({ children, name }) => { 69 | const { open } = useContext(ModalContext); 70 | const onClick = (e: MouseEvent | TouchEvent) => { 71 | open(name); 72 | }; 73 | return cloneElement(children, { onClick }); 74 | }; 75 | 76 | const Window: FC = ({ children, name }) => { 77 | const { openName, close } = useContext(ModalContext); 78 | const isWindowOpen = openName === name; 79 | const { ref } = useOutsideClick({ 80 | action: close, 81 | enable: isWindowOpen, 82 | }); 83 | 84 | useKeyPress({ 85 | key: "Escape", 86 | action: close, 87 | enable: isWindowOpen 88 | }) 89 | 90 | const isClient = useIsClient(); 91 | 92 | 93 | useEffect(() => { 94 | if (!isClient) return; 95 | const body = document.body; 96 | const rootNode = document.documentElement; 97 | if (isWindowOpen) { 98 | const scrollTop = rootNode.scrollTop; 99 | body.style.top = `-${scrollTop}px`; 100 | body.classList.add("no-scroll"); 101 | } else { 102 | const top = parseFloat(body.style.top) * -1; 103 | body.classList.remove("no-scroll"); 104 | if (top) { 105 | rootNode.scrollTop = top; 106 | body.style.top = ""; 107 | } 108 | } 109 | }, [isClient, isWindowOpen]); 110 | 111 | if (!isClient) return null; 112 | 113 | return createPortal( 114 | 115 | {isWindowOpen ? ( 116 | 123 |
    124 | 132 | {cloneElement(children, { onCloseModal: close })} 133 | 134 |
    135 |
    136 | ) : null} 137 |
    , 138 | document.body 139 | ); 140 | }; 141 | 142 | const WindowHeader: FC = ({ title }) => { 143 | const { close } = useContext(ModalContext); 144 | return ( 145 |
    146 | 153 |

    {title}

    154 |
    155 | ); 156 | }; 157 | 158 | Modal.Trigger = Trigger; 159 | Modal.Window = Window; 160 | Modal.WindowHeader = WindowHeader; 161 | 162 | export default Modal; 163 | -------------------------------------------------------------------------------- /services/reservation.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { revalidatePath } from "next/cache"; 3 | import { Listing, Reservation } from "@prisma/client"; 4 | 5 | import { db } from "@/lib/db"; 6 | import { LISTINGS_BATCH } from "@/utils/constants"; 7 | import { getCurrentUser } from "./user"; 8 | import { stripe } from "@/lib/stripe"; 9 | 10 | export const getReservations = async (args: Record) => { 11 | try { 12 | const { listingId, userId, authorId, cursor } = args; 13 | 14 | const where: any = {}; 15 | 16 | if (userId) { 17 | where.userId = userId; 18 | } 19 | 20 | if (listingId) { 21 | where.listingId = listingId; 22 | } 23 | 24 | if (authorId) { 25 | where.listing = { userId: authorId }; 26 | } 27 | 28 | const filterQuery: any = { 29 | where, 30 | take: LISTINGS_BATCH, 31 | include: { 32 | listing: true, 33 | }, 34 | orderBy: { createdAt: "desc" }, 35 | }; 36 | 37 | if (cursor) { 38 | filterQuery.cursor = { id: cursor }; 39 | filterQuery.skip = 1; 40 | } 41 | 42 | const reservations = (await db.reservation.findMany({ 43 | ...filterQuery, 44 | })) as (Reservation & { listing: Listing })[]; 45 | 46 | const nextCursor = 47 | reservations.length === LISTINGS_BATCH 48 | ? reservations[LISTINGS_BATCH - 1].id 49 | : null; 50 | 51 | const listings = reservations.map((reservation) => { 52 | const { id, startDate, endDate, totalPrice, listing } = reservation; 53 | 54 | return { 55 | ...listing, 56 | reservation: { id, startDate, endDate, totalPrice }, 57 | }; 58 | }); 59 | 60 | return { 61 | listings, 62 | nextCursor, 63 | }; 64 | } catch (error: any) { 65 | console.log(error?.message); 66 | return { 67 | listings: [], 68 | nextCursor: null, 69 | }; 70 | } 71 | }; 72 | 73 | export const createReservation = async ({ 74 | listingId, 75 | startDate, 76 | endDate, 77 | totalPrice, 78 | userId 79 | }: { 80 | listingId: string; 81 | startDate: Date | undefined; 82 | endDate: Date | undefined; 83 | totalPrice: number; 84 | userId: string 85 | }) => { 86 | try { 87 | if (!listingId || !startDate || !endDate || !totalPrice) 88 | throw new Error("Invalid data"); 89 | 90 | await db.listing.update({ 91 | where: { 92 | id: listingId, 93 | }, 94 | data: { 95 | reservations: { 96 | create: { 97 | userId, 98 | startDate, 99 | endDate, 100 | totalPrice, 101 | }, 102 | }, 103 | }, 104 | }); 105 | 106 | revalidatePath(`/listings/${listingId}`); 107 | } catch (error: any) { 108 | throw new Error(error?.message); 109 | } 110 | }; 111 | 112 | export const deleteReservation = async (reservationId: string) => { 113 | try { 114 | const currentUser = await getCurrentUser(); 115 | 116 | if (!currentUser) { 117 | throw new Error("Unauthorized"); 118 | } 119 | 120 | if (!reservationId || typeof reservationId !== "string") { 121 | throw new Error("Invalid ID"); 122 | } 123 | 124 | 125 | const reservation = await db.reservation.findUnique({ 126 | where: { 127 | id: reservationId, 128 | } 129 | }); 130 | 131 | if (!reservation) { 132 | throw new Error("Reservation not found!"); 133 | } 134 | 135 | await db.reservation.deleteMany({ 136 | where: { 137 | id: reservationId, 138 | OR: [ 139 | { userId: currentUser.id }, 140 | { listing: { userId: currentUser.id } }, 141 | ], 142 | }, 143 | }); 144 | 145 | revalidatePath("/reservations"); 146 | revalidatePath(`/listings/${reservation.listingId}`); 147 | revalidatePath("/trips"); 148 | 149 | return reservation; 150 | } catch (error: any) { 151 | throw new Error(error.message) 152 | } 153 | }; 154 | 155 | 156 | export const createPaymentSession = async ({ 157 | listingId, 158 | startDate, 159 | endDate, 160 | totalPrice, 161 | }: { 162 | listingId: string; 163 | startDate: Date | undefined; 164 | endDate: Date | undefined; 165 | totalPrice: number; 166 | }) => { 167 | if (!listingId || !startDate || !endDate || !totalPrice) 168 | throw new Error("Invalid data"); 169 | 170 | const listing = await db.listing.findUnique({ 171 | where: {id: listingId} 172 | }) 173 | 174 | if(!listing) throw new Error("Listing not found!"); 175 | 176 | const user = await getCurrentUser(); 177 | 178 | if (!user) { 179 | throw new Error("Please log in to reserve!"); 180 | } 181 | 182 | const product = await stripe.products.create({ 183 | name: "Listing", 184 | images: [listing.imageSrc], 185 | default_price_data: { 186 | currency: "USD", 187 | unit_amount: totalPrice * 100 188 | } 189 | }) 190 | 191 | const stripeSession = await stripe.checkout.sessions.create({ 192 | success_url: `${process.env.NEXT_PUBLIC_SERVER_URL}/trips`, 193 | cancel_url: `${process.env.NEXT_PUBLIC_SERVER_URL}/listings/${listing.id}`, 194 | payment_method_types: ['card'], 195 | mode: 'payment', 196 | shipping_address_collection: { 197 | allowed_countries: ["DE", "US", "NP", "CH", "BH", "AU"], 198 | }, 199 | metadata: { 200 | listingId, 201 | startDate: String(startDate), 202 | endDate: String(endDate), 203 | totalPrice, 204 | userId: user.id 205 | }, 206 | line_items: [{ price: product.default_price as string, quantity: 1 }], 207 | }); 208 | 209 | return {url: stripeSession.url} 210 | } -------------------------------------------------------------------------------- /components/modals/AuthModal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useTransition, useState, useEffect } from "react"; 3 | import { AiFillGithub } from "react-icons/ai"; 4 | import { FcGoogle } from "react-icons/fc"; 5 | import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; 6 | import { signIn } from "next-auth/react"; 7 | import toast from "react-hot-toast"; 8 | import { useRouter } from "next/navigation"; 9 | 10 | import Heading from "../Heading"; 11 | import Input from "../inputs/Input"; 12 | import Button from "../Button"; 13 | import Modal from "./Modal"; 14 | import SpinnerMini from "../Loader"; 15 | import { registerUser } from "@/services/auth"; 16 | 17 | const AuthModal = ({ 18 | name, 19 | onCloseModal, 20 | }: { 21 | name?: string; 22 | onCloseModal?: () => void; 23 | }) => { 24 | const [isLoading, startTransition] = useTransition(); 25 | const [title, setTitle] = useState(name || ""); 26 | const { 27 | register, 28 | handleSubmit, 29 | watch, 30 | formState: { errors }, 31 | reset, 32 | setError, 33 | setFocus, 34 | } = useForm({ 35 | defaultValues: { 36 | email: "", 37 | password: "", 38 | name: "", 39 | }, 40 | }); 41 | const router = useRouter(); 42 | const isLoginModal = title === "Login"; 43 | 44 | useEffect(() => { 45 | const timer = setTimeout(() => { 46 | if (isLoginModal) { 47 | setFocus("email"); 48 | } else { 49 | setFocus("name"); 50 | } 51 | }, 300); 52 | 53 | return () => clearTimeout(timer); 54 | }, [isLoginModal, setFocus]); 55 | 56 | const onToggle = () => { 57 | const newTitle = isLoginModal ? "Sign up" : "Login"; 58 | setTitle(newTitle); 59 | reset(); 60 | }; 61 | 62 | const onSubmit: SubmitHandler = (data) => { 63 | const { email, password, name } = data; 64 | 65 | startTransition(async () => { 66 | try { 67 | if (isLoginModal) { 68 | const callback = await signIn("credentials", { 69 | email, 70 | password, 71 | redirect: false, 72 | }); 73 | 74 | if (callback?.error) { 75 | throw new Error(callback.error); 76 | } 77 | if (callback?.ok) { 78 | toast.success("You've successfully logged in."); 79 | onCloseModal?.(); 80 | router.refresh(); 81 | } 82 | } else { 83 | await registerUser({ email, password, name }); 84 | setTitle("Login"); 85 | toast.success("You've successfully registered."); 86 | reset(); 87 | } 88 | } catch (error: any) { 89 | toast.error(error.message); 90 | if (isLoginModal) { 91 | reset(); 92 | setError("email", {}); 93 | setError("password", {}); 94 | setTimeout(() => { 95 | setFocus("email"); 96 | }, 100) 97 | } 98 | } 99 | }); 100 | }; 101 | 102 | return ( 103 |
    104 | 105 | 106 |
    110 | 118 | 119 | {!isLoginModal && ( 120 | 129 | )} 130 | 131 | 140 | 141 | 151 | 152 | 158 | 159 |
    160 |
    161 | 169 | 177 |
    185 |
    186 | 187 | {!isLoginModal 188 | ? "Already have an account?" 189 | : "First time using Airbnb?"} 190 | 191 | 204 |
    205 |
    206 |
    207 |
    208 | ); 209 | }; 210 | 211 | export default AuthModal; 212 | -------------------------------------------------------------------------------- /components/modals/SearchModal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useMemo, useState } from "react"; 3 | import dynamic from "next/dynamic"; 4 | import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; 5 | import { useRouter, useSearchParams } from "next/navigation"; 6 | import queryString from "query-string"; 7 | import { formatISO } from "date-fns"; 8 | 9 | import Modal from "./Modal"; 10 | import Button from "../Button"; 11 | import Heading from "../Heading"; 12 | import Counter from "../inputs/Counter"; 13 | import CountrySelect from "../inputs/CountrySelect"; 14 | 15 | const Calendar = dynamic(() => import("@/components/Calender"), { ssr: false }); 16 | 17 | const steps = { 18 | "0": "location", 19 | "1": "dateRange", 20 | "2": "guestCount", 21 | }; 22 | 23 | enum STEPS { 24 | LOCATION = 0, 25 | DATE = 1, 26 | INFO = 2, 27 | } 28 | 29 | const SearchModal = ({ onCloseModal }: { onCloseModal?: () => void }) => { 30 | const [step, setStep] = useState(STEPS.LOCATION); 31 | const router = useRouter(); 32 | const searchParams = useSearchParams(); 33 | 34 | const { handleSubmit, setValue, watch, getValues } = useForm({ 35 | defaultValues: { 36 | location: null, 37 | guestCount: 1, 38 | bathroomCount: 1, 39 | roomCount: 1, 40 | dateRange: { 41 | startDate: new Date(), 42 | endDate: new Date(), 43 | key: "selection", 44 | }, 45 | }, 46 | }); 47 | 48 | const location = watch("location"); 49 | const dateRange = watch("dateRange"); 50 | const country = location?.label; 51 | 52 | const Map = useMemo( 53 | () => 54 | dynamic(() => import("../Map"), { 55 | ssr: false, 56 | }), 57 | // eslint-disable-next-line react-hooks/exhaustive-deps 58 | [country] 59 | ); 60 | 61 | const setCustomValue = (id: string, value: any) => { 62 | setValue(id, value, { 63 | shouldDirty: true, 64 | shouldTouch: true, 65 | shouldValidate: true, 66 | }); 67 | }; 68 | 69 | const onBack = () => { 70 | setStep((value) => value - 1); 71 | }; 72 | 73 | const onNext = () => { 74 | setStep((value) => value + 1); 75 | }; 76 | 77 | const onSubmit: SubmitHandler = (data) => { 78 | if (step !== STEPS.INFO) return onNext(); 79 | const { guestCount, roomCount, bathroomCount, dateRange } = data; 80 | 81 | let currentQuery = {}; 82 | 83 | if (searchParams) { 84 | currentQuery = queryString.parse(searchParams.toString()); 85 | } 86 | 87 | const updatedQuery: any = { 88 | ...currentQuery, 89 | country: location?.label, 90 | guestCount, 91 | roomCount, 92 | bathroomCount, 93 | }; 94 | 95 | if (dateRange.startDate) { 96 | updatedQuery.startDate = formatISO(dateRange.startDate); 97 | } 98 | 99 | if (dateRange.endDate) { 100 | updatedQuery.endDate = formatISO(dateRange.endDate); 101 | } 102 | 103 | const url = queryString.stringifyUrl( 104 | { 105 | url: "/", 106 | query: updatedQuery, 107 | }, 108 | { skipNull: true } 109 | ); 110 | onCloseModal?.(); 111 | router.push(url); 112 | }; 113 | 114 | const body = () => { 115 | switch (step) { 116 | case STEPS.DATE: 117 | return ( 118 |
    119 | 123 |
    124 | 125 |
    126 |
    127 | ); 128 | 129 | case STEPS.INFO: 130 | return ( 131 |
    132 | 136 | 143 |
    144 | 151 |
    152 | 159 |
    160 | ); 161 | 162 | default: 163 | return ( 164 |
    165 | 169 | 170 |
    171 | 172 |
    173 |
    174 | ); 175 | } 176 | }; 177 | 178 | const isFieldFilled = !!getValues(steps[step]); 179 | 180 | return ( 181 |
    182 | 183 |
    187 |
    {body()}
    188 |
    189 |
    190 | {step !== STEPS.LOCATION ? ( 191 | 199 | ) : null} 200 | 207 |
    208 |
    209 |
    210 |
    211 | ); 212 | }; 213 | 214 | export default SearchModal; 215 | -------------------------------------------------------------------------------- /components/modals/RentModal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useMemo, useState, useTransition } from "react"; 3 | import dynamic from "next/dynamic"; 4 | import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; 5 | import toast from "react-hot-toast"; 6 | import { useQueryClient } from "@tanstack/react-query"; 7 | import { useRouter } from "next/navigation"; 8 | import { BiDollar } from "react-icons/bi"; 9 | 10 | import Modal from "./Modal"; 11 | import Button from "../Button"; 12 | import SpinnerMini from "../Loader"; 13 | import Heading from "../Heading"; 14 | import Counter from "../inputs/Counter"; 15 | import Input from "../inputs/Input"; 16 | import CategoryButton from "../inputs/CategoryButton"; 17 | import CountrySelect from "../inputs/CountrySelect"; 18 | import ImageUpload from "../ImageUpload"; 19 | 20 | import { categories } from "@/utils/constants"; 21 | import { createListing } from "@/services/listing"; 22 | 23 | const steps = { 24 | "0": "category", 25 | "1": "location", 26 | "2": "guestCount", 27 | "3": "image", 28 | "4": "title", 29 | "5": "price", 30 | }; 31 | 32 | enum STEPS { 33 | CATEGORY = 0, 34 | LOCATION = 1, 35 | INFO = 2, 36 | IMAGES = 3, 37 | DESCRIPTION = 4, 38 | PRICE = 5, 39 | } 40 | 41 | const RentModal = ({ onCloseModal }: { onCloseModal?: () => void }) => { 42 | const [step, setStep] = useState(STEPS.CATEGORY); 43 | const [isLoading, startTransition] = useTransition(); 44 | const queryClient = useQueryClient(); 45 | const router = useRouter(); 46 | const { 47 | register, 48 | handleSubmit, 49 | setValue, 50 | watch, 51 | formState: { errors }, 52 | reset, 53 | getValues, 54 | } = useForm({ 55 | defaultValues: { 56 | category: "Beach", 57 | location: null, 58 | guestCount: 1, 59 | bathroomCount: 1, 60 | roomCount: 1, 61 | image: "", 62 | price: "", 63 | title: "", 64 | description: "", 65 | }, 66 | }); 67 | 68 | const location = watch("location"); 69 | const country = location?.label; 70 | 71 | const Map = useMemo( 72 | () => 73 | dynamic(() => import("../Map"), { 74 | ssr: false, 75 | }), 76 | // eslint-disable-next-line react-hooks/exhaustive-deps 77 | [country] 78 | ); 79 | 80 | const setCustomValue = (id: string, value: any) => { 81 | setValue(id, value, { 82 | shouldDirty: true, 83 | shouldTouch: true, 84 | shouldValidate: true, 85 | }); 86 | }; 87 | 88 | const onBack = () => { 89 | setStep((value) => value - 1); 90 | }; 91 | 92 | const onNext = () => { 93 | setStep((value) => value + 1); 94 | }; 95 | 96 | const onSubmit: SubmitHandler = (data) => { 97 | if (step !== STEPS.PRICE) return onNext(); 98 | 99 | startTransition(async () => { 100 | try { 101 | const newListing = await createListing(data); 102 | toast.success(`${data.title} added successfully!`); 103 | queryClient.invalidateQueries({ 104 | queryKey: ["listings"], 105 | }); 106 | reset(); 107 | setStep(STEPS.CATEGORY); 108 | onCloseModal?.(); 109 | router.refresh(); 110 | router.push(`/listings/${newListing.id}`); 111 | } catch (error: any) { 112 | toast.error("Failed to create listing!"); 113 | console.log(error?.message) 114 | } 115 | }); 116 | }; 117 | 118 | const body = () => { 119 | switch (step) { 120 | case STEPS.LOCATION: 121 | return ( 122 |
    123 | 127 | 128 |
    129 | 130 |
    131 |
    132 | ); 133 | 134 | case STEPS.INFO: 135 | return ( 136 |
    137 | 141 | 148 |
    149 | 156 |
    157 | 164 |
    165 | ); 166 | 167 | case STEPS.IMAGES: 168 | return ( 169 |
    170 | 174 | 178 |
    179 | ); 180 | 181 | case STEPS.DESCRIPTION: 182 | return ( 183 |
    184 | 188 | 198 |
    199 | 208 |
    209 | ); 210 | 211 | case STEPS.PRICE: 212 | return ( 213 |
    214 | 218 | 231 |
    232 | ); 233 | 234 | default: 235 | return ( 236 |
    237 | 241 |
    242 | {categories.map((item) => ( 243 | 250 | ))} 251 |
    252 |
    253 | ); 254 | } 255 | }; 256 | 257 | const isFieldFilled = !!getValues(steps[step]); 258 | 259 | return ( 260 |
    261 | 262 |
    266 |
    {body()}
    267 |
    268 |
    269 | {step !== STEPS.CATEGORY ? ( 270 | 278 | ) : null} 279 | 292 |
    293 |
    294 |
    295 |
    296 | ); 297 | }; 298 | 299 | export default RentModal; 300 | -------------------------------------------------------------------------------- /data/countries.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "value": "BF", 4 | "label": "Burkina Faso", 5 | "flag": "🇧🇫", 6 | "region": "Africa", 7 | "latlng": [13, -2] 8 | }, 9 | { 10 | "value": "AF", 11 | "label": "Afghanistan", 12 | "flag": "🇦🇫", 13 | "region": "Asia", 14 | "latlng": [33, 65] 15 | }, 16 | { 17 | "value": "AI", 18 | "label": "Anguilla", 19 | "flag": "🇦🇮", 20 | "region": "Americas", 21 | "latlng": [18, -63] 22 | }, 23 | { 24 | "value": "AQ", 25 | "label": "Antarctica", 26 | "flag": "🇦🇶", 27 | "region": "Antarctic", 28 | "latlng": [-90, 0] 29 | }, 30 | { 31 | "value": "AR", 32 | "label": "Argentina", 33 | "flag": "🇦🇷", 34 | "region": "Americas", 35 | "latlng": [-34, -64] 36 | }, 37 | { 38 | "value": "AM", 39 | "label": "Armenia", 40 | "flag": "🇦🇲", 41 | "region": "Asia", 42 | "latlng": [40, 45] 43 | }, 44 | { 45 | "value": "FJ", 46 | "label": "Fiji", 47 | "flag": "🇫🇯", 48 | "region": "Oceania", 49 | "latlng": [-18, 175] 50 | }, 51 | { 52 | "value": "GA", 53 | "label": "Gabon", 54 | "flag": "🇬🇦", 55 | "region": "Africa", 56 | "latlng": [-1, 11] 57 | }, 58 | { 59 | "value": "GB", 60 | "label": "United Kingdom", 61 | "flag": "🇬🇧", 62 | "region": "Europe", 63 | "latlng": [54, -2] 64 | }, 65 | { 66 | "value": "IL", 67 | "label": "Israel", 68 | "flag": "🇮🇱", 69 | "region": "Asia", 70 | "latlng": [31, 35] 71 | }, 72 | { 73 | "value": "NC", 74 | "label": "New Caledonia", 75 | "flag": "🇳🇨", 76 | "region": "Oceania", 77 | "latlng": [-21, 165] 78 | }, 79 | { 80 | "value": "AL", 81 | "label": "Albania", 82 | "flag": "🇦🇱", 83 | "region": "Europe", 84 | "latlng": [41, 20] 85 | }, 86 | { 87 | "value": "PH", 88 | "label": "Philippines", 89 | "flag": "🇵🇭", 90 | "region": "Asia", 91 | "latlng": [13, 122] 92 | }, 93 | { 94 | "value": "SL", 95 | "label": "Sierra Leone", 96 | "flag": "🇸🇱", 97 | "region": "Africa", 98 | "latlng": [8, -11] 99 | }, 100 | { 101 | "value": "AG", 102 | "label": "Antigua and Barbuda", 103 | "flag": "🇦🇬", 104 | "region": "Americas", 105 | "latlng": [17, -61] 106 | }, 107 | { 108 | "value": "TK", 109 | "label": "Tokelau", 110 | "flag": "🇹🇰", 111 | "region": "Oceania", 112 | "latlng": [-9, -172] 113 | }, 114 | { 115 | "value": "ZW", 116 | "label": "Zimbabwe", 117 | "flag": "🇿🇼", 118 | "region": "Africa", 119 | "latlng": [-20, 30] 120 | }, 121 | { 122 | "value": "BM", 123 | "label": "Bermuda", 124 | "flag": "🇧🇲", 125 | "region": "Americas", 126 | "latlng": [32, -64] 127 | }, 128 | { 129 | "value": "CA", 130 | "label": "Canada", 131 | "flag": "🇨🇦", 132 | "region": "Americas", 133 | "latlng": [60, -95] 134 | }, 135 | { 136 | "value": "GL", 137 | "label": "Greenland", 138 | "flag": "🇬🇱", 139 | "region": "Americas", 140 | "latlng": [72, -40] 141 | }, 142 | { 143 | "value": "XK", 144 | "label": "Kosovo", 145 | "flag": "🇽🇰", 146 | "region": "Europe", 147 | "latlng": [42, 21] 148 | }, 149 | { 150 | "value": "MF", 151 | "label": "Saint Martin", 152 | "flag": "🇲🇫", 153 | "region": "Americas", 154 | "latlng": [18, -63] 155 | }, 156 | { 157 | "value": "MS", 158 | "label": "Montserrat", 159 | "flag": "🇲🇸", 160 | "region": "Americas", 161 | "latlng": [16, -62] 162 | }, 163 | { 164 | "value": "NA", 165 | "label": "Namibia", 166 | "flag": "🇳🇦", 167 | "region": "Africa", 168 | "latlng": [-22, 17] 169 | }, 170 | { 171 | "value": "SJ", 172 | "label": "Svalbard and Jan Mayen", 173 | "flag": "🇸🇯", 174 | "region": "Europe", 175 | "latlng": [78, 20] 176 | }, 177 | { 178 | "value": "SV", 179 | "label": "El Salvador", 180 | "flag": "🇸🇻", 181 | "region": "Americas", 182 | "latlng": [13, -88] 183 | }, 184 | { 185 | "value": "SI", 186 | "label": "Slovenia", 187 | "flag": "🇸🇮", 188 | "region": "Europe", 189 | "latlng": [46, 14] 190 | }, 191 | { 192 | "value": "SZ", 193 | "label": "Eswatini", 194 | "flag": "🇸🇿", 195 | "region": "Africa", 196 | "latlng": [-26, 31] 197 | }, 198 | { 199 | "value": "UG", 200 | "label": "Uganda", 201 | "flag": "🇺🇬", 202 | "region": "Africa", 203 | "latlng": [1, 32] 204 | }, 205 | { 206 | "value": "UM", 207 | "label": "United States Minor Outlying Islands", 208 | "flag": "🇺🇲", 209 | "region": "Americas", 210 | "latlng": [19, 166] 211 | }, 212 | { 213 | "value": "AT", 214 | "label": "Austria", 215 | "flag": "🇦🇹", 216 | "region": "Europe", 217 | "latlng": [47, 13] 218 | }, 219 | { 220 | "value": "BJ", 221 | "label": "Benin", 222 | "flag": "🇧🇯", 223 | "region": "Africa", 224 | "latlng": [9, 2] 225 | }, 226 | { 227 | "value": "SH", 228 | "label": "Saint Helena, Ascension and Tristan da Cunha", 229 | "flag": "🇸🇭", 230 | "region": "Africa", 231 | "latlng": [-15, -5] 232 | }, 233 | { 234 | "value": "BN", 235 | "label": "Brunei", 236 | "flag": "🇧🇳", 237 | "region": "Asia", 238 | "latlng": [4, 114] 239 | }, 240 | { 241 | "value": "BT", 242 | "label": "Bhutan", 243 | "flag": "🇧🇹", 244 | "region": "Asia", 245 | "latlng": [27, 90] 246 | }, 247 | { 248 | "value": "BV", 249 | "label": "Bouvet Island", 250 | "flag": "🇧🇻", 251 | "region": "Antarctic", 252 | "latlng": [-54, 3] 253 | }, 254 | { 255 | "value": "CV", 256 | "label": "Cape Verde", 257 | "flag": "🇨🇻", 258 | "region": "Africa", 259 | "latlng": [16, -24] 260 | }, 261 | { 262 | "value": "CW", 263 | "label": "Curaçao", 264 | "flag": "🇨🇼", 265 | "region": "Americas", 266 | "latlng": [12, -68] 267 | }, 268 | { 269 | "value": "GP", 270 | "label": "Guadeloupe", 271 | "flag": "🇬🇵", 272 | "region": "Americas", 273 | "latlng": [16, -61] 274 | }, 275 | { 276 | "value": "ID", 277 | "label": "Indonesia", 278 | "flag": "🇮🇩", 279 | "region": "Asia", 280 | "latlng": [-5, 120] 281 | }, 282 | { 283 | "value": "LB", 284 | "label": "Lebanon", 285 | "flag": "🇱🇧", 286 | "region": "Asia", 287 | "latlng": [33, 35] 288 | }, 289 | { 290 | "value": "MA", 291 | "label": "Morocco", 292 | "flag": "🇲🇦", 293 | "region": "Africa", 294 | "latlng": [32, -5] 295 | }, 296 | { 297 | "value": "YT", 298 | "label": "Mayotte", 299 | "flag": "🇾🇹", 300 | "region": "Africa", 301 | "latlng": [-12, 45] 302 | }, 303 | { 304 | "value": "RW", 305 | "label": "Rwanda", 306 | "flag": "🇷🇼", 307 | "region": "Africa", 308 | "latlng": [-2, 30] 309 | }, 310 | { 311 | "value": "SX", 312 | "label": "Sint Maarten", 313 | "flag": "🇸🇽", 314 | "region": "Americas", 315 | "latlng": [18, -63] 316 | }, 317 | { 318 | "value": "BE", 319 | "label": "Belgium", 320 | "flag": "🇧🇪", 321 | "region": "Europe", 322 | "latlng": [50, 4] 323 | }, 324 | { 325 | "value": "BB", 326 | "label": "Barbados", 327 | "flag": "🇧🇧", 328 | "region": "Americas", 329 | "latlng": [13, -59] 330 | }, 331 | { 332 | "value": "CF", 333 | "label": "Central African Republic", 334 | "flag": "🇨🇫", 335 | "region": "Africa", 336 | "latlng": [7, 21] 337 | }, 338 | { 339 | "value": "CY", 340 | "label": "Cyprus", 341 | "flag": "🇨🇾", 342 | "region": "Europe", 343 | "latlng": [35, 33] 344 | }, 345 | { 346 | "value": "ER", 347 | "label": "Eritrea", 348 | "flag": "🇪🇷", 349 | "region": "Africa", 350 | "latlng": [15, 39] 351 | }, 352 | { 353 | "value": "GH", 354 | "label": "Ghana", 355 | "flag": "🇬🇭", 356 | "region": "Africa", 357 | "latlng": [8, -2] 358 | }, 359 | { 360 | "value": "GR", 361 | "label": "Greece", 362 | "flag": "🇬🇷", 363 | "region": "Europe", 364 | "latlng": [39, 22] 365 | }, 366 | { 367 | "value": "HN", 368 | "label": "Honduras", 369 | "flag": "🇭🇳", 370 | "region": "Americas", 371 | "latlng": [15, -86] 372 | }, 373 | { 374 | "value": "IR", 375 | "label": "Iran", 376 | "flag": "🇮🇷", 377 | "region": "Asia", 378 | "latlng": [32, 53] 379 | }, 380 | { 381 | "value": "IQ", 382 | "label": "Iraq", 383 | "flag": "🇮🇶", 384 | "region": "Asia", 385 | "latlng": [33, 44] 386 | }, 387 | { 388 | "value": "KE", 389 | "label": "Kenya", 390 | "flag": "🇰🇪", 391 | "region": "Africa", 392 | "latlng": [1, 38] 393 | }, 394 | { 395 | "value": "KN", 396 | "label": "Saint Kitts and Nevis", 397 | "flag": "🇰🇳", 398 | "region": "Americas", 399 | "latlng": [17, -62] 400 | }, 401 | { 402 | "value": "KW", 403 | "label": "Kuwait", 404 | "flag": "🇰🇼", 405 | "region": "Asia", 406 | "latlng": [29, 45] 407 | }, 408 | { 409 | "value": "LA", 410 | "label": "Laos", 411 | "flag": "🇱🇦", 412 | "region": "Asia", 413 | "latlng": [18, 105] 414 | }, 415 | { 416 | "value": "MP", 417 | "label": "Northern Mariana Islands", 418 | "flag": "🇲🇵", 419 | "region": "Oceania", 420 | "latlng": [15, 145] 421 | }, 422 | { 423 | "value": "MQ", 424 | "label": "Martinique", 425 | "flag": "🇲🇶", 426 | "region": "Americas", 427 | "latlng": [14, -61] 428 | }, 429 | { 430 | "value": "PL", 431 | "label": "Poland", 432 | "flag": "🇵🇱", 433 | "region": "Europe", 434 | "latlng": [52, 20] 435 | }, 436 | { 437 | "value": "PY", 438 | "label": "Paraguay", 439 | "flag": "🇵🇾", 440 | "region": "Americas", 441 | "latlng": [-23, -58] 442 | }, 443 | { 444 | "value": "SS", 445 | "label": "South Sudan", 446 | "flag": "🇸🇸", 447 | "region": "Africa", 448 | "latlng": [7, 30] 449 | }, 450 | { 451 | "value": "VC", 452 | "label": "Saint Vincent and the Grenadines", 453 | "flag": "🇻🇨", 454 | "region": "Americas", 455 | "latlng": [13, -61] 456 | }, 457 | { 458 | "value": "BZ", 459 | "label": "Belize", 460 | "flag": "🇧🇿", 461 | "region": "Americas", 462 | "latlng": [17, -88] 463 | }, 464 | { 465 | "value": "AD", 466 | "label": "Andorra", 467 | "flag": "🇦🇩", 468 | "region": "Europe", 469 | "latlng": [42, 1] 470 | }, 471 | { 472 | "value": "CO", 473 | "label": "Colombia", 474 | "flag": "🇨🇴", 475 | "region": "Americas", 476 | "latlng": [4, -72] 477 | }, 478 | { 479 | "value": "DM", 480 | "label": "Dominica", 481 | "flag": "🇩🇲", 482 | "region": "Americas", 483 | "latlng": [15, -61] 484 | }, 485 | { 486 | "value": "EC", 487 | "label": "Ecuador", 488 | "flag": "🇪🇨", 489 | "region": "Americas", 490 | "latlng": [-2, -77] 491 | }, 492 | { 493 | "value": "FM", 494 | "label": "Micronesia", 495 | "flag": "🇫🇲", 496 | "region": "Oceania", 497 | "latlng": [6, 158] 498 | }, 499 | { 500 | "value": "GW", 501 | "label": "Guinea-Bissau", 502 | "flag": "🇬🇼", 503 | "region": "Africa", 504 | "latlng": [12, -15] 505 | }, 506 | { 507 | "value": "MG", 508 | "label": "Madagascar", 509 | "flag": "🇲🇬", 510 | "region": "Africa", 511 | "latlng": [-20, 47] 512 | }, 513 | { 514 | "value": "MY", 515 | "label": "Malaysia", 516 | "flag": "🇲🇾", 517 | "region": "Asia", 518 | "latlng": [2, 112] 519 | }, 520 | { 521 | "value": "OM", 522 | "label": "Oman", 523 | "flag": "🇴🇲", 524 | "region": "Asia", 525 | "latlng": [21, 57] 526 | }, 527 | { 528 | "value": "PK", 529 | "label": "Pakistan", 530 | "flag": "🇵🇰", 531 | "region": "Asia", 532 | "latlng": [30, 70] 533 | }, 534 | { 535 | "value": "PA", 536 | "label": "Panama", 537 | "flag": "🇵🇦", 538 | "region": "Americas", 539 | "latlng": [9, -80] 540 | }, 541 | { 542 | "value": "PF", 543 | "label": "French Polynesia", 544 | "flag": "🇵🇫", 545 | "region": "Oceania", 546 | "latlng": [-15, -140] 547 | }, 548 | { 549 | "value": "TG", 550 | "label": "Togo", 551 | "flag": "🇹🇬", 552 | "region": "Africa", 553 | "latlng": [8, 1] 554 | }, 555 | { 556 | "value": "TO", 557 | "label": "Tonga", 558 | "flag": "🇹🇴", 559 | "region": "Oceania", 560 | "latlng": [-20, -175] 561 | }, 562 | { 563 | "value": "ZM", 564 | "label": "Zambia", 565 | "flag": "🇿🇲", 566 | "region": "Africa", 567 | "latlng": [-15, 30] 568 | }, 569 | { 570 | "value": "CL", 571 | "label": "Chile", 572 | "flag": "🇨🇱", 573 | "region": "Americas", 574 | "latlng": [-30, -71] 575 | }, 576 | { 577 | "value": "CM", 578 | "label": "Cameroon", 579 | "flag": "🇨🇲", 580 | "region": "Africa", 581 | "latlng": [6, 12] 582 | }, 583 | { 584 | "value": "EE", 585 | "label": "Estonia", 586 | "flag": "🇪🇪", 587 | "region": "Europe", 588 | "latlng": [59, 26] 589 | }, 590 | { 591 | "value": "FO", 592 | "label": "Faroe Islands", 593 | "flag": "🇫🇴", 594 | "region": "Europe", 595 | "latlng": [62, -7] 596 | }, 597 | { 598 | "value": "GI", 599 | "label": "Gibraltar", 600 | "flag": "🇬🇮", 601 | "region": "Europe", 602 | "latlng": [36, -5] 603 | }, 604 | { 605 | "value": "HT", 606 | "label": "Haiti", 607 | "flag": "🇭🇹", 608 | "region": "Americas", 609 | "latlng": [19, -72] 610 | }, 611 | { 612 | "value": "JP", 613 | "label": "Japan", 614 | "flag": "🇯🇵", 615 | "region": "Asia", 616 | "latlng": [36, 138] 617 | }, 618 | { 619 | "value": "LK", 620 | "label": "Sri Lanka", 621 | "flag": "🇱🇰", 622 | "region": "Asia", 623 | "latlng": [7, 81] 624 | }, 625 | { 626 | "value": "SO", 627 | "label": "Somalia", 628 | "flag": "🇸🇴", 629 | "region": "Africa", 630 | "latlng": [10, 49] 631 | }, 632 | { 633 | "value": "TR", 634 | "label": "Turkey", 635 | "flag": "🇹🇷", 636 | "region": "Asia", 637 | "latlng": [39, 35] 638 | }, 639 | { 640 | "value": "VA", 641 | "label": "Vatican City", 642 | "flag": "🇻🇦", 643 | "region": "Europe", 644 | "latlng": [41, 12] 645 | }, 646 | { 647 | "value": "BI", 648 | "label": "Burundi", 649 | "flag": "🇧🇮", 650 | "region": "Africa", 651 | "latlng": [-3, 30] 652 | }, 653 | { 654 | "value": "BO", 655 | "label": "Bolivia", 656 | "flag": "🇧🇴", 657 | "region": "Americas", 658 | "latlng": [-17, -65] 659 | }, 660 | { 661 | "value": "CD", 662 | "label": "DR Congo", 663 | "flag": "🇨🇩", 664 | "region": "Africa", 665 | "latlng": [0, 25] 666 | }, 667 | { 668 | "value": "IS", 669 | "label": "Iceland", 670 | "flag": "🇮🇸", 671 | "region": "Europe", 672 | "latlng": [65, -18] 673 | }, 674 | { 675 | "value": "LR", 676 | "label": "Liberia", 677 | "flag": "🇱🇷", 678 | "region": "Africa", 679 | "latlng": [6, -9] 680 | }, 681 | { 682 | "value": "LV", 683 | "label": "Latvia", 684 | "flag": "🇱🇻", 685 | "region": "Europe", 686 | "latlng": [57, 25] 687 | }, 688 | { 689 | "value": "MK", 690 | "label": "North Macedonia", 691 | "flag": "🇲🇰", 692 | "region": "Europe", 693 | "latlng": [41, 22] 694 | }, 695 | { 696 | "value": "ME", 697 | "label": "Montenegro", 698 | "flag": "🇲🇪", 699 | "region": "Europe", 700 | "latlng": [42, 19] 701 | }, 702 | { 703 | "value": "NF", 704 | "label": "Norfolk Island", 705 | "flag": "🇳🇫", 706 | "region": "Oceania", 707 | "latlng": [-29, 167] 708 | }, 709 | { 710 | "value": "NG", 711 | "label": "Nigeria", 712 | "flag": "🇳🇬", 713 | "region": "Africa", 714 | "latlng": [10, 8] 715 | }, 716 | { 717 | "value": "SB", 718 | "label": "Solomon Islands", 719 | "flag": "🇸🇧", 720 | "region": "Oceania", 721 | "latlng": [-8, 159] 722 | }, 723 | { 724 | "value": "UZ", 725 | "label": "Uzbekistan", 726 | "flag": "🇺🇿", 727 | "region": "Asia", 728 | "latlng": [41, 64] 729 | }, 730 | { 731 | "value": "VU", 732 | "label": "Vanuatu", 733 | "flag": "🇻🇺", 734 | "region": "Oceania", 735 | "latlng": [-16, 167] 736 | }, 737 | { 738 | "value": "YE", 739 | "label": "Yemen", 740 | "flag": "🇾🇪", 741 | "region": "Asia", 742 | "latlng": [15, 48] 743 | }, 744 | { 745 | "value": "AS", 746 | "label": "American Samoa", 747 | "flag": "🇦🇸", 748 | "region": "Oceania", 749 | "latlng": [-14, -170] 750 | }, 751 | { 752 | "value": "AE", 753 | "label": "United Arab Emirates", 754 | "flag": "🇦🇪", 755 | "region": "Asia", 756 | "latlng": [24, 54] 757 | }, 758 | { 759 | "value": "AO", 760 | "label": "Angola", 761 | "flag": "🇦🇴", 762 | "region": "Africa", 763 | "latlng": [-12, 18] 764 | }, 765 | { 766 | "value": "TF", 767 | "label": "French Southern and Antarctic Lands", 768 | "flag": "🇹🇫", 769 | "region": "Antarctic", 770 | "latlng": [-49, 69] 771 | }, 772 | { 773 | "value": "CC", 774 | "label": "Cocos (Keeling) Islands", 775 | "flag": "🇨🇨", 776 | "region": "Oceania", 777 | "latlng": [-12, 96] 778 | }, 779 | { 780 | "value": "CK", 781 | "label": "Cook Islands", 782 | "flag": "🇨🇰", 783 | "region": "Oceania", 784 | "latlng": [-21, -159] 785 | }, 786 | { 787 | "value": "CZ", 788 | "label": "Czechia", 789 | "flag": "🇨🇿", 790 | "region": "Europe", 791 | "latlng": [49, 15] 792 | }, 793 | { 794 | "value": "DO", 795 | "label": "Dominican Republic", 796 | "flag": "🇩🇴", 797 | "region": "Americas", 798 | "latlng": [19, -70] 799 | }, 800 | { 801 | "value": "FI", 802 | "label": "Finland", 803 | "flag": "🇫🇮", 804 | "region": "Europe", 805 | "latlng": [64, 26] 806 | }, 807 | { 808 | "value": "FR", 809 | "label": "France", 810 | "flag": "🇫🇷", 811 | "region": "Europe", 812 | "latlng": [46, 2] 813 | }, 814 | { 815 | "value": "GN", 816 | "label": "Guinea", 817 | "flag": "🇬🇳", 818 | "region": "Africa", 819 | "latlng": [11, -10] 820 | }, 821 | { 822 | "value": "KR", 823 | "label": "South Korea", 824 | "flag": "🇰🇷", 825 | "region": "Asia", 826 | "latlng": [37, 127] 827 | }, 828 | { 829 | "value": "LI", 830 | "label": "Liechtenstein", 831 | "flag": "🇱🇮", 832 | "region": "Europe", 833 | "latlng": [47, 9] 834 | }, 835 | { 836 | "value": "MD", 837 | "label": "Moldova", 838 | "flag": "🇲🇩", 839 | "region": "Europe", 840 | "latlng": [47, 29] 841 | }, 842 | { 843 | "value": "NZ", 844 | "label": "New Zealand", 845 | "flag": "🇳🇿", 846 | "region": "Oceania", 847 | "latlng": [-41, 174] 848 | }, 849 | { 850 | "value": "PE", 851 | "label": "Peru", 852 | "flag": "🇵🇪", 853 | "region": "Americas", 854 | "latlng": [-10, -76] 855 | }, 856 | { 857 | "value": "QA", 858 | "label": "Qatar", 859 | "flag": "🇶🇦", 860 | "region": "Asia", 861 | "latlng": [25, 51] 862 | }, 863 | { 864 | "value": "RO", 865 | "label": "Romania", 866 | "flag": "🇷🇴", 867 | "region": "Europe", 868 | "latlng": [46, 25] 869 | }, 870 | { 871 | "value": "RU", 872 | "label": "Russia", 873 | "flag": "🇷🇺", 874 | "region": "Europe", 875 | "latlng": [60, 100] 876 | }, 877 | { 878 | "value": "SM", 879 | "label": "San Marino", 880 | "flag": "🇸🇲", 881 | "region": "Europe", 882 | "latlng": [43, 12] 883 | }, 884 | { 885 | "value": "TH", 886 | "label": "Thailand", 887 | "flag": "🇹🇭", 888 | "region": "Asia", 889 | "latlng": [15, 100] 890 | }, 891 | { 892 | "value": "VG", 893 | "label": "British Virgin Islands", 894 | "flag": "🇻🇬", 895 | "region": "Americas", 896 | "latlng": [18, -64] 897 | }, 898 | { 899 | "value": "BG", 900 | "label": "Bulgaria", 901 | "flag": "🇧🇬", 902 | "region": "Europe", 903 | "latlng": [43, 25] 904 | }, 905 | { 906 | "value": "BW", 907 | "label": "Botswana", 908 | "flag": "🇧🇼", 909 | "region": "Africa", 910 | "latlng": [-22, 24] 911 | }, 912 | { 913 | "value": "GY", 914 | "label": "Guyana", 915 | "flag": "🇬🇾", 916 | "region": "Americas", 917 | "latlng": [5, -59] 918 | }, 919 | { 920 | "value": "HK", 921 | "label": "Hong Kong", 922 | "flag": "🇭🇰", 923 | "region": "Asia", 924 | "latlng": [22, 114] 925 | }, 926 | { 927 | "value": "HM", 928 | "label": "Heard Island and McDonald Islands", 929 | "flag": "🇭🇲", 930 | "region": "Antarctic", 931 | "latlng": [-53, 72] 932 | }, 933 | { 934 | "value": "IO", 935 | "label": "British Indian Ocean Territory", 936 | "flag": "🇮🇴", 937 | "region": "Africa", 938 | "latlng": [-6, 71] 939 | }, 940 | { 941 | "value": "KZ", 942 | "label": "Kazakhstan", 943 | "flag": "🇰🇿", 944 | "region": "Asia", 945 | "latlng": [48, 68] 946 | }, 947 | { 948 | "value": "KG", 949 | "label": "Kyrgyzstan", 950 | "flag": "🇰🇬", 951 | "region": "Asia", 952 | "latlng": [41, 75] 953 | }, 954 | { 955 | "value": "LU", 956 | "label": "Luxembourg", 957 | "flag": "🇱🇺", 958 | "region": "Europe", 959 | "latlng": [49, 6] 960 | }, 961 | { 962 | "value": "MM", 963 | "label": "Myanmar", 964 | "flag": "🇲🇲", 965 | "region": "Asia", 966 | "latlng": [22, 98] 967 | }, 968 | { 969 | "value": "MZ", 970 | "label": "Mozambique", 971 | "flag": "🇲🇿", 972 | "region": "Africa", 973 | "latlng": [-18, 35] 974 | }, 975 | { 976 | "value": "MW", 977 | "label": "Malawi", 978 | "flag": "🇲🇼", 979 | "region": "Africa", 980 | "latlng": [-13, 34] 981 | }, 982 | { 983 | "value": "KP", 984 | "label": "North Korea", 985 | "flag": "🇰🇵", 986 | "region": "Asia", 987 | "latlng": [40, 127] 988 | }, 989 | { 990 | "value": "SA", 991 | "label": "Saudi Arabia", 992 | "flag": "🇸🇦", 993 | "region": "Asia", 994 | "latlng": [25, 45] 995 | }, 996 | { 997 | "value": "TM", 998 | "label": "Turkmenistan", 999 | "flag": "🇹🇲", 1000 | "region": "Asia", 1001 | "latlng": [40, 60] 1002 | }, 1003 | { 1004 | "value": "AX", 1005 | "label": "Åland Islands", 1006 | "flag": "🇦🇽", 1007 | "region": "Europe", 1008 | "latlng": [60, 19] 1009 | }, 1010 | { 1011 | "value": "AU", 1012 | "label": "Australia", 1013 | "flag": "🇦🇺", 1014 | "region": "Oceania", 1015 | "latlng": [-27, 133] 1016 | }, 1017 | { 1018 | "value": "CI", 1019 | "label": "Ivory Coast", 1020 | "flag": "🇨🇮", 1021 | "region": "Africa", 1022 | "latlng": [8, -5] 1023 | }, 1024 | { 1025 | "value": "CX", 1026 | "label": "Christmas Island", 1027 | "flag": "🇨🇽", 1028 | "region": "Oceania", 1029 | "latlng": [-10, 105] 1030 | }, 1031 | { 1032 | "value": "DE", 1033 | "label": "Germany", 1034 | "flag": "🇩🇪", 1035 | "region": "Europe", 1036 | "latlng": [51, 9] 1037 | }, 1038 | { 1039 | "value": "DZ", 1040 | "label": "Algeria", 1041 | "flag": "🇩🇿", 1042 | "region": "Africa", 1043 | "latlng": [28, 3] 1044 | }, 1045 | { 1046 | "value": "GM", 1047 | "label": "Gambia", 1048 | "flag": "🇬🇲", 1049 | "region": "Africa", 1050 | "latlng": [13, -16] 1051 | }, 1052 | { 1053 | "value": "GT", 1054 | "label": "Guatemala", 1055 | "flag": "🇬🇹", 1056 | "region": "Americas", 1057 | "latlng": [15, -90] 1058 | }, 1059 | { 1060 | "value": "GU", 1061 | "label": "Guam", 1062 | "flag": "🇬🇺", 1063 | "region": "Oceania", 1064 | "latlng": [13, 144] 1065 | }, 1066 | { 1067 | "value": "MR", 1068 | "label": "Mauritania", 1069 | "flag": "🇲🇷", 1070 | "region": "Africa", 1071 | "latlng": [20, -12] 1072 | }, 1073 | { 1074 | "value": "PG", 1075 | "label": "Papua New Guinea", 1076 | "flag": "🇵🇬", 1077 | "region": "Oceania", 1078 | "latlng": [-6, 147] 1079 | }, 1080 | { 1081 | "value": "SE", 1082 | "label": "Sweden", 1083 | "flag": "🇸🇪", 1084 | "region": "Europe", 1085 | "latlng": [62, 15] 1086 | }, 1087 | { 1088 | "value": "TD", 1089 | "label": "Chad", 1090 | "flag": "🇹🇩", 1091 | "region": "Africa", 1092 | "latlng": [15, 19] 1093 | }, 1094 | { 1095 | "value": "TL", 1096 | "label": "Timor-Leste", 1097 | "flag": "🇹🇱", 1098 | "region": "Asia", 1099 | "latlng": [-8, 125] 1100 | }, 1101 | { 1102 | "value": "TW", 1103 | "label": "Taiwan", 1104 | "flag": "🇹🇼", 1105 | "region": "Asia", 1106 | "latlng": [23, 121] 1107 | }, 1108 | { 1109 | "value": "WF", 1110 | "label": "Wallis and Futuna", 1111 | "flag": "🇼🇫", 1112 | "region": "Oceania", 1113 | "latlng": [-13, -176] 1114 | }, 1115 | { 1116 | "value": "AZ", 1117 | "label": "Azerbaijan", 1118 | "flag": "🇦🇿", 1119 | "region": "Asia", 1120 | "latlng": [40, 47] 1121 | }, 1122 | { 1123 | "value": "CH", 1124 | "label": "Switzerland", 1125 | "flag": "🇨🇭", 1126 | "region": "Europe", 1127 | "latlng": [47, 8] 1128 | }, 1129 | { 1130 | "value": "CN", 1131 | "label": "China", 1132 | "flag": "🇨🇳", 1133 | "region": "Asia", 1134 | "latlng": [35, 105] 1135 | }, 1136 | { 1137 | "value": "CR", 1138 | "label": "Costa Rica", 1139 | "flag": "🇨🇷", 1140 | "region": "Americas", 1141 | "latlng": [10, -84] 1142 | }, 1143 | { 1144 | "value": "KY", 1145 | "label": "Cayman Islands", 1146 | "flag": "🇰🇾", 1147 | "region": "Americas", 1148 | "latlng": [19, -80] 1149 | }, 1150 | { 1151 | "value": "DJ", 1152 | "label": "Djibouti", 1153 | "flag": "🇩🇯", 1154 | "region": "Africa", 1155 | "latlng": [11, 43] 1156 | }, 1157 | { 1158 | "value": "EG", 1159 | "label": "Egypt", 1160 | "flag": "🇪🇬", 1161 | "region": "Africa", 1162 | "latlng": [27, 30] 1163 | }, 1164 | { 1165 | "value": "GE", 1166 | "label": "Georgia", 1167 | "flag": "🇬🇪", 1168 | "region": "Asia", 1169 | "latlng": [42, 43] 1170 | }, 1171 | { 1172 | "value": "GG", 1173 | "label": "Guernsey", 1174 | "flag": "🇬🇬", 1175 | "region": "Europe", 1176 | "latlng": [49, -2] 1177 | }, 1178 | { 1179 | "value": "LY", 1180 | "label": "Libya", 1181 | "flag": "🇱🇾", 1182 | "region": "Africa", 1183 | "latlng": [25, 17] 1184 | }, 1185 | { 1186 | "value": "MO", 1187 | "label": "Macau", 1188 | "flag": "🇲🇴", 1189 | "region": "Asia", 1190 | "latlng": [22, 113] 1191 | }, 1192 | { 1193 | "value": "MC", 1194 | "label": "Monaco", 1195 | "flag": "🇲🇨", 1196 | "region": "Europe", 1197 | "latlng": [43, 7] 1198 | }, 1199 | { 1200 | "value": "PN", 1201 | "label": "Pitcairn Islands", 1202 | "flag": "🇵🇳", 1203 | "region": "Oceania", 1204 | "latlng": [-25, -130] 1205 | }, 1206 | { 1207 | "value": "PT", 1208 | "label": "Portugal", 1209 | "flag": "🇵🇹", 1210 | "region": "Europe", 1211 | "latlng": [39, -8] 1212 | }, 1213 | { 1214 | "value": "GS", 1215 | "label": "South Georgia", 1216 | "flag": "🇬🇸", 1217 | "region": "Antarctic", 1218 | "latlng": [-54, -37] 1219 | }, 1220 | { 1221 | "value": "SC", 1222 | "label": "Seychelles", 1223 | "flag": "🇸🇨", 1224 | "region": "Africa", 1225 | "latlng": [-4, 55] 1226 | }, 1227 | { 1228 | "value": "TV", 1229 | "label": "Tuvalu", 1230 | "flag": "🇹🇻", 1231 | "region": "Oceania", 1232 | "latlng": [-8, 178] 1233 | }, 1234 | { 1235 | "value": "VE", 1236 | "label": "Venezuela", 1237 | "flag": "🇻🇪", 1238 | "region": "Americas", 1239 | "latlng": [8, -66] 1240 | }, 1241 | { 1242 | "value": "VN", 1243 | "label": "Vietnam", 1244 | "flag": "🇻🇳", 1245 | "region": "Asia", 1246 | "latlng": [16, 107] 1247 | }, 1248 | { 1249 | "value": "BL", 1250 | "label": "Saint Barthélemy", 1251 | "flag": "🇧🇱", 1252 | "region": "Americas", 1253 | "latlng": [18, -63] 1254 | }, 1255 | { 1256 | "value": "BQ", 1257 | "label": "Caribbean Netherlands", 1258 | "flag": "", 1259 | "region": "Americas", 1260 | "latlng": [12, -68] 1261 | }, 1262 | { 1263 | "value": "CG", 1264 | "label": "Republic of the Congo", 1265 | "flag": "🇨🇬", 1266 | "region": "Africa", 1267 | "latlng": [-1, 15] 1268 | }, 1269 | { 1270 | "value": "KM", 1271 | "label": "Comoros", 1272 | "flag": "🇰🇲", 1273 | "region": "Africa", 1274 | "latlng": [-12, 44] 1275 | }, 1276 | { 1277 | "value": "CU", 1278 | "label": "Cuba", 1279 | "flag": "🇨🇺", 1280 | "region": "Americas", 1281 | "latlng": [21, -80] 1282 | }, 1283 | { 1284 | "value": "HR", 1285 | "label": "Croatia", 1286 | "flag": "🇭🇷", 1287 | "region": "Europe", 1288 | "latlng": [45, 15] 1289 | }, 1290 | { 1291 | "value": "IT", 1292 | "label": "Italy", 1293 | "flag": "🇮🇹", 1294 | "region": "Europe", 1295 | "latlng": [42, 12] 1296 | }, 1297 | { 1298 | "value": "LS", 1299 | "label": "Lesotho", 1300 | "flag": "🇱🇸", 1301 | "region": "Africa", 1302 | "latlng": [-29, 28] 1303 | }, 1304 | { 1305 | "value": "NL", 1306 | "label": "Netherlands", 1307 | "flag": "🇳🇱", 1308 | "region": "Europe", 1309 | "latlng": [52, 5] 1310 | }, 1311 | { 1312 | "value": "PW", 1313 | "label": "Palau", 1314 | "flag": "🇵🇼", 1315 | "region": "Oceania", 1316 | "latlng": [7, 134] 1317 | }, 1318 | { 1319 | "value": "PR", 1320 | "label": "Puerto Rico", 1321 | "flag": "🇵🇷", 1322 | "region": "Americas", 1323 | "latlng": [18, -66] 1324 | }, 1325 | { 1326 | "value": "PS", 1327 | "label": "Palestine", 1328 | "flag": "🇵🇸", 1329 | "region": "Asia", 1330 | "latlng": [31, 35] 1331 | }, 1332 | { 1333 | "value": "RS", 1334 | "label": "Serbia", 1335 | "flag": "🇷🇸", 1336 | "region": "Europe", 1337 | "latlng": [44, 21] 1338 | }, 1339 | { 1340 | "value": "ST", 1341 | "label": "São Tomé and Príncipe", 1342 | "flag": "🇸🇹", 1343 | "region": "Africa", 1344 | "latlng": [1, 7] 1345 | }, 1346 | { 1347 | "value": "US", 1348 | "label": "United States", 1349 | "flag": "🇺🇸", 1350 | "region": "Americas", 1351 | "latlng": [38, -97] 1352 | }, 1353 | { 1354 | "value": "AW", 1355 | "label": "Aruba", 1356 | "flag": "🇦🇼", 1357 | "region": "Americas", 1358 | "latlng": [12, -69] 1359 | }, 1360 | { 1361 | "value": "BR", 1362 | "label": "Brazil", 1363 | "flag": "🇧🇷", 1364 | "region": "Americas", 1365 | "latlng": [-10, -55] 1366 | }, 1367 | { 1368 | "value": "GD", 1369 | "label": "Grenada", 1370 | "flag": "🇬🇩", 1371 | "region": "Americas", 1372 | "latlng": [12, -61] 1373 | }, 1374 | { 1375 | "value": "JM", 1376 | "label": "Jamaica", 1377 | "flag": "🇯🇲", 1378 | "region": "Americas", 1379 | "latlng": [18, -77] 1380 | }, 1381 | { 1382 | "value": "MT", 1383 | "label": "Malta", 1384 | "flag": "🇲🇹", 1385 | "region": "Europe", 1386 | "latlng": [35, 14] 1387 | }, 1388 | { 1389 | "value": "PM", 1390 | "label": "Saint Pierre and Miquelon", 1391 | "flag": "🇵🇲", 1392 | "region": "Americas", 1393 | "latlng": [46, -56] 1394 | }, 1395 | { 1396 | "value": "SK", 1397 | "label": "Slovakia", 1398 | "flag": "🇸🇰", 1399 | "region": "Europe", 1400 | "latlng": [48, 19] 1401 | }, 1402 | { 1403 | "value": "TN", 1404 | "label": "Tunisia", 1405 | "flag": "🇹🇳", 1406 | "region": "Africa", 1407 | "latlng": [34, 9] 1408 | }, 1409 | { 1410 | "value": "TZ", 1411 | "label": "Tanzania", 1412 | "flag": "🇹🇿", 1413 | "region": "Africa", 1414 | "latlng": [-6, 35] 1415 | }, 1416 | { 1417 | "value": "VI", 1418 | "label": "United States Virgin Islands", 1419 | "flag": "🇻🇮", 1420 | "region": "Americas", 1421 | "latlng": [18, -64] 1422 | }, 1423 | { 1424 | "value": "WS", 1425 | "label": "Samoa", 1426 | "flag": "🇼🇸", 1427 | "region": "Oceania", 1428 | "latlng": [-13, -172] 1429 | }, 1430 | { 1431 | "value": "ZA", 1432 | "label": "South Africa", 1433 | "flag": "🇿🇦", 1434 | "region": "Africa", 1435 | "latlng": [-29, 24] 1436 | }, 1437 | { 1438 | "value": "BD", 1439 | "label": "Bangladesh", 1440 | "flag": "🇧🇩", 1441 | "region": "Asia", 1442 | "latlng": [24, 90] 1443 | }, 1444 | { 1445 | "value": "BA", 1446 | "label": "Bosnia and Herzegovina", 1447 | "flag": "🇧🇦", 1448 | "region": "Europe", 1449 | "latlng": [44, 18] 1450 | }, 1451 | { 1452 | "value": "BY", 1453 | "label": "Belarus", 1454 | "flag": "🇧🇾", 1455 | "region": "Europe", 1456 | "latlng": [53, 28] 1457 | }, 1458 | { 1459 | "value": "ET", 1460 | "label": "Ethiopia", 1461 | "flag": "🇪🇹", 1462 | "region": "Africa", 1463 | "latlng": [8, 38] 1464 | }, 1465 | { 1466 | "value": "JE", 1467 | "label": "Jersey", 1468 | "flag": "🇯🇪", 1469 | "region": "Europe", 1470 | "latlng": [49, -2] 1471 | }, 1472 | { 1473 | "value": "MX", 1474 | "label": "Mexico", 1475 | "flag": "🇲🇽", 1476 | "region": "Americas", 1477 | "latlng": [23, -102] 1478 | }, 1479 | { 1480 | "value": "NI", 1481 | "label": "Nicaragua", 1482 | "flag": "🇳🇮", 1483 | "region": "Americas", 1484 | "latlng": [13, -85] 1485 | }, 1486 | { 1487 | "value": "NR", 1488 | "label": "Nauru", 1489 | "flag": "🇳🇷", 1490 | "region": "Oceania", 1491 | "latlng": [0, 166] 1492 | }, 1493 | { 1494 | "value": "SD", 1495 | "label": "Sudan", 1496 | "flag": "🇸🇩", 1497 | "region": "Africa", 1498 | "latlng": [15, 30] 1499 | }, 1500 | { 1501 | "value": "TT", 1502 | "label": "Trinidad and Tobago", 1503 | "flag": "🇹🇹", 1504 | "region": "Americas", 1505 | "latlng": [11, -61] 1506 | }, 1507 | { 1508 | "value": "UA", 1509 | "label": "Ukraine", 1510 | "flag": "🇺🇦", 1511 | "region": "Europe", 1512 | "latlng": [49, 32] 1513 | }, 1514 | { 1515 | "value": "UY", 1516 | "label": "Uruguay", 1517 | "flag": "🇺🇾", 1518 | "region": "Americas", 1519 | "latlng": [-33, -56] 1520 | }, 1521 | { 1522 | "value": "DK", 1523 | "label": "Denmark", 1524 | "flag": "🇩🇰", 1525 | "region": "Europe", 1526 | "latlng": [56, 10] 1527 | }, 1528 | { 1529 | "value": "EH", 1530 | "label": "Western Sahara", 1531 | "flag": "🇪🇭", 1532 | "region": "Africa", 1533 | "latlng": [24, -13] 1534 | }, 1535 | { 1536 | "value": "ES", 1537 | "label": "Spain", 1538 | "flag": "🇪🇸", 1539 | "region": "Europe", 1540 | "latlng": [40, -4] 1541 | }, 1542 | { 1543 | "value": "FK", 1544 | "label": "Falkland Islands", 1545 | "flag": "🇫🇰", 1546 | "region": "Americas", 1547 | "latlng": [-51, -59] 1548 | }, 1549 | { 1550 | "value": "GF", 1551 | "label": "French Guiana", 1552 | "flag": "🇬🇫", 1553 | "region": "Americas", 1554 | "latlng": [4, -53] 1555 | }, 1556 | { 1557 | "value": "IM", 1558 | "label": "Isle of Man", 1559 | "flag": "🇮🇲", 1560 | "region": "Europe", 1561 | "latlng": [54, -4] 1562 | }, 1563 | { 1564 | "value": "IN", 1565 | "label": "India", 1566 | "flag": "🇮🇳", 1567 | "region": "Asia", 1568 | "latlng": [20, 77] 1569 | }, 1570 | { 1571 | "value": "IE", 1572 | "label": "Ireland", 1573 | "flag": "🇮🇪", 1574 | "region": "Europe", 1575 | "latlng": [53, -8] 1576 | }, 1577 | { 1578 | "value": "JO", 1579 | "label": "Jordan", 1580 | "flag": "🇯🇴", 1581 | "region": "Asia", 1582 | "latlng": [31, 36] 1583 | }, 1584 | { 1585 | "value": "KI", 1586 | "label": "Kiribati", 1587 | "flag": "🇰🇮", 1588 | "region": "Oceania", 1589 | "latlng": [1, 173] 1590 | }, 1591 | { 1592 | "value": "LT", 1593 | "label": "Lithuania", 1594 | "flag": "🇱🇹", 1595 | "region": "Europe", 1596 | "latlng": [56, 24] 1597 | }, 1598 | { 1599 | "value": "MH", 1600 | "label": "Marshall Islands", 1601 | "flag": "🇲🇭", 1602 | "region": "Oceania", 1603 | "latlng": [9, 168] 1604 | }, 1605 | { 1606 | "value": "NE", 1607 | "label": "Niger", 1608 | "flag": "🇳🇪", 1609 | "region": "Africa", 1610 | "latlng": [16, 8] 1611 | }, 1612 | { 1613 | "value": "RE", 1614 | "label": "Réunion", 1615 | "flag": "🇷🇪", 1616 | "region": "Africa", 1617 | "latlng": [-21, 55] 1618 | }, 1619 | { 1620 | "value": "SY", 1621 | "label": "Syria", 1622 | "flag": "🇸🇾", 1623 | "region": "Asia", 1624 | "latlng": [35, 38] 1625 | }, 1626 | { 1627 | "value": "TC", 1628 | "label": "Turks and Caicos Islands", 1629 | "flag": "🇹🇨", 1630 | "region": "Americas", 1631 | "latlng": [21, -71] 1632 | }, 1633 | { 1634 | "value": "BH", 1635 | "label": "Bahrain", 1636 | "flag": "🇧🇭", 1637 | "region": "Asia", 1638 | "latlng": [26, 50] 1639 | }, 1640 | { 1641 | "value": "BS", 1642 | "label": "Bahamas", 1643 | "flag": "🇧🇸", 1644 | "region": "Americas", 1645 | "latlng": [24, -76] 1646 | }, 1647 | { 1648 | "value": "GQ", 1649 | "label": "Equatorial Guinea", 1650 | "flag": "🇬🇶", 1651 | "region": "Africa", 1652 | "latlng": [2, 10] 1653 | }, 1654 | { 1655 | "value": "HU", 1656 | "label": "Hungary", 1657 | "flag": "🇭🇺", 1658 | "region": "Europe", 1659 | "latlng": [47, 20] 1660 | }, 1661 | { 1662 | "value": "KH", 1663 | "label": "Cambodia", 1664 | "flag": "🇰🇭", 1665 | "region": "Asia", 1666 | "latlng": [13, 105] 1667 | }, 1668 | { 1669 | "value": "LC", 1670 | "label": "Saint Lucia", 1671 | "flag": "🇱🇨", 1672 | "region": "Americas", 1673 | "latlng": [13, -60] 1674 | }, 1675 | { 1676 | "value": "MV", 1677 | "label": "Maldives", 1678 | "flag": "🇲🇻", 1679 | "region": "Asia", 1680 | "latlng": [3, 73] 1681 | }, 1682 | { 1683 | "value": "ML", 1684 | "label": "Mali", 1685 | "flag": "🇲🇱", 1686 | "region": "Africa", 1687 | "latlng": [17, -4] 1688 | }, 1689 | { 1690 | "value": "MN", 1691 | "label": "Mongolia", 1692 | "flag": "🇲🇳", 1693 | "region": "Asia", 1694 | "latlng": [46, 105] 1695 | }, 1696 | { 1697 | "value": "MU", 1698 | "label": "Mauritius", 1699 | "flag": "🇲🇺", 1700 | "region": "Africa", 1701 | "latlng": [-20, 57] 1702 | }, 1703 | { 1704 | "value": "NU", 1705 | "label": "Niue", 1706 | "flag": "🇳🇺", 1707 | "region": "Oceania", 1708 | "latlng": [-19, -169] 1709 | }, 1710 | { 1711 | "value": "NO", 1712 | "label": "Norway", 1713 | "flag": "🇳🇴", 1714 | "region": "Europe", 1715 | "latlng": [62, 10] 1716 | }, 1717 | { 1718 | "value": "NP", 1719 | "label": "Nepal", 1720 | "flag": "🇳🇵", 1721 | "region": "Asia", 1722 | "latlng": [28, 84] 1723 | }, 1724 | { 1725 | "value": "SN", 1726 | "label": "Senegal", 1727 | "flag": "🇸🇳", 1728 | "region": "Africa", 1729 | "latlng": [14, -14] 1730 | }, 1731 | { 1732 | "value": "SG", 1733 | "label": "Singapore", 1734 | "flag": "🇸🇬", 1735 | "region": "Asia", 1736 | "latlng": [1, 103] 1737 | }, 1738 | { 1739 | "value": "SR", 1740 | "label": "Suriname", 1741 | "flag": "🇸🇷", 1742 | "region": "Americas", 1743 | "latlng": [4, -56] 1744 | }, 1745 | { 1746 | "value": "TJ", 1747 | "label": "Tajikistan", 1748 | "flag": "🇹🇯", 1749 | "region": "Asia", 1750 | "latlng": [39, 71] 1751 | } 1752 | ] 1753 | --------------------------------------------------------------------------------