├── .eslintrc.json ├── app ├── favicon.ico ├── loading.tsx ├── libs │ └── prismadb.ts ├── hooks │ ├── useRentModal.ts │ ├── useLoginModal.ts │ ├── useSearchModal.ts │ ├── useRegisterModal.ts │ ├── useCountries.ts │ └── useFavorite.ts ├── components │ ├── Container.tsx │ ├── Loader.tsx │ ├── Avatar.tsx │ ├── navbar │ │ ├── MenuItem.tsx │ │ ├── Logo.tsx │ │ ├── Navbar.tsx │ │ ├── Search.tsx │ │ ├── Categories.tsx │ │ └── UserMenu.tsx │ ├── ClientOnly.tsx │ ├── Heading.tsx │ ├── inputs │ │ ├── Calendar.tsx │ │ ├── CategoryInput.tsx │ │ ├── CountrySelect.tsx │ │ ├── ImageUpload.tsx │ │ ├── Counter.tsx │ │ └── Input.tsx │ ├── listings │ │ ├── ListingCategory.tsx │ │ ├── ListingHead.tsx │ │ ├── ListingReservation.tsx │ │ ├── ListingInfo.tsx │ │ └── ListingCard.tsx │ ├── EmptyState.tsx │ ├── HeartButton.tsx │ ├── Map.tsx │ ├── Button.tsx │ ├── CategoryBox.tsx │ └── Modals │ │ ├── LoginModal.tsx │ │ ├── RegisterModal.tsx │ │ ├── Modal.tsx │ │ ├── SearchModal.tsx │ │ └── RentModal.tsx ├── globals.css ├── error.tsx ├── providers │ └── ToasterProvider.tsx ├── api │ ├── register │ │ └── route.ts │ ├── listings │ │ ├── [listingId] │ │ │ └── route.ts │ │ └── route.ts │ ├── reservations │ │ ├── [reservationId] │ │ │ └── route.ts │ │ └── route.ts │ └── favorites │ │ └── [listingId] │ │ └── route.ts ├── actions │ ├── getFavoriteListings.ts │ ├── getListingById.ts │ ├── getCurrentUser.ts │ ├── getReservation.ts │ └── getListings.ts ├── favorites │ ├── page.tsx │ └── FavoritesClient.tsx ├── types │ └── index.ts ├── listings │ └── [listingId] │ │ ├── page.tsx │ │ └── ListingClient.tsx ├── properties │ ├── page.tsx │ └── PropertiesClient.tsx ├── trips │ ├── page.tsx │ └── TripsClient.tsx ├── layout.tsx ├── reservations │ ├── page.tsx │ └── ReservationsClient.tsx └── page.tsx ├── public ├── images │ ├── logo.png │ └── placeholder.jpg ├── vercel.svg └── next.svg ├── .husky └── commit-msg ├── postcss.config.js ├── .prettierignore ├── prettier.config.js ├── middleware.ts ├── tailwind.config.js ├── next.config.js ├── .editorconfig ├── .vscode └── settings.json ├── .gitignore ├── commitlint.config.js ├── tsconfig.json ├── .env ├── package.json ├── pages └── api │ └── auth │ └── [...nextauth].ts ├── README.md ├── prisma └── schema.prisma └── .prettierrc.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haofeng0705/Airbnb-v2/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haofeng0705/Airbnb-v2/HEAD/public/images/logo.png -------------------------------------------------------------------------------- /public/images/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haofeng0705/Airbnb-v2/HEAD/public/images/placeholder.jpg -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist/* 2 | .local 3 | .output.js 4 | /node_modules/** 5 | 6 | **/*.svg 7 | **/*.sh 8 | 9 | /public/* 10 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // prettier.config.js 2 | module.exports = { 3 | plugins: [require('prettier-plugin-tailwindcss')], 4 | } -------------------------------------------------------------------------------- /app/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from "./components/Loader"; 2 | 3 | const Loading = () => { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | export default Loading; 10 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'next-auth/middleware' 2 | 3 | // middleware to check if user is authenticated, if not: redirect to home page 4 | export const config = { 5 | matcher: ['/trips', '/reservations', '/properties', '/favorites'] 6 | } 7 | 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './app/**/*.{js,ts,jsx,tsx}', 5 | './pages/**/*.{js,ts,jsx,tsx}', 6 | './components/**/*.{js,ts,jsx,tsx}' 7 | ], 8 | theme: { 9 | extend: {} 10 | }, 11 | plugins: [] 12 | } 13 | 14 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | images: { 7 | domains: [ 8 | 'res.cloudinary.com', 9 | 'avatars.githubusercontent.com', 10 | 'lh3.googleusercontent.com' 11 | ] 12 | }, 13 | } 14 | 15 | module.exports = nextConfig 16 | -------------------------------------------------------------------------------- /app/libs/prismadb.ts: -------------------------------------------------------------------------------- 1 | //这么做是为了防止 next hot load 后 prisma 实例的报错 2 | import { PrismaClient } from "@prisma/client" 3 | 4 | declare global { 5 | var prisma: PrismaClient | undefined 6 | } 7 | 8 | const client = globalThis.prisma || new PrismaClient() 9 | if (process.env.NODE_ENV !== "production") globalThis.prisma = client 10 | 11 | export default client 12 | -------------------------------------------------------------------------------- /app/hooks/useRentModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface RentModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | const useRentModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }) 13 | })); 14 | 15 | 16 | export default useRentModal; 17 | -------------------------------------------------------------------------------- /app/components/Container.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import React from 'react' 3 | 4 | interface ContainerProps { 5 | children: React.ReactNode 6 | } 7 | 8 | const Container: React.FC = ({ children }) => { 9 | return ( 10 |
11 | {children} 12 |
13 | ) 14 | } 15 | 16 | export default Container 17 | 18 | -------------------------------------------------------------------------------- /app/hooks/useLoginModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface LoginModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | const useLoginModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }) 13 | })); 14 | 15 | 16 | export default useLoginModal; 17 | -------------------------------------------------------------------------------- /app/hooks/useSearchModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface SearchModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | const useSearchModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }) 13 | })); 14 | 15 | 16 | export default useSearchModal; 17 | -------------------------------------------------------------------------------- /app/hooks/useRegisterModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface RegisterModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | const useRegisterModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }) 13 | })); 14 | 15 | 16 | export default useRegisterModal; 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] # 表示所有文件适用 6 | charset = utf-8 # 设置文件字符集为 utf-8 7 | indent_style = space # 缩进风格(tab | space) 8 | indent_size = 2 # 缩进大小 9 | end_of_line = lf # 控制换行类型(lf | cr | crlf) 10 | trim_trailing_whitespace = true # 去除行尾的任意空白字符 11 | insert_final_newline = true # 始终在文件末尾插入一个新行 12 | max_line_length = 80 13 | [*.md] # 表示仅 md 文件适用以下规则 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | height: 100%; 9 | } 10 | 11 | .leaflet-bottom, 12 | .leaflet-control, 13 | .leaflet-pane, 14 | .leaflet-top { 15 | z-index: 0 !important; 16 | } 17 | 18 | .rdrMonth { 19 | width: 100% !important; 20 | } 21 | 22 | .rdrCalendarWrapper { 23 | font-size: 16px !important; 24 | width: 100% !important; 25 | } 26 | 27 | -------------------------------------------------------------------------------- /app/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { PuffLoader } from "react-spinners"; 4 | 5 | const Loader = () => { 6 | return ( 7 |
16 | 20 |
21 | ); 22 | } 23 | 24 | export default Loader; -------------------------------------------------------------------------------- /app/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image from "next/image"; 4 | 5 | interface AvatarProps { 6 | src: string | null | undefined; 7 | } 8 | 9 | const Avatar: React.FC = ({ src }) => { 10 | // console.log('src->',src) 11 | return ( 12 | Avatar 19 | ); 20 | } 21 | 22 | export default Avatar; 23 | -------------------------------------------------------------------------------- /app/components/navbar/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | interface MenuItemProps { 3 | onClick: () => void 4 | label: string 5 | } 6 | const MenuItem: React.FC = ({ onClick, label }) => { 7 | return ( 8 |
18 | {label} 19 |
20 | ) 21 | } 22 | 23 | export default MenuItem 24 | 25 | -------------------------------------------------------------------------------- /app/components/ClientOnly.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useEffect, useState } from 'react' 4 | 5 | interface ClientOnlyProps { 6 | children: React.ReactNode 7 | } 8 | 9 | const ClientOnly: React.FC = ({ children }) => { 10 | const [hasMounted, setHasMounted] = useState(false) 11 | 12 | useEffect(() => { 13 | setHasMounted(true) 14 | }, []) 15 | 16 | if (!hasMounted) return null 17 | 18 | return <>{children} 19 | } 20 | 21 | export default ClientOnly 22 | 23 | -------------------------------------------------------------------------------- /app/components/Heading.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | interface HeadingProps { 4 | title: string 5 | subtitle?: string 6 | center?: boolean 7 | } 8 | 9 | const Heading: React.FC = ({ title, subtitle, center }) => { 10 | return ( 11 |
12 |
{title}
13 |
{subtitle}
14 |
15 | ) 16 | } 17 | 18 | export default Heading 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "explorer.fileNesting.enabled": true, 5 | "explorer.fileNesting.patterns": { 6 | "tsconfig.json": "tsconfig.*.json, README.md,*.json,*.js,.prettierignore,.env,.gitignore,.editorconfig,*.ts," 7 | }, 8 | "files.exclude": { 9 | ".husky": true, 10 | "node_modules": true 11 | }, 12 | "tailwindCSS.files.exclude": ["**/.git/**", "**/.hg/**", "**/.svn/**"] 13 | } 14 | 15 | -------------------------------------------------------------------------------- /app/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from "react"; 4 | 5 | import EmptyState from "@/app/components/EmptyState"; 6 | 7 | interface ErrorStateProps { 8 | error: Error 9 | } 10 | 11 | const ErrorState: React.FC = ({ error }) => { 12 | useEffect(() => { 13 | console.error(error); 14 | }, [error]); 15 | 16 | return ( 17 | 21 | ); 22 | } 23 | 24 | export default ErrorState; 25 | -------------------------------------------------------------------------------- /.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 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /app/components/navbar/Logo.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Image from 'next/image' 4 | import { useRouter } from 'next/navigation' 5 | 6 | const Logo = () => { 7 | const router = useRouter() 8 | 9 | return ( 10 | <> 11 | router.push('/')} 14 | className='hidden md:block cursor-pointer' 15 | src='/images/logo.png' 16 | alt='Logo' 17 | width={100} 18 | height={100} 19 | /> 20 | 21 | ) 22 | } 23 | 24 | export default Logo 25 | 26 | -------------------------------------------------------------------------------- /app/providers/ToasterProvider.tsx: -------------------------------------------------------------------------------- 1 | // 为什么使用包裹的形式: 因为 Toaster 是一个外部库, 没有适配 nextjs的 app router, 2 | // 是一个 client 组件, 包裹后才能在 layout.tsx 中直接使用 3 | 'use client' 4 | 5 | import { Toaster } from 'react-hot-toast' 6 | 7 | const ToasterProvider = () => { 8 | return ( 9 | 19 | ) 20 | } 21 | 22 | export default ToasterProvider 23 | 24 | -------------------------------------------------------------------------------- /app/api/register/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import bcrypt from "bcrypt"; 3 | 4 | import prisma from "@/app/libs/prismadb"; 5 | 6 | export async function POST( 7 | request: Request, 8 | ) { 9 | const body = await request.json(); 10 | const { 11 | email, 12 | name, 13 | password, 14 | } = body; 15 | 16 | const hashedPassword = await bcrypt.hash(password, 12); 17 | 18 | const user = await prisma.user.create({ 19 | data: { 20 | email, 21 | name, 22 | hashedPassword, 23 | } 24 | }); 25 | 26 | return NextResponse.json(user); 27 | } 28 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/hooks/useCountries.ts: -------------------------------------------------------------------------------- 1 | import countries from 'world-countries'; 2 | // 自定义 Country hook 3 | const formattedCountries = countries.map((country) => ({ 4 | value: country.cca2, 5 | label: country.name.common, 6 | flag: country.flag, 7 | latlng: country.latlng, 8 | region: country.region, 9 | })); 10 | 11 | const useCountries = () => { 12 | const getAll = () => formattedCountries; 13 | 14 | const getByValue = (value: string) => { 15 | return formattedCountries.find((item) => item.value === value); 16 | } 17 | 18 | return { 19 | getAll, 20 | getByValue 21 | } 22 | }; 23 | 24 | export default useCountries; 25 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | rules: { 4 | "type-enum": [ 5 | 2, 6 | "always", 7 | [ 8 | "build", 9 | "ci", 10 | "perf", 11 | "feat", 12 | "fix", 13 | "refactor", 14 | "docs", 15 | "chore", 16 | "style", 17 | "revert", 18 | "test", 19 | ], 20 | ], 21 | "type-case": [0], 22 | "type-empty": [0], 23 | "scope-empty": [0], 24 | "scope-case": [0], 25 | "subject-full-stop": [0], 26 | "subject-case": [0, "never"], 27 | "header-max-length": [0, "always", 72], 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | 30 | -------------------------------------------------------------------------------- /app/actions/getFavoriteListings.ts: -------------------------------------------------------------------------------- 1 | import getCurrentUser from './getCurrentUser' 2 | import prisma from '@/app/libs/prismadb' 3 | 4 | export default async function getFavoriteListings() { 5 | try { 6 | const currentUser = await getCurrentUser() 7 | 8 | if (!currentUser) { 9 | return [] 10 | } 11 | 12 | const favorites = await prisma.listing.findMany({ 13 | where: { 14 | id: { 15 | in: [...(currentUser.favoriteIds || [])] 16 | } 17 | } 18 | }) 19 | 20 | const safeFavorites = favorites.map((favorite) => ({ 21 | ...favorite, 22 | createdAt: favorite.createdAt.toString() 23 | })) 24 | 25 | return safeFavorites 26 | } catch (error: any) { 27 | throw new Error(error) 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /app/components/inputs/Calendar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import 'react-date-range/dist/styles.css' 4 | import 'react-date-range/dist/theme/default.css' 5 | 6 | import { DateRange, Range, RangeKeyDict } from 'react-date-range' 7 | 8 | interface CalendarProps { 9 | value: Range 10 | onChange: (value: RangeKeyDict) => void 11 | disabledDates?: Date[] 12 | } 13 | 14 | const Calendar: React.FC = ({ 15 | value, 16 | onChange, 17 | disabledDates 18 | }) => { 19 | return ( 20 | 30 | ) 31 | } 32 | 33 | export default Calendar 34 | 35 | -------------------------------------------------------------------------------- /app/api/listings/[listingId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import getCurrentUser from '@/app/actions/getCurrentUser' 3 | import prisma from '@/app/libs/prismadb' 4 | 5 | interface IParams { 6 | listingId?: string 7 | } 8 | 9 | export async function DELETE( 10 | request: Request, 11 | { params }: { params: IParams } 12 | ) { 13 | const currentUser = await getCurrentUser() 14 | 15 | if (!currentUser) { 16 | return NextResponse.error() 17 | } 18 | 19 | const { listingId } = params 20 | 21 | if (!listingId || typeof listingId !== 'string') { 22 | throw new Error('Invalid ID') 23 | } 24 | // 删除 listing 25 | const listing = await prisma.listing.deleteMany({ 26 | where: { 27 | id: listingId, 28 | userId: currentUser.id 29 | } 30 | }) 31 | 32 | return NextResponse.json(listing) 33 | } 34 | 35 | -------------------------------------------------------------------------------- /app/components/listings/ListingCategory.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { IconType } from 'react-icons' 4 | 5 | interface CategoryViewProps { 6 | icon: IconType 7 | label: string 8 | description: string 9 | } 10 | 11 | const CategoryView: React.FC = ({ 12 | icon: Icon, 13 | label, 14 | description 15 | }) => { 16 | return ( 17 |
18 |
19 | 23 |
24 |
{label}
25 |
{description}
26 |
27 |
28 |
29 | ) 30 | } 31 | 32 | export default CategoryView 33 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | DATABASE_URL="mongodb+srv://raphael:raphael@cluster0.pulk3rt.mongodb.net/test" 8 | NEXTAUTH_SECRET="NEXTAUTH_SECRET" 9 | 10 | GITHUB_ID=3e99e239abcce09744ad 11 | GITHUB_SECRET=52d426cf932ab8aaf14aaa0aa45bcb647fc79724 12 | 13 | GOOGLE_CLIENT_ID=265791213328-ld33o2ggi557o3c1f3tvvlrmeqb26945.apps.googleusercontent.com 14 | GOOGLE_CLIENT_SECRET=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 15 | 16 | NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="dsfiwvvv4" 17 | -------------------------------------------------------------------------------- /app/components/inputs/CategoryInput.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { IconType } from 'react-icons' 3 | 4 | interface CategoryBoxProps { 5 | icon: IconType 6 | label: string 7 | selected?: boolean 8 | onClick: (value: string) => void 9 | } 10 | const CategoryInput: React.FC = ({ 11 | icon: Icon, 12 | label, 13 | selected, 14 | onClick 15 | }) => { 16 | return ( 17 |
onClick(label)} 19 | className={` 20 | rounded-xl 21 | border-2 22 | p-4 23 | flex 24 | flex-col 25 | gap-3 26 | hover:border-black 27 | transition 28 | cursor-pointer 29 | ${selected ? 'border-black' : 'border-neutral-200'} 30 | `} 31 | > 32 | 33 |
{label}
34 |
35 | ) 36 | } 37 | 38 | export default CategoryInput 39 | 40 | -------------------------------------------------------------------------------- /app/actions/getListingById.ts: -------------------------------------------------------------------------------- 1 | import prisma from '@/app/libs/prismadb' 2 | 3 | interface IParams { 4 | listingId?: string 5 | } 6 | 7 | export default async function getListingById(params: IParams) { 8 | try { 9 | const { listingId } = params 10 | 11 | const listing = await prisma.listing.findUnique({ 12 | where: { 13 | id: listingId 14 | }, 15 | include: { 16 | user: true 17 | } 18 | }) 19 | 20 | if (!listing) { 21 | return null 22 | } 23 | 24 | return { 25 | ...listing, 26 | createdAt: listing.createdAt.toString(), 27 | user: { 28 | ...listing.user, 29 | createdAt: listing.user.createdAt.toString(), 30 | updatedAt: listing.user.updatedAt.toString(), 31 | emailVerified: listing.user.emailVerified?.toString() || null 32 | } 33 | } 34 | } catch (error: any) { 35 | throw new Error(error) 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /app/favorites/page.tsx: -------------------------------------------------------------------------------- 1 | import ClientOnly from '../components/ClientOnly' 2 | import EmptyState from '../components/EmptyState' 3 | import FavoritesClient from './FavoritesClient' 4 | import getCurrentUser from '../actions/getCurrentUser' 5 | import getFavoriteListings from '../actions/getFavoriteListings' 6 | 7 | const ListingPage = async () => { 8 | const listings = await getFavoriteListings() 9 | const currentUser = await getCurrentUser() 10 | 11 | if (listings.length === 0) { 12 | return ( 13 | 14 | 18 | 19 | ) 20 | } 21 | 22 | return ( 23 | 24 | 28 | 29 | ) 30 | } 31 | 32 | export default ListingPage 33 | 34 | -------------------------------------------------------------------------------- /app/components/navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { SafeUser } from '@/app/types' 3 | import Container from '../Container' 4 | import Logo from './Logo' 5 | import Search from './Search' 6 | import UserMenu from './UserMenu' 7 | import Categories from './Categories' 8 | interface NavbarProps { 9 | currentUser?: SafeUser | null 10 | } 11 | const NavBar: React.FC = ({ currentUser }) => { 12 | // console.log('currentUser->', currentUser) 13 | return ( 14 |
15 |
16 | 17 |
18 | 19 | 20 | 21 |
22 |
23 |
24 | 25 |
26 | ) 27 | } 28 | 29 | export default NavBar 30 | 31 | -------------------------------------------------------------------------------- /app/actions/getCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]' 2 | import { getServerSession } from 'next-auth/next' 3 | import prisma from '@/app/libs/prismadb' 4 | 5 | export async function getSession() { 6 | return await getServerSession(authOptions) 7 | } 8 | 9 | export default async function getCurrentUser() { 10 | try { 11 | const session = await getSession() 12 | 13 | if (!session?.user?.email) { 14 | return null 15 | } 16 | 17 | const currentUser = await prisma.user.findUnique({ 18 | where: { 19 | email: session.user.email as string 20 | } 21 | }) 22 | 23 | if (!currentUser) { 24 | return null 25 | } 26 | 27 | return { 28 | ...currentUser, 29 | createdAt: currentUser.createdAt.toISOString(), 30 | updatedAt: currentUser.updatedAt.toISOString(), 31 | emailVerified: currentUser.emailVerified?.toISOString() || null 32 | } 33 | } catch (error: any) { 34 | return null 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /app/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Listing, Reservation, User } from "@prisma/client"; 2 | 3 | export type SafeListing = Omit & { 4 | createdAt: string; 5 | }; 6 | 7 | export type SafeReservation = Omit< 8 | Reservation, 9 | "createdAt" | "startDate" | "endDate" | "listing" 10 | > & { 11 | createdAt: string; 12 | startDate: string; 13 | endDate: string; 14 | listing: SafeListing; 15 | }; 16 | /** 17 | * 具体来说,这个类型别名做了以下事情: 18 | Omit 表示从 User 类型中删除 "createdAt"、"updatedAt" 和 "emailVerified" 这三个属性,得到一个新的类型。 19 | & 符号表示将新类型和一个对象类型合并成一个新的类型,对象类型包含新增的属性 createdAt、updatedAt 和 emailVerified。 20 | createdAt: string 表示 createdAt 属性的类型为 string。 21 | updatedAt: string 表示 updatedAt 属性的类型为 string。 22 | emailVerified: string | null 表示 emailVerified 属性的类型为 string 或 null。 23 | */ 24 | export type SafeUser = Omit< 25 | User, 26 | "createdAt" | "updatedAt" | "emailVerified" 27 | > & { 28 | createdAt: string; 29 | updatedAt: string; 30 | emailVerified: string | null; 31 | }; 32 | -------------------------------------------------------------------------------- /app/listings/[listingId]/page.tsx: -------------------------------------------------------------------------------- 1 | import ClientOnly from '@/app/components/ClientOnly' 2 | import EmptyState from '@/app/components/EmptyState' 3 | import ListingClient from './ListingClient' 4 | import getCurrentUser from '@/app/actions/getCurrentUser' 5 | import getListingById from '@/app/actions/getListingById' 6 | import getReservations from '@/app/actions/getReservation' 7 | 8 | interface IParams { 9 | listingId?: string 10 | } 11 | const ListingPage = async ({ params }: { params: IParams }) => { 12 | const listing = await getListingById(params) 13 | const reservations = await getReservations(params) 14 | const currentUser = await getCurrentUser() 15 | if (!listing) { 16 | return ( 17 | 18 | 19 | 20 | ) 21 | } 22 | return ( 23 | 24 | 29 | 30 | ) 31 | } 32 | 33 | export default ListingPage 34 | 35 | -------------------------------------------------------------------------------- /app/api/reservations/[reservationId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import getCurrentUser from '@/app/actions/getCurrentUser' 3 | import prisma from '@/app/libs/prismadb' 4 | 5 | interface IParams { 6 | reservationId?: string 7 | } 8 | 9 | // 根据 id 获取预约信息 10 | export async function DELETE( 11 | request: Request, 12 | { params }: { params: IParams } 13 | ) { 14 | const currentUser = await getCurrentUser() 15 | // 如果没有登录,返回 401 16 | if (!currentUser) { 17 | return NextResponse.error() 18 | } 19 | 20 | const { reservationId } = params 21 | // 如果没有传入 id 或者 id 不是字符串,报错 22 | if (!reservationId || typeof reservationId !== 'string') { 23 | throw new Error('Invalid ID') 24 | } 25 | // 根据 id 删除预约信息,保证能删除信息的只有创建者和房东 26 | const reservation = await prisma.reservation.deleteMany({ 27 | where: { 28 | // 只能删除自己的预约信息 29 | id: reservationId, 30 | OR: [{ userId: currentUser.id }, { listing: { userId: currentUser.id } }] 31 | } 32 | }) 33 | 34 | return NextResponse.json(reservation) 35 | } 36 | 37 | -------------------------------------------------------------------------------- /app/api/reservations/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import getCurrentUser from '@/app/actions/getCurrentUser' 3 | import prisma from '@/app/libs/prismadb' 4 | 5 | export async function POST(request: Request) { 6 | const currentUser = await getCurrentUser() 7 | // 没有用户登录,返回错误 8 | if (!currentUser) { 9 | return NextResponse.error() 10 | } 11 | 12 | const body = await request.json() 13 | const { listingId, startDate, endDate, totalPrice } = body 14 | // 没有listingId,startDate,endDate,totalPrice,返回错误 15 | if (!listingId || !startDate || !endDate || !totalPrice) { 16 | return NextResponse.error() 17 | } 18 | // 创建预定 19 | const listingAndReservation = await prisma.listing.update({ 20 | where: { 21 | id: listingId 22 | }, 23 | data: { 24 | reservations: { 25 | create: { 26 | userId: currentUser.id, 27 | startDate, 28 | endDate, 29 | totalPrice 30 | } 31 | } 32 | } 33 | }) 34 | // 返回预定 35 | return NextResponse.json(listingAndReservation) 36 | } 37 | 38 | -------------------------------------------------------------------------------- /app/properties/page.tsx: -------------------------------------------------------------------------------- 1 | import ClientOnly from '@/app/components/ClientOnly' 2 | import EmptyState from '@/app/components/EmptyState' 3 | import PropertiesClient from './PropertiesClient' 4 | import getCurrentUser from '@/app/actions/getCurrentUser' 5 | import getListings from '@/app/actions/getListings' 6 | 7 | const PropertiesPage = async () => { 8 | const currentUser = await getCurrentUser() 9 | 10 | if (!currentUser) { 11 | return ( 12 | 16 | ) 17 | } 18 | 19 | const listings = await getListings({ userId: currentUser.id }) 20 | 21 | if (listings.length === 0) { 22 | return ( 23 | 24 | 28 | 29 | ) 30 | } 31 | 32 | return ( 33 | 34 | 38 | 39 | ) 40 | } 41 | 42 | export default PropertiesPage 43 | 44 | -------------------------------------------------------------------------------- /app/components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Button from "./Button"; 4 | import Heading from "./Heading"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | interface EmptyStateProps { 8 | title?: string; 9 | subtitle?: string; 10 | showReset?: boolean; 11 | } 12 | 13 | const EmptyState: React.FC = ({ 14 | title = "No exact matches", 15 | subtitle = "Try changing or removing some of your filters.", 16 | showReset 17 | }) => { 18 | const router = useRouter(); 19 | 20 | return ( 21 |
31 | 36 |
37 | {showReset && ( 38 |
45 |
46 | ); 47 | } 48 | 49 | export default EmptyState; 50 | -------------------------------------------------------------------------------- /app/api/listings/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import getCurrentUser from '@/app/actions/getCurrentUser' 3 | import prisma from '@/app/libs/prismadb' 4 | 5 | export async function POST(request: Request) { 6 | const currentUser = await getCurrentUser() 7 | 8 | if (!currentUser) { 9 | return NextResponse.error() 10 | } 11 | 12 | const body = await request.json() 13 | const { 14 | title, 15 | description, 16 | imageSrc, 17 | category, 18 | roomCount, 19 | bathroomCount, 20 | guestCount, 21 | location, 22 | price 23 | } = body 24 | // 检查 body 是否完整 25 | Object.keys(body).forEach((value: any) => { 26 | if (!body[value]) { 27 | NextResponse.error() 28 | } 29 | }) 30 | 31 | const listing = await prisma.listing.create({ 32 | data: { 33 | title, 34 | description, 35 | imageSrc, 36 | category, 37 | roomCount, 38 | bathroomCount, 39 | guestCount, 40 | locationValue: location.value, 41 | price: parseInt(price, 10), 42 | userId: currentUser.id 43 | } 44 | }) 45 | 46 | return NextResponse.json(listing) 47 | } 48 | 49 | -------------------------------------------------------------------------------- /app/components/HeartButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { AiFillHeart, AiOutlineHeart } from 'react-icons/ai' 4 | 5 | import { SafeUser } from '../types' 6 | import useFavorite from '../hooks/useFavorite' 7 | 8 | interface HeartButtonProps { 9 | listingId: string 10 | currentUser?: SafeUser | null 11 | } 12 | 13 | const HeartButton: React.FC = ({ 14 | listingId, 15 | currentUser 16 | }) => { 17 | // 使用 hooks 18 | // console.log('currentUser->', currentUser) 19 | const { hasFavorited, toggleFavorite } = useFavorite({ 20 | listingId, 21 | currentUser 22 | }) 23 | // const hasFavorited = false 24 | // const toggleFavorite = () => {} 25 | 26 | return ( 27 |
31 | 40 | 44 |
45 | ) 46 | } 47 | 48 | export default HeartButton 49 | 50 | -------------------------------------------------------------------------------- /app/favorites/FavoritesClient.tsx: -------------------------------------------------------------------------------- 1 | import { SafeListing, SafeUser } from "@/app/types"; 2 | 3 | import Heading from "@/app/components/Heading"; 4 | import Container from "@/app/components/Container"; 5 | import ListingCard from "@/app/components/listings/ListingCard"; 6 | 7 | interface FavoritesClientProps { 8 | listings: SafeListing[], 9 | currentUser?: SafeUser | null, 10 | } 11 | 12 | const FavoritesClient: React.FC = ({ 13 | listings, 14 | currentUser 15 | }) => { 16 | return ( 17 | 18 | 22 |
35 | {listings.map((listing: any) => ( 36 | 41 | ))} 42 |
43 |
44 | ); 45 | } 46 | 47 | export default FavoritesClient; -------------------------------------------------------------------------------- /app/trips/page.tsx: -------------------------------------------------------------------------------- 1 | import ClientOnly from '../components/ClientOnly' 2 | import EmptyState from '../components/EmptyState' 3 | import TripsClient from './TripsClient' 4 | import getCurrentUser from '../actions/getCurrentUser' 5 | import getReservations from '../actions/getReservation' 6 | 7 | const TripsPage = async () => { 8 | const currentUser = await getCurrentUser() 9 | 10 | if (!currentUser) { 11 | return ( 12 | 13 | 17 | 18 | ) 19 | } 20 | // Get all reservations for the current user 21 | const reservations = await getReservations({ userId: currentUser.id }) 22 | // 如果没有预定,显示空状态 23 | if (reservations.length === 0) { 24 | return ( 25 | 26 | 30 | 31 | ) 32 | } 33 | // 如果有预定,显示预定列表 34 | return ( 35 | 36 | 40 | 41 | ) 42 | } 43 | 44 | export default TripsPage 45 | 46 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | 3 | import ClientOnly from './components/ClientOnly' 4 | import LoginModal from './components/Modals/LoginModal' 5 | import NavBar from './components/navbar/Navbar' 6 | import { Nunito } from 'next/font/google' 7 | import RegisterModal from './components/Modals/RegisterModal' 8 | import RentModal from './components/Modals/RentModal' 9 | import SearchModal from './components/Modals/SearchModal' 10 | import ToasterProvider from './providers/ToasterProvider' 11 | import getCurrentUser from './actions/getCurrentUser' 12 | 13 | export const metadata = { 14 | title: 'Airbnb', 15 | description: 'Airbnbv2 clone' 16 | } 17 | const font = Nunito({ 18 | subsets: ['latin'] 19 | }) 20 | 21 | export default async function RootLayout({ 22 | children 23 | }: { 24 | children: React.ReactNode 25 | }) { 26 | const currentUser = await getCurrentUser() 27 | // console.log('currentUser->',currentUser) 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
{children}
40 | 41 | 42 | ) 43 | } 44 | 45 | -------------------------------------------------------------------------------- /app/components/Map.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import 'leaflet/dist/leaflet.css' 4 | 5 | // 导入 leaflet 地图库 6 | import { MapContainer, Marker, TileLayer } from 'react-leaflet' 7 | 8 | import L from 'leaflet' 9 | import markerIcon from 'leaflet/dist/images/marker-icon.png' 10 | import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png' 11 | import markerShadow from 'leaflet/dist/images/marker-shadow.png' 12 | 13 | // @ts-ignore 14 | delete L.Icon.Default.prototype._getIconUrl 15 | L.Icon.Default.mergeOptions({ 16 | iconUrl: markerIcon.src, 17 | iconRetinaUrl: markerIcon2x.src, 18 | shadowUrl: markerShadow.src 19 | }) 20 | 21 | interface MapProps { 22 | center?: number[] 23 | } 24 | 25 | const url = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' 26 | const attribution = 27 | '© OpenStreetMap contributors' 28 | 29 | const Map: React.FC = ({ center }) => { 30 | return ( 31 | 37 | 41 | {center && } 42 | 43 | ) 44 | } 45 | 46 | export default Map 47 | 48 | -------------------------------------------------------------------------------- /app/components/Button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { IconType } from 'react-icons' 4 | interface ButtonProps { 5 | label: string 6 | onClick: (e: React.MouseEvent) => void 7 | disabled?: boolean 8 | outline?: boolean 9 | small?: boolean 10 | icon?: IconType 11 | } 12 | 13 | const Button: React.FC = ({ 14 | label, 15 | onClick, 16 | disabled, 17 | outline, 18 | small, 19 | icon: Icon 20 | }) => { 21 | return ( 22 | 54 | ) 55 | } 56 | 57 | export default Button 58 | -------------------------------------------------------------------------------- /app/actions/getReservation.ts: -------------------------------------------------------------------------------- 1 | import prisma from '@/app/libs/prismadb' 2 | 3 | interface IParams { 4 | listingId?: string 5 | userId?: string 6 | authorId?: string 7 | } 8 | // 获取预定 9 | export default async function getReservations(params: IParams) { 10 | try { 11 | const { listingId, userId, authorId } = params 12 | 13 | const query: any = {} 14 | // 按不同的值 query 15 | // 对单一房源查询预定 16 | if (listingId) { 17 | query.listingId = listingId 18 | } 19 | // 对单一用户查询预定 20 | if (userId) { 21 | query.userId = userId 22 | } 23 | // 对不同的用户查询预定 24 | if (authorId) { 25 | query.listing = { userId: authorId } 26 | } 27 | 28 | const reservations = await prisma.reservation.findMany({ 29 | where: query, 30 | include: { 31 | listing: true 32 | }, 33 | orderBy: { 34 | createdAt: 'desc' 35 | } 36 | }) 37 | 38 | const safeReservations = reservations.map((reservation) => ({ 39 | ...reservation, 40 | createdAt: reservation.createdAt.toISOString(), 41 | startDate: reservation.startDate.toISOString(), 42 | endDate: reservation.endDate.toISOString(), 43 | listing: { 44 | ...reservation.listing, 45 | createdAt: reservation.listing.createdAt.toISOString() 46 | } 47 | })) 48 | 49 | return safeReservations 50 | } catch (error: any) { 51 | throw new Error(error) 52 | } 53 | } 54 | 55 | -------------------------------------------------------------------------------- /app/reservations/page.tsx: -------------------------------------------------------------------------------- 1 | import ClientOnly from '../components/ClientOnly' 2 | import EmptyState from '../components/EmptyState' 3 | import ReservationsClient from './ReservationsClient' 4 | import TripsClient from '../trips/TripsClient' 5 | import getCurrentUser from '../actions/getCurrentUser' 6 | import getReservations from '../actions/getReservation' 7 | 8 | const ReservationsPage = async () => { 9 | // 获取当前用户(房东) 10 | const currentUser = await getCurrentUser() 11 | // 如果没有当前用户,显示空状态 12 | if (!currentUser) { 13 | return ( 14 | 15 | 19 | 20 | ) 21 | } 22 | // 获取房东的所有预定 23 | const reservations = await getReservations({ authorId: currentUser.id }) 24 | 25 | if (reservations.length === 0) { 26 | return ( 27 | 28 | 32 | 33 | ) 34 | } 35 | 36 | return ( 37 | // 38 | // 42 | // 43 | 44 | 48 | 49 | ) 50 | } 51 | 52 | export default ReservationsPage 53 | 54 | -------------------------------------------------------------------------------- /app/components/listings/ListingHead.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Heading from '../Heading' 4 | import HeartButton from '../HeartButton' 5 | import Image from 'next/image' 6 | import { SafeUser } from '@/app/types' 7 | import useCountries from '@/app/hooks/useCountries' 8 | 9 | interface ListingHeadProps { 10 | title: string 11 | locationValue: string 12 | imageSrc: string 13 | id: string 14 | currentUser?: SafeUser | null 15 | } 16 | 17 | const ListingHead: React.FC = ({ 18 | title, 19 | locationValue, 20 | imageSrc, 21 | id, 22 | currentUser 23 | }) => { 24 | const { getByValue } = useCountries() 25 | 26 | const location = getByValue(locationValue) 27 | 28 | return ( 29 | <> 30 | 34 |
43 | Image 49 |
56 | 60 |
61 |
62 | 63 | ) 64 | } 65 | 66 | export default ListingHead 67 | 68 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import getListings, { IListingsParams } from '@/app/actions/getListings' 2 | 3 | import ClientOnly from './components/ClientOnly' 4 | import Container from '@/app/components/Container' 5 | import EmptyState from '@/app/components/EmptyState' 6 | import ListingCard from '@/app/components/listings/ListingCard' 7 | import getCurrentUser from '@/app/actions/getCurrentUser' 8 | 9 | interface HomeProps { 10 | searchParams: IListingsParams 11 | } 12 | 13 | const Home = async ({ searchParams }: HomeProps) => { 14 | // console.log('searchParams->',searchParams) 15 | const listings = await getListings(searchParams) 16 | const currentUser = await getCurrentUser() 17 | 18 | // 没有数据的时候显示空状态,当选中 icon 的时候,会触发 reset 19 | if (listings.length === 0) { 20 | return ( 21 | 22 | 23 | 24 | ) 25 | } 26 | 27 | return ( 28 | 29 | 30 |
43 | {listings.map((listing: any) => ( 44 | 49 | ))} 50 |
51 |
52 |
53 | ) 54 | } 55 | 56 | export default Home 57 | 58 | -------------------------------------------------------------------------------- /app/hooks/useFavorite.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react' 2 | 3 | import { SafeUser } from '@/app/types' 4 | import axios from 'axios' 5 | import { toast } from 'react-hot-toast' 6 | import useLoginModal from './useLoginModal' 7 | import { useRouter } from 'next/navigation' 8 | 9 | interface IUseFavorite { 10 | listingId: string 11 | currentUser?: SafeUser | null 12 | } 13 | 14 | const useFavorite = ({ listingId, currentUser }: IUseFavorite) => { 15 | const router = useRouter() 16 | 17 | const loginModal = useLoginModal() 18 | 19 | const hasFavorited = useMemo(() => { 20 | const list = currentUser?.favoriteIds || [] 21 | 22 | return list.includes(listingId) 23 | }, [currentUser, listingId]) 24 | 25 | const toggleFavorite = useCallback( 26 | async (e: React.MouseEvent) => { 27 | e.stopPropagation() 28 | 29 | if (!currentUser) { 30 | toast.error('Please login first.') 31 | return loginModal.onOpen() 32 | } 33 | 34 | try { 35 | let request 36 | 37 | if (hasFavorited) { 38 | request = () => axios.delete(`/api/favorites/${listingId}`) 39 | } else { 40 | request = () => axios.post(`/api/favorites/${listingId}`) 41 | } 42 | 43 | await request() 44 | router.refresh() 45 | toast.success('Success!') 46 | } catch (error) { 47 | toast.error('Something went wrong.') 48 | } 49 | }, 50 | [currentUser, hasFavorited, listingId, loginModal, router] 51 | ) 52 | 53 | return { 54 | hasFavorited, 55 | toggleFavorite 56 | } 57 | } 58 | 59 | export default useFavorite 60 | 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airbnb-v2", 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 | "commit": "cz" 11 | }, 12 | "dependencies": { 13 | "@next-auth/prisma-adapter": "^1.0.5", 14 | "@prisma/client": "^4.11.0", 15 | "@types/node": "18.15.5", 16 | "@types/react": "18.0.28", 17 | "@types/react-dom": "18.0.11", 18 | "axios": "^1.3.4", 19 | "bcrypt": "^5.1.0", 20 | "date-fns": "^2.29.3", 21 | "eslint": "8.36.0", 22 | "eslint-config-next": "13.2.4", 23 | "leaflet": "^1.9.3", 24 | "next": "13.2.4", 25 | "next-auth": "^4.20.1", 26 | "next-cloudinary": "^4.0.1", 27 | "next-superjson-plugin": "^0.5.6", 28 | "query-string": "^8.1.0", 29 | "react": "18.2.0", 30 | "react-date-range": "^1.4.0", 31 | "react-dom": "18.2.0", 32 | "react-hook-form": "^7.43.7", 33 | "react-hot-toast": "^2.4.0", 34 | "react-icons": "^4.8.0", 35 | "react-leaflet": "^4.2.1", 36 | "react-select": "^5.7.2", 37 | "react-spinners": "^0.13.8", 38 | "swr": "^2.1.1", 39 | "typescript": "5.0.2", 40 | "world-countries": "^4.0.0", 41 | "zustand": "^4.3.6" 42 | }, 43 | "devDependencies": { 44 | "@commitlint/cli": "^17.6.1", 45 | "@commitlint/config-conventional": "^17.6.1", 46 | "@trivago/prettier-plugin-sort-imports": "^4.1.1", 47 | "@types/bcrypt": "^5.0.0", 48 | "@types/leaflet": "^1.9.3", 49 | "@types/react-date-range": "^1.4.4", 50 | "autoprefixer": "^10.4.14", 51 | "postcss": "^8.4.21", 52 | "prisma": "^4.11.0", 53 | "tailwindcss": "^3.2.7" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/api/favorites/[listingId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import getCurrentUser from '@/app/actions/getCurrentUser' 3 | import prisma from '@/app/libs/prismadb' 4 | 5 | interface IParams { 6 | listingId?: string 7 | } 8 | 9 | //注意:不能使用默认导出,因为我们需要使用命名导出 10 | // 添加操作 11 | export async function POST(request: Request, { params }: { params: IParams }) { 12 | const currentUser = await getCurrentUser() 13 | if (!currentUser) { 14 | return NextResponse.error() 15 | } 16 | 17 | const { listingId } = params 18 | 19 | if (!listingId || typeof listingId !== 'string') { 20 | throw new Error('Invalid ID') 21 | } 22 | let favoriteIds = [...(currentUser.favoriteIds || [])] 23 | 24 | favoriteIds.push(listingId) 25 | // 更新 favorite id 数据库 26 | const user = await prisma.user.update({ 27 | where: { 28 | id: currentUser.id 29 | }, 30 | data: { 31 | favoriteIds 32 | } 33 | }) 34 | 35 | return NextResponse.json(user) 36 | } 37 | 38 | // 删除操作 39 | export async function DELETE( 40 | request: Request, 41 | { params }: { params: IParams } 42 | ) { 43 | const currentUser = await getCurrentUser() 44 | if (!currentUser) { 45 | return NextResponse.error() 46 | } 47 | const { listingId } = params 48 | if (!listingId || typeof listingId !== 'string') { 49 | throw new Error('Invalid ID') 50 | } 51 | let favoriteIds = [...(currentUser.favoriteIds || [])] 52 | favoriteIds = favoriteIds.filter((id) => id !== listingId) 53 | // 更新 favorite id 数据库 54 | const user = await prisma.user.update({ 55 | where: { 56 | id: currentUser.id 57 | }, 58 | data: { 59 | favoriteIds 60 | } 61 | }) 62 | return NextResponse.json(user) 63 | } 64 | 65 | -------------------------------------------------------------------------------- /app/components/inputs/CountrySelect.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Select from 'react-select' 4 | import useCountries from '@/app/hooks/useCountries' 5 | 6 | export type CountrySelectValue = { 7 | flag: string 8 | label: string 9 | latlng: number[] 10 | region: string 11 | value: string 12 | } 13 | 14 | interface CountrySelectProps { 15 | value?: CountrySelectValue 16 | onChange: (value: CountrySelectValue) => void 17 | } 18 | 19 | const CountrySelect: React.FC = ({ value, onChange }) => { 20 | const { getAll } = useCountries() 21 | return ( 22 |
23 | 66 | 86 |
87 | ) 88 | } 89 | 90 | export default Input 91 | 92 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://prettier.io/docs/en/options.html#print-width 3 | * @author lcm 4 | */ 5 | module.exports = { 6 | /** 7 | * 换行宽度,当代码宽度达到多少时换行 8 | * @default 80 9 | * @type {number} 10 | */ 11 | printWidth: 80, 12 | /** 13 | * 缩进的空格数量 14 | * @default 2 15 | * @type {number} 16 | */ 17 | tabWidth: 2, 18 | /** 19 | * 是否使用制表符代替空格 20 | * @default false 21 | * @type {boolean} 22 | */ 23 | useTabs: false, 24 | /** 25 | * 是否在代码块结尾加上分号 26 | * @default true 27 | * @type {boolean} 28 | */ 29 | semi: false, 30 | /** 31 | * 是否使用单引号替代双引号 32 | * @default false 33 | * @type {boolean} 34 | */ 35 | singleQuote: true, 36 | /** 37 | * 对象属性的引号处理 38 | * @default "as-needed" 39 | * @type {"as-needed"|"consistent"|"preserve"} 40 | */ 41 | quoteProps: 'as-needed', 42 | /** 43 | * jsx中是否使用单引号替代双引号 44 | * @default false 45 | * @type {boolean} 46 | */ 47 | jsxSingleQuote: true, 48 | /** 49 | * 在jsx中使用是否单引号代替双引号 50 | * @default false 51 | * @type {boolean} 52 | */ 53 | /** 54 | * 末尾是否加上逗号 55 | * @default "es5" 56 | * @type {"es5"|"none"|"all"} 57 | */ 58 | trailingComma: 'none', 59 | /** 60 | * 在对象,数组括号与文字之间加空格 "{ foo: bar }" 61 | * @default true 62 | * @type {boolean} 63 | */ 64 | bracketSpacing: true, 65 | /** 66 | * 把多行HTML (HTML, JSX, Vue, Angular)元素的>放在最后一行的末尾,而不是单独放在下一行(不适用于自关闭元素)。 67 | * @default false 68 | * @type {boolean} 69 | */ 70 | bracketSameLine: false, 71 | /** 72 | * 当箭头函数只有一个参数是否加括号 73 | * @default "always" 74 | * @type {"always"|"avoid"} 75 | */ 76 | arrowParens: 'always', 77 | /** 78 | * 为HTML、Vue、Angular和Handlebars指定全局空格敏感性 79 | * @default "css" 80 | * @type {"css"|"strict"|"ignore"} 81 | */ 82 | htmlWhitespaceSensitivity: 'ignore', 83 | /** 84 | * 是否缩进Vue文件中的