- Node.js
- Next.js
- TypeScript
- TailwindCSS
- Stripe
- Zod
- React Hook Form
- Shadcn
- uploadthing
Cloning the Repository
git clone https://github.com/your-username/your-project.git
cd your-project
Installation
Install the project dependencies using npm:
npm install
Set Up Environment Variables
Create a new file named .env
in the root of your project and add the following content:
#NEXT
NEXT_PUBLIC_SERVER_URL=
#CLERK
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
NEXT_CLERK_WEBHOOK_SECRET=
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/
#MONGODB
MONGODB_URI=
#UPLOADTHING
UPLOADTHING_SECRET=
UPLOADTHING_APP_ID=
#STRIPE
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
Replace the placeholder values with your actual credentials
Running the Project
npm start
Open http://localhost:3000 in your browser to view the project.
globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
* {
list-style: none;
padding: 0;
margin: 0;
scroll-behavior: smooth;
}
body {
font-family: var(--font-poppins)
}
.filter-grey {
filter: brightness(0) saturate(100%) invert(47%) sepia(0%) saturate(217%)
hue-rotate(32deg) brightness(98%) contrast(92%);
}
/* ========================================== TAILWIND STYLES */
@layer utilities {
.wrapper {
@apply max-w-7xl lg:mx-auto p-5 md:px-10 xl:px-0 w-full;
}
.flex-center {
@apply flex justify-center items-center;
}
.flex-between {
@apply flex justify-between items-center;
}
/* TYPOGRAPHY */
/* 64 */
.h1-bold {
@apply font-bold text-[40px] leading-[48px] lg:text-[48px] lg:leading-[60px] xl:text-[58px] xl:leading-[74px];
}
/* 40 */
.h2-bold {
@apply font-bold text-[32px] leading-[40px] lg:text-[36px] lg:leading-[44px] xl:text-[40px] xl:leading-[48px];
}
.h2-medium {
@apply font-medium text-[32px] leading-[40px] lg:text-[36px] lg:leading-[44px] xl:text-[40px] xl:leading-[48px];
}
/* 36 */
.h3-bold {
@apply font-bold text-[28px] leading-[36px] md:text-[36px] md:leading-[44px];
}
.h3-medium {
@apply font-medium text-[28px] leading-[36px] md:text-[36px] md:leading-[44px];
}
/* 32 */
.h4-medium {
@apply font-medium text-[32px] leading-[40px];
}
/* 28 */
.h5-bold {
@apply font-bold text-[28px] leading-[36px];
}
/* 24 */
.p-bold-24 {
@apply font-bold text-[24px] leading-[36px];
}
.p-medium-24 {
@apply font-medium text-[24px] leading-[36px];
}
.p-regular-24 {
@apply font-normal text-[24px] leading-[36px];
}
/* 20 */
.p-bold-20 {
@apply font-bold text-[20px] leading-[30px] tracking-[2%];
}
.p-semibold-20 {
@apply text-[20px] font-semibold leading-[30px] tracking-[2%];
}
.p-medium-20 {
@apply text-[20px] font-medium leading-[30px];
}
.p-regular-20 {
@apply text-[20px] font-normal leading-[30px] tracking-[2%];
}
/* 18 */
.p-semibold-18 {
@apply text-[18px] font-semibold leading-[28px] tracking-[2%];
}
.p-medium-18 {
@apply text-[18px] font-medium leading-[28px];
}
.p-regular-18 {
@apply text-[18px] font-normal leading-[28px] tracking-[2%];
}
/* 16 */
.p-bold-16 {
@apply text-[16px] font-bold leading-[24px];
}
.p-medium-16 {
@apply text-[16px] font-medium leading-[24px];
}
.p-regular-16 {
@apply text-[16px] font-normal leading-[24px];
}
/* 14 */
.p-semibold-14 {
@apply text-[14px] font-semibold leading-[20px];
}
.p-medium-14 {
@apply text-[14px] font-medium leading-[20px];
}
.p-regular-14 {
@apply text-[14px] font-normal leading-[20px];
}
/* 12 */
.p-medium-12 {
@apply text-[12px] font-medium leading-[20px];
}
/* SHADCN OVERRIDES */
.select-field {
@apply w-full bg-grey-50 h-[54px] placeholder:text-grey-500 rounded-full p-regular-16 px-5 py-3 border-none focus-visible:ring-transparent focus:ring-transparent !important;
}
.input-field {
@apply bg-grey-50 h-[54px] focus-visible:ring-offset-0 placeholder:text-grey-500 rounded-full p-regular-16 px-4 py-3 border-none focus-visible:ring-transparent !important;
}
.textarea {
@apply bg-grey-50 flex flex-1 placeholder:text-grey-500 p-regular-16 px-5 py-3 border-none focus-visible:ring-transparent !important;
}
.button {
@apply rounded-full h-[54px] p-regular-16;
}
.select-item {
@apply py-3 cursor-pointer focus:bg-primary-50;
}
.toggle-switch {
@apply bg-gray-300 !important;
}
}
/* ========================================== CLERK STYLES */
.cl-logoImage {
height: 38px;
}
.cl-userButtonBox {
flex-direction: row-reverse;
}
.cl-userButtonOuterIdentifier {
font-size: 16px;
}
.cl-userButtonPopoverCard {
right: 4px !important;
}
.cl-formButtonPrimary:hover,
.cl-formButtonPrimary:focus,
.cl-formButtonPrimary:active {
background-color: #705CF7
}
/* ========================================== REACT-DATEPICKER STYLES */
.datePicker {
width: 100%;
}
.react-datepicker__input-container input {
background-color: transparent;
width: 100%;
outline: none;
margin-left: 16px;
}
.react-datepicker__day--selected {
background-color: #624cf5 !important;
color: #ffffff !important;
border-radius: 4px;
}
.react-datepicker__time-list-item--selected {
background-color: #624cf5 !important;
}
tailwind.config.ts
/** @type {import('tailwindcss').Config} */
import { withUt } from 'uploadthing/tw';
module.exports = withUt({
darkMode: ['class'],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
primary: {
500: '#624CF5',
50: ' #F6F8FD',
DEFAULT: '#624CF5',
foreground: 'hsl(var(--primary-foreground))',
},
coral: {
500: '#15BF59',
},
grey: {
600: '#545454', // Subdued - color name in figma
500: '#757575',
400: '#AFAFAF', // Disabled - color name in figma
50: '#F6F6F6', // White Grey - color name in figma
},
black: '#000000',
white: '#FFFFFF',
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
foreground: 'hsl(var(--foreground))',
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
fontFamily: {
poppins: ['var(--font-poppins)'],
},
backgroundImage: {
'dotted-pattern': "url('/assets/images/dotted-pattern.png')",
'hero-img': "url('/assets/images/hero.png')",
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
});
Clerk webhook
import { Webhook } from 'svix'
import { headers } from 'next/headers'
import { WebhookEvent } from '@clerk/nextjs/server'
import { createUser, deleteUser, updateUser } from '@/lib/actions/user.actions'
import { clerkClient } from '@clerk/nextjs'
import { NextResponse } from 'next/server'
export async function POST(req: Request) {
// You can find this in the Clerk Dashboard -> Webhooks -> choose the webhook
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET
if (!WEBHOOK_SECRET) {
throw new Error('Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local')
}
// Get the headers
const headerPayload = headers();
const svix_id = headerPayload.get("svix-id");
const svix_timestamp = headerPayload.get("svix-timestamp");
const svix_signature = headerPayload.get("svix-signature");
// If there are no headers, error out
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response('Error occured -- no svix headers', {
status: 400
})
}
// Get the body
const payload = await req.json()
const body = JSON.stringify(payload);
// Create a new Svix instance with your secret.
const wh = new Webhook(WEBHOOK_SECRET);
let evt: WebhookEvent
// Verify the payload with the headers
try {
evt = wh.verify(body, {
"svix-id": svix_id,
"svix-timestamp": svix_timestamp,
"svix-signature": svix_signature,
}) as WebhookEvent
} catch (err) {
console.error('Error verifying webhook:', err);
return new Response('Error occured', {
status: 400
})
}
// Get the ID and type
const { id } = evt.data;
const eventType = evt.type;
if(eventType === 'user.created') {
const { id, email_addresses, image_url, first_name, last_name, username } = evt.data;
const user = {
clerkId: id,
email: email_addresses[0].email_address,
username: username!,
firstName: first_name,
lastName: last_name,
photo: image_url,
}
const newUser = await createUser(user);
if(newUser) {
await clerkClient.users.updateUserMetadata(id, {
publicMetadata: {
userId: newUser._id
}
})
}
return NextResponse.json({ message: 'OK', user: newUser })
}
if (eventType === 'user.updated') {
const {id, image_url, first_name, last_name, username } = evt.data
const user = {
firstName: first_name,
lastName: last_name,
username: username!,
photo: image_url,
}
const updatedUser = await updateUser(id, user)
return NextResponse.json({ message: 'OK', user: updatedUser })
}
if (eventType === 'user.deleted') {
const { id } = evt.data
const deletedUser = await deleteUser(id!)
return NextResponse.json({ message: 'OK', user: deletedUser })
}
return new Response('', { status: 200 })
}
user.actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { connectToDatabase } from '@/lib/database'
import User from '@/lib/database/models/user.model'
import Order from '@/lib/database/models/order.model'
import Event from '@/lib/database/models/event.model'
import { handleError } from '@/lib/utils'
import { CreateUserParams, UpdateUserParams } from '@/types'
export async function createUser(user: CreateUserParams) {
try {
await connectToDatabase()
const newUser = await User.create(user)
return JSON.parse(JSON.stringify(newUser))
} catch (error) {
handleError(error)
}
}
export async function getUserById(userId: string) {
try {
await connectToDatabase()
const user = await User.findById(userId)
if (!user) throw new Error('User not found')
return JSON.parse(JSON.stringify(user))
} catch (error) {
handleError(error)
}
}
export async function updateUser(clerkId: string, user: UpdateUserParams) {
try {
await connectToDatabase()
const updatedUser = await User.findOneAndUpdate({ clerkId }, user, { new: true })
if (!updatedUser) throw new Error('User update failed')
return JSON.parse(JSON.stringify(updatedUser))
} catch (error) {
handleError(error)
}
}
export async function deleteUser(clerkId: string) {
try {
await connectToDatabase()
// Find user to delete
const userToDelete = await User.findOne({ clerkId })
if (!userToDelete) {
throw new Error('User not found')
}
// Unlink relationships
await Promise.all([
// Update the 'events' collection to remove references to the user
Event.updateMany(
{ _id: { $in: userToDelete.events } },
{ $pull: { organizer: userToDelete._id } }
),
// Update the 'orders' collection to remove references to the user
Order.updateMany({ _id: { $in: userToDelete.orders } }, { $unset: { buyer: 1 } }),
])
// Delete user
const deletedUser = await User.findByIdAndDelete(userToDelete._id)
revalidatePath('/')
return deletedUser ? JSON.parse(JSON.stringify(deletedUser)) : null
} catch (error) {
handleError(error)
}
}
order.model.ts
import { Schema, model, models, Document } from 'mongoose'
export interface IOrder extends Document {
createdAt: Date
stripeId: string
totalAmount: string
event: {
_id: string
title: string
}
buyer: {
_id: string
firstName: string
lastName: string
}
}
export type IOrderItem = {
_id: string
totalAmount: string
createdAt: Date
eventTitle: string
eventId: string
buyer: string
}
const OrderSchema = new Schema({
createdAt: {
type: Date,
default: Date.now,
},
stripeId: {
type: String,
required: true,
unique: true,
},
totalAmount: {
type: String,
},
event: {
type: Schema.Types.ObjectId,
ref: 'Event',
},
buyer: {
type: Schema.Types.ObjectId,
ref: 'User',
},
})
const Order = models.Order || model('Order', OrderSchema)
export default Order
FileUploader.tsx
'use client'
import { useCallback, Dispatch, SetStateAction } from 'react'
import type { FileWithPath } from '@uploadthing/react'
import { useDropzone } from '@uploadthing/react/hooks'
import { generateClientDropzoneAccept } from 'uploadthing/client'
import { Button } from '@/components/ui/button'
import { convertFileToUrl } from '@/lib/utils'
type FileUploaderProps = {
onFieldChange: (url: string) => void
imageUrl: string
setFiles: Dispatch<SetStateAction<File[]>>
}
export function FileUploader({ imageUrl, onFieldChange, setFiles }: FileUploaderProps) {
const onDrop = useCallback((acceptedFiles: FileWithPath[]) => {
setFiles(acceptedFiles)
onFieldChange(convertFileToUrl(acceptedFiles[0]))
}, [])
const { getRootProps, getInputProps } = useDropzone({
onDrop,
accept: 'image/*' ? generateClientDropzoneAccept(['image/*']) : undefined,
})
return (
<div
{...getRootProps()}
className="flex-center bg-dark-3 flex h-72 cursor-pointer flex-col overflow-hidden rounded-xl bg-grey-50">
<input {...getInputProps()} className="cursor-pointer" />
{imageUrl ? (
<div className="flex h-full w-full flex-1 justify-center ">
<img
src={imageUrl}
alt="image"
width={250}
height={250}
className="w-full object-cover object-center"
/>
</div>
) : (
<div className="flex-center flex-col py-5 text-grey-500">
<img src="/assets/icons/upload.svg" width={77} height={77} alt="file upload" />
<h3 className="mb-2 mt-2">Drag photo here</h3>
<p className="p-medium-12 mb-4">SVG, PNG, JPG</p>
<Button type="button" className="rounded-full">
Select from computer
</Button>
</div>
)}
</div>
)
}
DeleteConfirmation.tsx
'use client'
import { useTransition } from 'react'
import { usePathname } from 'next/navigation'
import Image from 'next/image'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { deleteEvent } from '@/lib/actions/event.actions'
export const DeleteConfirmation = ({ eventId }: { eventId: string }) => {
const pathname = usePathname()
let [isPending, startTransition] = useTransition()
return (
<AlertDialog>
<AlertDialogTrigger>
<Image src="/assets/icons/delete.svg" alt="edit" width={20} height={20} />
</AlertDialogTrigger>
<AlertDialogContent className="bg-white">
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete?</AlertDialogTitle>
<AlertDialogDescription className="p-regular-16 text-grey-600">
This will permanently delete this event
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
startTransition(async () => {
await deleteEvent({ eventId, path: pathname })
})
}>
{isPending ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
event.action.ts
'use server'
import { revalidatePath } from 'next/cache'
import { connectToDatabase } from '@/lib/database'
import Event from '@/lib/database/models/event.model'
import User from '@/lib/database/models/user.model'
import Category from '@/lib/database/models/category.model'
import { handleError } from '@/lib/utils'
import {
CreateEventParams,
UpdateEventParams,
DeleteEventParams,
GetAllEventsParams,
GetEventsByUserParams,
GetRelatedEventsByCategoryParams,
} from '@/types'
const getCategoryByName = async (name: string) => {
return Category.findOne({ name: { $regex: name, $options: 'i' } })
}
const populateEvent = (query: any) => {
return query
.populate({ path: 'organizer', model: User, select: '_id firstName lastName' })
.populate({ path: 'category', model: Category, select: '_id name' })
}
// CREATE
export async function createEvent({ userId, event, path }: CreateEventParams) {
try {
await connectToDatabase()
const organizer = await User.findById(userId)
if (!organizer) throw new Error('Organizer not found')
const newEvent = await Event.create({ ...event, category: event.categoryId, organizer: userId })
revalidatePath(path)
return JSON.parse(JSON.stringify(newEvent))
} catch (error) {
handleError(error)
}
}
// GET ONE EVENT BY ID
export async function getEventById(eventId: string) {
try {
await connectToDatabase()
const event = await populateEvent(Event.findById(eventId))
if (!event) throw new Error('Event not found')
return JSON.parse(JSON.stringify(event))
} catch (error) {
handleError(error)
}
}
// UPDATE
export async function updateEvent({ userId, event, path }: UpdateEventParams) {
try {
await connectToDatabase()
const eventToUpdate = await Event.findById(event._id)
if (!eventToUpdate || eventToUpdate.organizer.toHexString() !== userId) {
throw new Error('Unauthorized or event not found')
}
const updatedEvent = await Event.findByIdAndUpdate(
event._id,
{ ...event, category: event.categoryId },
{ new: true }
)
revalidatePath(path)
return JSON.parse(JSON.stringify(updatedEvent))
} catch (error) {
handleError(error)
}
}
// DELETE
export async function deleteEvent({ eventId, path }: DeleteEventParams) {
try {
await connectToDatabase()
const deletedEvent = await Event.findByIdAndDelete(eventId)
if (deletedEvent) revalidatePath(path)
} catch (error) {
handleError(error)
}
}
// GET ALL EVENTS
export async function getAllEvents({ query, limit = 6, page, category }: GetAllEventsParams) {
try {
await connectToDatabase()
const titleCondition = query ? { title: { $regex: query, $options: 'i' } } : {}
const categoryCondition = category ? await getCategoryByName(category) : null
const conditions = {
$and: [titleCondition, categoryCondition ? { category: categoryCondition._id } : {}],
}
const skipAmount = (Number(page) - 1) * limit
const eventsQuery = Event.find(conditions)
.sort({ createdAt: 'desc' })
.skip(skipAmount)
.limit(limit)
const events = await populateEvent(eventsQuery)
const eventsCount = await Event.countDocuments(conditions)
return {
data: JSON.parse(JSON.stringify(events)),
totalPages: Math.ceil(eventsCount / limit),
}
} catch (error) {
handleError(error)
}
}
// GET EVENTS BY ORGANIZER
export async function getEventsByUser({ userId, limit = 6, page }: GetEventsByUserParams) {
try {
await connectToDatabase()
const conditions = { organizer: userId }
const skipAmount = (page - 1) * limit
const eventsQuery = Event.find(conditions)
.sort({ createdAt: 'desc' })
.skip(skipAmount)
.limit(limit)
const events = await populateEvent(eventsQuery)
const eventsCount = await Event.countDocuments(conditions)
return { data: JSON.parse(JSON.stringify(events)), totalPages: Math.ceil(eventsCount / limit) }
} catch (error) {
handleError(error)
}
}
// GET RELATED EVENTS: EVENTS WITH SAME CATEGORY
export async function getRelatedEventsByCategory({
categoryId,
eventId,
limit = 3,
page = 1,
}: GetRelatedEventsByCategoryParams) {
try {
await connectToDatabase()
const skipAmount = (Number(page) - 1) * limit
const conditions = { $and: [{ category: categoryId }, { _id: { $ne: eventId } }] }
const eventsQuery = Event.find(conditions)
.sort({ createdAt: 'desc' })
.skip(skipAmount)
.limit(limit)
const events = await populateEvent(eventsQuery)
const eventsCount = await Event.countDocuments(conditions)
return { data: JSON.parse(JSON.stringify(events)), totalPages: Math.ceil(eventsCount / limit) }
} catch (error) {
handleError(error)
}
}
order.action.ts
"use server"
import Stripe from 'stripe';
import { CheckoutOrderParams, CreateOrderParams, GetOrdersByEventParams, GetOrdersByUserParams } from "@/types"
import { redirect } from 'next/navigation';
import { handleError } from '../utils';
import { connectToDatabase } from '../database';
import Order from '../database/models/order.model';
import Event from '../database/models/event.model';
import {ObjectId} from 'mongodb';
import User from '../database/models/user.model';
export const checkoutOrder = async (order: CheckoutOrderParams) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const price = order.isFree ? 0 : Number(order.price) * 100;
try {
const session = await stripe.checkout.sessions.create({
line_items: [
{
price_data: {
currency: 'usd',
unit_amount: price,
product_data: {
name: order.eventTitle
}
},
quantity: 1
},
],
metadata: {
eventId: order.eventId,
buyerId: order.buyerId,
},
mode: 'payment',
success_url: `${process.env.NEXT_PUBLIC_SERVER_URL}/profile`,
cancel_url: `${process.env.NEXT_PUBLIC_SERVER_URL}/`,
});
redirect(session.url!)
} catch (error) {
throw error;
}
}
export const createOrder = async (order: CreateOrderParams) => {
try {
await connectToDatabase();
const newOrder = await Order.create({
...order,
event: order.eventId,
buyer: order.buyerId,
});
return JSON.parse(JSON.stringify(newOrder));
} catch (error) {
handleError(error);
}
}
// GET ORDERS BY EVENT
export async function getOrdersByEvent({ searchString, eventId }: GetOrdersByEventParams) {
try {
await connectToDatabase()
if (!eventId) throw new Error('Event ID is required')
const eventObjectId = new ObjectId(eventId)
const orders = await Order.aggregate([
{
$lookup: {
from: 'users',
localField: 'buyer',
foreignField: '_id',
as: 'buyer',
},
},
{
$unwind: '$buyer',
},
{
$lookup: {
from: 'events',
localField: 'event',
foreignField: '_id',
as: 'event',
},
},
{
$unwind: '$event',
},
{
$project: {
_id: 1,
totalAmount: 1,
createdAt: 1,
eventTitle: '$event.title',
eventId: '$event._id',
buyer: {
$concat: ['$buyer.firstName', ' ', '$buyer.lastName'],
},
},
},
{
$match: {
$and: [{ eventId: eventObjectId }, { buyer: { $regex: RegExp(searchString, 'i') } }],
},
},
])
return JSON.parse(JSON.stringify(orders))
} catch (error) {
handleError(error)
}
}
// GET ORDERS BY USER
export async function getOrdersByUser({ userId, limit = 3, page }: GetOrdersByUserParams) {
try {
await connectToDatabase()
const skipAmount = (Number(page) - 1) * limit
const conditions = { buyer: userId }
const orders = await Order.distinct('event._id')
.find(conditions)
.sort({ createdAt: 'desc' })
.skip(skipAmount)
.limit(limit)
.populate({
path: 'event',
model: Event,
populate: {
path: 'organizer',
model: User,
select: '_id firstName lastName',
},
})
const ordersCount = await Order.distinct('event._id').countDocuments(conditions)
return { data: JSON.parse(JSON.stringify(orders)), totalPages: Math.ceil(ordersCount / limit) }
} catch (error) {
handleError(error)
}
}
orders/page.tsx
import Search from '@/components/shared/Search'
import { getOrdersByEvent } from '@/lib/actions/order.actions'
import { formatDateTime, formatPrice } from '@/lib/utils'
import { SearchParamProps } from '@/types'
import { IOrderItem } from '@/lib/database/models/order.model'
const Orders = async ({ searchParams }: SearchParamProps) => {
const eventId = (searchParams?.eventId as string) || ''
const searchText = (searchParams?.query as string) || ''
const orders = await getOrdersByEvent({ eventId, searchString: searchText })
return (
<>
<section className=" bg-primary-50 bg-dotted-pattern bg-cover bg-center py-5 md:py-10">
<h3 className="wrapper h3-bold text-center sm:text-left ">Orders</h3>
</section>
<section className="wrapper mt-8">
<Search placeholder="Search buyer name..." />
</section>
<section className="wrapper overflow-x-auto">
<table className="w-full border-collapse border-t">
<thead>
<tr className="p-medium-14 border-b text-grey-500">
<th className="min-w-[250px] py-3 text-left">Order ID</th>
<th className="min-w-[200px] flex-1 py-3 pr-4 text-left">Event Title</th>
<th className="min-w-[150px] py-3 text-left">Buyer</th>
<th className="min-w-[100px] py-3 text-left">Created</th>
<th className="min-w-[100px] py-3 text-right">Amount</th>
</tr>
</thead>
<tbody>
{orders && orders.length === 0 ? (
<tr className="border-b">
<td colSpan={5} className="py-4 text-center text-gray-500">
No orders found.
</td>
</tr>
) : (
<>
{orders &&
orders.map((row: IOrderItem) => (
<tr
key={row._id}
className="p-regular-14 lg:p-regular-16 border-b "
style={{ boxSizing: 'border-box' }}>
<td className="min-w-[250px] py-4 text-primary-500">{row._id}</td>
<td className="min-w-[200px] flex-1 py-4 pr-4">{row.eventTitle}</td>
<td className="min-w-[150px] py-4">{row.buyer}</td>
<td className="min-w-[100px] py-4">
{formatDateTime(row.createdAt).dateTime}
</td>
<td className="min-w-[100px] py-4 text-right">
{formatPrice(row.totalAmount)}
</td>
</tr>
))}
</>
)}
</tbody>
</table>
</section>
</>
)
}
export default Orders