Clerk Docs Clerk + Next.js Setup
- create new application
npm install @clerk/nextjs
- create .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
.env.local
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/profile/create
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/profile/create
- create account and organization
- create project
- setup password in .env (optional)
- add .env to .gitignore !!!
- it will take few minutes
- 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.
npm install prisma --save-dev
npm install @prisma/client
npx prisma init
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
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient()
}
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>
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
- add to .env
DATABASE_URL=""
DIRECT_URL=""
- DATABASE_URL : Transaction + Password + "?pgbouncer=true&connection_limit=1"
- DIRECT_URL : Session + Password
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 push
npx 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.
npx prisma db push
npx prisma studio
// 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
const upsertTask = await prisma.task.upsert({
where: {
id: id,
},
update: {
content: 'some value',
},
create: {
content: 'some value',
},
})
- Delete a single record
const deleteTask = await prisma.task.delete({
where: {
id: id,
},
})
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
}
npx prisma db push
npx prisma studio
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('/')
}
actions.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
import { LuUser2 } from 'react-icons/lu'
import { fetchProfileImage } from '@/utils/actions'
async function UserIcon() {
const profileImage = await fetchProfileImage()
if (profileImage)
return (
<img src={profileImage} className='w-6 h-6 rounded-full object-cover' />
)
return <LuUser2 className='w-6 h-6 bg-primary rounded-full text-white' />
}
export default UserIcon
import { currentUser } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
async function CreateProfile() {
const user = await currentUser();
if (user?.privateMetadata?.hasProfile) redirect('/');
....
}
actions.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
}
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
}
export const updateProfileAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
return { message: 'update profile action' }
}
app/profile/page.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 (
<section>
<h1 className='text-2xl font-semibold mb-8 capitalize'>user profile</h1>
<div className='border p-8 rounded-md'>
{/* image input container */}
<FormContainer action={updateProfileAction}>
<div className='grid gap-4 md:grid-cols-2 mt-4 '>
<FormInput
type='text'
name='firstName'
label='First Name'
defaultValue={profile.firstName}
/>
<FormInput
type='text'
name='lastName'
label='Last Name'
defaultValue={profile.lastName}
/>
<FormInput
type='text'
name='username'
label='Username'
defaultValue={profile.username}
/>
</div>
<SubmitButton text='Update Profile' className='mt-8' />
</FormContainer>
</div>
</section>
)
}
export default ProfilePage
actions.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',
}
}
}
actions.ts
const renderError = (error: unknown): { message: string } => {
console.log(error)
return {
message: error instanceof Error ? error.message : 'An error occurred',
}
}
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)
}
}
schemas.ts
export function validateWithZodSchema<T>(
schema: ZodSchema<T>,
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
// createProfileAction
const validatedFields = validateWithZodSchema(profileSchema, rawData)
// updateProfileAction
const validatedFields = validateWithZodSchema(profileSchema, rawData)
await db.profile.update({
where: {
clerkId: user.id,
},
data: validatedFields,
})
components/form/ImageInput.tsx
import { Label } from '../ui/label'
import { Input } from '../ui/input'
function ImageInput() {
const name = 'image'
return (
<div className='mb-2'>
<Label htmlFor={name} className='capitalize'>
Image
</Label>
<Input
id={name}
name={name}
type='file'
required
accept='image/*'
className='max-w-xs'
/>
</div>
)
}
export default ImageInput
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 (
<Button
type='submit'
disabled={pending}
className={`capitalize ${className}`}
size={size}
>
{pending ? (
<>
<ReloadIcon className='mr-2 h-4 w-4 animate-spin' />
Please wait...
</>
) : (
text
)}
</Button>
)
}
components/form/ImageInputContainer.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 = (
<LuUser2 className='w-24 h-24 bg-primary rounded-md text-white mb-4' />
)
return (
<div>
{image ? (
<Image
src={image}
width={100}
height={100}
className='rounded-md object-cover mb-4 w-24 h-24'
alt={name}
/>
) : (
userIcon
)}
<Button
variant='outline'
size='sm'
onClick={() => setUpdateFormVisible((prev) => !prev)}
>
{text}
</Button>
{isUpdateFormVisible && (
<div className='max-w-lg mt-4'>
<FormContainer action={action}>
{props.children}
<ImageInput />
<SubmitButton size='sm' />
</FormContainer>
</div>
)}
</div>
)
}
export default ImageInputContainer
actions.ts
export const updateProfileImageAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
return { message: 'Profile image updated successfully' }
}
import {
updateProfileAction,
fetchProfile,
updateProfileImageAction,
} from '@/utils/actions'
import ImageInputContainer from '@/components/form/ImageInputContainer'
/* image input container */
;<ImageInputContainer
image={profile.profileImage}
name={profile.username}
action={updateProfileImageAction}
text='Update Profile Image'
/>
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'img.clerk.com',
},
],
},
}
export default nextConfig
schemas.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.
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)
}
}
SUPABASE_URL=
SUPABASE_KEY=
npm install @supabase/supabase-js
utils/supabase.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
}
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)
}
}
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'img.clerk.com',
},
{
protocol: 'https',
hostname: 'jxdujzgweuaphpgoowhu.supabase.co',
},
],
},
}
export default nextConfig
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
}
- yes, no image π
schemas.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(),
})
actions.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('/')
}
- app/rentals/create/page.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 (
<section>
<h1 className='text-2xl font-semibold mb-8 capitalize'>
create property
</h1>
<div className='border p-8 rounded-md'>
<h3 className='text-lg mb-4 font-medium'>General Info</h3>
<FormContainer action={createPropertyAction}>
<div className='grid md:grid-cols-2 gap-8 mb-4'>
<FormInput
name='name'
type='text'
label='Name (20 limit)'
defaultValue='Cabin in Latvia'
/>
<FormInput
name='tagline'
type='text '
label='Tagline (30 limit)'
defaultValue='Dream Getaway Awaits You Here!'
/>
{/* price */}
{/* categories */}
</div>
{/* text area / description */}
<SubmitButton text='create rental' className='mt-12' />
</FormContainer>
</div>
</section>
)
}
export default CreateProperty
- components/form/PriceInput.tsx
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 (
<div className='mb-2'>
<Label htmlFor='price' className='capitalize'>
Price ($)
</Label>
<Input
id={name}
type='number'
name={name}
min={0}
defaultValue={defaultValue || 100}
required
/>
</div>
)
}
export default PriceInput
/* price */
<PriceInput />
- utils/categories.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,
},
]
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 (
<div className='mb-2'>
<Label htmlFor={name} className='capitalize'>
Categories
</Label>
<Select
defaultValue={defaultValue || categories[0].label}
name={name}
required
>
<SelectTrigger id={name}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map((item) => {
return (
<SelectItem key={item.label} value={item.label}>
<span className='flex items-center gap-2'>
<item.icon /> {item.label}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
)
}
export default CategoriesInput
/* categories */
<CategoriesInput />
- components/form/TextAreaInput.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 (
<div className='mb-2'>
<Label htmlFor={name} className='capitalize'>
{labelText || name}
</Label>
<Textarea
id={name}
name={name}
defaultValue={defaultValue || tempDefaultDescription}
rows={5}
required
className='leading-loose'
/>
</div>
)
}
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
/* text area / description */
<TextAreaInput name='description' labelText='Description (10 - 1000 Words)' />
npm i world-countries
- utils/countries.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
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 (
<div className='mb-2'>
<Label htmlFor={name} className='capitalize'>
country
</Label>
<Select
defaultValue={defaultValue || formattedCountries[0].code}
name={name}
required
>
<SelectTrigger id={name}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{formattedCountries.map((item) => {
return (
<SelectItem key={item.code} value={item.code}>
<span className='flex items-center gap-2'>
{item.flag} {item.name}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
)
}
export default CountriesInput
<div className='grid sm:grid-cols-2 gap-8 mt-4'>
<CountriesInput />
<ImageInput />
</div>
- components/form/CounterInput.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 (
<Card className='mb-4'>
<input type='hidden' name={detail} value={count} />
<CardHeader className='flex flex-col gapy-5'>
<div className='flex items-center justify-between flex-wrap'>
<div className='flex flex-col'>
<h2 className='font-medium capitalize'>{detail}</h2>
<p className='text-muted-foreground text-sm'>
Specify the number of {detail}
</p>
</div>
<div className='flex items-center gap-4'>
<Button
variant='outline'
size='icon'
type='button'
onClick={decreaseCount}
>
<LuMinus className='w-5 h-5 text-primary' />
</Button>
<span className='text-xl font-bold w-5 text-center'>{count}</span>
<Button
variant='outline'
size='icon'
type='button'
onClick={increaseCount}
>
<LuPlus className='w-5 h-5 text-primary' />
</Button>
</div>
</div>
</CardHeader>
</Card>
)
}
export default CounterInput
return (
<>
<h3 className='text-lg mt-8 mb-4 font-medium'>Accommodation Details</h3>
<CounterInput detail='guests' />
<CounterInput detail='bedrooms' />
<CounterInput detail='beds' />
<CounterInput detail='baths' />
</>
)
- utils/amenities.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
'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<Amenity[]>(
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 (
<section>
<input
type='hidden'
name='amenities'
value={JSON.stringify(selectedAmenities)}
/>
<div className='grid grid-cols-2 gap-4'>
{selectedAmenities.map((amenity) => (
<div key={amenity.name} className='flex items-center space-x-2'>
<Checkbox
id={amenity.name}
checked={amenity.selected}
onCheckedChange={() => handleChange(amenity)}
/>
<label
htmlFor={amenity.name}
className='text-sm font-medium leading-none capitalize flex gap-x-2 items-center'
>
{amenity.name}
<amenity.icon className='w-4 h-4' />
</label>
</div>
))}
</div>
</section>
)
}
export default AmenitiesInput
return (
<>
<h3 className='text-lg mt-10 mb-6 font-medium'>Amenities</h3>
<AmenitiesInput />
</>
)
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('/')
}
utils/types.ts
export type PropertyCardProps = {
image: string
id: string
name: string
tagline: string
country: string
price: number
}
actions.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
}
- create in components/home
- CategoriesList.tsx
- EmptyList.tsx
- PropertiesContainer.tsx
- PropertiesList.tsx
import CategoriesList from '@/components/home/CategoriesList'
import PropertiesContainer from '@/components/home/PropertiesContainer'
function HomePage() {
return (
<section>
<CategoriesList />
<PropertiesContainer />
</section>
)
}
export default HomePage
import CategoriesList from '@/components/home/CategoriesList'
import PropertiesContainer from '@/components/home/PropertiesContainer'
function HomePage({
searchParams,
}: {
searchParams: { category?: string; search?: string }
}) {
// console.log(searchParams);
return (
<section>
<CategoriesList
category={searchParams?.category}
search={searchParams?.search}
/>
<PropertiesContainer
category={searchParams?.category}
search={searchParams?.search}
/>
</section>
)
}
export default HomePage
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 (
<section>
<ScrollArea className='py-6'>
<div className='flex gap-x-4'>
{categories.map((item) => {
const isActive = item.label === category
return (
<Link
key={item.label}
href={`/?category=${item.label}${searchTerm}`}
>
<article
className={`p-3 flex flex-col items-center cursor-pointer duration-300 hover:text-primary w-[100px] ${
isActive ? 'text-primary' : ''
}`}
>
<item.icon className='w-8 h-8 ' />
<p className='capitalize text-sm mt-1'>{item.label}</p>
</article>
</Link>
)
})}
</div>
<ScrollBar orientation='horizontal' />
</ScrollArea>
</section>
)
}
export default CategoriesList
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 (
<div className='mt-4'>
<h2 className='text-xl font-bold '>{heading}</h2>
<p className='text-lg'>{message}</p>
<Button asChild className='mt-4 capitalize' size='lg'>
<Link href='/'>{btnText}</Link>
</Button>
</div>
)
}
export default EmptyList
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 (
<EmptyList
heading='No results.'
message='Try changing or removing some of your filters.'
btnText='Clear Filters'
/>
)
}
return <PropertiesList properties={properties} />
}
export default PropertiesContainer
- components/card
- CountryFlagAndName.tsx
- FavoriteToggleButton.tsx
- FavoriteToggleForm.tsx
- LoadingCards.tsx
- PropertyCard.tsx
- PropertyRating.tsx
import PropertyCard from '../card/PropertyCard'
import type { PropertyCardProps } from '@/utils/types'
function PropertiesList({ properties }: { properties: PropertyCardProps[] }) {
return (
<section className='mt-4 gap-8 grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{properties.map((property) => {
return <PropertyCard key={property.id} property={property} />
})}
</section>
)
}
export default PropertiesList
- utils/format.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)
}
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 (
<article className='group relative'>
<Link href={`/properties/${propertyId}`}>
<div className='relative h-[300px] mb-2 overflow-hidden rounded-md'>
<Image
src={image}
fill
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw'
alt={name}
className='rounded-md object-cover transform group-hover:scale-110 transition-transform duration-500'
/>
</div>
<div className='flex justify-between items-center'>
<h3 className='text-sm font-semibold mt-1'>
{name.substring(0, 30)}
</h3>
{/* property rating */}
</div>
<p className='text-sm mt-1 text-muted-foreground '>
{tagline.substring(0, 40)}
</p>
<div className='flex justify-between items-center mt-1'>
<p className='text-sm mt-1 '>
<span className='font-semibold'>{formatCurrency(price)} </span>
night
</p>
{/* country and flag */}
</div>
</Link>
<div className='absolute top-5 right-5 z-5'>
{/* favorite toggle button */}
</div>
</article>
)
}
export default PropertyCard
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 (
<span className={className}>
<FaStar className='w-3 h-3' />
{rating} {countValue}
</span>
)
}
export default PropertyRating
<PropertyRating inPage={false} propertyId={propertyId} />
import { FaHeart } from 'react-icons/fa'
import { Button } from '@/components/ui/button'
function FavoriteToggleButton({ propertyId }: { propertyId: string }) {
return (
<Button size='icon' variant='outline' className='p-2 cursor-pointer'>
<FaHeart />
</Button>
)
}
export default FavoriteToggleButton
<div className='absolute top-5 right-5 z-5'>
<FavoriteToggleButton propertyId={propertyId} />
</div>
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 (
<span className='flex justify-between items-center gap-2 text-sm '>
{validCountry?.flag} {countryName}
</span>
)
}
export default CountryFlagAndName
<CountryFlagAndName countryCode={country} />
- app/loading.tsx - always an option
components/card/LoadingCards.tsx
import { Skeleton } from '@/components/ui/skeleton'
function LoadingCards() {
return (
<section className='mt-4 gap-8 grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</section>
)
}
export default LoadingCards
export function SkeletonCard() {
return (
<div>
<Skeleton className='h-[300px] rounded-md' />
<Skeleton className='h-4 mt-2 w-3/4' />
<Skeleton className='h-4 mt-2 w-1/2' />
</div>
)
}
app/page.tsx
- navigate to a different page, refresh and then navigate back to home page
- make sure you fetch in component not page
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 (
<section>
<CategoriesList
category={searchParams?.category}
search={searchParams?.search}
/>
<Suspense fallback={<LoadingCards />}>
<PropertiesContainer
category={searchParams?.category}
search={searchParams?.search}
/>
</Suspense>
</section>
)
}
export default HomePage
npm i use-debounce
components/navbar/NavSearch.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 (
<Input
type='search'
placeholder='find a property...'
className='max-w-xs dark:bg-muted '
onChange={(e) => {
setSearch(e.target.value)
handleSearch(e.target.value)
}}
value={search}
/>
)
}
export default NavSearch
model Profile {
favorites Favorite[]
}
model Property {
favorites Favorite[]
}
model Favorite {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profile Profile @relation(fields: [profileId], references: [clerkId], onDelete: Cascade)
profileId String
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
propertyId String
}
npx prisma db push
components/form/Buttons.tsx
import { SignInButton } from '@clerk/nextjs'
import { FaRegHeart, FaHeart } from 'react-icons/fa'
export const CardSignInButton = () => {
return (
<SignInButton mode='modal'>
<Button
type='button'
size='icon'
variant='outline'
className='p-2 cursor-pointer'
asChild
>
<FaRegHeart />
</Button>
</SignInButton>
)
}
components/card/FavoriteToggleButton.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 <CardSignInButton />
return (
<Button size='icon' variant='outline' className='p-2 cursor-pointer'>
<FaHeart />
</Button>
)
}
export default FavoriteToggleButton
actions.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' }
}
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 <CardSignInButton />
const favoriteId = await fetchFavoriteId({ propertyId })
return <FavoriteToggleForm favoriteId={favoriteId} propertyId={propertyId} />
}
export default FavoriteToggleButton
components/form/Buttons.tsx
export const CardSubmitButton = ({ isFavorite }: { isFavorite: boolean }) => {
const { pending } = useFormStatus()
return (
<Button
type='submit'
size='icon'
variant='outline'
className=' p-2 cursor-pointer'
>
{pending ? (
<ReloadIcon className=' animate-spin' />
) : isFavorite ? (
<FaHeart />
) : (
<FaRegHeart />
)}
</Button>
)
}
'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 (
<FormContainer action={toggleAction}>
<CardSubmitButton isFavorite={favoriteId ? true : false} />
</FormContainer>
)
}
export default FavoriteToggleForm
actions.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)
}
}
actions.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/loading.tsx
'use client'
import LoadingCards from '@/components/card/LoadingCards'
function loading() {
return <LoadingCards />
}
export default loading
- favorites/page.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 <EmptyList />
}
return <PropertiesList properties={favorites} />
}
export default FavoritesPage
- utils/actions.ts
export const fetchPropertyDetails = (id: string) => {
return db.property.findUnique({
where: {
id,
},
include: {
profile: true,
},
})
}
- properties/[id]/loading.tsx
'use client'
import { Skeleton } from '@/components/ui/skeleton'
function loading() {
return <Skeleton className='h-[300px] md:h-[500px] w-full rounded' />
}
export default loading
- properties/[id]/page.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 }
return <div>PropertyDetailsPage</div>
}
export default PropertyDetailsPage
- components/properties/BreadCrumbs.tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
function BreadCrumbs({ name }: { name: string }) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href='/'>Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{name}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}
export default BreadCrumbs
- properties/[id]/page.tsx
return (
<section>
<BreadCrumbs name={property.name} />
<header className='flex justify-between items-center mt-4'>
<h1 className='text-4xl font-bold '>{property.tagline}</h1>
<div className='flex items-center gap-x-4'>
{/* share button */}
<FavoriteToggleButton propertyId={property.id} />
</div>
</header>
</section>
)
npm i react-share
- components/properties/ShareButton.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 (
<Popover>
<PopoverTrigger asChild>
<Button variant='outline' size='icon' className='p-2'>
<LuShare2 />
</Button>
</PopoverTrigger>
<PopoverContent
side='top'
align='end'
sideOffset={10}
className='flex items-center gap-x-2 justify-center w-full'
>
<TwitterShareButton url={shareLink} title={name}>
<TwitterIcon size={32} round />
</TwitterShareButton>
<LinkedinShareButton url={shareLink} title={name}>
<LinkedinIcon size={32} round />
</LinkedinShareButton>
<EmailShareButton url={shareLink} subject={name}>
<EmailIcon size={32} round />
</EmailShareButton>
</PopoverContent>
</Popover>
)
}
export default ShareButton
- properties/[id]/page.tsx
return (
<div className='flex items-center gap-x-4'>
<ShareButton name={property.name} propertyId={property.id} />
<FavoriteToggleButton propertyId={property.id} />
</div>
)
- components/properties/ImageContainer.tsx
import Image from 'next/image'
function ImageContainer({
mainImage,
name,
}: {
mainImage: string
name: string
}) {
return (
<section className='h-[300px] md:h-[500px] relative mt-8'>
<Image
src={mainImage}
fill
sizes='100vw'
alt={name}
className='object-cover rounded-md'
priority
/>
</section>
)
}
export default ImageContainer
- properties/[id]/page.tsx
<ImageContainer mainImage={property.image} name={property.name} />
- properties/[id]/page.tsx
return (
<section className='lg:grid lg:grid-cols-12 gap-x-12 mt-12'>
<div className='lg:col-span-8'>
<div className='flex gap-x-4 items-center'>
<h1 className='text-xl font-bold'>{property.name}</h1>
<PropertyRating inPage propertyId={property.id} />
</div>
</div>
<div className='lg:col-span-4 flex flex-col items-center'>
{/* calendar */}
</div>
</section>
)
- components/properties/booking/BookingCalendar.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<DateRange | undefined>(defaultSelected)
return (
<Calendar
id='test'
mode='range'
defaultMonth={currentDate}
selected={range}
onSelect={setRange}
/>
)
}
- properties/[id]/page.tsx
<div className='lg:col-span-4 flex flex-col items-center'>
{/* calendar */}
<BookingCalendar />
</div>
- utils/format.ts
export function formatQuantity(quantity: number, noun: string): string {
return quantity === 1 ? `${quantity} ${noun}` : `${quantity} ${noun}s`
}
- components/properties/PropertyDetails.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 (
<p className='text-md font-light '>
<span>{formatQuantity(bedrooms, 'bedroom')} · </span>
<span>{formatQuantity(baths, 'bath')} · </span>
<span>{formatQuantity(guests, 'guest')} · </span>
<span>{formatQuantity(beds, 'bed')}</span>
</p>
)
}
export default PropertyDetails
- properties/[id]/page.tsx
<PropertyDetails details={details} />
- components/properties/UserInfo.tsx
import Image from 'next/image'
type UserInfoProps = {
profile: {
profileImage: string
firstName: string
}
}
function UserInfo({ profile: { profileImage, firstName } }: UserInfoProps) {
return (
<article className='grid grid-cols-[auto,1fr] gap-4 mt-4'>
<Image
src={profileImage}
alt={firstName}
width={50}
height={50}
className='rounded-md w-12 h-12 object-cover'
/>
<div>
<p>
Hosted by
<span className='font-bold'> {firstName}</span>
</p>
<p className='text-muted-foreground font-light'>
Superhost · 2 years hosting
</p>
</div>
</article>
)
}
export default UserInfo
- properties/[id]/page.tsx
const firstName = property.profile.firstName
const profileImage = property.profile.profileImage
;<UserInfo profile={{ firstName, profileImage }} />
- components/properties/Title.tsx
function Title({ text }: { text: string }) {
return <h3 className='text-lg font-bold mb-2'>{text}</h3>
}
export default Title
- components/properties/Description.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 (
<article className='mt-4'>
<Title text='Description' />
<p className='text-muted-foreground font-light leading-loose'>
{displayedDescription}
</p>
{isLongDescription && (
<Button variant='link' className='pl-0' onClick={toggleDescription}>
{isFullDescriptionShown ? 'Show less' : 'Show more'}
</Button>
)}
</article>
)
}
export default Description
- properties/[id]/page.tsx
<Separator className='mt-4' />
<Description description={property.description} />
- components/properties/Amenities.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 (
<div className='mt-4'>
<Title text='What this place offers' />
<div className='grid md:grid-cols-2 gap-x-4'>
{amenitiesList.map((amenity) => {
if (!amenity.selected) {
return null
}
return (
<div key={amenity.name} className='flex items-center gap-x-4 mb-2 '>
<LuFolderCheck className='h-6 w-6 text-primary' />
<span className='font-light text-sm capitalize'>
{amenity.name}
</span>
</div>
)
})}
</div>
</div>
)
}
export default Amenities
- properties/[id]/page.tsx
<Amenities amenities={property.amenities} />
Leaflet makes direct calls to the DOM when it is loaded, therefore React Leaflet is not compatible with server-side rendering.
npm install react react-dom leaflet react-leaflet
npm install -D @types/leaflet
- components/properties/PropertyMap.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 (
<div className='mt-4'>
<div className='mb-4 '>
<Title text='Where you will be staying' />
<CountryFlagAndName countryCode={countryCode} />
</div>
<MapContainer
scrollWheelZoom={false}
zoomControl={false}
className='h-[50vh] rounded-lg relative z-0'
center={location || defaultLocation}
zoom={7}
>
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
/>
<ZoomControl position='bottomright' />
<Marker
position={location || defaultLocation}
icon={markerIcon}
></Marker>
</MapContainer>
</div>
)
}
export default PropertyMap
- properties/[id]/page.tsx
const DynamicMap = dynamic(
() => import('@/components/properties/PropertyMap'),
{
ssr: false,
loading: () => <Skeleton className='h-[400px] w-full' />,
}
)
return <DynamicMap countryCode={property.country} />
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.
"scripts": {
"dev": "next dev",
"build": "npx prisma generate && next build",
"start": "next start",
"lint": "next lint"
},
- refactor NavSearch Component
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 !!!!
npx prisma db push
- restart server
-
create components/reviews
- Comment.tsx
- PropertyReviews.tsx
- Rating.tsx
- SubmitReview.tsx
- ReviewCard.tsx
-
create placeholder functions in actions.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' }
}
- components/form/RatingInput.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 (
<div className='mb-2 max-w-xs'>
<Label htmlFor={name} className='capitalize'>
{labelText || name}
</Label>
<Select defaultValue={numbers[0]} name={name} required>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{numbers.map((number) => {
return (
<SelectItem key={number} value={number}>
{number}
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
)
}
export default RatingInput
- app/properties/[id]
return (
<section>
<section></section>
{/* after two column section */}
<SubmitReview propertyId={property.id} />;
</section>
)
- components/reviews/SubmitReview.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 (
<div className='mt-8'>
<Button onClick={() => setIsReviewFormVisible((prev) => !prev)}>
Leave a Review
</Button>
{isReviewFormVisible && (
<Card className='p-8 mt-8'>
<FormContainer action={createReviewAction}>
<input type='hidden' name='propertyId' value={propertyId} />
<RatingInput name='rating' />
<TextAreaInput
name='comment'
labelText='your thoughts on this property'
defaultValue='Amazing place !!!'
/>
<SubmitButton text='Submit' className='mt-4' />
</FormContainer>
</Card>
)}
</div>
)
}
export default SubmitReview
- optional : set rows prop in TextArea.tsx
- utils/schemas.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
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)
}
}
- actions.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
}
- app/properties/[id]
return (
<>
{/* after two column section */}
<SubmitReview propertyId={property.id} />
<PropertyReviews propertyId={property.id} />
</>
)
- components/reviews/PropertyReviews.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 (
<div className='mt-8'>
<Title text='Reviews' />
<div className='grid md:grid-cols-2 gap-8 mt-4 '>
{reviews.map((review) => {
const { comment, rating } = review
const { firstName, profileImage } = review.profile
const reviewInfo = {
comment,
rating,
name: firstName,
image: profileImage,
}
return <ReviewCard key={review.id} reviewInfo={reviewInfo} />
})}
</div>
</div>
)
}
export default PropertyReviews
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 (
<Card className='relative'>
<CardHeader>
<div className='flex items-center'>
<img
src={reviewInfo.image}
alt='profile'
className='w-12 h-12 rounded-full object-cover'
/>
<div className='ml-4'>
<h3 className='text-sm font-bold capitalize mb-1'>
{reviewInfo.name}
</h3>
<Rating rating={reviewInfo.rating} />
</div>
</div>
</CardHeader>
<CardContent>
<Comment comment={reviewInfo.comment} />
</CardContent>
{/* delete button later */}
<div className='absolute top-3 right-3'>{children}</div>
</Card>
)
}
export default ReviewCard
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 (
<div className='flex items-center gap-x-1'>
{stars.map((isFilled, i) => {
const className = `w-3 h-3 ${
isFilled ? 'text-primary' : 'text-gray-400'
}`
return isFilled ? (
<FaStar className={className} key={i} />
) : (
<FaRegStar className={className} key={i} />
)
})}
</div>
)
}
export default Rating
'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 (
<div>
<p className='text-sm'>{displayComment}</p>
{longComment && (
<Button
variant='link'
className='pl-0 text-muted-foreground'
onClick={toggleExpanded}
>
{isExpanded ? 'Show Less' : 'Show More'}
</Button>
)}
</div>
)
}
export default Comment
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)
}
}
- components/form/Buttons.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 <LuPenSquare />
case 'delete':
return <LuTrash2 />
default:
const never: never = actionType
throw new Error(`Invalid action type: ${never}`)
}
}
return (
<Button
type='submit'
size='icon'
variant='link'
className='p-2 cursor-pointer'
>
{pending ? <ReloadIcon className=' animate-spin' /> : renderIcon()}
</Button>
)
}
- app/reviews/page.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 <EmptyList />
return (
<>
<Title text='Your Reviews' />
<section className='grid md:grid-cols-2 gap-8 mt-4 '>
{reviews.map((review) => {
const { comment, rating } = review
const { name, image } = review.property
const reviewInfo = {
comment,
rating,
name,
image,
}
return (
<ReviewCard key={review.id} reviewInfo={reviewInfo}>
<DeleteReview reviewId={review.id} />
</ReviewCard>
)
})}
</section>
</>
)
}
const DeleteReview = ({ reviewId }: { reviewId: string }) => {
const deleteReview = deleteReviewAction.bind(null, { reviewId })
return (
<FormContainer action={deleteReview}>
<IconButton actionType='delete' />
</FormContainer>
)
}
export default ReviewsPage
- loading.tsx
'use client'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
function loading() {
return (
<section className='grid md:grid-cols-2 gap-8 mt-4 '>
<ReviewLoadingCard />
<ReviewLoadingCard />
</section>
)
}
const ReviewLoadingCard = () => {
return (
<Card>
<CardHeader>
<div className='flex items-center'>
<Skeleton className='w-12 h-12 rounded-full' />
<div className='ml-4'>
<Skeleton className='w-[150px] h-4 mb-2' />
<Skeleton className='w-[100px] h-4' />
</div>
</div>
</CardHeader>
</Card>
)
}
export default loading
- actions.ts
export const findExistingReview = async (
userId: string,
propertyId: string
) => {
return db.review.findFirst({
where: {
profileId: userId,
propertyId: propertyId,
},
})
}
- app/properties/[id]
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 && <SubmitReview propertyId={property.id} />}</>
}
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.
const user = await prisma.user.findUnique({
where: {
email: '[email protected]',
},
})
const user = await prisma.user.findFirst({
where: {
email: {
contains: 'prisma.io',
},
},
orderBy: {
name: 'asc',
},
})
- actions
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
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 (
<span className={className}>
<FaStar className='w-3 h-3' />
{rating} {countValue}
</span>
)
}
export default PropertyRating
- schema.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[]
}
npx prisma db push
- restart server !!!
- actions.ts
export const fetchPropertyDetails = (id: string) => {
return db.property.findUnique({
where: {
id,
},
include: {
profile: true,
bookings: {
select: {
checkIn: true,
checkOut: true,
},
},
},
})
}
- utils/types.ts
export type DateRangeSelect = {
startDate: Date
endDate: Date
key: string
}
export type Booking = {
checkIn: Date
checkOut: Date
}
-
remove @/components/properties/BookingCalendar.tsx
-
create @/components/booking
- BookingCalendar.tsx
- BookingContainer.tsx
- BookingForm.tsx
- BookingWrapper.tsx
- ConfirmBooking.tsx
npm install zustand
- utils/store.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<PropertyState>(() => {
return {
propertyId: '',
price: 0,
bookings: [],
range: undefined,
}
})
'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 (
<>
<BookingCalendar />
<BookingContainer />
</>
)
}
- properties/[id]/page.tsx
const DynamicBookingWrapper = dynamic(
() => import('@/components/booking/BookingWrapper'),
{
ssr: false,
loading: () => <Skeleton className='h-[200px] w-full' />,
}
)
return (
<div className='lg:col-span-4 flex flex-col items-center'>
{/* calendar */}
<DynamicBookingWrapper
propertyId={property.id}
price={property.price}
bookings={property.bookings}
/>
</div>
)
- utils/calendar.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.000
const 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 part
disabledDays.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
}
'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<DateRange | undefined>(defaultSelected)
useEffect(() => {
useProperty.setState({ range })
}, [range])
return (
<Calendar
mode='range'
defaultMonth={currentDate}
selected={range}
onSelect={setRange}
className='mb-4'
/>
)
}
export default BookingCalendar
'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 (
<div className='w-full'>
<BookingForm />
<ConfirmBooking />
</div>
)
}
export default BookingContainer
- utils/calculateTotals.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 }
}
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 (
<Card className='p-8 mb-4'>
<CardTitle className='mb-8'>Summary </CardTitle>
<FormRow label={`$${price} x ${totalNights} nights`} amount={subTotal} />
<FormRow label='Cleaning Fee' amount={cleaning} />
<FormRow label='Service Fee' amount={service} />
<FormRow label='Tax' amount={tax} />
<Separator className='mt-4' />
<CardTitle className='mt-8'>
<FormRow label='Booking Total' amount={orderTotal} />
</CardTitle>
</Card>
)
}
function FormRow({ label, amount }: { label: string; amount: number }) {
return (
<p className='flex justify-between text-sm mb-2'>
<span>{label}</span>
<span>{formatCurrency(amount)}</span>
</p>
)
}
export default BookingForm
- action.ts
export const createBookingAction = async () => {
return { message: 'create booking' }
}
'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 (
<SignInButton mode='modal'>
<Button type='button' className='w-full'>
Sign In to Complete Booking
</Button>
</SignInButton>
)
const createBooking = createBookingAction.bind(null, {
propertyId,
checkIn,
checkOut,
})
return (
<section>
<FormContainer action={createBooking}>
<SubmitButton text='Reserve' className='w-full' />
</FormContainer>
</section>
)
}
export default ConfirmBooking
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')
}
BookingCalendar.tsx
function BookingCalendar() {
const bookings = useProperty((state) => state.bookings)
const blockedPeriods = generateBlockedPeriods({
bookings,
today: currentDate,
})
return (
<Calendar
mode='range'
defaultMonth={currentDate}
selected={range}
onSelect={setRange}
className='mb-4'
// add disabled
disabled={blockedPeriods}
/>
)
}
export default BookingCalendar
BookingCalendar.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 (
<Calendar
mode='range'
defaultMonth={currentDate}
selected={range}
onSelect={setRange}
className='mb-4'
// add disabled
disabled={blockedPeriods}
/>
)
}
export default BookingCalendar
- actions.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)
}
}
- utils/format.ts
export const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
Bookings.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 <EmptyList />
}
return (
<div className='mt-16'>
<h4 className='mb-4 capitalize'>total bookings : {bookings.length}</h4>
<Table>
<TableCaption>A list of your recent bookings.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Property Name</TableHead>
<TableHead>Country</TableHead>
<TableHead>Nights</TableHead>
<TableHead>Total</TableHead>
<TableHead>Check In</TableHead>
<TableHead>Check Out</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={id}>
<TableCell>
<Link
href={`/properties/${propertyId}`}
className='underline text-muted-foreground tracking-wide'
>
{name}
</Link>
</TableCell>
<TableCell>
<CountryFlagAndName countryCode={country} />
</TableCell>
<TableCell>{totalNights}</TableCell>
<TableCell>{formatCurrency(orderTotal)}</TableCell>
<TableCell>{startDate}</TableCell>
<TableCell>{endDate}</TableCell>
<TableCell>
<DeleteBooking bookingId={id} />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
function DeleteBooking({ bookingId }: { bookingId: string }) {
const deleteBooking = deleteBookingAction.bind(null, { bookingId })
return (
<FormContainer action={deleteBooking}>
<IconButton actionType='delete' />
</FormContainer>
)
}
export default BookingsPage
- create @/components/booking/LoadingTable.tsx
import { Skeleton } from '../ui/skeleton'
function LoadingTable({ rows }: { rows?: number }) {
const tableRows = Array.from({ length: rows || 5 }, (_, i) => {
return (
<div className='mb-4' key={i}>
<Skeleton className='w-full h-8 rounded' />
</div>
)
})
return <>{tableRows}</>
}
export default LoadingTable
- create app/bookings/loading.tsx
'use client'
import LoadingTable from '@/components/booking/LoadingTable'
function loading() {
return <LoadingTable />
}
export default loading
- actions.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)
}
}
- create rentals/loading.tsx
'use client'
import LoadingTable from '@/components/booking/LoadingTable'
function loading() {
return <LoadingTable />
}
export default loading
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 (
<EmptyList
heading='No rentals to display.'
message="Don't hesitate to create a rental."
/>
)
}
return (
<div className='mt-16'>
<h4 className='mb-4 capitalize'>Active Properties : {rentals.length}</h4>
<Table>
<TableCaption>A list of all your properties.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Property Name</TableHead>
<TableHead>Nightly Rate </TableHead>
<TableHead>Nights Booked</TableHead>
<TableHead>Total Income</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rentals.map((rental) => {
const { id: propertyId, name, price } = rental
const { totalNightsSum, orderTotalSum } = rental
return (
<TableRow key={propertyId}>
<TableCell>
<Link
href={`/properties/${propertyId}`}
className='underline text-muted-foreground tracking-wide'
>
{name}
</Link>
</TableCell>
<TableCell>{formatCurrency(price)}</TableCell>
<TableCell>{totalNightsSum || 0}</TableCell>
<TableCell>{formatCurrency(orderTotalSum)}</TableCell>
<TableCell className='flex items-center gap-x-2'>
<Link href={`/rentals/${propertyId}/edit`}>
<IconButton actionType='edit'></IconButton>
</Link>
<DeleteRental propertyId={propertyId} />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
function DeleteRental({ propertyId }: { propertyId: string }) {
const deleteRental = deleteRentalAction.bind(null, { propertyId })
return (
<FormContainer action={deleteRental}>
<IconButton actionType='delete' />
</FormContainer>
)
}
export default RentalsPage
- actions.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/[id]/edit/page.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 (
<section>
<h1 className='text-2xl font-semibold mb-8 capitalize'>Edit Property</h1>
<div className='border p-8 rounded-md '>
<ImageInputContainer
name={property.name}
text='Update Image'
action={updatePropertyImageAction}
image={property.image}
>
<input type='hidden' name='id' value={property.id} />
</ImageInputContainer>
<FormContainer action={updatePropertyAction}>
<input type='hidden' name='id' value={property.id} />
<div className='grid md:grid-cols-2 gap-8 mb-4 mt-8'>
<FormInput
name='name'
type='text'
label='Name (20 limit)'
defaultValue={property.name}
/>
<FormInput
name='tagline'
type='text '
label='Tagline (30 limit)'
defaultValue={property.tagline}
/>
<PriceInput defaultValue={property.price} />
<CategoriesInput defaultValue={property.category} />
<CountriesInput defaultValue={property.country} />
</div>
<TextAreaInput
name='description'
labelText='Description (10 - 100 Words)'
defaultValue={property.description}
/>
<h3 className='text-lg mt-8 mb-4 font-medium'>
Accommodation Details
</h3>
<CounterInput detail='guests' defaultValue={property.guests} />
<CounterInput detail='bedrooms' defaultValue={property.bedrooms} />
<CounterInput detail='beds' defaultValue={property.beds} />
<CounterInput detail='baths' defaultValue={property.baths} />
<h3 className='text-lg mt-10 mb-6 font-medium'>Amenities</h3>
<AmenitiesInput defaultValue={defaultAmenities} />
<SubmitButton text='edit property' className='mt-12' />
</FormContainer>
</div>
</section>
)
}
export default EditRentalPage
'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<Amenity[]>(
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 (
<section>
<input
type='hidden'
name='amenities'
value={JSON.stringify(selectedAmenities)}
/>
<div className='grid grid-cols-2 gap-4'>
{selectedAmenities.map((amenity) => {
return (
<div key={amenity.name} className='flex items-center space-x-2'>
<Checkbox
id={amenity.name}
checked={amenity.selected}
onCheckedChange={() => handleChange(amenity)}
/>
<label
htmlFor={amenity.name}
className='text-sm font-medium leading-none capitalize flex gap-x-2 items-center'
>
{amenity.name} <amenity.icon className='w-4 h-4' />
</label>
</div>
)
})}
</div>
</section>
)
}
export default AmenitiesInput
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)
}
}
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)
}
}
- in app/reservations create page.tsx and loading.tsx
'use client'
import LoadingTable from '@/components/booking/LoadingTable'
function loading() {
return <LoadingTable />
}
export default loading
- add to links
utils/links.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' },
]
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
}
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 <EmptyList />
}
return (
<div className='mt-16'>
<h4 className='mb-4 capitalize'>
total reservations : {reservations.length}
</h4>
<Table>
<TableCaption>A list of your recent reservations.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Property Name</TableHead>
<TableHead>Country</TableHead>
<TableHead>Nights</TableHead>
<TableHead>Total</TableHead>
<TableHead>Check In</TableHead>
<TableHead>Check Out</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={id}>
<TableCell>
<Link
href={`/properties/${propertyId}`}
className='underline text-muted-foreground tracking-wide'
>
{name}
</Link>
</TableCell>
<TableCell>
<CountryFlagAndName countryCode={country} />
</TableCell>
<TableCell>{totalNights}</TableCell>
<TableCell>{formatCurrency(orderTotal)}</TableCell>
<TableCell>{startDate}</TableCell>
<TableCell>{endDate}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
export default ReservationsPage
- create app/admin/page.tsx
- add admin to links
- create components/admin
- Chart.tsx
- ChartsContainer.tsx
- Loading.tsx
- StatsCard.tsx
- StatsContainer.tsx
- refactor middleware
- create ENV variable with userId
- add to VERCEL
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)(.*)'],
}
- LinksDropdown.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 (
<DropdownMenuItem key={link.href}>
<Link href={link.href} className='capitalize w-full'>
{link.label}
</Link>
</DropdownMenuItem>
)
})}
</>
)
import { Card, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
export function StatsLoadingContainer() {
return (
<div className='mt-8 grid md:grid-cols-2 gap-4 lg:grid-cols-3'>
<LoadingCard />
<LoadingCard />
<LoadingCard />
</div>
)
}
function LoadingCard() {
return (
<Card>
<CardHeader>
<Skeleton className='w-full h-20 rounded' />
</CardHeader>
</Card>
)
}
export function ChartsLoadingContainer() {
return <Skeleton className='mt-16 w-full h-[300px] rounded' />
}
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 (
<>
<Suspense fallback={<StatsLoadingContainer />}>
<StatsContainer />
</Suspense>
<Suspense fallback={<ChartsLoadingContainer />}>
<ChartsContainer />
</Suspense>
</>
)
}
export default AdminPage
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,
}
}
import { fetchStats } from '@/utils/actions'
import StatsCard from './StatsCard'
async function StatsContainer() {
const data = await fetchStats()
return (
<div className='mt-8 grid md:grid-cols-2 gap-4 lg:grid-cols-3'>
<StatsCard title='users' value={data?.usersCount || 0} />
<StatsCard title='properties' value={data?.propertiesCount || 0} />
<StatsCard title='bookings' value={data?.bookingsCount || 0} />
</div>
)
}
export default StatsContainer
import { Card, CardHeader } from '@/components/ui/card'
type StatsCardsProps = {
title: string
value: number
}
function StatsCards({ title, value }: StatsCardsProps) {
return (
<Card className='bg-muted'>
<CardHeader className='flex flex-row justify-between items-center'>
<h3 className='capitalize text-3xl font-bold'>{title}</h3>
<span className='text-primary text-5xl font-extrabold'>{value}</span>
</CardHeader>
</Card>
)
}
export default StatsCards
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
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)
}
import { fetchChartsData } from '@/utils/actions'
import Chart from './Chart'
async function ChartsContainer() {
const bookings = await fetchChartsData()
if (bookings.length < 1) return null
return <Chart data={bookings} />
}
export default ChartsContainer
npm install recharts
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts'
type ChartPropsType = {
data: {
date: string
count: number
}[]
}
function Chart({ data }: ChartPropsType) {
return (
<section className='mt-24'>
<h1 className='text-4xl font-semibold text-center'>Monthly Bookings</h1>
<ResponsiveContainer width='100%' height={300}>
<BarChart data={data} margin={{ top: 50 }}>
<CartesianGrid strokeDasharray='3 3' />
<XAxis dataKey='date' />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey='count' fill='#F97215' barSize={75} />
</BarChart>
</ResponsiveContainer>
</section>
)
}
export default Chart
- setup and add keys to .env
- install
npm install --save @stripe/react-stripe-js @stripe/stripe-js stripe axios
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}`);
};
'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 (
<div id='checkout'>
<EmbeddedCheckoutProvider stripe={stripePromise} options={options}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
)
}
api/payment/route.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.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')
}
- remove all bookings with 'paymentStatus' false, before creating a booking createBookingAction.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
const bookingsCount = await db.booking.count({ where: { paymentStatus: true, }, })
- fetchChartsData
- actions.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
import StatsCards from '@/components/admin/StatsCard'
import { fetchReservationStats } from '@/utils/actions'
import { formatCurrency } from '@/utils/format'
async function Stats() {
const stats = await fetchReservationStats()
return (
<div className='mt-8 grid md:grid-cols-2 gap-4 lg:grid-cols-3'>
<StatsCards title='properties' value={stats.properties} />
<StatsCards title='nights' value={stats.nights} />
<StatsCards title='total' value={formatCurrency(stats.amount)} />
</div>
)
}
export default Stats
- refactor StatsCard.tsx
import { Card, CardHeader } from '@/components/ui/card'
type StatsCardsProps = {
title: string
value: number | string
}
- render in reservations
import Stats from '@/components/reservations/Stats'
return (
<>
<Stats />
....
</>
)