├── .gitignore ├── LICENSE ├── README.md ├── components.json ├── docker-compose.yml ├── drizzle.config.ts ├── eslint.config.mjs ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public └── imgs │ └── products │ ├── js-simplified.jpg │ └── ts-simplified.jpg ├── src ├── app │ ├── (auth) │ │ ├── layout.tsx │ │ ├── sign-in │ │ │ └── [[...sign-in]] │ │ │ │ └── page.tsx │ │ └── sign-up │ │ │ └── [[...sign-up]] │ │ │ └── page.tsx │ ├── (consumer) │ │ ├── courses │ │ │ ├── [courseId] │ │ │ │ ├── _client.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── lessons │ │ │ │ │ └── [lessonId] │ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── products │ │ │ ├── [productId] │ │ │ │ ├── page.tsx │ │ │ │ └── purchase │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── success │ │ │ │ │ └── page.tsx │ │ │ └── purchase-failure │ │ │ │ └── page.tsx │ │ └── purchases │ │ │ ├── [purchaseId] │ │ │ └── page.tsx │ │ │ └── page.tsx │ ├── admin │ │ ├── courses │ │ │ ├── [courseId] │ │ │ │ └── edit │ │ │ │ │ └── page.tsx │ │ │ ├── new │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── products │ │ │ ├── [productId] │ │ │ │ └── edit │ │ │ │ │ └── page.tsx │ │ │ ├── new │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ └── sales │ │ │ └── page.tsx │ ├── api │ │ ├── clerk │ │ │ └── syncUsers │ │ │ │ └── route.ts │ │ └── webhooks │ │ │ ├── clerk │ │ │ └── route.ts │ │ │ └── stripe │ │ │ └── route.ts │ ├── favicon.ico │ ├── globals.css │ └── layout.tsx ├── components │ ├── ActionButton.tsx │ ├── LoadingSpinner.tsx │ ├── PageHeader.tsx │ ├── RequiredLabelIcon.tsx │ ├── Skeleton.tsx │ ├── SortableList.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── command.tsx │ │ ├── custom │ │ └── multi-select.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── select.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ └── toaster.tsx ├── data │ ├── env │ │ ├── client.ts │ │ └── server.ts │ ├── pppCoupons.ts │ └── typeOverrides │ │ └── clerk.d.ts ├── drizzle │ ├── db.ts │ ├── migrations │ │ ├── 0000_orange_wind_dancer.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ └── _journal.json │ ├── schema.ts │ ├── schema │ │ ├── course.ts │ │ ├── courseProduct.ts │ │ ├── courseSection.ts │ │ ├── lesson.ts │ │ ├── product.ts │ │ ├── purchase.ts │ │ ├── user.ts │ │ ├── userCourseAccess.ts │ │ └── userLessonComplete.ts │ └── schemaHelpers.ts ├── features │ ├── courseSections │ │ ├── actions │ │ │ └── sections.ts │ │ ├── components │ │ │ ├── SectionForm.tsx │ │ │ ├── SectionFormDialog.tsx │ │ │ └── SortableSectionList.tsx │ │ ├── db │ │ │ ├── cache.ts │ │ │ └── sections.ts │ │ ├── permissions │ │ │ └── sections.ts │ │ └── schemas │ │ │ └── sections.ts │ ├── courses │ │ ├── actions │ │ │ └── courses.ts │ │ ├── components │ │ │ ├── CourseForm.tsx │ │ │ └── CourseTable.tsx │ │ ├── db │ │ │ ├── cache │ │ │ │ ├── courses.ts │ │ │ │ └── userCourseAccess.ts │ │ │ ├── courses.ts │ │ │ └── userCourseAcccess.ts │ │ ├── permissions │ │ │ └── courses.ts │ │ └── schemas │ │ │ └── courses.ts │ ├── lessons │ │ ├── actions │ │ │ ├── lessons.ts │ │ │ └── userLessonComplete.ts │ │ ├── components │ │ │ ├── LessonForm.tsx │ │ │ ├── LessonFormDialog.tsx │ │ │ ├── SortableLessonList.tsx │ │ │ └── YouTubeVideoPlayer.tsx │ │ ├── db │ │ │ ├── cache │ │ │ │ ├── lessons.ts │ │ │ │ └── userLessonComplete.ts │ │ │ ├── lessons.ts │ │ │ └── userLessonComplete.ts │ │ ├── permissions │ │ │ ├── lessons.ts │ │ │ └── userLessonComplete.ts │ │ └── schemas │ │ │ └── lessons.ts │ ├── products │ │ ├── actions │ │ │ └── products.ts │ │ ├── components │ │ │ ├── ProductCard.tsx │ │ │ ├── ProductForm.tsx │ │ │ └── ProductTable.tsx │ │ ├── db │ │ │ ├── cache.ts │ │ │ └── products.ts │ │ ├── permissions │ │ │ └── products.ts │ │ └── schema │ │ │ └── products.ts │ ├── purchases │ │ ├── actions │ │ │ └── purchases.ts │ │ ├── components │ │ │ ├── PurchaseTable.tsx │ │ │ └── UserPurchaseTable.tsx │ │ ├── db │ │ │ ├── cache.ts │ │ │ └── purchases.ts │ │ └── permissions │ │ │ └── products.ts │ └── users │ │ └── db │ │ ├── cache.ts │ │ └── users.ts ├── hooks │ └── use-toast.ts ├── lib │ ├── dataCache.ts │ ├── formatters.ts │ ├── sumArray.ts │ ├── userCountryHeader.ts │ └── utils.ts ├── middleware.ts ├── permissions │ └── general.ts └── services │ ├── clerk.ts │ └── stripe │ ├── actions │ └── stripe.ts │ ├── components │ └── StripeCheckoutForm.tsx │ ├── stripeClient.ts │ └── stripeServer.ts ├── tailwind.config.ts └── tsconfig.json /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 WebDevSimplified 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:17.0 4 | hostname: localhost 5 | ports: 6 | - "5432:5432" 7 | environment: 8 | - POSTGRES_PASSWORD=${DB_PASSWORD} 9 | - POSTGRES_USER=${DB_USER} 10 | - POSTGRES_DB=${DB_NAME} 11 | volumes: 12 | - pgdata:/var/lib/postgresql/data 13 | volumes: 14 | pgdata: 15 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/data/env/server" 2 | import { defineConfig } from "drizzle-kit" 3 | 4 | export default defineConfig({ 5 | out: "./src/drizzle/migrations", 6 | schema: "./src/drizzle/schema.ts", 7 | dialect: "postgresql", 8 | strict: true, 9 | verbose: true, 10 | dbCredentials: { 11 | password: env.DB_PASSWORD, 12 | user: env.DB_USER, 13 | database: env.DB_NAME, 14 | host: env.DB_HOST, 15 | ssl: false, 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next" 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | experimental: { 6 | dynamicIO: true, 7 | authInterrupts: true, 8 | }, 9 | } 10 | 11 | export default nextConfig 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "course-platform-project", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "db:generate": "drizzle-kit generate", 11 | "db:migrate": "drizzle-kit migrate", 12 | "db:studio": "drizzle-kit studio" 13 | }, 14 | "dependencies": { 15 | "@arcjet/next": "^1.0.0-beta.1", 16 | "@clerk/nextjs": "^6.9.10", 17 | "@dnd-kit/core": "^6.3.1", 18 | "@dnd-kit/sortable": "^10.0.0", 19 | "@hookform/resolvers": "^3.10.0", 20 | "@radix-ui/react-accordion": "^1.2.2", 21 | "@radix-ui/react-alert-dialog": "^1.1.4", 22 | "@radix-ui/react-dialog": "^1.1.4", 23 | "@radix-ui/react-label": "^2.1.1", 24 | "@radix-ui/react-popover": "^1.1.4", 25 | "@radix-ui/react-select": "^2.1.4", 26 | "@radix-ui/react-slot": "^1.1.1", 27 | "@radix-ui/react-tabs": "^1.1.2", 28 | "@radix-ui/react-toast": "^1.2.4", 29 | "@stripe/react-stripe-js": "^3.1.1", 30 | "@stripe/stripe-js": "^5.5.0", 31 | "@t3-oss/env-nextjs": "^0.11.1", 32 | "class-variance-authority": "^0.7.1", 33 | "clsx": "^2.1.1", 34 | "cmdk": "^1.0.0", 35 | "drizzle-orm": "^0.38.3", 36 | "lucide-react": "^0.471.1", 37 | "next": "^15.2.0-canary.12", 38 | "pg": "^8.13.1", 39 | "react": "^19.0.0", 40 | "react-dom": "^19.0.0", 41 | "react-hook-form": "^7.54.2", 42 | "react-youtube": "^10.1.0", 43 | "stripe": "^17.5.0", 44 | "svix": "^1.45.1", 45 | "tailwind-merge": "^2.6.0", 46 | "tailwindcss-animate": "^1.0.7", 47 | "zod": "^3.24.1" 48 | }, 49 | "devDependencies": { 50 | "@eslint/eslintrc": "^3", 51 | "@tailwindcss/container-queries": "^0.1.1", 52 | "@types/node": "^20", 53 | "@types/react": "^19", 54 | "@types/react-dom": "^19", 55 | "drizzle-kit": "^0.30.1", 56 | "eslint": "^9", 57 | "eslint-config-next": "15.2.0-canary.11", 58 | "postcss": "^8", 59 | "tailwindcss": "^3.4.1", 60 | "typescript": "^5" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/imgs/products/js-simplified.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebDevSimplified/course-platform/082e1fce0c80dd14a0bdda44ef51b76b9a3b749e/public/imgs/products/js-simplified.jpg -------------------------------------------------------------------------------- /public/imgs/products/ts-simplified.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebDevSimplified/course-platform/082e1fce0c80dd14a0bdda44ef51b76b9a3b749e/public/imgs/products/ts-simplified.jpg -------------------------------------------------------------------------------- /src/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | export default async function AuthLayout({ 2 | children, 3 | }: { 4 | children: React.ReactNode 5 | }) { 6 | return ( 7 |
8 | {children} 9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs" 2 | 3 | export default function Page() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs" 2 | 3 | export default function Page() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /src/app/(consumer)/courses/[courseId]/_client.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | Accordion, 5 | AccordionContent, 6 | AccordionItem, 7 | AccordionTrigger, 8 | } from "@/components/ui/accordion" 9 | import { Button } from "@/components/ui/button" 10 | import { cn } from "@/lib/utils" 11 | import { CheckCircle2Icon, VideoIcon } from "lucide-react" 12 | import Link from "next/link" 13 | import { useParams } from "next/navigation" 14 | 15 | export function CoursePageClient({ 16 | course, 17 | }: { 18 | course: { 19 | id: string 20 | courseSections: { 21 | id: string 22 | name: string 23 | lessons: { 24 | id: string 25 | name: string 26 | isComplete: boolean 27 | }[] 28 | }[] 29 | } 30 | }) { 31 | const { lessonId } = useParams() 32 | const defaultValue = 33 | typeof lessonId === "string" 34 | ? course.courseSections.find(section => 35 | section.lessons.find(lesson => lesson.id === lessonId) 36 | ) 37 | : course.courseSections[0] 38 | 39 | return ( 40 | 44 | {course.courseSections.map(section => ( 45 | 46 | 47 | {section.name} 48 | 49 | 50 | {section.lessons.map(lesson => ( 51 | 69 | ))} 70 | 71 | 72 | ))} 73 | 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /src/app/(consumer)/courses/[courseId]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/drizzle/db" 2 | import { 3 | CourseSectionTable, 4 | CourseTable, 5 | LessonTable, 6 | UserLessonCompleteTable, 7 | } from "@/drizzle/schema" 8 | import { getCourseIdTag } from "@/features/courses/db/cache/courses" 9 | import { getCourseSectionCourseTag } from "@/features/courseSections/db/cache" 10 | import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections" 11 | import { getLessonCourseTag } from "@/features/lessons/db/cache/lessons" 12 | import { wherePublicLessons } from "@/features/lessons/permissions/lessons" 13 | import { getCurrentUser } from "@/services/clerk" 14 | import { asc, eq } from "drizzle-orm" 15 | import { cacheTag } from "next/dist/server/use-cache/cache-tag" 16 | import { notFound } from "next/navigation" 17 | import { ReactNode, Suspense } from "react" 18 | import { CoursePageClient } from "./_client" 19 | import { getUserLessonCompleteUserTag } from "@/features/lessons/db/cache/userLessonComplete" 20 | 21 | export default async function CoursePageLayout({ 22 | params, 23 | children, 24 | }: { 25 | params: Promise<{ courseId: string }> 26 | children: ReactNode 27 | }) { 28 | const { courseId } = await params 29 | const course = await getCourse(courseId) 30 | 31 | if (course == null) return notFound() 32 | 33 | return ( 34 |
35 |
36 |
{course.name}
37 | } 39 | > 40 | 41 | 42 |
43 |
{children}
44 |
45 | ) 46 | } 47 | 48 | async function getCourse(id: string) { 49 | "use cache" 50 | cacheTag( 51 | getCourseIdTag(id), 52 | getCourseSectionCourseTag(id), 53 | getLessonCourseTag(id) 54 | ) 55 | 56 | return db.query.CourseTable.findFirst({ 57 | where: eq(CourseTable.id, id), 58 | columns: { id: true, name: true }, 59 | with: { 60 | courseSections: { 61 | orderBy: asc(CourseSectionTable.order), 62 | where: wherePublicCourseSections, 63 | columns: { id: true, name: true }, 64 | with: { 65 | lessons: { 66 | orderBy: asc(LessonTable.order), 67 | where: wherePublicLessons, 68 | columns: { 69 | id: true, 70 | name: true, 71 | }, 72 | }, 73 | }, 74 | }, 75 | }, 76 | }) 77 | } 78 | 79 | async function SuspenseBoundary({ 80 | course, 81 | }: { 82 | course: { 83 | name: string 84 | id: string 85 | courseSections: { 86 | name: string 87 | id: string 88 | lessons: { 89 | name: string 90 | id: string 91 | }[] 92 | }[] 93 | } 94 | }) { 95 | const { userId } = await getCurrentUser() 96 | const completedLessonIds = 97 | userId == null ? [] : await getCompletedLessonIds(userId) 98 | 99 | return 100 | } 101 | 102 | async function getCompletedLessonIds(userId: string) { 103 | "use cache" 104 | cacheTag(getUserLessonCompleteUserTag(userId)) 105 | 106 | const data = await db.query.UserLessonCompleteTable.findMany({ 107 | columns: { lessonId: true }, 108 | where: eq(UserLessonCompleteTable.userId, userId), 109 | }) 110 | 111 | return data.map(d => d.lessonId) 112 | } 113 | 114 | function mapCourse( 115 | course: { 116 | name: string 117 | id: string 118 | courseSections: { 119 | name: string 120 | id: string 121 | lessons: { 122 | name: string 123 | id: string 124 | }[] 125 | }[] 126 | }, 127 | completedLessonIds: string[] 128 | ) { 129 | return { 130 | ...course, 131 | courseSections: course.courseSections.map(section => { 132 | return { 133 | ...section, 134 | lessons: section.lessons.map(lesson => { 135 | return { 136 | ...lesson, 137 | isComplete: completedLessonIds.includes(lesson.id), 138 | } 139 | }), 140 | } 141 | }), 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/app/(consumer)/courses/[courseId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { PageHeader } from "@/components/PageHeader" 2 | import { db } from "@/drizzle/db" 3 | import { CourseTable } from "@/drizzle/schema" 4 | import { getCourseIdTag } from "@/features/courses/db/cache/courses" 5 | import { eq } from "drizzle-orm" 6 | import { cacheTag } from "next/dist/server/use-cache/cache-tag" 7 | import { notFound } from "next/navigation" 8 | 9 | export default async function CoursePage({ 10 | params, 11 | }: { 12 | params: Promise<{ courseId: string }> 13 | }) { 14 | const { courseId } = await params 15 | const course = await getCourse(courseId) 16 | 17 | if (course == null) return notFound() 18 | 19 | return ( 20 |
21 | 22 |

{course.description}

23 |
24 | ) 25 | } 26 | 27 | async function getCourse(id: string) { 28 | "use cache" 29 | cacheTag(getCourseIdTag(id)) 30 | 31 | return db.query.CourseTable.findFirst({ 32 | columns: { id: true, name: true, description: true }, 33 | where: eq(CourseTable.id, id), 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /src/app/(consumer)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import { canAccessAdminPages } from "@/permissions/general" 3 | import { getCurrentUser } from "@/services/clerk" 4 | import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs" 5 | import Link from "next/link" 6 | import { ReactNode, Suspense } from "react" 7 | 8 | export default function ConsumerLayout({ 9 | children, 10 | }: Readonly<{ children: ReactNode }>) { 11 | return ( 12 | <> 13 | 14 | {children} 15 | 16 | ) 17 | } 18 | 19 | function Navbar() { 20 | return ( 21 |
22 | 63 |
64 | ) 65 | } 66 | 67 | async function AdminLink() { 68 | const user = await getCurrentUser() 69 | if (!canAccessAdminPages(user)) return null 70 | 71 | return ( 72 | 73 | Admin 74 | 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /src/app/(consumer)/page.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/drizzle/db" 2 | import { ProductTable } from "@/drizzle/schema" 3 | import { ProductCard } from "@/features/products/components/ProductCard" 4 | import { getProductGlobalTag } from "@/features/products/db/cache" 5 | import { wherePublicProducts } from "@/features/products/permissions/products" 6 | import { asc } from "drizzle-orm" 7 | import { cacheTag } from "next/dist/server/use-cache/cache-tag" 8 | 9 | export default async function HomePage() { 10 | const products = await getPublicProducts() 11 | 12 | return ( 13 |
14 |
15 | {products.map(product => ( 16 | 17 | ))} 18 |
19 |
20 | ) 21 | } 22 | 23 | async function getPublicProducts() { 24 | "use cache" 25 | cacheTag(getProductGlobalTag()) 26 | 27 | return db.query.ProductTable.findMany({ 28 | columns: { 29 | id: true, 30 | name: true, 31 | description: true, 32 | priceInDollars: true, 33 | imageUrl: true, 34 | }, 35 | where: wherePublicProducts, 36 | orderBy: asc(ProductTable.name), 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/app/(consumer)/products/[productId]/purchase/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "@/components/LoadingSpinner" 2 | import { PageHeader } from "@/components/PageHeader" 3 | import { db } from "@/drizzle/db" 4 | import { ProductTable } from "@/drizzle/schema" 5 | import { getProductIdTag } from "@/features/products/db/cache" 6 | import { userOwnsProduct } from "@/features/products/db/products" 7 | import { wherePublicProducts } from "@/features/products/permissions/products" 8 | import { getCurrentUser } from "@/services/clerk" 9 | import { StripeCheckoutForm } from "@/services/stripe/components/StripeCheckoutForm" 10 | import { SignIn, SignUp } from "@clerk/nextjs" 11 | import { and, eq } from "drizzle-orm" 12 | import { cacheTag } from "next/dist/server/use-cache/cache-tag" 13 | import { notFound, redirect } from "next/navigation" 14 | import { Suspense } from "react" 15 | 16 | export default function PurchasePage({ 17 | params, 18 | searchParams, 19 | }: { 20 | params: Promise<{ productId: string }> 21 | searchParams: Promise<{ authMode: string }> 22 | }) { 23 | return ( 24 | }> 25 | 26 | 27 | ) 28 | } 29 | 30 | async function SuspendedComponent({ 31 | params, 32 | searchParams, 33 | }: { 34 | params: Promise<{ productId: string }> 35 | searchParams: Promise<{ authMode: string }> 36 | }) { 37 | const { productId } = await params 38 | const { user } = await getCurrentUser({ allData: true }) 39 | const product = await getPublicProduct(productId) 40 | 41 | if (product == null) return notFound() 42 | 43 | if (user != null) { 44 | if (await userOwnsProduct({ userId: user.id, productId })) { 45 | redirect("/courses") 46 | } 47 | 48 | return ( 49 |
50 | 51 |
52 | ) 53 | } 54 | 55 | const { authMode } = await searchParams 56 | const isSignUp = authMode === "signUp" 57 | 58 | return ( 59 |
60 | 61 | {isSignUp ? ( 62 | 67 | ) : ( 68 | 73 | )} 74 |
75 | ) 76 | } 77 | 78 | async function getPublicProduct(id: string) { 79 | "use cache" 80 | cacheTag(getProductIdTag(id)) 81 | 82 | return db.query.ProductTable.findFirst({ 83 | columns: { 84 | name: true, 85 | id: true, 86 | imageUrl: true, 87 | description: true, 88 | priceInDollars: true, 89 | }, 90 | where: and(eq(ProductTable.id, id), wherePublicProducts), 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /src/app/(consumer)/products/[productId]/purchase/success/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import { db } from "@/drizzle/db" 3 | import { ProductTable } from "@/drizzle/schema" 4 | import { getProductIdTag } from "@/features/products/db/cache" 5 | import { wherePublicProducts } from "@/features/products/permissions/products" 6 | import { and, eq } from "drizzle-orm" 7 | import { cacheTag } from "next/dist/server/use-cache/cache-tag" 8 | import Image from "next/image" 9 | import Link from "next/link" 10 | 11 | export default async function ProductPurchaseSuccessPage({ 12 | params, 13 | }: { 14 | params: Promise<{ productId: string }> 15 | }) { 16 | const { productId } = await params 17 | const product = await getPublicProduct(productId) 18 | 19 | if (product == null) return 20 | 21 | return ( 22 |
23 |
24 |
25 |
Purchase Successful
26 |
27 | Thank you for purchasing {product.name}. 28 |
29 | 32 |
33 |
34 | {product.name} 40 |
41 |
42 |
43 | ) 44 | } 45 | 46 | async function getPublicProduct(id: string) { 47 | "use cache" 48 | cacheTag(getProductIdTag(id)) 49 | 50 | return db.query.ProductTable.findFirst({ 51 | columns: { 52 | name: true, 53 | imageUrl: true, 54 | }, 55 | where: and(eq(ProductTable.id, id), wherePublicProducts), 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /src/app/(consumer)/products/purchase-failure/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import Link from "next/link" 3 | 4 | export default async function ProductPurchaseFailurePage() { 5 | return ( 6 |
7 |
8 |
Purchase Failed
9 |
10 | There was a problem purchasing your product. 11 |
12 | 15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/app/(consumer)/purchases/page.tsx: -------------------------------------------------------------------------------- 1 | import { PageHeader } from "@/components/PageHeader" 2 | import { Button } from "@/components/ui/button" 3 | import { db } from "@/drizzle/db" 4 | import { PurchaseTable } from "@/drizzle/schema" 5 | import { 6 | UserPurchaseTable, 7 | UserPurchaseTableSkeleton, 8 | } from "@/features/purchases/components/UserPurchaseTable" 9 | import { getPurchaseUserTag } from "@/features/purchases/db/cache" 10 | import { getCurrentUser } from "@/services/clerk" 11 | import { desc, eq } from "drizzle-orm" 12 | import { cacheTag } from "next/dist/server/use-cache/cache-tag" 13 | import Link from "next/link" 14 | import { Suspense } from "react" 15 | 16 | export default function PurchasesPage() { 17 | return ( 18 |
19 | 20 | }> 21 | 22 | 23 |
24 | ) 25 | } 26 | 27 | async function SuspenseBoundary() { 28 | const { userId, redirectToSignIn } = await getCurrentUser() 29 | if (userId == null) return redirectToSignIn() 30 | 31 | const purchases = await getPurchases(userId) 32 | 33 | if (purchases.length === 0) { 34 | return ( 35 |
36 | You have made no purchases yet 37 | 40 |
41 | ) 42 | } 43 | 44 | return 45 | } 46 | 47 | async function getPurchases(userId: string) { 48 | "use cache" 49 | cacheTag(getPurchaseUserTag(userId)) 50 | 51 | return db.query.PurchaseTable.findMany({ 52 | columns: { 53 | id: true, 54 | pricePaidInCents: true, 55 | refundedAt: true, 56 | productDetails: true, 57 | createdAt: true, 58 | }, 59 | where: eq(PurchaseTable.userId, userId), 60 | orderBy: desc(PurchaseTable.createdAt), 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /src/app/admin/courses/[courseId]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import { PageHeader } from "@/components/PageHeader" 2 | import { Button } from "@/components/ui/button" 3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 4 | import { DialogTrigger } from "@/components/ui/dialog" 5 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" 6 | import { db } from "@/drizzle/db" 7 | import { CourseSectionTable, CourseTable, LessonTable } from "@/drizzle/schema" 8 | import { CourseForm } from "@/features/courses/components/CourseForm" 9 | import { getCourseIdTag } from "@/features/courses/db/cache/courses" 10 | import { SectionFormDialog } from "@/features/courseSections/components/SectionFormDialog" 11 | import { SortableSectionList } from "@/features/courseSections/components/SortableSectionList" 12 | import { getCourseSectionCourseTag } from "@/features/courseSections/db/cache" 13 | import { LessonFormDialog } from "@/features/lessons/components/LessonFormDialog" 14 | import { SortableLessonList } from "@/features/lessons/components/SortableLessonList" 15 | import { getLessonCourseTag } from "@/features/lessons/db/cache/lessons" 16 | import { cn } from "@/lib/utils" 17 | import { asc, eq } from "drizzle-orm" 18 | import { EyeClosed, PlusIcon } from "lucide-react" 19 | import { cacheTag } from "next/dist/server/use-cache/cache-tag" 20 | import { notFound } from "next/navigation" 21 | 22 | export default async function EditCoursePage({ 23 | params, 24 | }: { 25 | params: Promise<{ courseId: string }> 26 | }) { 27 | const { courseId } = await params 28 | const course = await getCourse(courseId) 29 | 30 | if (course == null) return notFound() 31 | 32 | return ( 33 |
34 | 35 | 36 | 37 | Lessons 38 | Details 39 | 40 | 41 | 42 | 43 | Sections 44 | 45 | 46 | 49 | 50 | 51 | 52 | 53 | 57 | 58 | 59 |
60 | {course.courseSections.map(section => ( 61 | 62 | 63 | 69 | {section.status === "private" && } {section.name} 70 | 71 | 75 | 76 | 79 | 80 | 81 | 82 | 83 | 87 | 88 | 89 | ))} 90 |
91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 |
99 |
100 | ) 101 | } 102 | 103 | async function getCourse(id: string) { 104 | "use cache" 105 | cacheTag( 106 | getCourseIdTag(id), 107 | getCourseSectionCourseTag(id), 108 | getLessonCourseTag(id) 109 | ) 110 | 111 | return db.query.CourseTable.findFirst({ 112 | columns: { id: true, name: true, description: true }, 113 | where: eq(CourseTable.id, id), 114 | with: { 115 | courseSections: { 116 | orderBy: asc(CourseSectionTable.order), 117 | columns: { id: true, status: true, name: true }, 118 | with: { 119 | lessons: { 120 | orderBy: asc(LessonTable.order), 121 | columns: { 122 | id: true, 123 | name: true, 124 | status: true, 125 | description: true, 126 | youtubeVideoId: true, 127 | sectionId: true, 128 | }, 129 | }, 130 | }, 131 | }, 132 | }, 133 | }) 134 | } 135 | -------------------------------------------------------------------------------- /src/app/admin/courses/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { PageHeader } from "@/components/PageHeader" 2 | import { CourseForm } from "@/features/courses/components/CourseForm" 3 | 4 | export default function NewCoursePage() { 5 | return ( 6 |
7 | 8 | 9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/app/admin/courses/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import { PageHeader } from "@/components/PageHeader" 3 | import Link from "next/link" 4 | import { CourseTable } from "@/features/courses/components/CourseTable" 5 | import { cacheTag } from "next/dist/server/use-cache/cache-tag" 6 | import { getCourseGlobalTag } from "@/features/courses/db/cache/courses" 7 | import { db } from "@/drizzle/db" 8 | import { 9 | CourseSectionTable, 10 | CourseTable as DbCourseTable, 11 | LessonTable, 12 | UserCourseAccessTable, 13 | } from "@/drizzle/schema" 14 | import { asc, countDistinct, eq } from "drizzle-orm" 15 | import { getUserCourseAccessGlobalTag } from "@/features/courses/db/cache/userCourseAccess" 16 | import { getCourseSectionGlobalTag } from "@/features/courseSections/db/cache" 17 | import { getLessonGlobalTag } from "@/features/lessons/db/cache/lessons" 18 | 19 | export default async function CoursesPage() { 20 | const courses = await getCourses() 21 | 22 | return ( 23 |
24 | 25 | 28 | 29 | 30 | 31 |
32 | ) 33 | } 34 | 35 | async function getCourses() { 36 | "use cache" 37 | cacheTag( 38 | getCourseGlobalTag(), 39 | getUserCourseAccessGlobalTag(), 40 | getCourseSectionGlobalTag(), 41 | getLessonGlobalTag() 42 | ) 43 | 44 | return db 45 | .select({ 46 | id: DbCourseTable.id, 47 | name: DbCourseTable.name, 48 | sectionsCount: countDistinct(CourseSectionTable), 49 | lessonsCount: countDistinct(LessonTable), 50 | studentsCount: countDistinct(UserCourseAccessTable), 51 | }) 52 | .from(DbCourseTable) 53 | .leftJoin( 54 | CourseSectionTable, 55 | eq(CourseSectionTable.courseId, DbCourseTable.id) 56 | ) 57 | .leftJoin(LessonTable, eq(LessonTable.sectionId, CourseSectionTable.id)) 58 | .leftJoin( 59 | UserCourseAccessTable, 60 | eq(UserCourseAccessTable.courseId, DbCourseTable.id) 61 | ) 62 | .orderBy(asc(DbCourseTable.name)) 63 | .groupBy(DbCourseTable.id) 64 | } 65 | -------------------------------------------------------------------------------- /src/app/admin/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Badge } from "@/components/ui/badge" 2 | import { UserButton } from "@clerk/nextjs" 3 | import Link from "next/link" 4 | import { ReactNode } from "react" 5 | 6 | export default function AdminLayout({ 7 | children, 8 | }: Readonly<{ children: ReactNode }>) { 9 | return ( 10 | <> 11 | 12 | {children} 13 | 14 | ) 15 | } 16 | 17 | function Navbar() { 18 | return ( 19 |
20 | 55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/app/admin/products/[productId]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import { PageHeader } from "@/components/PageHeader" 2 | import { db } from "@/drizzle/db" 3 | import { CourseTable, ProductTable } from "@/drizzle/schema" 4 | import { getCourseGlobalTag } from "@/features/courses/db/cache/courses" 5 | import { ProductForm } from "@/features/products/components/ProductForm" 6 | import { getProductIdTag } from "@/features/products/db/cache" 7 | import { asc, eq } from "drizzle-orm" 8 | import { cacheTag } from "next/dist/server/use-cache/cache-tag" 9 | import { notFound } from "next/navigation" 10 | 11 | export default async function EditProductPage({ 12 | params, 13 | }: { 14 | params: Promise<{ productId: string }> 15 | }) { 16 | const { productId } = await params 17 | const product = await getProduct(productId) 18 | 19 | if (product == null) return notFound() 20 | 21 | return ( 22 |
23 | 24 | c.courseId), 28 | }} 29 | courses={await getCourses()} 30 | /> 31 |
32 | ) 33 | } 34 | 35 | async function getCourses() { 36 | "use cache" 37 | cacheTag(getCourseGlobalTag()) 38 | 39 | return db.query.CourseTable.findMany({ 40 | orderBy: asc(CourseTable.name), 41 | columns: { id: true, name: true }, 42 | }) 43 | } 44 | 45 | async function getProduct(id: string) { 46 | "use cache" 47 | cacheTag(getProductIdTag(id)) 48 | 49 | return db.query.ProductTable.findFirst({ 50 | columns: { 51 | id: true, 52 | name: true, 53 | description: true, 54 | priceInDollars: true, 55 | status: true, 56 | imageUrl: true, 57 | }, 58 | where: eq(ProductTable.id, id), 59 | with: { courseProducts: { columns: { courseId: true } } }, 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /src/app/admin/products/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { PageHeader } from "@/components/PageHeader" 2 | import { db } from "@/drizzle/db" 3 | import { CourseTable } from "@/drizzle/schema" 4 | import { getCourseGlobalTag } from "@/features/courses/db/cache/courses" 5 | import { ProductForm } from "@/features/products/components/ProductForm" 6 | import { asc } from "drizzle-orm" 7 | import { cacheTag } from "next/dist/server/use-cache/cache-tag" 8 | 9 | export default async function NewProductPage() { 10 | return ( 11 |
12 | 13 | 14 |
15 | ) 16 | } 17 | 18 | async function getCourses() { 19 | "use cache" 20 | cacheTag(getCourseGlobalTag()) 21 | 22 | return db.query.CourseTable.findMany({ 23 | orderBy: asc(CourseTable.name), 24 | columns: { id: true, name: true }, 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /src/app/admin/products/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import { PageHeader } from "@/components/PageHeader" 3 | import Link from "next/link" 4 | import { cacheTag } from "next/dist/server/use-cache/cache-tag" 5 | import { db } from "@/drizzle/db" 6 | import { 7 | CourseProductTable, 8 | ProductTable as DbProductTable, 9 | PurchaseTable, 10 | } from "@/drizzle/schema" 11 | import { asc, countDistinct, eq } from "drizzle-orm" 12 | import { getProductGlobalTag } from "@/features/products/db/cache" 13 | import { ProductTable } from "@/features/products/components/ProductTable" 14 | 15 | export default async function ProductsPage() { 16 | const products = await getProducts() 17 | 18 | return ( 19 |
20 | 21 | 24 | 25 | 26 | 27 |
28 | ) 29 | } 30 | 31 | async function getProducts() { 32 | "use cache" 33 | cacheTag(getProductGlobalTag()) 34 | 35 | return db 36 | .select({ 37 | id: DbProductTable.id, 38 | name: DbProductTable.name, 39 | status: DbProductTable.status, 40 | priceInDollars: DbProductTable.priceInDollars, 41 | description: DbProductTable.description, 42 | imageUrl: DbProductTable.imageUrl, 43 | coursesCount: countDistinct(CourseProductTable.courseId), 44 | customersCount: countDistinct(PurchaseTable.userId), 45 | }) 46 | .from(DbProductTable) 47 | .leftJoin(PurchaseTable, eq(PurchaseTable.productId, DbProductTable.id)) 48 | .leftJoin( 49 | CourseProductTable, 50 | eq(CourseProductTable.productId, DbProductTable.id) 51 | ) 52 | .orderBy(asc(DbProductTable.name)) 53 | .groupBy(DbProductTable.id) 54 | } 55 | -------------------------------------------------------------------------------- /src/app/admin/sales/page.tsx: -------------------------------------------------------------------------------- 1 | import { PageHeader } from "@/components/PageHeader" 2 | import { db } from "@/drizzle/db" 3 | import { PurchaseTable as DbPurchaseTable } from "@/drizzle/schema" 4 | import { PurchaseTable } from "@/features/purchases/components/PurchaseTable" 5 | import { getPurchaseGlobalTag } from "@/features/purchases/db/cache" 6 | import { getUserGlobalTag } from "@/features/users/db/cache" 7 | import { desc } from "drizzle-orm" 8 | import { cacheTag } from "next/dist/server/use-cache/cache-tag" 9 | 10 | export default async function PurchasesPage() { 11 | const purchases = await getPurchases() 12 | 13 | return ( 14 |
15 | 16 | 17 | 18 |
19 | ) 20 | } 21 | 22 | async function getPurchases() { 23 | "use cache" 24 | cacheTag(getPurchaseGlobalTag(), getUserGlobalTag()) 25 | 26 | return db.query.PurchaseTable.findMany({ 27 | columns: { 28 | id: true, 29 | pricePaidInCents: true, 30 | refundedAt: true, 31 | productDetails: true, 32 | createdAt: true, 33 | }, 34 | orderBy: desc(DbPurchaseTable.createdAt), 35 | with: { user: { columns: { name: true } } }, 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/app/api/clerk/syncUsers/route.ts: -------------------------------------------------------------------------------- 1 | import { insertUser } from "@/features/users/db/users" 2 | import { syncClerkUserMetadata } from "@/services/clerk" 3 | import { currentUser } from "@clerk/nextjs/server" 4 | import { NextResponse } from "next/server" 5 | 6 | export async function GET(request: Request) { 7 | const user = await currentUser() 8 | 9 | if (user == null) return new Response("User not found", { status: 500 }) 10 | if (user.fullName == null) { 11 | return new Response("User name missing", { status: 500 }) 12 | } 13 | if (user.primaryEmailAddress?.emailAddress == null) { 14 | return new Response("User email missing", { status: 500 }) 15 | } 16 | 17 | const dbUser = await insertUser({ 18 | clerkUserId: user.id, 19 | name: user.fullName, 20 | email: user.primaryEmailAddress.emailAddress, 21 | imageUrl: user.imageUrl, 22 | role: user.publicMetadata.role ?? "user", 23 | }) 24 | 25 | await syncClerkUserMetadata(dbUser) 26 | 27 | await new Promise(res => setTimeout(res, 100)) 28 | 29 | return NextResponse.redirect(request.headers.get("referer") ?? "/") 30 | } 31 | -------------------------------------------------------------------------------- /src/app/api/webhooks/clerk/route.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/data/env/server" 2 | import { deleteUser, insertUser, updateUser } from "@/features/users/db/users" 3 | import { syncClerkUserMetadata } from "@/services/clerk" 4 | import { WebhookEvent } from "@clerk/nextjs/server" 5 | import { headers } from "next/headers" 6 | import { Webhook } from "svix" 7 | 8 | export async function POST(req: Request) { 9 | const headerPayload = await headers() 10 | const svixId = headerPayload.get("svix-id") 11 | const svixTimestamp = headerPayload.get("svix-timestamp") 12 | const svixSignature = headerPayload.get("svix-signature") 13 | 14 | if (!svixId || !svixTimestamp || !svixSignature) { 15 | return new Response("Error occurred -- no svix headers", { 16 | status: 400, 17 | }) 18 | } 19 | 20 | const payload = await req.json() 21 | const body = JSON.stringify(payload) 22 | 23 | const wh = new Webhook(env.CLERK_WEBHOOK_SECRET) 24 | let event: WebhookEvent 25 | 26 | try { 27 | event = wh.verify(body, { 28 | "svix-id": svixId, 29 | "svix-timestamp": svixTimestamp, 30 | "svix-signature": svixSignature, 31 | }) as WebhookEvent 32 | } catch (err) { 33 | console.error("Error verifying webhook:", err) 34 | return new Response("Error occurred", { 35 | status: 400, 36 | }) 37 | } 38 | 39 | switch (event.type) { 40 | case "user.created": 41 | case "user.updated": { 42 | const email = event.data.email_addresses.find( 43 | email => email.id === event.data.primary_email_address_id 44 | )?.email_address 45 | const name = `${event.data.first_name} ${event.data.last_name}`.trim() 46 | if (email == null) return new Response("No email", { status: 400 }) 47 | if (name === "") return new Response("No name", { status: 400 }) 48 | 49 | if (event.type === "user.created") { 50 | const user = await insertUser({ 51 | clerkUserId: event.data.id, 52 | email, 53 | name, 54 | imageUrl: event.data.image_url, 55 | role: "user", 56 | }) 57 | 58 | await syncClerkUserMetadata(user) 59 | } else { 60 | await updateUser( 61 | { clerkUserId: event.data.id }, 62 | { 63 | email, 64 | name, 65 | imageUrl: event.data.image_url, 66 | role: event.data.public_metadata.role, 67 | } 68 | ) 69 | } 70 | break 71 | } 72 | case "user.deleted": { 73 | if (event.data.id != null) { 74 | await deleteUser({ clerkUserId: event.data.id }) 75 | } 76 | break 77 | } 78 | } 79 | 80 | return new Response("", { status: 200 }) 81 | } 82 | -------------------------------------------------------------------------------- /src/app/api/webhooks/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/data/env/server" 2 | import { db } from "@/drizzle/db" 3 | import { ProductTable, UserTable } from "@/drizzle/schema" 4 | import { addUserCourseAccess } from "@/features/courses/db/userCourseAcccess" 5 | import { insertPurchase } from "@/features/purchases/db/purchases" 6 | import { stripeServerClient } from "@/services/stripe/stripeServer" 7 | import { eq } from "drizzle-orm" 8 | import { redirect } from "next/navigation" 9 | import { NextRequest, NextResponse } from "next/server" 10 | import Stripe from "stripe" 11 | 12 | export async function GET(request: NextRequest) { 13 | const stripeSessionId = request.nextUrl.searchParams.get("stripeSessionId") 14 | if (stripeSessionId == null) redirect("/products/purchase-failure") 15 | 16 | let redirectUrl: string 17 | try { 18 | const checkoutSession = await stripeServerClient.checkout.sessions.retrieve( 19 | stripeSessionId, 20 | { expand: ["line_items"] } 21 | ) 22 | const productId = await processStripeCheckout(checkoutSession) 23 | 24 | redirectUrl = `/products/${productId}/purchase/success` 25 | } catch { 26 | redirectUrl = "/products/purchase-failure" 27 | } 28 | 29 | return NextResponse.redirect(new URL(redirectUrl, request.url)) 30 | } 31 | 32 | export async function POST(request: NextRequest) { 33 | const event = await stripeServerClient.webhooks.constructEvent( 34 | await request.text(), 35 | request.headers.get("stripe-signature") as string, 36 | env.STRIPE_WEBHOOK_SECRET 37 | ) 38 | 39 | switch (event.type) { 40 | case "checkout.session.completed": 41 | case "checkout.session.async_payment_succeeded": { 42 | try { 43 | await processStripeCheckout(event.data.object) 44 | } catch { 45 | return new Response(null, { status: 500 }) 46 | } 47 | } 48 | } 49 | return new Response(null, { status: 200 }) 50 | } 51 | 52 | async function processStripeCheckout(checkoutSession: Stripe.Checkout.Session) { 53 | const userId = checkoutSession.metadata?.userId 54 | const productId = checkoutSession.metadata?.productId 55 | 56 | if (userId == null || productId == null) { 57 | throw new Error("Missing metadata") 58 | } 59 | 60 | const [product, user] = await Promise.all([ 61 | getProduct(productId), 62 | await getUser(userId), 63 | ]) 64 | 65 | if (product == null) throw new Error("Product not found") 66 | if (user == null) throw new Error("User not found") 67 | 68 | const courseIds = product.courseProducts.map(cp => cp.courseId) 69 | db.transaction(async trx => { 70 | try { 71 | await addUserCourseAccess({ userId: user.id, courseIds }, trx) 72 | await insertPurchase( 73 | { 74 | stripeSessionId: checkoutSession.id, 75 | pricePaidInCents: 76 | checkoutSession.amount_total || product.priceInDollars * 100, 77 | productDetails: product, 78 | userId: user.id, 79 | productId, 80 | }, 81 | trx 82 | ) 83 | } catch (error) { 84 | trx.rollback() 85 | throw error 86 | } 87 | }) 88 | 89 | return productId 90 | } 91 | 92 | function getProduct(id: string) { 93 | return db.query.ProductTable.findFirst({ 94 | columns: { 95 | id: true, 96 | priceInDollars: true, 97 | name: true, 98 | description: true, 99 | imageUrl: true, 100 | }, 101 | where: eq(ProductTable.id, id), 102 | with: { 103 | courseProducts: { columns: { courseId: true } }, 104 | }, 105 | }) 106 | } 107 | 108 | function getUser(id: string) { 109 | return db.query.UserTable.findFirst({ 110 | columns: { id: true }, 111 | where: eq(UserTable.id, id), 112 | }) 113 | } 114 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebDevSimplified/course-platform/082e1fce0c80dd14a0bdda44ef51b76b9a3b749e/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer base { 10 | :root { 11 | --background: 0 0% 100%; 12 | --foreground: 240 10% 3.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 240 10% 3.9%; 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 240 10% 3.9%; 17 | --primary: 240 5.9% 10%; 18 | --primary-foreground: 0 0% 98%; 19 | --secondary: 240 4.8% 95.9%; 20 | --secondary-foreground: 240 5.9% 10%; 21 | --muted: 240 4.8% 95.9%; 22 | --muted-foreground: 240 3.8% 46.1%; 23 | --accent: 280 75% 50%; 24 | --accent-foreground: 0 0% 98%; 25 | --destructive: 0 84.2% 60.2%; 26 | --destructive-foreground: 0 0% 98%; 27 | --border: 240 5.9% 90%; 28 | --input: 240 5.9% 90%; 29 | --ring: 240 10% 3.9%; 30 | --chart-1: 12 76% 61%; 31 | --chart-2: 173 58% 39%; 32 | --chart-3: 197 37% 24%; 33 | --chart-4: 43 74% 66%; 34 | --chart-5: 27 87% 67%; 35 | --radius: 0.5rem; 36 | } 37 | } 38 | 39 | @layer base { 40 | * { 41 | @apply border-border; 42 | } 43 | body { 44 | @apply bg-background text-foreground; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next" 2 | import "./globals.css" 3 | import { ClerkProvider } from "@clerk/nextjs" 4 | import { Toaster } from "@/components/ui/toaster" 5 | 6 | export const metadata: Metadata = { 7 | title: "Create Next App", 8 | description: "Generated by create next app", 9 | } 10 | 11 | export default function RootLayout({ 12 | children, 13 | }: Readonly<{ 14 | children: React.ReactNode 15 | }>) { 16 | return ( 17 | 18 | 19 | 20 | {children} 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/components/ActionButton.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ComponentPropsWithRef, ReactNode, useTransition } from "react" 4 | import { Button } from "./ui/button" 5 | import { actionToast } from "@/hooks/use-toast" 6 | import { Loader2Icon } from "lucide-react" 7 | import { cn } from "@/lib/utils" 8 | import { 9 | AlertDialog, 10 | AlertDialogDescription, 11 | AlertDialogHeader, 12 | AlertDialogTitle, 13 | AlertDialogContent, 14 | AlertDialogTrigger, 15 | AlertDialogFooter, 16 | AlertDialogCancel, 17 | AlertDialogAction, 18 | } from "./ui/alert-dialog" 19 | 20 | export function ActionButton({ 21 | action, 22 | requireAreYouSure = false, 23 | ...props 24 | }: Omit, "onClick"> & { 25 | action: () => Promise<{ error: boolean; message: string }> 26 | requireAreYouSure?: boolean 27 | }) { 28 | { 29 | const [isLoading, startTransition] = useTransition() 30 | 31 | function performAction() { 32 | startTransition(async () => { 33 | const data = await action() 34 | actionToast({ actionData: data }) 35 | }) 36 | } 37 | 38 | if (requireAreYouSure) { 39 | return ( 40 | 41 | 42 | 68 | ) 69 | } 70 | } 71 | 72 | function LoadingTextSwap({ 73 | isLoading, 74 | children, 75 | }: { 76 | isLoading: boolean 77 | children: ReactNode 78 | }) { 79 | return ( 80 |
81 |
87 | {children} 88 |
89 |
95 | 96 |
97 |
98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import { Loader2Icon } from "lucide-react" 3 | import { ComponentProps } from "react" 4 | 5 | export function LoadingSpinner({ 6 | className, 7 | ...props 8 | }: ComponentProps) { 9 | return ( 10 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/PageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import { ReactNode } from "react" 3 | 4 | export function PageHeader({ 5 | title, 6 | children, 7 | className, 8 | }: { 9 | title: string 10 | children?: ReactNode 11 | className?: string 12 | }) { 13 | return ( 14 |
17 |

{title}

18 | {children &&
{children}
} 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/RequiredLabelIcon.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import { AsteriskIcon } from "lucide-react" 3 | import { ComponentPropsWithoutRef } from "react" 4 | 5 | export function RequiredLabelIcon({ 6 | className, 7 | ...props 8 | }: ComponentPropsWithoutRef) { 9 | return ( 10 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import { buttonVariants } from "./ui/button" 3 | import { ReactNode } from "react" 4 | 5 | export function SkeletonButton({ className }: { className?: string }) { 6 | return ( 7 |
16 | ) 17 | } 18 | 19 | export function SkeletonArray({ 20 | amount, 21 | children, 22 | }: { 23 | amount: number 24 | children: ReactNode 25 | }) { 26 | return Array.from({ length: amount }).map(() => children) 27 | } 28 | 29 | export function SkeletonText({ 30 | rows = 1, 31 | size = "md", 32 | className, 33 | }: { 34 | rows?: number 35 | size?: "md" | "lg" 36 | className?: string 37 | }) { 38 | return ( 39 |
40 | 41 |
1 && "last:w-3/4", 45 | size === "md" && "h-3", 46 | size === "lg" && "h-5", 47 | className 48 | )} 49 | /> 50 | 51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/components/SortableList.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ReactNode, useId, useOptimistic, useTransition } from "react" 4 | import { DndContext, DragEndEvent } from "@dnd-kit/core" 5 | import { 6 | arrayMove, 7 | SortableContext, 8 | useSortable, 9 | verticalListSortingStrategy, 10 | } from "@dnd-kit/sortable" 11 | import { CSS } from "@dnd-kit/utilities" 12 | import { cn } from "@/lib/utils" 13 | import { GripVerticalIcon } from "lucide-react" 14 | import { actionToast } from "@/hooks/use-toast" 15 | 16 | export function SortableList({ 17 | items, 18 | onOrderChange, 19 | children, 20 | }: { 21 | items: T[] 22 | onOrderChange: ( 23 | newOrder: string[] 24 | ) => Promise<{ error: boolean; message: string }> 25 | children: (items: T[]) => ReactNode 26 | }) { 27 | const dndContextId = useId() 28 | const [optimisticItems, setOptimisticItems] = useOptimistic(items) 29 | const [, startTransition] = useTransition() 30 | 31 | function handleDragEnd(event: DragEndEvent) { 32 | const { active, over } = event 33 | const activeId = active.id.toString() 34 | const overId = over?.id.toString() 35 | if (overId == null || activeId == null) return 36 | 37 | function getNewArray(array: T[], activeId: string, overId: string) { 38 | const oldIndex = array.findIndex(section => section.id === activeId) 39 | const newIndex = array.findIndex(section => section.id === overId) 40 | return arrayMove(array, oldIndex, newIndex) 41 | } 42 | 43 | startTransition(async () => { 44 | setOptimisticItems(items => getNewArray(items, activeId, overId)) 45 | const actionData = await onOrderChange( 46 | getNewArray(optimisticItems, activeId, overId).map(s => s.id) 47 | ) 48 | 49 | actionToast({ actionData }) 50 | }) 51 | } 52 | 53 | return ( 54 | 55 | 59 |
{children(optimisticItems)}
60 |
61 |
62 | ) 63 | } 64 | 65 | export function SortableItem({ 66 | id, 67 | children, 68 | className, 69 | }: { 70 | id: string 71 | children: ReactNode 72 | className?: string 73 | }) { 74 | const { 75 | setNodeRef, 76 | transform, 77 | transition, 78 | activeIndex, 79 | index, 80 | attributes, 81 | listeners, 82 | } = useSortable({ id }) 83 | const isActive = activeIndex === index 84 | 85 | return ( 86 |
97 | 102 |
{children}
103 |
104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )) 21 | AccordionItem.displayName = "AccordionItem" 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )) 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 52 |
{children}
53 |
54 | )) 55 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 56 | 57 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 58 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | destructiveOutline: 17 | "border border-destructive bg-background shadow-sm text-destructive hover:bg-destructive hover:text-destructive-foreground", 18 | outline: 19 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-accent", 20 | secondary: 21 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 22 | ghost: "hover:bg-accent hover:text-accent-foreground", 23 | link: "text-primary underline-offset-4 hover:underline", 24 | }, 25 | size: { 26 | default: "h-9 px-4 py-2", 27 | sm: "h-8 rounded-md px-3 text-xs", 28 | lg: "h-10 rounded-md px-8", 29 | icon: "h-9 w-9", 30 | }, 31 | }, 32 | defaultVariants: { 33 | variant: "default", 34 | size: "default", 35 | }, 36 | } 37 | ) 38 | 39 | export interface ButtonProps 40 | extends React.ButtonHTMLAttributes, 41 | VariantProps { 42 | asChild?: boolean 43 | } 44 | 45 | const Button = React.forwardRef( 46 | ({ className, variant, size, asChild = false, ...props }, ref) => { 47 | const Comp = asChild ? Slot : "button" 48 | return ( 49 | 54 | ) 55 | } 56 | ) 57 | Button.displayName = "Button" 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /src/components/ui/custom/multi-select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Check, ChevronsUpDown } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { Button } from "@/components/ui/button" 8 | import { 9 | Command, 10 | CommandEmpty, 11 | CommandGroup, 12 | CommandInput, 13 | CommandItem, 14 | CommandList, 15 | } from "@/components/ui/command" 16 | import { 17 | Popover, 18 | PopoverContent, 19 | PopoverTrigger, 20 | } from "@/components/ui/popover" 21 | import { Badge } from "../badge" 22 | 23 | export function MultiSelect