Repository: WebDevSimplified/course-platform Branch: main Commit: 082e1fce0c80 Files: 144 Total size: 255.2 KB Directory structure: gitextract_7h_8njrb/ ├── .gitignore ├── LICENSE ├── README.md ├── components.json ├── docker-compose.yml ├── drizzle.config.ts ├── eslint.config.mjs ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── 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 │ │ ├── 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/ │ │ │ ├── course.ts │ │ │ ├── courseProduct.ts │ │ │ ├── courseSection.ts │ │ │ ├── lesson.ts │ │ │ ├── product.ts │ │ │ ├── purchase.ts │ │ │ ├── user.ts │ │ │ ├── userCourseAccess.ts │ │ │ └── userLessonComplete.ts │ │ ├── schema.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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) .env* # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 WebDevSimplified Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ 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). ## Getting Started First, run the development server: ```bash npm run dev # or yarn dev # or pnpm dev # or bun dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 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. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ## Deploy on Vercel 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. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "tailwind.config.ts", "css": "src/app/globals.css", "baseColor": "zinc", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: docker-compose.yml ================================================ services: db: image: postgres:17.0 hostname: localhost ports: - "5432:5432" environment: - POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_USER=${DB_USER} - POSTGRES_DB=${DB_NAME} volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata: ================================================ FILE: drizzle.config.ts ================================================ import { env } from "@/data/env/server" import { defineConfig } from "drizzle-kit" export default defineConfig({ out: "./src/drizzle/migrations", schema: "./src/drizzle/schema.ts", dialect: "postgresql", strict: true, verbose: true, dbCredentials: { password: env.DB_PASSWORD, user: env.DB_USER, database: env.DB_NAME, host: env.DB_HOST, ssl: false, }, }) ================================================ FILE: eslint.config.mjs ================================================ import { dirname } from "path"; import { fileURLToPath } from "url"; import { FlatCompat } from "@eslint/eslintrc"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const compat = new FlatCompat({ baseDirectory: __dirname, }); const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), ]; export default eslintConfig; ================================================ FILE: next.config.ts ================================================ import type { NextConfig } from "next" const nextConfig: NextConfig = { /* config options here */ experimental: { dynamicIO: true, authInterrupts: true, }, } export default nextConfig ================================================ FILE: package.json ================================================ { "name": "course-platform-project", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio" }, "dependencies": { "@arcjet/next": "^1.0.0-beta.1", "@clerk/nextjs": "^6.9.10", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", "@stripe/react-stripe-js": "^3.1.1", "@stripe/stripe-js": "^5.5.0", "@t3-oss/env-nextjs": "^0.11.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", "drizzle-orm": "^0.38.3", "lucide-react": "^0.471.1", "next": "^15.2.0-canary.12", "pg": "^8.13.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-youtube": "^10.1.0", "stripe": "^17.5.0", "svix": "^1.45.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "zod": "^3.24.1" }, "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/container-queries": "^0.1.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "drizzle-kit": "^0.30.1", "eslint": "^9", "eslint-config-next": "15.2.0-canary.11", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" } } ================================================ FILE: postcss.config.mjs ================================================ /** @type {import('postcss-load-config').Config} */ const config = { plugins: { tailwindcss: {}, }, }; export default config; ================================================ FILE: src/app/(auth)/layout.tsx ================================================ export default async function AuthLayout({ children, }: { children: React.ReactNode }) { return (
{children}
) } ================================================ FILE: src/app/(auth)/sign-in/[[...sign-in]]/page.tsx ================================================ import { SignIn } from "@clerk/nextjs" export default function Page() { return } ================================================ FILE: src/app/(auth)/sign-up/[[...sign-up]]/page.tsx ================================================ import { SignUp } from "@clerk/nextjs" export default function Page() { return } ================================================ FILE: src/app/(consumer)/courses/[courseId]/_client.tsx ================================================ "use client" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { CheckCircle2Icon, VideoIcon } from "lucide-react" import Link from "next/link" import { useParams } from "next/navigation" export function CoursePageClient({ course, }: { course: { id: string courseSections: { id: string name: string lessons: { id: string name: string isComplete: boolean }[] }[] } }) { const { lessonId } = useParams() const defaultValue = typeof lessonId === "string" ? course.courseSections.find(section => section.lessons.find(lesson => lesson.id === lessonId) ) : course.courseSections[0] return ( {course.courseSections.map(section => ( {section.name} {section.lessons.map(lesson => ( ))} ))} ) } ================================================ FILE: src/app/(consumer)/courses/[courseId]/layout.tsx ================================================ import { db } from "@/drizzle/db" import { CourseSectionTable, CourseTable, LessonTable, UserLessonCompleteTable, } from "@/drizzle/schema" import { getCourseIdTag } from "@/features/courses/db/cache/courses" import { getCourseSectionCourseTag } from "@/features/courseSections/db/cache" import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections" import { getLessonCourseTag } from "@/features/lessons/db/cache/lessons" import { wherePublicLessons } from "@/features/lessons/permissions/lessons" import { getCurrentUser } from "@/services/clerk" import { asc, eq } from "drizzle-orm" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import { notFound } from "next/navigation" import { ReactNode, Suspense } from "react" import { CoursePageClient } from "./_client" import { getUserLessonCompleteUserTag } from "@/features/lessons/db/cache/userLessonComplete" export default async function CoursePageLayout({ params, children, }: { params: Promise<{ courseId: string }> children: ReactNode }) { const { courseId } = await params const course = await getCourse(courseId) if (course == null) return notFound() return (
{course.name}
} >
{children}
) } async function getCourse(id: string) { "use cache" cacheTag( getCourseIdTag(id), getCourseSectionCourseTag(id), getLessonCourseTag(id) ) return db.query.CourseTable.findFirst({ where: eq(CourseTable.id, id), columns: { id: true, name: true }, with: { courseSections: { orderBy: asc(CourseSectionTable.order), where: wherePublicCourseSections, columns: { id: true, name: true }, with: { lessons: { orderBy: asc(LessonTable.order), where: wherePublicLessons, columns: { id: true, name: true, }, }, }, }, }, }) } async function SuspenseBoundary({ course, }: { course: { name: string id: string courseSections: { name: string id: string lessons: { name: string id: string }[] }[] } }) { const { userId } = await getCurrentUser() const completedLessonIds = userId == null ? [] : await getCompletedLessonIds(userId) return } async function getCompletedLessonIds(userId: string) { "use cache" cacheTag(getUserLessonCompleteUserTag(userId)) const data = await db.query.UserLessonCompleteTable.findMany({ columns: { lessonId: true }, where: eq(UserLessonCompleteTable.userId, userId), }) return data.map(d => d.lessonId) } function mapCourse( course: { name: string id: string courseSections: { name: string id: string lessons: { name: string id: string }[] }[] }, completedLessonIds: string[] ) { return { ...course, courseSections: course.courseSections.map(section => { return { ...section, lessons: section.lessons.map(lesson => { return { ...lesson, isComplete: completedLessonIds.includes(lesson.id), } }), } }), } } ================================================ FILE: src/app/(consumer)/courses/[courseId]/lessons/[lessonId]/page.tsx ================================================ import { ActionButton } from "@/components/ActionButton" import { SkeletonButton } from "@/components/Skeleton" import { Button } from "@/components/ui/button" import { db } from "@/drizzle/db" import { CourseSectionTable, LessonStatus, LessonTable, UserLessonCompleteTable, } from "@/drizzle/schema" import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections" import { updateLessonCompleteStatus } from "@/features/lessons/actions/userLessonComplete" import { YouTubeVideoPlayer } from "@/features/lessons/components/YouTubeVideoPlayer" import { getLessonIdTag } from "@/features/lessons/db/cache/lessons" import { getUserLessonCompleteIdTag } from "@/features/lessons/db/cache/userLessonComplete" import { canViewLesson, wherePublicLessons, } from "@/features/lessons/permissions/lessons" import { canUpdateUserLessonCompleteStatus } from "@/features/lessons/permissions/userLessonComplete" import { getCurrentUser } from "@/services/clerk" import { and, asc, desc, eq, gt, lt } from "drizzle-orm" import { CheckSquare2Icon, LockIcon, XSquareIcon } from "lucide-react" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import Link from "next/link" import { notFound } from "next/navigation" import { ReactNode, Suspense } from "react" export default async function LessonPage({ params, }: { params: Promise<{ courseId: string; lessonId: string }> }) { const { courseId, lessonId } = await params const lesson = await getLesson(lessonId) if (lesson == null) return notFound() return ( }> ) } function LoadingSkeleton() { return null } async function SuspenseBoundary({ lesson, courseId, }: { lesson: { id: string youtubeVideoId: string name: string description: string | null status: LessonStatus sectionId: string order: number } courseId: string }) { const { userId, role } = await getCurrentUser() const isLessonComplete = userId == null ? false : await getIsLessonComplete({ lessonId: lesson.id, userId }) const canView = await canViewLesson({ role, userId }, lesson) const canUpdateCompletionStatus = await canUpdateUserLessonCompleteStatus( { userId }, lesson.id ) return (
{canView ? ( ) : (
)}

