├── .gitignore ├── README.md ├── app ├── (auth) │ ├── layout.tsx │ ├── sign-in │ │ └── [[...sign-in]] │ │ │ └── page.tsx │ └── sign-up │ │ └── [[...sign-up]] │ │ └── page.tsx ├── (root) │ ├── events │ │ ├── [id] │ │ │ ├── page.tsx │ │ │ └── update │ │ │ │ └── page.tsx │ │ └── create │ │ │ └── page.tsx │ ├── layout.tsx │ ├── orders │ │ └── page.tsx │ ├── page.tsx │ └── profile │ │ └── page.tsx ├── api │ ├── uploadthing │ │ ├── core.ts │ │ └── route.ts │ └── webhook │ │ ├── clerk │ │ └── route.ts │ │ └── stripe │ │ └── route.ts ├── favicon.ico ├── globals.css └── layout.tsx ├── components.json ├── components ├── shared │ ├── Card.tsx │ ├── CategoryFilter.tsx │ ├── Checkout.tsx │ ├── CheckoutButton.tsx │ ├── Collection.tsx │ ├── DeleteConfirmation.tsx │ ├── Dropdown.tsx │ ├── EventForm.tsx │ ├── FileUploader.tsx │ ├── Footer.tsx │ ├── Header.tsx │ ├── MobileNav.tsx │ ├── NavItems.tsx │ ├── Pagination.tsx │ └── Search.tsx └── ui │ ├── alert-dialog.tsx │ ├── button.tsx │ ├── checkbox.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ └── textarea.tsx ├── constants └── index.ts ├── lib ├── actions │ ├── category.actions.ts │ ├── event.actions.ts │ ├── order.actions.ts │ └── user.actions.ts ├── database │ ├── index.ts │ └── models │ │ ├── category.model.ts │ │ ├── event.model.ts │ │ ├── order.model.ts │ │ └── user.model.ts ├── uploadthing.ts ├── utils.ts └── validator.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── assets │ ├── icons │ │ ├── arrow.svg │ │ ├── calendar.svg │ │ ├── clock.svg │ │ ├── delete.svg │ │ ├── dollar.svg │ │ ├── edit.svg │ │ ├── file-upload.svg │ │ ├── link.svg │ │ ├── loader.svg │ │ ├── location-grey.svg │ │ ├── location.svg │ │ ├── logo-grey.svg │ │ ├── menu.svg │ │ ├── search.svg │ │ ├── spinner.svg │ │ └── upload.svg │ └── images │ │ ├── dotted-pattern.png │ │ ├── hero.png │ │ ├── logo.svg │ │ ├── placeholder.png │ │ ├── test-2.png │ │ └── test.png ├── next.svg └── vercel.svg ├── tailwind.config.ts ├── tsconfig.json └── types └── index.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | Next.js 5 | TypeScript 6 | stripe 7 |
8 | 9 |

A Full Stack Next 14 Events App

