[
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.*\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n\n# env files (can opt-in for committing if needed)\n.env*\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 WebDevSimplified\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "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).\n\n## Getting Started\n\nFirst, run the development server:\n\n```bash\nnpm run dev\n# or\nyarn dev\n# or\npnpm dev\n# or\nbun dev\n```\n\nOpen [http://localhost:3000](http://localhost:3000) with your browser to see the result.\n\nYou can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.\n\nThis 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.\n\n## Learn More\n\nTo learn more about Next.js, take a look at the following resources:\n\n- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.\n- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.\n\nYou can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!\n\n## Deploy on Vercel\n\nThe 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.\n\nCheck out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.\n"
  },
  {
    "path": "components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": true,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.ts\",\n    \"css\": \"src/app/globals.css\",\n    \"baseColor\": \"zinc\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  db:\n    image: postgres:17.0\n    hostname: localhost\n    ports:\n      - \"5432:5432\"\n    environment:\n      - POSTGRES_PASSWORD=${DB_PASSWORD}\n      - POSTGRES_USER=${DB_USER}\n      - POSTGRES_DB=${DB_NAME}\n    volumes:\n      - pgdata:/var/lib/postgresql/data\nvolumes:\n  pgdata:\n"
  },
  {
    "path": "drizzle.config.ts",
    "content": "import { env } from \"@/data/env/server\"\nimport { defineConfig } from \"drizzle-kit\"\n\nexport default defineConfig({\n  out: \"./src/drizzle/migrations\",\n  schema: \"./src/drizzle/schema.ts\",\n  dialect: \"postgresql\",\n  strict: true,\n  verbose: true,\n  dbCredentials: {\n    password: env.DB_PASSWORD,\n    user: env.DB_USER,\n    database: env.DB_NAME,\n    host: env.DB_HOST,\n    ssl: false,\n  },\n})\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import { dirname } from \"path\";\nimport { fileURLToPath } from \"url\";\nimport { FlatCompat } from \"@eslint/eslintrc\";\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\nconst compat = new FlatCompat({\n  baseDirectory: __dirname,\n});\n\nconst eslintConfig = [\n  ...compat.extends(\"next/core-web-vitals\", \"next/typescript\"),\n];\n\nexport default eslintConfig;\n"
  },
  {
    "path": "next.config.ts",
    "content": "import type { NextConfig } from \"next\"\n\nconst nextConfig: NextConfig = {\n  /* config options here */\n  experimental: {\n    dynamicIO: true,\n    authInterrupts: true,\n  },\n}\n\nexport default nextConfig\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"course-platform-project\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev\",\n    \"build\": \"next build\",\n    \"start\": \"next start\",\n    \"lint\": \"next lint\",\n    \"db:generate\": \"drizzle-kit generate\",\n    \"db:migrate\": \"drizzle-kit migrate\",\n    \"db:studio\": \"drizzle-kit studio\"\n  },\n  \"dependencies\": {\n    \"@arcjet/next\": \"^1.0.0-beta.1\",\n    \"@clerk/nextjs\": \"^6.9.10\",\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@dnd-kit/sortable\": \"^10.0.0\",\n    \"@hookform/resolvers\": \"^3.10.0\",\n    \"@radix-ui/react-accordion\": \"^1.2.2\",\n    \"@radix-ui/react-alert-dialog\": \"^1.1.4\",\n    \"@radix-ui/react-dialog\": \"^1.1.4\",\n    \"@radix-ui/react-label\": \"^2.1.1\",\n    \"@radix-ui/react-popover\": \"^1.1.4\",\n    \"@radix-ui/react-select\": \"^2.1.4\",\n    \"@radix-ui/react-slot\": \"^1.1.1\",\n    \"@radix-ui/react-tabs\": \"^1.1.2\",\n    \"@radix-ui/react-toast\": \"^1.2.4\",\n    \"@stripe/react-stripe-js\": \"^3.1.1\",\n    \"@stripe/stripe-js\": \"^5.5.0\",\n    \"@t3-oss/env-nextjs\": \"^0.11.1\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.0.0\",\n    \"drizzle-orm\": \"^0.38.3\",\n    \"lucide-react\": \"^0.471.1\",\n    \"next\": \"^15.2.0-canary.12\",\n    \"pg\": \"^8.13.1\",\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\",\n    \"react-hook-form\": \"^7.54.2\",\n    \"react-youtube\": \"^10.1.0\",\n    \"stripe\": \"^17.5.0\",\n    \"svix\": \"^1.45.1\",\n    \"tailwind-merge\": \"^2.6.0\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"zod\": \"^3.24.1\"\n  },\n  \"devDependencies\": {\n    \"@eslint/eslintrc\": \"^3\",\n    \"@tailwindcss/container-queries\": \"^0.1.1\",\n    \"@types/node\": \"^20\",\n    \"@types/react\": \"^19\",\n    \"@types/react-dom\": \"^19\",\n    \"drizzle-kit\": \"^0.30.1\",\n    \"eslint\": \"^9\",\n    \"eslint-config-next\": \"15.2.0-canary.11\",\n    \"postcss\": \"^8\",\n    \"tailwindcss\": \"^3.4.1\",\n    \"typescript\": \"^5\"\n  }\n}\n"
  },
  {
    "path": "postcss.config.mjs",
    "content": "/** @type {import('postcss-load-config').Config} */\nconst config = {\n  plugins: {\n    tailwindcss: {},\n  },\n};\n\nexport default config;\n"
  },
  {
    "path": "src/app/(auth)/layout.tsx",
    "content": "export default async function AuthLayout({\n  children,\n}: {\n  children: React.ReactNode\n}) {\n  return (\n    <div className=\"min-h-screen flex flex-col justify-center items-center\">\n      {children}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/(auth)/sign-in/[[...sign-in]]/page.tsx",
    "content": "import { SignIn } from \"@clerk/nextjs\"\n\nexport default function Page() {\n  return <SignIn />\n}\n"
  },
  {
    "path": "src/app/(auth)/sign-up/[[...sign-up]]/page.tsx",
    "content": "import { SignUp } from \"@clerk/nextjs\"\n\nexport default function Page() {\n  return <SignUp />\n}\n"
  },
  {
    "path": "src/app/(consumer)/courses/[courseId]/_client.tsx",
    "content": "\"use client\"\n\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@/components/ui/accordion\"\nimport { Button } from \"@/components/ui/button\"\nimport { cn } from \"@/lib/utils\"\nimport { CheckCircle2Icon, VideoIcon } from \"lucide-react\"\nimport Link from \"next/link\"\nimport { useParams } from \"next/navigation\"\n\nexport function CoursePageClient({\n  course,\n}: {\n  course: {\n    id: string\n    courseSections: {\n      id: string\n      name: string\n      lessons: {\n        id: string\n        name: string\n        isComplete: boolean\n      }[]\n    }[]\n  }\n}) {\n  const { lessonId } = useParams()\n  const defaultValue =\n    typeof lessonId === \"string\"\n      ? course.courseSections.find(section =>\n          section.lessons.find(lesson => lesson.id === lessonId)\n        )\n      : course.courseSections[0]\n\n  return (\n    <Accordion\n      type=\"multiple\"\n      defaultValue={defaultValue ? [defaultValue.id] : undefined}\n    >\n      {course.courseSections.map(section => (\n        <AccordionItem key={section.id} value={section.id}>\n          <AccordionTrigger className=\"text-lg\">\n            {section.name}\n          </AccordionTrigger>\n          <AccordionContent className=\"flex flex-col gap-1\">\n            {section.lessons.map(lesson => (\n              <Button\n                variant=\"ghost\"\n                asChild\n                key={lesson.id}\n                className={cn(\n                  \"justify-start\",\n                  lesson.id === lessonId &&\n                    \"bg-accent/75 text-accent-foreground\"\n                )}\n              >\n                <Link href={`/courses/${course.id}/lessons/${lesson.id}`}>\n                  <VideoIcon />\n                  {lesson.name}\n                  {lesson.isComplete && (\n                    <CheckCircle2Icon className=\"ml-auto\" />\n                  )}\n                </Link>\n              </Button>\n            ))}\n          </AccordionContent>\n        </AccordionItem>\n      ))}\n    </Accordion>\n  )\n}\n"
  },
  {
    "path": "src/app/(consumer)/courses/[courseId]/layout.tsx",
    "content": "import { db } from \"@/drizzle/db\"\nimport {\n  CourseSectionTable,\n  CourseTable,\n  LessonTable,\n  UserLessonCompleteTable,\n} from \"@/drizzle/schema\"\nimport { getCourseIdTag } from \"@/features/courses/db/cache/courses\"\nimport { getCourseSectionCourseTag } from \"@/features/courseSections/db/cache\"\nimport { wherePublicCourseSections } from \"@/features/courseSections/permissions/sections\"\nimport { getLessonCourseTag } from \"@/features/lessons/db/cache/lessons\"\nimport { wherePublicLessons } from \"@/features/lessons/permissions/lessons\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { asc, eq } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport { notFound } from \"next/navigation\"\nimport { ReactNode, Suspense } from \"react\"\nimport { CoursePageClient } from \"./_client\"\nimport { getUserLessonCompleteUserTag } from \"@/features/lessons/db/cache/userLessonComplete\"\n\nexport default async function CoursePageLayout({\n  params,\n  children,\n}: {\n  params: Promise<{ courseId: string }>\n  children: ReactNode\n}) {\n  const { courseId } = await params\n  const course = await getCourse(courseId)\n\n  if (course == null) return notFound()\n\n  return (\n    <div className=\"grid grid-cols-[300px,1fr] gap-8 container\">\n      <div className=\"py-4\">\n        <div className=\"text-lg font-semibold\">{course.name}</div>\n        <Suspense\n          fallback={<CoursePageClient course={mapCourse(course, [])} />}\n        >\n          <SuspenseBoundary course={course} />\n        </Suspense>\n      </div>\n      <div className=\"py-4\">{children}</div>\n    </div>\n  )\n}\n\nasync function getCourse(id: string) {\n  \"use cache\"\n  cacheTag(\n    getCourseIdTag(id),\n    getCourseSectionCourseTag(id),\n    getLessonCourseTag(id)\n  )\n\n  return db.query.CourseTable.findFirst({\n    where: eq(CourseTable.id, id),\n    columns: { id: true, name: true },\n    with: {\n      courseSections: {\n        orderBy: asc(CourseSectionTable.order),\n        where: wherePublicCourseSections,\n        columns: { id: true, name: true },\n        with: {\n          lessons: {\n            orderBy: asc(LessonTable.order),\n            where: wherePublicLessons,\n            columns: {\n              id: true,\n              name: true,\n            },\n          },\n        },\n      },\n    },\n  })\n}\n\nasync function SuspenseBoundary({\n  course,\n}: {\n  course: {\n    name: string\n    id: string\n    courseSections: {\n      name: string\n      id: string\n      lessons: {\n        name: string\n        id: string\n      }[]\n    }[]\n  }\n}) {\n  const { userId } = await getCurrentUser()\n  const completedLessonIds =\n    userId == null ? [] : await getCompletedLessonIds(userId)\n\n  return <CoursePageClient course={mapCourse(course, completedLessonIds)} />\n}\n\nasync function getCompletedLessonIds(userId: string) {\n  \"use cache\"\n  cacheTag(getUserLessonCompleteUserTag(userId))\n\n  const data = await db.query.UserLessonCompleteTable.findMany({\n    columns: { lessonId: true },\n    where: eq(UserLessonCompleteTable.userId, userId),\n  })\n\n  return data.map(d => d.lessonId)\n}\n\nfunction mapCourse(\n  course: {\n    name: string\n    id: string\n    courseSections: {\n      name: string\n      id: string\n      lessons: {\n        name: string\n        id: string\n      }[]\n    }[]\n  },\n  completedLessonIds: string[]\n) {\n  return {\n    ...course,\n    courseSections: course.courseSections.map(section => {\n      return {\n        ...section,\n        lessons: section.lessons.map(lesson => {\n          return {\n            ...lesson,\n            isComplete: completedLessonIds.includes(lesson.id),\n          }\n        }),\n      }\n    }),\n  }\n}\n"
  },
  {
    "path": "src/app/(consumer)/courses/[courseId]/lessons/[lessonId]/page.tsx",
    "content": "import { ActionButton } from \"@/components/ActionButton\"\nimport { SkeletonButton } from \"@/components/Skeleton\"\nimport { Button } from \"@/components/ui/button\"\nimport { db } from \"@/drizzle/db\"\nimport {\n  CourseSectionTable,\n  LessonStatus,\n  LessonTable,\n  UserLessonCompleteTable,\n} from \"@/drizzle/schema\"\nimport { wherePublicCourseSections } from \"@/features/courseSections/permissions/sections\"\nimport { updateLessonCompleteStatus } from \"@/features/lessons/actions/userLessonComplete\"\nimport { YouTubeVideoPlayer } from \"@/features/lessons/components/YouTubeVideoPlayer\"\nimport { getLessonIdTag } from \"@/features/lessons/db/cache/lessons\"\nimport { getUserLessonCompleteIdTag } from \"@/features/lessons/db/cache/userLessonComplete\"\nimport {\n  canViewLesson,\n  wherePublicLessons,\n} from \"@/features/lessons/permissions/lessons\"\nimport { canUpdateUserLessonCompleteStatus } from \"@/features/lessons/permissions/userLessonComplete\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { and, asc, desc, eq, gt, lt } from \"drizzle-orm\"\nimport { CheckSquare2Icon, LockIcon, XSquareIcon } from \"lucide-react\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport Link from \"next/link\"\nimport { notFound } from \"next/navigation\"\nimport { ReactNode, Suspense } from \"react\"\n\nexport default async function LessonPage({\n  params,\n}: {\n  params: Promise<{ courseId: string; lessonId: string }>\n}) {\n  const { courseId, lessonId } = await params\n  const lesson = await getLesson(lessonId)\n\n  if (lesson == null) return notFound()\n\n  return (\n    <Suspense fallback={<LoadingSkeleton />}>\n      <SuspenseBoundary lesson={lesson} courseId={courseId} />\n    </Suspense>\n  )\n}\n\nfunction LoadingSkeleton() {\n  return null\n}\n\nasync function SuspenseBoundary({\n  lesson,\n  courseId,\n}: {\n  lesson: {\n    id: string\n    youtubeVideoId: string\n    name: string\n    description: string | null\n    status: LessonStatus\n    sectionId: string\n    order: number\n  }\n  courseId: string\n}) {\n  const { userId, role } = await getCurrentUser()\n  const isLessonComplete =\n    userId == null\n      ? false\n      : await getIsLessonComplete({ lessonId: lesson.id, userId })\n  const canView = await canViewLesson({ role, userId }, lesson)\n  const canUpdateCompletionStatus = await canUpdateUserLessonCompleteStatus(\n    { userId },\n    lesson.id\n  )\n\n  return (\n    <div className=\"my-4 flex flex-col gap-4\">\n      <div className=\"aspect-video\">\n        {canView ? (\n          <YouTubeVideoPlayer\n            videoId={lesson.youtubeVideoId}\n            onFinishedVideo={\n              !isLessonComplete && canUpdateCompletionStatus\n                ? updateLessonCompleteStatus.bind(null, lesson.id, true)\n                : undefined\n            }\n          />\n        ) : (\n          <div className=\"flex items-center justify-center bg-primary text-primary-foreground h-full w-full\">\n            <LockIcon className=\"size-16\" />\n          </div>\n        )}\n      </div>\n      <div className=\"flex flex-col gap-2\">\n        <div className=\"flex justify-between items-start gap-4\">\n          <h1 className=\"text-2xl font-semibold\">{lesson.name}</h1>\n          <div className=\"flex gap-2 justify-end\">\n            <Suspense fallback={<SkeletonButton />}>\n              <ToLessonButton\n                lesson={lesson}\n                courseId={courseId}\n                lessonFunc={getPreviousLesson}\n              >\n                Previous\n              </ToLessonButton>\n            </Suspense>\n            {canUpdateCompletionStatus && (\n              <ActionButton\n                action={updateLessonCompleteStatus.bind(\n                  null,\n                  lesson.id,\n                  !isLessonComplete\n                )}\n                variant=\"outline\"\n              >\n                <div className=\"flex gap-2 items-center\">\n                  {isLessonComplete ? (\n                    <>\n                      <CheckSquare2Icon /> Mark Incomplete\n                    </>\n                  ) : (\n                    <>\n                      <XSquareIcon /> Mark Complete\n                    </>\n                  )}\n                </div>\n              </ActionButton>\n            )}\n            <Suspense fallback={<SkeletonButton />}>\n              <ToLessonButton\n                lesson={lesson}\n                courseId={courseId}\n                lessonFunc={getNextLesson}\n              >\n                Next\n              </ToLessonButton>\n            </Suspense>\n          </div>\n        </div>\n        {canView ? (\n          lesson.description && <p>{lesson.description}</p>\n        ) : (\n          <p>This lesson is locked. Please purchase the course to view it.</p>\n        )}\n      </div>\n    </div>\n  )\n}\n\nasync function ToLessonButton({\n  children,\n  courseId,\n  lessonFunc,\n  lesson,\n}: {\n  children: ReactNode\n  courseId: string\n  lesson: {\n    id: string\n    sectionId: string\n    order: number\n  }\n  lessonFunc: (lesson: {\n    id: string\n    sectionId: string\n    order: number\n  }) => Promise<{ id: string } | undefined>\n}) {\n  const toLesson = await lessonFunc(lesson)\n  if (toLesson == null) return null\n\n  return (\n    <Button variant=\"outline\" asChild>\n      <Link href={`/courses/${courseId}/lessons/${toLesson.id}`}>\n        {children}\n      </Link>\n    </Button>\n  )\n}\n\nasync function getPreviousLesson(lesson: {\n  id: string\n  sectionId: string\n  order: number\n}) {\n  let previousLesson = await db.query.LessonTable.findFirst({\n    where: and(\n      lt(LessonTable.order, lesson.order),\n      eq(LessonTable.sectionId, lesson.sectionId),\n      wherePublicLessons\n    ),\n    orderBy: desc(LessonTable.order),\n    columns: { id: true },\n  })\n\n  if (previousLesson == null) {\n    const section = await db.query.CourseSectionTable.findFirst({\n      where: eq(CourseSectionTable.id, lesson.sectionId),\n      columns: { order: true, courseId: true },\n    })\n\n    if (section == null) return\n\n    const previousSection = await db.query.CourseSectionTable.findFirst({\n      where: and(\n        lt(CourseSectionTable.order, section.order),\n        eq(CourseSectionTable.courseId, section.courseId),\n        wherePublicCourseSections\n      ),\n      orderBy: desc(CourseSectionTable.order),\n      columns: { id: true },\n    })\n\n    if (previousSection == null) return\n\n    previousLesson = await db.query.LessonTable.findFirst({\n      where: and(\n        eq(LessonTable.sectionId, previousSection.id),\n        wherePublicLessons\n      ),\n      orderBy: desc(LessonTable.order),\n      columns: { id: true },\n    })\n  }\n\n  return previousLesson\n}\n\nasync function getNextLesson(lesson: {\n  id: string\n  sectionId: string\n  order: number\n}) {\n  let nextLesson = await db.query.LessonTable.findFirst({\n    where: and(\n      gt(LessonTable.order, lesson.order),\n      eq(LessonTable.sectionId, lesson.sectionId),\n      wherePublicLessons\n    ),\n    orderBy: asc(LessonTable.order),\n    columns: { id: true },\n  })\n\n  if (nextLesson == null) {\n    const section = await db.query.CourseSectionTable.findFirst({\n      where: eq(CourseSectionTable.id, lesson.sectionId),\n      columns: { order: true, courseId: true },\n    })\n\n    if (section == null) return\n\n    const nextSection = await db.query.CourseSectionTable.findFirst({\n      where: and(\n        gt(CourseSectionTable.order, section.order),\n        eq(CourseSectionTable.courseId, section.courseId),\n        wherePublicCourseSections\n      ),\n      orderBy: asc(CourseSectionTable.order),\n      columns: { id: true },\n    })\n\n    if (nextSection == null) return\n\n    nextLesson = await db.query.LessonTable.findFirst({\n      where: and(eq(LessonTable.sectionId, nextSection.id), wherePublicLessons),\n      orderBy: asc(LessonTable.order),\n      columns: { id: true },\n    })\n  }\n\n  return nextLesson\n}\n\nasync function getLesson(id: string) {\n  \"use cache\"\n  cacheTag(getLessonIdTag(id))\n\n  return db.query.LessonTable.findFirst({\n    columns: {\n      id: true,\n      youtubeVideoId: true,\n      name: true,\n      description: true,\n      status: true,\n      sectionId: true,\n      order: true,\n    },\n    where: and(eq(LessonTable.id, id), wherePublicLessons),\n  })\n}\n\nasync function getIsLessonComplete({\n  userId,\n  lessonId,\n}: {\n  userId: string\n  lessonId: string\n}) {\n  \"use cache\"\n  cacheTag(getUserLessonCompleteIdTag({ userId, lessonId }))\n\n  const data = await db.query.UserLessonCompleteTable.findFirst({\n    where: and(\n      eq(UserLessonCompleteTable.userId, userId),\n      eq(UserLessonCompleteTable.lessonId, lessonId)\n    ),\n  })\n\n  return data != null\n}\n"
  },
  {
    "path": "src/app/(consumer)/courses/[courseId]/page.tsx",
    "content": "import { PageHeader } from \"@/components/PageHeader\"\nimport { db } from \"@/drizzle/db\"\nimport { CourseTable } from \"@/drizzle/schema\"\nimport { getCourseIdTag } from \"@/features/courses/db/cache/courses\"\nimport { eq } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport { notFound } from \"next/navigation\"\n\nexport default async function CoursePage({\n  params,\n}: {\n  params: Promise<{ courseId: string }>\n}) {\n  const { courseId } = await params\n  const course = await getCourse(courseId)\n\n  if (course == null) return notFound()\n\n  return (\n    <div className=\"my-6 container\">\n      <PageHeader className=\"mb-2\" title={course.name} />\n      <p className=\"text-muted-foreground\">{course.description}</p>\n    </div>\n  )\n}\n\nasync function getCourse(id: string) {\n  \"use cache\"\n  cacheTag(getCourseIdTag(id))\n\n  return db.query.CourseTable.findFirst({\n    columns: { id: true, name: true, description: true },\n    where: eq(CourseTable.id, id),\n  })\n}\n"
  },
  {
    "path": "src/app/(consumer)/courses/page.tsx",
    "content": "import { PageHeader } from \"@/components/PageHeader\"\nimport {\n  SkeletonArray,\n  SkeletonButton,\n  SkeletonText,\n} from \"@/components/Skeleton\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport { db } from \"@/drizzle/db\"\nimport {\n  CourseSectionTable,\n  CourseTable,\n  LessonTable,\n  UserCourseAccessTable,\n  UserLessonCompleteTable,\n} from \"@/drizzle/schema\"\nimport { getCourseIdTag } from \"@/features/courses/db/cache/courses\"\nimport { getUserCourseAccessUserTag } from \"@/features/courses/db/cache/userCourseAccess\"\nimport { getCourseSectionCourseTag } from \"@/features/courseSections/db/cache\"\nimport { wherePublicCourseSections } from \"@/features/courseSections/permissions/sections\"\nimport { getLessonCourseTag } from \"@/features/lessons/db/cache/lessons\"\nimport { getUserLessonCompleteUserTag } from \"@/features/lessons/db/cache/userLessonComplete\"\nimport { wherePublicLessons } from \"@/features/lessons/permissions/lessons\"\nimport { formatPlural } from \"@/lib/formatters\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { and, countDistinct, eq } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport Link from \"next/link\"\nimport { Suspense } from \"react\"\n\nexport default function CoursesPage() {\n  return (\n    <div className=\"container my-6\">\n      <PageHeader title=\"My Courses\" />\n      <div className=\"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4\">\n        <Suspense\n          fallback={\n            <SkeletonArray amount={3}>\n              <SkeletonCourseCard />\n            </SkeletonArray>\n          }\n        >\n          <CourseGrid />\n        </Suspense>\n      </div>\n    </div>\n  )\n}\n\nasync function CourseGrid() {\n  const { userId, redirectToSignIn } = await getCurrentUser()\n  if (userId == null) return redirectToSignIn()\n\n  const courses = await getUserCourses(userId)\n\n  if (courses.length === 0) {\n    return (\n      <div className=\"flex flex-col gap-2 items-start\">\n        You have no courses yet\n        <Button asChild size=\"lg\">\n          <Link href=\"/\">Browse Courses</Link>\n        </Button>\n      </div>\n    )\n  }\n\n  return courses.map(course => (\n    <Card key={course.id} className=\"overflow-hidden flex flex-col\">\n      <CardHeader>\n        <CardTitle>{course.name}</CardTitle>\n        <CardDescription>\n          {formatPlural(course.sectionsCount, {\n            plural: \"sections\",\n            singular: \"section\",\n          })}{\" \"}\n          •{\" \"}\n          {formatPlural(course.lessonsCount, {\n            plural: \"lessons\",\n            singular: \"lesson\",\n          })}\n        </CardDescription>\n      </CardHeader>\n      <CardContent className=\"line-clamp-3\" title={course.description}>\n        {course.description}\n      </CardContent>\n      <div className=\"flex-grow\" />\n      <CardFooter>\n        <Button asChild>\n          <Link href={`/courses/${course.id}`}>View Course</Link>\n        </Button>\n      </CardFooter>\n      <div\n        className=\"bg-accent h-2 -mt-2\"\n        style={{\n          width: `${(course.lessonsComplete / course.lessonsCount) * 100}%`,\n        }}\n      />\n    </Card>\n  ))\n}\n\nfunction SkeletonCourseCard() {\n  return (\n    <Card>\n      <CardHeader>\n        <CardTitle>\n          <SkeletonText className=\"w-3/4\" />\n        </CardTitle>\n        <CardDescription>\n          <SkeletonText className=\"w-1/2\" />\n        </CardDescription>\n      </CardHeader>\n      <CardContent>\n        <SkeletonText rows={3} />\n      </CardContent>\n      <CardFooter>\n        <SkeletonButton />\n      </CardFooter>\n    </Card>\n  )\n}\n\nasync function getUserCourses(userId: string) {\n  \"use cache\"\n  cacheTag(\n    getUserCourseAccessUserTag(userId),\n    getUserLessonCompleteUserTag(userId)\n  )\n\n  const courses = await db\n    .select({\n      id: CourseTable.id,\n      name: CourseTable.name,\n      description: CourseTable.description,\n      sectionsCount: countDistinct(CourseSectionTable.id),\n      lessonsCount: countDistinct(LessonTable.id),\n      lessonsComplete: countDistinct(UserLessonCompleteTable.lessonId),\n    })\n    .from(CourseTable)\n    .leftJoin(\n      UserCourseAccessTable,\n      and(\n        eq(UserCourseAccessTable.courseId, CourseTable.id),\n        eq(UserCourseAccessTable.userId, userId)\n      )\n    )\n    .leftJoin(\n      CourseSectionTable,\n      and(\n        eq(CourseSectionTable.courseId, CourseTable.id),\n        wherePublicCourseSections\n      )\n    )\n    .leftJoin(\n      LessonTable,\n      and(eq(LessonTable.sectionId, CourseSectionTable.id), wherePublicLessons)\n    )\n    .leftJoin(\n      UserLessonCompleteTable,\n      and(\n        eq(UserLessonCompleteTable.lessonId, LessonTable.id),\n        eq(UserLessonCompleteTable.userId, userId)\n      )\n    )\n    .orderBy(CourseTable.name)\n    .groupBy(CourseTable.id)\n\n  courses.forEach(course => {\n    cacheTag(\n      getCourseIdTag(course.id),\n      getCourseSectionCourseTag(course.id),\n      getLessonCourseTag(course.id)\n    )\n  })\n\n  return courses\n}\n"
  },
  {
    "path": "src/app/(consumer)/layout.tsx",
    "content": "import { Button } from \"@/components/ui/button\"\nimport { canAccessAdminPages } from \"@/permissions/general\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { SignedIn, SignedOut, SignInButton, UserButton } from \"@clerk/nextjs\"\nimport Link from \"next/link\"\nimport { ReactNode, Suspense } from \"react\"\n\nexport default function ConsumerLayout({\n  children,\n}: Readonly<{ children: ReactNode }>) {\n  return (\n    <>\n      <Navbar />\n      {children}\n    </>\n  )\n}\n\nfunction Navbar() {\n  return (\n    <header className=\"flex h-12 shadow bg-background z-10\">\n      <nav className=\"flex gap-4 container\">\n        <Link\n          className=\"mr-auto text-lg hover:underline flex items-center\"\n          href=\"/\"\n        >\n          Web Dev Simplified\n        </Link>\n        <Suspense>\n          <SignedIn>\n            <AdminLink />\n            <Link\n              className=\"hover:bg-accent/10 flex items-center px-2\"\n              href=\"/courses\"\n            >\n              My Courses\n            </Link>\n            <Link\n              className=\"hover:bg-accent/10 flex items-center px-2\"\n              href=\"/purchases\"\n            >\n              Purchase History\n            </Link>\n            <div className=\"size-8 self-center\">\n              <UserButton\n                appearance={{\n                  elements: {\n                    userButtonAvatarBox: { width: \"100%\", height: \"100%\" },\n                  },\n                }}\n              />\n            </div>\n          </SignedIn>\n        </Suspense>\n        <Suspense>\n          <SignedOut>\n            <Button className=\"self-center\" asChild>\n              <SignInButton>Sign In</SignInButton>\n            </Button>\n          </SignedOut>\n        </Suspense>\n      </nav>\n    </header>\n  )\n}\n\nasync function AdminLink() {\n  const user = await getCurrentUser()\n  if (!canAccessAdminPages(user)) return null\n\n  return (\n    <Link className=\"hover:bg-accent/10 flex items-center px-2\" href=\"/admin\">\n      Admin\n    </Link>\n  )\n}\n"
  },
  {
    "path": "src/app/(consumer)/page.tsx",
    "content": "import { db } from \"@/drizzle/db\"\nimport { ProductTable } from \"@/drizzle/schema\"\nimport { ProductCard } from \"@/features/products/components/ProductCard\"\nimport { getProductGlobalTag } from \"@/features/products/db/cache\"\nimport { wherePublicProducts } from \"@/features/products/permissions/products\"\nimport { asc } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\n\nexport default async function HomePage() {\n  const products = await getPublicProducts()\n\n  return (\n    <div className=\"container my-6\">\n      <div className=\"grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-4\">\n        {products.map(product => (\n          <ProductCard key={product.id} {...product} />\n        ))}\n      </div>\n    </div>\n  )\n}\n\nasync function getPublicProducts() {\n  \"use cache\"\n  cacheTag(getProductGlobalTag())\n\n  return db.query.ProductTable.findMany({\n    columns: {\n      id: true,\n      name: true,\n      description: true,\n      priceInDollars: true,\n      imageUrl: true,\n    },\n    where: wherePublicProducts,\n    orderBy: asc(ProductTable.name),\n  })\n}\n"
  },
  {
    "path": "src/app/(consumer)/products/[productId]/page.tsx",
    "content": "import { SkeletonButton } from \"@/components/Skeleton\"\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@/components/ui/accordion\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport { db } from \"@/drizzle/db\"\nimport { CourseSectionTable, LessonTable, ProductTable } from \"@/drizzle/schema\"\nimport { getCourseIdTag } from \"@/features/courses/db/cache/courses\"\nimport { getCourseSectionCourseTag } from \"@/features/courseSections/db/cache\"\nimport { wherePublicCourseSections } from \"@/features/courseSections/permissions/sections\"\nimport { getLessonCourseTag } from \"@/features/lessons/db/cache/lessons\"\nimport { wherePublicLessons } from \"@/features/lessons/permissions/lessons\"\nimport { getProductIdTag } from \"@/features/products/db/cache\"\nimport { userOwnsProduct } from \"@/features/products/db/products\"\nimport { wherePublicProducts } from \"@/features/products/permissions/products\"\nimport { formatPlural, formatPrice } from \"@/lib/formatters\"\nimport { sumArray } from \"@/lib/sumArray\"\nimport { getUserCoupon } from \"@/lib/userCountryHeader\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { and, asc, eq } from \"drizzle-orm\"\nimport { VideoIcon } from \"lucide-react\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport Image from \"next/image\"\nimport Link from \"next/link\"\nimport { notFound } from \"next/navigation\"\nimport { Suspense } from \"react\"\n\nexport default async function ProductPage({\n  params,\n}: {\n  params: Promise<{ productId: string }>\n}) {\n  const { productId } = await params\n  const product = await getPublicProduct(productId)\n\n  if (product == null) return notFound()\n\n  const courseCount = product.courses.length\n  const lessonCount = sumArray(product.courses, course =>\n    sumArray(course.courseSections, s => s.lessons.length)\n  )\n\n  return (\n    <div className=\"container my-6\">\n      <div className=\"flex gap-16 items-center justify-between\">\n        <div className=\"flex gap-6 flex-col items-start\">\n          <div className=\"flex flex-col gap-2\">\n            <Suspense\n              fallback={\n                <div className=\"text-xl\">\n                  {formatPrice(product.priceInDollars)}\n                </div>\n              }\n            >\n              <Price price={product.priceInDollars} />\n            </Suspense>\n            <h1 className=\"text-4xl font-semibold\">{product.name}</h1>\n            <div className=\"text-muted-foreground\">\n              {formatPlural(courseCount, {\n                singular: \"course\",\n                plural: \"courses\",\n              })}{\" \"}\n              •{\" \"}\n              {formatPlural(lessonCount, {\n                singular: \"lesson\",\n                plural: \"lessons\",\n              })}\n            </div>\n          </div>\n          <div className=\"text-xl\">{product.description}</div>\n          <Suspense fallback={<SkeletonButton className=\"h-12 w-36\" />}>\n            <PurchaseButton productId={product.id} />\n          </Suspense>\n        </div>\n        <div className=\"relative aspect-video max-w-lg flex-grow\">\n          <Image\n            src={product.imageUrl}\n            fill\n            alt={product.name}\n            className=\"object-contain rounded-xl\"\n          />\n        </div>\n      </div>\n      <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-8 mt-8 items-start\">\n        {product.courses.map(course => (\n          <Card key={course.id}>\n            <CardHeader>\n              <CardTitle>{course.name}</CardTitle>\n              <CardDescription>\n                {formatPlural(course.courseSections.length, {\n                  plural: \"sections\",\n                  singular: \"section\",\n                })}{\" \"}\n                •{\" \"}\n                {formatPlural(\n                  sumArray(course.courseSections, s => s.lessons.length),\n                  {\n                    plural: \"lessons\",\n                    singular: \"lesson\",\n                  }\n                )}\n              </CardDescription>\n            </CardHeader>\n            <CardContent>\n              <Accordion type=\"multiple\">\n                {course.courseSections.map(section => (\n                  <AccordionItem key={section.id} value={section.id}>\n                    <AccordionTrigger className=\"flex gap-2\">\n                      <div className=\"flex flex-col flex-grow\">\n                        <span className=\"text-lg\">{section.name}</span>\n                        <span className=\"text-muted-foreground\">\n                          {formatPlural(section.lessons.length, {\n                            plural: \"lessons\",\n                            singular: \"lesson\",\n                          })}\n                        </span>\n                      </div>\n                    </AccordionTrigger>\n                    <AccordionContent className=\"flex flex-col gap-2\">\n                      {section.lessons.map(lesson => (\n                        <div\n                          key={lesson.id}\n                          className=\"flex items-center gap-2 text-base\"\n                        >\n                          <VideoIcon className=\"size-4\" />\n                          {lesson.status === \"preview\" ? (\n                            <Link\n                              href={`/courses/${course.id}/lessons/${lesson.id}`}\n                              className=\"underline text-accent\"\n                            >\n                              {lesson.name}\n                            </Link>\n                          ) : (\n                            lesson.name\n                          )}\n                        </div>\n                      ))}\n                    </AccordionContent>\n                  </AccordionItem>\n                ))}\n              </Accordion>\n            </CardContent>\n          </Card>\n        ))}\n      </div>\n    </div>\n  )\n}\n\nasync function PurchaseButton({ productId }: { productId: string }) {\n  const { userId } = await getCurrentUser()\n  const alreadyOwnsProduct =\n    userId != null && (await userOwnsProduct({ userId, productId }))\n\n  if (alreadyOwnsProduct) {\n    return <p>You already own this product</p>\n  } else {\n    return (\n      <Button className=\"text-xl h-auto py-4 px-8 rounded-lg\" asChild>\n        <Link href={`/products/${productId}/purchase`}>Get Now</Link>\n      </Button>\n    )\n  }\n}\n\nasync function Price({ price }: { price: number }) {\n  const coupon = await getUserCoupon()\n  if (price === 0 || coupon == null) {\n    return <div className=\"text-xl\">{formatPrice(price)}</div>\n  }\n\n  return (\n    <div className=\"flex gap-2 items-baseline\">\n      <div className=\"line-through text-sm opacity-50\">\n        {formatPrice(price)}\n      </div>\n      <div className=\"text-xl\">\n        {formatPrice(price * (1 - coupon.discountPercentage))}\n      </div>\n    </div>\n  )\n}\n\nasync function getPublicProduct(id: string) {\n  \"use cache\"\n  cacheTag(getProductIdTag(id))\n\n  const product = await db.query.ProductTable.findFirst({\n    columns: {\n      id: true,\n      name: true,\n      description: true,\n      priceInDollars: true,\n      imageUrl: true,\n    },\n    where: and(eq(ProductTable.id, id), wherePublicProducts),\n    with: {\n      courseProducts: {\n        columns: {},\n        with: {\n          course: {\n            columns: { id: true, name: true },\n            with: {\n              courseSections: {\n                columns: { id: true, name: true },\n                where: wherePublicCourseSections,\n                orderBy: asc(CourseSectionTable.order),\n                with: {\n                  lessons: {\n                    columns: { id: true, name: true, status: true },\n                    where: wherePublicLessons,\n                    orderBy: asc(LessonTable.order),\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n    },\n  })\n\n  if (product == null) return product\n\n  cacheTag(\n    ...product.courseProducts.flatMap(cp => [\n      getLessonCourseTag(cp.course.id),\n      getCourseSectionCourseTag(cp.course.id),\n      getCourseIdTag(cp.course.id),\n    ])\n  )\n\n  const { courseProducts, ...other } = product\n\n  return {\n    ...other,\n    courses: courseProducts.map(cp => cp.course),\n  }\n}\n"
  },
  {
    "path": "src/app/(consumer)/products/[productId]/purchase/page.tsx",
    "content": "import { LoadingSpinner } from \"@/components/LoadingSpinner\"\nimport { PageHeader } from \"@/components/PageHeader\"\nimport { db } from \"@/drizzle/db\"\nimport { ProductTable } from \"@/drizzle/schema\"\nimport { getProductIdTag } from \"@/features/products/db/cache\"\nimport { userOwnsProduct } from \"@/features/products/db/products\"\nimport { wherePublicProducts } from \"@/features/products/permissions/products\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { StripeCheckoutForm } from \"@/services/stripe/components/StripeCheckoutForm\"\nimport { SignIn, SignUp } from \"@clerk/nextjs\"\nimport { and, eq } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport { notFound, redirect } from \"next/navigation\"\nimport { Suspense } from \"react\"\n\nexport default function PurchasePage({\n  params,\n  searchParams,\n}: {\n  params: Promise<{ productId: string }>\n  searchParams: Promise<{ authMode: string }>\n}) {\n  return (\n    <Suspense fallback={<LoadingSpinner className=\"my-6 size-36 mx-auto\" />}>\n      <SuspendedComponent params={params} searchParams={searchParams} />\n    </Suspense>\n  )\n}\n\nasync function SuspendedComponent({\n  params,\n  searchParams,\n}: {\n  params: Promise<{ productId: string }>\n  searchParams: Promise<{ authMode: string }>\n}) {\n  const { productId } = await params\n  const { user } = await getCurrentUser({ allData: true })\n  const product = await getPublicProduct(productId)\n\n  if (product == null) return notFound()\n\n  if (user != null) {\n    if (await userOwnsProduct({ userId: user.id, productId })) {\n      redirect(\"/courses\")\n    }\n\n    return (\n      <div className=\"container my-6\">\n        <StripeCheckoutForm product={product} user={user} />\n      </div>\n    )\n  }\n\n  const { authMode } = await searchParams\n  const isSignUp = authMode === \"signUp\"\n\n  return (\n    <div className=\"container my-6 flex flex-col items-center\">\n      <PageHeader title=\"You need an account to make a purchase\" />\n      {isSignUp ? (\n        <SignUp\n          routing=\"hash\"\n          signInUrl={`/products/${productId}/purchase?authMode=signIn`}\n          forceRedirectUrl={`/products/${productId}/purchase`}\n        />\n      ) : (\n        <SignIn\n          routing=\"hash\"\n          signUpUrl={`/products/${productId}/purchase?authMode=signUp`}\n          forceRedirectUrl={`/products/${productId}/purchase`}\n        />\n      )}\n    </div>\n  )\n}\n\nasync function getPublicProduct(id: string) {\n  \"use cache\"\n  cacheTag(getProductIdTag(id))\n\n  return db.query.ProductTable.findFirst({\n    columns: {\n      name: true,\n      id: true,\n      imageUrl: true,\n      description: true,\n      priceInDollars: true,\n    },\n    where: and(eq(ProductTable.id, id), wherePublicProducts),\n  })\n}\n"
  },
  {
    "path": "src/app/(consumer)/products/[productId]/purchase/success/page.tsx",
    "content": "import { Button } from \"@/components/ui/button\"\nimport { db } from \"@/drizzle/db\"\nimport { ProductTable } from \"@/drizzle/schema\"\nimport { getProductIdTag } from \"@/features/products/db/cache\"\nimport { wherePublicProducts } from \"@/features/products/permissions/products\"\nimport { and, eq } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport Image from \"next/image\"\nimport Link from \"next/link\"\n\nexport default async function ProductPurchaseSuccessPage({\n  params,\n}: {\n  params: Promise<{ productId: string }>\n}) {\n  const { productId } = await params\n  const product = await getPublicProduct(productId)\n\n  if (product == null) return\n\n  return (\n    <div className=\"container my-6\">\n      <div className=\"flex gap-16 items-center justify-between\">\n        <div className=\"flex flex-col gap-4 items-start\">\n          <div className=\"text-3xl font-semibold\">Purchase Successful</div>\n          <div className=\"text-xl\">\n            Thank you for purchasing {product.name}.\n          </div>\n          <Button asChild className=\"text-xl h-auto py-4 px-8 rounded-lg\">\n            <Link href=\"/courses\">View My Courses</Link>\n          </Button>\n        </div>\n        <div className=\"relative aspect-video max-w-lg flex-grow\">\n          <Image\n            src={product.imageUrl}\n            alt={product.name}\n            fill\n            className=\"object-contain rounded-xl\"\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n\nasync function getPublicProduct(id: string) {\n  \"use cache\"\n  cacheTag(getProductIdTag(id))\n\n  return db.query.ProductTable.findFirst({\n    columns: {\n      name: true,\n      imageUrl: true,\n    },\n    where: and(eq(ProductTable.id, id), wherePublicProducts),\n  })\n}\n"
  },
  {
    "path": "src/app/(consumer)/products/purchase-failure/page.tsx",
    "content": "import { Button } from \"@/components/ui/button\"\nimport Link from \"next/link\"\n\nexport default async function ProductPurchaseFailurePage() {\n  return (\n    <div className=\"container my-6\">\n      <div className=\"flex flex-col gap-4 items-start\">\n        <div className=\"text-3xl font-semibold\">Purchase Failed</div>\n        <div className=\"text-xl\">\n          There was a problem purchasing your product.\n        </div>\n        <Button asChild className=\"text-xl h-auto py-4 px-8 rounded-lg\">\n          <Link href=\"/\">Try again</Link>\n        </Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/(consumer)/purchases/[purchaseId]/page.tsx",
    "content": "import { LoadingSpinner } from \"@/components/LoadingSpinner\"\nimport { PageHeader } from \"@/components/PageHeader\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport { db } from \"@/drizzle/db\"\nimport { PurchaseTable } from \"@/drizzle/schema\"\nimport { getPurchaseIdTag } from \"@/features/purchases/db/cache\"\nimport { formatDate, formatPrice } from \"@/lib/formatters\"\nimport { cn } from \"@/lib/utils\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { stripeServerClient } from \"@/services/stripe/stripeServer\"\nimport { and, eq } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport Link from \"next/link\"\nimport { notFound } from \"next/navigation\"\nimport { Fragment, Suspense } from \"react\"\nimport Stripe from \"stripe\"\n\nexport default async function PurchasePage({\n  params,\n}: {\n  params: Promise<{ purchaseId: string }>\n}) {\n  const { purchaseId } = await params\n\n  return (\n    <div className=\"container my-6\">\n      <Suspense fallback={<LoadingSpinner className=\"size-36 mx-auto\" />}>\n        <SuspenseBoundary purchaseId={purchaseId} />\n      </Suspense>\n    </div>\n  )\n}\n\nasync function SuspenseBoundary({ purchaseId }: { purchaseId: string }) {\n  const { userId, redirectToSignIn, user } = await getCurrentUser({\n    allData: true,\n  })\n  if (userId == null || user == null) return redirectToSignIn()\n\n  const purchase = await getPurchase({ userId, id: purchaseId })\n\n  if (purchase == null) return notFound()\n\n  const { receiptUrl, pricingRows } = await getStripeDetails(\n    purchase.stripeSessionId,\n    purchase.pricePaidInCents,\n    purchase.refundedAt != null\n  )\n\n  return (\n    <>\n      <PageHeader title={purchase.productDetails.name}>\n        {receiptUrl && (\n          <Button variant=\"outline\" asChild>\n            <Link target=\"_blank\" href={receiptUrl}>\n              View Receipt\n            </Link>\n          </Button>\n        )}\n      </PageHeader>\n\n      <Card>\n        <CardHeader className=\"pb-4\">\n          <div className=\"flex justify-between items-start gap-4\">\n            <div className=\"flex flex-col gap-1\">\n              <CardTitle>Receipt</CardTitle>\n              <CardDescription>ID: {purchaseId}</CardDescription>\n            </div>\n            <Badge className=\"text-base\">\n              {purchase.refundedAt ? \"Refunded\" : \"Paid\"}\n            </Badge>\n          </div>\n        </CardHeader>\n        <CardContent className=\"pb-4 grid grid-cols-2 gap-8 border-t pt-4\">\n          <div>\n            <label className=\"text-sm text-muted-foreground\">Date</label>\n            <div>{formatDate(purchase.createdAt)}</div>\n          </div>\n          <div>\n            <label className=\"text-sm text-muted-foreground\">Product</label>\n            <div>{purchase.productDetails.name}</div>\n          </div>\n          <div>\n            <label className=\"text-sm text-muted-foreground\">Customer</label>\n            <div>{user.name}</div>\n          </div>\n          <div>\n            <label className=\"text-sm text-muted-foreground\">Seller</label>\n            <div>Web Dev Simplified</div>\n          </div>\n        </CardContent>\n        <CardFooter className=\"grid grid-cols-2 gap-y-4 gap-x-8 border-t pt-4\">\n          {pricingRows.map(({ label, amountInDollars, isBold }) => (\n            <Fragment key={label}>\n              <div className={cn(isBold && \"font-bold\")}>{label}</div>\n              <div className={cn(\"justify-self-end\", isBold && \"font-bold\")}>\n                {formatPrice(amountInDollars, { showZeroAsNumber: true })}\n              </div>\n            </Fragment>\n          ))}\n        </CardFooter>\n      </Card>\n    </>\n  )\n}\n\nasync function getPurchase({ userId, id }: { userId: string; id: string }) {\n  \"use cache\"\n  cacheTag(getPurchaseIdTag(id))\n\n  return db.query.PurchaseTable.findFirst({\n    columns: {\n      pricePaidInCents: true,\n      refundedAt: true,\n      productDetails: true,\n      createdAt: true,\n      stripeSessionId: true,\n    },\n    where: and(eq(PurchaseTable.id, id), eq(PurchaseTable.userId, userId)),\n  })\n}\n\nasync function getStripeDetails(\n  stripeSessionId: string,\n  pricePaidInCents: number,\n  isRefunded: boolean\n) {\n  const { payment_intent, total_details, amount_total, amount_subtotal } =\n    await stripeServerClient.checkout.sessions.retrieve(stripeSessionId, {\n      expand: [\n        \"payment_intent.latest_charge\",\n        \"total_details.breakdown.discounts\",\n      ],\n    })\n\n  const refundAmount =\n    typeof payment_intent !== \"string\" &&\n    typeof payment_intent?.latest_charge !== \"string\"\n      ? payment_intent?.latest_charge?.amount_refunded\n      : isRefunded\n      ? pricePaidInCents\n      : undefined\n\n  return {\n    receiptUrl: getReceiptUrl(payment_intent),\n    pricingRows: getPricingRows(total_details, {\n      total: (amount_total ?? pricePaidInCents) - (refundAmount ?? 0),\n      subtotal: amount_subtotal ?? pricePaidInCents,\n      refund: refundAmount,\n    }),\n  }\n}\n\nfunction getReceiptUrl(paymentIntent: Stripe.PaymentIntent | string | null) {\n  if (\n    typeof paymentIntent === \"string\" ||\n    typeof paymentIntent?.latest_charge === \"string\"\n  ) {\n    return\n  }\n\n  return paymentIntent?.latest_charge?.receipt_url\n}\n\nfunction getPricingRows(\n  totalDetails: Stripe.Checkout.Session.TotalDetails | null,\n  {\n    total,\n    subtotal,\n    refund,\n  }: { total: number; subtotal: number; refund?: number }\n) {\n  const pricingRows: {\n    label: string\n    amountInDollars: number\n    isBold?: boolean\n  }[] = []\n\n  if (totalDetails?.breakdown != null) {\n    totalDetails.breakdown.discounts.forEach(discount => {\n      pricingRows.push({\n        label: `${discount.discount.coupon.name} (${discount.discount.coupon.percent_off}% off)`,\n        amountInDollars: discount.amount / -100,\n      })\n    })\n  }\n\n  if (refund) {\n    pricingRows.push({\n      label: \"Refund\",\n      amountInDollars: refund / -100,\n    })\n  }\n\n  if (pricingRows.length === 0) {\n    return [{ label: \"Total\", amountInDollars: total / 100, isBold: true }]\n  }\n\n  return [\n    {\n      label: \"Subtotal\",\n      amountInDollars: subtotal / 100,\n    },\n    ...pricingRows,\n    {\n      label: \"Total\",\n      amountInDollars: total / 100,\n      isBold: true,\n    },\n  ]\n}\n"
  },
  {
    "path": "src/app/(consumer)/purchases/page.tsx",
    "content": "import { PageHeader } from \"@/components/PageHeader\"\nimport { Button } from \"@/components/ui/button\"\nimport { db } from \"@/drizzle/db\"\nimport { PurchaseTable } from \"@/drizzle/schema\"\nimport {\n  UserPurchaseTable,\n  UserPurchaseTableSkeleton,\n} from \"@/features/purchases/components/UserPurchaseTable\"\nimport { getPurchaseUserTag } from \"@/features/purchases/db/cache\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { desc, eq } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport Link from \"next/link\"\nimport { Suspense } from \"react\"\n\nexport default function PurchasesPage() {\n  return (\n    <div className=\"container my-6\">\n      <PageHeader title=\"Purchase History\" />\n      <Suspense fallback={<UserPurchaseTableSkeleton />}>\n        <SuspenseBoundary />\n      </Suspense>\n    </div>\n  )\n}\n\nasync function SuspenseBoundary() {\n  const { userId, redirectToSignIn } = await getCurrentUser()\n  if (userId == null) return redirectToSignIn()\n\n  const purchases = await getPurchases(userId)\n\n  if (purchases.length === 0) {\n    return (\n      <div className=\"flex flex-col gap-2 items-start\">\n        You have made no purchases yet\n        <Button asChild size=\"lg\">\n          <Link href=\"/\">Browse Courses</Link>\n        </Button>\n      </div>\n    )\n  }\n\n  return <UserPurchaseTable purchases={purchases} />\n}\n\nasync function getPurchases(userId: string) {\n  \"use cache\"\n  cacheTag(getPurchaseUserTag(userId))\n\n  return db.query.PurchaseTable.findMany({\n    columns: {\n      id: true,\n      pricePaidInCents: true,\n      refundedAt: true,\n      productDetails: true,\n      createdAt: true,\n    },\n    where: eq(PurchaseTable.userId, userId),\n    orderBy: desc(PurchaseTable.createdAt),\n  })\n}\n"
  },
  {
    "path": "src/app/admin/courses/[courseId]/edit/page.tsx",
    "content": "import { PageHeader } from \"@/components/PageHeader\"\nimport { Button } from \"@/components/ui/button\"\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\"\nimport { DialogTrigger } from \"@/components/ui/dialog\"\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\"\nimport { db } from \"@/drizzle/db\"\nimport { CourseSectionTable, CourseTable, LessonTable } from \"@/drizzle/schema\"\nimport { CourseForm } from \"@/features/courses/components/CourseForm\"\nimport { getCourseIdTag } from \"@/features/courses/db/cache/courses\"\nimport { SectionFormDialog } from \"@/features/courseSections/components/SectionFormDialog\"\nimport { SortableSectionList } from \"@/features/courseSections/components/SortableSectionList\"\nimport { getCourseSectionCourseTag } from \"@/features/courseSections/db/cache\"\nimport { LessonFormDialog } from \"@/features/lessons/components/LessonFormDialog\"\nimport { SortableLessonList } from \"@/features/lessons/components/SortableLessonList\"\nimport { getLessonCourseTag } from \"@/features/lessons/db/cache/lessons\"\nimport { cn } from \"@/lib/utils\"\nimport { asc, eq } from \"drizzle-orm\"\nimport { EyeClosed, PlusIcon } from \"lucide-react\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport { notFound } from \"next/navigation\"\n\nexport default async function EditCoursePage({\n  params,\n}: {\n  params: Promise<{ courseId: string }>\n}) {\n  const { courseId } = await params\n  const course = await getCourse(courseId)\n\n  if (course == null) return notFound()\n\n  return (\n    <div className=\"container my-6\">\n      <PageHeader title={course.name} />\n      <Tabs defaultValue=\"lessons\">\n        <TabsList>\n          <TabsTrigger value=\"lessons\">Lessons</TabsTrigger>\n          <TabsTrigger value=\"details\">Details</TabsTrigger>\n        </TabsList>\n        <TabsContent value=\"lessons\" className=\"flex flex-col gap-2\">\n          <Card>\n            <CardHeader className=\"flex items-center flex-row justify-between\">\n              <CardTitle>Sections</CardTitle>\n              <SectionFormDialog courseId={course.id}>\n                <DialogTrigger asChild>\n                  <Button variant=\"outline\">\n                    <PlusIcon /> New Section\n                  </Button>\n                </DialogTrigger>\n              </SectionFormDialog>\n            </CardHeader>\n            <CardContent>\n              <SortableSectionList\n                courseId={course.id}\n                sections={course.courseSections}\n              />\n            </CardContent>\n          </Card>\n          <hr className=\"my-2\" />\n          {course.courseSections.map(section => (\n            <Card key={section.id}>\n              <CardHeader className=\"flex items-center flex-row justify-between gap-4\">\n                <CardTitle\n                  className={cn(\n                    \"flex items-center gap-2\",\n                    section.status === \"private\" && \"text-muted-foreground\"\n                  )}\n                >\n                  {section.status === \"private\" && <EyeClosed />} {section.name}\n                </CardTitle>\n                <LessonFormDialog\n                  defaultSectionId={section.id}\n                  sections={course.courseSections}\n                >\n                  <DialogTrigger asChild>\n                    <Button variant=\"outline\">\n                      <PlusIcon /> New Lesson\n                    </Button>\n                  </DialogTrigger>\n                </LessonFormDialog>\n              </CardHeader>\n              <CardContent>\n                <SortableLessonList\n                  sections={course.courseSections}\n                  lessons={section.lessons}\n                />\n              </CardContent>\n            </Card>\n          ))}\n        </TabsContent>\n        <TabsContent value=\"details\">\n          <Card>\n            <CardHeader>\n              <CourseForm course={course} />\n            </CardHeader>\n          </Card>\n        </TabsContent>\n      </Tabs>\n    </div>\n  )\n}\n\nasync function getCourse(id: string) {\n  \"use cache\"\n  cacheTag(\n    getCourseIdTag(id),\n    getCourseSectionCourseTag(id),\n    getLessonCourseTag(id)\n  )\n\n  return db.query.CourseTable.findFirst({\n    columns: { id: true, name: true, description: true },\n    where: eq(CourseTable.id, id),\n    with: {\n      courseSections: {\n        orderBy: asc(CourseSectionTable.order),\n        columns: { id: true, status: true, name: true },\n        with: {\n          lessons: {\n            orderBy: asc(LessonTable.order),\n            columns: {\n              id: true,\n              name: true,\n              status: true,\n              description: true,\n              youtubeVideoId: true,\n              sectionId: true,\n            },\n          },\n        },\n      },\n    },\n  })\n}\n"
  },
  {
    "path": "src/app/admin/courses/new/page.tsx",
    "content": "import { PageHeader } from \"@/components/PageHeader\"\nimport { CourseForm } from \"@/features/courses/components/CourseForm\"\n\nexport default function NewCoursePage() {\n  return (\n    <div className=\"container my-6\">\n      <PageHeader title=\"New Course\" />\n      <CourseForm />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/admin/courses/page.tsx",
    "content": "import { Button } from \"@/components/ui/button\"\nimport { PageHeader } from \"@/components/PageHeader\"\nimport Link from \"next/link\"\nimport { CourseTable } from \"@/features/courses/components/CourseTable\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport { getCourseGlobalTag } from \"@/features/courses/db/cache/courses\"\nimport { db } from \"@/drizzle/db\"\nimport {\n  CourseSectionTable,\n  CourseTable as DbCourseTable,\n  LessonTable,\n  UserCourseAccessTable,\n} from \"@/drizzle/schema\"\nimport { asc, countDistinct, eq } from \"drizzle-orm\"\nimport { getUserCourseAccessGlobalTag } from \"@/features/courses/db/cache/userCourseAccess\"\nimport { getCourseSectionGlobalTag } from \"@/features/courseSections/db/cache\"\nimport { getLessonGlobalTag } from \"@/features/lessons/db/cache/lessons\"\n\nexport default async function CoursesPage() {\n  const courses = await getCourses()\n\n  return (\n    <div className=\"container my-6\">\n      <PageHeader title=\"Courses\">\n        <Button asChild>\n          <Link href=\"/admin/courses/new\">New Course</Link>\n        </Button>\n      </PageHeader>\n\n      <CourseTable courses={courses} />\n    </div>\n  )\n}\n\nasync function getCourses() {\n  \"use cache\"\n  cacheTag(\n    getCourseGlobalTag(),\n    getUserCourseAccessGlobalTag(),\n    getCourseSectionGlobalTag(),\n    getLessonGlobalTag()\n  )\n\n  return db\n    .select({\n      id: DbCourseTable.id,\n      name: DbCourseTable.name,\n      sectionsCount: countDistinct(CourseSectionTable),\n      lessonsCount: countDistinct(LessonTable),\n      studentsCount: countDistinct(UserCourseAccessTable),\n    })\n    .from(DbCourseTable)\n    .leftJoin(\n      CourseSectionTable,\n      eq(CourseSectionTable.courseId, DbCourseTable.id)\n    )\n    .leftJoin(LessonTable, eq(LessonTable.sectionId, CourseSectionTable.id))\n    .leftJoin(\n      UserCourseAccessTable,\n      eq(UserCourseAccessTable.courseId, DbCourseTable.id)\n    )\n    .orderBy(asc(DbCourseTable.name))\n    .groupBy(DbCourseTable.id)\n}\n"
  },
  {
    "path": "src/app/admin/layout.tsx",
    "content": "import { Badge } from \"@/components/ui/badge\"\nimport { UserButton } from \"@clerk/nextjs\"\nimport Link from \"next/link\"\nimport { ReactNode } from \"react\"\n\nexport default function AdminLayout({\n  children,\n}: Readonly<{ children: ReactNode }>) {\n  return (\n    <>\n      <Navbar />\n      {children}\n    </>\n  )\n}\n\nfunction Navbar() {\n  return (\n    <header className=\"flex h-12 shadow bg-background z-10\">\n      <nav className=\"flex gap-4 container\">\n        <div className=\"mr-auto flex items-center gap-2\">\n          <Link className=\"text-lg hover:underline\" href=\"/admin\">\n            Web Dev Simplified\n          </Link>\n          <Badge>Admin</Badge>\n        </div>\n        <Link\n          className=\"hover:bg-accent/10 flex items-center px-2\"\n          href=\"/admin/courses\"\n        >\n          Courses\n        </Link>\n        <Link\n          className=\"hover:bg-accent/10 flex items-center px-2\"\n          href=\"/admin/products\"\n        >\n          Products\n        </Link>\n        <Link\n          className=\"hover:bg-accent/10 flex items-center px-2\"\n          href=\"/admin/sales\"\n        >\n          Sales\n        </Link>\n        <div className=\"size-8 self-center\">\n          <UserButton\n            appearance={{\n              elements: {\n                userButtonAvatarBox: { width: \"100%\", height: \"100%\" },\n              },\n            }}\n          />\n        </div>\n      </nav>\n    </header>\n  )\n}\n"
  },
  {
    "path": "src/app/admin/page.tsx",
    "content": "import {\n  Card,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport { db } from \"@/drizzle/db\"\nimport {\n  CourseSectionTable,\n  CourseTable,\n  LessonTable,\n  ProductTable,\n  PurchaseTable,\n  UserCourseAccessTable,\n} from \"@/drizzle/schema\"\nimport { getCourseGlobalTag } from \"@/features/courses/db/cache/courses\"\nimport { getUserCourseAccessGlobalTag } from \"@/features/courses/db/cache/userCourseAccess\"\nimport { getCourseSectionGlobalTag } from \"@/features/courseSections/db/cache\"\nimport { getLessonGlobalTag } from \"@/features/lessons/db/cache/lessons\"\nimport { getProductGlobalTag } from \"@/features/products/db/cache\"\nimport { getPurchaseGlobalTag } from \"@/features/purchases/db/cache\"\nimport { formatNumber, formatPrice } from \"@/lib/formatters\"\nimport { count, countDistinct, isNotNull, sql, sum } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport { ReactNode } from \"react\"\n\nexport default async function AdminPage() {\n  const {\n    averageNetPurchasesPerCustomer,\n    netPurchases,\n    netSales,\n    refundedPurchases,\n    totalRefunds,\n  } = await getPurchaseDetails()\n\n  return (\n    <div className=\"container my-6\">\n      <div className=\"grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-5 md:grid-cols-4 gap-4\">\n        <StatCard title=\"Net Sales\">{formatPrice(netSales)}</StatCard>\n        <StatCard title=\"Refunded Sales\">{formatPrice(totalRefunds)}</StatCard>\n        <StatCard title=\"Un-Refunded Purchases\">\n          {formatNumber(netPurchases)}\n        </StatCard>\n        <StatCard title=\"Refunded Purchases\">\n          {formatNumber(refundedPurchases)}\n        </StatCard>\n        <StatCard title=\"Purchases Per User\">\n          {formatNumber(averageNetPurchasesPerCustomer, {\n            maximumFractionDigits: 2,\n          })}\n        </StatCard>\n        <StatCard title=\"Students\">\n          {formatNumber(await getTotalStudents())}\n        </StatCard>\n        <StatCard title=\"Products\">\n          {formatNumber(await getTotalProducts())}\n        </StatCard>\n        <StatCard title=\"Courses\">\n          {formatNumber(await getTotalCourses())}\n        </StatCard>\n        <StatCard title=\"CourseSections\">\n          {formatNumber(await getTotalCourseSections())}\n        </StatCard>\n        <StatCard title=\"Lessons\">\n          {formatNumber(await getTotalLessons())}\n        </StatCard>\n      </div>\n    </div>\n  )\n}\n\nfunction StatCard({ title, children }: { title: string; children: ReactNode }) {\n  return (\n    <Card>\n      <CardHeader className=\"text-center\">\n        <CardDescription>{title}</CardDescription>\n        <CardTitle className=\"font-bold text-2xl\">{children}</CardTitle>\n      </CardHeader>\n    </Card>\n  )\n}\n\nasync function getPurchaseDetails() {\n  \"use cache\"\n  cacheTag(getPurchaseGlobalTag())\n\n  const data = await db\n    .select({\n      totalSales: sql<number>`COALESCE(${sum(\n        PurchaseTable.pricePaidInCents\n      )}, 0)`.mapWith(Number),\n      totalPurchases: count(PurchaseTable.id),\n      totalUsers: countDistinct(PurchaseTable.userId),\n      isRefund: isNotNull(PurchaseTable.refundedAt),\n    })\n    .from(PurchaseTable)\n    .groupBy(table => table.isRefund)\n\n  const [refundData] = data.filter(row => row.isRefund)\n  const [salesData] = data.filter(row => !row.isRefund)\n\n  const netSales = (salesData?.totalSales ?? 0) / 100\n  const totalRefunds = (refundData?.totalSales ?? 0) / 100\n  const netPurchases = salesData?.totalPurchases ?? 0\n  const refundedPurchases = refundData?.totalPurchases ?? 0\n  const averageNetPurchasesPerCustomer =\n    salesData?.totalUsers != null && salesData.totalUsers > 0\n      ? netPurchases / salesData.totalUsers\n      : 0\n\n  return {\n    netSales,\n    totalRefunds,\n    netPurchases,\n    refundedPurchases,\n    averageNetPurchasesPerCustomer,\n  }\n}\n\nasync function getTotalStudents() {\n  \"use cache\"\n  cacheTag(getUserCourseAccessGlobalTag())\n\n  const [data] = await db\n    .select({ totalStudents: countDistinct(UserCourseAccessTable.userId) })\n    .from(UserCourseAccessTable)\n\n  if (data == null) return 0\n  return data.totalStudents\n}\n\nasync function getTotalCourses() {\n  \"use cache\"\n  cacheTag(getCourseGlobalTag())\n\n  const [data] = await db\n    .select({ totalCourses: count(CourseTable.id) })\n    .from(CourseTable)\n\n  if (data == null) return 0\n  return data.totalCourses\n}\n\nasync function getTotalProducts() {\n  \"use cache\"\n  cacheTag(getProductGlobalTag())\n\n  const [data] = await db\n    .select({ totalProducts: count(ProductTable.id) })\n    .from(ProductTable)\n  if (data == null) return 0\n  return data.totalProducts\n}\n\nasync function getTotalLessons() {\n  \"use cache\"\n  cacheTag(getLessonGlobalTag())\n\n  const [data] = await db\n    .select({ totalLessons: count(LessonTable.id) })\n    .from(LessonTable)\n  if (data == null) return 0\n  return data.totalLessons\n}\n\nasync function getTotalCourseSections() {\n  \"use cache\"\n  cacheTag(getCourseSectionGlobalTag())\n\n  const [data] = await db\n    .select({ totalCourseSections: count(CourseSectionTable.id) })\n    .from(CourseSectionTable)\n  if (data == null) return 0\n  return data.totalCourseSections\n}\n"
  },
  {
    "path": "src/app/admin/products/[productId]/edit/page.tsx",
    "content": "import { PageHeader } from \"@/components/PageHeader\"\nimport { db } from \"@/drizzle/db\"\nimport { CourseTable, ProductTable } from \"@/drizzle/schema\"\nimport { getCourseGlobalTag } from \"@/features/courses/db/cache/courses\"\nimport { ProductForm } from \"@/features/products/components/ProductForm\"\nimport { getProductIdTag } from \"@/features/products/db/cache\"\nimport { asc, eq } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport { notFound } from \"next/navigation\"\n\nexport default async function EditProductPage({\n  params,\n}: {\n  params: Promise<{ productId: string }>\n}) {\n  const { productId } = await params\n  const product = await getProduct(productId)\n\n  if (product == null) return notFound()\n\n  return (\n    <div className=\"container my-6\">\n      <PageHeader title=\"New Product\" />\n      <ProductForm\n        product={{\n          ...product,\n          courseIds: product.courseProducts.map(c => c.courseId),\n        }}\n        courses={await getCourses()}\n      />\n    </div>\n  )\n}\n\nasync function getCourses() {\n  \"use cache\"\n  cacheTag(getCourseGlobalTag())\n\n  return db.query.CourseTable.findMany({\n    orderBy: asc(CourseTable.name),\n    columns: { id: true, name: true },\n  })\n}\n\nasync function getProduct(id: string) {\n  \"use cache\"\n  cacheTag(getProductIdTag(id))\n\n  return db.query.ProductTable.findFirst({\n    columns: {\n      id: true,\n      name: true,\n      description: true,\n      priceInDollars: true,\n      status: true,\n      imageUrl: true,\n    },\n    where: eq(ProductTable.id, id),\n    with: { courseProducts: { columns: { courseId: true } } },\n  })\n}\n"
  },
  {
    "path": "src/app/admin/products/new/page.tsx",
    "content": "import { PageHeader } from \"@/components/PageHeader\"\nimport { db } from \"@/drizzle/db\"\nimport { CourseTable } from \"@/drizzle/schema\"\nimport { getCourseGlobalTag } from \"@/features/courses/db/cache/courses\"\nimport { ProductForm } from \"@/features/products/components/ProductForm\"\nimport { asc } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\n\nexport default async function NewProductPage() {\n  return (\n    <div className=\"container my-6\">\n      <PageHeader title=\"New Product\" />\n      <ProductForm courses={await getCourses()} />\n    </div>\n  )\n}\n\nasync function getCourses() {\n  \"use cache\"\n  cacheTag(getCourseGlobalTag())\n\n  return db.query.CourseTable.findMany({\n    orderBy: asc(CourseTable.name),\n    columns: { id: true, name: true },\n  })\n}\n"
  },
  {
    "path": "src/app/admin/products/page.tsx",
    "content": "import { Button } from \"@/components/ui/button\"\nimport { PageHeader } from \"@/components/PageHeader\"\nimport Link from \"next/link\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport { db } from \"@/drizzle/db\"\nimport {\n  CourseProductTable,\n  ProductTable as DbProductTable,\n  PurchaseTable,\n} from \"@/drizzle/schema\"\nimport { asc, countDistinct, eq } from \"drizzle-orm\"\nimport { getProductGlobalTag } from \"@/features/products/db/cache\"\nimport { ProductTable } from \"@/features/products/components/ProductTable\"\n\nexport default async function ProductsPage() {\n  const products = await getProducts()\n\n  return (\n    <div className=\"container my-6\">\n      <PageHeader title=\"Products\">\n        <Button asChild>\n          <Link href=\"/admin/products/new\">New Product</Link>\n        </Button>\n      </PageHeader>\n\n      <ProductTable products={products} />\n    </div>\n  )\n}\n\nasync function getProducts() {\n  \"use cache\"\n  cacheTag(getProductGlobalTag())\n\n  return db\n    .select({\n      id: DbProductTable.id,\n      name: DbProductTable.name,\n      status: DbProductTable.status,\n      priceInDollars: DbProductTable.priceInDollars,\n      description: DbProductTable.description,\n      imageUrl: DbProductTable.imageUrl,\n      coursesCount: countDistinct(CourseProductTable.courseId),\n      customersCount: countDistinct(PurchaseTable.userId),\n    })\n    .from(DbProductTable)\n    .leftJoin(PurchaseTable, eq(PurchaseTable.productId, DbProductTable.id))\n    .leftJoin(\n      CourseProductTable,\n      eq(CourseProductTable.productId, DbProductTable.id)\n    )\n    .orderBy(asc(DbProductTable.name))\n    .groupBy(DbProductTable.id)\n}\n"
  },
  {
    "path": "src/app/admin/sales/page.tsx",
    "content": "import { PageHeader } from \"@/components/PageHeader\"\nimport { db } from \"@/drizzle/db\"\nimport { PurchaseTable as DbPurchaseTable } from \"@/drizzle/schema\"\nimport { PurchaseTable } from \"@/features/purchases/components/PurchaseTable\"\nimport { getPurchaseGlobalTag } from \"@/features/purchases/db/cache\"\nimport { getUserGlobalTag } from \"@/features/users/db/cache\"\nimport { desc } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\n\nexport default async function PurchasesPage() {\n  const purchases = await getPurchases()\n\n  return (\n    <div className=\"container my-6\">\n      <PageHeader title=\"Sales\" />\n\n      <PurchaseTable purchases={purchases} />\n    </div>\n  )\n}\n\nasync function getPurchases() {\n  \"use cache\"\n  cacheTag(getPurchaseGlobalTag(), getUserGlobalTag())\n\n  return db.query.PurchaseTable.findMany({\n    columns: {\n      id: true,\n      pricePaidInCents: true,\n      refundedAt: true,\n      productDetails: true,\n      createdAt: true,\n    },\n    orderBy: desc(DbPurchaseTable.createdAt),\n    with: { user: { columns: { name: true } } },\n  })\n}\n"
  },
  {
    "path": "src/app/api/clerk/syncUsers/route.ts",
    "content": "import { insertUser } from \"@/features/users/db/users\"\nimport { syncClerkUserMetadata } from \"@/services/clerk\"\nimport { currentUser } from \"@clerk/nextjs/server\"\nimport { NextResponse } from \"next/server\"\n\nexport async function GET(request: Request) {\n  const user = await currentUser()\n\n  if (user == null) return new Response(\"User not found\", { status: 500 })\n  if (user.fullName == null) {\n    return new Response(\"User name missing\", { status: 500 })\n  }\n  if (user.primaryEmailAddress?.emailAddress == null) {\n    return new Response(\"User email missing\", { status: 500 })\n  }\n\n  const dbUser = await insertUser({\n    clerkUserId: user.id,\n    name: user.fullName,\n    email: user.primaryEmailAddress.emailAddress,\n    imageUrl: user.imageUrl,\n    role: user.publicMetadata.role ?? \"user\",\n  })\n\n  await syncClerkUserMetadata(dbUser)\n\n  await new Promise(res => setTimeout(res, 100))\n\n  return NextResponse.redirect(request.headers.get(\"referer\") ?? \"/\")\n}\n"
  },
  {
    "path": "src/app/api/webhooks/clerk/route.ts",
    "content": "import { env } from \"@/data/env/server\"\nimport { deleteUser, insertUser, updateUser } from \"@/features/users/db/users\"\nimport { syncClerkUserMetadata } from \"@/services/clerk\"\nimport { WebhookEvent } from \"@clerk/nextjs/server\"\nimport { headers } from \"next/headers\"\nimport { Webhook } from \"svix\"\n\nexport async function POST(req: Request) {\n  const headerPayload = await headers()\n  const svixId = headerPayload.get(\"svix-id\")\n  const svixTimestamp = headerPayload.get(\"svix-timestamp\")\n  const svixSignature = headerPayload.get(\"svix-signature\")\n\n  if (!svixId || !svixTimestamp || !svixSignature) {\n    return new Response(\"Error occurred -- no svix headers\", {\n      status: 400,\n    })\n  }\n\n  const payload = await req.json()\n  const body = JSON.stringify(payload)\n\n  const wh = new Webhook(env.CLERK_WEBHOOK_SECRET)\n  let event: WebhookEvent\n\n  try {\n    event = wh.verify(body, {\n      \"svix-id\": svixId,\n      \"svix-timestamp\": svixTimestamp,\n      \"svix-signature\": svixSignature,\n    }) as WebhookEvent\n  } catch (err) {\n    console.error(\"Error verifying webhook:\", err)\n    return new Response(\"Error occurred\", {\n      status: 400,\n    })\n  }\n\n  switch (event.type) {\n    case \"user.created\":\n    case \"user.updated\": {\n      const email = event.data.email_addresses.find(\n        email => email.id === event.data.primary_email_address_id\n      )?.email_address\n      const name = `${event.data.first_name} ${event.data.last_name}`.trim()\n      if (email == null) return new Response(\"No email\", { status: 400 })\n      if (name === \"\") return new Response(\"No name\", { status: 400 })\n\n      if (event.type === \"user.created\") {\n        const user = await insertUser({\n          clerkUserId: event.data.id,\n          email,\n          name,\n          imageUrl: event.data.image_url,\n          role: \"user\",\n        })\n\n        await syncClerkUserMetadata(user)\n      } else {\n        await updateUser(\n          { clerkUserId: event.data.id },\n          {\n            email,\n            name,\n            imageUrl: event.data.image_url,\n            role: event.data.public_metadata.role,\n          }\n        )\n      }\n      break\n    }\n    case \"user.deleted\": {\n      if (event.data.id != null) {\n        await deleteUser({ clerkUserId: event.data.id })\n      }\n      break\n    }\n  }\n\n  return new Response(\"\", { status: 200 })\n}\n"
  },
  {
    "path": "src/app/api/webhooks/stripe/route.ts",
    "content": "import { env } from \"@/data/env/server\"\nimport { db } from \"@/drizzle/db\"\nimport { ProductTable, UserTable } from \"@/drizzle/schema\"\nimport { addUserCourseAccess } from \"@/features/courses/db/userCourseAcccess\"\nimport { insertPurchase } from \"@/features/purchases/db/purchases\"\nimport { stripeServerClient } from \"@/services/stripe/stripeServer\"\nimport { eq } from \"drizzle-orm\"\nimport { redirect } from \"next/navigation\"\nimport { NextRequest, NextResponse } from \"next/server\"\nimport Stripe from \"stripe\"\n\nexport async function GET(request: NextRequest) {\n  const stripeSessionId = request.nextUrl.searchParams.get(\"stripeSessionId\")\n  if (stripeSessionId == null) redirect(\"/products/purchase-failure\")\n\n  let redirectUrl: string\n  try {\n    const checkoutSession = await stripeServerClient.checkout.sessions.retrieve(\n      stripeSessionId,\n      { expand: [\"line_items\"] }\n    )\n    const productId = await processStripeCheckout(checkoutSession)\n\n    redirectUrl = `/products/${productId}/purchase/success`\n  } catch {\n    redirectUrl = \"/products/purchase-failure\"\n  }\n\n  return NextResponse.redirect(new URL(redirectUrl, request.url))\n}\n\nexport async function POST(request: NextRequest) {\n  const event = await stripeServerClient.webhooks.constructEvent(\n    await request.text(),\n    request.headers.get(\"stripe-signature\") as string,\n    env.STRIPE_WEBHOOK_SECRET\n  )\n\n  switch (event.type) {\n    case \"checkout.session.completed\":\n    case \"checkout.session.async_payment_succeeded\": {\n      try {\n        await processStripeCheckout(event.data.object)\n      } catch {\n        return new Response(null, { status: 500 })\n      }\n    }\n  }\n  return new Response(null, { status: 200 })\n}\n\nasync function processStripeCheckout(checkoutSession: Stripe.Checkout.Session) {\n  const userId = checkoutSession.metadata?.userId\n  const productId = checkoutSession.metadata?.productId\n\n  if (userId == null || productId == null) {\n    throw new Error(\"Missing metadata\")\n  }\n\n  const [product, user] = await Promise.all([\n    getProduct(productId),\n    await getUser(userId),\n  ])\n\n  if (product == null) throw new Error(\"Product not found\")\n  if (user == null) throw new Error(\"User not found\")\n\n  const courseIds = product.courseProducts.map(cp => cp.courseId)\n  db.transaction(async trx => {\n    try {\n      await addUserCourseAccess({ userId: user.id, courseIds }, trx)\n      await insertPurchase(\n        {\n          stripeSessionId: checkoutSession.id,\n          pricePaidInCents:\n            checkoutSession.amount_total || product.priceInDollars * 100,\n          productDetails: product,\n          userId: user.id,\n          productId,\n        },\n        trx\n      )\n    } catch (error) {\n      trx.rollback()\n      throw error\n    }\n  })\n\n  return productId\n}\n\nfunction getProduct(id: string) {\n  return db.query.ProductTable.findFirst({\n    columns: {\n      id: true,\n      priceInDollars: true,\n      name: true,\n      description: true,\n      imageUrl: true,\n    },\n    where: eq(ProductTable.id, id),\n    with: {\n      courseProducts: { columns: { courseId: true } },\n    },\n  })\n}\n\nfunction getUser(id: string) {\n  return db.query.UserTable.findFirst({\n    columns: { id: true },\n    where: eq(UserTable.id, id),\n  })\n}\n"
  },
  {
    "path": "src/app/globals.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbody {\n  font-family: Arial, Helvetica, sans-serif;\n}\n\n@layer base {\n  :root {\n    --background: 0 0% 100%;\n    --foreground: 240 10% 3.9%;\n    --card: 0 0% 100%;\n    --card-foreground: 240 10% 3.9%;\n    --popover: 0 0% 100%;\n    --popover-foreground: 240 10% 3.9%;\n    --primary: 240 5.9% 10%;\n    --primary-foreground: 0 0% 98%;\n    --secondary: 240 4.8% 95.9%;\n    --secondary-foreground: 240 5.9% 10%;\n    --muted: 240 4.8% 95.9%;\n    --muted-foreground: 240 3.8% 46.1%;\n    --accent: 280 75% 50%;\n    --accent-foreground: 0 0% 98%;\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 0 0% 98%;\n    --border: 240 5.9% 90%;\n    --input: 240 5.9% 90%;\n    --ring: 240 10% 3.9%;\n    --chart-1: 12 76% 61%;\n    --chart-2: 173 58% 39%;\n    --chart-3: 197 37% 24%;\n    --chart-4: 43 74% 66%;\n    --chart-5: 27 87% 67%;\n    --radius: 0.5rem;\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n"
  },
  {
    "path": "src/app/layout.tsx",
    "content": "import type { Metadata } from \"next\"\nimport \"./globals.css\"\nimport { ClerkProvider } from \"@clerk/nextjs\"\nimport { Toaster } from \"@/components/ui/toaster\"\n\nexport const metadata: Metadata = {\n  title: \"Create Next App\",\n  description: \"Generated by create next app\",\n}\n\nexport default function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode\n}>) {\n  return (\n    <ClerkProvider>\n      <html lang=\"en\">\n        <body className=\"antialiased\">\n          {children}\n          <Toaster />\n        </body>\n      </html>\n    </ClerkProvider>\n  )\n}\n"
  },
  {
    "path": "src/components/ActionButton.tsx",
    "content": "\"use client\"\n\nimport { ComponentPropsWithRef, ReactNode, useTransition } from \"react\"\nimport { Button } from \"./ui/button\"\nimport { actionToast } from \"@/hooks/use-toast\"\nimport { Loader2Icon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  AlertDialog,\n  AlertDialogDescription,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogContent,\n  AlertDialogTrigger,\n  AlertDialogFooter,\n  AlertDialogCancel,\n  AlertDialogAction,\n} from \"./ui/alert-dialog\"\n\nexport function ActionButton({\n  action,\n  requireAreYouSure = false,\n  ...props\n}: Omit<ComponentPropsWithRef<typeof Button>, \"onClick\"> & {\n  action: () => Promise<{ error: boolean; message: string }>\n  requireAreYouSure?: boolean\n}) {\n  {\n    const [isLoading, startTransition] = useTransition()\n\n    function performAction() {\n      startTransition(async () => {\n        const data = await action()\n        actionToast({ actionData: data })\n      })\n    }\n\n    if (requireAreYouSure) {\n      return (\n        <AlertDialog open={isLoading ? true : undefined}>\n          <AlertDialogTrigger asChild>\n            <Button {...props} />\n          </AlertDialogTrigger>\n          <AlertDialogContent>\n            <AlertDialogHeader>\n              <AlertDialogTitle>Are you sure?</AlertDialogTitle>\n              <AlertDialogDescription>\n                This action cannot be undone.\n              </AlertDialogDescription>\n            </AlertDialogHeader>\n            <AlertDialogFooter>\n              <AlertDialogCancel>Cancel</AlertDialogCancel>\n              <AlertDialogAction disabled={isLoading} onClick={performAction}>\n                <LoadingTextSwap isLoading={isLoading}>Yes</LoadingTextSwap>\n              </AlertDialogAction>\n            </AlertDialogFooter>\n          </AlertDialogContent>\n        </AlertDialog>\n      )\n    }\n\n    return (\n      <Button {...props} disabled={isLoading} onClick={performAction}>\n        <LoadingTextSwap isLoading={isLoading}>\n          {props.children}\n        </LoadingTextSwap>\n      </Button>\n    )\n  }\n}\n\nfunction LoadingTextSwap({\n  isLoading,\n  children,\n}: {\n  isLoading: boolean\n  children: ReactNode\n}) {\n  return (\n    <div className=\"grid items-center justify-items-center\">\n      <div\n        className={cn(\n          \"col-start-1 col-end-2 row-start-1 row-end-2\",\n          isLoading ? \"invisible\" : \"visible\"\n        )}\n      >\n        {children}\n      </div>\n      <div\n        className={cn(\n          \"col-start-1 col-end-2 row-start-1 row-end-2 text-center\",\n          isLoading ? \"visible\" : \"invisible\"\n        )}\n      >\n        <Loader2Icon className=\"animate-spin\" />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/LoadingSpinner.tsx",
    "content": "import { cn } from \"@/lib/utils\"\nimport { Loader2Icon } from \"lucide-react\"\nimport { ComponentProps } from \"react\"\n\nexport function LoadingSpinner({\n  className,\n  ...props\n}: ComponentProps<typeof Loader2Icon>) {\n  return (\n    <Loader2Icon\n      className={cn(\"animate-spin text-accent\", className)}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "src/components/PageHeader.tsx",
    "content": "import { cn } from \"@/lib/utils\"\nimport { ReactNode } from \"react\"\n\nexport function PageHeader({\n  title,\n  children,\n  className,\n}: {\n  title: string\n  children?: ReactNode\n  className?: string\n}) {\n  return (\n    <div\n      className={cn(\"mb-8 flex gap-4 items-center justify-between\", className)}\n    >\n      <h1 className=\"text-2xl font-semibold\">{title}</h1>\n      {children && <div>{children}</div>}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/RequiredLabelIcon.tsx",
    "content": "import { cn } from \"@/lib/utils\"\nimport { AsteriskIcon } from \"lucide-react\"\nimport { ComponentPropsWithoutRef } from \"react\"\n\nexport function RequiredLabelIcon({\n  className,\n  ...props\n}: ComponentPropsWithoutRef<typeof AsteriskIcon>) {\n  return (\n    <AsteriskIcon\n      {...props}\n      className={cn(\"text-destructive inline size-4 align-top\", className)}\n    />\n  )\n}\n"
  },
  {
    "path": "src/components/Skeleton.tsx",
    "content": "import { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \"./ui/button\"\nimport { ReactNode } from \"react\"\n\nexport function SkeletonButton({ className }: { className?: string }) {\n  return (\n    <div\n      className={cn(\n        buttonVariants({\n          variant: \"secondary\",\n          className: \"pointer-events-none animate-pulse w-24\",\n        }),\n        className\n      )}\n    />\n  )\n}\n\nexport function SkeletonArray({\n  amount,\n  children,\n}: {\n  amount: number\n  children: ReactNode\n}) {\n  return Array.from({ length: amount }).map(() => children)\n}\n\nexport function SkeletonText({\n  rows = 1,\n  size = \"md\",\n  className,\n}: {\n  rows?: number\n  size?: \"md\" | \"lg\"\n  className?: string\n}) {\n  return (\n    <div className=\"flex flex-col gap-1\">\n      <SkeletonArray amount={rows}>\n        <div\n          className={cn(\n            \"bg-secondary animate-pulse w-full rounded-sm\",\n            rows > 1 && \"last:w-3/4\",\n            size === \"md\" && \"h-3\",\n            size === \"lg\" && \"h-5\",\n            className\n          )}\n        />\n      </SkeletonArray>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/SortableList.tsx",
    "content": "\"use client\"\n\nimport { ReactNode, useId, useOptimistic, useTransition } from \"react\"\nimport { DndContext, DragEndEvent } from \"@dnd-kit/core\"\nimport {\n  arrayMove,\n  SortableContext,\n  useSortable,\n  verticalListSortingStrategy,\n} from \"@dnd-kit/sortable\"\nimport { CSS } from \"@dnd-kit/utilities\"\nimport { cn } from \"@/lib/utils\"\nimport { GripVerticalIcon } from \"lucide-react\"\nimport { actionToast } from \"@/hooks/use-toast\"\n\nexport function SortableList<T extends { id: string }>({\n  items,\n  onOrderChange,\n  children,\n}: {\n  items: T[]\n  onOrderChange: (\n    newOrder: string[]\n  ) => Promise<{ error: boolean; message: string }>\n  children: (items: T[]) => ReactNode\n}) {\n  const dndContextId = useId()\n  const [optimisticItems, setOptimisticItems] = useOptimistic(items)\n  const [, startTransition] = useTransition()\n\n  function handleDragEnd(event: DragEndEvent) {\n    const { active, over } = event\n    const activeId = active.id.toString()\n    const overId = over?.id.toString()\n    if (overId == null || activeId == null) return\n\n    function getNewArray(array: T[], activeId: string, overId: string) {\n      const oldIndex = array.findIndex(section => section.id === activeId)\n      const newIndex = array.findIndex(section => section.id === overId)\n      return arrayMove(array, oldIndex, newIndex)\n    }\n\n    startTransition(async () => {\n      setOptimisticItems(items => getNewArray(items, activeId, overId))\n      const actionData = await onOrderChange(\n        getNewArray(optimisticItems, activeId, overId).map(s => s.id)\n      )\n\n      actionToast({ actionData })\n    })\n  }\n\n  return (\n    <DndContext id={dndContextId} onDragEnd={handleDragEnd}>\n      <SortableContext\n        items={optimisticItems}\n        strategy={verticalListSortingStrategy}\n      >\n        <div className=\"flex flex-col\">{children(optimisticItems)}</div>\n      </SortableContext>\n    </DndContext>\n  )\n}\n\nexport function SortableItem({\n  id,\n  children,\n  className,\n}: {\n  id: string\n  children: ReactNode\n  className?: string\n}) {\n  const {\n    setNodeRef,\n    transform,\n    transition,\n    activeIndex,\n    index,\n    attributes,\n    listeners,\n  } = useSortable({ id })\n  const isActive = activeIndex === index\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={{\n        transform: CSS.Transform.toString(transform),\n        transition,\n      }}\n      className={cn(\n        \"flex gap-1 items-center bg-background rounded-lg p-2\",\n        isActive && \"z-10 border shadow-md\"\n      )}\n    >\n      <GripVerticalIcon\n        className=\"text-muted-foreground size-6 p-1\"\n        {...attributes}\n        {...listeners}\n      />\n      <div className={cn(\"flex-grow\", className)}>{children}</div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/accordion.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AccordionPrimitive from \"@radix-ui/react-accordion\"\nimport { ChevronDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Accordion = AccordionPrimitive.Root\n\nconst AccordionItem = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <AccordionPrimitive.Item\n    ref={ref}\n    className={cn(\"border-b\", className)}\n    {...props}\n  />\n))\nAccordionItem.displayName = \"AccordionItem\"\n\nconst AccordionTrigger = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Header className=\"flex\">\n    <AccordionPrimitive.Trigger\n      ref={ref}\n      className={cn(\n        \"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronDown className=\"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200\" />\n    </AccordionPrimitive.Trigger>\n  </AccordionPrimitive.Header>\n))\nAccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName\n\nconst AccordionContent = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Content\n    ref={ref}\n    className=\"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\"\n    {...props}\n  >\n    <div className={cn(\"pb-4 pt-0\", className)}>{children}</div>\n  </AccordionPrimitive.Content>\n))\nAccordionContent.displayName = AccordionPrimitive.Content.displayName\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent }\n"
  },
  {
    "path": "src/components/ui/alert-dialog.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\"\n\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \"@/components/ui/button\"\n\nconst AlertDialog = AlertDialogPrimitive.Root\n\nconst AlertDialogTrigger = AlertDialogPrimitive.Trigger\n\nconst AlertDialogPortal = AlertDialogPrimitive.Portal\n\nconst AlertDialogOverlay = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Overlay\n    className={cn(\n      \"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n      className\n    )}\n    {...props}\n    ref={ref}\n  />\n))\nAlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName\n\nconst AlertDialogContent = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPortal>\n    <AlertDialogOverlay />\n    <AlertDialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n        className\n      )}\n      {...props}\n    />\n  </AlertDialogPortal>\n))\nAlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName\n\nconst AlertDialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-2 text-center sm:text-left\",\n      className\n    )}\n    {...props}\n  />\n)\nAlertDialogHeader.displayName = \"AlertDialogHeader\"\n\nconst AlertDialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n      className\n    )}\n    {...props}\n  />\n)\nAlertDialogFooter.displayName = \"AlertDialogFooter\"\n\nconst AlertDialogTitle = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Title\n    ref={ref}\n    className={cn(\"text-lg font-semibold\", className)}\n    {...props}\n  />\n))\nAlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName\n\nconst AlertDialogDescription = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nAlertDialogDescription.displayName =\n  AlertDialogPrimitive.Description.displayName\n\nconst AlertDialogAction = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Action>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Action\n    ref={ref}\n    className={cn(buttonVariants(), className)}\n    {...props}\n  />\n))\nAlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName\n\nconst AlertDialogCancel = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Cancel>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Cancel\n    ref={ref}\n    className={cn(\n      buttonVariants({ variant: \"outline\" }),\n      \"mt-2 sm:mt-0\",\n      className\n    )}\n    {...props}\n  />\n))\nAlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName\n\nexport {\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel,\n}\n"
  },
  {
    "path": "src/components/ui/badge.tsx",
    "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst badgeVariants = cva(\n  \"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\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80\",\n        secondary:\n          \"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        destructive:\n          \"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80\",\n        outline: \"text-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nexport interface BadgeProps\n  extends React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof badgeVariants> {}\n\nfunction Badge({ className, variant, ...props }: BadgeProps) {\n  return (\n    <div className={cn(badgeVariants({ variant }), className)} {...props} />\n  )\n}\n\nexport { Badge, badgeVariants }\n"
  },
  {
    "path": "src/components/ui/button.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n  \"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\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-primary text-primary-foreground shadow hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90\",\n        destructiveOutline:\n          \"border border-destructive bg-background shadow-sm text-destructive hover:bg-destructive hover:text-destructive-foreground\",\n        outline:\n          \"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-accent\",\n        secondary:\n          \"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80\",\n        ghost: \"hover:bg-accent hover:text-accent-foreground\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2\",\n        sm: \"h-8 rounded-md px-3 text-xs\",\n        lg: \"h-10 rounded-md px-8\",\n        icon: \"h-9 w-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : \"button\"\n    return (\n      <Comp\n        className={cn(buttonVariants({ variant, size, className }))}\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nButton.displayName = \"Button\"\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "src/components/ui/card.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Card = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\n      \"rounded-xl border bg-card text-card-foreground shadow\",\n      className\n    )}\n    {...props}\n  />\n))\nCard.displayName = \"Card\"\n\nconst CardHeader = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"flex flex-col space-y-1.5 p-6\", className)}\n    {...props}\n  />\n))\nCardHeader.displayName = \"CardHeader\"\n\nconst CardTitle = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"font-semibold leading-none tracking-tight\", className)}\n    {...props}\n  />\n))\nCardTitle.displayName = \"CardTitle\"\n\nconst CardDescription = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nCardDescription.displayName = \"CardDescription\"\n\nconst CardContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn(\"p-6 pt-0\", className)} {...props} />\n))\nCardContent.displayName = \"CardContent\"\n\nconst CardFooter = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"flex items-center p-6 pt-0\", className)}\n    {...props}\n  />\n))\nCardFooter.displayName = \"CardFooter\"\n\nexport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }\n"
  },
  {
    "path": "src/components/ui/command.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { type DialogProps } from \"@radix-ui/react-dialog\"\nimport { Command as CommandPrimitive } from \"cmdk\"\nimport { Search } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\"\n\nconst Command = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive\n    ref={ref}\n    className={cn(\n      \"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground\",\n      className\n    )}\n    {...props}\n  />\n))\nCommand.displayName = CommandPrimitive.displayName\n\nconst CommandDialog = ({ children, ...props }: DialogProps) => {\n  return (\n    <Dialog {...props}>\n      <DialogContent className=\"overflow-hidden p-0\">\n        <Command className=\"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nconst CommandInput = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Input>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>\n>(({ className, ...props }, ref) => (\n  <div className=\"flex items-center border-b px-3\" cmdk-input-wrapper=\"\">\n    <Search className=\"mr-2 h-4 w-4 shrink-0 opacity-50\" />\n    <CommandPrimitive.Input\n      ref={ref}\n      className={cn(\n        \"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    />\n  </div>\n))\n\nCommandInput.displayName = CommandPrimitive.Input.displayName\n\nconst CommandList = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.List\n    ref={ref}\n    className={cn(\"max-h-[300px] overflow-y-auto overflow-x-hidden\", className)}\n    {...props}\n  />\n))\n\nCommandList.displayName = CommandPrimitive.List.displayName\n\nconst CommandEmpty = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Empty>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>\n>((props, ref) => (\n  <CommandPrimitive.Empty\n    ref={ref}\n    className=\"py-6 text-center text-sm\"\n    {...props}\n  />\n))\n\nCommandEmpty.displayName = CommandPrimitive.Empty.displayName\n\nconst CommandGroup = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Group>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Group\n    ref={ref}\n    className={cn(\n      \"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground\",\n      className\n    )}\n    {...props}\n  />\n))\n\nCommandGroup.displayName = CommandPrimitive.Group.displayName\n\nconst CommandSeparator = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 h-px bg-border\", className)}\n    {...props}\n  />\n))\nCommandSeparator.displayName = CommandPrimitive.Separator.displayName\n\nconst CommandItem = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n      className\n    )}\n    {...props}\n  />\n))\n\nCommandItem.displayName = CommandPrimitive.Item.displayName\n\nconst CommandShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn(\n        \"ml-auto text-xs tracking-widest text-muted-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\nCommandShortcut.displayName = \"CommandShortcut\"\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator,\n}\n"
  },
  {
    "path": "src/components/ui/custom/multi-select.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronsUpDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport { Badge } from \"../badge\"\n\nexport function MultiSelect<Option>({\n  options,\n  getValue,\n  getLabel,\n  selectedValues,\n  onSelectedValuesChange,\n  selectPlaceholder,\n  searchPlaceholder,\n  noSearchResultsMessage = \"No results\",\n}: {\n  options: Option[]\n  getValue: (option: Option) => string\n  getLabel: (option: Option) => React.ReactNode\n  selectedValues: string[]\n  onSelectedValuesChange: (values: string[]) => void\n  selectPlaceholder?: string\n  searchPlaceholder?: string\n  noSearchResultsMessage?: string\n}) {\n  const [open, setOpen] = React.useState(false)\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          role=\"combobox\"\n          aria-expanded={open}\n          className=\"justify-between h-auto py-1.5 px-2 min-h-9 hover:bg-background w-full\"\n        >\n          <div className=\"flex gap-1 flex-wrap\">\n            {selectedValues.length > 0 ? (\n              selectedValues.map(value => {\n                const option = options.find(o => getValue(o) === value)\n                if (option == null) return null\n\n                return (\n                  <Badge key={getValue(option)} variant=\"outline\">\n                    {getLabel(option)}\n                  </Badge>\n                )\n              })\n            ) : (\n              <span className=\"text-muted-foreground\">{selectPlaceholder}</span>\n            )}\n          </div>\n          <ChevronsUpDown className=\"ml-2 h-4 w-4 shrink-0 opacity-50\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent align=\"start\" className=\"p-0\">\n        <Command>\n          <CommandInput placeholder={searchPlaceholder} />\n          <CommandList>\n            <CommandEmpty>{noSearchResultsMessage}</CommandEmpty>\n            <CommandGroup>\n              {options.map(option => (\n                <CommandItem\n                  key={getValue(option)}\n                  value={getValue(option)}\n                  onSelect={currentValue => {\n                    if (selectedValues.includes(currentValue)) {\n                      onSelectedValuesChange(\n                        selectedValues.filter(value => value !== currentValue)\n                      )\n                    } else {\n                      return onSelectedValuesChange([\n                        ...selectedValues,\n                        currentValue,\n                      ])\n                    }\n                  }}\n                >\n                  <Check\n                    className={cn(\n                      \"mr-2 h-4 w-4\",\n                      selectedValues.includes(getValue(option))\n                        ? \"opacity-100\"\n                        : \"opacity-0\"\n                    )}\n                  />\n                  {getLabel(option)}\n                </CommandItem>\n              ))}\n            </CommandGroup>\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/dialog.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Dialog = DialogPrimitive.Root\n\nconst DialogTrigger = DialogPrimitive.Trigger\n\nconst DialogPortal = DialogPrimitive.Portal\n\nconst DialogClose = DialogPrimitive.Close\n\nconst DialogOverlay = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Overlay\n    ref={ref}\n    className={cn(\n      \"fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n      className\n    )}\n    {...props}\n  />\n))\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName\n\nconst DialogContent = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <DialogPortal>\n    <DialogOverlay />\n    <DialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <DialogPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\">\n        <X className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Close</span>\n      </DialogPrimitive.Close>\n    </DialogPrimitive.Content>\n  </DialogPortal>\n))\nDialogContent.displayName = DialogPrimitive.Content.displayName\n\nconst DialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-1.5 text-center sm:text-left\",\n      className\n    )}\n    {...props}\n  />\n)\nDialogHeader.displayName = \"DialogHeader\"\n\nconst DialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n      className\n    )}\n    {...props}\n  />\n)\nDialogFooter.displayName = \"DialogFooter\"\n\nconst DialogTitle = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Title\n    ref={ref}\n    className={cn(\n      \"text-lg font-semibold leading-none tracking-tight\",\n      className\n    )}\n    {...props}\n  />\n))\nDialogTitle.displayName = DialogPrimitive.Title.displayName\n\nconst DialogDescription = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nDialogDescription.displayName = DialogPrimitive.Description.displayName\n\nexport {\n  Dialog,\n  DialogPortal,\n  DialogOverlay,\n  DialogTrigger,\n  DialogClose,\n  DialogContent,\n  DialogHeader,\n  DialogFooter,\n  DialogTitle,\n  DialogDescription,\n}\n"
  },
  {
    "path": "src/components/ui/form.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport {\n  Controller,\n  ControllerProps,\n  FieldPath,\n  FieldValues,\n  FormProvider,\n  useFormContext,\n} from \"react-hook-form\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Label } from \"@/components/ui/label\"\n\nconst Form = FormProvider\n\ntype FormFieldContextValue<\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n> = {\n  name: TName\n}\n\nconst FormFieldContext = React.createContext<FormFieldContextValue>(\n  {} as FormFieldContextValue\n)\n\nconst FormField = <\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n>({\n  ...props\n}: ControllerProps<TFieldValues, TName>) => {\n  return (\n    <FormFieldContext.Provider value={{ name: props.name }}>\n      <Controller {...props} />\n    </FormFieldContext.Provider>\n  )\n}\n\nconst useFormField = () => {\n  const fieldContext = React.useContext(FormFieldContext)\n  const itemContext = React.useContext(FormItemContext)\n  const { getFieldState, formState } = useFormContext()\n\n  const fieldState = getFieldState(fieldContext.name, formState)\n\n  if (!fieldContext) {\n    throw new Error(\"useFormField should be used within <FormField>\")\n  }\n\n  const { id } = itemContext\n\n  return {\n    id,\n    name: fieldContext.name,\n    formItemId: `${id}-form-item`,\n    formDescriptionId: `${id}-form-item-description`,\n    formMessageId: `${id}-form-item-message`,\n    ...fieldState,\n  }\n}\n\ntype FormItemContextValue = {\n  id: string\n}\n\nconst FormItemContext = React.createContext<FormItemContextValue>(\n  {} as FormItemContextValue\n)\n\nconst FormItem = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const id = React.useId()\n\n  return (\n    <FormItemContext.Provider value={{ id }}>\n      <div ref={ref} className={cn(\"space-y-2\", className)} {...props} />\n    </FormItemContext.Provider>\n  )\n})\nFormItem.displayName = \"FormItem\"\n\nconst FormLabel = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>\n>(({ className, ...props }, ref) => {\n  const { error, formItemId } = useFormField()\n\n  return (\n    <Label\n      ref={ref}\n      className={cn(error && \"text-destructive\", className)}\n      htmlFor={formItemId}\n      {...props}\n    />\n  )\n})\nFormLabel.displayName = \"FormLabel\"\n\nconst FormControl = React.forwardRef<\n  React.ElementRef<typeof Slot>,\n  React.ComponentPropsWithoutRef<typeof Slot>\n>(({ ...props }, ref) => {\n  const { error, formItemId, formDescriptionId, formMessageId } = useFormField()\n\n  return (\n    <Slot\n      ref={ref}\n      id={formItemId}\n      aria-describedby={\n        !error\n          ? `${formDescriptionId}`\n          : `${formDescriptionId} ${formMessageId}`\n      }\n      aria-invalid={!!error}\n      {...props}\n    />\n  )\n})\nFormControl.displayName = \"FormControl\"\n\nconst FormDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => {\n  const { formDescriptionId } = useFormField()\n\n  return (\n    <p\n      ref={ref}\n      id={formDescriptionId}\n      className={cn(\"text-[0.8rem] text-muted-foreground\", className)}\n      {...props}\n    />\n  )\n})\nFormDescription.displayName = \"FormDescription\"\n\nconst FormMessage = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, ...props }, ref) => {\n  const { error, formMessageId } = useFormField()\n  const body = error ? String(error?.message) : children\n\n  if (!body) {\n    return null\n  }\n\n  return (\n    <p\n      ref={ref}\n      id={formMessageId}\n      className={cn(\"text-[0.8rem] font-medium text-destructive\", className)}\n      {...props}\n    >\n      {body}\n    </p>\n  )\n})\nFormMessage.displayName = \"FormMessage\"\n\nexport {\n  useFormField,\n  Form,\n  FormItem,\n  FormLabel,\n  FormControl,\n  FormDescription,\n  FormMessage,\n  FormField,\n}\n"
  },
  {
    "path": "src/components/ui/input.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Input = React.forwardRef<HTMLInputElement, React.ComponentProps<\"input\">>(\n  ({ className, type, ...props }, ref) => {\n    return (\n      <input\n        type={type}\n        className={cn(\n          \"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n          className\n        )}\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nInput.displayName = \"Input\"\n\nexport { Input }\n"
  },
  {
    "path": "src/components/ui/label.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst labelVariants = cva(\n  \"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n)\n\nconst Label = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &\n    VariantProps<typeof labelVariants>\n>(({ className, ...props }, ref) => (\n  <LabelPrimitive.Root\n    ref={ref}\n    className={cn(labelVariants(), className)}\n    {...props}\n  />\n))\nLabel.displayName = LabelPrimitive.Root.displayName\n\nexport { Label }\n"
  },
  {
    "path": "src/components/ui/popover.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Popover = PopoverPrimitive.Root\n\nconst PopoverTrigger = PopoverPrimitive.Trigger\n\nconst PopoverAnchor = PopoverPrimitive.Anchor\n\nconst PopoverContent = React.forwardRef<\n  React.ElementRef<typeof PopoverPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = \"center\", sideOffset = 4, ...props }, ref) => (\n  <PopoverPrimitive.Portal>\n    <PopoverPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className\n      )}\n      {...props}\n    />\n  </PopoverPrimitive.Portal>\n))\nPopoverContent.displayName = PopoverPrimitive.Content.displayName\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }\n"
  },
  {
    "path": "src/components/ui/select.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SelectPrimitive from \"@radix-ui/react-select\"\nimport { Check, ChevronDown, ChevronUp } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Select = SelectPrimitive.Root\n\nconst SelectGroup = SelectPrimitive.Group\n\nconst SelectValue = SelectPrimitive.Value\n\nconst SelectTrigger = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1\",\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <SelectPrimitive.Icon asChild>\n      <ChevronDown className=\"h-4 w-4 opacity-50\" />\n    </SelectPrimitive.Icon>\n  </SelectPrimitive.Trigger>\n))\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName\n\nconst SelectScrollUpButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollUpButton\n    ref={ref}\n    className={cn(\n      \"flex cursor-default items-center justify-center py-1\",\n      className\n    )}\n    {...props}\n  >\n    <ChevronUp className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollUpButton>\n))\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName\n\nconst SelectScrollDownButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollDownButton\n    ref={ref}\n    className={cn(\n      \"flex cursor-default items-center justify-center py-1\",\n      className\n    )}\n    {...props}\n  >\n    <ChevronDown className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollDownButton>\n))\nSelectScrollDownButton.displayName =\n  SelectPrimitive.ScrollDownButton.displayName\n\nconst SelectContent = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>\n>(({ className, children, position = \"popper\", ...props }, ref) => (\n  <SelectPrimitive.Portal>\n    <SelectPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        position === \"popper\" &&\n          \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n        className\n      )}\n      position={position}\n      {...props}\n    >\n      <SelectScrollUpButton />\n      <SelectPrimitive.Viewport\n        className={cn(\n          \"p-1\",\n          position === \"popper\" &&\n            \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]\"\n        )}\n      >\n        {children}\n      </SelectPrimitive.Viewport>\n      <SelectScrollDownButton />\n    </SelectPrimitive.Content>\n  </SelectPrimitive.Portal>\n))\nSelectContent.displayName = SelectPrimitive.Content.displayName\n\nconst SelectLabel = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Label\n    ref={ref}\n    className={cn(\"px-2 py-1.5 text-sm font-semibold\", className)}\n    {...props}\n  />\n))\nSelectLabel.displayName = SelectPrimitive.Label.displayName\n\nconst SelectItem = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute right-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <SelectPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </SelectPrimitive.ItemIndicator>\n    </span>\n    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n  </SelectPrimitive.Item>\n))\nSelectItem.displayName = SelectPrimitive.Item.displayName\n\nconst SelectSeparator = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n    {...props}\n  />\n))\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName\n\nexport {\n  Select,\n  SelectGroup,\n  SelectValue,\n  SelectTrigger,\n  SelectContent,\n  SelectLabel,\n  SelectItem,\n  SelectSeparator,\n  SelectScrollUpButton,\n  SelectScrollDownButton,\n}\n"
  },
  {
    "path": "src/components/ui/table.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Table = React.forwardRef<\n  HTMLTableElement,\n  React.HTMLAttributes<HTMLTableElement>\n>(({ className, ...props }, ref) => (\n  <div className=\"relative w-full overflow-auto\">\n    <table\n      ref={ref}\n      className={cn(\"w-full caption-bottom text-sm\", className)}\n      {...props}\n    />\n  </div>\n))\nTable.displayName = \"Table\"\n\nconst TableHeader = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <thead ref={ref} className={cn(\"[&_tr]:border-b\", className)} {...props} />\n))\nTableHeader.displayName = \"TableHeader\"\n\nconst TableBody = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tbody\n    ref={ref}\n    className={cn(\"[&_tr:last-child]:border-0\", className)}\n    {...props}\n  />\n))\nTableBody.displayName = \"TableBody\"\n\nconst TableFooter = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tfoot\n    ref={ref}\n    className={cn(\n      \"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0\",\n      className\n    )}\n    {...props}\n  />\n))\nTableFooter.displayName = \"TableFooter\"\n\nconst TableRow = React.forwardRef<\n  HTMLTableRowElement,\n  React.HTMLAttributes<HTMLTableRowElement>\n>(({ className, ...props }, ref) => (\n  <tr\n    ref={ref}\n    className={cn(\n      \"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted\",\n      className\n    )}\n    {...props}\n  />\n))\nTableRow.displayName = \"TableRow\"\n\nconst TableHead = React.forwardRef<\n  HTMLTableCellElement,\n  React.ThHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <th\n    ref={ref}\n    className={cn(\n      \"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\",\n      className\n    )}\n    {...props}\n  />\n))\nTableHead.displayName = \"TableHead\"\n\nconst TableCell = React.forwardRef<\n  HTMLTableCellElement,\n  React.TdHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <td\n    ref={ref}\n    className={cn(\n      \"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\",\n      className\n    )}\n    {...props}\n  />\n))\nTableCell.displayName = \"TableCell\"\n\nconst TableCaption = React.forwardRef<\n  HTMLTableCaptionElement,\n  React.HTMLAttributes<HTMLTableCaptionElement>\n>(({ className, ...props }, ref) => (\n  <caption\n    ref={ref}\n    className={cn(\"mt-4 text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nTableCaption.displayName = \"TableCaption\"\n\nexport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption,\n}\n"
  },
  {
    "path": "src/components/ui/tabs.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Tabs = TabsPrimitive.Root\n\nconst TabsList = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.List\n    ref={ref}\n    className={cn(\n      \"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground\",\n      className\n    )}\n    {...props}\n  />\n))\nTabsList.displayName = TabsPrimitive.List.displayName\n\nconst TabsTrigger = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow\",\n      className\n    )}\n    {...props}\n  />\n))\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName\n\nconst TabsContent = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\n      \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n      className\n    )}\n    {...props}\n  />\n))\nTabsContent.displayName = TabsPrimitive.Content.displayName\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent }\n"
  },
  {
    "path": "src/components/ui/textarea.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Textarea = React.forwardRef<\n  HTMLTextAreaElement,\n  React.ComponentProps<\"textarea\">\n>(({ className, ...props }, ref) => {\n  return (\n    <textarea\n      className={cn(\n        \"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        className\n      )}\n      ref={ref}\n      {...props}\n    />\n  )\n})\nTextarea.displayName = \"Textarea\"\n\nexport { Textarea }\n"
  },
  {
    "path": "src/components/ui/toast.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ToastPrimitives from \"@radix-ui/react-toast\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ToastProvider = ToastPrimitives.Provider\n\nconst ToastViewport = React.forwardRef<\n  React.ElementRef<typeof ToastPrimitives.Viewport>,\n  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>\n>(({ className, ...props }, ref) => (\n  <ToastPrimitives.Viewport\n    ref={ref}\n    className={cn(\n      \"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]\",\n      className\n    )}\n    {...props}\n  />\n))\nToastViewport.displayName = ToastPrimitives.Viewport.displayName\n\nconst toastVariants = cva(\n  \"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full\",\n  {\n    variants: {\n      variant: {\n        default: \"border bg-background text-foreground\",\n        destructive:\n          \"destructive group border-destructive bg-destructive text-destructive-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nconst Toast = React.forwardRef<\n  React.ElementRef<typeof ToastPrimitives.Root>,\n  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &\n    VariantProps<typeof toastVariants>\n>(({ className, variant, ...props }, ref) => {\n  return (\n    <ToastPrimitives.Root\n      ref={ref}\n      className={cn(toastVariants({ variant }), className)}\n      {...props}\n    />\n  )\n})\nToast.displayName = ToastPrimitives.Root.displayName\n\nconst ToastAction = React.forwardRef<\n  React.ElementRef<typeof ToastPrimitives.Action>,\n  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>\n>(({ className, ...props }, ref) => (\n  <ToastPrimitives.Action\n    ref={ref}\n    className={cn(\n      \"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive\",\n      className\n    )}\n    {...props}\n  />\n))\nToastAction.displayName = ToastPrimitives.Action.displayName\n\nconst ToastClose = React.forwardRef<\n  React.ElementRef<typeof ToastPrimitives.Close>,\n  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>\n>(({ className, ...props }, ref) => (\n  <ToastPrimitives.Close\n    ref={ref}\n    className={cn(\n      \"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600\",\n      className\n    )}\n    toast-close=\"\"\n    {...props}\n  >\n    <X className=\"h-4 w-4\" />\n  </ToastPrimitives.Close>\n))\nToastClose.displayName = ToastPrimitives.Close.displayName\n\nconst ToastTitle = React.forwardRef<\n  React.ElementRef<typeof ToastPrimitives.Title>,\n  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>\n>(({ className, ...props }, ref) => (\n  <ToastPrimitives.Title\n    ref={ref}\n    className={cn(\"text-sm font-semibold [&+div]:text-xs\", className)}\n    {...props}\n  />\n))\nToastTitle.displayName = ToastPrimitives.Title.displayName\n\nconst ToastDescription = React.forwardRef<\n  React.ElementRef<typeof ToastPrimitives.Description>,\n  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>\n>(({ className, ...props }, ref) => (\n  <ToastPrimitives.Description\n    ref={ref}\n    className={cn(\"text-sm opacity-90\", className)}\n    {...props}\n  />\n))\nToastDescription.displayName = ToastPrimitives.Description.displayName\n\ntype ToastProps = React.ComponentPropsWithoutRef<typeof Toast>\n\ntype ToastActionElement = React.ReactElement<typeof ToastAction>\n\nexport {\n  type ToastProps,\n  type ToastActionElement,\n  ToastProvider,\n  ToastViewport,\n  Toast,\n  ToastTitle,\n  ToastDescription,\n  ToastClose,\n  ToastAction,\n}\n"
  },
  {
    "path": "src/components/ui/toaster.tsx",
    "content": "\"use client\"\n\nimport { useToast } from \"@/hooks/use-toast\"\nimport {\n  Toast,\n  ToastClose,\n  ToastDescription,\n  ToastProvider,\n  ToastTitle,\n  ToastViewport,\n} from \"@/components/ui/toast\"\n\nexport function Toaster() {\n  const { toasts } = useToast()\n\n  return (\n    <ToastProvider>\n      {toasts.map(function ({ id, title, description, action, ...props }) {\n        return (\n          <Toast key={id} {...props}>\n            <div className=\"grid gap-1\">\n              {title && <ToastTitle>{title}</ToastTitle>}\n              {description && (\n                <ToastDescription>{description}</ToastDescription>\n              )}\n            </div>\n            {action}\n            <ToastClose />\n          </Toast>\n        )\n      })}\n      <ToastViewport />\n    </ToastProvider>\n  )\n}\n"
  },
  {
    "path": "src/data/env/client.ts",
    "content": "import { createEnv } from \"@t3-oss/env-nextjs\"\nimport { z } from \"zod\"\n\nexport const env = createEnv({\n  client: {\n    NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),\n    NEXT_PUBLIC_CLERK_SIGN_IN_URL: z.string().min(1),\n    NEXT_PUBLIC_CLERK_SIGN_UP_URL: z.string().min(1),\n    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),\n    NEXT_PUBLIC_SERVER_URL: z.string().min(1),\n  },\n  experimental__runtimeEnv: {\n    NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:\n      process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,\n    NEXT_PUBLIC_CLERK_SIGN_IN_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL,\n    NEXT_PUBLIC_CLERK_SIGN_UP_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL,\n    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:\n      process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,\n    NEXT_PUBLIC_SERVER_URL: process.env.NEXT_PUBLIC_SERVER_URL,\n  },\n})\n"
  },
  {
    "path": "src/data/env/server.ts",
    "content": "import { createEnv } from \"@t3-oss/env-nextjs\"\nimport { z } from \"zod\"\n\nexport const env = createEnv({\n  server: {\n    DB_PASSWORD: z.string().min(1),\n    DB_USER: z.string().min(1),\n    DB_NAME: z.string().min(1),\n    DB_HOST: z.string().min(1),\n    CLERK_SECRET_KEY: z.string().min(1),\n    CLERK_WEBHOOK_SECRET: z.string().min(1),\n    ARCJET_KEY: z.string().min(1),\n    TEST_IP_ADDRESS: z.string().min(1).optional(),\n    STRIPE_PPP_50_COUPON_ID: z.string().min(1),\n    STRIPE_PPP_40_COUPON_ID: z.string().min(1),\n    STRIPE_PPP_30_COUPON_ID: z.string().min(1),\n    STRIPE_PPP_20_COUPON_ID: z.string().min(1),\n    STRIPE_SECRET_KEY: z.string().min(1),\n    STRIPE_WEBHOOK_SECRET: z.string().min(1),\n  },\n  experimental__runtimeEnv: process.env,\n})\n"
  },
  {
    "path": "src/data/pppCoupons.ts",
    "content": "import { env } from \"./env/server\"\n\nexport const pppCoupons = [\n  {\n    stripeCouponId: env.STRIPE_PPP_50_COUPON_ID,\n    discountPercentage: 0.5,\n    countryCodes: [\n      \"AF\",\n      \"EG\",\n      \"IR\",\n      \"KG\",\n      \"LK\",\n      \"BT\",\n      \"LA\",\n      \"LB\",\n      \"LY\",\n      \"MM\",\n      \"PK\",\n      \"SL\",\n      \"TJ\",\n      \"NP\",\n      \"UZ\",\n      \"SD\",\n      \"IN\",\n      \"MG\",\n      \"TR\",\n      \"AL\",\n      \"BA\",\n      \"CM\",\n      \"BD\",\n      \"BF\",\n      \"BJ\",\n      \"JO\",\n      \"BI\",\n      \"CO\",\n      \"CI\",\n      \"FJ\",\n      \"ET\",\n      \"GE\",\n      \"KM\",\n      \"LS\",\n      \"KH\",\n      \"AM\",\n      \"BO\",\n      \"BY\",\n      \"DZ\",\n      \"ER\",\n      \"GH\",\n      \"GM\",\n      \"GW\",\n      \"ID\",\n      \"KE\",\n      \"KZ\",\n      \"MD\",\n      \"MK\",\n      \"ML\",\n      \"MW\",\n      \"MY\",\n      \"MZ\",\n      \"NG\",\n      \"NI\",\n      \"PH\",\n      \"PY\",\n      \"RW\",\n      \"TH\",\n      \"TZ\",\n      \"UA\",\n      \"UG\",\n      \"VN\",\n      \"MN\",\n      \"MR\",\n      \"MU\",\n      \"SO\",\n      \"TN\",\n      \"ZM\",\n      \"ME\",\n      \"RO\",\n      \"RS\",\n      \"SN\",\n      \"MA\",\n      \"NE\",\n      \"SR\",\n      \"SZ\",\n      \"TG\",\n      \"EC\",\n      \"BG\",\n      \"HR\",\n      \"BW\",\n      \"AO\",\n      \"AZ\",\n      \"CF\",\n      \"CV\",\n      \"GY\",\n      \"HU\",\n      \"GQ\",\n      \"HN\",\n      \"BH\",\n      \"CD\",\n      \"DO\",\n      \"GN\",\n      \"LR\",\n      \"PA\",\n      \"NA\",\n      \"PE\",\n      \"PL\",\n      \"SC\",\n      \"SV\",\n      \"TW\",\n      \"MV\",\n      \"TD\",\n      \"YE\",\n      \"ZA\",\n      \"RU\",\n    ],\n  },\n  {\n    stripeCouponId: env.STRIPE_PPP_40_COUPON_ID,\n    discountPercentage: 0.4,\n    countryCodes: [\n      \"GR\",\n      \"KN\",\n      \"AR\",\n      \"BR\",\n      \"CN\",\n      \"DJ\",\n      \"IQ\",\n      \"JM\",\n      \"GT\",\n      \"LT\",\n      \"CL\",\n      \"CR\",\n      \"CZ\",\n      \"GA\",\n      \"GD\",\n      \"HT\",\n      \"LV\",\n      \"ST\",\n      \"VC\",\n      \"PT\",\n      \"MX\",\n      \"SA\",\n      \"SI\",\n      \"SK\",\n      \"TM\",\n      \"BN\",\n      \"MO\",\n      \"TL\",\n    ],\n  },\n  {\n    stripeCouponId: env.STRIPE_PPP_30_COUPON_ID,\n    discountPercentage: 0.3,\n    countryCodes: [\n      \"AE\",\n      \"ES\",\n      \"AW\",\n      \"CY\",\n      \"EE\",\n      \"IT\",\n      \"KR\",\n      \"BZ\",\n      \"CG\",\n      \"MT\",\n      \"SG\",\n      \"DM\",\n      \"TO\",\n      \"VE\",\n      \"WS\",\n      \"OM\",\n      \"ZW\",\n    ],\n  },\n  {\n    stripeCouponId: env.STRIPE_PPP_20_COUPON_ID,\n    discountPercentage: 0.2,\n    countryCodes: [\n      \"AT\",\n      \"JP\",\n      \"BE\",\n      \"BS\",\n      \"DE\",\n      \"FR\",\n      \"KI\",\n      \"KW\",\n      \"HK\",\n      \"LC\",\n      \"AG\",\n      \"QA\",\n      \"PG\",\n      \"TT\",\n      \"UY\",\n    ],\n  },\n]\n"
  },
  {
    "path": "src/data/typeOverrides/clerk.d.ts",
    "content": "import { UserRole } from \"@/drizzle/schema\"\n\nexport {}\n\ndeclare global {\n  interface CustomJwtSessionClaims {\n    dbId?: string\n    role?: UserRole\n  }\n\n  interface UserPublicMetadata {\n    dbId?: string\n    role?: UserRole\n  }\n}\n"
  },
  {
    "path": "src/drizzle/db.ts",
    "content": "import { env } from \"@/data/env/server\"\nimport { drizzle } from \"drizzle-orm/node-postgres\"\nimport * as schema from \"./schema\"\n\nexport const db = drizzle({\n  schema,\n  connection: {\n    password: env.DB_PASSWORD,\n    user: env.DB_USER,\n    database: env.DB_NAME,\n    host: env.DB_HOST,\n  },\n})\n"
  },
  {
    "path": "src/drizzle/migrations/0000_orange_wind_dancer.sql",
    "content": "CREATE TYPE \"public\".\"course_section_status\" AS ENUM('public', 'private');--> statement-breakpoint\nCREATE TYPE \"public\".\"lesson_status\" AS ENUM('public', 'private', 'preview');--> statement-breakpoint\nCREATE TYPE \"public\".\"product_status\" AS ENUM('public', 'private');--> statement-breakpoint\nCREATE TYPE \"public\".\"user_role\" AS ENUM('user', 'admin');--> statement-breakpoint\nCREATE TABLE \"courses\" (\n\t\"id\" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,\n\t\"name\" text NOT NULL,\n\t\"description\" text NOT NULL,\n\t\"createdAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updatedAt\" timestamp with time zone DEFAULT now() NOT NULL\n);\n--> statement-breakpoint\nCREATE TABLE \"course_products\" (\n\t\"courseId\" uuid NOT NULL,\n\t\"productId\" uuid NOT NULL,\n\t\"createdAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updatedAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\tCONSTRAINT \"course_products_courseId_productId_pk\" PRIMARY KEY(\"courseId\",\"productId\")\n);\n--> statement-breakpoint\nCREATE TABLE \"course_sections\" (\n\t\"id\" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,\n\t\"name\" text NOT NULL,\n\t\"status\" \"course_section_status\" DEFAULT 'private' NOT NULL,\n\t\"order\" integer NOT NULL,\n\t\"courseId\" uuid NOT NULL,\n\t\"createdAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updatedAt\" timestamp with time zone DEFAULT now() NOT NULL\n);\n--> statement-breakpoint\nCREATE TABLE \"lessons\" (\n\t\"id\" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,\n\t\"name\" text NOT NULL,\n\t\"description\" text,\n\t\"youtubeVideoId\" text NOT NULL,\n\t\"order\" integer NOT NULL,\n\t\"status\" \"lesson_status\" DEFAULT 'private' NOT NULL,\n\t\"sectionId\" uuid NOT NULL,\n\t\"createdAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updatedAt\" timestamp with time zone DEFAULT now() NOT NULL\n);\n--> statement-breakpoint\nCREATE TABLE \"products\" (\n\t\"id\" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,\n\t\"name\" text NOT NULL,\n\t\"description\" text NOT NULL,\n\t\"imageUrl\" text NOT NULL,\n\t\"priceInDollars\" integer NOT NULL,\n\t\"status\" \"product_status\" DEFAULT 'private' NOT NULL,\n\t\"createdAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updatedAt\" timestamp with time zone DEFAULT now() NOT NULL\n);\n--> statement-breakpoint\nCREATE TABLE \"purchases\" (\n\t\"id\" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,\n\t\"pricePaidInCents\" integer NOT NULL,\n\t\"productDetails\" jsonb NOT NULL,\n\t\"userId\" uuid NOT NULL,\n\t\"productId\" uuid NOT NULL,\n\t\"stripeSessionId\" text NOT NULL,\n\t\"refundedAt\" timestamp with time zone,\n\t\"createdAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updatedAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\tCONSTRAINT \"purchases_stripeSessionId_unique\" UNIQUE(\"stripeSessionId\")\n);\n--> statement-breakpoint\nCREATE TABLE \"users\" (\n\t\"id\" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,\n\t\"clerkUserId\" text NOT NULL,\n\t\"email\" text NOT NULL,\n\t\"name\" text NOT NULL,\n\t\"role\" \"user_role\" DEFAULT 'user' NOT NULL,\n\t\"imageUrl\" text,\n\t\"deletedAt\" timestamp with time zone,\n\t\"createdAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updatedAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\tCONSTRAINT \"users_clerkUserId_unique\" UNIQUE(\"clerkUserId\")\n);\n--> statement-breakpoint\nCREATE TABLE \"user_course_access\" (\n\t\"userId\" uuid NOT NULL,\n\t\"courseId\" uuid NOT NULL,\n\t\"createdAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updatedAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\tCONSTRAINT \"user_course_access_userId_courseId_pk\" PRIMARY KEY(\"userId\",\"courseId\")\n);\n--> statement-breakpoint\nCREATE TABLE \"user_lesson_complete\" (\n\t\"userId\" uuid NOT NULL,\n\t\"lessonId\" uuid NOT NULL,\n\t\"createdAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updatedAt\" timestamp with time zone DEFAULT now() NOT NULL,\n\tCONSTRAINT \"user_lesson_complete_userId_lessonId_pk\" PRIMARY KEY(\"userId\",\"lessonId\")\n);\n--> statement-breakpoint\nALTER TABLE \"course_products\" ADD CONSTRAINT \"course_products_courseId_courses_id_fk\" FOREIGN KEY (\"courseId\") REFERENCES \"public\".\"courses\"(\"id\") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint\nALTER TABLE \"course_products\" ADD CONSTRAINT \"course_products_productId_products_id_fk\" FOREIGN KEY (\"productId\") REFERENCES \"public\".\"products\"(\"id\") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint\nALTER TABLE \"course_sections\" ADD CONSTRAINT \"course_sections_courseId_courses_id_fk\" FOREIGN KEY (\"courseId\") REFERENCES \"public\".\"courses\"(\"id\") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint\nALTER TABLE \"lessons\" ADD CONSTRAINT \"lessons_sectionId_course_sections_id_fk\" FOREIGN KEY (\"sectionId\") REFERENCES \"public\".\"course_sections\"(\"id\") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint\nALTER TABLE \"purchases\" ADD CONSTRAINT \"purchases_userId_users_id_fk\" FOREIGN KEY (\"userId\") REFERENCES \"public\".\"users\"(\"id\") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint\nALTER TABLE \"purchases\" ADD CONSTRAINT \"purchases_productId_products_id_fk\" FOREIGN KEY (\"productId\") REFERENCES \"public\".\"products\"(\"id\") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint\nALTER TABLE \"user_course_access\" ADD CONSTRAINT \"user_course_access_userId_users_id_fk\" FOREIGN KEY (\"userId\") REFERENCES \"public\".\"users\"(\"id\") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint\nALTER TABLE \"user_course_access\" ADD CONSTRAINT \"user_course_access_courseId_courses_id_fk\" FOREIGN KEY (\"courseId\") REFERENCES \"public\".\"courses\"(\"id\") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint\nALTER TABLE \"user_lesson_complete\" ADD CONSTRAINT \"user_lesson_complete_userId_users_id_fk\" FOREIGN KEY (\"userId\") REFERENCES \"public\".\"users\"(\"id\") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint\nALTER TABLE \"user_lesson_complete\" ADD CONSTRAINT \"user_lesson_complete_lessonId_lessons_id_fk\" FOREIGN KEY (\"lessonId\") REFERENCES \"public\".\"lessons\"(\"id\") ON DELETE cascade ON UPDATE no action;"
  },
  {
    "path": "src/drizzle/migrations/meta/0000_snapshot.json",
    "content": "{\n  \"id\": \"d4730870-f365-4597-857f-fb8168440798\",\n  \"prevId\": \"00000000-0000-0000-0000-000000000000\",\n  \"version\": \"7\",\n  \"dialect\": \"postgresql\",\n  \"tables\": {\n    \"public.courses\": {\n      \"name\": \"courses\",\n      \"schema\": \"\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"uuid\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"default\": \"gen_random_uuid()\"\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"policies\": {},\n      \"checkConstraints\": {},\n      \"isRLSEnabled\": false\n    },\n    \"public.course_products\": {\n      \"name\": \"course_products\",\n      \"schema\": \"\",\n      \"columns\": {\n        \"courseId\": {\n          \"name\": \"courseId\",\n          \"type\": \"uuid\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"productId\": {\n          \"name\": \"productId\",\n          \"type\": \"uuid\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {\n        \"course_products_courseId_courses_id_fk\": {\n          \"name\": \"course_products_courseId_courses_id_fk\",\n          \"tableFrom\": \"course_products\",\n          \"tableTo\": \"courses\",\n          \"columnsFrom\": [\n            \"courseId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"restrict\",\n          \"onUpdate\": \"no action\"\n        },\n        \"course_products_productId_products_id_fk\": {\n          \"name\": \"course_products_productId_products_id_fk\",\n          \"tableFrom\": \"course_products\",\n          \"tableTo\": \"products\",\n          \"columnsFrom\": [\n            \"productId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {\n        \"course_products_courseId_productId_pk\": {\n          \"name\": \"course_products_courseId_productId_pk\",\n          \"columns\": [\n            \"courseId\",\n            \"productId\"\n          ]\n        }\n      },\n      \"uniqueConstraints\": {},\n      \"policies\": {},\n      \"checkConstraints\": {},\n      \"isRLSEnabled\": false\n    },\n    \"public.course_sections\": {\n      \"name\": \"course_sections\",\n      \"schema\": \"\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"uuid\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"default\": \"gen_random_uuid()\"\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"course_section_status\",\n          \"typeSchema\": \"public\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"'private'\"\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"courseId\": {\n          \"name\": \"courseId\",\n          \"type\": \"uuid\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {\n        \"course_sections_courseId_courses_id_fk\": {\n          \"name\": \"course_sections_courseId_courses_id_fk\",\n          \"tableFrom\": \"course_sections\",\n          \"tableTo\": \"courses\",\n          \"columnsFrom\": [\n            \"courseId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"policies\": {},\n      \"checkConstraints\": {},\n      \"isRLSEnabled\": false\n    },\n    \"public.lessons\": {\n      \"name\": \"lessons\",\n      \"schema\": \"\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"uuid\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"default\": \"gen_random_uuid()\"\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false\n        },\n        \"youtubeVideoId\": {\n          \"name\": \"youtubeVideoId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"lesson_status\",\n          \"typeSchema\": \"public\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"'private'\"\n        },\n        \"sectionId\": {\n          \"name\": \"sectionId\",\n          \"type\": \"uuid\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {\n        \"lessons_sectionId_course_sections_id_fk\": {\n          \"name\": \"lessons_sectionId_course_sections_id_fk\",\n          \"tableFrom\": \"lessons\",\n          \"tableTo\": \"course_sections\",\n          \"columnsFrom\": [\n            \"sectionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"policies\": {},\n      \"checkConstraints\": {},\n      \"isRLSEnabled\": false\n    },\n    \"public.products\": {\n      \"name\": \"products\",\n      \"schema\": \"\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"uuid\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"default\": \"gen_random_uuid()\"\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"description\": {\n          \"name\": \"description\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"imageUrl\": {\n          \"name\": \"imageUrl\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"priceInDollars\": {\n          \"name\": \"priceInDollars\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"status\": {\n          \"name\": \"status\",\n          \"type\": \"product_status\",\n          \"typeSchema\": \"public\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"'private'\"\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"policies\": {},\n      \"checkConstraints\": {},\n      \"isRLSEnabled\": false\n    },\n    \"public.purchases\": {\n      \"name\": \"purchases\",\n      \"schema\": \"\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"uuid\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"default\": \"gen_random_uuid()\"\n        },\n        \"pricePaidInCents\": {\n          \"name\": \"pricePaidInCents\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"productDetails\": {\n          \"name\": \"productDetails\",\n          \"type\": \"jsonb\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"userId\": {\n          \"name\": \"userId\",\n          \"type\": \"uuid\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"productId\": {\n          \"name\": \"productId\",\n          \"type\": \"uuid\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"stripeSessionId\": {\n          \"name\": \"stripeSessionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"refundedAt\": {\n          \"name\": \"refundedAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {\n        \"purchases_userId_users_id_fk\": {\n          \"name\": \"purchases_userId_users_id_fk\",\n          \"tableFrom\": \"purchases\",\n          \"tableTo\": \"users\",\n          \"columnsFrom\": [\n            \"userId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"restrict\",\n          \"onUpdate\": \"no action\"\n        },\n        \"purchases_productId_products_id_fk\": {\n          \"name\": \"purchases_productId_products_id_fk\",\n          \"tableFrom\": \"purchases\",\n          \"tableTo\": \"products\",\n          \"columnsFrom\": [\n            \"productId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"restrict\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {\n        \"purchases_stripeSessionId_unique\": {\n          \"name\": \"purchases_stripeSessionId_unique\",\n          \"nullsNotDistinct\": false,\n          \"columns\": [\n            \"stripeSessionId\"\n          ]\n        }\n      },\n      \"policies\": {},\n      \"checkConstraints\": {},\n      \"isRLSEnabled\": false\n    },\n    \"public.users\": {\n      \"name\": \"users\",\n      \"schema\": \"\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"uuid\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"default\": \"gen_random_uuid()\"\n        },\n        \"clerkUserId\": {\n          \"name\": \"clerkUserId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"email\": {\n          \"name\": \"email\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"role\": {\n          \"name\": \"role\",\n          \"type\": \"user_role\",\n          \"typeSchema\": \"public\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"'user'\"\n        },\n        \"imageUrl\": {\n          \"name\": \"imageUrl\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false\n        },\n        \"deletedAt\": {\n          \"name\": \"deletedAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {\n        \"users_clerkUserId_unique\": {\n          \"name\": \"users_clerkUserId_unique\",\n          \"nullsNotDistinct\": false,\n          \"columns\": [\n            \"clerkUserId\"\n          ]\n        }\n      },\n      \"policies\": {},\n      \"checkConstraints\": {},\n      \"isRLSEnabled\": false\n    },\n    \"public.user_course_access\": {\n      \"name\": \"user_course_access\",\n      \"schema\": \"\",\n      \"columns\": {\n        \"userId\": {\n          \"name\": \"userId\",\n          \"type\": \"uuid\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"courseId\": {\n          \"name\": \"courseId\",\n          \"type\": \"uuid\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {\n        \"user_course_access_userId_users_id_fk\": {\n          \"name\": \"user_course_access_userId_users_id_fk\",\n          \"tableFrom\": \"user_course_access\",\n          \"tableTo\": \"users\",\n          \"columnsFrom\": [\n            \"userId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"user_course_access_courseId_courses_id_fk\": {\n          \"name\": \"user_course_access_courseId_courses_id_fk\",\n          \"tableFrom\": \"user_course_access\",\n          \"tableTo\": \"courses\",\n          \"columnsFrom\": [\n            \"courseId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {\n        \"user_course_access_userId_courseId_pk\": {\n          \"name\": \"user_course_access_userId_courseId_pk\",\n          \"columns\": [\n            \"userId\",\n            \"courseId\"\n          ]\n        }\n      },\n      \"uniqueConstraints\": {},\n      \"policies\": {},\n      \"checkConstraints\": {},\n      \"isRLSEnabled\": false\n    },\n    \"public.user_lesson_complete\": {\n      \"name\": \"user_lesson_complete\",\n      \"schema\": \"\",\n      \"columns\": {\n        \"userId\": {\n          \"name\": \"userId\",\n          \"type\": \"uuid\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"lessonId\": {\n          \"name\": \"lessonId\",\n          \"type\": \"uuid\",\n          \"primaryKey\": false,\n          \"notNull\": true\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"timestamp with time zone\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"default\": \"now()\"\n        }\n      },\n      \"indexes\": {},\n      \"foreignKeys\": {\n        \"user_lesson_complete_userId_users_id_fk\": {\n          \"name\": \"user_lesson_complete_userId_users_id_fk\",\n          \"tableFrom\": \"user_lesson_complete\",\n          \"tableTo\": \"users\",\n          \"columnsFrom\": [\n            \"userId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"user_lesson_complete_lessonId_lessons_id_fk\": {\n          \"name\": \"user_lesson_complete_lessonId_lessons_id_fk\",\n          \"tableFrom\": \"user_lesson_complete\",\n          \"tableTo\": \"lessons\",\n          \"columnsFrom\": [\n            \"lessonId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {\n        \"user_lesson_complete_userId_lessonId_pk\": {\n          \"name\": \"user_lesson_complete_userId_lessonId_pk\",\n          \"columns\": [\n            \"userId\",\n            \"lessonId\"\n          ]\n        }\n      },\n      \"uniqueConstraints\": {},\n      \"policies\": {},\n      \"checkConstraints\": {},\n      \"isRLSEnabled\": false\n    }\n  },\n  \"enums\": {\n    \"public.course_section_status\": {\n      \"name\": \"course_section_status\",\n      \"schema\": \"public\",\n      \"values\": [\n        \"public\",\n        \"private\"\n      ]\n    },\n    \"public.lesson_status\": {\n      \"name\": \"lesson_status\",\n      \"schema\": \"public\",\n      \"values\": [\n        \"public\",\n        \"private\",\n        \"preview\"\n      ]\n    },\n    \"public.product_status\": {\n      \"name\": \"product_status\",\n      \"schema\": \"public\",\n      \"values\": [\n        \"public\",\n        \"private\"\n      ]\n    },\n    \"public.user_role\": {\n      \"name\": \"user_role\",\n      \"schema\": \"public\",\n      \"values\": [\n        \"user\",\n        \"admin\"\n      ]\n    }\n  },\n  \"schemas\": {},\n  \"sequences\": {},\n  \"roles\": {},\n  \"policies\": {},\n  \"views\": {},\n  \"_meta\": {\n    \"columns\": {},\n    \"schemas\": {},\n    \"tables\": {}\n  }\n}"
  },
  {
    "path": "src/drizzle/migrations/meta/_journal.json",
    "content": "{\n  \"version\": \"7\",\n  \"dialect\": \"postgresql\",\n  \"entries\": [\n    {\n      \"idx\": 0,\n      \"version\": \"7\",\n      \"when\": 1736952636321,\n      \"tag\": \"0000_orange_wind_dancer\",\n      \"breakpoints\": true\n    }\n  ]\n}"
  },
  {
    "path": "src/drizzle/schema/course.ts",
    "content": "import { relations } from \"drizzle-orm\"\nimport { pgTable, text } from \"drizzle-orm/pg-core\"\nimport { createdAt, id, updatedAt } from \"../schemaHelpers\"\nimport { CourseProductTable } from \"./courseProduct\"\nimport { UserCourseAccessTable } from \"./userCourseAccess\"\nimport { CourseSectionTable } from \"./courseSection\"\n\nexport const CourseTable = pgTable(\"courses\", {\n  id,\n  name: text().notNull(),\n  description: text().notNull(),\n  createdAt,\n  updatedAt,\n})\n\nexport const CourseRelationships = relations(CourseTable, ({ many }) => ({\n  courseProducts: many(CourseProductTable),\n  userCourseAccesses: many(UserCourseAccessTable),\n  courseSections: many(CourseSectionTable),\n}))\n"
  },
  {
    "path": "src/drizzle/schema/courseProduct.ts",
    "content": "import { pgTable, primaryKey, uuid } from \"drizzle-orm/pg-core\"\nimport { CourseTable } from \"./course\"\nimport { ProductTable } from \"./product\"\nimport { createdAt, updatedAt } from \"../schemaHelpers\"\nimport { relations } from \"drizzle-orm\"\n\nexport const CourseProductTable = pgTable(\n  \"course_products\",\n  {\n    courseId: uuid()\n      .notNull()\n      .references(() => CourseTable.id, { onDelete: \"restrict\" }),\n    productId: uuid()\n      .notNull()\n      .references(() => ProductTable.id, { onDelete: \"cascade\" }),\n    createdAt,\n    updatedAt,\n  },\n  t => [primaryKey({ columns: [t.courseId, t.productId] })]\n)\n\nexport const CourseProductRelationships = relations(\n  CourseProductTable,\n  ({ one }) => ({\n    course: one(CourseTable, {\n      fields: [CourseProductTable.courseId],\n      references: [CourseTable.id],\n    }),\n    product: one(ProductTable, {\n      fields: [CourseProductTable.productId],\n      references: [ProductTable.id],\n    }),\n  })\n)\n"
  },
  {
    "path": "src/drizzle/schema/courseSection.ts",
    "content": "import { integer, pgEnum, pgTable, text, uuid } from \"drizzle-orm/pg-core\"\nimport { createdAt, id, updatedAt } from \"../schemaHelpers\"\nimport { CourseTable } from \"./course\"\nimport { relations } from \"drizzle-orm\"\nimport { LessonTable } from \"./lesson\"\n\nexport const courseSectionStatuses = [\"public\", \"private\"] as const\nexport type CourseSectionStatus = (typeof courseSectionStatuses)[number]\nexport const courseSectionStatusEnum = pgEnum(\n  \"course_section_status\",\n  courseSectionStatuses\n)\n\nexport const CourseSectionTable = pgTable(\"course_sections\", {\n  id,\n  name: text().notNull(),\n  status: courseSectionStatusEnum().notNull().default(\"private\"),\n  order: integer().notNull(),\n  courseId: uuid()\n    .notNull()\n    .references(() => CourseTable.id, { onDelete: \"cascade\" }),\n  createdAt,\n  updatedAt,\n})\n\nexport const CourseSectionRelationships = relations(\n  CourseSectionTable,\n  ({ many, one }) => ({\n    course: one(CourseTable, {\n      fields: [CourseSectionTable.courseId],\n      references: [CourseTable.id],\n    }),\n    lessons: many(LessonTable),\n  })\n)\n"
  },
  {
    "path": "src/drizzle/schema/lesson.ts",
    "content": "import { pgTable, text, uuid, integer, pgEnum } from \"drizzle-orm/pg-core\"\nimport { createdAt, id, updatedAt } from \"../schemaHelpers\"\nimport { relations } from \"drizzle-orm\"\nimport { CourseSectionTable } from \"./courseSection\"\nimport { UserLessonCompleteTable } from \"./userLessonComplete\"\n\nexport const lessonStatuses = [\"public\", \"private\", \"preview\"] as const\nexport type LessonStatus = (typeof lessonStatuses)[number]\nexport const lessonStatusEnum = pgEnum(\"lesson_status\", lessonStatuses)\n\nexport const LessonTable = pgTable(\"lessons\", {\n  id,\n  name: text().notNull(),\n  description: text(),\n  youtubeVideoId: text().notNull(),\n  order: integer().notNull(),\n  status: lessonStatusEnum().notNull().default(\"private\"),\n  sectionId: uuid()\n    .notNull()\n    .references(() => CourseSectionTable.id, { onDelete: \"cascade\" }),\n  createdAt,\n  updatedAt,\n})\n\nexport const LessonRelationships = relations(LessonTable, ({ one, many }) => ({\n  section: one(CourseSectionTable, {\n    fields: [LessonTable.sectionId],\n    references: [CourseSectionTable.id],\n  }),\n  userLessonsComplete: many(UserLessonCompleteTable),\n}))\n"
  },
  {
    "path": "src/drizzle/schema/product.ts",
    "content": "import { relations } from \"drizzle-orm\"\nimport { pgTable, text, integer, pgEnum } from \"drizzle-orm/pg-core\"\nimport { createdAt, id, updatedAt } from \"../schemaHelpers\"\nimport { CourseProductTable } from \"./courseProduct\"\n\nexport const productStatuses = [\"public\", \"private\"] as const\nexport type ProductStatus = (typeof productStatuses)[number]\nexport const productStatusEnum = pgEnum(\"product_status\", productStatuses)\n\nexport const ProductTable = pgTable(\"products\", {\n  id,\n  name: text().notNull(),\n  description: text().notNull(),\n  imageUrl: text().notNull(),\n  priceInDollars: integer().notNull(),\n  status: productStatusEnum().notNull().default(\"private\"),\n  createdAt,\n  updatedAt,\n})\n\nexport const ProductRelationships = relations(ProductTable, ({ many }) => ({\n  courseProducts: many(CourseProductTable),\n}))\n"
  },
  {
    "path": "src/drizzle/schema/purchase.ts",
    "content": "import {\n  pgTable,\n  integer,\n  jsonb,\n  uuid,\n  text,\n  timestamp,\n} from \"drizzle-orm/pg-core\"\nimport { createdAt, id, updatedAt } from \"../schemaHelpers\"\nimport { relations } from \"drizzle-orm\"\nimport { UserTable } from \"./user\"\nimport { ProductTable } from \"./product\"\n\nexport const PurchaseTable = pgTable(\"purchases\", {\n  id,\n  pricePaidInCents: integer().notNull(),\n  productDetails: jsonb()\n    .notNull()\n    .$type<{ name: string; description: string; imageUrl: string }>(),\n  userId: uuid()\n    .notNull()\n    .references(() => UserTable.id, { onDelete: \"restrict\" }),\n  productId: uuid()\n    .notNull()\n    .references(() => ProductTable.id, { onDelete: \"restrict\" }),\n  stripeSessionId: text().notNull().unique(),\n  refundedAt: timestamp({ withTimezone: true }),\n  createdAt,\n  updatedAt,\n})\n\nexport const PurchaseRelationships = relations(PurchaseTable, ({ one }) => ({\n  user: one(UserTable, {\n    fields: [PurchaseTable.userId],\n    references: [UserTable.id],\n  }),\n  product: one(ProductTable, {\n    fields: [PurchaseTable.productId],\n    references: [ProductTable.id],\n  }),\n}))\n"
  },
  {
    "path": "src/drizzle/schema/user.ts",
    "content": "import { pgEnum, pgTable, text, timestamp } from \"drizzle-orm/pg-core\"\nimport { createdAt, id, updatedAt } from \"../schemaHelpers\"\nimport { relations } from \"drizzle-orm\"\nimport { UserCourseAccessTable } from \"./userCourseAccess\"\n\nexport const userRoles = [\"user\", \"admin\"] as const\nexport type UserRole = (typeof userRoles)[number]\nexport const userRoleEnum = pgEnum(\"user_role\", userRoles)\n\nexport const UserTable = pgTable(\"users\", {\n  id,\n  clerkUserId: text().notNull().unique(),\n  email: text().notNull(),\n  name: text().notNull(),\n  role: userRoleEnum().notNull().default(\"user\"),\n  imageUrl: text(),\n  deletedAt: timestamp({ withTimezone: true }),\n  createdAt,\n  updatedAt,\n})\n\nexport const UserRelationships = relations(UserTable, ({ many }) => ({\n  userCourseAccesses: many(UserCourseAccessTable),\n}))\n"
  },
  {
    "path": "src/drizzle/schema/userCourseAccess.ts",
    "content": "import { pgTable, primaryKey, uuid } from \"drizzle-orm/pg-core\"\nimport { createdAt, updatedAt } from \"../schemaHelpers\"\nimport { relations } from \"drizzle-orm\"\nimport { UserTable } from \"./user\"\nimport { CourseTable } from \"./course\"\n\nexport const UserCourseAccessTable = pgTable(\n  \"user_course_access\",\n  {\n    userId: uuid()\n      .notNull()\n      .references(() => UserTable.id, { onDelete: \"cascade\" }),\n    courseId: uuid()\n      .notNull()\n      .references(() => CourseTable.id, { onDelete: \"cascade\" }),\n    createdAt,\n    updatedAt,\n  },\n  t => [primaryKey({ columns: [t.userId, t.courseId] })]\n)\n\nexport const UserCourseAccessRelationships = relations(\n  UserCourseAccessTable,\n  ({ one }) => ({\n    user: one(UserTable, {\n      fields: [UserCourseAccessTable.userId],\n      references: [UserTable.id],\n    }),\n    course: one(CourseTable, {\n      fields: [UserCourseAccessTable.courseId],\n      references: [CourseTable.id],\n    }),\n  })\n)\n"
  },
  {
    "path": "src/drizzle/schema/userLessonComplete.ts",
    "content": "import { pgTable, primaryKey, uuid } from \"drizzle-orm/pg-core\"\nimport { createdAt, updatedAt } from \"../schemaHelpers\"\nimport { relations } from \"drizzle-orm\"\nimport { UserTable } from \"./user\"\nimport { LessonTable } from \"./lesson\"\n\nexport const UserLessonCompleteTable = pgTable(\n  \"user_lesson_complete\",\n  {\n    userId: uuid()\n      .notNull()\n      .references(() => UserTable.id, { onDelete: \"cascade\" }),\n    lessonId: uuid()\n      .notNull()\n      .references(() => LessonTable.id, { onDelete: \"cascade\" }),\n    createdAt,\n    updatedAt,\n  },\n  t => [primaryKey({ columns: [t.userId, t.lessonId] })]\n)\n\nexport const UserLessonCompleteRelationships = relations(\n  UserLessonCompleteTable,\n  ({ one }) => ({\n    user: one(UserTable, {\n      fields: [UserLessonCompleteTable.userId],\n      references: [UserTable.id],\n    }),\n    lesson: one(LessonTable, {\n      fields: [UserLessonCompleteTable.lessonId],\n      references: [LessonTable.id],\n    }),\n  })\n)\n"
  },
  {
    "path": "src/drizzle/schema.ts",
    "content": "export * from \"./schema/course\"\nexport * from \"./schema/courseProduct\"\nexport * from \"./schema/courseSection\"\nexport * from \"./schema/lesson\"\nexport * from \"./schema/product\"\nexport * from \"./schema/purchase\"\nexport * from \"./schema/user\"\nexport * from \"./schema/userCourseAccess\"\nexport * from \"./schema/userLessonComplete\"\n"
  },
  {
    "path": "src/drizzle/schemaHelpers.ts",
    "content": "import { timestamp, uuid } from \"drizzle-orm/pg-core\"\n\nexport const id = uuid().primaryKey().defaultRandom()\nexport const createdAt = timestamp({ withTimezone: true })\n  .notNull()\n  .defaultNow()\nexport const updatedAt = timestamp({ withTimezone: true })\n  .notNull()\n  .defaultNow()\n  .$onUpdate(() => new Date())\n"
  },
  {
    "path": "src/features/courseSections/actions/sections.ts",
    "content": "\"use server\"\n\nimport { z } from \"zod\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { sectionSchema } from \"../schemas/sections\"\nimport {\n  canCreateCourseSections,\n  canDeleteCourseSections,\n  canUpdateCourseSections,\n} from \"../permissions/sections\"\nimport {\n  getNextCourseSectionOrder,\n  insertSection,\n  updateSection as updateSectionDb,\n  deleteSection as deleteSectionDb,\n  updateSectionOrders as updateSectionOrdersDb,\n} from \"../db/sections\"\n\nexport async function createSection(\n  courseId: string,\n  unsafeData: z.infer<typeof sectionSchema>\n) {\n  const { success, data } = sectionSchema.safeParse(unsafeData)\n\n  if (!success || !canCreateCourseSections(await getCurrentUser())) {\n    return { error: true, message: \"There was an error creating your section\" }\n  }\n\n  const order = await getNextCourseSectionOrder(courseId)\n\n  await insertSection({ ...data, courseId, order })\n\n  return { error: false, message: \"Successfully created your section\" }\n}\n\nexport async function updateSection(\n  id: string,\n  unsafeData: z.infer<typeof sectionSchema>\n) {\n  const { success, data } = sectionSchema.safeParse(unsafeData)\n\n  if (!success || !canUpdateCourseSections(await getCurrentUser())) {\n    return { error: true, message: \"There was an error updating your section\" }\n  }\n\n  await updateSectionDb(id, data)\n\n  return { error: false, message: \"Successfully updated your section\" }\n}\n\nexport async function deleteSection(id: string) {\n  if (!canDeleteCourseSections(await getCurrentUser())) {\n    return { error: true, message: \"Error deleting your section\" }\n  }\n\n  await deleteSectionDb(id)\n\n  return { error: false, message: \"Successfully deleted your section\" }\n}\n\nexport async function updateSectionOrders(sectionIds: string[]) {\n  if (\n    sectionIds.length === 0 ||\n    !canUpdateCourseSections(await getCurrentUser())\n  ) {\n    return { error: true, message: \"Error reordering your sections\" }\n  }\n\n  await updateSectionOrdersDb(sectionIds)\n\n  return { error: false, message: \"Successfully reordered your sections\" }\n}\n"
  },
  {
    "path": "src/features/courseSections/components/SectionForm.tsx",
    "content": "\"use client\"\n\nimport { useForm } from \"react-hook-form\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { sectionSchema } from \"../schemas/sections\"\nimport { z } from \"zod\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@/components/ui/form\"\nimport { RequiredLabelIcon } from \"@/components/RequiredLabelIcon\"\nimport { Input } from \"@/components/ui/input\"\nimport { Button } from \"@/components/ui/button\"\nimport { actionToast } from \"@/hooks/use-toast\"\nimport { CourseSectionStatus, courseSectionStatuses } from \"@/drizzle/schema\"\nimport {\n  Select,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n  SelectContent,\n} from \"@/components/ui/select\"\nimport { createSection, updateSection } from \"../actions/sections\"\n\nexport function SectionForm({\n  section,\n  courseId,\n  onSuccess,\n}: {\n  section?: {\n    id: string\n    name: string\n    status: CourseSectionStatus\n  }\n  courseId: string\n  onSuccess?: () => void\n}) {\n  const form = useForm<z.infer<typeof sectionSchema>>({\n    resolver: zodResolver(sectionSchema),\n    defaultValues: section ?? {\n      name: \"\",\n      status: \"public\",\n    },\n  })\n\n  async function onSubmit(values: z.infer<typeof sectionSchema>) {\n    const action =\n      section == null\n        ? createSection.bind(null, courseId)\n        : updateSection.bind(null, section.id)\n    const data = await action(values)\n    actionToast({ actionData: data })\n    if (!data.error) onSuccess?.()\n  }\n\n  return (\n    <Form {...form}>\n      <form\n        onSubmit={form.handleSubmit(onSubmit)}\n        className=\"flex gap-6 flex-col @container\"\n      >\n        <div className=\"grid grid-cols-1 @lg:grid-cols-2 gap-6\">\n          <FormField\n            control={form.control}\n            name=\"name\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  <RequiredLabelIcon />\n                  Name\n                </FormLabel>\n                <FormControl>\n                  <Input {...field} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"status\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>Status</FormLabel>\n                <Select\n                  onValueChange={field.onChange}\n                  defaultValue={field.value}\n                >\n                  <FormControl>\n                    <SelectTrigger>\n                      <SelectValue />\n                    </SelectTrigger>\n                  </FormControl>\n                  <SelectContent>\n                    {courseSectionStatuses.map(status => (\n                      <SelectItem key={status} value={status}>\n                        {status}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n        </div>\n        <div className=\"self-end\">\n          <Button disabled={form.formState.isSubmitting} type=\"submit\">\n            Save\n          </Button>\n        </div>\n      </form>\n    </Form>\n  )\n}\n"
  },
  {
    "path": "src/features/courseSections/components/SectionFormDialog.tsx",
    "content": "\"use client\"\n\nimport {\n  Dialog,\n  DialogHeader,\n  DialogTitle,\n  DialogContent,\n} from \"@/components/ui/dialog\"\nimport { CourseSectionStatus } from \"@/drizzle/schema\"\nimport { ReactNode, useState } from \"react\"\nimport { SectionForm } from \"./SectionForm\"\n\nexport function SectionFormDialog({\n  courseId,\n  section,\n  children,\n}: {\n  courseId: string\n  children: ReactNode\n  section?: { id: string; name: string; status: CourseSectionStatus }\n}) {\n  const [isOpen, setIsOpen] = useState(false)\n\n  return (\n    <Dialog open={isOpen} onOpenChange={setIsOpen}>\n      {children}\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>\n            {section == null ? \"New Section\" : `Edit ${section.name}`}\n          </DialogTitle>\n        </DialogHeader>\n        <div className=\"mt-4\">\n          <SectionForm\n            section={section}\n            courseId={courseId}\n            onSuccess={() => setIsOpen(false)}\n          />\n        </div>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "src/features/courseSections/components/SortableSectionList.tsx",
    "content": "\"use client\"\n\nimport { SortableItem, SortableList } from \"@/components/SortableList\"\nimport { CourseSectionStatus } from \"@/drizzle/schema\"\nimport { cn } from \"@/lib/utils\"\nimport { EyeClosed, Trash2Icon } from \"lucide-react\"\nimport { SectionFormDialog } from \"./SectionFormDialog\"\nimport { Button } from \"@/components/ui/button\"\nimport { ActionButton } from \"@/components/ActionButton\"\nimport { deleteSection, updateSectionOrders } from \"../actions/sections\"\nimport { DialogTrigger } from \"@/components/ui/dialog\"\n\nexport function SortableSectionList({\n  courseId,\n  sections,\n}: {\n  courseId: string\n  sections: {\n    id: string\n    name: string\n    status: CourseSectionStatus\n  }[]\n}) {\n  return (\n    <SortableList items={sections} onOrderChange={updateSectionOrders}>\n      {items =>\n        items.map(section => (\n          <SortableItem\n            key={section.id}\n            id={section.id}\n            className=\"flex items-center gap-1\"\n          >\n            <div\n              className={cn(\n                \"contents\",\n                section.status === \"private\" && \"text-muted-foreground\"\n              )}\n            >\n              {section.status === \"private\" && <EyeClosed className=\"size-4\" />}\n              {section.name}\n            </div>\n            <SectionFormDialog section={section} courseId={courseId}>\n              <DialogTrigger asChild>\n                <Button variant=\"outline\" size=\"sm\" className=\"ml-auto\">\n                  Edit\n                </Button>\n              </DialogTrigger>\n            </SectionFormDialog>\n            <ActionButton\n              action={deleteSection.bind(null, section.id)}\n              requireAreYouSure\n              variant=\"destructiveOutline\"\n              size=\"sm\"\n            >\n              <Trash2Icon />\n              <span className=\"sr-only\">Delete</span>\n            </ActionButton>\n          </SortableItem>\n        ))\n      }\n    </SortableList>\n  )\n}\n"
  },
  {
    "path": "src/features/courseSections/db/cache.ts",
    "content": "import { getCourseTag, getGlobalTag, getIdTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport function getCourseSectionGlobalTag() {\n  return getGlobalTag(\"courseSections\")\n}\n\nexport function getCourseSectionIdTag(id: string) {\n  return getIdTag(\"courseSections\", id)\n}\n\nexport function getCourseSectionCourseTag(courseId: string) {\n  return getCourseTag(\"courseSections\", courseId)\n}\n\nexport function revalidateCourseSectionCache({\n  id,\n  courseId,\n}: {\n  id: string\n  courseId: string\n}) {\n  revalidateTag(getCourseSectionGlobalTag())\n  revalidateTag(getCourseSectionIdTag(id))\n  revalidateTag(getCourseSectionCourseTag(courseId))\n}\n"
  },
  {
    "path": "src/features/courseSections/db/sections.ts",
    "content": "import { CourseSectionTable } from \"@/drizzle/schema\"\nimport { revalidateCourseSectionCache } from \"./cache\"\nimport { db } from \"@/drizzle/db\"\nimport { eq } from \"drizzle-orm\"\n\nexport async function getNextCourseSectionOrder(courseId: string) {\n  const section = await db.query.CourseSectionTable.findFirst({\n    columns: { order: true },\n    where: ({ courseId: courseIdCol }, { eq }) => eq(courseIdCol, courseId),\n    orderBy: ({ order }, { desc }) => desc(order),\n  })\n\n  return section ? section.order + 1 : 0\n}\n\nexport async function insertSection(\n  data: typeof CourseSectionTable.$inferInsert\n) {\n  const [newSection] = await db\n    .insert(CourseSectionTable)\n    .values(data)\n    .returning()\n  if (newSection == null) throw new Error(\"Failed to create section\")\n\n  revalidateCourseSectionCache({\n    courseId: newSection.courseId,\n    id: newSection.id,\n  })\n\n  return newSection\n}\n\nexport async function updateSection(\n  id: string,\n  data: Partial<typeof CourseSectionTable.$inferInsert>\n) {\n  const [updatedSection] = await db\n    .update(CourseSectionTable)\n    .set(data)\n    .where(eq(CourseSectionTable.id, id))\n    .returning()\n  if (updatedSection == null) throw new Error(\"Failed to update section\")\n\n  revalidateCourseSectionCache({\n    courseId: updatedSection.courseId,\n    id: updatedSection.id,\n  })\n\n  return updatedSection\n}\n\nexport async function deleteSection(id: string) {\n  const [deletedSection] = await db\n    .delete(CourseSectionTable)\n    .where(eq(CourseSectionTable.id, id))\n    .returning()\n  if (deletedSection == null) throw new Error(\"Failed to delete section\")\n\n  revalidateCourseSectionCache({\n    courseId: deletedSection.courseId,\n    id: deletedSection.id,\n  })\n\n  return deletedSection\n}\n\nexport async function updateSectionOrders(sectionIds: string[]) {\n  const sections = await Promise.all(\n    sectionIds.map((id, index) =>\n      db\n        .update(CourseSectionTable)\n        .set({ order: index })\n        .where(eq(CourseSectionTable.id, id))\n        .returning({\n          courseId: CourseSectionTable.courseId,\n          id: CourseSectionTable.id,\n        })\n    )\n  )\n\n  sections.flat().forEach(({ id, courseId }) => {\n    revalidateCourseSectionCache({\n      courseId,\n      id,\n    })\n  })\n}\n"
  },
  {
    "path": "src/features/courseSections/permissions/sections.ts",
    "content": "import { CourseSectionTable, UserRole } from \"@/drizzle/schema\"\nimport { eq } from \"drizzle-orm\"\n\nexport function canCreateCourseSections({\n  role,\n}: {\n  role: UserRole | undefined\n}) {\n  return role === \"admin\"\n}\n\nexport function canUpdateCourseSections({\n  role,\n}: {\n  role: UserRole | undefined\n}) {\n  return role === \"admin\"\n}\n\nexport function canDeleteCourseSections({\n  role,\n}: {\n  role: UserRole | undefined\n}) {\n  return role === \"admin\"\n}\n\nexport const wherePublicCourseSections = eq(CourseSectionTable.status, \"public\")\n"
  },
  {
    "path": "src/features/courseSections/schemas/sections.ts",
    "content": "import { courseSectionStatuses } from \"@/drizzle/schema\"\nimport { z } from \"zod\"\n\nexport const sectionSchema = z.object({\n  name: z.string().min(1, \"Required\"),\n  status: z.enum(courseSectionStatuses),\n})\n"
  },
  {
    "path": "src/features/courses/actions/courses.ts",
    "content": "\"use server\"\n\nimport { z } from \"zod\"\nimport { courseSchema } from \"../schemas/courses\"\nimport { redirect } from \"next/navigation\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport {\n  canCreateCourses,\n  canDeleteCourses,\n  canUpdateCourses,\n} from \"../permissions/courses\"\nimport {\n  insertCourse,\n  deleteCourse as deleteCourseDB,\n  updateCourse as updateCourseDb,\n} from \"../db/courses\"\n\nexport async function createCourse(unsafeData: z.infer<typeof courseSchema>) {\n  const { success, data } = courseSchema.safeParse(unsafeData)\n\n  if (!success || !canCreateCourses(await getCurrentUser())) {\n    return { error: true, message: \"There was an error creating your course\" }\n  }\n\n  const course = await insertCourse(data)\n\n  redirect(`/admin/courses/${course.id}/edit`)\n}\n\nexport async function updateCourse(\n  id: string,\n  unsafeData: z.infer<typeof courseSchema>\n) {\n  const { success, data } = courseSchema.safeParse(unsafeData)\n\n  if (!success || !canUpdateCourses(await getCurrentUser())) {\n    return { error: true, message: \"There was an error updating your course\" }\n  }\n\n  await updateCourseDb(id, data)\n\n  return { error: false, message: \"Successfully updated your course\" }\n}\n\nexport async function deleteCourse(id: string) {\n  if (!canDeleteCourses(await getCurrentUser())) {\n    return { error: true, message: \"Error deleting your course\" }\n  }\n\n  await deleteCourseDB(id)\n\n  return { error: false, message: \"Successfully deleted your course\" }\n}\n"
  },
  {
    "path": "src/features/courses/components/CourseForm.tsx",
    "content": "\"use client\"\n\nimport { useForm } from \"react-hook-form\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { courseSchema } from \"../schemas/courses\"\nimport { z } from \"zod\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@/components/ui/form\"\nimport { RequiredLabelIcon } from \"@/components/RequiredLabelIcon\"\nimport { Input } from \"@/components/ui/input\"\nimport { Textarea } from \"@/components/ui/textarea\"\nimport { Button } from \"@/components/ui/button\"\nimport { createCourse, updateCourse } from \"../actions/courses\"\nimport { actionToast } from \"@/hooks/use-toast\"\n\nexport function CourseForm({\n  course,\n}: {\n  course?: {\n    id: string\n    name: string\n    description: string\n  }\n}) {\n  const form = useForm<z.infer<typeof courseSchema>>({\n    resolver: zodResolver(courseSchema),\n    defaultValues: course ?? {\n      name: \"\",\n      description: \"\",\n    },\n  })\n\n  async function onSubmit(values: z.infer<typeof courseSchema>) {\n    const action =\n      course == null ? createCourse : updateCourse.bind(null, course.id)\n    const data = await action(values)\n    actionToast({ actionData: data })\n  }\n\n  return (\n    <Form {...form}>\n      <form\n        onSubmit={form.handleSubmit(onSubmit)}\n        className=\"flex gap-6 flex-col\"\n      >\n        <FormField\n          control={form.control}\n          name=\"name\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>\n                <RequiredLabelIcon />\n                Name\n              </FormLabel>\n              <FormControl>\n                <Input {...field} />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"description\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>\n                <RequiredLabelIcon />\n                Description\n              </FormLabel>\n              <FormControl>\n                <Textarea className=\"min-h-20 resize-none\" {...field} />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <div className=\"self-end\">\n          <Button disabled={form.formState.isSubmitting} type=\"submit\">\n            Save\n          </Button>\n        </div>\n      </form>\n    </Form>\n  )\n}\n"
  },
  {
    "path": "src/features/courses/components/CourseTable.tsx",
    "content": "import { ActionButton } from \"@/components/ActionButton\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\"\nimport { formatPlural } from \"@/lib/formatters\"\nimport { Trash2Icon } from \"lucide-react\"\nimport Link from \"next/link\"\nimport { deleteCourse } from \"../actions/courses\"\n\nexport function CourseTable({\n  courses,\n}: {\n  courses: {\n    id: string\n    name: string\n    sectionsCount: number\n    lessonsCount: number\n    studentsCount: number\n  }[]\n}) {\n  return (\n    <Table>\n      <TableHeader>\n        <TableRow>\n          <TableHead>\n            {formatPlural(courses.length, {\n              singular: \"course\",\n              plural: \"courses\",\n            })}\n          </TableHead>\n          <TableHead>Students</TableHead>\n          <TableHead>Actions</TableHead>\n        </TableRow>\n      </TableHeader>\n      <TableBody>\n        {courses.map(course => (\n          <TableRow key={course.id}>\n            <TableCell>\n              <div className=\"flex flex-col gap-1\">\n                <div className=\"font-semibold\">{course.name}</div>\n                <div className=\"text-muted-foreground\">\n                  {formatPlural(course.sectionsCount, {\n                    singular: \"section\",\n                    plural: \"sections\",\n                  })}{\" \"}\n                  •{\" \"}\n                  {formatPlural(course.lessonsCount, {\n                    singular: \"lesson\",\n                    plural: \"lessons\",\n                  })}\n                </div>\n              </div>\n            </TableCell>\n            <TableCell>{course.studentsCount}</TableCell>\n            <TableCell>\n              <div className=\"flex gap-2\">\n                <Button asChild>\n                  <Link href={`/admin/courses/${course.id}/edit`}>Edit</Link>\n                </Button>\n                <ActionButton\n                  variant=\"destructiveOutline\"\n                  requireAreYouSure\n                  action={deleteCourse.bind(null, course.id)}\n                >\n                  <Trash2Icon />\n                  <span className=\"sr-only\">Delete</span>\n                </ActionButton>\n              </div>\n            </TableCell>\n          </TableRow>\n        ))}\n      </TableBody>\n    </Table>\n  )\n}\n"
  },
  {
    "path": "src/features/courses/db/cache/courses.ts",
    "content": "import { getGlobalTag, getIdTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport function getCourseGlobalTag() {\n  return getGlobalTag(\"courses\")\n}\n\nexport function getCourseIdTag(id: string) {\n  return getIdTag(\"courses\", id)\n}\n\nexport function revalidateCourseCache(id: string) {\n  revalidateTag(getCourseGlobalTag())\n  revalidateTag(getCourseIdTag(id))\n}\n"
  },
  {
    "path": "src/features/courses/db/cache/userCourseAccess.ts",
    "content": "import { getGlobalTag, getIdTag, getUserTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport function getUserCourseAccessGlobalTag() {\n  return getGlobalTag(\"userCourseAccess\")\n}\n\nexport function getUserCourseAccessIdTag({\n  courseId,\n  userId,\n}: {\n  courseId: string\n  userId: string\n}) {\n  return getIdTag(\"userCourseAccess\", `course:${courseId}-user:${userId}`)\n}\n\nexport function getUserCourseAccessUserTag(userId: string) {\n  return getUserTag(\"userCourseAccess\", userId)\n}\n\nexport function revalidateUserCourseAccessCache({\n  courseId,\n  userId,\n}: {\n  courseId: string\n  userId: string\n}) {\n  revalidateTag(getUserCourseAccessGlobalTag())\n  revalidateTag(getUserCourseAccessIdTag({ courseId, userId }))\n  revalidateTag(getUserCourseAccessUserTag(userId))\n}\n"
  },
  {
    "path": "src/features/courses/db/courses.ts",
    "content": "import { db } from \"@/drizzle/db\"\nimport { CourseTable } from \"@/drizzle/schema\"\nimport { revalidateCourseCache } from \"./cache/courses\"\nimport { eq } from \"drizzle-orm\"\n\nexport async function insertCourse(data: typeof CourseTable.$inferInsert) {\n  const [newCourse] = await db.insert(CourseTable).values(data).returning()\n  if (newCourse == null) throw new Error(\"Failed to create course\")\n  revalidateCourseCache(newCourse.id)\n\n  return newCourse\n}\n\nexport async function updateCourse(\n  id: string,\n  data: typeof CourseTable.$inferInsert\n) {\n  const [updatedCourse] = await db\n    .update(CourseTable)\n    .set(data)\n    .where(eq(CourseTable.id, id))\n    .returning()\n  if (updatedCourse == null) throw new Error(\"Failed to update course\")\n  revalidateCourseCache(updatedCourse.id)\n\n  return updatedCourse\n}\n\nexport async function deleteCourse(id: string) {\n  const [deletedCourse] = await db\n    .delete(CourseTable)\n    .where(eq(CourseTable.id, id))\n    .returning()\n  if (deletedCourse == null) throw new Error(\"Failed to delete course\")\n  revalidateCourseCache(deletedCourse.id)\n\n  return deletedCourse\n}\n"
  },
  {
    "path": "src/features/courses/db/userCourseAcccess.ts",
    "content": "import { db } from \"@/drizzle/db\"\nimport {\n  ProductTable,\n  PurchaseTable,\n  UserCourseAccessTable,\n} from \"@/drizzle/schema\"\nimport { revalidateUserCourseAccessCache } from \"./cache/userCourseAccess\"\nimport { and, eq, inArray, isNull } from \"drizzle-orm\"\n\nexport async function addUserCourseAccess(\n  {\n    userId,\n    courseIds,\n  }: {\n    userId: string\n    courseIds: string[]\n  },\n  trx: Omit<typeof db, \"$client\"> = db\n) {\n  const accesses = await trx\n    .insert(UserCourseAccessTable)\n    .values(courseIds.map(courseId => ({ userId, courseId })))\n    .onConflictDoNothing()\n    .returning()\n\n  accesses.forEach(revalidateUserCourseAccessCache)\n\n  return accesses\n}\n\nexport async function revokeUserCourseAccess(\n  {\n    userId,\n    productId,\n  }: {\n    userId: string\n    productId: string\n  },\n  trx: Omit<typeof db, \"$client\"> = db\n) {\n  const validPurchases = await trx.query.PurchaseTable.findMany({\n    where: and(\n      eq(PurchaseTable.userId, userId),\n      isNull(PurchaseTable.refundedAt)\n    ),\n    with: {\n      product: {\n        with: { courseProducts: { columns: { courseId: true } } },\n      },\n    },\n  })\n\n  const refundPurchase = await trx.query.ProductTable.findFirst({\n    where: eq(ProductTable.id, productId),\n    with: { courseProducts: { columns: { courseId: true } } },\n  })\n\n  if (refundPurchase == null) return\n\n  const validCourseIds = validPurchases.flatMap(p =>\n    p.product.courseProducts.map(cp => cp.courseId)\n  )\n\n  const removeCourseIds = refundPurchase.courseProducts\n    .flatMap(cp => cp.courseId)\n    .filter(courseId => !validCourseIds.includes(courseId))\n\n  const revokedAccesses = await trx\n    .delete(UserCourseAccessTable)\n    .where(\n      and(\n        eq(UserCourseAccessTable.userId, userId),\n        inArray(UserCourseAccessTable.courseId, removeCourseIds)\n      )\n    )\n    .returning()\n\n  revokedAccesses.forEach(revalidateUserCourseAccessCache)\n\n  return revokedAccesses\n}\n"
  },
  {
    "path": "src/features/courses/permissions/courses.ts",
    "content": "import { UserRole } from \"@/drizzle/schema\"\n\nexport function canCreateCourses({ role }: { role: UserRole | undefined }) {\n  return role === \"admin\"\n}\n\nexport function canUpdateCourses({ role }: { role: UserRole | undefined }) {\n  return role === \"admin\"\n}\n\nexport function canDeleteCourses({ role }: { role: UserRole | undefined }) {\n  return role === \"admin\"\n}\n"
  },
  {
    "path": "src/features/courses/schemas/courses.ts",
    "content": "import { z } from \"zod\"\n\nexport const courseSchema = z.object({\n  name: z.string().min(1, \"Required\"),\n  description: z.string().min(1, \"Required\"),\n})\n"
  },
  {
    "path": "src/features/lessons/actions/lessons.ts",
    "content": "\"use server\"\n\nimport { z } from \"zod\"\nimport { lessonSchema } from \"../schemas/lessons\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport {\n  canCreateLessons,\n  canDeleteLessons,\n  canUpdateLessons,\n} from \"../permissions/lessons\"\nimport {\n  getNextCourseLessonOrder,\n  insertLesson,\n  updateLesson as updateLessonDb,\n  deleteLesson as deleteLessonDb,\n  updateLessonOrders as updateLessonOrdersDb,\n} from \"../db/lessons\"\n\nexport async function createLesson(unsafeData: z.infer<typeof lessonSchema>) {\n  const { success, data } = lessonSchema.safeParse(unsafeData)\n\n  if (!success || !canCreateLessons(await getCurrentUser())) {\n    return { error: true, message: \"There was an error creating your lesson\" }\n  }\n\n  const order = await getNextCourseLessonOrder(data.sectionId)\n\n  await insertLesson({ ...data, order })\n\n  return { error: false, message: \"Successfully created your lesson\" }\n}\n\nexport async function updateLesson(\n  id: string,\n  unsafeData: z.infer<typeof lessonSchema>\n) {\n  const { success, data } = lessonSchema.safeParse(unsafeData)\n\n  if (!success || !canUpdateLessons(await getCurrentUser())) {\n    return { error: true, message: \"There was an error updating your lesson\" }\n  }\n\n  await updateLessonDb(id, data)\n\n  return { error: false, message: \"Successfully updated your lesson\" }\n}\n\nexport async function deleteLesson(id: string) {\n  if (!canDeleteLessons(await getCurrentUser())) {\n    return { error: true, message: \"Error deleting your lesson\" }\n  }\n\n  await deleteLessonDb(id)\n\n  return { error: false, message: \"Successfully deleted your lesson\" }\n}\n\nexport async function updateLessonOrders(lessonIds: string[]) {\n  if (lessonIds.length === 0 || !canUpdateLessons(await getCurrentUser())) {\n    return { error: true, message: \"Error reordering your lessons\" }\n  }\n\n  await updateLessonOrdersDb(lessonIds)\n\n  return { error: false, message: \"Successfully reordered your lessons\" }\n}\n"
  },
  {
    "path": "src/features/lessons/actions/userLessonComplete.ts",
    "content": "\"use server\"\n\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { canUpdateUserLessonCompleteStatus } from \"../permissions/userLessonComplete\"\nimport { updateLessonCompleteStatus as updateLessonCompleteStatusDb } from \"../db/userLessonComplete\"\n\nexport async function updateLessonCompleteStatus(\n  lessonId: string,\n  complete: boolean\n) {\n  const { userId } = await getCurrentUser()\n\n  const hasPermission = await canUpdateUserLessonCompleteStatus(\n    { userId },\n    lessonId\n  )\n\n  if (userId == null || !hasPermission) {\n    return { error: true, message: \"Error updating lesson completion status\" }\n  }\n\n  await updateLessonCompleteStatusDb({ lessonId, userId, complete })\n\n  return {\n    error: false,\n    message: \"Successfully updated lesson completion status\",\n  }\n}\n"
  },
  {
    "path": "src/features/lessons/components/LessonForm.tsx",
    "content": "\"use client\"\n\nimport { useForm } from \"react-hook-form\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { z } from \"zod\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@/components/ui/form\"\nimport { RequiredLabelIcon } from \"@/components/RequiredLabelIcon\"\nimport { Input } from \"@/components/ui/input\"\nimport { Button } from \"@/components/ui/button\"\nimport { actionToast } from \"@/hooks/use-toast\"\nimport { LessonStatus, lessonStatuses } from \"@/drizzle/schema\"\nimport {\n  Select,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n  SelectContent,\n} from \"@/components/ui/select\"\nimport { lessonSchema } from \"../schemas/lessons\"\nimport { Textarea } from \"@/components/ui/textarea\"\nimport { createLesson, updateLesson } from \"../actions/lessons\"\nimport { YouTubeVideoPlayer } from \"./YouTubeVideoPlayer\"\n\nexport function LessonForm({\n  sections,\n  defaultSectionId,\n  onSuccess,\n  lesson,\n}: {\n  sections: {\n    id: string\n    name: string\n  }[]\n  onSuccess?: () => void\n  defaultSectionId?: string\n  lesson?: {\n    id: string\n    name: string\n    status: LessonStatus\n    youtubeVideoId: string\n    description: string | null\n    sectionId: string\n  }\n}) {\n  const form = useForm<z.infer<typeof lessonSchema>>({\n    resolver: zodResolver(lessonSchema),\n    defaultValues: {\n      name: lesson?.name ?? \"\",\n      status: lesson?.status ?? \"public\",\n      youtubeVideoId: lesson?.youtubeVideoId ?? \"\",\n      description: lesson?.description ?? \"\",\n      sectionId: lesson?.sectionId ?? defaultSectionId ?? sections[0]?.id ?? \"\",\n    },\n  })\n\n  async function onSubmit(values: z.infer<typeof lessonSchema>) {\n    const action =\n      lesson == null ? createLesson : updateLesson.bind(null, lesson.id)\n    const data = await action(values)\n    actionToast({ actionData: data })\n    if (!data.error) onSuccess?.()\n  }\n\n  const videoId = form.watch(\"youtubeVideoId\")\n  console.log(videoId)\n\n  return (\n    <Form {...form}>\n      <form\n        onSubmit={form.handleSubmit(onSubmit)}\n        className=\"flex gap-6 flex-col @container\"\n      >\n        <div className=\"grid grid-cols-1 @lg:grid-cols-2 gap-6\">\n          <FormField\n            control={form.control}\n            name=\"name\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  <RequiredLabelIcon />\n                  Name\n                </FormLabel>\n                <FormControl>\n                  <Input {...field} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"youtubeVideoId\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  <RequiredLabelIcon />\n                  YouTube Video Id\n                </FormLabel>\n                <FormControl>\n                  <Input {...field} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"sectionId\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>Section</FormLabel>\n                <Select\n                  onValueChange={field.onChange}\n                  defaultValue={field.value}\n                >\n                  <FormControl>\n                    <SelectTrigger>\n                      <SelectValue />\n                    </SelectTrigger>\n                  </FormControl>\n                  <SelectContent>\n                    {sections.map(section => (\n                      <SelectItem key={section.id} value={section.id}>\n                        {section.name}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"status\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>Status</FormLabel>\n                <Select\n                  onValueChange={field.onChange}\n                  defaultValue={field.value}\n                >\n                  <FormControl>\n                    <SelectTrigger>\n                      <SelectValue />\n                    </SelectTrigger>\n                  </FormControl>\n                  <SelectContent>\n                    {lessonStatuses.map(status => (\n                      <SelectItem key={status} value={status}>\n                        {status}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n        </div>\n        <FormField\n          control={form.control}\n          name=\"description\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>Description</FormLabel>\n              <FormControl>\n                <Textarea\n                  className=\"min-h-20 resize-none\"\n                  {...field}\n                  value={field.value ?? \"\"}\n                />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <div className=\"self-end\">\n          <Button disabled={form.formState.isSubmitting} type=\"submit\">\n            Save\n          </Button>\n        </div>\n        {videoId && (\n          <div className=\"aspect-video\">\n            <YouTubeVideoPlayer videoId={videoId} />\n          </div>\n        )}\n      </form>\n    </Form>\n  )\n}\n"
  },
  {
    "path": "src/features/lessons/components/LessonFormDialog.tsx",
    "content": "\"use client\"\n\nimport {\n  Dialog,\n  DialogHeader,\n  DialogTitle,\n  DialogContent,\n} from \"@/components/ui/dialog\"\nimport { LessonStatus } from \"@/drizzle/schema\"\nimport { ReactNode, useState } from \"react\"\nimport { LessonForm } from \"./LessonForm\"\n\nexport function LessonFormDialog({\n  sections,\n  defaultSectionId,\n  lesson,\n  children,\n}: {\n  children: ReactNode\n  sections: { id: string; name: string }[]\n  defaultSectionId?: string\n  lesson?: {\n    id: string\n    name: string\n    status: LessonStatus\n    youtubeVideoId: string\n    description: string | null\n    sectionId: string\n  }\n}) {\n  const [isOpen, setIsOpen] = useState(false)\n\n  return (\n    <Dialog open={isOpen} onOpenChange={setIsOpen}>\n      {children}\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>\n            {lesson == null ? \"New Lesson\" : `Edit ${lesson.name}`}\n          </DialogTitle>\n        </DialogHeader>\n        <div className=\"mt-4\">\n          <LessonForm\n            sections={sections}\n            onSuccess={() => setIsOpen(false)}\n            lesson={lesson}\n            defaultSectionId={defaultSectionId}\n          />\n        </div>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "src/features/lessons/components/SortableLessonList.tsx",
    "content": "\"use client\"\n\nimport { SortableItem, SortableList } from \"@/components/SortableList\"\nimport { LessonStatus } from \"@/drizzle/schema\"\nimport { cn } from \"@/lib/utils\"\nimport { EyeClosed, Trash2Icon, VideoIcon } from \"lucide-react\"\nimport { Button } from \"@/components/ui/button\"\nimport { ActionButton } from \"@/components/ActionButton\"\nimport { DialogTrigger } from \"@/components/ui/dialog\"\nimport { LessonFormDialog } from \"./LessonFormDialog\"\nimport { deleteLesson, updateLessonOrders } from \"../actions/lessons\"\n\nexport function SortableLessonList({\n  sections,\n  lessons,\n}: {\n  sections: {\n    id: string\n    name: string\n  }[]\n  lessons: {\n    id: string\n    name: string\n    status: LessonStatus\n    youtubeVideoId: string\n    description: string | null\n    sectionId: string\n  }[]\n}) {\n  return (\n    <SortableList items={lessons} onOrderChange={updateLessonOrders}>\n      {items =>\n        items.map(lesson => (\n          <SortableItem\n            key={lesson.id}\n            id={lesson.id}\n            className=\"flex items-center gap-1\"\n          >\n            <div\n              className={cn(\n                \"contents\",\n                lesson.status === \"private\" && \"text-muted-foreground\"\n              )}\n            >\n              {lesson.status === \"private\" && <EyeClosed className=\"size-4\" />}\n              {lesson.status === \"preview\" && <VideoIcon className=\"size-4\" />}\n              {lesson.name}\n            </div>\n            <LessonFormDialog lesson={lesson} sections={sections}>\n              <DialogTrigger asChild>\n                <Button variant=\"outline\" size=\"sm\" className=\"ml-auto\">\n                  Edit\n                </Button>\n              </DialogTrigger>\n            </LessonFormDialog>\n            <ActionButton\n              action={deleteLesson.bind(null, lesson.id)}\n              requireAreYouSure\n              variant=\"destructiveOutline\"\n              size=\"sm\"\n            >\n              <Trash2Icon />\n              <span className=\"sr-only\">Delete</span>\n            </ActionButton>\n          </SortableItem>\n        ))\n      }\n    </SortableList>\n  )\n}\n"
  },
  {
    "path": "src/features/lessons/components/YouTubeVideoPlayer.tsx",
    "content": "\"use client\"\n\nimport YouTube from \"react-youtube\"\n\nexport function YouTubeVideoPlayer({\n  videoId,\n  onFinishedVideo,\n}: {\n  videoId: string\n  onFinishedVideo?: () => void\n}) {\n  return (\n    <YouTube\n      videoId={videoId}\n      className=\"w-full h-full\"\n      opts={{ width: \"100%\", height: \"100%\" }}\n      onEnd={onFinishedVideo}\n    />\n  )\n}\n"
  },
  {
    "path": "src/features/lessons/db/cache/lessons.ts",
    "content": "import { getCourseTag, getGlobalTag, getIdTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport function getLessonGlobalTag() {\n  return getGlobalTag(\"lessons\")\n}\n\nexport function getLessonIdTag(id: string) {\n  return getIdTag(\"lessons\", id)\n}\n\nexport function getLessonCourseTag(courseId: string) {\n  return getCourseTag(\"lessons\", courseId)\n}\n\nexport function revalidateLessonCache({\n  id,\n  courseId,\n}: {\n  id: string\n  courseId: string\n}) {\n  revalidateTag(getLessonGlobalTag())\n  revalidateTag(getLessonIdTag(id))\n  revalidateTag(getLessonCourseTag(courseId))\n}\n"
  },
  {
    "path": "src/features/lessons/db/cache/userLessonComplete.ts",
    "content": "import { getGlobalTag, getIdTag, getUserTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport function getUserLessonCompleteGlobalTag() {\n  return getGlobalTag(\"userLessonComplete\")\n}\n\nexport function getUserLessonCompleteIdTag({\n  lessonId,\n  userId,\n}: {\n  lessonId: string\n  userId: string\n}) {\n  return getIdTag(\"userLessonComplete\", `lesson:${lessonId}-user:${userId}`)\n}\n\nexport function getUserLessonCompleteUserTag(userId: string) {\n  return getUserTag(\"userLessonComplete\", userId)\n}\n\nexport function revalidateUserLessonCompleteCache({\n  lessonId,\n  userId,\n}: {\n  lessonId: string\n  userId: string\n}) {\n  revalidateTag(getUserLessonCompleteGlobalTag())\n  revalidateTag(getUserLessonCompleteIdTag({ lessonId, userId }))\n  revalidateTag(getUserLessonCompleteUserTag(userId))\n}\n"
  },
  {
    "path": "src/features/lessons/db/lessons.ts",
    "content": "import { db } from \"@/drizzle/db\"\nimport { CourseSectionTable, LessonTable } from \"@/drizzle/schema\"\nimport { eq } from \"drizzle-orm\"\nimport { revalidateLessonCache } from \"./cache/lessons\"\n\nexport async function getNextCourseLessonOrder(sectionId: string) {\n  const lesson = await db.query.LessonTable.findFirst({\n    columns: { order: true },\n    where: ({ sectionId: sectionIdCol }, { eq }) => eq(sectionIdCol, sectionId),\n    orderBy: ({ order }, { desc }) => desc(order),\n  })\n\n  return lesson ? lesson.order + 1 : 0\n}\n\nexport async function insertLesson(data: typeof LessonTable.$inferInsert) {\n  const [newLesson, courseId] = await db.transaction(async trx => {\n    const [[newLesson], section] = await Promise.all([\n      trx.insert(LessonTable).values(data).returning(),\n      trx.query.CourseSectionTable.findFirst({\n        columns: { courseId: true },\n        where: eq(CourseSectionTable.id, data.sectionId),\n      }),\n    ])\n\n    if (section == null) return trx.rollback()\n\n    return [newLesson, section.courseId]\n  })\n  if (newLesson == null) throw new Error(\"Failed to create lesson\")\n\n  revalidateLessonCache({ courseId, id: newLesson.id })\n\n  return newLesson\n}\n\nexport async function updateLesson(\n  id: string,\n  data: Partial<typeof LessonTable.$inferInsert>\n) {\n  const [updatedLesson, courseId] = await db.transaction(async trx => {\n    const currentLesson = await trx.query.LessonTable.findFirst({\n      where: eq(LessonTable.id, id),\n      columns: { sectionId: true },\n    })\n\n    if (\n      data.sectionId != null &&\n      currentLesson?.sectionId !== data.sectionId &&\n      data.order == null\n    ) {\n      data.order = await getNextCourseLessonOrder(data.sectionId)\n    }\n\n    const [updatedLesson] = await trx\n      .update(LessonTable)\n      .set(data)\n      .where(eq(LessonTable.id, id))\n      .returning()\n    if (updatedLesson == null) {\n      trx.rollback()\n      throw new Error(\"Failed to update lesson\")\n    }\n\n    const section = await trx.query.CourseSectionTable.findFirst({\n      columns: { courseId: true },\n      where: eq(CourseSectionTable.id, updatedLesson.sectionId),\n    })\n\n    if (section == null) return trx.rollback()\n\n    return [updatedLesson, section.courseId]\n  })\n\n  revalidateLessonCache({ courseId, id: updatedLesson.id })\n\n  return updatedLesson\n}\n\nexport async function deleteLesson(id: string) {\n  const [deletedLesson, courseId] = await db.transaction(async trx => {\n    const [deletedLesson] = await trx\n      .delete(LessonTable)\n      .where(eq(LessonTable.id, id))\n      .returning()\n    if (deletedLesson == null) {\n      trx.rollback()\n      throw new Error(\"Failed to delete lesson\")\n    }\n\n    const section = await trx.query.CourseSectionTable.findFirst({\n      columns: { courseId: true },\n      where: ({ id }, { eq }) => eq(id, deletedLesson.sectionId),\n    })\n\n    if (section == null) return trx.rollback()\n\n    return [deletedLesson, section.courseId]\n  })\n\n  revalidateLessonCache({\n    id: deletedLesson.id,\n    courseId,\n  })\n\n  return deletedLesson\n}\n\nexport async function updateLessonOrders(lessonIds: string[]) {\n  const [lessons, courseId] = await db.transaction(async trx => {\n    const lessons = await Promise.all(\n      lessonIds.map((id, index) =>\n        db\n          .update(LessonTable)\n          .set({ order: index })\n          .where(eq(LessonTable.id, id))\n          .returning({\n            sectionId: LessonTable.sectionId,\n            id: LessonTable.id,\n          })\n      )\n    )\n    const sectionId = lessons[0]?.[0]?.sectionId\n    if (sectionId == null) return trx.rollback()\n\n    const section = await trx.query.CourseSectionTable.findFirst({\n      columns: { courseId: true },\n      where: ({ id }, { eq }) => eq(id, sectionId),\n    })\n\n    if (section == null) return trx.rollback()\n\n    return [lessons, section.courseId]\n  })\n\n  lessons.flat().forEach(({ id }) => {\n    revalidateLessonCache({\n      courseId,\n      id,\n    })\n  })\n}\n"
  },
  {
    "path": "src/features/lessons/db/userLessonComplete.ts",
    "content": "import { db } from \"@/drizzle/db\"\nimport { UserLessonCompleteTable } from \"@/drizzle/schema\"\nimport { and, eq } from \"drizzle-orm\"\nimport { revalidateUserLessonCompleteCache } from \"./cache/userLessonComplete\"\n\nexport async function updateLessonCompleteStatus({\n  lessonId,\n  userId,\n  complete,\n}: {\n  lessonId: string\n  userId: string\n  complete: boolean\n}) {\n  let completion: { lessonId: string; userId: string } | undefined\n  if (complete) {\n    const [c] = await db\n      .insert(UserLessonCompleteTable)\n      .values({\n        lessonId,\n        userId,\n      })\n      .onConflictDoNothing()\n      .returning()\n    completion = c\n  } else {\n    const [c] = await db\n      .delete(UserLessonCompleteTable)\n      .where(\n        and(\n          eq(UserLessonCompleteTable.lessonId, lessonId),\n          eq(UserLessonCompleteTable.userId, userId)\n        )\n      )\n      .returning()\n    completion = c\n  }\n\n  if (completion == null) return\n\n  revalidateUserLessonCompleteCache({\n    lessonId: completion.lessonId,\n    userId: completion.userId,\n  })\n\n  return completion\n}\n"
  },
  {
    "path": "src/features/lessons/permissions/lessons.ts",
    "content": "import { db } from \"@/drizzle/db\"\nimport {\n  CourseSectionTable,\n  CourseTable,\n  LessonStatus,\n  LessonTable,\n  UserCourseAccessTable,\n  UserRole,\n} from \"@/drizzle/schema\"\nimport { getUserCourseAccessUserTag } from \"@/features/courses/db/cache/userCourseAccess\"\nimport { wherePublicCourseSections } from \"@/features/courseSections/permissions/sections\"\nimport { and, eq, or } from \"drizzle-orm\"\nimport { getLessonIdTag } from \"../db/cache/lessons\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\n\nexport function canCreateLessons({ role }: { role: UserRole | undefined }) {\n  return role === \"admin\"\n}\n\nexport function canUpdateLessons({ role }: { role: UserRole | undefined }) {\n  return role === \"admin\"\n}\n\nexport function canDeleteLessons({ role }: { role: UserRole | undefined }) {\n  return role === \"admin\"\n}\n\nexport async function canViewLesson(\n  {\n    role,\n    userId,\n  }: {\n    userId: string | undefined\n    role: UserRole | undefined\n  },\n  lesson: { id: string; status: LessonStatus }\n) {\n  \"use cache\"\n  if (role === \"admin\" || lesson.status === \"preview\") return true\n  if (userId == null || lesson.status === \"private\") return false\n\n  cacheTag(getUserCourseAccessUserTag(userId), getLessonIdTag(lesson.id))\n\n  const [data] = await db\n    .select({ courseId: CourseTable.id })\n    .from(UserCourseAccessTable)\n    .leftJoin(CourseTable, eq(CourseTable.id, UserCourseAccessTable.courseId))\n    .leftJoin(\n      CourseSectionTable,\n      and(\n        eq(CourseSectionTable.courseId, CourseTable.id),\n        wherePublicCourseSections\n      )\n    )\n    .leftJoin(\n      LessonTable,\n      and(eq(LessonTable.sectionId, CourseSectionTable.id), wherePublicLessons)\n    )\n    .where(\n      and(\n        eq(LessonTable.id, lesson.id),\n        eq(UserCourseAccessTable.userId, userId)\n      )\n    )\n    .limit(1)\n\n  return data != null && data.courseId != null\n}\n\nexport const wherePublicLessons = or(\n  eq(LessonTable.status, \"public\"),\n  eq(LessonTable.status, \"preview\")\n)\n"
  },
  {
    "path": "src/features/lessons/permissions/userLessonComplete.ts",
    "content": "import { db } from \"@/drizzle/db\"\nimport {\n  CourseSectionTable,\n  CourseTable,\n  LessonTable,\n  UserCourseAccessTable,\n} from \"@/drizzle/schema\"\nimport { wherePublicCourseSections } from \"@/features/courseSections/permissions/sections\"\nimport { and, eq } from \"drizzle-orm\"\nimport { wherePublicLessons } from \"./lessons\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport { getUserCourseAccessUserTag } from \"@/features/courses/db/cache/userCourseAccess\"\nimport { getLessonIdTag } from \"../db/cache/lessons\"\n\nexport async function canUpdateUserLessonCompleteStatus(\n  user: { userId: string | undefined },\n  lessonId: string\n) {\n  \"use cache\"\n  cacheTag(getLessonIdTag(lessonId))\n  if (user.userId == null) return false\n\n  cacheTag(getUserCourseAccessUserTag(user.userId))\n\n  const [courseAccess] = await db\n    .select({ courseId: CourseTable.id })\n    .from(UserCourseAccessTable)\n    .innerJoin(CourseTable, eq(CourseTable.id, UserCourseAccessTable.courseId))\n    .innerJoin(\n      CourseSectionTable,\n      and(\n        eq(CourseSectionTable.courseId, CourseTable.id),\n        wherePublicCourseSections\n      )\n    )\n    .innerJoin(\n      LessonTable,\n      and(eq(LessonTable.sectionId, CourseSectionTable.id), wherePublicLessons)\n    )\n    .where(\n      and(\n        eq(LessonTable.id, lessonId),\n        eq(UserCourseAccessTable.userId, user.userId)\n      )\n    )\n    .limit(1)\n\n  return courseAccess != null\n}\n"
  },
  {
    "path": "src/features/lessons/schemas/lessons.ts",
    "content": "import { lessonStatusEnum } from \"@/drizzle/schema\"\nimport { z } from \"zod\"\n\nexport const lessonSchema = z.object({\n  name: z.string().min(1, \"Required\"),\n  sectionId: z.string().min(1, \"Required\"),\n  status: z.enum(lessonStatusEnum.enumValues),\n  youtubeVideoId: z.string().min(1, \"Required\"),\n  description: z\n    .string()\n    .transform(v => (v === \"\" ? null : v))\n    .nullable(),\n})\n"
  },
  {
    "path": "src/features/products/actions/products.ts",
    "content": "\"use server\"\n\nimport { z } from \"zod\"\nimport {\n  insertProduct,\n  updateProduct as updateProductDb,\n  deleteProduct as deleteProductDb,\n} from \"@/features/products/db/products\"\nimport { redirect } from \"next/navigation\"\nimport {\n  canCreateProducts,\n  canDeleteProducts,\n  canUpdateProducts,\n} from \"../permissions/products\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { productSchema } from \"../schema/products\"\n\nexport async function createProduct(unsafeData: z.infer<typeof productSchema>) {\n  const { success, data } = productSchema.safeParse(unsafeData)\n\n  if (!success || !canCreateProducts(await getCurrentUser())) {\n    return { error: true, message: \"There was an error creating your product\" }\n  }\n\n  await insertProduct(data)\n\n  redirect(\"/admin/products\")\n}\n\nexport async function updateProduct(\n  id: string,\n  unsafeData: z.infer<typeof productSchema>\n) {\n  const { success, data } = productSchema.safeParse(unsafeData)\n\n  if (!success || !canUpdateProducts(await getCurrentUser())) {\n    return { error: true, message: \"There was an error updating your product\" }\n  }\n\n  await updateProductDb(id, data)\n\n  redirect(\"/admin/products\")\n}\n\nexport async function deleteProduct(id: string) {\n  if (!canDeleteProducts(await getCurrentUser())) {\n    return { error: true, message: \"Error deleting your product\" }\n  }\n\n  await deleteProductDb(id)\n\n  return { error: false, message: \"Successfully deleted your product\" }\n}\n"
  },
  {
    "path": "src/features/products/components/ProductCard.tsx",
    "content": "import { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport { formatPrice } from \"@/lib/formatters\"\nimport { getUserCoupon } from \"@/lib/userCountryHeader\"\nimport Image from \"next/image\"\nimport Link from \"next/link\"\nimport { Suspense } from \"react\"\n\nexport function ProductCard({\n  id,\n  imageUrl,\n  name,\n  priceInDollars,\n  description,\n}: {\n  id: string\n  imageUrl: string\n  name: string\n  priceInDollars: number\n  description: string\n}) {\n  return (\n    <Card className=\"overflow-hidden flex flex-col w-full max-w-[500px] mx-auto\">\n      <div className=\"relative aspect-video w-full\">\n        <Image src={imageUrl} alt={name} fill className=\"object-cover\" />\n      </div>\n      <CardHeader className=\"space-y-0\">\n        <CardDescription>\n          <Suspense fallback={formatPrice(priceInDollars)}>\n            <Price price={priceInDollars} />\n          </Suspense>\n        </CardDescription>\n        <CardTitle className=\"text-xl\">{name}</CardTitle>\n      </CardHeader>\n      <CardContent>\n        <p className=\"line-clamp-3\">{description}</p>\n      </CardContent>\n      <CardFooter className=\"mt-auto\">\n        <Button className=\"w-full text-md py-y\" asChild>\n          <Link href={`/products/${id}`}>View Course</Link>\n        </Button>\n      </CardFooter>\n    </Card>\n  )\n}\n\nasync function Price({ price }: { price: number }) {\n  const coupon = await getUserCoupon()\n  if (price === 0 || coupon == null) {\n    return formatPrice(price)\n  }\n\n  return (\n    <div className=\"flex gap-2 items-baseline\">\n      <div className=\"line-through text-xs opacity-50\">\n        {formatPrice(price)}\n      </div>\n      <div>{formatPrice(price * (1 - coupon.discountPercentage))}</div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/features/products/components/ProductForm.tsx",
    "content": "\"use client\"\n\nimport { useForm } from \"react-hook-form\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { z } from \"zod\"\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@/components/ui/form\"\nimport { RequiredLabelIcon } from \"@/components/RequiredLabelIcon\"\nimport { Input } from \"@/components/ui/input\"\nimport { Textarea } from \"@/components/ui/textarea\"\nimport { Button } from \"@/components/ui/button\"\nimport { actionToast } from \"@/hooks/use-toast\"\nimport { productSchema } from \"../schema/products\"\nimport { ProductStatus, productStatuses } from \"@/drizzle/schema\"\nimport { createProduct, updateProduct } from \"../actions/products\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\nimport { MultiSelect } from \"@/components/ui/custom/multi-select\"\n\nexport function ProductForm({\n  product,\n  courses,\n}: {\n  product?: {\n    id: string\n    name: string\n    description: string\n    priceInDollars: number\n    imageUrl: string\n    status: ProductStatus\n    courseIds: string[]\n  }\n  courses: {\n    id: string\n    name: string\n  }[]\n}) {\n  const form = useForm<z.infer<typeof productSchema>>({\n    resolver: zodResolver(productSchema),\n    defaultValues: product ?? {\n      name: \"\",\n      description: \"\",\n      courseIds: [],\n      imageUrl: \"\",\n      priceInDollars: 0,\n      status: \"private\",\n    },\n  })\n\n  async function onSubmit(values: z.infer<typeof productSchema>) {\n    const action =\n      product == null ? createProduct : updateProduct.bind(null, product.id)\n    const data = await action(values)\n    actionToast({ actionData: data })\n  }\n\n  return (\n    <Form {...form}>\n      <form\n        onSubmit={form.handleSubmit(onSubmit)}\n        className=\"flex gap-6 flex-col\"\n      >\n        <div className=\"grid gap-6 grid-cols-1 md:grid-cols-2 items-start\">\n          <FormField\n            control={form.control}\n            name=\"name\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  <RequiredLabelIcon />\n                  Name\n                </FormLabel>\n                <FormControl>\n                  <Input {...field} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"priceInDollars\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  <RequiredLabelIcon />\n                  Price\n                </FormLabel>\n                <FormControl>\n                  <Input\n                    type=\"number\"\n                    {...field}\n                    step={1}\n                    min={0}\n                    onChange={e =>\n                      field.onChange(\n                        isNaN(e.target.valueAsNumber)\n                          ? \"\"\n                          : e.target.valueAsNumber\n                      )\n                    }\n                  />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"imageUrl\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>\n                  <RequiredLabelIcon />\n                  Image Url\n                </FormLabel>\n                <FormControl>\n                  <Input {...field} />\n                </FormControl>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n          <FormField\n            control={form.control}\n            name=\"status\"\n            render={({ field }) => (\n              <FormItem>\n                <FormLabel>Status</FormLabel>\n                <Select\n                  onValueChange={field.onChange}\n                  defaultValue={field.value}\n                >\n                  <FormControl>\n                    <SelectTrigger>\n                      <SelectValue />\n                    </SelectTrigger>\n                  </FormControl>\n                  <SelectContent>\n                    {productStatuses.map(status => (\n                      <SelectItem key={status} value={status}>\n                        {status}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n                <FormMessage />\n              </FormItem>\n            )}\n          />\n        </div>\n        <FormField\n          control={form.control}\n          name=\"courseIds\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>Included Courses</FormLabel>\n              <FormControl>\n                <MultiSelect\n                  selectPlaceholder=\"Select courses\"\n                  searchPlaceholder=\"Search courses\"\n                  options={courses}\n                  getLabel={c => c.name}\n                  getValue={c => c.id}\n                  selectedValues={field.value}\n                  onSelectedValuesChange={field.onChange}\n                />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"description\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>\n                <RequiredLabelIcon />\n                Description\n              </FormLabel>\n              <FormControl>\n                <Textarea className=\"min-h-20 resize-none\" {...field} />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <div className=\"self-end\">\n          <Button disabled={form.formState.isSubmitting} type=\"submit\">\n            Save\n          </Button>\n        </div>\n      </form>\n    </Form>\n  )\n}\n"
  },
  {
    "path": "src/features/products/components/ProductTable.tsx",
    "content": "import { ActionButton } from \"@/components/ActionButton\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\"\nimport { ProductStatus } from \"@/drizzle/schema\"\nimport { formatPlural, formatPrice } from \"@/lib/formatters\"\nimport { EyeIcon, LockIcon, Trash2Icon } from \"lucide-react\"\nimport Image from \"next/image\"\nimport Link from \"next/link\"\nimport { deleteProduct } from \"../actions/products\"\n\nexport function ProductTable({\n  products,\n}: {\n  products: {\n    id: string\n    name: string\n    description: string\n    imageUrl: string\n    priceInDollars: number\n    status: ProductStatus\n    coursesCount: number\n    customersCount: number\n  }[]\n}) {\n  return (\n    <Table>\n      <TableHeader>\n        <TableRow>\n          <TableHead>\n            {formatPlural(products.length, {\n              singular: \"product\",\n              plural: \"products\",\n            })}\n          </TableHead>\n          <TableHead>Customers</TableHead>\n          <TableHead>Status</TableHead>\n          <TableHead>Actions</TableHead>\n        </TableRow>\n      </TableHeader>\n      <TableBody>\n        {products.map(product => (\n          <TableRow key={product.id}>\n            <TableCell>\n              <div className=\"flex items-center gap-4\">\n                <Image\n                  className=\"object-cover rounded size-12\"\n                  src={product.imageUrl}\n                  alt={product.name}\n                  width={192}\n                  height={192}\n                />\n                <div className=\"flex flex-col gap-1\">\n                  <div className=\"font-semibold\">{product.name}</div>\n                  <div className=\"text-muted-foreground\">\n                    {formatPlural(product.coursesCount, {\n                      singular: \"course\",\n                      plural: \"courses\",\n                    })}{\" \"}\n                    • {formatPrice(product.priceInDollars)}\n                  </div>\n                </div>\n              </div>\n            </TableCell>\n            <TableCell>{product.customersCount}</TableCell>\n            <TableCell>\n              <Badge className=\"inline-flex items-center gap-2\">\n                {getStatusIcon(product.status)} {product.status}\n              </Badge>\n            </TableCell>\n            <TableCell>\n              <div className=\"flex gap-2\">\n                <Button asChild>\n                  <Link href={`/admin/products/${product.id}/edit`}>Edit</Link>\n                </Button>\n                <ActionButton\n                  variant=\"destructiveOutline\"\n                  requireAreYouSure\n                  action={deleteProduct.bind(null, product.id)}\n                >\n                  <Trash2Icon />\n                  <span className=\"sr-only\">Delete</span>\n                </ActionButton>\n              </div>\n            </TableCell>\n          </TableRow>\n        ))}\n      </TableBody>\n    </Table>\n  )\n}\n\nfunction getStatusIcon(status: ProductStatus) {\n  const Icon = {\n    public: EyeIcon,\n    private: LockIcon,\n  }[status]\n\n  return <Icon className=\"size-4\" />\n}\n"
  },
  {
    "path": "src/features/products/db/cache.ts",
    "content": "import { getGlobalTag, getIdTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport function getProductGlobalTag() {\n  return getGlobalTag(\"products\")\n}\n\nexport function getProductIdTag(id: string) {\n  return getIdTag(\"products\", id)\n}\n\nexport function revalidateProductCache(id: string) {\n  revalidateTag(getProductGlobalTag())\n  revalidateTag(getProductIdTag(id))\n}\n"
  },
  {
    "path": "src/features/products/db/products.ts",
    "content": "import { and, eq, isNull } from \"drizzle-orm\"\nimport { db } from \"@/drizzle/db\"\nimport { revalidateProductCache } from \"./cache\"\nimport {\n  CourseProductTable,\n  ProductTable,\n  PurchaseTable,\n} from \"@/drizzle/schema\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport { getPurchaseUserTag } from \"@/features/purchases/db/cache\"\n\nexport async function userOwnsProduct({\n  userId,\n  productId,\n}: {\n  userId: string\n  productId: string\n}) {\n  \"use cache\"\n  cacheTag(getPurchaseUserTag(userId))\n\n  const existingPurchase = await db.query.PurchaseTable.findFirst({\n    where: and(\n      eq(PurchaseTable.productId, productId),\n      eq(PurchaseTable.userId, userId),\n      isNull(PurchaseTable.refundedAt)\n    ),\n  })\n\n  return existingPurchase != null\n}\n\nexport async function insertProduct(\n  data: typeof ProductTable.$inferInsert & { courseIds: string[] }\n) {\n  const newProduct = await db.transaction(async trx => {\n    const [newProduct] = await trx.insert(ProductTable).values(data).returning()\n    if (newProduct == null) {\n      trx.rollback()\n      throw new Error(\"Failed to create product\")\n    }\n\n    await trx.insert(CourseProductTable).values(\n      data.courseIds.map(courseId => ({\n        productId: newProduct.id,\n        courseId,\n      }))\n    )\n\n    return newProduct\n  })\n\n  revalidateProductCache(newProduct.id)\n\n  return newProduct\n}\n\nexport async function updateProduct(\n  id: string,\n  data: Partial<typeof ProductTable.$inferInsert> & { courseIds: string[] }\n) {\n  const updatedProduct = await db.transaction(async trx => {\n    const [updatedProduct] = await trx\n      .update(ProductTable)\n      .set(data)\n      .where(eq(ProductTable.id, id))\n      .returning()\n    if (updatedProduct == null) {\n      trx.rollback()\n      throw new Error(\"Failed to create product\")\n    }\n\n    await trx\n      .delete(CourseProductTable)\n      .where(eq(CourseProductTable.productId, updatedProduct.id))\n\n    await trx.insert(CourseProductTable).values(\n      data.courseIds.map(courseId => ({\n        productId: updatedProduct.id,\n        courseId,\n      }))\n    )\n\n    return updatedProduct\n  })\n\n  revalidateProductCache(updatedProduct.id)\n\n  return updatedProduct\n}\n\nexport async function deleteProduct(id: string) {\n  const [deletedProduct] = await db\n    .delete(ProductTable)\n    .where(eq(ProductTable.id, id))\n    .returning()\n  if (deletedProduct == null) throw new Error(\"Failed to delete product\")\n\n  revalidateProductCache(deletedProduct.id)\n\n  return deletedProduct\n}\n"
  },
  {
    "path": "src/features/products/permissions/products.ts",
    "content": "import { ProductTable, UserRole } from \"@/drizzle/schema\"\nimport { eq } from \"drizzle-orm\"\n\nexport function canCreateProducts({ role }: { role: UserRole | undefined }) {\n  return role === \"admin\"\n}\n\nexport function canUpdateProducts({ role }: { role: UserRole | undefined }) {\n  return role === \"admin\"\n}\n\nexport function canDeleteProducts({ role }: { role: UserRole | undefined }) {\n  return role === \"admin\"\n}\n\nexport const wherePublicProducts = eq(ProductTable.status, \"public\")\n"
  },
  {
    "path": "src/features/products/schema/products.ts",
    "content": "import { productStatuses } from \"@/drizzle/schema\"\nimport { z } from \"zod\"\n\nexport const productSchema = z.object({\n  name: z.string().min(1, \"Required\"),\n  priceInDollars: z.number().int().nonnegative(),\n  description: z.string().min(1, \"Required\"),\n  imageUrl: z.union([\n    z.string().url(\"Invalid url\"),\n    z.string().startsWith(\"/\", \"Invalid url\"),\n  ]),\n  status: z.enum(productStatuses),\n  courseIds: z.array(z.string()).min(1, \"At least one course is required\"),\n})\n"
  },
  {
    "path": "src/features/purchases/actions/purchases.ts",
    "content": "\"use server\"\n\nimport { stripeServerClient } from \"@/services/stripe/stripeServer\"\nimport { canRefundPurchases } from \"../permissions/products\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { db } from \"@/drizzle/db\"\nimport { updatePurchase } from \"../db/purchases\"\nimport { revokeUserCourseAccess } from \"@/features/courses/db/userCourseAcccess\"\n\nexport async function refundPurchase(id: string) {\n  if (!canRefundPurchases(await getCurrentUser())) {\n    return {\n      error: true,\n      message: \"There was an error refunding this purchase\",\n    }\n  }\n\n  const data = await db.transaction(async trx => {\n    const refundedPurchase = await updatePurchase(\n      id,\n      { refundedAt: new Date() },\n      trx\n    )\n\n    const session = await stripeServerClient.checkout.sessions.retrieve(\n      refundedPurchase.stripeSessionId\n    )\n\n    if (session.payment_intent == null) {\n      trx.rollback()\n      return {\n        error: true,\n        message: \"There was an error refunding this purchase\",\n      }\n    }\n\n    try {\n      await stripeServerClient.refunds.create({\n        payment_intent:\n          typeof session.payment_intent === \"string\"\n            ? session.payment_intent\n            : session.payment_intent.id,\n      })\n      await revokeUserCourseAccess(refundedPurchase, trx)\n    } catch {\n      trx.rollback()\n      return {\n        error: true,\n        message: \"There was an error refunding this purchase\",\n      }\n    }\n  })\n\n  return data ?? { error: false, message: \"Successfully refunded purchase\" }\n}\n"
  },
  {
    "path": "src/features/purchases/components/PurchaseTable.tsx",
    "content": "import { ActionButton } from \"@/components/ActionButton\"\nimport {\n  SkeletonArray,\n  SkeletonButton,\n  SkeletonText,\n} from \"@/components/Skeleton\"\nimport { Badge } from \"@/components/ui/badge\"\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\"\nimport { formatDate, formatPlural, formatPrice } from \"@/lib/formatters\"\nimport Image from \"next/image\"\nimport { refundPurchase } from \"../actions/purchases\"\n\nexport function PurchaseTable({\n  purchases,\n}: {\n  purchases: {\n    id: string\n    pricePaidInCents: number\n    createdAt: Date\n    refundedAt: Date | null\n    productDetails: {\n      name: string\n      imageUrl: string\n    }\n    user: {\n      name: string\n    }\n  }[]\n}) {\n  return (\n    <Table>\n      <TableHeader>\n        <TableRow>\n          <TableHead>\n            {\" \"}\n            {formatPlural(purchases.length, {\n              singular: \"sale\",\n              plural: \"sales\",\n            })}\n          </TableHead>\n          <TableHead>Customer Name</TableHead>\n          <TableHead>Amount</TableHead>\n          <TableHead>Actions</TableHead>\n        </TableRow>\n      </TableHeader>\n      <TableBody>\n        {purchases.map(purchase => (\n          <TableRow key={purchase.id}>\n            <TableCell>\n              <div className=\"flex items-center gap-4\">\n                <Image\n                  className=\"object-cover rounded size-12\"\n                  src={purchase.productDetails.imageUrl}\n                  alt={purchase.productDetails.name}\n                  width={192}\n                  height={192}\n                />\n                <div className=\"flex flex-col gap-1\">\n                  <div className=\"font-semibold\">\n                    {purchase.productDetails.name}\n                  </div>\n                  <div className=\"text-muted-foreground\">\n                    {formatDate(purchase.createdAt)}\n                  </div>\n                </div>\n              </div>\n            </TableCell>\n            <TableCell>{purchase.user.name}</TableCell>\n            <TableCell>\n              {purchase.refundedAt ? (\n                <Badge variant=\"outline\">Refunded</Badge>\n              ) : (\n                formatPrice(purchase.pricePaidInCents / 100)\n              )}\n            </TableCell>\n            <TableCell>\n              {purchase.refundedAt == null && purchase.pricePaidInCents > 0 && (\n                <ActionButton\n                  action={refundPurchase.bind(null, purchase.id)}\n                  variant=\"destructiveOutline\"\n                  requireAreYouSure\n                >\n                  Refund\n                </ActionButton>\n              )}\n            </TableCell>\n          </TableRow>\n        ))}\n      </TableBody>\n    </Table>\n  )\n}\n\nexport function UserPurchaseTableSkeleton() {\n  return (\n    <Table>\n      <TableHeader>\n        <TableRow>\n          <TableHead>Product</TableHead>\n          <TableHead>Amount</TableHead>\n          <TableHead>Actions</TableHead>\n        </TableRow>\n      </TableHeader>\n      <TableBody>\n        <SkeletonArray amount={3}>\n          <TableRow>\n            <TableCell>\n              <div className=\"flex items-center gap-4\">\n                <div className=\"size-12 bg-secondary animate-pulse rounded\" />\n                <div className=\"flex flex-col gap-1\">\n                  <SkeletonText className=\"w-36\" />\n                  <SkeletonText className=\"w-3/4\" />\n                </div>\n              </div>\n            </TableCell>\n            <TableCell>\n              <SkeletonText className=\"w-12\" />\n            </TableCell>\n            <TableCell>\n              <SkeletonButton />\n            </TableCell>\n          </TableRow>\n        </SkeletonArray>\n      </TableBody>\n    </Table>\n  )\n}\n"
  },
  {
    "path": "src/features/purchases/components/UserPurchaseTable.tsx",
    "content": "import {\n  SkeletonArray,\n  SkeletonButton,\n  SkeletonText,\n} from \"@/components/Skeleton\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\"\nimport { formatDate, formatPrice } from \"@/lib/formatters\"\nimport Image from \"next/image\"\nimport Link from \"next/link\"\n\nexport function UserPurchaseTable({\n  purchases,\n}: {\n  purchases: {\n    id: string\n    pricePaidInCents: number\n    createdAt: Date\n    refundedAt: Date | null\n    productDetails: {\n      name: string\n      imageUrl: string\n    }\n  }[]\n}) {\n  return (\n    <Table>\n      <TableHeader>\n        <TableRow>\n          <TableHead>Product</TableHead>\n          <TableHead>Amount</TableHead>\n          <TableHead>Actions</TableHead>\n        </TableRow>\n      </TableHeader>\n      <TableBody>\n        {purchases.map(purchase => (\n          <TableRow key={purchase.id}>\n            <TableCell>\n              <div className=\"flex items-center gap-4\">\n                <Image\n                  className=\"object-cover rounded size-12\"\n                  src={purchase.productDetails.imageUrl}\n                  alt={purchase.productDetails.name}\n                  width={192}\n                  height={192}\n                />\n                <div className=\"flex flex-col gap-1\">\n                  <div className=\"font-semibold\">\n                    {purchase.productDetails.name}\n                  </div>\n                  <div className=\"text-muted-foreground\">\n                    {formatDate(purchase.createdAt)}\n                  </div>\n                </div>\n              </div>\n            </TableCell>\n            <TableCell>\n              {purchase.refundedAt ? (\n                <Badge variant=\"outline\">Refunded</Badge>\n              ) : (\n                formatPrice(purchase.pricePaidInCents / 100)\n              )}\n            </TableCell>\n            <TableCell>\n              <Button variant=\"outline\" asChild>\n                <Link href={`/purchases/${purchase.id}`}>Details</Link>\n              </Button>\n            </TableCell>\n          </TableRow>\n        ))}\n      </TableBody>\n    </Table>\n  )\n}\n\nexport function UserPurchaseTableSkeleton() {\n  return (\n    <Table>\n      <TableHeader>\n        <TableRow>\n          <TableHead>Product</TableHead>\n          <TableHead>Amount</TableHead>\n          <TableHead>Actions</TableHead>\n        </TableRow>\n      </TableHeader>\n      <TableBody>\n        <SkeletonArray amount={3}>\n          <TableRow>\n            <TableCell>\n              <div className=\"flex items-center gap-4\">\n                <div className=\"size-12 bg-secondary animate-pulse rounded\" />\n                <div className=\"flex flex-col gap-1\">\n                  <SkeletonText className=\"w-36\" />\n                  <SkeletonText className=\"w-3/4\" />\n                </div>\n              </div>\n            </TableCell>\n            <TableCell>\n              <SkeletonText className=\"w-12\" />\n            </TableCell>\n            <TableCell>\n              <SkeletonButton />\n            </TableCell>\n          </TableRow>\n        </SkeletonArray>\n      </TableBody>\n    </Table>\n  )\n}\n"
  },
  {
    "path": "src/features/purchases/db/cache.ts",
    "content": "import { getGlobalTag, getIdTag, getUserTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport function getPurchaseGlobalTag() {\n  return getGlobalTag(\"purchases\")\n}\n\nexport function getPurchaseIdTag(id: string) {\n  return getIdTag(\"purchases\", id)\n}\n\nexport function getPurchaseUserTag(userId: string) {\n  return getUserTag(\"purchases\", userId)\n}\n\nexport function revalidatePurchaseCache({\n  id,\n  userId,\n}: {\n  id: string\n  userId: string\n}) {\n  revalidateTag(getPurchaseGlobalTag())\n  revalidateTag(getPurchaseIdTag(id))\n  revalidateTag(getPurchaseUserTag(userId))\n}\n"
  },
  {
    "path": "src/features/purchases/db/purchases.ts",
    "content": "import { db } from \"@/drizzle/db\"\nimport { PurchaseTable } from \"@/drizzle/schema\"\nimport { revalidatePurchaseCache } from \"./cache\"\nimport { eq } from \"drizzle-orm\"\n\nexport async function insertPurchase(\n  data: typeof PurchaseTable.$inferInsert,\n  trx: Omit<typeof db, \"$client\"> = db\n) {\n  const details = data.productDetails\n\n  const [newPurchase] = await trx\n    .insert(PurchaseTable)\n    .values({\n      ...data,\n      productDetails: {\n        name: details.name,\n        description: details.description,\n        imageUrl: details.imageUrl,\n      },\n    })\n    .onConflictDoNothing()\n    .returning()\n\n  if (newPurchase != null) revalidatePurchaseCache(newPurchase)\n\n  return newPurchase\n}\n\nexport async function updatePurchase(\n  id: string,\n  data: Partial<typeof PurchaseTable.$inferInsert>,\n  trx: Omit<typeof db, \"$client\"> = db\n) {\n  const details = data.productDetails\n\n  const [updatedPurchase] = await trx\n    .update(PurchaseTable)\n    .set({\n      ...data,\n      productDetails: details\n        ? {\n            name: details.name,\n            description: details.description,\n            imageUrl: details.imageUrl,\n          }\n        : undefined,\n    })\n    .where(eq(PurchaseTable.id, id))\n    .returning()\n  if (updatedPurchase == null) throw new Error(\"Failed to update purchase\")\n\n  revalidatePurchaseCache(updatedPurchase)\n\n  return updatedPurchase\n}\n"
  },
  {
    "path": "src/features/purchases/permissions/products.ts",
    "content": "import { UserRole } from \"@/drizzle/schema\"\n\nexport function canRefundPurchases({ role }: { role: UserRole | undefined }) {\n  return role === \"admin\"\n}\n"
  },
  {
    "path": "src/features/users/db/cache.ts",
    "content": "import { getGlobalTag, getIdTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport function getUserGlobalTag() {\n  return getGlobalTag(\"users\")\n}\n\nexport function getUserIdTag(id: string) {\n  return getIdTag(\"users\", id)\n}\n\nexport function revalidateUserCache(id: string) {\n  revalidateTag(getUserGlobalTag())\n  revalidateTag(getUserIdTag(id))\n}\n"
  },
  {
    "path": "src/features/users/db/users.ts",
    "content": "import { db } from \"@/drizzle/db\"\nimport { UserTable } from \"@/drizzle/schema\"\nimport { eq } from \"drizzle-orm\"\nimport { revalidateUserCache } from \"./cache\"\n\nexport async function insertUser(data: typeof UserTable.$inferInsert) {\n  const [newUser] = await db\n    .insert(UserTable)\n    .values(data)\n    .returning()\n    .onConflictDoUpdate({\n      target: [UserTable.clerkUserId],\n      set: data,\n    })\n\n  if (newUser == null) throw new Error(\"Failed to create user\")\n  revalidateUserCache(newUser.id)\n\n  return newUser\n}\n\nexport async function updateUser(\n  { clerkUserId }: { clerkUserId: string },\n  data: Partial<typeof UserTable.$inferInsert>\n) {\n  const [updatedUser] = await db\n    .update(UserTable)\n    .set(data)\n    .where(eq(UserTable.clerkUserId, clerkUserId))\n    .returning()\n\n  if (updatedUser == null) throw new Error(\"Failed to update user\")\n  revalidateUserCache(updatedUser.id)\n\n  return updatedUser\n}\n\nexport async function deleteUser({ clerkUserId }: { clerkUserId: string }) {\n  const [deletedUser] = await db\n    .update(UserTable)\n    .set({\n      deletedAt: new Date(),\n      email: \"redacted@deleted.com\",\n      name: \"Deleted User\",\n      clerkUserId: \"deleted\",\n      imageUrl: null,\n    })\n    .where(eq(UserTable.clerkUserId, clerkUserId))\n    .returning()\n\n  if (deletedUser == null) throw new Error(\"Failed to delete user\")\n  revalidateUserCache(deletedUser.id)\n\n  return deletedUser\n}\n"
  },
  {
    "path": "src/hooks/use-toast.ts",
    "content": "\"use client\"\n\n// Inspired by react-hot-toast library\nimport * as React from \"react\"\n\nimport type { ToastActionElement, ToastProps } from \"@/components/ui/toast\"\n\nconst TOAST_LIMIT = 1\nconst TOAST_REMOVE_DELAY = 1000000\n\ntype ToasterToast = ToastProps & {\n  id: string\n  title?: React.ReactNode\n  description?: React.ReactNode\n  action?: ToastActionElement\n}\n\nlet count = 0\n\nfunction genId() {\n  count = (count + 1) % Number.MAX_SAFE_INTEGER\n  return count.toString()\n}\n\ntype ActionType = {\n  ADD_TOAST: \"ADD_TOAST\"\n  UPDATE_TOAST: \"UPDATE_TOAST\"\n  DISMISS_TOAST: \"DISMISS_TOAST\"\n  REMOVE_TOAST: \"REMOVE_TOAST\"\n}\n\ntype Action =\n  | {\n      type: ActionType[\"ADD_TOAST\"]\n      toast: ToasterToast\n    }\n  | {\n      type: ActionType[\"UPDATE_TOAST\"]\n      toast: Partial<ToasterToast>\n    }\n  | {\n      type: ActionType[\"DISMISS_TOAST\"]\n      toastId?: ToasterToast[\"id\"]\n    }\n  | {\n      type: ActionType[\"REMOVE_TOAST\"]\n      toastId?: ToasterToast[\"id\"]\n    }\n\ninterface State {\n  toasts: ToasterToast[]\n}\n\nconst toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()\n\nconst addToRemoveQueue = (toastId: string) => {\n  if (toastTimeouts.has(toastId)) {\n    return\n  }\n\n  const timeout = setTimeout(() => {\n    toastTimeouts.delete(toastId)\n    dispatch({\n      type: \"REMOVE_TOAST\",\n      toastId: toastId,\n    })\n  }, TOAST_REMOVE_DELAY)\n\n  toastTimeouts.set(toastId, timeout)\n}\n\nexport const reducer = (state: State, action: Action): State => {\n  switch (action.type) {\n    case \"ADD_TOAST\":\n      return {\n        ...state,\n        toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),\n      }\n\n    case \"UPDATE_TOAST\":\n      return {\n        ...state,\n        toasts: state.toasts.map(t =>\n          t.id === action.toast.id ? { ...t, ...action.toast } : t\n        ),\n      }\n\n    case \"DISMISS_TOAST\": {\n      const { toastId } = action\n\n      // ! Side effects ! - This could be extracted into a dismissToast() action,\n      // but I'll keep it here for simplicity\n      if (toastId) {\n        addToRemoveQueue(toastId)\n      } else {\n        state.toasts.forEach(toast => {\n          addToRemoveQueue(toast.id)\n        })\n      }\n\n      return {\n        ...state,\n        toasts: state.toasts.map(t =>\n          t.id === toastId || toastId === undefined\n            ? {\n                ...t,\n                open: false,\n              }\n            : t\n        ),\n      }\n    }\n    case \"REMOVE_TOAST\":\n      if (action.toastId === undefined) {\n        return {\n          ...state,\n          toasts: [],\n        }\n      }\n      return {\n        ...state,\n        toasts: state.toasts.filter(t => t.id !== action.toastId),\n      }\n  }\n}\n\nconst listeners: Array<(state: State) => void> = []\n\nlet memoryState: State = { toasts: [] }\n\nfunction dispatch(action: Action) {\n  memoryState = reducer(memoryState, action)\n  listeners.forEach(listener => {\n    listener(memoryState)\n  })\n}\n\ntype Toast = Omit<ToasterToast, \"id\">\n\nfunction toast({ ...props }: Toast) {\n  const id = genId()\n\n  const update = (props: ToasterToast) =>\n    dispatch({\n      type: \"UPDATE_TOAST\",\n      toast: { ...props, id },\n    })\n  const dismiss = () => dispatch({ type: \"DISMISS_TOAST\", toastId: id })\n\n  dispatch({\n    type: \"ADD_TOAST\",\n    toast: {\n      ...props,\n      id,\n      open: true,\n      onOpenChange: open => {\n        if (!open) dismiss()\n      },\n    },\n  })\n\n  return {\n    id: id,\n    dismiss,\n    update,\n  }\n}\n\nfunction useToast() {\n  const [state, setState] = React.useState<State>(memoryState)\n\n  React.useEffect(() => {\n    listeners.push(setState)\n    return () => {\n      const index = listeners.indexOf(setState)\n      if (index > -1) {\n        listeners.splice(index, 1)\n      }\n    }\n  }, [state])\n\n  return {\n    ...state,\n    toast,\n    dismiss: (toastId?: string) => dispatch({ type: \"DISMISS_TOAST\", toastId }),\n  }\n}\n\nfunction actionToast({\n  actionData,\n  ...props\n}: Omit<Toast, \"title\" | \"description\" | \"variant\"> & {\n  actionData: { error: boolean; message: string }\n}) {\n  return toast({\n    ...props,\n    title: actionData.error ? \"Error\" : \"Success\",\n    description: actionData.message,\n    variant: actionData.error ? \"destructive\" : \"default\",\n  })\n}\n\nexport { useToast, toast, actionToast }\n"
  },
  {
    "path": "src/lib/dataCache.ts",
    "content": "type CACHE_TAG =\n  | \"products\"\n  | \"users\"\n  | \"courses\"\n  | \"userCourseAccess\"\n  | \"courseSections\"\n  | \"lessons\"\n  | \"purchases\"\n  | \"userLessonComplete\"\n\nexport function getGlobalTag(tag: CACHE_TAG) {\n  return `global:${tag}` as const\n}\n\nexport function getIdTag(tag: CACHE_TAG, id: string) {\n  return `id:${id}-${tag}` as const\n}\n\nexport function getUserTag(tag: CACHE_TAG, userId: string) {\n  return `user:${userId}-${tag}` as const\n}\n\nexport function getCourseTag(tag: CACHE_TAG, courseId: string) {\n  return `course:${courseId}-${tag}` as const\n}\n"
  },
  {
    "path": "src/lib/formatters.ts",
    "content": "export function formatPlural(\n  count: number,\n  { singular, plural }: { singular: string; plural: string },\n  { includeCount = true } = {}\n) {\n  const word = count === 1 ? singular : plural\n\n  return includeCount ? `${count} ${word}` : word\n}\n\nexport function formatPrice(amount: number, { showZeroAsNumber = false } = {}) {\n  const formatter = new Intl.NumberFormat(undefined, {\n    style: \"currency\",\n    currency: \"USD\",\n    minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,\n  })\n\n  if (amount === 0 && !showZeroAsNumber) return \"Free\"\n  return formatter.format(amount)\n}\n\nconst DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {\n  dateStyle: \"medium\",\n  timeStyle: \"short\",\n})\n\nexport function formatDate(date: Date) {\n  return DATE_FORMATTER.format(date)\n}\n\nexport function formatNumber(\n  number: number,\n  options?: Intl.NumberFormatOptions\n) {\n  const formatter = new Intl.NumberFormat(undefined, options)\n  return formatter.format(number)\n}\n"
  },
  {
    "path": "src/lib/sumArray.ts",
    "content": "export function sumArray<T>(array: T[], func: (item: T) => number) {\n  return array.reduce((acc, item) => acc + func(item), 0)\n}\n"
  },
  {
    "path": "src/lib/userCountryHeader.ts",
    "content": "import { pppCoupons } from \"@/data/pppCoupons\"\nimport { headers } from \"next/headers\"\n\nconst COUNTRY_HEADER_KEY = \"x-user-country\"\n\nexport function setUserCountryHeader(\n  headers: Headers,\n  country: string | undefined\n) {\n  if (country == null) {\n    headers.delete(COUNTRY_HEADER_KEY)\n  } else {\n    headers.set(COUNTRY_HEADER_KEY, country)\n  }\n}\n\nasync function getUserCountry() {\n  const head = await headers()\n  return head.get(COUNTRY_HEADER_KEY)\n}\n\nexport async function getUserCoupon() {\n  const country = await getUserCountry()\n  if (country == null) return\n\n  const coupon = pppCoupons.find(coupon =>\n    coupon.countryCodes.includes(country)\n  )\n\n  if (coupon == null) return\n\n  return {\n    stripeCouponId: coupon.stripeCouponId,\n    discountPercentage: coupon.discountPercentage,\n  }\n}\n"
  },
  {
    "path": "src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"
  },
  {
    "path": "src/middleware.ts",
    "content": "import { clerkMiddleware, createRouteMatcher } from \"@clerk/nextjs/server\"\nimport arcjet, { detectBot, shield, slidingWindow } from \"@arcjet/next\"\nimport { env } from \"./data/env/server\"\nimport { setUserCountryHeader } from \"./lib/userCountryHeader\"\nimport { NextResponse } from \"next/server\"\n\nconst isPublicRoute = createRouteMatcher([\n  \"/\",\n  \"/sign-in(.*)\",\n  \"/sign-up(.*)\",\n  \"/api(.*)\",\n  \"/courses/:courseId/lessons/:lessonId\",\n  \"/products(.*)\",\n])\n\nconst isAdminRoute = createRouteMatcher([\"/admin(.*)\"])\n\nconst aj = arcjet({\n  key: env.ARCJET_KEY,\n  rules: [\n    shield({ mode: \"LIVE\" }),\n    detectBot({\n      mode: \"LIVE\",\n      allow: [\"CATEGORY:SEARCH_ENGINE\", \"CATEGORY:MONITOR\", \"CATEGORY:PREVIEW\"],\n    }),\n    slidingWindow({\n      mode: \"LIVE\",\n      interval: \"1m\",\n      max: 100,\n    }),\n  ],\n})\n\nexport default clerkMiddleware(async (auth, req) => {\n  const decision = await aj.protect(\n    env.TEST_IP_ADDRESS\n      ? { ...req, ip: env.TEST_IP_ADDRESS, headers: req.headers }\n      : req\n  )\n\n  if (decision.isDenied()) {\n    return new NextResponse(null, { status: 403 })\n  }\n\n  if (isAdminRoute(req)) {\n    const user = await auth.protect()\n    if (user.sessionClaims.role !== \"admin\") {\n      return new NextResponse(null, { status: 404 })\n    }\n  }\n\n  if (!isPublicRoute(req)) {\n    await auth.protect()\n  }\n\n  if (!decision.ip.isVpn() && !decision.ip.isProxy()) {\n    const headers = new Headers(req.headers)\n    setUserCountryHeader(headers, decision.ip.country)\n\n    return NextResponse.next({ request: { headers } })\n  }\n})\n\nexport const config = {\n  matcher: [\n    // Skip Next.js internals and all static files, unless found in search params\n    \"/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)\",\n    // Always run for API routes\n    \"/(api|trpc)(.*)\",\n  ],\n}\n"
  },
  {
    "path": "src/permissions/general.ts",
    "content": "import { UserRole } from \"@/drizzle/schema\"\n\nexport function canAccessAdminPages({ role }: { role: UserRole | undefined }) {\n  return role === \"admin\"\n}\n"
  },
  {
    "path": "src/services/clerk.ts",
    "content": "import { db } from \"@/drizzle/db\"\nimport { UserRole, UserTable } from \"@/drizzle/schema\"\nimport { getUserIdTag } from \"@/features/users/db/cache\"\nimport { auth, clerkClient } from \"@clerk/nextjs/server\"\nimport { eq } from \"drizzle-orm\"\nimport { cacheTag } from \"next/dist/server/use-cache/cache-tag\"\nimport { redirect } from \"next/navigation\"\n\nconst client = await clerkClient()\n\nexport async function getCurrentUser({ allData = false } = {}) {\n  const { userId, sessionClaims, redirectToSignIn } = await auth()\n\n  if (userId != null && sessionClaims.dbId == null) {\n    redirect(\"/api/clerk/syncUsers\")\n  }\n\n  return {\n    clerkUserId: userId,\n    userId: sessionClaims?.dbId,\n    role: sessionClaims?.role,\n    user:\n      allData && sessionClaims?.dbId != null\n        ? await getUser(sessionClaims.dbId)\n        : undefined,\n    redirectToSignIn,\n  }\n}\n\nexport function syncClerkUserMetadata(user: {\n  id: string\n  clerkUserId: string\n  role: UserRole\n}) {\n  return client.users.updateUserMetadata(user.clerkUserId, {\n    publicMetadata: {\n      dbId: user.id,\n      role: user.role,\n    },\n  })\n}\n\nasync function getUser(id: string) {\n  \"use cache\"\n  cacheTag(getUserIdTag(id))\n  console.log(\"Called\")\n\n  return db.query.UserTable.findFirst({\n    where: eq(UserTable.id, id),\n  })\n}\n"
  },
  {
    "path": "src/services/stripe/actions/stripe.ts",
    "content": "\"use server\"\n\nimport { getUserCoupon } from \"@/lib/userCountryHeader\"\nimport { stripeServerClient } from \"../stripeServer\"\nimport { env } from \"@/data/env/client\"\n\nexport async function getClientSessionSecret(\n  product: {\n    priceInDollars: number\n    name: string\n    imageUrl: string\n    description: string\n    id: string\n  },\n  user: { email: string; id: string }\n) {\n  const coupon = await getUserCoupon()\n  const discounts = coupon ? [{ coupon: coupon.stripeCouponId }] : undefined\n\n  const session = await stripeServerClient.checkout.sessions.create({\n    line_items: [\n      {\n        quantity: 1,\n        price_data: {\n          currency: \"usd\",\n          product_data: {\n            name: product.name,\n            images: [\n              new URL(product.imageUrl, env.NEXT_PUBLIC_SERVER_URL).href,\n            ],\n            description: product.description,\n          },\n          unit_amount: product.priceInDollars * 100,\n        },\n      },\n    ],\n    ui_mode: \"embedded\",\n    mode: \"payment\",\n    return_url: `${env.NEXT_PUBLIC_SERVER_URL}/api/webhooks/stripe?stripeSessionId={CHECKOUT_SESSION_ID}`,\n    customer_email: user.email,\n    payment_intent_data: {\n      receipt_email: user.email,\n    },\n    discounts,\n    metadata: {\n      productId: product.id,\n      userId: user.id,\n    },\n  })\n\n  if (session.client_secret == null) throw new Error(\"Client secret is null\")\n\n  return session.client_secret\n}\n"
  },
  {
    "path": "src/services/stripe/components/StripeCheckoutForm.tsx",
    "content": "\"use client\"\n\nimport {\n  EmbeddedCheckoutProvider,\n  EmbeddedCheckout,\n} from \"@stripe/react-stripe-js\"\nimport { stripeClientPromise } from \"../stripeClient\"\nimport { getClientSessionSecret } from \"../actions/stripe\"\n\nexport function StripeCheckoutForm({\n  product,\n  user,\n}: {\n  product: {\n    priceInDollars: number\n    name: string\n    id: string\n    imageUrl: string\n    description: string\n  }\n  user: {\n    email: string\n    id: string\n  }\n}) {\n  return (\n    <EmbeddedCheckoutProvider\n      stripe={stripeClientPromise}\n      options={{\n        fetchClientSecret: getClientSessionSecret.bind(null, product, user),\n      }}\n    >\n      <EmbeddedCheckout />\n    </EmbeddedCheckoutProvider>\n  )\n}\n"
  },
  {
    "path": "src/services/stripe/stripeClient.ts",
    "content": "import { env } from \"@/data/env/client\"\nimport { loadStripe } from \"@stripe/stripe-js\"\n\nexport const stripeClientPromise = loadStripe(\n  env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY\n)\n"
  },
  {
    "path": "src/services/stripe/stripeServer.ts",
    "content": "import { env } from \"@/data/env/server\"\nimport Stripe from \"stripe\"\n\nexport const stripeServerClient = new Stripe(env.STRIPE_SECRET_KEY)\n"
  },
  {
    "path": "tailwind.config.ts",
    "content": "import type { Config } from \"tailwindcss\"\nimport containerQueries from \"@tailwindcss/container-queries\"\n\nexport default {\n  darkMode: [\"class\"],\n  content: [\n    \"./src/pages/**/*.{js,ts,jsx,tsx,mdx}\",\n    \"./src/components/**/*.{js,ts,jsx,tsx,mdx}\",\n    \"./src/features/**/components/**/*.{js,ts,jsx,tsx,mdx}\",\n    \"./src/app/**/*.{js,ts,jsx,tsx,mdx}\",\n  ],\n  theme: {\n  \tcontainer: {\n  \t\tcenter: true,\n  \t\tpadding: '2rem',\n  \t\tscreens: {\n  \t\t\tsm: '1500px'\n  \t\t}\n  \t},\n  \textend: {\n  \t\tcolors: {\n  \t\t\tbackground: 'hsl(var(--background))',\n  \t\t\tforeground: 'hsl(var(--foreground))',\n  \t\t\tcard: {\n  \t\t\t\tDEFAULT: 'hsl(var(--card))',\n  \t\t\t\tforeground: 'hsl(var(--card-foreground))'\n  \t\t\t},\n  \t\t\tpopover: {\n  \t\t\t\tDEFAULT: 'hsl(var(--popover))',\n  \t\t\t\tforeground: 'hsl(var(--popover-foreground))'\n  \t\t\t},\n  \t\t\tprimary: {\n  \t\t\t\tDEFAULT: 'hsl(var(--primary))',\n  \t\t\t\tforeground: 'hsl(var(--primary-foreground))'\n  \t\t\t},\n  \t\t\tsecondary: {\n  \t\t\t\tDEFAULT: 'hsl(var(--secondary))',\n  \t\t\t\tforeground: 'hsl(var(--secondary-foreground))'\n  \t\t\t},\n  \t\t\tmuted: {\n  \t\t\t\tDEFAULT: 'hsl(var(--muted))',\n  \t\t\t\tforeground: 'hsl(var(--muted-foreground))'\n  \t\t\t},\n  \t\t\taccent: {\n  \t\t\t\tDEFAULT: 'hsl(var(--accent))',\n  \t\t\t\tforeground: 'hsl(var(--accent-foreground))'\n  \t\t\t},\n  \t\t\tdestructive: {\n  \t\t\t\tDEFAULT: 'hsl(var(--destructive))',\n  \t\t\t\tforeground: 'hsl(var(--destructive-foreground))'\n  \t\t\t},\n  \t\t\tborder: 'hsl(var(--border))',\n  \t\t\tinput: 'hsl(var(--input))',\n  \t\t\tring: 'hsl(var(--ring))',\n  \t\t\tchart: {\n  \t\t\t\t'1': 'hsl(var(--chart-1))',\n  \t\t\t\t'2': 'hsl(var(--chart-2))',\n  \t\t\t\t'3': 'hsl(var(--chart-3))',\n  \t\t\t\t'4': 'hsl(var(--chart-4))',\n  \t\t\t\t'5': 'hsl(var(--chart-5))'\n  \t\t\t}\n  \t\t},\n  \t\tborderRadius: {\n  \t\t\tlg: 'var(--radius)',\n  \t\t\tmd: 'calc(var(--radius) - 2px)',\n  \t\t\tsm: 'calc(var(--radius) - 4px)'\n  \t\t},\n  \t\tkeyframes: {\n  \t\t\t'accordion-down': {\n  \t\t\t\tfrom: {\n  \t\t\t\t\theight: '0'\n  \t\t\t\t},\n  \t\t\t\tto: {\n  \t\t\t\t\theight: 'var(--radix-accordion-content-height)'\n  \t\t\t\t}\n  \t\t\t},\n  \t\t\t'accordion-up': {\n  \t\t\t\tfrom: {\n  \t\t\t\t\theight: 'var(--radix-accordion-content-height)'\n  \t\t\t\t},\n  \t\t\t\tto: {\n  \t\t\t\t\theight: '0'\n  \t\t\t\t}\n  \t\t\t}\n  \t\t},\n  \t\tanimation: {\n  \t\t\t'accordion-down': 'accordion-down 0.2s ease-out',\n  \t\t\t'accordion-up': 'accordion-up 0.2s ease-out'\n  \t\t}\n  \t}\n  },\n  plugins: [require(\"tailwindcss-animate\"), containerQueries],\n} satisfies Config\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"noUncheckedIndexedAccess\": true,\n    \"target\": \"ES2017\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"preserve\",\n    \"incremental\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\", \".next/types/**/*.ts\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  }
]