Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/dzaky-pr/airbnb-clone
https://github.com/dzaky-pr/airbnb-clone
clerk-auth nextjs14 prisma radix-ui shadcn-ui supabase-storage typescript zustand
Last synced: 8 days ago
JSON representation
- Host: GitHub
- URL: https://github.com/dzaky-pr/airbnb-clone
- Owner: dzaky-pr
- Created: 2024-12-12T17:05:15.000Z (10 days ago)
- Default Branch: main
- Last Pushed: 2024-12-12T17:41:23.000Z (10 days ago)
- Last Synced: 2024-12-12T18:31:45.012Z (10 days ago)
- Topics: clerk-auth, nextjs14, prisma, radix-ui, shadcn-ui, supabase-storage, typescript, zustand
- Language: TypeScript
- Homepage:
- Size: 6.12 MB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
### Next App
```sh
npx create-next-app@14 home-away
``````sh
npm run dev
```### Remove Boilerplate
- in globals.css remove all code after directives
- page.tsx```tsx
function HomePage() {
returnHomePage
;
}
export default HomePage;
```- layout.tsx
```tsx
export const metadata: Metadata = {
title: 'HomeAway',
description: 'Feel at home, away from home.',
};
```- get a hold of the README.MD
### Create Pages
- bookings
- checkout
- favorites
- profile
- properties
- rentals
- reviews- new file - pageName/page.tsx
```tsx
function BookingsPage() {
returnBookingsPage
;
}
export default BookingsPage;
```### Shadcn/ui
[Docs](https://ui.shadcn.com/)
[Next Install](https://ui.shadcn.com/docs/installation/next)
```sh
npx shadcn@latest init```
- New York
- Zinc```sh
npx shadcn@latest add button
``````tsx
import { Button } from '@/components/ui/button';function HomePage() {
return (
HomePage
Click me
);
}
export default HomePage;
``````sh
npx shadcn@latest add breadcrumb calendar card checkbox dropdown-menu input label popover scroll-area select separator table textarea toast skeleton
```- components
- ui
- card
- form
- home
- navbar
- properties### Navbar - Setup
- create
- navbar
- DarkMode.tsx
- LinksDropdown.tsx
- Logo.tsx
- Navbar.tsx
- NavSearch.tsx
- SignOutLink.tsx
- UserIcon.tsx### Tailwind Custom Class
globals.css
```css
@layer components {
.container {
@apply mx-auto max-w-6xl xl:max-w-7xl px-8;
}
}
```### Navbar - Structure
```tsx
import NavSearch from './NavSearch';
import LinksDropdown from './LinksDropdown';
import DarkMode from './DarkMode';
function Navbar() {
return (
);
}
export default Navbar;
``````tsx
import Navbar from '@/components/navbar/Navbar';return (
{children}
);
```### Logo
```sh
npm install react-icons
```[React Icons](https://react-icons.github.io/react-icons/)
```tsx
import Link from 'next/link';
import { LuTent } from 'react-icons/lu';
import { Button } from '../ui/button';function Logo() {
return (
);
}
```### NavSearch
```tsx
import { Input } from '../ui/input';function NavSearch() {
return (
);
}
export default NavSearch;
```### Theme
[Theming Options](https://ui.shadcn.com/docs/theming)
[Themes](https://ui.shadcn.com/themes)- replace css variables in in globals.css
### Providers
- create app/providers.tsx
```tsx
'use client';function Providers({ children }: { children: React.ReactNode }) {
return <>{children}>;
}
export default Providers;
```layout.tsx
```tsx
import Providers from './providers';return (
{children}
);
```### DarkMode
[Next.js Dark Mode](https://ui.shadcn.com/docs/dark-mode/next)
```sh
npm install next-themes
```- create app/theme-provider.tsx
```tsx
'use client';import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes/dist/types';export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return {children};
}
```providers.tsx
```tsx
'use client';
import { ThemeProvider } from './theme-provider';function Providers({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
export default Providers;
```### DarkMode
- make sure you export as default !!!
```tsx
'use client';import * as React from 'react';
import { MoonIcon, SunIcon } from '@radix-ui/react-icons';
import { useTheme } from 'next-themes';import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';export default function ModeToggle() {
const { setTheme } = useTheme();return (
Toggle theme
setTheme('light')}>
Light
setTheme('dark')}>
Dark
setTheme('system')}>
System
);
}
```### UserIcon
```tsx
import { LuUser2 } from 'react-icons/lu';function UserIcon() {
return ;
}
export default UserIcon;
```### Links Data
- create utils/links.ts
```ts
type NavLink = {
href: string;
label: string;
};export const links: NavLink[] = [
{ href: '/', label: 'home' },
{ href: '/favorites ', label: 'favorites' },
{ href: '/bookings ', label: 'bookings' },
{ href: '/reviews ', label: 'reviews' },
{ href: '/rentals/create ', label: 'create rental' },
{ href: '/rentals', label: 'my rentals' },
{ href: '/profile ', label: 'profile' },
];
```### LinksDropdown
```tsx
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { LuAlignLeft } from 'react-icons/lu';
import Link from 'next/link';
import { Button } from '../ui/button';
import UserIcon from './UserIcon';
import { links } from '@/utils/links';
import SignOutLink from './SignOutLink';function LinksDropdown() {
return (
{links.map((link) => {
return (
{link.label}
);
})}
);
}
export default LinksDropdown;
```### Clerk
[Clerk Docs](https://clerk.com/)
[Clerk + Next.js Setup](https://clerk.com/docs/quickstarts/nextjs)- create new application
```sh
npm install @clerk/nextjs
```- create .env.local
```bash
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
```In Next.js, environment variables that start with NEXT*PUBLIC* are exposed to the browser. This means they can be accessed in your front-end code.
For example, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY can be used in both server-side and client-side code.
On the other hand, CLERK_SECRET_KEY is a server-side environment variable. It's not exposed to the browser, making it suitable for storing sensitive data like API secrets.
layout.tsx
```tsx
import { ClerkProvider } from '@clerk/nextjs';return (
{children}
);
```- create middleware.ts
```ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';const isProtectedRoute = createRouteMatcher([
'/bookings(.*)',
'/checkout(.*)',
'/favorites(.*)',
'/profile(.*)',
'/rentals(.*)',
'/reviews(.*)',
]);export default clerkMiddleware((auth, req) => {
if (isProtectedRoute(req)) auth().protect();
});export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
};
```- restart dev server
### SignUp/SignIn and Customize Avatar (optional)
- customization
- avatars### Toast Component
[Toast](https://ui.shadcn.com/docs/components/toast)
providers.tsx
```tsx
'use client';
import { ThemeProvider } from './theme-provider';
import { Toaster } from '@/components/ui/toaster';function Providers({ children }: { children: React.ReactNode }) {
return (
<>
{children}
>
);
}
export default Providers;
```### SignOutLink
- redirectUrl
```tsx
'use client';import { SignOutButton } from '@clerk/nextjs';
import { useToast } from '../ui/use-toast';function SignOutLink() {
const { toast } = useToast();
const handleLogout = () => {
toast({ description: 'You have been signed out.' });
};
return (
Logout
);
}
export default SignOutLink;
```### LinksDropdown - Complete
```tsx
return (
...
....
);
``````tsx
return (
Login
Register
{links.map((link) => {
return (
{link.label}
);
})}
);
```### Direct User
.env.local
```bash
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/profile/create
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/profile/create
```### Create Profile
- profile
- create```tsx
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
const createProfileAction = async (formData: FormData) => {
'use server';
const firstName = formData.get('firstName') as string;
console.log(firstName);
};function CreateProfile() {
return (
new user
First Name
Create Profile
);
}
export default CreateProfile;
```### FormInput
- components/form/FormInput.tsx
```tsx
import { Label } from '../ui/label';
import { Input } from '../ui/input';type FormInputProps = {
name: string;
type: string;
label?: string;
defaultValue?: string;
placeholder?: string;
};function FormInput({
label,
name,
type,
defaultValue,
placeholder,
}: FormInputProps) {
return (
{label || name}
);
}export default FormInput;
```### Default Submit Button
- components/form/Buttons.tsx
```tsx
'use client';
import { ReloadIcon } from '@radix-ui/react-icons';
import { useFormStatus } from 'react-dom';
import { Button } from '@/components/ui/button';type SubmitButtonProps = {
className?: string;
text?: string;
};export function SubmitButton({
className = '',
text = 'submit',
}: SubmitButtonProps) {
const { pending } = useFormStatus();
return (
{pending ? (
<>
Please wait...
>
) : (
text
)}
);
}
```### FormContainer
- create components/form/FormContainer.tsx
```tsx
'use client';import { useFormState } from 'react-dom';
import { useEffect } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { actionFunction } from '@/utils/types';const initialState = {
message: '',
};function FormContainer({
action,
children,
}: {
action: actionFunction;
children: React.ReactNode;
}) {
const [state, formAction] = useFormState(action, initialState);
const { toast } = useToast();
useEffect(() => {
if (state.message) {
toast({ description: state.message });
}
}, [state]);
return {children};
}
export default FormContainer;
```- create utils/types.ts
```ts
export type actionFunction = (
prevState: any,
formData: FormData
) => Promise<{ message: string }>;
```### Create Profile - Refactor
```tsx
import FormInput from '@/components/form/FormInput';
import { SubmitButton } from '@/components/form/Buttons';
import FormContainer from '@/components/form/FormContainer';const createProfileAction = async (prevState: any, formData: FormData) => {
'use server';
const firstName = formData.get('firstName') as string;
if (firstName !== 'shakeAndBake') return { message: 'there was an error...' };
return { message: 'Profile Created' };
};function CreateProfile() {
return (
new user
);
}
export default CreateProfile;
```### Zod
Zod is a JavaScript library for building schemas and validating data, providing type safety and error handling.
```sh
npm install zod
```- create utils/schemas.ts
```ts
import * as z from 'zod';
import { ZodSchema } from 'zod';export const profileSchema = z.object({
// firstName: z.string().max(5, { message: 'max length is 5' }),
firstName: z.string(),
lastName: z.string(),
username: z.string(),
});
```- create utils/actions.ts
- import in profile/create page.tsx```ts
'use server';import { profileSchema } from './schemas';
export const createProfileAction = async (
prevState: any,
formData: FormData
) => {
try {
const rawData = Object.fromEntries(formData);
const validatedFields = profileSchema.parse(rawData);
console.log(validatedFields);
return { message: 'Profile Created' };
} catch (error) {
console.log(error);
return { message: 'there was an error...' };
}
};
```### Supabase
- create account and organization
- create project
- setup password in .env (optional)
- add .env to .gitignore !!!
- it will take few minutes### Prisma
- install prisma vs-code extension
Prisma ORM is a database toolkit that simplifies database access in web applications. It allows developers to interact with databases using a type-safe and auto-generated API, making database operations easier and more secure.
- Prisma server: A standalone infrastructure component sitting on top of your database.
- Prisma client: An auto-generated library that connects to the Prisma server and lets you read, write and stream data in your database. It is used for data access in your applications.```sh
npm install prisma --save-dev
npm install @prisma/client
``````sh
npx prisma init
```### Setup Instance
In development, the command next dev clears Node.js cache on run. This in turn initializes a new PrismaClient instance each time due to hot reloading that creates a connection to the database. This can quickly exhaust the database connections as each PrismaClient instance holds its own connection pool.
(Prisma Instance)[https://www.prisma.io/docs/guides/other/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices#solution]
- create utils/db.ts
```ts
import { PrismaClient } from '@prisma/client';const prismaClientSingleton = () => {
return new PrismaClient();
};type PrismaClientSingleton = ReturnType;
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined;
};const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
```### Connect Supabase with Prisma
[Useful Info](https://supabase.com/partners/integrations/prisma)
- add to .env
```bash
DATABASE_URL=""
DIRECT_URL=""
```- DATABASE_URL : Transaction + Password + "?pgbouncer=true&connection_limit=1"
- DIRECT_URL : Session + Password```prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}generator client {
provider = "prisma-client-js"
}model TestProfile {
id String @id @default(uuid())
name String
}```
- npx prisma migrate dev --name init
- npx prisma db pushnpx prisma migrate dev --name init creates a new migration for your database schema
changes and applies it, while npx prisma db push directly updates the database schema without creating a migration. In the context of databases, a migration is set of operations, that modify the database schema, helping it evolve over time while preserving existing data.```bash
npx prisma db push
``````bash
npx prisma studio
```## Optional - Prisma Crud
[Prisma Docs](https://www.prisma.io/docs/concepts/components/prisma-client/crud)
- Create Single Record
```js
const task = await prisma.task.create({
data: {
content: 'some task',
},
});
```- Get All Records
```js
const tasks = await prisma.task.findMany();
```- Get record by ID or unique identifier
```js
// By unique identifier
const user = await prisma.user.findUnique({
where: {
email: '[email protected]',
},
});// By ID
const task = await prisma.task.findUnique({
where: {
id: id,
},
});
```- Update Record
```js
const updateTask = await prisma.task.update({
where: {
id: id,
},
data: {
content: 'updated task',
},
});
```- Update or create records
```js
const upsertTask = await prisma.task.upsert({
where: {
id: id,
},
update: {
content: 'some value',
},
create: {
content: 'some value',
},
});
```- Delete a single record
```js
const deleteTask = await prisma.task.delete({
where: {
id: id,
},
});
```### Profile Model
```prisma
model Profile {
id String @id @default(uuid())
clerkId String @unique
firstName String
lastName String
username String
email String
profileImage String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt}
``````bash
npx prisma db push
``````bash
npx prisma studio
```### CreateProfile Action - Complete
[Clerk User Metadata](https://clerk.com/docs/users/metadata)
```ts
import db from './db';
import { auth, clerkClient, currentUser } from '@clerk/nextjs/server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';export const createProfileAction = async (
prevState: any,
formData: FormData
) => {
try {
const user = await currentUser();
if (!user) throw new Error('Please login to create a profile');const rawData = Object.fromEntries(formData);
const validatedFields = profileSchema.parse(rawData);await db.profile.create({
data: {
clerkId: user.id,
email: user.emailAddresses[0].emailAddress,
profileImage: user.imageUrl ?? '',
...validatedFields,
},
});
await clerkClient.users.updateUserMetadata(user.id, {
privateMetadata: {
hasProfile: true,
},
});
} catch (error) {
return {
message: error instanceof Error ? error.message : 'An error occurred',
};
}
redirect('/');
};
```### FetchProfileImage
actions.ts
```ts
export const fetchProfileImage = async () => {
const user = await currentUser();
if (!user) return null;const profile = await db.profile.findUnique({
where: {
clerkId: user.id,
},
select: {
profileImage: true,
},
});
return profile?.profileImage;
};
```- components/navbar/UserIcon.tsx
```tsx
import { LuUser2 } from 'react-icons/lu';
import { fetchProfileImage } from '@/utils/actions';async function UserIcon() {
const profileImage = await fetchProfileImage();if (profileImage)
return (
);
return ;
}
export default UserIcon;
```### Modify Create Profile
```tsx
import { currentUser } from '@clerk/nextjs/server';import { redirect } from 'next/navigation';
async function CreateProfile() {
const user = await currentUser();
if (user?.privateMetadata?.hasProfile) redirect('/');
....
}
```### Update Profile
actions.ts
```ts
const getAuthUser = async () => {
const user = await currentUser();
if (!user) {
throw new Error('You must be logged in to access this route');
}
if (!user.privateMetadata.hasProfile) redirect('/profile/create');
return user;
};
``````ts
export const fetchProfile = async () => {
const user = await getAuthUser();const profile = await db.profile.findUnique({
where: {
clerkId: user.id,
},
});
if (!profile) return redirect('/profile/create');
return profile;
};
``````ts
export const updateProfileAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
return { message: 'update profile action' };
};
```app/profile/page.tsx
```tsx
import FormContainer from '@/components/form/FormContainer';
import { updateProfileAction, fetchProfile } from '@/utils/actions';
import FormInput from '@/components/form/FormInput';
import { SubmitButton } from '@/components/form/Buttons';async function ProfilePage() {
const profile = await fetchProfile();return (
user profile
{/* image input container */}
);
}
export default ProfilePage;
```actions.ts
```ts
export const updateProfileAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
const user = await getAuthUser();
try {
const rawData = Object.fromEntries(formData);const validatedFields = profileSchema.parse(rawData);
await db.profile.update({
where: {
clerkId: user.id,
},
data: validatedFields,
});
revalidatePath('/profile');
return { message: 'Profile updated successfully' };
} catch (error) {
return {
message: error instanceof Error ? error.message : 'An error occurred',
};
}
};
```### Alternative Error Handling
actions.ts
```ts
const renderError = (error: unknown): { message: string } => {
console.log(error);
return {
message: error instanceof Error ? error.message : 'An error occurred',
};
};
``````ts
export const updateProfileAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
const user = await getAuthUser();
try {
const rawData = Object.fromEntries(formData);const validatedFields = profileSchema.safeParse(rawData);
if (!validatedFields.success) {
const errors = validatedFields.error.errors.map((error) => error.message);
throw new Error(errors.join(','));
}await db.profile.update({
where: {
clerkId: user.id,
},
data: validatedFields.data,
});
revalidatePath('/profile');
return { message: 'Profile updated successfully' };
} catch (error) {
return renderError(error);
}
};
```### ValidateWithZodSchema
schemas.ts
```ts
export function validateWithZodSchema(
schema: ZodSchema,
data: unknown
): T {
const result = schema.safeParse(data);
if (!result.success) {
const errors = result.error.errors.map((error) => error.message);throw new Error(errors.join(', '));
}
return result.data;
}
```actions.ts
```ts
// createProfileActionconst validatedFields = validateWithZodSchema(profileSchema, rawData);
// updateProfileAction
const validatedFields = validateWithZodSchema(profileSchema, rawData);await db.profile.update({
where: {
clerkId: user.id,
},
data: validatedFields,
});
```### ImageInput
components/form/ImageInput.tsx
```tsx
import { Label } from '../ui/label';
import { Input } from '../ui/input';function ImageInput() {
const name = 'image';
return (
Image
);
}
export default ImageInput;
```### SubmitButton
```tsx
type btnSize = 'default' | 'lg' | 'sm';type SubmitButtonProps = {
className?: string;
text?: string;
size?: btnSize;
};export function SubmitButton({
className = '',
text = 'submit',
size = 'lg',
}: SubmitButtonProps) {
const { pending } = useFormStatus();
return (
{pending ? (
<>
Please wait...
>
) : (
text
)}
);
}
```### ImageInputContainer
components/form/ImageInputContainer.tsx
```tsx
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { Button } from '../ui/button';
import FormContainer from './FormContainer';
import ImageInput from './ImageInput';
import { SubmitButton } from './Buttons';
import { type actionFunction } from '@/utils/types';
import { LuUser2 } from 'react-icons/lu';type ImageInputContainerProps = {
image: string;
name: string;
action: actionFunction;
text: string;
children?: React.ReactNode;
};function ImageInputContainer(props: ImageInputContainerProps) {
const { image, name, action, text } = props;
const [isUpdateFormVisible, setUpdateFormVisible] = useState(false);const userIcon = (
);
return (
{image ? (
) : (
userIcon
)}setUpdateFormVisible((prev) => !prev)}
>
{text}
{isUpdateFormVisible && (
{props.children}
)}
);
}
export default ImageInputContainer;
```### updateProfileImageAction
actions.ts
```ts
export const updateProfileImageAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
return { message: 'Profile image updated successfully' };
};
```### Profile Page
```tsx
import {
updateProfileAction,
fetchProfile,
updateProfileImageAction,
} from '@/utils/actions';import ImageInputContainer from '@/components/form/ImageInputContainer';
/* image input container */
;
```### Remote Patterns
```mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'img.clerk.com',
},
],
},
};export default nextConfig;
```### imageSchema
schemas.ts
```ts
export const imageSchema = z.object({
image: validateFile(),
});function validateFile() {
const maxUploadSize = 1024 * 1024;
const acceptedFileTypes = ['image/'];
return z
.instanceof(File)
.refine((file) => {
return !file || file.size <= maxUploadSize;
}, `File size must be less than 1 MB`)
.refine((file) => {
return (
!file || acceptedFileTypes.some((type) => file.type.startsWith(type))
);
}, 'File must be an image');
}
```The .refine() method in Zod is used to add custom validation to a Zod schema. It takes two arguments:
A function that takes a value and returns a boolean. This function is the validation rule. If it returns true, the validation passes. If it returns false, the validation fails.
A string that is the error message to be returned when the validation fails.### updateProfileImageAction
```ts
export const updateProfileImageAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
const user = await getAuthUser();
try {
const image = formData.get('image') as File;
const validatedFields = validateWithZodSchema(imageSchema, { image });return { message: 'Profile image updated successfully' };
} catch (error) {
return renderError(error);
}
};
```### Create Bucket, Setup Policy and API Keys
```env
SUPABASE_URL=
SUPABASE_KEY=
```### Setup Supabase
```sh
npm install @supabase/supabase-js
```utils/supabase.ts
```ts
import { createClient } from '@supabase/supabase-js';const bucket = 'home-away-draft';
// Create a single supabase client for interacting with your database
export const supabase = createClient(
process.env.SUPABASE_URL as string,
process.env.SUPABASE_KEY as string
);export const uploadImage = async (image: File) => {
const timestamp = Date.now();
// const newName = `/users/${timestamp}-${image.name}`;
const newName = `${timestamp}-${image.name}`;const { data, error } = await supabase.storage
.from(bucket)
.upload(newName, image, {
cacheControl: '3600',
});
if (!data) throw new Error('Image upload failed');
return supabase.storage.from(bucket).getPublicUrl(newName).data.publicUrl;
};
```### updateProfileImageAction
```ts
export const updateProfileImageAction = async (
prevState: any,
formData: FormData
) => {
const user = await getAuthUser();
try {
const image = formData.get('image') as File;
const validatedFields = validateWithZodSchema(imageSchema, { image });
const fullPath = await uploadImage(validatedFields.image);await db.profile.update({
where: {
clerkId: user.id,
},
data: {
profileImage: fullPath,
},
});
revalidatePath('/profile');
return { message: 'Profile image updated successfully' };
} catch (error) {
return renderError(error);
}
};
```### Remote Patterns
```mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'img.clerk.com',
},
{
protocol: 'https',
hostname: 'jxdujzgweuaphpgoowhu.supabase.co',
},
],
},
};export default nextConfig;
```### Property Model
```prisma
model Profile {
properties Property[]
}model Property {
id String @id @default(uuid())
name String
tagline String
category String
image String
country String
description String
price Int
guests Int
bedrooms Int
beds Int
baths Int
amenities String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profile Profile @relation(fields: [profileId], references: [clerkId], onDelete: Cascade)
profileId String
}
```### Property Schema
- yes, no image 😜
schemas.ts
```ts
export const propertySchema = z.object({
name: z
.string()
.min(2, {
message: 'name must be at least 2 characters.',
})
.max(100, {
message: 'name must be less than 100 characters.',
}),
tagline: z
.string()
.min(2, {
message: 'tagline must be at least 2 characters.',
})
.max(100, {
message: 'tagline must be less than 100 characters.',
}),
price: z.coerce.number().int().min(0, {
message: 'price must be a positive number.',
}),
category: z.string(),
description: z.string().refine(
(description) => {
const wordCount = description.split(' ').length;
return wordCount >= 10 && wordCount <= 1000;
},
{
message: 'description must be between 10 and 1000 words.',
}
),
country: z.string(),
guests: z.coerce.number().int().min(0, {
message: 'guest amount must be a positive number.',
}),
bedrooms: z.coerce.number().int().min(0, {
message: 'bedrooms amount must be a positive number.',
}),
beds: z.coerce.number().int().min(0, {
message: 'beds amount must be a positive number.',
}),
baths: z.coerce.number().int().min(0, {
message: 'bahts amount must be a positive number.',
}),
amenities: z.string(),
});
```### createPropertyAction
actions.ts
```ts
export const createPropertyAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
const user = await getAuthUser();
try {
const rawData = Object.fromEntries(formData);
const validatedFields = validateWithZodSchema(propertySchema, rawData);
} catch (error) {
return renderError(error);
}
redirect('/');
};
```### Create Rental Page
- app/rentals/create/page.tsx
```tsx
import FormInput from '@/components/form/FormInput';
import FormContainer from '@/components/form/FormContainer';
import { createPropertyAction } from '@/utils/actions';
import { SubmitButton } from '@/components/form/Buttons';function CreateProperty() {
return (
create property
General Info
{/* price */}
{/* categories */}
{/* text area / description */}
);
}
export default CreateProperty;
```### Price Input
- components/form/PriceInput.tsx
```ts
import { Label } from '../ui/label';
import { Input } from '../ui/input';
import { Prisma } from '@prisma/client';const name = Prisma.PropertyScalarFieldEnum.price;
// const name = 'price';
type FormInputNumberProps = {
defaultValue?: number;
};function PriceInput({ defaultValue }: FormInputNumberProps) {
return (
Price ($)
);
}
export default PriceInput;
``````tsx
/* price */```
### Categories Data
- utils/categories.ts
```ts
import { IconType } from 'react-icons';
import { MdCabin } from 'react-icons/md';import { TbCaravan, TbTent, TbBuildingCottage } from 'react-icons/tb';
import { GiWoodCabin, GiMushroomHouse } from 'react-icons/gi';
import { PiWarehouse, PiLighthouse, PiVan } from 'react-icons/pi';import { GoContainer } from 'react-icons/go';
type Category = {
label: CategoryLabel;
icon: IconType;
};export type CategoryLabel =
| 'cabin'
| 'tent'
| 'airstream'
| 'cottage'
| 'container'
| 'caravan'
| 'tiny'
| 'magic'
| 'warehouse'
| 'lodge';export const categories: Category[] = [
{
label: 'cabin',
icon: MdCabin,
},
{
label: 'airstream',
icon: PiVan,
},
{
label: 'tent',
icon: TbTent,
},
{
label: 'warehouse',
icon: PiWarehouse,
},
{
label: 'cottage',
icon: TbBuildingCottage,
},
{
label: 'magic',
icon: GiMushroomHouse,
},
{
label: 'container',
icon: GoContainer,
},
{
label: 'caravan',
icon: TbCaravan,
},{
label: 'tiny',
icon: PiLighthouse,
},
{
label: 'lodge',
icon: GiWoodCabin,
},
];
```### Categories Input
```tsx
import { Label } from '@/components/ui/label';
import { categories } from '@/utils/categories';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';const name = 'category';
function CategoriesInput({ defaultValue }: { defaultValue?: string }) {
return (
Categories
{categories.map((item) => {
return (
{item.label}
);
})}
);
}
export default CategoriesInput;
``````tsx
/* categories */```
### TextArea Input
- components/form/TextAreaInput.tsx
```tsx
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';type TextAreaInputProps = {
name: string;
labelText?: string;
defaultValue?: string;
};function TextAreaInput({ name, labelText, defaultValue }: TextAreaInputProps) {
return (
{labelText || name}
);
}const tempDefaultDescription =
'Glamping Tuscan Style in an Aframe Cabin Tent, nestled in a beautiful olive orchard. AC, heat, Queen Bed, TV, Wi-Fi and an amazing view. Close to Weeki Wachee River State Park, mermaids, manatees, Chassahwitzka River and on the SC Bike Path. Kayaks available for rivers. Bathhouse, fire pit, Kitchenette, fresh eggs. Relax & enjoy fresh country air. No pets please. Ducks, hens and roosters roam the grounds. We have a Pot Cake Rescue from Bimini, Retriever and Pom dog. The space is inspiring and relaxing. Enjoy the beauty of the orchard. Spring trees are in blossom and harvested in Fall. We have a farm store where we sell our farm to table products';
export default TextAreaInput;
``````tsx
/* text area / description */```
### Countries Input
```sh
npm i world-countries
```- utils/countries.ts
```ts
import countries from 'world-countries';export const formattedCountries = countries.map((item) => ({
code: item.cca2,
name: item.name.common,
flag: item.flag,
location: item.latlng,
region: item.region,
}));
export const findCountryByCode = (code: string) =>
formattedCountries.find((item) => item.code === code);
```- components/form/CountriesInput.tsx
```tsx
import { Label } from '@/components/ui/label';
import { formattedCountries } from '@/utils/countries';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';const name = 'country';
function CountriesInput({ defaultValue }: { defaultValue?: string }) {
return (
country
{formattedCountries.map((item) => {
return (
{item.flag} {item.name}
);
})}
);
}
export default CountriesInput;
``````tsx
```### Accommodation / Counter Input
- components/form/CounterInput.tsx
```tsx
'use client';
import { Card, CardHeader } from '@/components/ui/card';
import { LuMinus, LuPlus } from 'react-icons/lu';import { Button } from '../ui/button';
import { useState } from 'react';function CounterInput({
detail,
defaultValue,
}: {
detail: string;
defaultValue?: number;
}) {
const [count, setCount] = useState(defaultValue || 0);
const increaseCount = () => {
setCount((prevCount) => prevCount + 1);
};
const decreaseCount = () => {
setCount((prevCount) => {
if (prevCount > 0) {
return prevCount - 1;
}
return prevCount;
});
};
return (
{detail}
Specify the number of {detail}
{count}
);
}export default CounterInput;
``````tsx
return (
<>
Accommodation Details
>
);
```### Amenities
- utils/amenities.ts
```ts
import { IconType } from 'react-icons';
export type Amenity = {
name: string;
icon: IconType;
selected: boolean;
};
import {
FiCloud,
FiTruck,
FiZap,
FiWind,
FiSun,
FiCoffee,
FiFeather,
FiAirplay,
FiTrello,
FiBox,
FiAnchor,
FiDroplet,
FiMapPin,
FiSunrise,
FiSunset,
FiMusic,
FiHeadphones,
FiRadio,
FiFilm,
FiTv,
} from 'react-icons/fi';export const amenities: Amenity[] = [
{ name: 'unlimited cloud storage', icon: FiCloud, selected: false },
{ name: 'VIP parking for squirrels', icon: FiTruck, selected: false },
{ name: 'self-lighting fire pit', icon: FiZap, selected: false },
{
name: 'bbq grill with a masterchef diploma',
icon: FiWind,
selected: false,
},
{ name: 'outdoor furniture (tree stumps)', icon: FiSun, selected: false },
{ name: 'private bathroom (bushes nearby)', icon: FiCoffee, selected: false },
{ name: 'hot shower (sun required)', icon: FiFeather, selected: false },
{ name: 'kitchenette (aka fire pit)', icon: FiAirplay, selected: false },
{ name: 'natural heating (bring a coat)', icon: FiTrello, selected: false },
{
name: 'air conditioning (breeze from the west)',
icon: FiBox,
selected: false,
},
{ name: 'bed linens (leaves)', icon: FiAnchor, selected: false },
{ name: 'towels (more leaves)', icon: FiDroplet, selected: false },
{
name: 'picnic table (yet another tree stump)',
icon: FiMapPin,
selected: false,
},
{ name: 'hammock (two trees and a rope)', icon: FiSunrise, selected: false },
{ name: 'solar power (daylight)', icon: FiSunset, selected: false },
{ name: 'water supply (river a mile away)', icon: FiMusic, selected: false },
{
name: 'cooking utensils (sticks and stones)',
icon: FiHeadphones,
selected: false,
},
{ name: 'cool box (hole in the ground)', icon: FiRadio, selected: false },
{ name: 'lanterns (fireflies)', icon: FiFilm, selected: false },
{ name: 'first aid kit (hope and prayers)', icon: FiTv, selected: false },
];export const conservativeAmenities: Amenity[] = [
{ name: 'cloud storage', icon: FiCloud, selected: false },
{ name: 'parking', icon: FiTruck, selected: false },
{ name: 'fire pit', icon: FiZap, selected: false },
{ name: 'bbq grill', icon: FiWind, selected: false },
{ name: 'outdoor furniture', icon: FiSun, selected: false },
{ name: 'private bathroom', icon: FiCoffee, selected: false },
{ name: 'hot shower', icon: FiFeather, selected: false },
{ name: 'kitchenette', icon: FiAirplay, selected: false },
{ name: 'heating', icon: FiTrello, selected: false },
{ name: 'air conditioning', icon: FiBox, selected: false },
{ name: 'bed linens', icon: FiAnchor, selected: false },
{ name: 'towels', icon: FiDroplet, selected: false },
{ name: 'picnic table', icon: FiMapPin, selected: false },
{ name: 'hammock', icon: FiSunrise, selected: false },
{ name: 'solar power', icon: FiSunset, selected: false },
{ name: 'water supply', icon: FiMusic, selected: false },
{ name: 'cooking utensils', icon: FiHeadphones, selected: false },
{ name: 'cool box', icon: FiRadio, selected: false },
{ name: 'lanterns', icon: FiFilm, selected: false },
{ name: 'first aid kit', icon: FiTv, selected: false },
];
```- components/form/AmenitiesInput.tsx
```tsx
'use client';
import { useState } from 'react';
import { amenities, Amenity } from '@/utils/amenities';
import { Checkbox } from '@/components/ui/checkbox';function AmenitiesInput({ defaultValue }: { defaultValue?: Amenity[] }) {
const [selectedAmenities, setSelectedAmenities] = useState(
defaultValue || amenities
);const handleChange = (amenity: Amenity) => {
setSelectedAmenities((prev) => {
return prev.map((a) => {
if (a.name === amenity.name) {
return { ...a, selected: !a.selected };
}
return a;
});
});
};return (
{selectedAmenities.map((amenity) => (
handleChange(amenity)}
/>
{amenity.name}
))}
);
}
export default AmenitiesInput;
``````tsx
return (
<>
Amenities
>
);
```### createRentalAction
```tsx
export const createPropertyAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
const user = await getAuthUser();
try {
const rawData = Object.fromEntries(formData);
const file = formData.get('image') as File;const validatedFields = validateWithZodSchema(propertySchema, rawData);
const validatedFile = validateWithZodSchema(imageSchema, { image: file });
const fullPath = await uploadImage(validatedFile.image);await db.property.create({
data: {
...validatedFields,
image: fullPath,
profileId: user.id,
},
});
} catch (error) {
return renderError(error);
}
redirect('/');
};
```### fetchProperties
utils/types.ts
```ts
export type PropertyCardProps = {
image: string;
id: string;
name: string;
tagline: string;
country: string;
price: number;
};
```actions.ts
```ts
export const fetchProperties = async ({
search = '',
category,
}: {
search?: string;
category?: string;
}) => {
const properties = await db.property.findMany({
where: {
category,
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ tagline: { contains: search, mode: 'insensitive' } },
],
},
select: {
id: true,
name: true,
tagline: true,
country: true,
image: true,
price: true,
},
});
return properties;
};
```### Home Page
- create in components/home
- CategoriesList.tsx
- EmptyList.tsx
- PropertiesContainer.tsx
- PropertiesList.tsx```tsx
import CategoriesList from '@/components/home/CategoriesList';
import PropertiesContainer from '@/components/home/PropertiesContainer';function HomePage() {
return (
);
}
export default HomePage;
```### Search Params
```tsx
import CategoriesList from '@/components/home/CategoriesList';
import PropertiesContainer from '@/components/home/PropertiesContainer';function HomePage({
searchParams,
}: {
searchParams: { category?: string; search?: string };
}) {
// console.log(searchParams);return (
);
}
export default HomePage;
```### CategoriesList
```tsx
import { categories } from '@/utils/categories';
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
import Link from 'next/link';function CategoriesList({
category,
search,
}: {
category?: string;
search?: string;
}) {
const searchTerm = search ? `&search=${search}` : '';
return (
{categories.map((item) => {
const isActive = item.label === category;
return (
{item.label}
);
})}
);
}
export default CategoriesList;
```### EmptyList
```tsx
import { Button } from '../ui/button';
import Link from 'next/link';function EmptyList({
heading = 'No items in the list.',
message = 'Keep exploring our properties.',
btnText = 'back home',
}: {
heading?: string;
message?: string;
btnText?: string;
}) {
return (
{heading}
{message}
{btnText}
);
}
export default EmptyList;
```### PropertiesContainer
```tsx
import { fetchProperties } from '@/utils/actions';
import PropertiesList from './PropertiesList';
import EmptyList from './EmptyList';
import type { PropertyCardProps } from '@/utils/types';async function PropertiesContainer({
category,
search,
}: {
category?: string;
search?: string;
}) {
const properties: PropertyCardProps[] = await fetchProperties({
category,
search,
});if (properties.length === 0) {
return (
);
}return ;
}
export default PropertiesContainer;
```### Card Components
- components/card
- CountryFlagAndName.tsx
- FavoriteToggleButton.tsx
- FavoriteToggleForm.tsx
- LoadingCards.tsx
- PropertyCard.tsx
- PropertyRating.tsx### PropertiesList
```tsx
import PropertyCard from '../card/PropertyCard';
import type { PropertyCardProps } from '@/utils/types';function PropertiesList({ properties }: { properties: PropertyCardProps[] }) {
return (
{properties.map((property) => {
return ;
})}
);
}
export default PropertiesList;
```### formatCurrency
- utils/format.ts
```ts
export const formatCurrency = (amount: number | null) => {
const value = amount || 0;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
```### PropertyCard
```tsx
import Image from 'next/image';
import Link from 'next/link';
import CountryFlagAndName from './CountryFlagAndName';
import PropertyRating from './PropertyRating';
import FavoriteToggleButton from './FavoriteToggleButton';
import { PropertyCardProps } from '@/utils/types';
import { formatCurrency } from '@/utils/format';function PropertyCard({ property }: { property: PropertyCardProps }) {
const { name, image, price } = property;
const { country, id: propertyId, tagline } = property;return (
{name.substring(0, 30)}
{/* property rating */}
{tagline.substring(0, 40)}
{formatCurrency(price)}
night
{/* country and flag */}
{/* favorite toggle button */}
);
}
export default PropertyCard;
```### Property Rating
```tsx
import { FaStar } from 'react-icons/fa';async function PropertyRating({
propertyId,
inPage,
}: {
propertyId: string;
inPage: boolean;
}) {
// temp
const rating = 4.7;
const count = 100;const className = `flex gap-1 items-center ${inPage ? 'text-md' : 'text-xs'}`;
const countText = count > 1 ? 'reviews' : 'review';
const countValue = `(${count}) ${inPage ? countText : ''}`;
return (
{rating} {countValue}
);
}export default PropertyRating;
``````tsx
```
### FavoriteToggleButton
```tsx
import { FaHeart } from 'react-icons/fa';
import { Button } from '@/components/ui/button';
function FavoriteToggleButton({ propertyId }: { propertyId: string }) {
return (
);
}
export default FavoriteToggleButton;
``````tsx
```### CountryFlagAndName
```tsx
import { findCountryByCode } from '@/utils/countries';function CountryFlagAndName({ countryCode }: { countryCode: string }) {
const validCountry = findCountryByCode(countryCode);
const countryName =
validCountry!.name.length > 20
? `${validCountry!.name.substring(0, 20)}...`
: validCountry!.name;
return (
{validCountry?.flag} {countryName}
);
}
export default CountryFlagAndName;
``````tsx
```
### Suspense
- app/loading.tsx - always an option
components/card/LoadingCards.tsx
```tsx
import { Skeleton } from '@/components/ui/skeleton';function LoadingCards() {
return (
);
}
export default LoadingCards;export function SkeletonCard() {
return (
);
}
```app/page.tsx
- navigate to a different page, refresh and then navigate back to home page
- make sure you fetch in component not page```tsx
import CategoriesList from '@/components/home/CategoriesList';
import PropertiesContainer from '@/components/home/PropertiesContainer';
import LoadingCards from '@/components/card/LoadingCards';
import { Suspense } from 'react';
function HomePage({
searchParams,
}: {
searchParams: { category?: string; search?: string };
}) {
return (
}>
);
}
export default HomePage;
```### SearchInput
```sh
npm i use-debounce
```components/navbar/NavSearch.tsx
```tsx
'use client';
import { Input } from '../ui/input';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
import { useState, useEffect } from 'react';function NavSearch() {
const searchParams = useSearchParams();const pathname = usePathname();
const { replace } = useRouter();
const [search, setSearch] = useState(
searchParams.get('search')?.toString() || ''
);
const handleSearch = useDebouncedCallback((value: string) => {
const params = new URLSearchParams(searchParams);
if (value) {
params.set('search', value);
} else {
params.delete('search');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
useEffect(() => {
if (!searchParams.get('search')) {
setSearch('');
}
}, [searchParams.get('search')]);
return (
{
setSearch(e.target.value);
handleSearch(e.target.value);
}}
value={search}
/>
);
}
export default NavSearch;
```### Favorites Model
```prisma
model Profile {
favorites Favorite[]
}model Property {
favorites Favorite[]
}model Favorite {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAtprofile Profile @relation(fields: [profileId], references: [clerkId], onDelete: Cascade)
profileId Stringproperty Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
propertyId String}
``````sh
npx prisma db push
```### CardSignInButton
components/form/Buttons.tsx
```tsx
import { SignInButton } from '@clerk/nextjs';
import { FaRegHeart, FaHeart } from 'react-icons/fa';export const CardSignInButton = () => {
return (
);
};
```components/card/FavoriteToggleButton.tsx
```tsx
import { FaHeart } from 'react-icons/fa';
import { Button } from '@/components/ui/button';
import { auth } from '@clerk/nextjs/server';
import { CardSignInButton } from '../form/Buttons';
function FavoriteToggleButton({ propertyId }: { propertyId: string }) {
const { userId } = auth();
if (!userId) return ;
return (
);
}
export default FavoriteToggleButton;
```### fetchFavorite
actions.ts
```ts
export const fetchFavoriteId = async ({
propertyId,
}: {
propertyId: string;
}) => {
const user = await getAuthUser();
const favorite = await db.favorite.findFirst({
where: {
propertyId,
profileId: user.id,
},
select: {
id: true,
},
});
return favorite?.id || null;
};
export const toggleFavoriteAction = async () => {
return { message: 'toggle favorite' };
};
```### FavoriteToggleButton - Complete
```tsx
import { auth } from '@clerk/nextjs/server';
import { CardSignInButton } from '../form/Buttons';
import { fetchFavoriteId } from '@/utils/actions';
import FavoriteToggleForm from './FavoriteToggleForm';
async function FavoriteToggleButton({ propertyId }: { propertyId: string }) {
const { userId } = auth();
if (!userId) return ;
const favoriteId = await fetchFavoriteId({ propertyId });return ;
}
export default FavoriteToggleButton;
```### CardSubmitButton
components/form/Buttons.tsx
```tsx
export const CardSubmitButton = ({ isFavorite }: { isFavorite: boolean }) => {
const { pending } = useFormStatus();
return (
{pending ? (
) : isFavorite ? (
) : (
)}
);
};
```### FavoriteToggleForm
```tsx
'use client';import { usePathname } from 'next/navigation';
import FormContainer from '../form/FormContainer';
import { toggleFavoriteAction } from '@/utils/actions';
import { CardSubmitButton } from '../form/Buttons';type FavoriteToggleFormProps = {
propertyId: string;
favoriteId: string | null;
};function FavoriteToggleForm({
propertyId,
favoriteId,
}: FavoriteToggleFormProps) {
const pathname = usePathname();
const toggleAction = toggleFavoriteAction.bind(null, {
propertyId,
favoriteId,
pathname,
});
return (
);
}
export default FavoriteToggleForm;
```### toggleFavoriteAction
actions.ts
```ts
export const toggleFavoriteAction = async (prevState: {
propertyId: string;
favoriteId: string | null;
pathname: string;
}) => {
const user = await getAuthUser();
const { propertyId, favoriteId, pathname } = prevState;
try {
if (favoriteId) {
await db.favorite.delete({
where: {
id: favoriteId,
},
});
} else {
await db.favorite.create({
data: {
propertyId,
profileId: user.id,
},
});
}
revalidatePath(pathname);
return { message: favoriteId ? 'Removed from Faves' : 'Added to Faves' };
} catch (error) {
return renderError(error);
}
};
```### fetchFavorites
actions.ts
```ts
export const fetchFavorites = async () => {
const user = await getAuthUser();
const favorites = await db.favorite.findMany({
where: {
profileId: user.id,
},
select: {
property: {
select: {
id: true,
name: true,
tagline: true,
price: true,
country: true,
image: true,
},
},
},
});
return favorites.map((favorite) => favorite.property);
};
```### Favorites Page
- favorites/loading.tsx
```tsx
'use client';
import LoadingCards from '@/components/card/LoadingCards';function loading() {
return ;
}
export default loading;
```- favorites/page.tsx
```tsx
import EmptyList from '@/components/home/EmptyList';
import PropertiesList from '@/components/home/PropertiesList';
import { fetchFavorites } from '@/utils/actions';async function FavoritesPage() {
const favorites = await fetchFavorites();if (favorites.length === 0) {
return ;
}return ;
}
export default FavoritesPage;
```### fetchPropertyDetails
- utils/actions.ts
```ts
export const fetchPropertyDetails = (id: string) => {
return db.property.findUnique({
where: {
id,
},
include: {
profile: true,
},
});
};
```- properties/[id]/loading.tsx
```ts
'use client';import { Skeleton } from '@/components/ui/skeleton';
function loading() {
return ;
}export default loading;
```- properties/[id]/page.tsx
```tsx
import { fetchPropertyDetails } from '@/utils/actions';
import { redirect } from 'next/navigation';async function PropertyDetailsPage({ params }: { params: { id: string } }) {
const property = await fetchPropertyDetails(params.id);
if (!property) redirect('/');
const { baths, bedrooms, beds, guests } = property;
const details = { baths, bedrooms, beds, guests };
returnPropertyDetailsPage;
}
export default PropertyDetailsPage;
```### BreadCrumbs
- components/properties/BreadCrumbs.tsx
```tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';function BreadCrumbs({ name }: { name: string }) {
return (
Home
{name}
);
}
export default BreadCrumbs;
```- properties/[id]/page.tsx
```tsx
return (
{property.tagline}
{/* share button */}
);
```### ShareButton
[React Share](https://www.npmjs.com/package/react-share)
```sh
npm i react-share
```- components/properties/ShareButton.tsx
```tsx
'use client';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '../ui/button';
import { LuShare2 } from 'react-icons/lu';import {
TwitterShareButton,
EmailShareButton,
LinkedinShareButton,
TwitterIcon,
EmailIcon,
LinkedinIcon,
} from 'react-share';function ShareButton({
propertyId,
name,
}: {
propertyId: string;
name: string;
}) {
const url = process.env.NEXT_PUBLIC_WEBSITE_URL;
const shareLink = `${url}/properties/${propertyId}`;return (
);
}
export default ShareButton;
```- properties/[id]/page.tsx
```tsx
return (
);
```### ImageContainer
- components/properties/ImageContainer.tsx
```tsx
import Image from 'next/image';function ImageContainer({
mainImage,
name,
}: {
mainImage: string;
name: string;
}) {
return (
);
}
export default ImageContainer;
```- properties/[id]/page.tsx
```tsx
```
### Col Layout
- properties/[id]/page.tsx
```tsx
return (
{property.name}
{/* calendar */}
);
```### Calendar - Initial Setup
- components/properties/booking/BookingCalendar.tsx
```tsx
'use client';
import { useState } from 'react';
import { Calendar } from '@/components/ui/calendar';
import { DateRange } from 'react-day-picker';export default function App() {
const currentDate = new Date();
const defaultSelected: DateRange = {
from: undefined,
to: undefined,
};
const [range, setRange] = useState(defaultSelected);return (
);
}
```- properties/[id]/page.tsx
```tsx
{/* calendar */}
```### PropertyDetails
- utils/format.ts
```ts
export function formatQuantity(quantity: number, noun: string): string {
return quantity === 1 ? `${quantity} ${noun}` : `${quantity} ${noun}s`;
}
```- components/properties/PropertyDetails.tsx
```tsx
import { formatQuantity } from '@/utils/format';type PropertyDetailsProps = {
details: {
bedrooms: number;
baths: number;
guests: number;
beds: number;
};
};function PropertyDetails({
details: { bedrooms, baths, guests, beds },
}: PropertyDetailsProps) {
return (
{formatQuantity(bedrooms, 'bedroom')} ·
{formatQuantity(baths, 'bath')} ·
{formatQuantity(guests, 'guest')} ·
{formatQuantity(beds, 'bed')}
);
}
export default PropertyDetails;
```- properties/[id]/page.tsx
```tsx
```
### UserInfo
- components/properties/UserInfo.tsx
```tsx
import Image from 'next/image';type UserInfoProps = {
profile: {
profileImage: string;
firstName: string;
};
};function UserInfo({ profile: { profileImage, firstName } }: UserInfoProps) {
return (
Hosted by
{firstName}
Superhost · 2 years hosting
);
}
export default UserInfo;
```- properties/[id]/page.tsx
```tsx
const firstName = property.profile.firstName;
const profileImage = property.profile.profileImage;;
```### Description
- components/properties/Title.tsx
```tsx
function Title({ text }: { text: string }) {
return{text}
;
}
export default Title;
```- components/properties/Description.tsx
```tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import Title from './Title';
const Description = ({ description }: { description: string }) => {
const [isFullDescriptionShown, setIsFullDescriptionShown] = useState(false);
const words = description.split(' ');
const isLongDescription = words.length > 100;const toggleDescription = () => {
setIsFullDescriptionShown(!isFullDescriptionShown);
};const displayedDescription =
isLongDescription && !isFullDescriptionShown
? words.slice(0, 100).join(' ') + '...'
: description;return (
{displayedDescription}
{isLongDescription && (
{isFullDescriptionShown ? 'Show less' : 'Show more'}
)}
);
};export default Description;
```- properties/[id]/page.tsx
```tsx
```
### Amenities
- components/properties/Amenities.tsx
```tsx
import { Amenity } from '@/utils/amenities';
import { LuFolderCheck } from 'react-icons/lu';
import Title from './Title';function Amenities({ amenities }: { amenities: string }) {
const amenitiesList: Amenity[] = JSON.parse(amenities as string);
const noAmenities = amenitiesList.every((amenity) => !amenity.selected);if (noAmenities) {
return null;
}
return (
{amenitiesList.map((amenity) => {
if (!amenity.selected) {
return null;
}
return (
{amenity.name}
);
})}
);
}
export default Amenities;
```- properties/[id]/page.tsx
```tsx
```
### PropertyMap
[React Leaflet](https://react-leaflet.js.org/)
Leaflet makes direct calls to the DOM when it is loaded, therefore React Leaflet is not compatible with server-side rendering.
```sh
npm install react react-dom leaflet react-leaflet
``````sh
npm install -D @types/leaflet
```- components/properties/PropertyMap.tsx
```tsx
'use client';
import { MapContainer, TileLayer, Marker, ZoomControl } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { icon } from 'leaflet';
const iconUrl =
'https://unpkg.com/[email protected]/dist/images/marker-icon-2x.png';
const markerIcon = icon({
iconUrl: iconUrl,
iconSize: [20, 30],
});import { findCountryByCode } from '@/utils/countries';
import CountryFlagAndName from '../card/CountryFlagAndName';
import Title from './Title';function PropertyMap({ countryCode }: { countryCode: string }) {
const defaultLocation = [51.505, -0.09] as [number, number];
const location = findCountryByCode(countryCode)?.location as [number, number];return (
);
}
export default PropertyMap;
```- properties/[id]/page.tsx
```tsx
const DynamicMap = dynamic(
() => import('@/components/properties/PropertyMap'),
{
ssr: false,
loading: () => ,
}
);
return ;
```Lazy Loading: Components wrapped with dynamic are lazy loaded. This means that the component code is not loaded until it is needed. For example, if you have a component that is only visible when a user clicks a button, you could use dynamic to ensure that the code for that component is not loaded until the button is clicked.
Server Side Rendering (SSR) Control: By default, Next.js pre-renders every page. This means that it generates HTML for each page in advance, instead of doing it all on the client-side. However, with dynamic, you can control this behavior. You can choose to disable SSR for specific modules, which can be useful for modules that have client-side dependencies.
### Deploy
```json
"scripts": {
"dev": "next dev",
"build": "npx prisma generate && next build",
"start": "next start",
"lint": "next lint"
},
```- refactor NavSearch Component
### Review Model
```prisma
model Review {
id String @id @default(uuid())
profile Profile @relation(fields: [profileId], references: [clerkId], onDelete: Cascade)
profileId String
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
propertyId String
rating Int
comment String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Property {
reviews Review[]
}
model Profile {
reviews Review[]
}
```DON'T FORGET !!!!
```sh
npx prisma db push
```- restart server
### Reviews Setup
- create components/reviews
- Comment.tsx
- PropertyReviews.tsx
- Rating.tsx
- SubmitReview.tsx
- ReviewCard.tsx- create placeholder functions in actions.ts
```ts
export const createReviewAction = async () => {
return { message: 'create review' };
};export const fetchPropertyReviews = async () => {
return { message: 'fetch reviews' };
};export const fetchPropertyReviewsByUser = async () => {
return { message: 'fetch user reviews' };
};export const deleteReviewAction = async () => {
return { message: 'delete reviews' };
};
```### RatingInput
- components/form/RatingInput.tsx
```tsx
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';const RatingInput = ({
name,
labelText,
}: {
name: string;
labelText?: string;
}) => {
const numbers = Array.from({ length: 5 }, (_, i) => {
const value = i + 1;
return value.toString();
}).reverse();return (
{labelText || name}
{numbers.map((number) => {
return (
{number}
);
})}
);
};export default RatingInput;
```### SubmitReview Component
- app/properties/[id]
```tsx
return (
{/* after two column section */}
;
);
```- components/reviews/SubmitReview.tsx
```tsx
'use client';
import { useState } from 'react';
import { SubmitButton } from '@/components/form/Buttons';
import FormContainer from '@/components/form/FormContainer';
import { Card } from '@/components/ui/card';
import RatingInput from '@/components/form/RatingInput';
import TextAreaInput from '@/components/form/TextAreaInput';
import { Button } from '@/components/ui/button';
import { createReviewAction } from '@/utils/actions';
function SubmitReview({ propertyId }: { propertyId: string }) {
const [isReviewFormVisible, setIsReviewFormVisible] = useState(false);
return (
setIsReviewFormVisible((prev) => !prev)}>
Leave a Review
{isReviewFormVisible && (
)}
);
}export default SubmitReview;
```- optional : set rows prop in TextArea.tsx
### Submit Review
- utils/schemas.ts
```ts
export const createReviewSchema = z.object({
propertyId: z.string(),
rating: z.coerce.number().int().min(1).max(5),
comment: z.string().min(10).max(1000),
});
```- action.ts
```ts
export async function createReviewAction(prevState: any, formData: FormData) {
const user = await getAuthUser();
try {
const rawData = Object.fromEntries(formData);const validatedFields = validateWithZodSchema(createReviewSchema, rawData);
await db.review.create({
data: {
...validatedFields,
profileId: user.id,
},
});
revalidatePath(`/properties/${validatedFields.propertyId}`);
return { message: 'Review submitted successfully' };
} catch (error) {
return renderError(error);
}
}
```### Fetch Property Reviews
- actions.ts
```ts
export async function fetchPropertyReviews(propertyId: string) {
const reviews = await db.review.findMany({
where: {
propertyId,
},
select: {
id: true,
rating: true,
comment: true,
profile: {
select: {
firstName: true,
profileImage: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return reviews;
}
```### Render Reviews
- app/properties/[id]
```tsx
return (
<>
{/* after two column section */}
>
);
```- components/reviews/PropertyReviews.tsx
```tsx
import { fetchPropertyReviews } from '@/utils/actions';
import Title from '@/components/properties/Title';import ReviewCard from './ReviewCard';
async function PropertyReviews({ propertyId }: { propertyId: string }) {
const reviews = await fetchPropertyReviews(propertyId);
if (reviews.length < 1) return null;
return (
{reviews.map((review) => {
const { comment, rating } = review;
const { firstName, profileImage } = review.profile;
const reviewInfo = {
comment,
rating,
name: firstName,
image: profileImage,
};
return ;
})}
);
}
export default PropertyReviews;
```### ReviewCard Component
```tsx
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import Rating from './Rating';
import Comment from './Comment';
type ReviewCardProps = {
reviewInfo: {
comment: string;
rating: number;
name: string;
image: string;
};
children?: React.ReactNode;
};function ReviewCard({ reviewInfo, children }: ReviewCardProps) {
return (
{reviewInfo.name}
{/* delete button later */}
{children}
);
}
export default ReviewCard;
```### Rating
```tsx
import { FaStar, FaRegStar } from 'react-icons/fa';function Rating({ rating }: { rating: number }) {
// rating = 2
// 1 <= 2 true
// 2 <= 2 true
// 3 <= 2 false
// ....
const stars = Array.from({ length: 5 }, (_, i) => i + 1 <= rating);return (
{stars.map((isFilled, i) => {
const className = `w-3 h-3 ${
isFilled ? 'text-primary' : 'text-gray-400'
}`;
return isFilled ? (
) : (
);
})}
);
}export default Rating;
```### Comment
```tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
function Comment({ comment }: { comment: string }) {
const [isExpanded, setIsExpanded] = useState(false);const toggleExpanded = () => {
setIsExpanded(!isExpanded);
};
const longComment = comment.length > 130;
const displayComment =
longComment && !isExpanded ? `${comment.slice(0, 130)}...` : comment;return (
{displayComment}
{longComment && (
{isExpanded ? 'Show Less' : 'Show More'}
)}
);
}export default Comment;
```### Fetch User's Reviews and Delete Review
```ts
export const fetchPropertyReviewsByUser = async () => {
const user = await getAuthUser();
const reviews = await db.review.findMany({
where: {
profileId: user.id,
},
select: {
id: true,
rating: true,
comment: true,
property: {
select: {
name: true,
image: true,
},
},
},
});
return reviews;
};export const deleteReviewAction = async (prevState: { reviewId: string }) => {
const { reviewId } = prevState;
const user = await getAuthUser();try {
await db.review.delete({
where: {
id: reviewId,
profileId: user.id,
},
});revalidatePath('/reviews');
return { message: 'Review deleted successfully' };
} catch (error) {
return renderError(error);
}
};
```### Icon Button
- components/form/Buttons.tsx
```tsx
import { LuTrash2, LuPenSquare } from 'react-icons/lu';type actionType = 'edit' | 'delete';
export const IconButton = ({ actionType }: { actionType: actionType }) => {
const { pending } = useFormStatus();const renderIcon = () => {
switch (actionType) {
case 'edit':
return ;
case 'delete':
return ;
default:
const never: never = actionType;
throw new Error(`Invalid action type: ${never}`);
}
};return (
{pending ? : renderIcon()}
);
};
```### Reviews Page
- app/reviews/page.tsx
```tsx
import EmptyList from '@/components/home/EmptyList';
import {
deleteReviewAction,
fetchPropertyReviewsByUser,
} from '@/utils/actions';
import ReviewCard from '@/components/reviews/ReviewCard';
import Title from '@/components/properties/Title';
import FormContainer from '@/components/form/FormContainer';
import { IconButton } from '@/components/form/Buttons';
async function ReviewsPage() {
const reviews = await fetchPropertyReviewsByUser();
if (reviews.length === 0) return ;return (
<>
{reviews.map((review) => {
const { comment, rating } = review;
const { name, image } = review.property;
const reviewInfo = {
comment,
rating,
name,
image,
};
return (
);
})}
>
);
}const DeleteReview = ({ reviewId }: { reviewId: string }) => {
const deleteReview = deleteReviewAction.bind(null, { reviewId });
return (
);
};export default ReviewsPage;
```- loading.tsx
```tsx
'use client';import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
function loading() {
return (
);
}const ReviewLoadingCard = () => {
return (
);
};export default loading;
```### Allow Review
- actions.ts
```ts
export const findExistingReview = async (
userId: string,
propertyId: string
) => {
return db.review.findFirst({
where: {
profileId: userId,
propertyId: propertyId,
},
});
};
```- app/properties/[id]
```tsx
import { findExistingReview } from '@/utils/actions';
import { auth } from '@clerk/nextjs/server';async function PropertyDetailsPage({ params }: { params: { id: string } }) {
const { userId } = auth();
const isNotOwner = property.profile.clerkId !== userId;
const reviewDoesNotExist =
userId && isNotOwner && !(await findExistingReview(userId, property.id));return <>{reviewDoesNotExist && }>;
}
```Prisma's findUnique and findFirst methods are used to retrieve a single record from the database, but they have some differences in their behavior:
- findUnique: This method is used when you want to retrieve a single record that matches a unique constraint or a primary key. If no record is found, it returns null.
- findFirst: This method is used when you want to retrieve a single record that matches a non-unique constraint. It can also be used with ordering and filtering. If no record is found, it returns null.
In summary, use findUnique when you're sure the field you're querying by is unique, and use findFirst when you're querying by a non-unique field or need more complex queries with ordering and filtering.
```ts
const user = await prisma.user.findUnique({
where: {
email: '[email protected]',
},
});const user = await prisma.user.findFirst({
where: {
email: {
contains: 'prisma.io',
},
},
orderBy: {
name: 'asc',
},
});
```### PropertyRating - Complete
- actions
```ts
export async function fetchPropertyRating(propertyId: string) {
const result = await db.review.groupBy({
by: ['propertyId'],
_avg: {
rating: true,
},
_count: {
rating: true,
},
where: {
propertyId,
},
});// empty array if no reviews
return {
rating: result[0]?._avg.rating?.toFixed(1) ?? 0,
count: result[0]?._count.rating ?? 0,
};
}
```- components/card/PropertyRating.tsx
```tsx
import { fetchPropertyRating } from '@/utils/actions';
import { FaStar } from 'react-icons/fa';async function PropertyRating({
propertyId,
inPage,
}: {
propertyId: string;
inPage: boolean;
}) {
const { rating, count } = await fetchPropertyRating(propertyId);
if (count === 0) return null;
const className = `flex gap-1 items-center ${inPage ? 'text-md' : 'text-xs'}`;
const countText = count === 1 ? 'review' : 'reviews';
const countValue = `(${count}) ${inPage ? countText : ''}`;
return (
{rating} {countValue}
);
}export default PropertyRating;
```### Booking Model
- schema.prisma
```prisma
model Booking {
id String @id @default(uuid())
profile Profile @relation(fields: [profileId], references: [clerkId], onDelete: Cascade)
profileId String
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
propertyId String
orderTotal Int
totalNights Int
checkIn DateTime
checkOut DateTime
paymentStatus Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}model Profile {
bookings Booking[]
}
model Property {
bookings Booking[]
}```
```bash
npx prisma db push
```- restart server !!!
### Fetch Bookings
- actions.ts
```ts
export const fetchPropertyDetails = (id: string) => {
return db.property.findUnique({
where: {
id,
},
include: {
profile: true,
bookings: {
select: {
checkIn: true,
checkOut: true,
},
},
},
});
};
```### Booking Types
- utils/types.ts
```ts
export type DateRangeSelect = {
startDate: Date;
endDate: Date;
key: string;
};export type Booking = {
checkIn: Date;
checkOut: Date;
};
```### Booking Components
- remove @/components/properties/BookingCalendar.tsx
- create @/components/booking
- BookingCalendar.tsx
- BookingContainer.tsx
- BookingForm.tsx
- BookingWrapper.tsx
- ConfirmBooking.tsx### Zustand
[Docs](https://docs.pmnd.rs/zustand/getting-started/introduction)
```sh
npm install zustand
```### Setup Store
- utils/store.ts
```ts
import { create } from 'zustand';
import { Booking } from './types';
import { DateRange } from 'react-day-picker';
// Define the state's shape
type PropertyState = {
propertyId: string;
price: number;
bookings: Booking[];
range: DateRange | undefined;
};// Create the store
export const useProperty = create(() => {
return {
propertyId: '',
price: 0,
bookings: [],
range: undefined,
};
});
```### BookingWrapper
```tsx
'use client';import { useProperty } from '@/utils/store';
import { Booking } from '@/utils/types';
import BookingCalendar from './BookingCalendar';
import BookingContainer from './BookingContainer';
import { useEffect } from 'react';type BookingWrapperProps = {
propertyId: string;
price: number;
bookings: Booking[];
};
export default function BookingWrapper({
propertyId,
price,
bookings,
}: BookingWrapperProps) {
useEffect(() => {
useProperty.setState({
propertyId,
price,
bookings,
});
}, []);
return (
<>
>
);
}
```- properties/[id]/page.tsx
```tsx
const DynamicBookingWrapper = dynamic(
() => import('@/components/booking/BookingWrapper'),
{
ssr: false,
loading: () => ,
}
);return (
{/* calendar */}
);
```### Helper Functions
- utils/calendar.ts
```ts
import { DateRange } from 'react-day-picker';
import { Booking } from '@/utils/types';export const defaultSelected: DateRange = {
from: undefined,
to: undefined,
};export const generateBlockedPeriods = ({
bookings,
today,
}: {
bookings: Booking[];
today: Date;
}) => {
today.setHours(0, 0, 0, 0); // Set the time to 00:00:00.000const disabledDays: DateRange[] = [
...bookings.map((booking) => ({
from: booking.checkIn,
to: booking.checkOut,
})),
{
from: new Date(0), // This is 01 January 1970 00:00:00 UTC.
to: new Date(today.getTime() - 24 * 60 * 60 * 1000), // This is yesterday.
},
];
return disabledDays;
};export const generateDateRange = (range: DateRange | undefined): string[] => {
if (!range || !range.from || !range.to) return [];let currentDate = new Date(range.from);
const endDate = new Date(range.to);
const dateRange: string[] = [];while (currentDate <= endDate) {
const dateString = currentDate.toISOString().split('T')[0];
dateRange.push(dateString);
currentDate.setDate(currentDate.getDate() + 1);
}return dateRange;
};export const generateDisabledDates = (
disabledDays: DateRange[]
): { [key: string]: boolean } => {
if (disabledDays.length === 0) return {};const disabledDates: { [key: string]: boolean } = {};
const today = new Date();
today.setHours(0, 0, 0, 0); // set time to 00:00:00 to compare only the date partdisabledDays.forEach((range) => {
if (!range.from || !range.to) return;let currentDate = new Date(range.from);
const endDate = new Date(range.to);while (currentDate <= endDate) {
if (currentDate < today) {
currentDate.setDate(currentDate.getDate() + 1);
continue;
}
const dateString = currentDate.toISOString().split('T')[0];
disabledDates[dateString] = true;
currentDate.setDate(currentDate.getDate() + 1);
}
});return disabledDates;
};export function calculateDaysBetween({
checkIn,
checkOut,
}: {
checkIn: Date;
checkOut: Date;
}) {
// Calculate the difference in milliseconds
const diffInMs = Math.abs(checkOut.getTime() - checkIn.getTime());// Convert the difference in milliseconds to days
const diffInDays = diffInMs / (1000 * 60 * 60 * 24);return diffInDays;
}
```### BoookingCalendar
```tsx
'use client';
import { Calendar } from '@/components/ui/calendar';
import { useEffect, useState } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { DateRange } from 'react-day-picker';
import { useProperty } from '@/utils/store';import {
generateDisabledDates,
generateDateRange,
defaultSelected,
generateBlockedPeriods,
} from '@/utils/calendar';function BookingCalendar() {
const currentDate = new Date();const [range, setRange] = useState(defaultSelected);
useEffect(() => {
useProperty.setState({ range });
}, [range]);return (
);
}
export default BookingCalendar;
```### BookingContainer
```tsx
'use client';import { useProperty } from '@/utils/store';
import ConfirmBooking from './ConfirmBooking';
import BookingForm from './BookingForm';
function BookingContainer() {
const { range } = useProperty((state) => state);if (!range || !range.from || !range.to) return null;
if (range.to.getTime() === range.from.getTime()) return null;
return (
);
}export default BookingContainer;
```### CalculateTotals
- utils/calculateTotals.ts
```ts
import { calculateDaysBetween } from '@/utils/calendar';type BookingDetails = {
checkIn: Date;
checkOut: Date;
price: number;
};export const calculateTotals = ({
checkIn,
checkOut,
price,
}: BookingDetails) => {
const totalNights = calculateDaysBetween({ checkIn, checkOut });
const subTotal = totalNights * price;
const cleaning = 21;
const service = 40;
const tax = subTotal * 0.1;
const orderTotal = subTotal + cleaning + service + tax;
return { totalNights, subTotal, cleaning, service, tax, orderTotal };
};
```### BookingForm
```tsx
import { calculateTotals } from '@/utils/calculateTotals';
import { Card, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { useProperty } from '@/utils/store';
import { formatCurrency } from '@/utils/format';
function BookingForm() {
const { range, price } = useProperty((state) => state);
const checkIn = range?.from as Date;
const checkOut = range?.to as Date;const { totalNights, subTotal, cleaning, service, tax, orderTotal } =
calculateTotals({
checkIn,
checkOut,
price,
});
return (
Summary
);
}function FormRow({ label, amount }: { label: string; amount: number }) {
return (
{label}
{formatCurrency(amount)}
);
}export default BookingForm;
```### ConfirmBooking
- action.ts
```ts
export const createBookingAction = async () => {
return { message: 'create booking' };
};
``````tsx
'use client';
import { SignInButton, useAuth } from '@clerk/nextjs';
import { Button } from '@/components/ui/button';
import { useProperty } from '@/utils/store';
import FormContainer from '@/components/form/FormContainer';
import { SubmitButton } from '@/components/form/Buttons';
import { createBookingAction } from '@/utils/actions';function ConfirmBooking() {
const { userId } = useAuth();
const { propertyId, range } = useProperty((state) => state);
const checkIn = range?.from as Date;
const checkOut = range?.to as Date;
if (!userId)
return (
Sign In to Complete Booking
);const createBooking = createBookingAction.bind(null, {
propertyId,
checkIn,
checkOut,
});
return (
);
}
export default ConfirmBooking;
```### CreateBookingAction
```tsx
export const createBookingAction = async (prevState: {
propertyId: string;
checkIn: Date;
checkOut: Date;
}) => {
const user = await getAuthUser();const { propertyId, checkIn, checkOut } = prevState;
const property = await db.property.findUnique({
where: { id: propertyId },
select: { price: true },
});
if (!property) {
return { message: 'Property not found' };
}
const { orderTotal, totalNights } = calculateTotals({
checkIn,
checkOut,
price: property.price,
});try {
const booking = await db.booking.create({
data: {
checkIn,
checkOut,
orderTotal,
totalNights,
profileId: user.id,
propertyId,
},
});
} catch (error) {
return renderError(error);
}
redirect('/bookings');
};
```### Blocked Periods/Dates
BookingCalendar.tsx
```tsx
function BookingCalendar() {
const bookings = useProperty((state) => state.bookings);
const blockedPeriods = generateBlockedPeriods({
bookings,
today: currentDate,
});return (
);
}
export default BookingCalendar;
```### Unavailable Dates
BookingCalendar.tsx
```tsx
function BookingCalendar() {
const { toast } = useToast();
const unavailableDates = generateDisabledDates(blockedPeriods);useEffect(() => {
const selectedRange = generateDateRange(range);
const isDisabledDateIncluded = selectedRange.some((date) => {
if (unavailableDates[date]) {
setRange(defaultSelected);
toast({
description: 'Some dates are booked. Please select again.',
});
return true;
}
return false;
});
useProperty.setState({ range });
}, [range]);return (
);
}
export default BookingCalendar;
```### Fetch Bookings and Delete Booking
- actions.ts
```ts
export const fetchBookings = async () => {
const user = await getAuthUser();
const bookings = await db.booking.findMany({
where: {
profileId: user.id,
},
include: {
property: {
select: {
id: true,
name: true,
country: true,
},
},
},
orderBy: {
checkIn: 'desc',
},
});
return bookings;
};export async function deleteBookingAction(prevState: { bookingId: string }) {
const { bookingId } = prevState;
const user = await getAuthUser();try {
const result = await db.booking.delete({
where: {
id: bookingId,
profileId: user.id,
},
});revalidatePath('/bookings');
return { message: 'Booking deleted successfully' };
} catch (error) {
return renderError(error);
}
}
```### Bookings Page
- utils/format.ts
```ts
export const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
};
```Bookings.tsx
```tsx
import EmptyList from '@/components/home/EmptyList';
import CountryFlagAndName from '@/components/card/CountryFlagAndName';
import Link from 'next/link';import { formatDate, formatCurrency } from '@/utils/format';
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';import FormContainer from '@/components/form/FormContainer';
import { IconButton } from '@/components/form/Buttons';
import { fetchBookings, deleteBookingAction } from '@/utils/actions';async function BookingsPage() {
const bookings = await fetchBookings();
if (bookings.length === 0) {
return ;
}
return (
total bookings : {bookings.length}
A list of your recent bookings.
Property Name
Country
Nights
Total
Check In
Check Out
Actions
{bookings.map((booking) => {
const { id, orderTotal, totalNights, checkIn, checkOut } = booking;
const { id: propertyId, name, country } = booking.property;
const startDate = formatDate(checkIn);
const endDate = formatDate(checkOut);
return (
{name}
{totalNights}
{formatCurrency(orderTotal)}
{startDate}
{endDate}
);
})}
);
}function DeleteBooking({ bookingId }: { bookingId: string }) {
const deleteBooking = deleteBookingAction.bind(null, { bookingId });
return (
);
}export default BookingsPage;
```### LoadingTable
- create @/components/booking/LoadingTable.tsx
```tsx
import { Skeleton } from '../ui/skeleton';function LoadingTable({ rows }: { rows?: number }) {
const tableRows = Array.from({ length: rows || 5 }, (_, i) => {
return (
);
});
return <>{tableRows}>;
}
export default LoadingTable;
```- create app/bookings/loading.tsx
```tsx
'use client';import LoadingTable from '@/components/booking/LoadingTable';
function loading() {
return ;
}export default loading;
```### Fetch and Delete Rentals
- actions.ts
```ts
export const fetchRentals = async () => {
const user = await getAuthUser();
const rentals = await db.property.findMany({
where: {
profileId: user.id,
},
select: {
id: true,
name: true,
price: true,
},
});const rentalsWithBookingSums = await Promise.all(
rentals.map(async (rental) => {
const totalNightsSum = await db.booking.aggregate({
where: {
propertyId: rental.id,
},
_sum: {
totalNights: true,
},
});const orderTotalSum = await db.booking.aggregate({
where: {
propertyId: rental.id,
},
_sum: {
orderTotal: true,
},
});return {
...rental,
totalNightsSum: totalNightsSum._sum.totalNights,
orderTotalSum: orderTotalSum._sum.orderTotal,
};
})
);return rentalsWithBookingSums;
};export async function deleteRentalAction(prevState: { propertyId: string }) {
const { propertyId } = prevState;
const user = await getAuthUser();try {
await db.property.delete({
where: {
id: propertyId,
profileId: user.id,
},
});revalidatePath('/rentals');
return { message: 'Rental deleted successfully' };
} catch (error) {
return renderError(error);
}
}
```### Rentals Page
- create rentals/loading.tsx
```tsx
'use client';
import LoadingTable from '@/components/booking/LoadingTable';
function loading() {
return ;
}
export default loading;
``````tsx
import EmptyList from '@/components/home/EmptyList';
import { fetchRentals, deleteRentalAction } from '@/utils/actions';
import Link from 'next/link';import { formatCurrency } from '@/utils/format';
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';import FormContainer from '@/components/form/FormContainer';
import { IconButton } from '@/components/form/Buttons';async function RentalsPage() {
const rentals = await fetchRentals();if (rentals.length === 0) {
return (
);
}return (
Active Properties : {rentals.length}
A list of all your properties.
Property Name
Nightly Rate
Nights Booked
Total Income
Actions
{rentals.map((rental) => {
const { id: propertyId, name, price } = rental;
const { totalNightsSum, orderTotalSum } = rental;
return (
{name}
{formatCurrency(price)}
{totalNightsSum || 0}
{formatCurrency(orderTotalSum)}
);
})}
);
}function DeleteRental({ propertyId }: { propertyId: string }) {
const deleteRental = deleteRentalAction.bind(null, { propertyId });
return (
);
}export default RentalsPage;
```### Fetch Rental Details
- actions.ts
```ts
export const fetchRentalDetails = async (propertyId: string) => {
const user = await getAuthUser();return db.property.findUnique({
where: {
id: propertyId,
profileId: user.id,
},
});
};export const updatePropertyAction = async () => {
return { message: 'update property action' };
};export const updatePropertyImageAction = async () => {
return { message: 'update property image' };
};
```### Rentals Edit Page
- rentals/[id]/edit/page.tsx
```tsx
import {
fetchRentalDetails,
updatePropertyImageAction,
updatePropertyAction,
} from '@/utils/actions';
import FormContainer from '@/components/form/FormContainer';
import FormInput from '@/components/form/FormInput';
import CategoriesInput from '@/components/form/CategoriesInput';
import PriceInput from '@/components/form/PriceInput';
import TextAreaInput from '@/components/form/TextAreaInput';
import CountriesInput from '@/components/form/CountriesInput';
import CounterInput from '@/components/form/CounterInput';
import AmenitiesInput from '@/components/form/AmenitiesInput';
import { SubmitButton } from '@/components/form/Buttons';
import { redirect } from 'next/navigation';
import { type Amenity } from '@/utils/amenities';
import ImageInputContainer from '@/components/form/ImageInputContainer';async function EditRentalPage({ params }: { params: { id: string } }) {
const property = await fetchRentalDetails(params.id);if (!property) redirect('/');
const defaultAmenities: Amenity[] = JSON.parse(property.amenities);
return (
Edit Property
Accommodation Details
Amenities
);
}
export default EditRentalPage;
```### Amenities Input
```tsx
'use client';
import { useState } from 'react';
import { amenities, Amenity } from '@/utils/amenities';
import { Checkbox } from '@/components/ui/checkbox';function AmenitiesInput({ defaultValue }: { defaultValue?: Amenity[] }) {
const amenitiesWithIcons = defaultValue?.map(({ name, selected }) => ({
name,
selected,
icon: amenities.find((amenity) => amenity.name === name)!.icon,
}));
const [selectedAmenities, setSelectedAmenities] = useState(
amenitiesWithIcons || amenities
);
const handleChange = (amenity: Amenity) => {
setSelectedAmenities((prev) => {
return prev.map((a) => {
if (a.name === amenity.name) {
return { ...a, selected: !a.selected };
}
return a;
});
});
};return (
{selectedAmenities.map((amenity) => {
return (
handleChange(amenity)}
/>
{amenity.name}
);
})}
);
}
export default AmenitiesInput;
```### Update Property Action
```ts
export const updatePropertyAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
const user = await getAuthUser();
const propertyId = formData.get('id') as string;try {
const rawData = Object.fromEntries(formData);
const validatedFields = validateWithZodSchema(propertySchema, rawData);
await db.property.update({
where: {
id: propertyId,
profileId: user.id,
},
data: {
...validatedFields,
},
});revalidatePath(`/rentals/${propertyId}/edit`);
return { message: 'Update Successful' };
} catch (error) {
return renderError(error);
}
};
```### Update Property Image Action
```ts
export const updatePropertyImageAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
const user = await getAuthUser();
const propertyId = formData.get('id') as string;try {
const image = formData.get('image') as File;
const validatedFields = validateWithZodSchema(imageSchema, { image });
const fullPath = await uploadImage(validatedFields.image);await db.property.update({
where: {
id: propertyId,
profileId: user.id,
},
data: {
image: fullPath,
},
});
revalidatePath(`/rentals/${propertyId}/edit`);
return { message: 'Property Image Updated Successful' };
} catch (error) {
return renderError(error);
}
};
```### Reservations
- in app/reservations create page.tsx and loading.tsx
```tsx
'use client';import LoadingTable from '@/components/booking/LoadingTable';
function loading() {
return ;
}
export default loading;
```- add to links
utils/links.ts
```ts
export const links: NavLink[] = [
{ href: '/', label: 'home' },
{ href: '/favorites ', label: 'favorites' },
{ href: '/bookings ', label: 'bookings' },
{ href: '/reviews ', label: 'reviews' },
{ href: '/reservations ', label: 'reservations' },
{ href: '/rentals/create ', label: 'create rental' },
{ href: '/rentals', label: 'my rentals' },
{ href: '/profile ', label: 'profile' },
];
```### Fetch Reservations
```ts
export const fetchReservations = async () => {
const user = await getAuthUser();const reservations = await db.booking.findMany({
where: {
property: {
profileId: user.id,
},
},orderBy: {
createdAt: 'desc', // or 'asc' for ascending order
},include: {
property: {
select: {
id: true,
name: true,
price: true,
country: true,
},
}, // include property details in the result
},
});
return reservations;
};
```### Reservations Page
```tsx
import { fetchReservations } from '@/utils/actions';
import Link from 'next/link';
import EmptyList from '@/components/home/EmptyList';
import CountryFlagAndName from '@/components/card/CountryFlagAndName';import { formatDate, formatCurrency } from '@/utils/format';
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';async function ReservationsPage() {
const reservations = await fetchReservations();if (reservations.length === 0) {
return ;
}return (
total reservations : {reservations.length}
A list of your recent reservations.
Property Name
Country
Nights
Total
Check In
Check Out
{reservations.map((item) => {
const { id, orderTotal, totalNights, checkIn, checkOut } = item;
const { id: propertyId, name, country } = item.property;
const startDate = formatDate(checkIn);
const endDate = formatDate(checkOut);
return (
{name}
{totalNights}
{formatCurrency(orderTotal)}
{startDate}
{endDate}
);
})}
);
}
export default ReservationsPage;
```### Admin User - Setup
- create app/admin/page.tsx
- add admin to links
- create components/admin
- Chart.tsx
- ChartsContainer.tsx
- Loading.tsx
- StatsCard.tsx
- StatsContainer.tsx### Admin User - Middleware
- refactor middleware
- create ENV variable with userId
- add to VERCEL```ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';import { NextResponse } from 'next/server';
const isPublicRoute = createRouteMatcher(['/', '/properties(.*)']);
const isAdminRoute = createRouteMatcher(['/admin(.*)']);
export default clerkMiddleware(async (auth, req) => {
const isAdminUser = auth().userId === process.env.ADMIN_USER_ID;
if (isAdminRoute(req) && !isAdminUser) {
return NextResponse.redirect(new URL('/', req.url));
}
if (!isPublicRoute(req)) auth().protect();
});export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
};
```### Admin User - LinksDropdown
- LinksDropdown.tsx
```tsx
import { auth } from '@clerk/nextjs/server';function LinksDropdown() {
const { userId } = auth();
const isAdminUser = userId === process.env.ADMIN_USER_ID;
}
return (
<>
{links.map((link) => {
if (link.label === 'admin' && !isAdminUser) return null;
return (
{link.label}
);
})}
>
);
```### Admin User - Loading
```tsx
import { Card, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';export function StatsLoadingContainer() {
return (
);
}function LoadingCard() {
return (
);
}export function ChartsLoadingContainer() {
return ;
}
```### Admin User - Main Page
```tsx
import ChartsContainer from '@/components/admin/ChartsContainer';
import StatsContainer from '@/components/admin/StatsContainer';
import {
ChartsLoadingContainer,
StatsLoadingContainer,
} from '@/components/admin/Loading';
import { Suspense } from 'react';
async function AdminPage() {
return (
<>
}>
}>
>
);
}
export default AdminPage;
```### Admin User - Fetch Stats
```ts
const getAdminUser = async () => {
const user = await getAuthUser();
if (user.id !== process.env.ADMIN_USER_ID) redirect('/');
return user;
};export const fetchStats = async () => {
await getAdminUser();const usersCount = await db.profile.count();
const propertiesCount = await db.property.count();
const bookingsCount = await db.booking.count();return {
usersCount,
propertiesCount,
bookingsCount,
};
};
```### Admin User - StatsContainer
```tsx
import { fetchStats } from '@/utils/actions';
import StatsCard from './StatsCard';
async function StatsContainer() {
const data = await fetchStats();return (
);
}
export default StatsContainer;
```### Admin User - StatsCard
```tsx
import { Card, CardHeader } from '@/components/ui/card';type StatsCardsProps = {
title: string;
value: number;
};function StatsCards({ title, value }: StatsCardsProps) {
return (
{title}
{value}
);
}export default StatsCards;
```### Admin User - Fetch Charts Data
```ts
export const fetchChartsData = async () => {
await getAdminUser();
const date = new Date();
date.setMonth(date.getMonth() - 6);
const sixMonthsAgo = date;const bookings = await db.booking.findMany({
where: {
createdAt: {
gte: sixMonthsAgo,
},
},
orderBy: {
createdAt: 'asc',
},
});
let bookingsPerMonth = bookings.reduce((total, current) => {
const date = formatDate(current.createdAt, true);const existingEntry = total.find((entry) => entry.date === date);
if (existingEntry) {
existingEntry.count += 1;
} else {
total.push({ date, count: 1 });
}
return total;
}, [] as Array<{ date: string; count: number }>);
return bookingsPerMonth;
};
```format.ts
```ts
export const formatDate = (date: Date, onlyMonth?: boolean) => {
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
};if (!onlyMonth) {
options.day = 'numeric';
}return new Intl.DateTimeFormat('en-US', options).format(date);
};
```### Admin User - ChartsContainer
```tsx
import { fetchChartsData } from '@/utils/actions';
import Chart from './Chart';async function ChartsContainer() {
const bookings = await fetchChartsData();
if (bookings.length < 1) return null;return ;
}
export default ChartsContainer;
```### Recharts
[Recharts](https://recharts.org/en-US/)
```sh
npm install recharts
```### Admin User - Chart Component
```tsx
'use client';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';type ChartPropsType = {
data: {
date: string;
count: number;
}[];
};function Chart({ data }: ChartPropsType) {
return (
Monthly Bookings
);
}
export default Chart;
```### Stripe
[Embedded Form](https://docs.stripe.com/checkout/embedded/quickstart)
- setup and add keys to .env
- install```sh
npm install --save @stripe/react-stripe-js @stripe/stripe-js stripe axios
```### Refactor createBookingAction
```ts
export const createBookingAction = async (prevState: {
propertyId: string;
checkIn: Date;
checkOut: Date;
}) => {
// create variable
let bookingId: null | string = null;try {
const booking = await db.booking.create(....);
// change value
bookingId = booking.id;
} catch (error) {
return renderError(error);
}
// redirect to checkout
redirect(`/checkout?bookingId=${bookingId}`);
};
```### Checkout Page
```tsx
'use client';
import axios from 'axios';
import { useSearchParams } from 'next/navigation';
import React, { useCallback } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from '@stripe/react-stripe-js';const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY as string
);export default function CheckoutPage() {
const searchParams = useSearchParams();const bookingId = searchParams.get('bookingId');
const fetchClientSecret = useCallback(async () => {
// Create a Checkout Session
const response = await axios.post('/api/payment', {
bookingId: bookingId,
});
return response.data.clientSecret;
}, []);const options = { fetchClientSecret };
return (
);
}
```### API - Payment Route
api/payment/route.ts
```ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
import { type NextRequest, type NextResponse } from 'next/server';
import db from '@/utils/db';
import { formatDate } from '@/utils/format';
export const POST = async (req: NextRequest, res: NextResponse) => {
const requestHeaders = new Headers(req.headers);
const origin = requestHeaders.get('origin');const { bookingId } = await req.json();
const booking = await db.booking.findUnique({
where: { id: bookingId },
include: {
property: {
select: {
name: true,
image: true,
},
},
},
});if (!booking) {
return Response.json(null, {
status: 404,
statusText: 'Not Found',
});
}
const {
totalNights,
orderTotal,
checkIn,
checkOut,
property: { image, name },
} = booking;try {
const session = await stripe.checkout.sessions.create({
ui_mode: 'embedded',
metadata: { bookingId: booking.id },
line_items: [
{
// Provide the exact Price ID (for example, pr_1234) of
// the product you want to sell
quantity: 1,
price_data: {
currency: 'usd',product_data: {
name: `${name}`,
images: [image],
description: `Stay in this wonderful place for ${totalNights} nights, from ${formatDate(
checkIn
)} to ${formatDate(checkOut)}. Enjoy your stay!`,
},
unit_amount: orderTotal * 100,
},
},
],
mode: 'payment',
return_url: `${origin}/api/confirm?session_id={CHECKOUT_SESSION_ID}`,
});return Response.json({ clientSecret: session.client_secret });
} catch (error) {
console.log(error);return Response.json(null, {
status: 500,
statusText: 'Internal Server Error',
});
}
};
```### API - Confirm Route
api/confirm/route.ts
```ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
import { redirect } from 'next/navigation';import { type NextRequest, type NextResponse } from 'next/server';
import db from '@/utils/db';export const GET = async (req: NextRequest) => {
const { searchParams } = new URL(req.url);
const session_id = searchParams.get('session_id') as string;try {
const session = await stripe.checkout.sessions.retrieve(session_id);
// console.log(session);const bookingId = session.metadata?.bookingId;
if (session.status === 'complete' && bookingId) {
await db.booking.update({
where: { id: bookingId },
data: { paymentStatus: true },
});
}
} catch (err) {
console.log(err);
return Response.json(null, {
status: 500,
statusText: 'Internal Server Error',
});
}
redirect('/bookings');
};
```### Refactor Actions
- remove all bookings with 'paymentStatus' false, before creating a booking
createBookingAction.ts```ts
export const createBookingAction = async (prevState: {
propertyId: string;
checkIn: Date;
checkOut: Date;
}) => {
let bookingId: null | string = null;const user = await getAuthUser();
await db.booking.deleteMany({
where: {
profileId: user.id,
paymentStatus: false,
},
});
.....
}
```- Check for 'paymentStatus' when fetching bookings
- fetchBookings
- rentalsWithBookingSums
- fetchReservations
- fetchStats```ts
const bookingsCount = await db.booking.count({
where: {
paymentStatus: true,
},
});
```- fetchChartsData
### Reservation Stats
- actions.ts
```ts
export const fetchReservationStats = async () => {
const user = await getAuthUser();
const properties = await db.property.count({
where: {
profileId: user.id,
},
});const totals = await db.booking.aggregate({
_sum: {
orderTotal: true,
totalNights: true,
},
where: {
property: {
profileId: user.id,
},
},
});return {
properties,
nights: totals._sum.totalNights || 0,
amount: totals._sum.orderTotal || 0,
};
};
```- create components/reservations/Stats.tsx
```tsx
import StatsCards from '@/components/admin/StatsCard';
import { fetchReservationStats } from '@/utils/actions';
import { formatCurrency } from '@/utils/format';
async function Stats() {
const stats = await fetchReservationStats();return (
);
}
export default Stats;
```- refactor StatsCard.tsx
```tsx
import { Card, CardHeader } from '@/components/ui/card';type StatsCardsProps = {
title: string;
value: number | string;
};
```- render in reservations
```tsx
import Stats from '@/components/reservations/Stats';return (
<>
....
>
);
```### 🚀🚀🚀 THE END 🚀🚀🚀