10 | 11 | 12 | ## 📋 Table of Contents 13 | 14 | 1. 🤖 [Introduction](#introduction) 15 | 2. ⚙️ [Tech Stack](#tech-stack) 16 | 3. 🔋 [Features](#features) 17 | 4. 🤸 [Quick Start](#quick-start) 18 | 5. 🕸️ [Snippets](#snippets) 19 | 6. 🔗 [Links](#links) 20 | 7. 🚀 [More](#more) 21 | 22 | ## 🚨 Tutorial 23 | 24 | 25 | 26 | 27 | ## 🤖 Introduction 28 | 29 | Built on Next.js 14, the events application stands as a comprehensive, full-stack platform for managing events. It serves as a hub, spotlighting diverse events taking place globally. Featuring seamless payment processing through Stripe, you have the capability to purchase tickets for any event or even initiate and manage your own events. 30 | 31 | If you're getting started and need assistance or face any bugs, join our active Discord community with over 27k+ members. It's a place where people help each other out. 32 | 33 | 34 | ## ⚙️ Tech Stack 35 | 36 | - Node.js 37 | - Next.js 38 | - TypeScript 39 | - TailwindCSS 40 | - Stripe 41 | - Zod 42 | - React Hook Form 43 | - Shadcn 44 | - uploadthing 45 | 46 | ## 🔋 Features 47 | 48 | 👉 **Authentication (CRUD) with Clerk:** User management through Clerk, ensuring secure and efficient authentication. 49 | 50 | 👉 **Events (CRUD):** Comprehensive functionality for creating, reading, updating, and deleting events, giving users full control over event management. 51 | - **Create Events:** Users can effortlessly generate new events, providing essential details such as title, date, location, and any additional information. 52 | - **Read Events:** Seamless access to a detailed view of all events, allowing users to explore event specifics, including descriptions, schedules, and related information. 53 | - **Update Events:** Empowering users to modify event details dynamically, ensuring that event information remains accurate and up-to-date. 54 | - **Delete Events:** A straightforward process for removing events from the system, giving administrators the ability to manage and curate the platform effectively. 55 | 56 | 👉 **Related Events:** Smartly connects events that are related and displaying on the event details page, making it more engaging for users 57 | 58 | 👉 **Organized Events:** Efficient organization of events, ensuring a structured and user-friendly display for the audience, i.e., showing events created by the user on the user profile 59 | 60 | 👉 **Search & Filter:** Empowering users with a robust search and filter system, enabling them to easily find the events that match their preferences. 61 | 62 | 👉 **New Category:** Dynamic categorization allows for the seamless addition of new event categories, keeping your platform adaptable. 63 | 64 | 👉 **Checkout and Pay with Stripe:** Smooth and secure payment transactions using Stripe, enhancing user experience during the checkout process. 65 | 66 | 👉 **Event Orders:** Comprehensive order management system, providing a clear overview of all event-related transactions. 67 | 68 | 👉 **Search Orders:** Quick and efficient search functionality for orders, facilitating easy tracking and management. 69 | 70 | and many more, including code architecture and reusability 71 | 72 | ## 🤸 Quick Start 73 | 74 | Follow these steps to set up the project locally on your machine. 75 | 76 | **Prerequisites** 77 | 78 | Make sure you have the following installed on your machine: 79 | 80 | - [Git](https://git-scm.com/) 81 | - [Node.js](https://nodejs.org/en) 82 | - [npm](https://www.npmjs.com/) (Node Package Manager) 83 | 84 | **Cloning the Repository** 85 | 86 | ```bash 87 | git clone https://github.com/your-username/your-project.git 88 | cd your-project 89 | ``` 90 | 91 | **Installation** 92 | 93 | Install the project dependencies using npm: 94 | 95 | ```bash 96 | npm install 97 | ``` 98 | 99 | **Set Up Environment Variables** 100 | 101 | Create a new file named `.env` in the root of your project and add the following content: 102 | 103 | ```env 104 | #NEXT 105 | NEXT_PUBLIC_SERVER_URL= 106 | 107 | #CLERK 108 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 109 | CLERK_SECRET_KEY= 110 | NEXT_CLERK_WEBHOOK_SECRET= 111 | 112 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 113 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 114 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ 115 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ 116 | 117 | #MONGODB 118 | MONGODB_URI= 119 | 120 | #UPLOADTHING 121 | UPLOADTHING_SECRET= 122 | UPLOADTHING_APP_ID= 123 | 124 | #STRIPE 125 | STRIPE_SECRET_KEY= 126 | STRIPE_WEBHOOK_SECRET= 127 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= 128 | ``` 129 | 130 | Replace the placeholder values with your actual credentials 131 | 132 | **Running the Project** 133 | 134 | ```bash 135 | npm start 136 | ``` 137 | 138 | Open [http://localhost:3000](http://localhost:3000) in your browser to view the project. 139 | 140 | ## 🕸️ Snippets 141 | 142 |
143 | globals.css 144 | 145 | ```css 146 | @tailwind base; 147 | @tailwind components; 148 | @tailwind utilities; 149 | 150 | @layer base { 151 | :root { 152 | --background: 0 0% 100%; 153 | --foreground: 222.2 84% 4.9%; 154 | 155 | --card: 0 0% 100%; 156 | --card-foreground: 222.2 84% 4.9%; 157 | 158 | --popover: 0 0% 100%; 159 | --popover-foreground: 222.2 84% 4.9%; 160 | 161 | --primary: 222.2 47.4% 11.2%; 162 | --primary-foreground: 210 40% 98%; 163 | 164 | --secondary: 210 40% 96.1%; 165 | --secondary-foreground: 222.2 47.4% 11.2%; 166 | 167 | --muted: 210 40% 96.1%; 168 | --muted-foreground: 215.4 16.3% 46.9%; 169 | 170 | --accent: 210 40% 96.1%; 171 | --accent-foreground: 222.2 47.4% 11.2%; 172 | 173 | --destructive: 0 84.2% 60.2%; 174 | --destructive-foreground: 210 40% 98%; 175 | 176 | --border: 214.3 31.8% 91.4%; 177 | --input: 214.3 31.8% 91.4%; 178 | --ring: 222.2 84% 4.9%; 179 | 180 | --radius: 0.5rem; 181 | } 182 | 183 | .dark { 184 | --background: 222.2 84% 4.9%; 185 | --foreground: 210 40% 98%; 186 | 187 | --card: 222.2 84% 4.9%; 188 | --card-foreground: 210 40% 98%; 189 | 190 | --popover: 222.2 84% 4.9%; 191 | --popover-foreground: 210 40% 98%; 192 | 193 | --primary: 210 40% 98%; 194 | --primary-foreground: 222.2 47.4% 11.2%; 195 | 196 | --secondary: 217.2 32.6% 17.5%; 197 | --secondary-foreground: 210 40% 98%; 198 | 199 | --muted: 217.2 32.6% 17.5%; 200 | --muted-foreground: 215 20.2% 65.1%; 201 | 202 | --accent: 217.2 32.6% 17.5%; 203 | --accent-foreground: 210 40% 98%; 204 | 205 | --destructive: 0 62.8% 30.6%; 206 | --destructive-foreground: 210 40% 98%; 207 | 208 | --border: 217.2 32.6% 17.5%; 209 | --input: 217.2 32.6% 17.5%; 210 | --ring: 212.7 26.8% 83.9%; 211 | } 212 | } 213 | 214 | * { 215 | list-style: none; 216 | padding: 0; 217 | margin: 0; 218 | scroll-behavior: smooth; 219 | } 220 | 221 | body { 222 | font-family: var(--font-poppins) 223 | } 224 | 225 | .filter-grey { 226 | filter: brightness(0) saturate(100%) invert(47%) sepia(0%) saturate(217%) 227 | hue-rotate(32deg) brightness(98%) contrast(92%); 228 | } 229 | 230 | /* ========================================== TAILWIND STYLES */ 231 | @layer utilities { 232 | .wrapper { 233 | @apply max-w-7xl lg:mx-auto p-5 md:px-10 xl:px-0 w-full; 234 | } 235 | 236 | .flex-center { 237 | @apply flex justify-center items-center; 238 | } 239 | 240 | .flex-between { 241 | @apply flex justify-between items-center; 242 | } 243 | 244 | /* TYPOGRAPHY */ 245 | /* 64 */ 246 | .h1-bold { 247 | @apply font-bold text-[40px] leading-[48px] lg:text-[48px] lg:leading-[60px] xl:text-[58px] xl:leading-[74px]; 248 | } 249 | 250 | /* 40 */ 251 | .h2-bold { 252 | @apply font-bold text-[32px] leading-[40px] lg:text-[36px] lg:leading-[44px] xl:text-[40px] xl:leading-[48px]; 253 | } 254 | 255 | .h2-medium { 256 | @apply font-medium text-[32px] leading-[40px] lg:text-[36px] lg:leading-[44px] xl:text-[40px] xl:leading-[48px]; 257 | } 258 | 259 | /* 36 */ 260 | .h3-bold { 261 | @apply font-bold text-[28px] leading-[36px] md:text-[36px] md:leading-[44px]; 262 | } 263 | 264 | .h3-medium { 265 | @apply font-medium text-[28px] leading-[36px] md:text-[36px] md:leading-[44px]; 266 | } 267 | 268 | /* 32 */ 269 | .h4-medium { 270 | @apply font-medium text-[32px] leading-[40px]; 271 | } 272 | 273 | /* 28 */ 274 | .h5-bold { 275 | @apply font-bold text-[28px] leading-[36px]; 276 | } 277 | 278 | /* 24 */ 279 | .p-bold-24 { 280 | @apply font-bold text-[24px] leading-[36px]; 281 | } 282 | 283 | .p-medium-24 { 284 | @apply font-medium text-[24px] leading-[36px]; 285 | } 286 | 287 | .p-regular-24 { 288 | @apply font-normal text-[24px] leading-[36px]; 289 | } 290 | 291 | /* 20 */ 292 | .p-bold-20 { 293 | @apply font-bold text-[20px] leading-[30px] tracking-[2%]; 294 | } 295 | 296 | .p-semibold-20 { 297 | @apply text-[20px] font-semibold leading-[30px] tracking-[2%]; 298 | } 299 | 300 | .p-medium-20 { 301 | @apply text-[20px] font-medium leading-[30px]; 302 | } 303 | 304 | .p-regular-20 { 305 | @apply text-[20px] font-normal leading-[30px] tracking-[2%]; 306 | } 307 | 308 | /* 18 */ 309 | .p-semibold-18 { 310 | @apply text-[18px] font-semibold leading-[28px] tracking-[2%]; 311 | } 312 | 313 | .p-medium-18 { 314 | @apply text-[18px] font-medium leading-[28px]; 315 | } 316 | 317 | .p-regular-18 { 318 | @apply text-[18px] font-normal leading-[28px] tracking-[2%]; 319 | } 320 | 321 | /* 16 */ 322 | .p-bold-16 { 323 | @apply text-[16px] font-bold leading-[24px]; 324 | } 325 | 326 | .p-medium-16 { 327 | @apply text-[16px] font-medium leading-[24px]; 328 | } 329 | 330 | .p-regular-16 { 331 | @apply text-[16px] font-normal leading-[24px]; 332 | } 333 | 334 | /* 14 */ 335 | .p-semibold-14 { 336 | @apply text-[14px] font-semibold leading-[20px]; 337 | } 338 | 339 | .p-medium-14 { 340 | @apply text-[14px] font-medium leading-[20px]; 341 | } 342 | 343 | .p-regular-14 { 344 | @apply text-[14px] font-normal leading-[20px]; 345 | } 346 | 347 | /* 12 */ 348 | .p-medium-12 { 349 | @apply text-[12px] font-medium leading-[20px]; 350 | } 351 | 352 | /* SHADCN OVERRIDES */ 353 | .select-field { 354 | @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; 355 | } 356 | 357 | .input-field { 358 | @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; 359 | } 360 | 361 | .textarea { 362 | @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; 363 | } 364 | 365 | .button { 366 | @apply rounded-full h-[54px] p-regular-16; 367 | } 368 | 369 | .select-item { 370 | @apply py-3 cursor-pointer focus:bg-primary-50; 371 | } 372 | 373 | .toggle-switch { 374 | @apply bg-gray-300 !important; 375 | } 376 | } 377 | 378 | /* ========================================== CLERK STYLES */ 379 | .cl-logoImage { 380 | height: 38px; 381 | } 382 | 383 | .cl-userButtonBox { 384 | flex-direction: row-reverse; 385 | } 386 | 387 | .cl-userButtonOuterIdentifier { 388 | font-size: 16px; 389 | } 390 | 391 | .cl-userButtonPopoverCard { 392 | right: 4px !important; 393 | } 394 | 395 | .cl-formButtonPrimary:hover, 396 | .cl-formButtonPrimary:focus, 397 | .cl-formButtonPrimary:active { 398 | background-color: #705CF7 399 | } 400 | 401 | /* ========================================== REACT-DATEPICKER STYLES */ 402 | .datePicker { 403 | width: 100%; 404 | } 405 | 406 | .react-datepicker__input-container input { 407 | background-color: transparent; 408 | width: 100%; 409 | outline: none; 410 | margin-left: 16px; 411 | } 412 | 413 | .react-datepicker__day--selected { 414 | background-color: #624cf5 !important; 415 | color: #ffffff !important; 416 | border-radius: 4px; 417 | } 418 | 419 | .react-datepicker__time-list-item--selected { 420 | background-color: #624cf5 !important; 421 | } 422 | ``` 423 |
424 | 425 |
426 | tailwind.config.ts 427 | 428 | ```typescript 429 | /** @type {import('tailwindcss').Config} */ 430 | import { withUt } from 'uploadthing/tw' 431 | 432 | module.exports = withUt({ 433 | darkMode: ['class'], 434 | content: [ 435 | './pages/**/*.{ts,tsx}', 436 | './components/**/*.{ts,tsx}', 437 | './app/**/*.{ts,tsx}', 438 | './src/**/*.{ts,tsx}', 439 | ], 440 | theme: { 441 | container: { 442 | center: true, 443 | padding: '2rem', 444 | screens: { 445 | '2xl': '1400px', 446 | }, 447 | }, 448 | extend: { 449 | colors: { 450 | primary: { 451 | 500: '#624CF5', 452 | 50: ' #F6F8FD', 453 | DEFAULT: '#624CF5', 454 | foreground: 'hsl(var(--primary-foreground))', 455 | }, 456 | coral: { 457 | 500: '#15BF59', 458 | }, 459 | 460 | grey: { 461 | 600: '#545454', // Subdued - color name in figma 462 | 500: '#757575', 463 | 400: '#AFAFAF', // Disabled - color name in figma 464 | 50: '#F6F6F6', // White Grey - color name in figma 465 | }, 466 | black: '#000000', 467 | white: '#FFFFFF', 468 | border: 'hsl(var(--border))', 469 | input: 'hsl(var(--input))', 470 | ring: 'hsl(var(--ring))', 471 | foreground: 'hsl(var(--foreground))', 472 | secondary: { 473 | DEFAULT: 'hsl(var(--secondary))', 474 | foreground: 'hsl(var(--secondary-foreground))', 475 | }, 476 | destructive: { 477 | DEFAULT: 'hsl(var(--destructive))', 478 | foreground: 'hsl(var(--destructive-foreground))', 479 | }, 480 | muted: { 481 | DEFAULT: 'hsl(var(--muted))', 482 | foreground: 'hsl(var(--muted-foreground))', 483 | }, 484 | accent: { 485 | DEFAULT: 'hsl(var(--accent))', 486 | foreground: 'hsl(var(--accent-foreground))', 487 | }, 488 | popover: { 489 | DEFAULT: 'hsl(var(--popover))', 490 | foreground: 'hsl(var(--popover-foreground))', 491 | }, 492 | card: { 493 | DEFAULT: 'hsl(var(--card))', 494 | foreground: 'hsl(var(--card-foreground))', 495 | }, 496 | }, 497 | fontFamily: { 498 | poppins: 'var(--font-poppins)', 499 | }, 500 | backgroundImage: { 501 | 'dotted-pattern': "url('/assets/images/dotted-pattern.png')", 502 | 'hero-img': "url('/assets/images/hero.png')", 503 | }, 504 | borderRadius: { 505 | lg: 'var(--radius)', 506 | md: 'calc(var(--radius) - 2px)', 507 | sm: 'calc(var(--radius) - 4px)', 508 | }, 509 | keyframes: { 510 | 'accordion-down': { 511 | from: { height: '0' }, 512 | to: { height: 'var(--radix-accordion-content-height)' }, 513 | }, 514 | 'accordion-up': { 515 | from: { height: 'var(--radix-accordion-content-height)' }, 516 | to: { height: '0' }, 517 | }, 518 | }, 519 | animation: { 520 | 'accordion-down': 'accordion-down 0.2s ease-out', 521 | 'accordion-up': 'accordion-up 0.2s ease-out', 522 | }, 523 | }, 524 | }, 525 | plugins: [require('tailwindcss-animate')], 526 | }) 527 | ``` 528 |
529 | 530 |
531 | Clerk webhook 532 | 533 | ```typescript 534 | import { Webhook } from 'svix' 535 | import { headers } from 'next/headers' 536 | import { WebhookEvent } from '@clerk/nextjs/server' 537 | import { NextResponse } from 'next/server' 538 | import { clerkClient } from '@clerk/nextjs' 539 | 540 | import { createUser, updateUser, deleteUser } from '@/lib/actions/user.actions' 541 | 542 | export async function POST(req: Request) { 543 | 544 | // You can find this in the Clerk Dashboard -> Webhooks -> choose the webhook 545 | const WEBHOOK_SECRET = process.env.NEXT_CLERK_WEBHOOK_SECRET 546 | 547 | if (!WEBHOOK_SECRET) { 548 | throw new Error('Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local') 549 | } 550 | 551 | // Get the headers 552 | const headerPayload = headers() 553 | const svix_id = headerPayload.get('svix-id') 554 | const svix_timestamp = headerPayload.get('svix-timestamp') 555 | const svix_signature = headerPayload.get('svix-signature') 556 | 557 | // If there are no headers, error out 558 | if (!svix_id || !svix_timestamp || !svix_signature) { 559 | return new Response('Error occured -- no svix headers', { 560 | status: 400, 561 | }) 562 | } 563 | 564 | // Get the body 565 | const payload = await req.json() 566 | const body = JSON.stringify(payload) 567 | 568 | // Create a new Svix instance with your secret. 569 | const wh = new Webhook(WEBHOOK_SECRET) 570 | 571 | let evt: WebhookEvent 572 | 573 | // Verify the payload with the headers 574 | try { 575 | evt = wh.verify(body, { 576 | 'svix-id': svix_id, 577 | 'svix-timestamp': svix_timestamp, 578 | 'svix-signature': svix_signature, 579 | }) as WebhookEvent 580 | } catch (err) { 581 | console.error('Error verifying webhook:', err) 582 | return new Response('Error occured', { 583 | status: 400, 584 | }) 585 | } 586 | 587 | // Get the ID and type 588 | const eventType = evt.type 589 | 590 | // TODO START CHANGES 591 | // CREATE 592 | if (eventType === 'user.created') { 593 | const { id, email_addresses, image_url, first_name, last_name, username } = evt.data 594 | 595 | const user = { 596 | clerkId: id, 597 | email: email_addresses[0].email_address, 598 | username: username!, 599 | firstName: first_name, 600 | lastName: last_name, 601 | photo: image_url, 602 | } 603 | 604 | const newUser = await createUser(user) 605 | 606 | if (newUser) { 607 | await clerkClient.users.updateUserMetadata(id, { 608 | publicMetadata: { 609 | userId: newUser._id, 610 | }, 611 | }) 612 | } 613 | 614 | return NextResponse.json({ message: 'OK', user: newUser }) 615 | } 616 | 617 | // UPDATE 618 | if (eventType === 'user.updated') { 619 | 620 | const {id, image_url, first_name, last_name, username } = evt.data 621 | 622 | const user = { 623 | firstName: first_name, 624 | lastName: last_name, 625 | username: username!, 626 | photo: image_url, 627 | } 628 | 629 | const updatedUser = await updateUser(id, user) 630 | 631 | return NextResponse.json({ message: 'OK', user: updatedUser }) 632 | } 633 | 634 | // DELETE 635 | if (eventType === 'user.deleted') { 636 | const { id } = evt.data 637 | 638 | const deletedUser = await deleteUser(id!) 639 | 640 | return NextResponse.json({ message: 'OK', user: deletedUser }) 641 | } 642 | // TODO END CHANGES 643 | 644 | return new Response('', { status: 200 }) 645 | } 646 | ``` 647 |
648 | 649 |
650 | user.actions.ts 651 | 652 | ```typescript 653 | 'use server' 654 | 655 | import { revalidatePath } from 'next/cache' 656 | 657 | import { connectToDB } from '@/lib/mongodb/mongoose' 658 | import User from '@/lib/mongodb/models/user.model' 659 | import Order from '@/lib/mongodb/models/order.model' 660 | import Event from '@/lib/mongodb/models/event.model' 661 | import { handleError } from '@/lib/utils' 662 | 663 | import { CreateUserParams, UpdateUserParams } from '@/types' 664 | 665 | // CREATE 666 | export async function createUser(user: CreateUserParams) { 667 | try { 668 | await connectToDB() 669 | 670 | const newUser = await User.create(user) 671 | return JSON.parse(JSON.stringify(newUser)) 672 | } catch (error) { 673 | handleError(error) 674 | } 675 | } 676 | 677 | // READ 678 | export async function getUserById(userId: string) { 679 | try { 680 | await connectToDB() 681 | 682 | const user = await User.findById(userId) 683 | 684 | if (!user) throw new Error('User not found') 685 | return JSON.parse(JSON.stringify(user)) 686 | } catch (error) { 687 | handleError(error) 688 | } 689 | } 690 | 691 | // UPDATE 692 | export async function updateUser(clerkId: string, user: UpdateUserParams) { 693 | try { 694 | await connectToDB() 695 | 696 | const updatedUser = await User.findOneAndUpdate({ clerkId }, user, { new: true }) 697 | 698 | if (!updatedUser) throw new Error('User update failed') 699 | return JSON.parse(JSON.stringify(updatedUser)) 700 | } catch (error) { 701 | handleError(error) 702 | } 703 | } 704 | 705 | //=============================== 706 | export async function deleteUser(clerkId: string) { 707 | try { 708 | await connectToDB() 709 | 710 | // Find user to delete 711 | const userToDelete = await User.findOne({ clerkId }) 712 | 713 | if (!userToDelete) { 714 | throw new Error('User not found') 715 | } 716 | 717 | // Unlink relationships 718 | await Promise.all([ 719 | // Update the 'events' collection to remove references to the user 720 | Event.updateMany( 721 | { _id: { $in: userToDelete.events } }, 722 | { $pull: { organizer: userToDelete._id } } 723 | ), 724 | 725 | // Update the 'orders' collection to remove references to the user 726 | Order.updateMany({ _id: { $in: userToDelete.orders } }, { $unset: { buyer: 1 } }), 727 | ]) 728 | 729 | // Delete user 730 | const deletedUser = await User.findByIdAndDelete(userToDelete._id) 731 | revalidatePath('/') 732 | 733 | return deletedUser ? JSON.parse(JSON.stringify(deletedUser)) : null 734 | } catch (error) { 735 | handleError(error) 736 | } 737 | } 738 | ``` 739 |
740 | 741 |
742 | order.model.ts 743 | ```typescript 744 | import { Schema, model, models, Document } from 'mongoose' 745 | 746 | export interface IOrder extends Document { 747 | createdAt: Date 748 | stripeId: string 749 | totalAmount: string 750 | event: { 751 | _id: string 752 | title: string 753 | } 754 | buyer: { 755 | _id: string 756 | firstName: string 757 | lastName: string 758 | } 759 | } 760 | 761 | export type IOrderItem = { 762 | _id: string 763 | totalAmount: string 764 | createdAt: Date 765 | eventTitle: string 766 | eventId: string 767 | buyer: string 768 | } 769 | 770 | const OrderSchema = new Schema({ 771 | createdAt: { 772 | type: Date, 773 | default: Date.now, 774 | }, 775 | stripeId: { 776 | type: String, 777 | required: true, 778 | unique: true, 779 | }, 780 | totalAmount: { 781 | type: String, 782 | }, 783 | event: { 784 | type: Schema.Types.ObjectId, 785 | ref: 'Event', 786 | }, 787 | buyer: { 788 | type: Schema.Types.ObjectId, 789 | ref: 'User', 790 | }, 791 | }) 792 | 793 | const Order = models.Order || model('Order', OrderSchema) 794 | 795 | export default Order 796 | ``` 797 |
798 | 799 |
800 | FileUploader.tsx 801 | 802 | ```typescript 803 | 'use client' 804 | 805 | import { useCallback, Dispatch, SetStateAction } from 'react' 806 | import type { FileWithPath } from '@uploadthing/react' 807 | import { useDropzone } from '@uploadthing/react/hooks' 808 | import { generateClientDropzoneAccept } from 'uploadthing/client' 809 | 810 | import { Button } from '@/components/ui/button' 811 | import { convertFileToUrl } from '@/lib/utils' 812 | 813 | type FileUploaderProps = { 814 | onFieldChange: (url: string) => void 815 | imageUrl: string 816 | setFiles: Dispatch> 817 | } 818 | 819 | export function FileUploader({ imageUrl, onFieldChange, setFiles }: FileUploaderProps) { 820 | const onDrop = useCallback((acceptedFiles: FileWithPath[]) => { 821 | setFiles(acceptedFiles) 822 | onFieldChange(convertFileToUrl(acceptedFiles[0])) 823 | }, []) 824 | 825 | const { getRootProps, getInputProps } = useDropzone({ 826 | onDrop, 827 | accept: 'image/*' ? generateClientDropzoneAccept(['image/*']) : undefined, 828 | }) 829 | 830 | return ( 831 |
834 | 835 | 836 | {imageUrl ? ( 837 |
838 | image 845 |
846 | ) : ( 847 |
848 | file upload 849 |

Drag photo here

850 |

SVG, PNG, JPG

851 | 854 |
855 | )} 856 |
857 | ) 858 | } 859 | ``` 860 |
861 | 862 |
863 | DeleteConfirmation.tsx 864 | 865 | ```typescript 866 | 'use client' 867 | 868 | import { useTransition } from 'react' 869 | import { usePathname } from 'next/navigation' 870 | import Image from 'next/image' 871 | 872 | import { 873 | AlertDialog, 874 | AlertDialogAction, 875 | AlertDialogCancel, 876 | AlertDialogContent, 877 | AlertDialogDescription, 878 | AlertDialogFooter, 879 | AlertDialogHeader, 880 | AlertDialogTitle, 881 | AlertDialogTrigger, 882 | } from '@/components/ui/alert-dialog' 883 | 884 | import { deleteEvent } from '@/lib/actions/event.actions' 885 | 886 | export const DeleteConfirmation = ({ eventId }: { eventId: string }) => { 887 | const pathname = usePathname() 888 | let [isPending, startTransition] = useTransition() 889 | 890 | return ( 891 | 892 | 893 | edit 894 | 895 | 896 | 897 | 898 | Are you sure you want to delete? 899 | 900 | This will permanently delete this event 901 | 902 | 903 | 904 | 905 | Cancel 906 | 907 | 909 | startTransition(async () => { 910 | await deleteEvent({ eventId, path: pathname }) 911 | }) 912 | }> 913 | {isPending ? 'Delteing...' : 'Delete'} 914 | 915 | 916 | 917 | 918 | ) 919 | } 920 | ``` 921 | 922 |
923 | 924 |
925 | event.action.ts 926 | 927 | ```typescript 928 | 'use server' 929 | 930 | import { revalidatePath } from 'next/cache' 931 | 932 | import { connectToDB } from '@/lib/mongodb/mongoose' 933 | import Event from '@/lib/mongodb/models/event.model' 934 | import User from '@/lib/mongodb/models/user.model' 935 | import Category from '@/lib/mongodb/models/category.model' 936 | import { handleError } from '@/lib/utils' 937 | 938 | import { 939 | CreateEventParams, 940 | UpdateEventParams, 941 | DeleteEventParams, 942 | GetAllEventsParams, 943 | GetEventsByUserParams, 944 | GetRelatedEventsByCategoryParams, 945 | } from '@/types' 946 | import events from 'events' 947 | 948 | const getCategoryByName = async (name: string) => { 949 | return Category.findOne({ name: { $regex: name, $options: 'i' } }) 950 | } 951 | 952 | const populateEvent = (query: any) => { 953 | return query 954 | .populate({ path: 'organizer', model: User, select: '_id firstName lastName' }) 955 | .populate({ path: 'category', model: Category, select: '_id name' }) 956 | } 957 | 958 | // CREATE 959 | export async function createEvent({ userId, event, path }: CreateEventParams) { 960 | try { 961 | await connectToDB() 962 | 963 | const organizer = await User.findById(userId) 964 | if (!organizer) throw new Error('Organizer not found') 965 | 966 | const newEvent = await Event.create({ ...event, category: event.categoryId, organizer: userId }) 967 | revalidatePath(path) 968 | 969 | return JSON.parse(JSON.stringify(newEvent)) 970 | } catch (error) { 971 | handleError(error) 972 | } 973 | } 974 | 975 | // GET ONE EVENT BY ID 976 | export async function getEventById(eventId: string) { 977 | try { 978 | await connectToDB() 979 | 980 | const event = await populateEvent(Event.findById(eventId)) 981 | 982 | if (!event) throw new Error('Event not found') 983 | 984 | return JSON.parse(JSON.stringify(event)) 985 | } catch (error) { 986 | handleError(error) 987 | } 988 | } 989 | 990 | // UPDATE 991 | export async function updateEvent({ userId, event, path }: UpdateEventParams) { 992 | try { 993 | await connectToDB() 994 | 995 | const eventToUpdate = await Event.findById(event._id) 996 | if (!eventToUpdate || eventToUpdate.organizer.toHexString() !== userId) { 997 | throw new Error('Unauthorized or event not found') 998 | } 999 | 1000 | const updatedEvent = await Event.findByIdAndUpdate( 1001 | event._id, 1002 | { ...event, category: event.categoryId }, 1003 | { new: true } 1004 | ) 1005 | revalidatePath(path) 1006 | 1007 | return JSON.parse(JSON.stringify(updatedEvent)) 1008 | } catch (error) { 1009 | handleError(error) 1010 | } 1011 | } 1012 | 1013 | // DELETE 1014 | export async function deleteEvent({ eventId, path }: DeleteEventParams) { 1015 | try { 1016 | await connectToDB() 1017 | 1018 | const deletedEvent = await Event.findByIdAndDelete(eventId) 1019 | if (deletedEvent) revalidatePath(path) 1020 | } catch (error) { 1021 | handleError(error) 1022 | } 1023 | } 1024 | 1025 | // GET ALL EVENTS 1026 | export async function getAllEvents({ query, limit = 6, page, category }: GetAllEventsParams) { 1027 | try { 1028 | await connectToDB() 1029 | 1030 | const titleCondition = query ? { title: { $regex: query, $options: 'i' } } : {} 1031 | const categoryCondition = category ? await getCategoryByName(category) : null 1032 | const conditions = { 1033 | $and: [titleCondition, categoryCondition ? { category: categoryCondition._id } : {}], 1034 | } 1035 | 1036 | const skipAmount = (Number(page) - 1) * limit 1037 | const eventsQuery = Event.find(conditions) 1038 | .sort({ createdAt: 'desc' }) 1039 | .skip(skipAmount) 1040 | .limit(limit) 1041 | 1042 | const events = await populateEvent(eventsQuery) 1043 | const eventsCount = await Event.countDocuments(conditions) 1044 | 1045 | return { 1046 | data: JSON.parse(JSON.stringify(events)), 1047 | totalPages: Math.ceil(eventsCount / limit), 1048 | } 1049 | } catch (error) { 1050 | handleError(error) 1051 | } 1052 | } 1053 | 1054 | // GET EVENTS BY ORGANIZER 1055 | export async function getEventsByUser({ userId, limit = 6, page }: GetEventsByUserParams) { 1056 | try { 1057 | await connectToDB() 1058 | 1059 | const conditions = { organizer: userId } 1060 | const skipAmount = (page - 1) * limit 1061 | 1062 | const eventsQuery = Event.find(conditions) 1063 | .sort({ createdAt: 'desc' }) 1064 | .skip(skipAmount) 1065 | .limit(limit) 1066 | 1067 | const events = await populateEvent(eventsQuery) 1068 | const eventsCount = await Event.countDocuments(conditions) 1069 | 1070 | return { data: JSON.parse(JSON.stringify(events)), totalPages: Math.ceil(eventsCount / limit) } 1071 | } catch (error) { 1072 | handleError(error) 1073 | } 1074 | } 1075 | 1076 | // GET RELATED EVENTS: EVENTS WITH SAME CATEGORY 1077 | export async function getRelatedEventsByCategory({ 1078 | categoryId, 1079 | eventId, 1080 | limit = 3, 1081 | page = 1, 1082 | }: GetRelatedEventsByCategoryParams) { 1083 | try { 1084 | await connectToDB() 1085 | 1086 | const skipAmount = (Number(page) - 1) * limit 1087 | const conditions = { $and: [{ category: categoryId }, { _id: { $ne: eventId } }] } 1088 | 1089 | const eventsQuery = Event.find(conditions) 1090 | .sort({ createdAt: 'desc' }) 1091 | .skip(skipAmount) 1092 | .limit(limit) 1093 | 1094 | const events = await populateEvent(eventsQuery) 1095 | const eventsCount = await Event.countDocuments(conditions) 1096 | 1097 | return { data: JSON.parse(JSON.stringify(events)), totalPages: Math.ceil(eventsCount / limit) } 1098 | } catch (error) { 1099 | handleError(error) 1100 | } 1101 | } 1102 | ``` 1103 | 1104 |
1105 | 1106 |
1107 | order.action.ts 1108 | 1109 | ```typescript 1110 | 'use server' 1111 | 1112 | import Stripe from 'stripe' 1113 | import { redirect } from 'next/navigation' 1114 | import { ObjectId } from 'mongodb' 1115 | 1116 | import { connectToDB } from '@/lib/mongodb/mongoose' 1117 | import Order from '@/lib/mongodb/models/order.model' 1118 | import Event from '@/lib/mongodb/models/event.model' 1119 | import User from '@/lib/mongodb/models/user.model' 1120 | import { handleError } from '@/lib/utils' 1121 | 1122 | import { 1123 | CheckoutOrderParams, 1124 | CreateOrderParams, 1125 | GetOrdersByEventParams, 1126 | GetOrdersByUserParams, 1127 | } from '@/types' 1128 | 1129 | // CHECKOUT 1130 | export async function checkoutOrder(order: CheckoutOrderParams) { 1131 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) 1132 | 1133 | const price = order.isFree ? 0 : Number(order.price) * 100 1134 | 1135 | try { 1136 | const session = await stripe.checkout.sessions.create({ 1137 | line_items: [ 1138 | { 1139 | price_data: { 1140 | currency: 'usd', 1141 | unit_amount: price, 1142 | product_data: { 1143 | name: order.eventTitle, 1144 | }, 1145 | }, 1146 | quantity: 1, 1147 | }, 1148 | ], 1149 | metadata: { 1150 | eventId: order.eventId, 1151 | buyerId: order.buyerId, 1152 | }, 1153 | mode: 'payment', 1154 | success_url: `${process.env.NEXT_PUBLIC_SERVER_URL}/profile`, 1155 | cancel_url: `${process.env.NEXT_PUBLIC_SERVER_URL}/`, 1156 | }) 1157 | 1158 | redirect(session.url!) 1159 | } catch (error) { 1160 | throw error 1161 | } 1162 | } 1163 | 1164 | // CREATE 1165 | export async function createOrder(order: CreateOrderParams) { 1166 | try { 1167 | await connectToDB() 1168 | 1169 | const newOrder = await Order.create({ 1170 | ...order, 1171 | event: order.eventId, 1172 | buyer: order.buyerId, 1173 | }) 1174 | 1175 | return JSON.parse(JSON.stringify(newOrder)) 1176 | } catch (error) { 1177 | handleError(error) 1178 | } 1179 | } 1180 | 1181 | // GET ORDERS BY EVENT 1182 | export async function getOrdersByEvent({ searchString, eventId }: GetOrdersByEventParams) { 1183 | try { 1184 | await connectToDB() 1185 | 1186 | if (!eventId) throw new Error('Event ID is required') 1187 | const eventObjectId = new ObjectId(eventId) 1188 | 1189 | const orders = await Order.aggregate([ 1190 | { 1191 | $lookup: { 1192 | from: 'users', 1193 | localField: 'buyer', 1194 | foreignField: '_id', 1195 | as: 'buyer', 1196 | }, 1197 | }, 1198 | { 1199 | $unwind: '$buyer', 1200 | }, 1201 | { 1202 | $lookup: { 1203 | from: 'events', 1204 | localField: 'event', 1205 | foreignField: '_id', 1206 | as: 'event', 1207 | }, 1208 | }, 1209 | { 1210 | $unwind: '$event', 1211 | }, 1212 | { 1213 | $project: { 1214 | _id: 1, 1215 | totalAmount: 1, 1216 | createdAt: 1, 1217 | eventTitle: '$event.title', 1218 | eventId: '$event._id', 1219 | buyer: { 1220 | $concat: ['$buyer.firstName', ' ', '$buyer.lastName'], 1221 | }, 1222 | }, 1223 | }, 1224 | { 1225 | $match: { 1226 | $and: [{ eventId: eventObjectId }, { buyer: { $regex: RegExp(searchString, 'i') } }], 1227 | }, 1228 | }, 1229 | ]) 1230 | 1231 | return JSON.parse(JSON.stringify(orders)) 1232 | } catch (error) { 1233 | handleError(error) 1234 | } 1235 | } 1236 | 1237 | // GET ORDERS BY USER 1238 | export async function getOrdersByUser({ userId, limit = 3, page }: GetOrdersByUserParams) { 1239 | try { 1240 | await connectToDB() 1241 | 1242 | const skipAmount = (Number(page) - 1) * limit 1243 | const conditions = { buyer: userId } 1244 | 1245 | const orders = await Order.distinct('event._id') 1246 | .find(conditions) 1247 | .sort({ createdAt: 'desc' }) 1248 | .skip(skipAmount) 1249 | .limit(limit) 1250 | .populate({ 1251 | path: 'event', 1252 | model: Event, 1253 | populate: { 1254 | path: 'organizer', 1255 | model: User, 1256 | select: '_id firstName lastName', 1257 | }, 1258 | }) 1259 | 1260 | const ordersCount = await Order.distinct('event._id').countDocuments(conditions) 1261 | 1262 | return { data: JSON.parse(JSON.stringify(orders)), totalPages: Math.ceil(ordersCount / limit) } 1263 | } catch (error) { 1264 | handleError(error) 1265 | } 1266 | } 1267 | ``` 1268 | 1269 |
1270 | 1271 |
1272 | orders/page.tsx 1273 | 1274 | ```typescript 1275 | import { Search } from '@/components/shared/Search' 1276 | import { getOrdersByEvent } from '@/lib/actions/order.actions' 1277 | import { formatDateTime, formatPrice } from '@/lib/utils' 1278 | import { SearchParamProps } from '@/types' 1279 | import { IOrderItem } from '@/lib/mongodb/models/order.model' 1280 | 1281 | const Orders = async ({ searchParams }: SearchParamProps) => { 1282 | const eventId = (searchParams?.eventId as string) || '' 1283 | const searchText = (searchParams?.query as string) || '' 1284 | 1285 | const orders = await getOrdersByEvent({ eventId, searchString: searchText }) 1286 | 1287 | return ( 1288 | <> 1289 |
1290 |

Orders

1291 |
1292 | 1293 |
1294 | 1295 |
1296 | 1297 |
1298 | 1299 | 1300 | 1301 | 1302 | 1303 | 1304 | 1305 | 1306 | 1307 | 1308 | 1309 | {orders && orders.length === 0 ? ( 1310 | 1311 | 1314 | 1315 | ) : ( 1316 | <> 1317 | {orders && 1318 | orders.map((row: IOrderItem) => ( 1319 | 1323 | 1324 | 1325 | 1326 | 1329 | 1332 | 1333 | ))} 1334 | 1335 | )} 1336 | 1337 |
Order IDEvent TitleBuyerCreatedAmount
1312 | No orders found. 1313 |
{row._id}{row.eventTitle}{row.buyer} 1327 | {formatDateTime(row.createdAt).dateTime} 1328 | 1330 | {formatPrice(row.totalAmount)} 1331 |
1338 |
1339 | 1340 | ) 1341 | } 1342 | 1343 | export default Orders 1344 | ``` 1345 | 1346 |
1347 | 1348 | ## 🔗 Links 1349 | 1350 | All assets used in the project can be found [here](https://drive.google.com/file/d/1O7Th9vWcbPIwJQB_mJR3507LUk2kYgVQ/view?usp=sharing) 1351 | 1352 | ## 🚀 More 1353 | 1354 | **Advance your skills with Next.js 14 Pro Course** 1355 | 1356 | Enjoyed creating this project? Dive deeper into our PRO courses for a richer learning adventure. They're packed with detailed explanations, cool features, and exercises to boost your skills. Give it a go! 1357 | 1358 | 1359 | Project Banner 1360 | 1361 | 1362 |
1363 |
1364 | 1365 | **Accelerate your professional journey with Expert Training program** 1366 | 1367 | And if you're hungry for more than just a course and want to understand how we learn and tackle tech challenges, hop into our personalized masterclass. We cover best practices, different web skills, and offer mentorship to boost your confidence. Let's learn and grow together! 1368 | 1369 | 1370 | Project Banner 1371 | 1372 | 1373 | # 1374 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | const Layout = ({ children }: { children: React.ReactNode }) => { 2 | return ( 3 |
4 | {children} 5 |
6 | ) 7 | } 8 | 9 | export default Layout -------------------------------------------------------------------------------- /app/(auth)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return 5 | } -------------------------------------------------------------------------------- /app/(auth)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return 5 | } -------------------------------------------------------------------------------- /app/(root)/events/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import CheckoutButton from '@/components/shared/CheckoutButton'; 2 | import Collection from '@/components/shared/Collection'; 3 | import { getEventById, getRelatedEventsByCategory } from '@/lib/actions/event.actions' 4 | import { formatDateTime } from '@/lib/utils'; 5 | import { SearchParamProps } from '@/types' 6 | import Image from 'next/image'; 7 | 8 | const EventDetails = async ({ params: { id }, searchParams }: SearchParamProps) => { 9 | const event = await getEventById(id); 10 | 11 | const relatedEvents = await getRelatedEventsByCategory({ 12 | categoryId: event.category._id, 13 | eventId: event._id, 14 | page: searchParams.page as string, 15 | }) 16 | 17 | return ( 18 | <> 19 |
20 |
21 | hero image 28 | 29 |
30 |
31 |

{event.title}

32 | 33 |
34 |
35 |

36 | {event.isFree ? 'FREE' : `$${event.price}`} 37 |

38 |

39 | {event.category.name} 40 |

41 |
42 | 43 |

44 | by{' '} 45 | {event.organizer.firstName} {event.organizer.lastName} 46 |

47 |
48 |
49 | 50 | 51 | 52 |
53 |
54 | calendar 55 |
56 |

57 | {formatDateTime(event.startDateTime).dateOnly} - {' '} 58 | {formatDateTime(event.startDateTime).timeOnly} 59 |

60 |

61 | {formatDateTime(event.endDateTime).dateOnly} - {' '} 62 | {formatDateTime(event.endDateTime).timeOnly} 63 |

64 |
65 |
66 | 67 |
68 | location 69 |

{event.location}

70 |
71 |
72 | 73 |
74 |

What You'll Learn:

75 |

{event.description}

76 |

{event.url}

77 |
78 |
79 |
80 |
81 | 82 | {/* EVENTS with the same category */} 83 |
84 |

Related Events

85 | 86 | 95 |
96 | 97 | ) 98 | } 99 | 100 | export default EventDetails -------------------------------------------------------------------------------- /app/(root)/events/[id]/update/page.tsx: -------------------------------------------------------------------------------- 1 | import EventForm from "@/components/shared/EventForm" 2 | import { getEventById } from "@/lib/actions/event.actions" 3 | import { auth } from "@clerk/nextjs"; 4 | 5 | type UpdateEventProps = { 6 | params: { 7 | id: string 8 | } 9 | } 10 | 11 | const UpdateEvent = async ({ params: { id } }: UpdateEventProps) => { 12 | const { sessionClaims } = auth(); 13 | 14 | const userId = sessionClaims?.userId as string; 15 | const event = await getEventById(id) 16 | 17 | return ( 18 | <> 19 |
20 |

Update Event

21 |
22 | 23 |
24 | 30 |
31 | 32 | ) 33 | } 34 | 35 | export default UpdateEvent -------------------------------------------------------------------------------- /app/(root)/events/create/page.tsx: -------------------------------------------------------------------------------- 1 | import EventForm from "@/components/shared/EventForm" 2 | import { auth } from "@clerk/nextjs"; 3 | 4 | const CreateEvent = () => { 5 | const { sessionClaims } = auth(); 6 | 7 | const userId = sessionClaims?.userId as string; 8 | 9 | return ( 10 | <> 11 |
12 |

Create Event

13 |
14 | 15 |
16 | 17 |
18 | 19 | ) 20 | } 21 | 22 | export default CreateEvent -------------------------------------------------------------------------------- /app/(root)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/components/shared/Footer" 2 | import Header from "@/components/shared/Header" 3 | 4 | export default function RootLayout({ 5 | children, 6 | }: { 7 | children: React.ReactNode 8 | }) { 9 | return ( 10 |
11 |
12 |
{children}
13 |
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/(root)/orders/page.tsx: -------------------------------------------------------------------------------- 1 | import Search from '@/components/shared/Search' 2 | import { getOrdersByEvent } from '@/lib/actions/order.actions' 3 | import { formatDateTime, formatPrice } from '@/lib/utils' 4 | import { SearchParamProps } from '@/types' 5 | import { IOrderItem } from '@/lib/database/models/order.model' 6 | 7 | const Orders = async ({ searchParams }: SearchParamProps) => { 8 | const eventId = (searchParams?.eventId as string) || '' 9 | const searchText = (searchParams?.query as string) || '' 10 | 11 | const orders = await getOrdersByEvent({ eventId, searchString: searchText }) 12 | 13 | return ( 14 | <> 15 |
16 |

Orders

17 |
18 | 19 |
20 | 21 |
22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {orders && orders.length === 0 ? ( 36 | 37 | 40 | 41 | ) : ( 42 | <> 43 | {orders && 44 | orders.map((row: IOrderItem) => ( 45 | 49 | 50 | 51 | 52 | 55 | 58 | 59 | ))} 60 | 61 | )} 62 | 63 |
Order IDEvent TitleBuyerCreatedAmount
38 | No orders found. 39 |
{row._id}{row.eventTitle}{row.buyer} 53 | {formatDateTime(row.createdAt).dateTime} 54 | 56 | {formatPrice(row.totalAmount)} 57 |
64 |
65 | 66 | ) 67 | } 68 | 69 | export default Orders 70 | -------------------------------------------------------------------------------- /app/(root)/page.tsx: -------------------------------------------------------------------------------- 1 | import CategoryFilter from '@/components/shared/CategoryFilter'; 2 | import Collection from '@/components/shared/Collection' 3 | import Search from '@/components/shared/Search'; 4 | import { Button } from '@/components/ui/button' 5 | import { getAllEvents } from '@/lib/actions/event.actions'; 6 | import { SearchParamProps } from '@/types'; 7 | import Image from 'next/image' 8 | import Link from 'next/link' 9 | 10 | export default async function Home({ searchParams }: SearchParamProps) { 11 | const page = Number(searchParams?.page) || 1; 12 | const searchText = (searchParams?.query as string) || ''; 13 | const category = (searchParams?.category as string) || ''; 14 | 15 | const events = await getAllEvents({ 16 | query: searchText, 17 | category, 18 | page, 19 | limit: 6 20 | }) 21 | 22 | return ( 23 | <> 24 |
25 |
26 |
27 |

Host, Connect, Celebrate: Your Events, Our Platform!

28 |

Book and learn helpful tips from 3,168+ mentors in world-class companies with our global community.

29 | 34 |
35 | 36 | hero 43 |
44 |
45 | 46 |
47 |

Trust by
Thousands of Events

48 | 49 |
50 | 51 | 52 |
53 | 54 | 63 |
64 | 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /app/(root)/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import Collection from '@/components/shared/Collection' 2 | import { Button } from '@/components/ui/button' 3 | import { getEventsByUser } from '@/lib/actions/event.actions' 4 | import { getOrdersByUser } from '@/lib/actions/order.actions' 5 | import { IOrder } from '@/lib/database/models/order.model' 6 | import { SearchParamProps } from '@/types' 7 | import { auth } from '@clerk/nextjs' 8 | import Link from 'next/link' 9 | import React from 'react' 10 | 11 | const ProfilePage = async ({ searchParams }: SearchParamProps) => { 12 | const { sessionClaims } = auth(); 13 | const userId = sessionClaims?.userId as string; 14 | 15 | const ordersPage = Number(searchParams?.ordersPage) || 1; 16 | const eventsPage = Number(searchParams?.eventsPage) || 1; 17 | 18 | const orders = await getOrdersByUser({ userId, page: ordersPage}) 19 | 20 | const orderedEvents = orders?.data.map((order: IOrder) => order.event) || []; 21 | const organizedEvents = await getEventsByUser({ userId, page: eventsPage }) 22 | 23 | return ( 24 | <> 25 | {/* My Tickets */} 26 |
27 |
28 |

My Tickets

29 | 34 |
35 |
36 | 37 |
38 | 48 |
49 | 50 | {/* Events Organized */} 51 |
52 |
53 |

Events Organized

54 | 59 |
60 |
61 | 62 |
63 | 73 |
74 | 75 | ) 76 | } 77 | 78 | export default ProfilePage -------------------------------------------------------------------------------- /app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { createUploadthing, type FileRouter } from "uploadthing/next"; 2 | 3 | const f = createUploadthing(); 4 | 5 | const auth = (req: Request) => ({ id: "fakeId" }); // Fake auth function 6 | 7 | // FileRouter for your app, can contain multiple FileRoutes 8 | export const ourFileRouter = { 9 | // Define as many FileRoutes as you like, each with a unique routeSlug 10 | imageUploader: f({ image: { maxFileSize: "4MB" } }) 11 | // Set permissions and file types for this FileRoute 12 | .middleware(async ({ req }) => { 13 | // This code runs on your server before upload 14 | const user = await auth(req); 15 | 16 | // If you throw, the user will not be able to upload 17 | if (!user) throw new Error("Unauthorized"); 18 | 19 | // Whatever is returned here is accessible in onUploadComplete as `metadata` 20 | return { userId: user.id }; 21 | }) 22 | .onUploadComplete(async ({ metadata, file }) => { 23 | // This code RUNS ON YOUR SERVER after upload 24 | console.log("Upload complete for userId:", metadata.userId); 25 | 26 | console.log("file url", file.url); 27 | 28 | // !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback 29 | return { uploadedBy: metadata.userId }; 30 | }), 31 | } satisfies FileRouter; 32 | 33 | export type OurFileRouter = typeof ourFileRouter; -------------------------------------------------------------------------------- /app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createNextRouteHandler } from "uploadthing/next"; 2 | 3 | import { ourFileRouter } from "./core"; 4 | 5 | // Export routes for Next App Router 6 | export const { GET, POST } = createNextRouteHandler({ 7 | router: ourFileRouter, 8 | }); -------------------------------------------------------------------------------- /app/api/webhook/clerk/route.ts: -------------------------------------------------------------------------------- 1 | import { Webhook } from 'svix' 2 | import { headers } from 'next/headers' 3 | import { WebhookEvent } from '@clerk/nextjs/server' 4 | import { createUser, deleteUser, updateUser } from '@/lib/actions/user.actions' 5 | import { clerkClient } from '@clerk/nextjs' 6 | import { NextResponse } from 'next/server' 7 | 8 | export async function POST(req: Request) { 9 | 10 | // You can find this in the Clerk Dashboard -> Webhooks -> choose the webhook 11 | const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET 12 | 13 | if (!WEBHOOK_SECRET) { 14 | throw new Error('Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local') 15 | } 16 | 17 | // Get the headers 18 | const headerPayload = headers(); 19 | const svix_id = headerPayload.get("svix-id"); 20 | const svix_timestamp = headerPayload.get("svix-timestamp"); 21 | const svix_signature = headerPayload.get("svix-signature"); 22 | 23 | // If there are no headers, error out 24 | if (!svix_id || !svix_timestamp || !svix_signature) { 25 | return new Response('Error occured -- no svix headers', { 26 | status: 400 27 | }) 28 | } 29 | 30 | // Get the body 31 | const payload = await req.json() 32 | const body = JSON.stringify(payload); 33 | 34 | // Create a new Svix instance with your secret. 35 | const wh = new Webhook(WEBHOOK_SECRET); 36 | 37 | let evt: WebhookEvent 38 | 39 | // Verify the payload with the headers 40 | try { 41 | evt = wh.verify(body, { 42 | "svix-id": svix_id, 43 | "svix-timestamp": svix_timestamp, 44 | "svix-signature": svix_signature, 45 | }) as WebhookEvent 46 | } catch (err) { 47 | console.error('Error verifying webhook:', err); 48 | return new Response('Error occured', { 49 | status: 400 50 | }) 51 | } 52 | 53 | // Get the ID and type 54 | const { id } = evt.data; 55 | const eventType = evt.type; 56 | 57 | if(eventType === 'user.created') { 58 | const { id, email_addresses, image_url, first_name, last_name, username } = evt.data; 59 | 60 | const user = { 61 | clerkId: id, 62 | email: email_addresses[0].email_address, 63 | username: username!, 64 | firstName: first_name, 65 | lastName: last_name, 66 | photo: image_url, 67 | } 68 | 69 | const newUser = await createUser(user); 70 | 71 | if(newUser) { 72 | await clerkClient.users.updateUserMetadata(id, { 73 | publicMetadata: { 74 | userId: newUser._id 75 | } 76 | }) 77 | } 78 | 79 | return NextResponse.json({ message: 'OK', user: newUser }) 80 | } 81 | 82 | if (eventType === 'user.updated') { 83 | const {id, image_url, first_name, last_name, username } = evt.data 84 | 85 | const user = { 86 | firstName: first_name, 87 | lastName: last_name, 88 | username: username!, 89 | photo: image_url, 90 | } 91 | 92 | const updatedUser = await updateUser(id, user) 93 | 94 | return NextResponse.json({ message: 'OK', user: updatedUser }) 95 | } 96 | 97 | if (eventType === 'user.deleted') { 98 | const { id } = evt.data 99 | 100 | const deletedUser = await deleteUser(id!) 101 | 102 | return NextResponse.json({ message: 'OK', user: deletedUser }) 103 | } 104 | 105 | return new Response('', { status: 200 }) 106 | } 107 | -------------------------------------------------------------------------------- /app/api/webhook/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import stripe from 'stripe' 2 | import { NextResponse } from 'next/server' 3 | import { createOrder } from '@/lib/actions/order.actions' 4 | 5 | export async function POST(request: Request) { 6 | const body = await request.text() 7 | 8 | const sig = request.headers.get('stripe-signature') as string 9 | const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET! 10 | 11 | let event 12 | 13 | try { 14 | event = stripe.webhooks.constructEvent(body, sig, endpointSecret) 15 | } catch (err) { 16 | return NextResponse.json({ message: 'Webhook error', error: err }) 17 | } 18 | 19 | // Get the ID and type 20 | const eventType = event.type 21 | 22 | // CREATE 23 | if (eventType === 'checkout.session.completed') { 24 | const { id, amount_total, metadata } = event.data.object 25 | 26 | const order = { 27 | stripeId: id, 28 | eventId: metadata?.eventId || '', 29 | buyerId: metadata?.buyerId || '', 30 | totalAmount: amount_total ? (amount_total / 100).toString() : '0', 31 | createdAt: new Date(), 32 | } 33 | 34 | const newOrder = await createOrder(order) 35 | return NextResponse.json({ message: 'OK', order: newOrder }) 36 | } 37 | 38 | return new Response('', { status: 200 }) 39 | } 40 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeWithUsman0/Event/4b60e8401e4fd3713453c9acaf098bd56392ad58/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | * { 70 | list-style: none; 71 | padding: 0; 72 | margin: 0; 73 | scroll-behavior: smooth; 74 | } 75 | 76 | body { 77 | font-family: var(--font-poppins) 78 | } 79 | 80 | .filter-grey { 81 | filter: brightness(0) saturate(100%) invert(47%) sepia(0%) saturate(217%) 82 | hue-rotate(32deg) brightness(98%) contrast(92%); 83 | } 84 | 85 | /* ========================================== TAILWIND STYLES */ 86 | @layer utilities { 87 | .wrapper { 88 | @apply max-w-7xl lg:mx-auto p-5 md:px-10 xl:px-0 w-full; 89 | } 90 | 91 | .flex-center { 92 | @apply flex justify-center items-center; 93 | } 94 | 95 | .flex-between { 96 | @apply flex justify-between items-center; 97 | } 98 | 99 | /* TYPOGRAPHY */ 100 | /* 64 */ 101 | .h1-bold { 102 | @apply font-bold text-[40px] leading-[48px] lg:text-[48px] lg:leading-[60px] xl:text-[58px] xl:leading-[74px]; 103 | } 104 | 105 | /* 40 */ 106 | .h2-bold { 107 | @apply font-bold text-[32px] leading-[40px] lg:text-[36px] lg:leading-[44px] xl:text-[40px] xl:leading-[48px]; 108 | } 109 | 110 | .h2-medium { 111 | @apply font-medium text-[32px] leading-[40px] lg:text-[36px] lg:leading-[44px] xl:text-[40px] xl:leading-[48px]; 112 | } 113 | 114 | /* 36 */ 115 | .h3-bold { 116 | @apply font-bold text-[28px] leading-[36px] md:text-[36px] md:leading-[44px]; 117 | } 118 | 119 | .h3-medium { 120 | @apply font-medium text-[28px] leading-[36px] md:text-[36px] md:leading-[44px]; 121 | } 122 | 123 | /* 32 */ 124 | .h4-medium { 125 | @apply font-medium text-[32px] leading-[40px]; 126 | } 127 | 128 | /* 28 */ 129 | .h5-bold { 130 | @apply font-bold text-[28px] leading-[36px]; 131 | } 132 | 133 | /* 24 */ 134 | .p-bold-24 { 135 | @apply font-bold text-[24px] leading-[36px]; 136 | } 137 | 138 | .p-medium-24 { 139 | @apply font-medium text-[24px] leading-[36px]; 140 | } 141 | 142 | .p-regular-24 { 143 | @apply font-normal text-[24px] leading-[36px]; 144 | } 145 | 146 | /* 20 */ 147 | .p-bold-20 { 148 | @apply font-bold text-[20px] leading-[30px] tracking-[2%]; 149 | } 150 | 151 | .p-semibold-20 { 152 | @apply text-[20px] font-semibold leading-[30px] tracking-[2%]; 153 | } 154 | 155 | .p-medium-20 { 156 | @apply text-[20px] font-medium leading-[30px]; 157 | } 158 | 159 | .p-regular-20 { 160 | @apply text-[20px] font-normal leading-[30px] tracking-[2%]; 161 | } 162 | 163 | /* 18 */ 164 | .p-semibold-18 { 165 | @apply text-[18px] font-semibold leading-[28px] tracking-[2%]; 166 | } 167 | 168 | .p-medium-18 { 169 | @apply text-[18px] font-medium leading-[28px]; 170 | } 171 | 172 | .p-regular-18 { 173 | @apply text-[18px] font-normal leading-[28px] tracking-[2%]; 174 | } 175 | 176 | /* 16 */ 177 | .p-bold-16 { 178 | @apply text-[16px] font-bold leading-[24px]; 179 | } 180 | 181 | .p-medium-16 { 182 | @apply text-[16px] font-medium leading-[24px]; 183 | } 184 | 185 | .p-regular-16 { 186 | @apply text-[16px] font-normal leading-[24px]; 187 | } 188 | 189 | /* 14 */ 190 | .p-semibold-14 { 191 | @apply text-[14px] font-semibold leading-[20px]; 192 | } 193 | 194 | .p-medium-14 { 195 | @apply text-[14px] font-medium leading-[20px]; 196 | } 197 | 198 | .p-regular-14 { 199 | @apply text-[14px] font-normal leading-[20px]; 200 | } 201 | 202 | /* 12 */ 203 | .p-medium-12 { 204 | @apply text-[12px] font-medium leading-[20px]; 205 | } 206 | 207 | /* SHADCN OVERRIDES */ 208 | .select-field { 209 | @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; 210 | } 211 | 212 | .input-field { 213 | @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; 214 | } 215 | 216 | .textarea { 217 | @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; 218 | } 219 | 220 | .button { 221 | @apply rounded-full h-[54px] p-regular-16; 222 | } 223 | 224 | .select-item { 225 | @apply py-3 cursor-pointer focus:bg-primary-50; 226 | } 227 | 228 | .toggle-switch { 229 | @apply bg-gray-300 !important; 230 | } 231 | } 232 | 233 | /* ========================================== CLERK STYLES */ 234 | .cl-logoImage { 235 | height: 38px; 236 | } 237 | 238 | .cl-userButtonBox { 239 | flex-direction: row-reverse; 240 | } 241 | 242 | .cl-userButtonOuterIdentifier { 243 | font-size: 16px; 244 | } 245 | 246 | .cl-userButtonPopoverCard { 247 | right: 4px !important; 248 | } 249 | 250 | .cl-formButtonPrimary:hover, 251 | .cl-formButtonPrimary:focus, 252 | .cl-formButtonPrimary:active { 253 | background-color: #705CF7 254 | } 255 | 256 | /* ========================================== REACT-DATEPICKER STYLES */ 257 | .datePicker { 258 | width: 100%; 259 | } 260 | 261 | .react-datepicker__input-container input { 262 | background-color: transparent; 263 | width: 100%; 264 | outline: none; 265 | margin-left: 16px; 266 | } 267 | 268 | .react-datepicker__day--selected { 269 | background-color: #624cf5 !important; 270 | color: #ffffff !important; 271 | border-radius: 4px; 272 | } 273 | 274 | .react-datepicker__time-list-item--selected { 275 | background-color: #624cf5 !important; 276 | } 277 | 278 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Poppins } from 'next/font/google' 3 | import { ClerkProvider } from '@clerk/nextjs' 4 | 5 | import './globals.css' 6 | 7 | const poppins = Poppins({ 8 | subsets: ['latin'], 9 | weight: ['400', '500', '600', '700'], 10 | variable: '--font-poppins', 11 | }) 12 | 13 | export const metadata: Metadata = { 14 | title: 'Evently', 15 | description: 'Evently is a platform for event management.', 16 | icons: { 17 | icon: '/assets/images/logo.svg' 18 | } 19 | } 20 | 21 | export default function RootLayout({ 22 | children, 23 | }: { 24 | children: React.ReactNode 25 | }) { 26 | return ( 27 | 28 | 29 | {children} 30 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /components/shared/Card.tsx: -------------------------------------------------------------------------------- 1 | import { IEvent } from '@/lib/database/models/event.model' 2 | import { formatDateTime } from '@/lib/utils' 3 | import { auth } from '@clerk/nextjs' 4 | import Image from 'next/image' 5 | import Link from 'next/link' 6 | import React from 'react' 7 | import { DeleteConfirmation } from './DeleteConfirmation' 8 | 9 | type CardProps = { 10 | event: IEvent, 11 | hasOrderLink?: boolean, 12 | hidePrice?: boolean 13 | } 14 | 15 | const Card = ({ event, hasOrderLink, hidePrice }: CardProps) => { 16 | const { sessionClaims } = auth(); 17 | const userId = sessionClaims?.userId as string; 18 | 19 | const isEventCreator = userId === event.organizer._id.toString(); 20 | 21 | return ( 22 |
23 | 28 | {/* IS EVENT CREATOR ... */} 29 | 30 | {isEventCreator && !hidePrice && ( 31 |
32 | 33 | edit 34 | 35 | 36 | 37 |
38 | )} 39 | 40 |
43 | {!hidePrice &&
44 | 45 | {event.isFree ? 'FREE' : `$${event.price}`} 46 | 47 |

48 | {event.category.name} 49 |

50 |
} 51 | 52 |

53 | {formatDateTime(event.startDateTime).dateTime} 54 |

55 | 56 | 57 |

{event.title}

58 | 59 | 60 |
61 |

62 | {event.organizer.firstName} {event.organizer.lastName} 63 |

64 | 65 | {hasOrderLink && ( 66 | 67 |

Order Details

68 | search 69 | 70 | )} 71 |
72 |
73 |
74 | ) 75 | } 76 | 77 | export default Card -------------------------------------------------------------------------------- /components/shared/CategoryFilter.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | Select, 5 | SelectContent, 6 | SelectItem, 7 | SelectTrigger, 8 | SelectValue, 9 | } from "@/components/ui/select" 10 | import { getAllCategories } from "@/lib/actions/category.actions"; 11 | import { ICategory } from "@/lib/database/models/category.model"; 12 | import { formUrlQuery, removeKeysFromQuery } from "@/lib/utils"; 13 | import { useRouter, useSearchParams } from "next/navigation"; 14 | import { useEffect, useState } from "react"; 15 | 16 | const CategoryFilter = () => { 17 | const [categories, setCategories] = useState([]); 18 | const router = useRouter(); 19 | const searchParams = useSearchParams(); 20 | 21 | useEffect(() => { 22 | const getCategories = async () => { 23 | const categoryList = await getAllCategories(); 24 | 25 | categoryList && setCategories(categoryList as ICategory[]) 26 | } 27 | 28 | getCategories(); 29 | }, []) 30 | 31 | const onSelectCategory = (category: string) => { 32 | let newUrl = ''; 33 | 34 | if(category && category !== 'All') { 35 | newUrl = formUrlQuery({ 36 | params: searchParams.toString(), 37 | key: 'category', 38 | value: category 39 | }) 40 | } else { 41 | newUrl = removeKeysFromQuery({ 42 | params: searchParams.toString(), 43 | keysToRemove: ['category'] 44 | }) 45 | } 46 | 47 | router.push(newUrl, { scroll: false }); 48 | } 49 | 50 | return ( 51 | 65 | ) 66 | } 67 | 68 | export default CategoryFilter -------------------------------------------------------------------------------- /components/shared/Checkout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { loadStripe } from '@stripe/stripe-js'; 3 | 4 | import { IEvent } from '@/lib/database/models/event.model'; 5 | import { Button } from '../ui/button'; 6 | import { checkoutOrder } from '@/lib/actions/order.actions'; 7 | 8 | loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); 9 | 10 | const Checkout = ({ event, userId }: { event: IEvent, userId: string }) => { 11 | useEffect(() => { 12 | // Check to see if this is a redirect back from Checkout 13 | const query = new URLSearchParams(window.location.search); 14 | if (query.get('success')) { 15 | console.log('Order placed! You will receive an email confirmation.'); 16 | } 17 | 18 | if (query.get('canceled')) { 19 | console.log('Order canceled -- continue to shop around and checkout when you’re ready.'); 20 | } 21 | }, []); 22 | 23 | const onCheckout = async () => { 24 | const order = { 25 | eventTitle: event.title, 26 | eventId: event._id, 27 | price: event.price, 28 | isFree: event.isFree, 29 | buyerId: userId 30 | } 31 | 32 | await checkoutOrder(order); 33 | } 34 | 35 | return ( 36 |
37 | 40 |
41 | ) 42 | } 43 | 44 | export default Checkout -------------------------------------------------------------------------------- /components/shared/CheckoutButton.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { IEvent } from '@/lib/database/models/event.model' 4 | import { SignedIn, SignedOut, useUser } from '@clerk/nextjs' 5 | import Link from 'next/link' 6 | import React from 'react' 7 | import { Button } from '../ui/button' 8 | import Checkout from './Checkout' 9 | 10 | const CheckoutButton = ({ event }: { event: IEvent }) => { 11 | const { user } = useUser(); 12 | const userId = user?.publicMetadata.userId as string; 13 | const hasEventFinished = new Date(event.endDateTime) < new Date(); 14 | 15 | return ( 16 |
17 | {hasEventFinished ? ( 18 |

Sorry, tickets are no longer available.

19 | ): ( 20 | <> 21 | 22 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | )} 34 |
35 | ) 36 | } 37 | 38 | export default CheckoutButton -------------------------------------------------------------------------------- /components/shared/Collection.tsx: -------------------------------------------------------------------------------- 1 | import { IEvent } from '@/lib/database/models/event.model' 2 | import React from 'react' 3 | import Card from './Card' 4 | import Pagination from './Pagination' 5 | 6 | type CollectionProps = { 7 | data: IEvent[], 8 | emptyTitle: string, 9 | emptyStateSubtext: string, 10 | limit: number, 11 | page: number | string, 12 | totalPages?: number, 13 | urlParamName?: string, 14 | collectionType?: 'Events_Organized' | 'My_Tickets' | 'All_Events' 15 | } 16 | 17 | const Collection = ({ 18 | data, 19 | emptyTitle, 20 | emptyStateSubtext, 21 | page, 22 | totalPages = 0, 23 | collectionType, 24 | urlParamName, 25 | }: CollectionProps) => { 26 | return ( 27 | <> 28 | {data.length > 0 ? ( 29 |
30 |
    31 | {data.map((event) => { 32 | const hasOrderLink = collectionType === 'Events_Organized'; 33 | const hidePrice = collectionType === 'My_Tickets'; 34 | 35 | return ( 36 |
  • 37 | 38 |
  • 39 | ) 40 | })} 41 |
42 | 43 | {totalPages > 1 && ( 44 | 45 | )} 46 |
47 | ): ( 48 |
49 |

{emptyTitle}

50 |

{emptyStateSubtext}

51 |
52 | )} 53 | 54 | ) 55 | } 56 | 57 | export default Collection -------------------------------------------------------------------------------- /components/shared/DeleteConfirmation.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useTransition } from 'react' 4 | import { usePathname } from 'next/navigation' 5 | import Image from 'next/image' 6 | 7 | import { 8 | AlertDialog, 9 | AlertDialogAction, 10 | AlertDialogCancel, 11 | AlertDialogContent, 12 | AlertDialogDescription, 13 | AlertDialogFooter, 14 | AlertDialogHeader, 15 | AlertDialogTitle, 16 | AlertDialogTrigger, 17 | } from '@/components/ui/alert-dialog' 18 | 19 | import { deleteEvent } from '@/lib/actions/event.actions' 20 | 21 | export const DeleteConfirmation = ({ eventId }: { eventId: string }) => { 22 | const pathname = usePathname() 23 | let [isPending, startTransition] = useTransition() 24 | 25 | return ( 26 | 27 | 28 | edit 29 | 30 | 31 | 32 | 33 | Are you sure you want to delete? 34 | 35 | This will permanently delete this event 36 | 37 | 38 | 39 | 40 | Cancel 41 | 42 | 44 | startTransition(async () => { 45 | await deleteEvent({ eventId, path: pathname }) 46 | }) 47 | }> 48 | {isPending ? 'Deleting...' : 'Delete'} 49 | 50 | 51 | 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /components/shared/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectItem, 5 | SelectTrigger, 6 | SelectValue, 7 | } from "@/components/ui/select" 8 | import { ICategory } from "@/lib/database/models/category.model" 9 | import { startTransition, useEffect, useState } from "react" 10 | import { 11 | AlertDialog, 12 | AlertDialogAction, 13 | AlertDialogCancel, 14 | AlertDialogContent, 15 | AlertDialogDescription, 16 | AlertDialogFooter, 17 | AlertDialogHeader, 18 | AlertDialogTitle, 19 | AlertDialogTrigger, 20 | } from "@/components/ui/alert-dialog" 21 | import { Input } from "../ui/input" 22 | import { createCategory, getAllCategories } from "@/lib/actions/category.actions" 23 | 24 | type DropdownProps = { 25 | value?: string 26 | onChangeHandler?: () => void 27 | } 28 | 29 | const Dropdown = ({ value, onChangeHandler }: DropdownProps) => { 30 | const [categories, setCategories] = useState([]) 31 | const [newCategory, setNewCategory] = useState(''); 32 | 33 | const handleAddCategory = () => { 34 | createCategory({ 35 | categoryName: newCategory.trim() 36 | }) 37 | .then((category) => { 38 | setCategories((prevState) => [...prevState, category]) 39 | }) 40 | } 41 | 42 | useEffect(() => { 43 | const getCategories = async () => { 44 | const categoryList = await getAllCategories(); 45 | 46 | categoryList && setCategories(categoryList as ICategory[]) 47 | } 48 | 49 | getCategories(); 50 | }, []) 51 | 52 | return ( 53 | setNewCategory(e.target.value)} /> 71 | 72 | 73 | 74 | Cancel 75 | startTransition(handleAddCategory)}>Add 76 | 77 | 78 | 79 | 80 | 81 | ) 82 | } 83 | 84 | export default Dropdown -------------------------------------------------------------------------------- /components/shared/EventForm.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { zodResolver } from "@hookform/resolvers/zod" 4 | import { useForm } from "react-hook-form" 5 | import { Button } from "@/components/ui/button" 6 | import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" 7 | import { Input } from "@/components/ui/input" 8 | import { eventFormSchema } from "@/lib/validator" 9 | import * as z from 'zod' 10 | import { eventDefaultValues } from "@/constants" 11 | import Dropdown from "./Dropdown" 12 | import { Textarea } from "@/components/ui/textarea" 13 | import { FileUploader } from "./FileUploader" 14 | import { useState } from "react" 15 | import Image from "next/image" 16 | import DatePicker from "react-datepicker"; 17 | import { useUploadThing } from '@/lib/uploadthing' 18 | 19 | import "react-datepicker/dist/react-datepicker.css"; 20 | import { Checkbox } from "../ui/checkbox" 21 | import { useRouter } from "next/navigation" 22 | import { createEvent, updateEvent } from "@/lib/actions/event.actions" 23 | import { IEvent } from "@/lib/database/models/event.model" 24 | 25 | 26 | type EventFormProps = { 27 | userId: string 28 | type: "Create" | "Update" 29 | event?: IEvent, 30 | eventId?: string 31 | } 32 | 33 | const EventForm = ({ userId, type, event, eventId }: EventFormProps) => { 34 | const [files, setFiles] = useState([]) 35 | const initialValues = event && type === 'Update' 36 | ? { 37 | ...event, 38 | startDateTime: new Date(event.startDateTime), 39 | endDateTime: new Date(event.endDateTime) 40 | } 41 | : eventDefaultValues; 42 | const router = useRouter(); 43 | 44 | const { startUpload } = useUploadThing('imageUploader') 45 | 46 | const form = useForm>({ 47 | resolver: zodResolver(eventFormSchema), 48 | defaultValues: initialValues 49 | }) 50 | 51 | async function onSubmit(values: z.infer) { 52 | let uploadedImageUrl = values.imageUrl; 53 | 54 | if(files.length > 0) { 55 | const uploadedImages = await startUpload(files) 56 | 57 | if(!uploadedImages) { 58 | return 59 | } 60 | 61 | uploadedImageUrl = uploadedImages[0].url 62 | } 63 | 64 | if(type === 'Create') { 65 | try { 66 | const newEvent = await createEvent({ 67 | event: { ...values, imageUrl: uploadedImageUrl }, 68 | userId, 69 | path: '/profile' 70 | }) 71 | 72 | if(newEvent) { 73 | form.reset(); 74 | router.push(`/events/${newEvent._id}`) 75 | } 76 | } catch (error) { 77 | console.log(error); 78 | } 79 | } 80 | 81 | if(type === 'Update') { 82 | if(!eventId) { 83 | router.back() 84 | return; 85 | } 86 | 87 | try { 88 | const updatedEvent = await updateEvent({ 89 | userId, 90 | event: { ...values, imageUrl: uploadedImageUrl, _id: eventId }, 91 | path: `/events/${eventId}` 92 | }) 93 | 94 | if(updatedEvent) { 95 | form.reset(); 96 | router.push(`/events/${updatedEvent._id}`) 97 | } 98 | } catch (error) { 99 | console.log(error); 100 | } 101 | } 102 | } 103 | 104 | return ( 105 |
106 | 107 |
108 | ( 112 | 113 | 114 | 115 | 116 | 117 | 118 | )} 119 | /> 120 | ( 124 | 125 | 126 | 127 | 128 | 129 | 130 | )} 131 | /> 132 |
133 | 134 |
135 | ( 139 | 140 | 141 |