{lesson.name}

}> Previous {canUpdateCompletionStatus && (
{isLessonComplete ? ( <> Mark Incomplete ) : ( <> Mark Complete )}
)} }> Next
{canView ? ( lesson.description &&

{lesson.description}

) : (

This lesson is locked. Please purchase the course to view it.

)}
) } async function ToLessonButton({ children, courseId, lessonFunc, lesson, }: { children: ReactNode courseId: string lesson: { id: string sectionId: string order: number } lessonFunc: (lesson: { id: string sectionId: string order: number }) => Promise<{ id: string } | undefined> }) { const toLesson = await lessonFunc(lesson) if (toLesson == null) return null return ( ) } async function getPreviousLesson(lesson: { id: string sectionId: string order: number }) { let previousLesson = await db.query.LessonTable.findFirst({ where: and( lt(LessonTable.order, lesson.order), eq(LessonTable.sectionId, lesson.sectionId), wherePublicLessons ), orderBy: desc(LessonTable.order), columns: { id: true }, }) if (previousLesson == null) { const section = await db.query.CourseSectionTable.findFirst({ where: eq(CourseSectionTable.id, lesson.sectionId), columns: { order: true, courseId: true }, }) if (section == null) return const previousSection = await db.query.CourseSectionTable.findFirst({ where: and( lt(CourseSectionTable.order, section.order), eq(CourseSectionTable.courseId, section.courseId), wherePublicCourseSections ), orderBy: desc(CourseSectionTable.order), columns: { id: true }, }) if (previousSection == null) return previousLesson = await db.query.LessonTable.findFirst({ where: and( eq(LessonTable.sectionId, previousSection.id), wherePublicLessons ), orderBy: desc(LessonTable.order), columns: { id: true }, }) } return previousLesson } async function getNextLesson(lesson: { id: string sectionId: string order: number }) { let nextLesson = await db.query.LessonTable.findFirst({ where: and( gt(LessonTable.order, lesson.order), eq(LessonTable.sectionId, lesson.sectionId), wherePublicLessons ), orderBy: asc(LessonTable.order), columns: { id: true }, }) if (nextLesson == null) { const section = await db.query.CourseSectionTable.findFirst({ where: eq(CourseSectionTable.id, lesson.sectionId), columns: { order: true, courseId: true }, }) if (section == null) return const nextSection = await db.query.CourseSectionTable.findFirst({ where: and( gt(CourseSectionTable.order, section.order), eq(CourseSectionTable.courseId, section.courseId), wherePublicCourseSections ), orderBy: asc(CourseSectionTable.order), columns: { id: true }, }) if (nextSection == null) return nextLesson = await db.query.LessonTable.findFirst({ where: and(eq(LessonTable.sectionId, nextSection.id), wherePublicLessons), orderBy: asc(LessonTable.order), columns: { id: true }, }) } return nextLesson } async function getLesson(id: string) { "use cache" cacheTag(getLessonIdTag(id)) return db.query.LessonTable.findFirst({ columns: { id: true, youtubeVideoId: true, name: true, description: true, status: true, sectionId: true, order: true, }, where: and(eq(LessonTable.id, id), wherePublicLessons), }) } async function getIsLessonComplete({ userId, lessonId, }: { userId: string lessonId: string }) { "use cache" cacheTag(getUserLessonCompleteIdTag({ userId, lessonId })) const data = await db.query.UserLessonCompleteTable.findFirst({ where: and( eq(UserLessonCompleteTable.userId, userId), eq(UserLessonCompleteTable.lessonId, lessonId) ), }) return data != null } ================================================ FILE: src/app/(consumer)/courses/[courseId]/page.tsx ================================================ import { PageHeader } from "@/components/PageHeader" import { db } from "@/drizzle/db" import { CourseTable } from "@/drizzle/schema" import { getCourseIdTag } from "@/features/courses/db/cache/courses" import { eq } from "drizzle-orm" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import { notFound } from "next/navigation" export default async function CoursePage({ params, }: { params: Promise<{ courseId: string }> }) { const { courseId } = await params const course = await getCourse(courseId) if (course == null) return notFound() return (

{course.description}

) } async function getCourse(id: string) { "use cache" cacheTag(getCourseIdTag(id)) return db.query.CourseTable.findFirst({ columns: { id: true, name: true, description: true }, where: eq(CourseTable.id, id), }) } ================================================ FILE: src/app/(consumer)/courses/page.tsx ================================================ import { PageHeader } from "@/components/PageHeader" import { SkeletonArray, SkeletonButton, SkeletonText, } from "@/components/Skeleton" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card" import { db } from "@/drizzle/db" import { CourseSectionTable, CourseTable, LessonTable, UserCourseAccessTable, UserLessonCompleteTable, } from "@/drizzle/schema" import { getCourseIdTag } from "@/features/courses/db/cache/courses" import { getUserCourseAccessUserTag } from "@/features/courses/db/cache/userCourseAccess" import { getCourseSectionCourseTag } from "@/features/courseSections/db/cache" import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections" import { getLessonCourseTag } from "@/features/lessons/db/cache/lessons" import { getUserLessonCompleteUserTag } from "@/features/lessons/db/cache/userLessonComplete" import { wherePublicLessons } from "@/features/lessons/permissions/lessons" import { formatPlural } from "@/lib/formatters" import { getCurrentUser } from "@/services/clerk" import { and, countDistinct, eq } from "drizzle-orm" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import Link from "next/link" import { Suspense } from "react" export default function CoursesPage() { return (
} >
) } async function CourseGrid() { const { userId, redirectToSignIn } = await getCurrentUser() if (userId == null) return redirectToSignIn() const courses = await getUserCourses(userId) if (courses.length === 0) { return (
You have no courses yet
) } return courses.map(course => ( {course.name} {formatPlural(course.sectionsCount, { plural: "sections", singular: "section", })}{" "} •{" "} {formatPlural(course.lessonsCount, { plural: "lessons", singular: "lesson", })} {course.description}
)) } function SkeletonCourseCard() { return ( ) } async function getUserCourses(userId: string) { "use cache" cacheTag( getUserCourseAccessUserTag(userId), getUserLessonCompleteUserTag(userId) ) const courses = await db .select({ id: CourseTable.id, name: CourseTable.name, description: CourseTable.description, sectionsCount: countDistinct(CourseSectionTable.id), lessonsCount: countDistinct(LessonTable.id), lessonsComplete: countDistinct(UserLessonCompleteTable.lessonId), }) .from(CourseTable) .leftJoin( UserCourseAccessTable, and( eq(UserCourseAccessTable.courseId, CourseTable.id), eq(UserCourseAccessTable.userId, userId) ) ) .leftJoin( CourseSectionTable, and( eq(CourseSectionTable.courseId, CourseTable.id), wherePublicCourseSections ) ) .leftJoin( LessonTable, and(eq(LessonTable.sectionId, CourseSectionTable.id), wherePublicLessons) ) .leftJoin( UserLessonCompleteTable, and( eq(UserLessonCompleteTable.lessonId, LessonTable.id), eq(UserLessonCompleteTable.userId, userId) ) ) .orderBy(CourseTable.name) .groupBy(CourseTable.id) courses.forEach(course => { cacheTag( getCourseIdTag(course.id), getCourseSectionCourseTag(course.id), getLessonCourseTag(course.id) ) }) return courses } ================================================ FILE: src/app/(consumer)/layout.tsx ================================================ import { Button } from "@/components/ui/button" import { canAccessAdminPages } from "@/permissions/general" import { getCurrentUser } from "@/services/clerk" import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs" import Link from "next/link" import { ReactNode, Suspense } from "react" export default function ConsumerLayout({ children, }: Readonly<{ children: ReactNode }>) { return ( <> {children} ) } function Navbar() { return (
) } async function AdminLink() { const user = await getCurrentUser() if (!canAccessAdminPages(user)) return null return ( Admin ) } ================================================ FILE: src/app/(consumer)/page.tsx ================================================ import { db } from "@/drizzle/db" import { ProductTable } from "@/drizzle/schema" import { ProductCard } from "@/features/products/components/ProductCard" import { getProductGlobalTag } from "@/features/products/db/cache" import { wherePublicProducts } from "@/features/products/permissions/products" import { asc } from "drizzle-orm" import { cacheTag } from "next/dist/server/use-cache/cache-tag" export default async function HomePage() { const products = await getPublicProducts() return (
{products.map(product => ( ))}
) } async function getPublicProducts() { "use cache" cacheTag(getProductGlobalTag()) return db.query.ProductTable.findMany({ columns: { id: true, name: true, description: true, priceInDollars: true, imageUrl: true, }, where: wherePublicProducts, orderBy: asc(ProductTable.name), }) } ================================================ FILE: src/app/(consumer)/products/[productId]/page.tsx ================================================ import { SkeletonButton } from "@/components/Skeleton" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card" import { db } from "@/drizzle/db" import { CourseSectionTable, LessonTable, ProductTable } from "@/drizzle/schema" import { getCourseIdTag } from "@/features/courses/db/cache/courses" import { getCourseSectionCourseTag } from "@/features/courseSections/db/cache" import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections" import { getLessonCourseTag } from "@/features/lessons/db/cache/lessons" import { wherePublicLessons } from "@/features/lessons/permissions/lessons" import { getProductIdTag } from "@/features/products/db/cache" import { userOwnsProduct } from "@/features/products/db/products" import { wherePublicProducts } from "@/features/products/permissions/products" import { formatPlural, formatPrice } from "@/lib/formatters" import { sumArray } from "@/lib/sumArray" import { getUserCoupon } from "@/lib/userCountryHeader" import { getCurrentUser } from "@/services/clerk" import { and, asc, eq } from "drizzle-orm" import { VideoIcon } from "lucide-react" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import Image from "next/image" import Link from "next/link" import { notFound } from "next/navigation" import { Suspense } from "react" export default async function ProductPage({ params, }: { params: Promise<{ productId: string }> }) { const { productId } = await params const product = await getPublicProduct(productId) if (product == null) return notFound() const courseCount = product.courses.length const lessonCount = sumArray(product.courses, course => sumArray(course.courseSections, s => s.lessons.length) ) return (
{formatPrice(product.priceInDollars)}
} >

{product.name}

{formatPlural(courseCount, { singular: "course", plural: "courses", })}{" "} •{" "} {formatPlural(lessonCount, { singular: "lesson", plural: "lessons", })}
{product.description}
}>
{product.name}
{product.courses.map(course => ( {course.name} {formatPlural(course.courseSections.length, { plural: "sections", singular: "section", })}{" "} •{" "} {formatPlural( sumArray(course.courseSections, s => s.lessons.length), { plural: "lessons", singular: "lesson", } )} {course.courseSections.map(section => (
{section.name} {formatPlural(section.lessons.length, { plural: "lessons", singular: "lesson", })}
{section.lessons.map(lesson => (
{lesson.status === "preview" ? ( {lesson.name} ) : ( lesson.name )}
))}
))}
))}
) } async function PurchaseButton({ productId }: { productId: string }) { const { userId } = await getCurrentUser() const alreadyOwnsProduct = userId != null && (await userOwnsProduct({ userId, productId })) if (alreadyOwnsProduct) { return

You already own this product

} else { return ( ) } } async function Price({ price }: { price: number }) { const coupon = await getUserCoupon() if (price === 0 || coupon == null) { return
{formatPrice(price)}
} return (
{formatPrice(price)}
{formatPrice(price * (1 - coupon.discountPercentage))}
) } async function getPublicProduct(id: string) { "use cache" cacheTag(getProductIdTag(id)) const product = await db.query.ProductTable.findFirst({ columns: { id: true, name: true, description: true, priceInDollars: true, imageUrl: true, }, where: and(eq(ProductTable.id, id), wherePublicProducts), with: { courseProducts: { columns: {}, with: { course: { columns: { id: true, name: true }, with: { courseSections: { columns: { id: true, name: true }, where: wherePublicCourseSections, orderBy: asc(CourseSectionTable.order), with: { lessons: { columns: { id: true, name: true, status: true }, where: wherePublicLessons, orderBy: asc(LessonTable.order), }, }, }, }, }, }, }, }, }) if (product == null) return product cacheTag( ...product.courseProducts.flatMap(cp => [ getLessonCourseTag(cp.course.id), getCourseSectionCourseTag(cp.course.id), getCourseIdTag(cp.course.id), ]) ) const { courseProducts, ...other } = product return { ...other, courses: courseProducts.map(cp => cp.course), } } ================================================ FILE: src/app/(consumer)/products/[productId]/purchase/page.tsx ================================================ import { LoadingSpinner } from "@/components/LoadingSpinner" import { PageHeader } from "@/components/PageHeader" import { db } from "@/drizzle/db" import { ProductTable } from "@/drizzle/schema" import { getProductIdTag } from "@/features/products/db/cache" import { userOwnsProduct } from "@/features/products/db/products" import { wherePublicProducts } from "@/features/products/permissions/products" import { getCurrentUser } from "@/services/clerk" import { StripeCheckoutForm } from "@/services/stripe/components/StripeCheckoutForm" import { SignIn, SignUp } from "@clerk/nextjs" import { and, eq } from "drizzle-orm" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import { notFound, redirect } from "next/navigation" import { Suspense } from "react" export default function PurchasePage({ params, searchParams, }: { params: Promise<{ productId: string }> searchParams: Promise<{ authMode: string }> }) { return ( }> ) } async function SuspendedComponent({ params, searchParams, }: { params: Promise<{ productId: string }> searchParams: Promise<{ authMode: string }> }) { const { productId } = await params const { user } = await getCurrentUser({ allData: true }) const product = await getPublicProduct(productId) if (product == null) return notFound() if (user != null) { if (await userOwnsProduct({ userId: user.id, productId })) { redirect("/courses") } return (
) } const { authMode } = await searchParams const isSignUp = authMode === "signUp" return (
{isSignUp ? ( ) : ( )}
) } async function getPublicProduct(id: string) { "use cache" cacheTag(getProductIdTag(id)) return db.query.ProductTable.findFirst({ columns: { name: true, id: true, imageUrl: true, description: true, priceInDollars: true, }, where: and(eq(ProductTable.id, id), wherePublicProducts), }) } ================================================ FILE: src/app/(consumer)/products/[productId]/purchase/success/page.tsx ================================================ import { Button } from "@/components/ui/button" import { db } from "@/drizzle/db" import { ProductTable } from "@/drizzle/schema" import { getProductIdTag } from "@/features/products/db/cache" import { wherePublicProducts } from "@/features/products/permissions/products" import { and, eq } from "drizzle-orm" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import Image from "next/image" import Link from "next/link" export default async function ProductPurchaseSuccessPage({ params, }: { params: Promise<{ productId: string }> }) { const { productId } = await params const product = await getPublicProduct(productId) if (product == null) return return (
Purchase Successful
Thank you for purchasing {product.name}.
{product.name}
) } async function getPublicProduct(id: string) { "use cache" cacheTag(getProductIdTag(id)) return db.query.ProductTable.findFirst({ columns: { name: true, imageUrl: true, }, where: and(eq(ProductTable.id, id), wherePublicProducts), }) } ================================================ FILE: src/app/(consumer)/products/purchase-failure/page.tsx ================================================ import { Button } from "@/components/ui/button" import Link from "next/link" export default async function ProductPurchaseFailurePage() { return (
Purchase Failed
There was a problem purchasing your product.
) } ================================================ FILE: src/app/(consumer)/purchases/[purchaseId]/page.tsx ================================================ import { LoadingSpinner } from "@/components/LoadingSpinner" import { PageHeader } from "@/components/PageHeader" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card" import { db } from "@/drizzle/db" import { PurchaseTable } from "@/drizzle/schema" import { getPurchaseIdTag } from "@/features/purchases/db/cache" import { formatDate, formatPrice } from "@/lib/formatters" import { cn } from "@/lib/utils" import { getCurrentUser } from "@/services/clerk" import { stripeServerClient } from "@/services/stripe/stripeServer" import { and, eq } from "drizzle-orm" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import Link from "next/link" import { notFound } from "next/navigation" import { Fragment, Suspense } from "react" import Stripe from "stripe" export default async function PurchasePage({ params, }: { params: Promise<{ purchaseId: string }> }) { const { purchaseId } = await params return (
}>
) } async function SuspenseBoundary({ purchaseId }: { purchaseId: string }) { const { userId, redirectToSignIn, user } = await getCurrentUser({ allData: true, }) if (userId == null || user == null) return redirectToSignIn() const purchase = await getPurchase({ userId, id: purchaseId }) if (purchase == null) return notFound() const { receiptUrl, pricingRows } = await getStripeDetails( purchase.stripeSessionId, purchase.pricePaidInCents, purchase.refundedAt != null ) return ( <> {receiptUrl && ( )}
Receipt ID: {purchaseId}
{purchase.refundedAt ? "Refunded" : "Paid"}
{formatDate(purchase.createdAt)}
{purchase.productDetails.name}
{user.name}
Web Dev Simplified
{pricingRows.map(({ label, amountInDollars, isBold }) => (
{label}
{formatPrice(amountInDollars, { showZeroAsNumber: true })}
))}
) } async function getPurchase({ userId, id }: { userId: string; id: string }) { "use cache" cacheTag(getPurchaseIdTag(id)) return db.query.PurchaseTable.findFirst({ columns: { pricePaidInCents: true, refundedAt: true, productDetails: true, createdAt: true, stripeSessionId: true, }, where: and(eq(PurchaseTable.id, id), eq(PurchaseTable.userId, userId)), }) } async function getStripeDetails( stripeSessionId: string, pricePaidInCents: number, isRefunded: boolean ) { const { payment_intent, total_details, amount_total, amount_subtotal } = await stripeServerClient.checkout.sessions.retrieve(stripeSessionId, { expand: [ "payment_intent.latest_charge", "total_details.breakdown.discounts", ], }) const refundAmount = typeof payment_intent !== "string" && typeof payment_intent?.latest_charge !== "string" ? payment_intent?.latest_charge?.amount_refunded : isRefunded ? pricePaidInCents : undefined return { receiptUrl: getReceiptUrl(payment_intent), pricingRows: getPricingRows(total_details, { total: (amount_total ?? pricePaidInCents) - (refundAmount ?? 0), subtotal: amount_subtotal ?? pricePaidInCents, refund: refundAmount, }), } } function getReceiptUrl(paymentIntent: Stripe.PaymentIntent | string | null) { if ( typeof paymentIntent === "string" || typeof paymentIntent?.latest_charge === "string" ) { return } return paymentIntent?.latest_charge?.receipt_url } function getPricingRows( totalDetails: Stripe.Checkout.Session.TotalDetails | null, { total, subtotal, refund, }: { total: number; subtotal: number; refund?: number } ) { const pricingRows: { label: string amountInDollars: number isBold?: boolean }[] = [] if (totalDetails?.breakdown != null) { totalDetails.breakdown.discounts.forEach(discount => { pricingRows.push({ label: `${discount.discount.coupon.name} (${discount.discount.coupon.percent_off}% off)`, amountInDollars: discount.amount / -100, }) }) } if (refund) { pricingRows.push({ label: "Refund", amountInDollars: refund / -100, }) } if (pricingRows.length === 0) { return [{ label: "Total", amountInDollars: total / 100, isBold: true }] } return [ { label: "Subtotal", amountInDollars: subtotal / 100, }, ...pricingRows, { label: "Total", amountInDollars: total / 100, isBold: true, }, ] } ================================================ FILE: src/app/(consumer)/purchases/page.tsx ================================================ import { PageHeader } from "@/components/PageHeader" import { Button } from "@/components/ui/button" import { db } from "@/drizzle/db" import { PurchaseTable } from "@/drizzle/schema" import { UserPurchaseTable, UserPurchaseTableSkeleton, } from "@/features/purchases/components/UserPurchaseTable" import { getPurchaseUserTag } from "@/features/purchases/db/cache" import { getCurrentUser } from "@/services/clerk" import { desc, eq } from "drizzle-orm" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import Link from "next/link" import { Suspense } from "react" export default function PurchasesPage() { return (
}>
) } async function SuspenseBoundary() { const { userId, redirectToSignIn } = await getCurrentUser() if (userId == null) return redirectToSignIn() const purchases = await getPurchases(userId) if (purchases.length === 0) { return (
You have made no purchases yet
) } return } async function getPurchases(userId: string) { "use cache" cacheTag(getPurchaseUserTag(userId)) return db.query.PurchaseTable.findMany({ columns: { id: true, pricePaidInCents: true, refundedAt: true, productDetails: true, createdAt: true, }, where: eq(PurchaseTable.userId, userId), orderBy: desc(PurchaseTable.createdAt), }) } ================================================ FILE: src/app/admin/courses/[courseId]/edit/page.tsx ================================================ import { PageHeader } from "@/components/PageHeader" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { DialogTrigger } from "@/components/ui/dialog" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { db } from "@/drizzle/db" import { CourseSectionTable, CourseTable, LessonTable } from "@/drizzle/schema" import { CourseForm } from "@/features/courses/components/CourseForm" import { getCourseIdTag } from "@/features/courses/db/cache/courses" import { SectionFormDialog } from "@/features/courseSections/components/SectionFormDialog" import { SortableSectionList } from "@/features/courseSections/components/SortableSectionList" import { getCourseSectionCourseTag } from "@/features/courseSections/db/cache" import { LessonFormDialog } from "@/features/lessons/components/LessonFormDialog" import { SortableLessonList } from "@/features/lessons/components/SortableLessonList" import { getLessonCourseTag } from "@/features/lessons/db/cache/lessons" import { cn } from "@/lib/utils" import { asc, eq } from "drizzle-orm" import { EyeClosed, PlusIcon } from "lucide-react" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import { notFound } from "next/navigation" export default async function EditCoursePage({ params, }: { params: Promise<{ courseId: string }> }) { const { courseId } = await params const course = await getCourse(courseId) if (course == null) return notFound() return (
Lessons Details Sections
{course.courseSections.map(section => ( {section.status === "private" && } {section.name} ))}
) } async function getCourse(id: string) { "use cache" cacheTag( getCourseIdTag(id), getCourseSectionCourseTag(id), getLessonCourseTag(id) ) return db.query.CourseTable.findFirst({ columns: { id: true, name: true, description: true }, where: eq(CourseTable.id, id), with: { courseSections: { orderBy: asc(CourseSectionTable.order), columns: { id: true, status: true, name: true }, with: { lessons: { orderBy: asc(LessonTable.order), columns: { id: true, name: true, status: true, description: true, youtubeVideoId: true, sectionId: true, }, }, }, }, }, }) } ================================================ FILE: src/app/admin/courses/new/page.tsx ================================================ import { PageHeader } from "@/components/PageHeader" import { CourseForm } from "@/features/courses/components/CourseForm" export default function NewCoursePage() { return (
) } ================================================ FILE: src/app/admin/courses/page.tsx ================================================ import { Button } from "@/components/ui/button" import { PageHeader } from "@/components/PageHeader" import Link from "next/link" import { CourseTable } from "@/features/courses/components/CourseTable" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import { getCourseGlobalTag } from "@/features/courses/db/cache/courses" import { db } from "@/drizzle/db" import { CourseSectionTable, CourseTable as DbCourseTable, LessonTable, UserCourseAccessTable, } from "@/drizzle/schema" import { asc, countDistinct, eq } from "drizzle-orm" import { getUserCourseAccessGlobalTag } from "@/features/courses/db/cache/userCourseAccess" import { getCourseSectionGlobalTag } from "@/features/courseSections/db/cache" import { getLessonGlobalTag } from "@/features/lessons/db/cache/lessons" export default async function CoursesPage() { const courses = await getCourses() return (
) } async function getCourses() { "use cache" cacheTag( getCourseGlobalTag(), getUserCourseAccessGlobalTag(), getCourseSectionGlobalTag(), getLessonGlobalTag() ) return db .select({ id: DbCourseTable.id, name: DbCourseTable.name, sectionsCount: countDistinct(CourseSectionTable), lessonsCount: countDistinct(LessonTable), studentsCount: countDistinct(UserCourseAccessTable), }) .from(DbCourseTable) .leftJoin( CourseSectionTable, eq(CourseSectionTable.courseId, DbCourseTable.id) ) .leftJoin(LessonTable, eq(LessonTable.sectionId, CourseSectionTable.id)) .leftJoin( UserCourseAccessTable, eq(UserCourseAccessTable.courseId, DbCourseTable.id) ) .orderBy(asc(DbCourseTable.name)) .groupBy(DbCourseTable.id) } ================================================ FILE: src/app/admin/layout.tsx ================================================ import { Badge } from "@/components/ui/badge" import { UserButton } from "@clerk/nextjs" import Link from "next/link" import { ReactNode } from "react" export default function AdminLayout({ children, }: Readonly<{ children: ReactNode }>) { return ( <> {children} ) } function Navbar() { return (
) } ================================================ FILE: src/app/admin/page.tsx ================================================ import { Card, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card" import { db } from "@/drizzle/db" import { CourseSectionTable, CourseTable, LessonTable, ProductTable, PurchaseTable, UserCourseAccessTable, } from "@/drizzle/schema" import { getCourseGlobalTag } from "@/features/courses/db/cache/courses" import { getUserCourseAccessGlobalTag } from "@/features/courses/db/cache/userCourseAccess" import { getCourseSectionGlobalTag } from "@/features/courseSections/db/cache" import { getLessonGlobalTag } from "@/features/lessons/db/cache/lessons" import { getProductGlobalTag } from "@/features/products/db/cache" import { getPurchaseGlobalTag } from "@/features/purchases/db/cache" import { formatNumber, formatPrice } from "@/lib/formatters" import { count, countDistinct, isNotNull, sql, sum } from "drizzle-orm" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import { ReactNode } from "react" export default async function AdminPage() { const { averageNetPurchasesPerCustomer, netPurchases, netSales, refundedPurchases, totalRefunds, } = await getPurchaseDetails() return (
{formatPrice(netSales)} {formatPrice(totalRefunds)} {formatNumber(netPurchases)} {formatNumber(refundedPurchases)} {formatNumber(averageNetPurchasesPerCustomer, { maximumFractionDigits: 2, })} {formatNumber(await getTotalStudents())} {formatNumber(await getTotalProducts())} {formatNumber(await getTotalCourses())} {formatNumber(await getTotalCourseSections())} {formatNumber(await getTotalLessons())}
) } function StatCard({ title, children }: { title: string; children: ReactNode }) { return ( {title} {children} ) } async function getPurchaseDetails() { "use cache" cacheTag(getPurchaseGlobalTag()) const data = await db .select({ totalSales: sql`COALESCE(${sum( PurchaseTable.pricePaidInCents )}, 0)`.mapWith(Number), totalPurchases: count(PurchaseTable.id), totalUsers: countDistinct(PurchaseTable.userId), isRefund: isNotNull(PurchaseTable.refundedAt), }) .from(PurchaseTable) .groupBy(table => table.isRefund) const [refundData] = data.filter(row => row.isRefund) const [salesData] = data.filter(row => !row.isRefund) const netSales = (salesData?.totalSales ?? 0) / 100 const totalRefunds = (refundData?.totalSales ?? 0) / 100 const netPurchases = salesData?.totalPurchases ?? 0 const refundedPurchases = refundData?.totalPurchases ?? 0 const averageNetPurchasesPerCustomer = salesData?.totalUsers != null && salesData.totalUsers > 0 ? netPurchases / salesData.totalUsers : 0 return { netSales, totalRefunds, netPurchases, refundedPurchases, averageNetPurchasesPerCustomer, } } async function getTotalStudents() { "use cache" cacheTag(getUserCourseAccessGlobalTag()) const [data] = await db .select({ totalStudents: countDistinct(UserCourseAccessTable.userId) }) .from(UserCourseAccessTable) if (data == null) return 0 return data.totalStudents } async function getTotalCourses() { "use cache" cacheTag(getCourseGlobalTag()) const [data] = await db .select({ totalCourses: count(CourseTable.id) }) .from(CourseTable) if (data == null) return 0 return data.totalCourses } async function getTotalProducts() { "use cache" cacheTag(getProductGlobalTag()) const [data] = await db .select({ totalProducts: count(ProductTable.id) }) .from(ProductTable) if (data == null) return 0 return data.totalProducts } async function getTotalLessons() { "use cache" cacheTag(getLessonGlobalTag()) const [data] = await db .select({ totalLessons: count(LessonTable.id) }) .from(LessonTable) if (data == null) return 0 return data.totalLessons } async function getTotalCourseSections() { "use cache" cacheTag(getCourseSectionGlobalTag()) const [data] = await db .select({ totalCourseSections: count(CourseSectionTable.id) }) .from(CourseSectionTable) if (data == null) return 0 return data.totalCourseSections } ================================================ FILE: src/app/admin/products/[productId]/edit/page.tsx ================================================ import { PageHeader } from "@/components/PageHeader" import { db } from "@/drizzle/db" import { CourseTable, ProductTable } from "@/drizzle/schema" import { getCourseGlobalTag } from "@/features/courses/db/cache/courses" import { ProductForm } from "@/features/products/components/ProductForm" import { getProductIdTag } from "@/features/products/db/cache" import { asc, eq } from "drizzle-orm" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import { notFound } from "next/navigation" export default async function EditProductPage({ params, }: { params: Promise<{ productId: string }> }) { const { productId } = await params const product = await getProduct(productId) if (product == null) return notFound() return (
c.courseId), }} courses={await getCourses()} />
) } async function getCourses() { "use cache" cacheTag(getCourseGlobalTag()) return db.query.CourseTable.findMany({ orderBy: asc(CourseTable.name), columns: { id: true, name: true }, }) } async function getProduct(id: string) { "use cache" cacheTag(getProductIdTag(id)) return db.query.ProductTable.findFirst({ columns: { id: true, name: true, description: true, priceInDollars: true, status: true, imageUrl: true, }, where: eq(ProductTable.id, id), with: { courseProducts: { columns: { courseId: true } } }, }) } ================================================ FILE: src/app/admin/products/new/page.tsx ================================================ import { PageHeader } from "@/components/PageHeader" import { db } from "@/drizzle/db" import { CourseTable } from "@/drizzle/schema" import { getCourseGlobalTag } from "@/features/courses/db/cache/courses" import { ProductForm } from "@/features/products/components/ProductForm" import { asc } from "drizzle-orm" import { cacheTag } from "next/dist/server/use-cache/cache-tag" export default async function NewProductPage() { return (
) } async function getCourses() { "use cache" cacheTag(getCourseGlobalTag()) return db.query.CourseTable.findMany({ orderBy: asc(CourseTable.name), columns: { id: true, name: true }, }) } ================================================ FILE: src/app/admin/products/page.tsx ================================================ import { Button } from "@/components/ui/button" import { PageHeader } from "@/components/PageHeader" import Link from "next/link" import { cacheTag } from "next/dist/server/use-cache/cache-tag" import { db } from "@/drizzle/db" import { CourseProductTable, ProductTable as DbProductTable, PurchaseTable, } from "@/drizzle/schema" import { asc, countDistinct, eq } from "drizzle-orm" import { getProductGlobalTag } from "@/features/products/db/cache" import { ProductTable } from "@/features/products/components/ProductTable" export default async function ProductsPage() { const products = await getProducts() return (
) } async function getProducts() { "use cache" cacheTag(getProductGlobalTag()) return db .select({ id: DbProductTable.id, name: DbProductTable.name, status: DbProductTable.status, priceInDollars: DbProductTable.priceInDollars, description: DbProductTable.description, imageUrl: DbProductTable.imageUrl, coursesCount: countDistinct(CourseProductTable.courseId), customersCount: countDistinct(PurchaseTable.userId), }) .from(DbProductTable) .leftJoin(PurchaseTable, eq(PurchaseTable.productId, DbProductTable.id)) .leftJoin( CourseProductTable, eq(CourseProductTable.productId, DbProductTable.id) ) .orderBy(asc(DbProductTable.name)) .groupBy(DbProductTable.id) } ================================================ FILE: src/app/admin/sales/page.tsx ================================================ import { PageHeader } from "@/components/PageHeader" import { db } from "@/drizzle/db" import { PurchaseTable as DbPurchaseTable } from "@/drizzle/schema" import { PurchaseTable } from "@/features/purchases/components/PurchaseTable" import { getPurchaseGlobalTag } from "@/features/purchases/db/cache" import { getUserGlobalTag } from "@/features/users/db/cache" import { desc } from "drizzle-orm" import { cacheTag } from "next/dist/server/use-cache/cache-tag" export default async function PurchasesPage() { const purchases = await getPurchases() return (
) } async function getPurchases() { "use cache" cacheTag(getPurchaseGlobalTag(), getUserGlobalTag()) return db.query.PurchaseTable.findMany({ columns: { id: true, pricePaidInCents: true, refundedAt: true, productDetails: true, createdAt: true, }, orderBy: desc(DbPurchaseTable.createdAt), with: { user: { columns: { name: true } } }, }) } ================================================ FILE: src/app/api/clerk/syncUsers/route.ts ================================================ import { insertUser } from "@/features/users/db/users" import { syncClerkUserMetadata } from "@/services/clerk" import { currentUser } from "@clerk/nextjs/server" import { NextResponse } from "next/server" export async function GET(request: Request) { const user = await currentUser() if (user == null) return new Response("User not found", { status: 500 }) if (user.fullName == null) { return new Response("User name missing", { status: 500 }) } if (user.primaryEmailAddress?.emailAddress == null) { return new Response("User email missing", { status: 500 }) } const dbUser = await insertUser({ clerkUserId: user.id, name: user.fullName, email: user.primaryEmailAddress.emailAddress, imageUrl: user.imageUrl, role: user.publicMetadata.role ?? "user", }) await syncClerkUserMetadata(dbUser) await new Promise(res => setTimeout(res, 100)) return NextResponse.redirect(request.headers.get("referer") ?? "/") } ================================================ FILE: src/app/api/webhooks/clerk/route.ts ================================================ import { env } from "@/data/env/server" import { deleteUser, insertUser, updateUser } from "@/features/users/db/users" import { syncClerkUserMetadata } from "@/services/clerk" import { WebhookEvent } from "@clerk/nextjs/server" import { headers } from "next/headers" import { Webhook } from "svix" export async function POST(req: Request) { const headerPayload = await headers() const svixId = headerPayload.get("svix-id") const svixTimestamp = headerPayload.get("svix-timestamp") const svixSignature = headerPayload.get("svix-signature") if (!svixId || !svixTimestamp || !svixSignature) { return new Response("Error occurred -- no svix headers", { status: 400, }) } const payload = await req.json() const body = JSON.stringify(payload) const wh = new Webhook(env.CLERK_WEBHOOK_SECRET) let event: WebhookEvent try { event = wh.verify(body, { "svix-id": svixId, "svix-timestamp": svixTimestamp, "svix-signature": svixSignature, }) as WebhookEvent } catch (err) { console.error("Error verifying webhook:", err) return new Response("Error occurred", { status: 400, }) } switch (event.type) { case "user.created": case "user.updated": { const email = event.data.email_addresses.find( email => email.id === event.data.primary_email_address_id )?.email_address const name = `${event.data.first_name} ${event.data.last_name}`.trim() if (email == null) return new Response("No email", { status: 400 }) if (name === "") return new Response("No name", { status: 400 }) if (event.type === "user.created") { const user = await insertUser({ clerkUserId: event.data.id, email, name, imageUrl: event.data.image_url, role: "user", }) await syncClerkUserMetadata(user) } else { await updateUser( { clerkUserId: event.data.id }, { email, name, imageUrl: event.data.image_url, role: event.data.public_metadata.role, } ) } break } case "user.deleted": { if (event.data.id != null) { await deleteUser({ clerkUserId: event.data.id }) } break } } return new Response("", { status: 200 }) } ================================================ FILE: src/app/api/webhooks/stripe/route.ts ================================================ import { env } from "@/data/env/server" import { db } from "@/drizzle/db" import { ProductTable, UserTable } from "@/drizzle/schema" import { addUserCourseAccess } from "@/features/courses/db/userCourseAcccess" import { insertPurchase } from "@/features/purchases/db/purchases" import { stripeServerClient } from "@/services/stripe/stripeServer" import { eq } from "drizzle-orm" import { redirect } from "next/navigation" import { NextRequest, NextResponse } from "next/server" import Stripe from "stripe" export async function GET(request: NextRequest) { const stripeSessionId = request.nextUrl.searchParams.get("stripeSessionId") if (stripeSessionId == null) redirect("/products/purchase-failure") let redirectUrl: string try { const checkoutSession = await stripeServerClient.checkout.sessions.retrieve( stripeSessionId, { expand: ["line_items"] } ) const productId = await processStripeCheckout(checkoutSession) redirectUrl = `/products/${productId}/purchase/success` } catch { redirectUrl = "/products/purchase-failure" } return NextResponse.redirect(new URL(redirectUrl, request.url)) } export async function POST(request: NextRequest) { const event = await stripeServerClient.webhooks.constructEvent( await request.text(), request.headers.get("stripe-signature") as string, env.STRIPE_WEBHOOK_SECRET ) switch (event.type) { case "checkout.session.completed": case "checkout.session.async_payment_succeeded": { try { await processStripeCheckout(event.data.object) } catch { return new Response(null, { status: 500 }) } } } return new Response(null, { status: 200 }) } async function processStripeCheckout(checkoutSession: Stripe.Checkout.Session) { const userId = checkoutSession.metadata?.userId const productId = checkoutSession.metadata?.productId if (userId == null || productId == null) { throw new Error("Missing metadata") } const [product, user] = await Promise.all([ getProduct(productId), await getUser(userId), ]) if (product == null) throw new Error("Product not found") if (user == null) throw new Error("User not found") const courseIds = product.courseProducts.map(cp => cp.courseId) db.transaction(async trx => { try { await addUserCourseAccess({ userId: user.id, courseIds }, trx) await insertPurchase( { stripeSessionId: checkoutSession.id, pricePaidInCents: checkoutSession.amount_total || product.priceInDollars * 100, productDetails: product, userId: user.id, productId, }, trx ) } catch (error) { trx.rollback() throw error } }) return productId } function getProduct(id: string) { return db.query.ProductTable.findFirst({ columns: { id: true, priceInDollars: true, name: true, description: true, imageUrl: true, }, where: eq(ProductTable.id, id), with: { courseProducts: { columns: { courseId: true } }, }, }) } function getUser(id: string) { return db.query.UserTable.findFirst({ columns: { id: true }, where: eq(UserTable.id, id), }) } ================================================ FILE: src/app/globals.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; body { font-family: Arial, Helvetica, sans-serif; } @layer base { :root { --background: 0 0% 100%; --foreground: 240 10% 3.9%; --card: 0 0% 100%; --card-foreground: 240 10% 3.9%; --popover: 0 0% 100%; --popover-foreground: 240 10% 3.9%; --primary: 240 5.9% 10%; --primary-foreground: 0 0% 98%; --secondary: 240 4.8% 95.9%; --secondary-foreground: 240 5.9% 10%; --muted: 240 4.8% 95.9%; --muted-foreground: 240 3.8% 46.1%; --accent: 280 75% 50%; --accent-foreground: 0 0% 98%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 240 10% 3.9%; --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --radius: 0.5rem; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } ================================================ FILE: src/app/layout.tsx ================================================ import type { Metadata } from "next" import "./globals.css" import { ClerkProvider } from "@clerk/nextjs" import { Toaster } from "@/components/ui/toaster" export const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app", } export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( {children} ) } ================================================ FILE: src/components/ActionButton.tsx ================================================ "use client" import { ComponentPropsWithRef, ReactNode, useTransition } from "react" import { Button } from "./ui/button" import { actionToast } from "@/hooks/use-toast" import { Loader2Icon } from "lucide-react" import { cn } from "@/lib/utils" import { AlertDialog, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, AlertDialogContent, AlertDialogTrigger, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from "./ui/alert-dialog" export function ActionButton({ action, requireAreYouSure = false, ...props }: Omit, "onClick"> & { action: () => Promise<{ error: boolean; message: string }> requireAreYouSure?: boolean }) { { const [isLoading, startTransition] = useTransition() function performAction() { startTransition(async () => { const data = await action() actionToast({ actionData: data }) }) } if (requireAreYouSure) { return ( ) } } function LoadingTextSwap({ isLoading, children, }: { isLoading: boolean children: ReactNode }) { return (
{children}
) } ================================================ FILE: src/components/LoadingSpinner.tsx ================================================ import { cn } from "@/lib/utils" import { Loader2Icon } from "lucide-react" import { ComponentProps } from "react" export function LoadingSpinner({ className, ...props }: ComponentProps) { return ( ) } ================================================ FILE: src/components/PageHeader.tsx ================================================ import { cn } from "@/lib/utils" import { ReactNode } from "react" export function PageHeader({ title, children, className, }: { title: string children?: ReactNode className?: string }) { return (

{title}

{children &&
{children}
}
) } ================================================ FILE: src/components/RequiredLabelIcon.tsx ================================================ import { cn } from "@/lib/utils" import { AsteriskIcon } from "lucide-react" import { ComponentPropsWithoutRef } from "react" export function RequiredLabelIcon({ className, ...props }: ComponentPropsWithoutRef) { return ( ) } ================================================ FILE: src/components/Skeleton.tsx ================================================ import { cn } from "@/lib/utils" import { buttonVariants } from "./ui/button" import { ReactNode } from "react" export function SkeletonButton({ className }: { className?: string }) { return (
) } export function SkeletonArray({ amount, children, }: { amount: number children: ReactNode }) { return Array.from({ length: amount }).map(() => children) } export function SkeletonText({ rows = 1, size = "md", className, }: { rows?: number size?: "md" | "lg" className?: string }) { return (
1 && "last:w-3/4", size === "md" && "h-3", size === "lg" && "h-5", className )} />
) } ================================================ FILE: src/components/SortableList.tsx ================================================ "use client" import { ReactNode, useId, useOptimistic, useTransition } from "react" import { DndContext, DragEndEvent } from "@dnd-kit/core" import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" import { cn } from "@/lib/utils" import { GripVerticalIcon } from "lucide-react" import { actionToast } from "@/hooks/use-toast" export function SortableList({ items, onOrderChange, children, }: { items: T[] onOrderChange: ( newOrder: string[] ) => Promise<{ error: boolean; message: string }> children: (items: T[]) => ReactNode }) { const dndContextId = useId() const [optimisticItems, setOptimisticItems] = useOptimistic(items) const [, startTransition] = useTransition() function handleDragEnd(event: DragEndEvent) { const { active, over } = event const activeId = active.id.toString() const overId = over?.id.toString() if (overId == null || activeId == null) return function getNewArray(array: T[], activeId: string, overId: string) { const oldIndex = array.findIndex(section => section.id === activeId) const newIndex = array.findIndex(section => section.id === overId) return arrayMove(array, oldIndex, newIndex) } startTransition(async () => { setOptimisticItems(items => getNewArray(items, activeId, overId)) const actionData = await onOrderChange( getNewArray(optimisticItems, activeId, overId).map(s => s.id) ) actionToast({ actionData }) }) } return (
{children(optimisticItems)}
) } export function SortableItem({ id, children, className, }: { id: string children: ReactNode className?: string }) { const { setNodeRef, transform, transition, activeIndex, index, attributes, listeners, } = useSortable({ id }) const isActive = activeIndex === index return (
{children}
) } ================================================ FILE: src/components/ui/accordion.tsx ================================================ "use client" import * as React from "react" import * as AccordionPrimitive from "@radix-ui/react-accordion" import { ChevronDown } from "lucide-react" import { cn } from "@/lib/utils" const Accordion = AccordionPrimitive.Root const AccordionItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) AccordionItem.displayName = "AccordionItem" const AccordionTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( svg]:rotate-180", className )} {...props} > {children} )) AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName const AccordionContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => (
{children}
)) AccordionContent.displayName = AccordionPrimitive.Content.displayName export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } ================================================ FILE: src/components/ui/alert-dialog.tsx ================================================ "use client" import * as React from "react" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" const AlertDialog = AlertDialogPrimitive.Root const AlertDialogTrigger = AlertDialogPrimitive.Trigger const AlertDialogPortal = AlertDialogPrimitive.Portal const AlertDialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName const AlertDialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
) AlertDialogHeader.displayName = "AlertDialogHeader" const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
) AlertDialogFooter.displayName = "AlertDialogFooter" const AlertDialogTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName const AlertDialogDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName const AlertDialogAction = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName const AlertDialogCancel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName export { AlertDialog, AlertDialogPortal, AlertDialogOverlay, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel, } ================================================ FILE: src/components/ui/badge.tsx ================================================ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( "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", { variants: { variant: { default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", outline: "text-foreground", }, }, defaultVariants: { variant: "default", }, } ) export interface BadgeProps extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return (
) } export { Badge, badgeVariants } ================================================ FILE: src/components/ui/button.tsx ================================================ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( "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", { variants: { variant: { default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", destructiveOutline: "border border-destructive bg-background shadow-sm text-destructive hover:bg-destructive hover:text-destructive-foreground", outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-accent", secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2", sm: "h-8 rounded-md px-3 text-xs", lg: "h-10 rounded-md px-8", icon: "h-9 w-9", }, }, defaultVariants: { variant: "default", size: "default", }, } ) export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( ) } ) Button.displayName = "Button" export { Button, buttonVariants } ================================================ FILE: src/components/ui/card.tsx ================================================ import * as React from "react" import { cn } from "@/lib/utils" const Card = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) Card.displayName = "Card" const CardHeader = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) CardHeader.displayName = "CardHeader" const CardTitle = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) CardTitle.displayName = "CardTitle" const CardDescription = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) CardDescription.displayName = "CardDescription" const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) CardContent.displayName = "CardContent" const CardFooter = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) CardFooter.displayName = "CardFooter" export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } ================================================ FILE: src/components/ui/command.tsx ================================================ "use client" import * as React from "react" import { type DialogProps } from "@radix-ui/react-dialog" import { Command as CommandPrimitive } from "cmdk" import { Search } from "lucide-react" import { cn } from "@/lib/utils" import { Dialog, DialogContent } from "@/components/ui/dialog" const Command = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) Command.displayName = CommandPrimitive.displayName const CommandDialog = ({ children, ...props }: DialogProps) => { return ( {children} ) } const CommandInput = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => (
)) CommandInput.displayName = CommandPrimitive.Input.displayName const CommandList = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) CommandList.displayName = CommandPrimitive.List.displayName const CommandEmpty = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >((props, ref) => ( )) CommandEmpty.displayName = CommandPrimitive.Empty.displayName const CommandGroup = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) CommandGroup.displayName = CommandPrimitive.Group.displayName const CommandSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) CommandSeparator.displayName = CommandPrimitive.Separator.displayName const CommandItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) CommandItem.displayName = CommandPrimitive.Item.displayName const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { return ( ) } CommandShortcut.displayName = "CommandShortcut" export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator, } ================================================ FILE: src/components/ui/custom/multi-select.tsx ================================================ "use client" import * as React from "react" import { Check, ChevronsUpDown } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { Badge } from "../badge" export function MultiSelect